Pointing OPENAI_BASE_URL at a local llama-server or Ollama sends every gpt-*
call there instead of to OpenAI, and litellm reports that as a bare
"Connection Error" — which reads like a network problem even though the host
can reach api.openai.com fine. Deploying a .env that still carries the local
endpoint reproduces it on the server, with nothing in the message naming the
culprit.
Add OPENAI_BASE_URL and OPENAI_API_BASE to the settings allowlist so the
endpoint can be corrected without editing .env and recreating the container.
Both are listed because litellm reads OPENAI_API_BASE while the OpenAI SDK
reads OPENAI_BASE_URL, so fixing one can leave the other in effect. Neither is
a secret, so the panel shows the current value.
No cache invalidation is needed alongside this: litellm keys its client cache
on the endpoint, so a changed value takes effect on the next call.
Document the trap where it will be read — a commented template in .env.example
and a note in the Lightsail deploy guide about carrying a local .env over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vectors from two different embedding models are not comparable — Titan v2 has
1024 dimensions where nomic-embed-text has 768 — and _cosine_similarity zips
them, so a mismatch produced quietly meaningless scores rather than an error.
Nothing forced a re-embed either: sync_local_kb only looked at the content
hash, so switching models left every cached vector in place.
local_knowledge_docs now records the model each vector came from, and a row is
re-embedded when its content hash OR its model differs. Saving a new
LOCAL_KB_EMBEDDING_MODEL from the settings panel starts a background pass over
every file; the knowledge base panel also has a button for it. While that pass
runs, the per-request sync steps aside so no file is embedded twice.
The panel now shows what the embedding model is doing: model name, vector
size, how many files are embedded with the current model, how many are still
on an older one, a progress bar while a pass runs, and the last error. A
"接続テスト" button embeds one short string so a broken or unreachable model
can be told apart from one that is merely slow to warm up.
Also read TAVILY_API_KEY from os.environ at call time. It was imported as a
config constant, which is evaluated before load_into_env() runs, so a key
saved from the settings panel was stored but never used — not even after a
restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summarizing web search results falls back to DEFAULT_MODEL, so a local Ollama
model ends up doing it and the call can fail — which surfaces only as
"保存できる要約なし" in the chat. Make SEARCH_SUMMARY_MODEL settable at runtime:
add it to the settings allowlist and read it from os.environ on each call
(knowledge._summary_model), the way LOCAL_KB_EMBEDDING_MODEL already works.
Importing it as a config constant would keep the old value until a restart.
Show the current value of the settings that are not secrets. A model name
rendered as ••••qwen3 gives no way to tell what is in effect, so the store now
marks those keys (NON_SECRET_KEYS) and returns them unmasked; the API field is
renamed masked -> display_value and carries a secret flag that the panel uses
to pick the input type, replacing the list it kept on its own side.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add a read-only view of backend/knowledge_base/**.md so the files that get
injected into the system prompt can be inspected from the GUI:
- GET /api/kb/files lists the Markdown files (title, preview, size, mtime and
whether the file is already embedded), GET /api/kb/file returns one in full.
Paths are resolved against the knowledge base directory and rejected unless
they stay inside it and end in .md.
- KnowledgeBasePanel.vue groups the files by folder (collapsible, so web_search
can be folded away), filters by title/path/preview and renders the selected
file with MarkdownContent.
The header had grown to four side panel toggles, so collapse them into one
menu: panels.ts now holds the list of panels along with the component each one
renders, App.vue picks it with <component :is>, and SidePanelMenu.vue shows the
active panel on its button. Re-picking the open panel (or "パネルを閉じる")
closes it; a click outside or Esc closes just the menu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add a marked extension that tokenizes math delimiters and renders them
with KaTeX: `$$ ... $$` / `\[ ... \]` as block, `$ ... $` / `\( ... \)`
and mid-paragraph `$$ ... $$` as inline.
Inline `$ ... $` requires a non-space after the opening `$`, no newline
inside, and a non-digit after the closing `$`, so prose like "$100 から
$200" is left alone; `\$` still escapes a literal dollar sign. Parse
errors fall back to KaTeX's red inline message instead of throwing, and
math whose closing delimiter has not streamed in yet stays plain text.
DOMPurify strips `semantics` and `annotation` by default, which would
leak the raw TeX out of KaTeX's MathML output, so both are allowed
explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two additions to the shared notes, both about getting content in and out of
the prompt.
Per-chat pinning. Notes normally reach the model as a title list only, and
the body arrives just if the model decides to call read_note. A 📌 on the tab
now puts a page's full body in the system prompt for the chat that is open,
every turn, no tool round trip. The selection is per session (new table
session_note_pages, unique on session_id + page_id), so different
conversations carry different pages. The title list marks pinned pages so the
model does not read them twice, and bodies are capped per page by
SESSION_NOTES_CONTENT_LIMIT.
A new chat has no session row until its first message, so the front end
cannot PUT the selection yet. POST /chat/stream therefore accepts
note_page_ids and applies it before building the context, which makes the
pins count from the very first turn.
File import. Dropping .md/.txt on the editor - or picking them with the new
⬆ button - creates pages, and a .zip is unpacked into one page per text
entry with the first path component as the section. That is exactly the
shape the export writes, so a downloaded zip round-trips back in. Titles that
collide get a " (2)" suffix rather than overwriting, since restoring a backup
into a live workspace should not destroy anything. UploadFile needs
python-multipart, added to the lockfile.
Toasts grew a kind: the import success message was rendering in the red error
toast, as was the existing session-import message. Both are green now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shared notes had no way out: everything lived in the SQLite row and the
only reader was the panel itself. Add two export endpoints and buttons for
them at the bottom of the note panel.
GET /api/notes/{page_id}/export returns one page as .md, and
GET /api/notes/export returns every page as a zip laid out as
<section>/<NN>_<title>.md. Pages are not concatenated into a single file on
purpose - the headings inside a page would collide with whatever heading
level we used as a page separator, so each page stays a verbatim file and the
numeric prefix carries the tab order instead.
Titles reach the filesystem here, so strip the Windows-forbidden characters
and control characters, cap the length, and send Content-Disposition with an
RFC 5987 filename* so Japanese titles survive. The front end deliberately
omits the anchor's download attribute to let that name win.
notes store: flush() now returns a promise so the download can wait for
pending edits to be saved before asking the server for the file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tool calls were invisible until the answer arrived: the only trace was the
sources list, and only for web searches. Surface them as they happen instead.
Add llm.stream_tool_calls(), an incremental version of resolve_tool_calls()
that yields a start and a completion event per tool call and ends with a
"resolved" event carrying the previous return value. resolve_tool_calls() is
kept as a thin wrapper that drops the progress events.
api/chat.py relays those events as a new SSE "tool" event, and wraps the
search-summary/knowledge-save step in one of the same shape, since it is a
noticeable pause of its own.
On the front end, ToolActivityList.vue renders one row per tool above the
assistant bubble (label, target, spinner while running, short result when
done). The store merges events by id and marks anything still running as
interrupted when the stream ends early. Display only - not persisted, so the
rows disappear when the session is reloaded, same as sources.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Notes can now be clustered into named sections in the tab bar: each tab
gets a 🏷 button to assign it to a section, and clicking a section's
label renames (or, given an empty name, ungroups) every page in it.
note_pages gains a `section` column. Since the app has no migration
framework, database.py adds the column to existing SQLite files at
startup via PRAGMA table_info + ALTER TABLE. core/notes.set_section()
keeps same-section pages contiguous by renumbering position whenever a
page's section changes, so the tab bar and the AI-facing title list
(now grouped under "## section" headings) can both treat "adjacent" as
"same group" without a separate ordering field.
Also fixes a bug in the tab rename input: ref="renameInput" sits inside
a nested v-for, which Vue 3 binds as an array rather than a single
element, so renameInput.value?.select() was throwing on every rename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a note_pages table backing a tabbed notes panel where the user and
the model can both read and write. Only page titles (plus a short
preview) are injected into the system prompt on every turn; full page
content is kept out of context and fetched on demand via a read_note
tool, with write_note letting the model append to, replace, or create a
page.
Generalizes llm.resolve_web_search into resolve_tool_calls so the
tool-calling loop handles web_search and the new note tools through one
path. The chat stream emits a notes_updated SSE event when the model
edits a note so the frontend panel can refresh; local edits are tracked
as dirty and never clobbered by that refresh.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chat/memory/logs/models APIs were reachable without any auth even
though the app is deployed publicly behind Caddy HTTPS; only the
settings screen had a password. Add an optional SITE_PASSWORD-backed
session cookie gate in front of everything (settings included, on
top of its existing SETTINGS_PASSWORD), with a matching login screen
on the frontend. Stays fully backward compatible: with SITE_PASSWORD
unset the app behaves exactly as before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Split backend/app/core into weather/, discord/, memory_kb/, search/
subpackages by responsibility, keeping config.py and llm.py as the
shared foundation; updated all import sites accordingly.
- Add local Markdown knowledge base support (local_knowledge.py,
knowledge_base/fgo.md, LOCAL_KB_* settings) that embeds and injects
relevant docs into the system prompt by topic similarity.
- Add ruff and pyright as backend dev dependencies with basic config
in pyproject.toml for linting and type checking.
- Document local machine quirks in CLAUDE.md (Postgres on port 8080,
SSLKEYLOGFILE breaking litellm/pyright via OPENSSL_Uplink crashes).
- Add backend/run_dev.ps1 dev launcher and docs/discord-bot.md.