From 2316e1463243ee69ead3e0c2ba363579cca58abb Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 4 Nov 2026 17:50:24 +0000 Subject: [PATCH] feat(memory): add a Memory On/Off switch and private chats Memory now has one switch, On or Off, in the Memory tab of the account settings. Off means nothing from the notes is put in the prompt, the model has no memory skill, tool or note kind, nothing is proposed or saved (also by triggers and other runs that start by themselves), and the model is told in one line that memory is off. The notes stay and can still be read, exported and deleted. A chat can be private: the Memory row of its Tools menu is the memory switch group, stored with the chat. The narrowest level wins: chat, agent, person, server. A saved agent cannot widen the choice of the person. The tools list says which level is off (memory.code, blocked_by, fix); the chat shows a chip and one line, and the agent form shows the person's switch as a read-only line. New API: GET and PUT /api/chats/memory for the switch and the save level, and GET /api/chats/memory/export for a zip of the notes. The Memory tab gains the switch, the three save levels, Export and Forget everything. Assisted-by: Claude:claude-sonnet-5-5 --- core/http/auth/features.go | 3 + .../endpoints/localai/api_instructions.go | 2 +- core/http/endpoints/studio/mcpconnectors.go | 5 +- core/http/endpoints/studio/memory.go | 86 ++++++ core/http/react-ui/e2e/memory-off.spec.js | 251 ++++++++++++++++++ .../react-ui/public/locales/de/agents.json | 4 +- .../react-ui/public/locales/de/chats.json | 56 +++- .../react-ui/public/locales/en/agents.json | 4 +- .../react-ui/public/locales/en/chats.json | 56 +++- .../react-ui/public/locales/es/agents.json | 4 +- .../react-ui/public/locales/es/chats.json | 56 +++- .../react-ui/public/locales/fr/agents.json | 4 +- .../react-ui/public/locales/fr/chats.json | 56 +++- .../react-ui/public/locales/id/agents.json | 4 +- .../react-ui/public/locales/id/chats.json | 56 +++- .../react-ui/public/locales/it/agents.json | 4 +- .../react-ui/public/locales/it/chats.json | 56 +++- .../react-ui/public/locales/ko/agents.json | 4 +- .../react-ui/public/locales/ko/chats.json | 56 +++- .../react-ui/public/locales/pt-BR/agents.json | 4 +- .../react-ui/public/locales/pt-BR/chats.json | 56 +++- .../react-ui/public/locales/zh-CN/agents.json | 4 +- .../react-ui/public/locales/zh-CN/chats.json | 56 +++- .../src/components/MemorySettings.jsx | 85 ++++++ .../react-ui/src/components/MemoryTab.jsx | 8 +- .../src/components/chat/ChatComposer.jsx | 8 +- .../src/components/chat/MemoryNotice.jsx | 38 +++ .../src/components/chat/memorynotice.css | 4 + .../src/components/memorysettings.css | 27 ++ .../react-ui/src/components/memorytab.css | 2 +- .../react-ui/src/hooks/useChatSwitches.js | 11 +- .../react-ui/src/hooks/useMemorySettings.js | 39 +++ core/http/react-ui/src/hooks/useToolGroups.js | 13 +- core/http/react-ui/src/pages/Account.jsx | 3 +- core/http/react-ui/src/pages/AgentCreate.jsx | 8 + core/http/react-ui/src/utils/api.js | 8 + core/http/react-ui/src/utils/memoryState.js | 25 ++ .../tests/unit/composer-render.test.mjs | 1 + .../react-ui/tests/unit/memorystate.test.mjs | 27 ++ core/http/routes/studio.go | 3 + core/schema/chats.go | 52 ++++ core/services/studio/groupinfo.go | 5 + core/services/studio/mcpconnectors.go | 5 +- core/services/studio/memory.go | 96 ++++++- core/services/studio/memory_internal_test.go | 177 ++++++++++++ core/services/studio/memorysettings.go | 137 ++++++++++ .../studio/providerguard/guard_test.go | 2 + core/services/studio/service.go | 3 + core/services/studio/subagent.go | 5 + core/services/studio/webtools_test.go | 2 +- docs/content/features/chats.md | 1 + docs/content/features/short-memory.md | 48 ++++ swagger/docs.go | 206 +++++++++++++- swagger/swagger.json | 206 +++++++++++++- swagger/swagger.yaml | 193 +++++++++++++- 55 files changed, 2255 insertions(+), 80 deletions(-) create mode 100644 core/http/endpoints/studio/memory.go create mode 100644 core/http/react-ui/e2e/memory-off.spec.js create mode 100644 core/http/react-ui/src/components/MemorySettings.jsx create mode 100644 core/http/react-ui/src/components/chat/MemoryNotice.jsx create mode 100644 core/http/react-ui/src/components/chat/memorynotice.css create mode 100644 core/http/react-ui/src/components/memorysettings.css create mode 100644 core/http/react-ui/src/hooks/useMemorySettings.js create mode 100644 core/http/react-ui/src/utils/memoryState.js create mode 100644 core/http/react-ui/tests/unit/memorystate.test.mjs create mode 100644 core/services/studio/memorysettings.go diff --git a/core/http/auth/features.go b/core/http/auth/features.go index 12c2e86d3b..b6b8339a21 100644 --- a/core/http/auth/features.go +++ b/core/http/auth/features.go @@ -107,6 +107,9 @@ var RouteFeatureRegistry = []RouteFeature{ {"PUT", "/api/chats/shared-pages/:folder/:name/file", FeatureChats}, {"POST", "/api/chats/shared-pages/:folder/:name/chat", FeatureChats}, {"GET", "/api/chats/context-files", FeatureChats}, + {"GET", "/api/chats/memory", FeatureChats}, + {"PUT", "/api/chats/memory", FeatureChats}, + {"GET", "/api/chats/memory/export", FeatureChats}, {"GET", "/api/chats/changes", FeatureChats}, {"GET", "/api/chats/changes/audit", FeatureChats}, {"GET", "/api/chats/changes/:id", FeatureChats}, diff --git a/core/http/endpoints/localai/api_instructions.go b/core/http/endpoints/localai/api_instructions.go index afab53688b..7edc49250d 100644 --- a/core/http/endpoints/localai/api_instructions.go +++ b/core/http/endpoints/localai/api_instructions.go @@ -28,7 +28,7 @@ var instructionDefs = []instructionDef{ {Name: "agent-jobs", Description: "Agent tasks and jobs: saved prompt templates run in the background, with webhooks", Tags: []string{"agent-jobs"}, Intro: "A task is a prompt template ({{.name}} parameters) run by a model or a saved agent (agent_id); a job is one run of it, a background chat of its owner with origin job. POST /api/agent/tasks creates a task {name, model, agent_id, prompt, enabled, webhooks [{url, method, headers, payload_template}]}; GET, PUT and DELETE /api/agent/tasks/{id} read, replace and delete it; header values are never returned (sent back as [hidden] they keep their value). POST /api/agent/jobs/execute {task_id, parameters, images, videos, audios, files} or POST /api/agent/tasks/{name}/execute {parameters} starts a job and answers 201 {job_id, status, url} at once; attachments are base64 data URIs, an address is never fetched. GET /api/agent/jobs/{id} gives status (pending, running, completed, failed, cancelled), result, error, traces and the webhook delivery; GET /api/agent/jobs lists newest first (task_id, status, limit); POST .../jobs/{id}/cancel stops one, POST .../jobs/{id}/rerun runs it again, DELETE removes it with its chat. cron and multimedia_sources on a task are refused: schedule a saved agent with a trigger. A webhook to a private address is refused unless an administrator allows it. Needs the mcp_jobs feature (off by default); ?all_users=true gives an administrator a read-only view of every user's tasks and jobs."}, {Name: "collections", Description: "Collections: the knowledge base of documents the chats can search, with files and followed URLs as sources", Tags: []string{"collections"}, Intro: "A collection is a set of files the server cuts into passages and embeds. GET and POST /api/collections list and create (POST {name}) the collections of the user; POST /api/collections/{name}/upload (multipart field file) adds a file; GET .../entries, .../entries/{entry} and .../entries-raw/{entry} read them; POST .../search {query, max_results} returns the best passages (a number above the passages stored returns all of them); DELETE .../entry/delete {entry} removes one; POST .../reset removes every entry and keeps the collection and its sources; GET, POST and DELETE .../sources follow a URL. Names with special characters must be URL-encoded. Each user sees only their own collections. The routes need the collections feature (off by default for a user); the service can be turned off with LOCALAI_DISABLE_COLLECTIONS. A saved agent can search a collection when it is granted in the agent's collections list. The deprecated path /api/agents/collections serves the same store with a Deprecation header."}, {Name: "workspace", Description: "A live feed of the changes to your files, whoever made them", Tags: []string{"workspace"}, Intro: "GET /api/workspace/events streams server-sent events: a change event {revision, paths, kinds} when files in your home folder change, from any client, chat, trigger, connector or frontend. Paths are inside your home, without a leading slash; kinds are the kinds of the change sets (user, agent, upload, undo). Changes close together come as one event, about once a second at most. A resync event {revision} means some changes were left out: read again what you show. The event id is the revision; send it back as Last-Event-ID or ?after= to resume, otherwise the stream starts from now. A comment line comes every 25 seconds. You see only paths you may read. At most 8 streams per user (429 after). Needs the chats feature; 409 files_off when the server has no files."}, - {Name: "chats", Description: "Chats the server keeps: steering, sub-agents and live events", Tags: []string{"chats"}, Intro: "POST /api/chats starts a chat with a model; PATCH /api/chats/{id} {title, model, settings} renames it (1 to 80 characters), moves it to another model (history stays) or sets its settings {system_prompt, temperature, top_p, top_k, reasoning_effort, context_size}, which apply from the next turn (a new title or model needs an idle chat: 409 otherwise). POST .../{id}/regenerate answers the last message again, POST .../{id}/messages/{seq}/edit {text} replaces one of your messages and runs again from it (later lines are removed), POST .../{id}/fork {from_seq, title} copies a chat up to a line into a new chat, GET /api/chats/search?q= finds chats by title or text, GET .../{id}/export?format=markdown|json returns a chat as a file and POST /api/chats/import {chat, model} stores a JSON export as a new chat. Events also carry usage {used, limit, completion, tokens_per_second, model, node, prompt_ms} after each model request, for a context meter (node only when the server exposes the node header, prompt_ms only when the backend reports timings). Sub-agents: GET /api/chats/{id}/agents lists the sub-agent runs of a chat with running, last_turn and the background limit (from the shared store), GET .../{id}/agents/{agent_id}/messages reads one run's transcript and POST .../{id}/agents/{agent_id}/stop stops it; sub_agent_started and sub_agent_done events mark them in the parent's stream. A chat that PUT .../{id}/builtins {on: [\"saved_agents\"]} switched on (off by default; needs the agents feature and the chats_saved_agents setting) also gets list_agents and the agent argument of spawn_agent (a saved agent's id or name): the sub-agent then runs with that agent's instructions, model and grants. POST .../{id}/messages sends a message: with no turn running it starts one; with a turn running it waits (started false, pending_id) and reaches the model, together with any others and in order, at the model's next request. GET .../{id} lists the waiting messages; PATCH and DELETE .../{id}/pending/{pid} edit or cancel one until it is delivered (409 after). After a stop or a failed turn the waiting messages stay; POST .../{id}/send-now starts a turn from them and POST .../{id}/stop ends a running turn. Goals: PUT .../{id}/goal {text} sets a goal for the chat (it replaces the one it had, returned as replaced) and starts a turn on it when none runs; while the goal is active every turn's prompt carries it, the model gets goal_done {summary} and goal_blocked {reason}, and when a turn ends by itself the server starts the next one, until the goal is done or blocked, you stop it (POST .../{id}/stop pauses the goal), or chats_goal_max_turns (20 by default) automatic turns or chats_goal_max_steps (400) model steps pause it. Your messages still steer it: they go first. GET .../{id}/goal reads it {text, state active|paused|done|blocked, paused_by user|turn_limit|step_limit|error|interrupted, note, turns, max_turns, steps, max_steps}, DELETE .../{id}/goal clears it and POST .../{id}/goal/resume resumes a paused or blocked one with new counts; GET .../{id} carries it as goal, a goal event says it changed, and the transcript has goal lines (role goal, goal_event set|replaced|continue|paused|resumed|done|blocked|cleared). A chat that a trigger, a connector, a job or a page started, and a sub-agent run, cannot have a goal. A turn whose tool loop reaches the step limit (the runtime setting chats_max_iterations, 40 by default, or the model's agent.max_iterations) ends with last_turn paused after a paused event {steps}: the model answered from what it had, and a new message (for example \"Continue\") goes on. GET .../{id}/messages reads the transcript (a user line has text, the words typed, and attachments [{path, name, size, mime, version, missing}], the files it carried, with path inside your home for the files API; a waiting message has the same two fields; the model still gets the paths of the files with the message) and GET .../{id}/events streams live server-sent events, so read the transcript first. A chat is named after its first message. A conversation belongs to its user; another user's id is always 404. POST /api/chats/import/browser takes the chats the old chat page kept in the browser (the value of localStorage localai_chats_data) and stores them as yours; a chat that is there already is skipped. Needs the chats feature, which is on by default. Saved agents need the agents feature, triggers the triggers feature and connectors the connectors feature; all three are off by default for a user and an administrator grants them (an administrator has them all). The server can switch the chats off (LOCALAI_DISABLE_CHATS: every /api/chats path then answers 404 chats_off), and the triggers or the connectors alone (LOCALAI_DISABLE_TRIGGERS, LOCALAI_DISABLE_CONNECTORS: their routes answer 409 triggers_off or connectors_off); GET /api/features says what runs. Your files: GET /api/chats/files?path= lists a folder of your workspace (paths are inside your home, \"/\"), GET and PUT /api/chats/files/content?path= download and upload a file (a second upload that does not name the version it started from keeps both files and reports conflict_of), POST .../files/folders and .../files/move create and rename, DELETE .../files?path= moves to the Trash, GET .../files/trash lists deletions (each with files, folders and bytes, and totals for the whole Trash) and POST .../files/undo {rev} restores one, DELETE .../files/trash empties the Trash for good and DELETE .../files/trash/{rev} deletes one entry for good (neither can be undone; they touch only your own Trash and the agent tools never do this), GET .../files/history lists changes, GET .../files/search?q= searches names, text files and the text of PDF, Word, Excel and PowerPoint files (a document hit has document, page, sheet or slide; indexing counts documents still being read), and GET .../files/preview?path= reads a .xlsx, .xlsm, .csv, .tsv, .docx or .pptx file of up to 20 MiB for a preview (kind table: sheets of at most 200 rows by 30 columns with total_rows and total_cols; kind document: headings, list items, paragraphs and tables {type table, rows}; kind slides: titles, text and notes; 415 for another type). POST .../{id}/messages also takes multipart/form-data (text and files) to attach files (with a turn running the files are saved at once and the message waits with their paths): images go to a vision model inside the message (image_url parts; other models get the path and describe_image), videos to a model that takes video, and the text of PDF, Word, Excel, PowerPoint and EPUB files is added to the message (the model reads the rest with read_document). Skills: GET /api/chats/skills?chat= lists the skills of the five layers (folder, personal, shared, addon, builtin; an addon skill names its add-on in addon and addon_title) with enabled and shadowed, POST creates one {layer personal|shared, name, description, body} (shared needs an administrator), GET PUT DELETE .../skills/{layer}/{name} read, change and delete (to the Trash), PUT /api/chats/{id}/skills {off} switches skills off for one chat. Well-known files: GET and PUT /api/chats/wellknown/{name} (persona, user, shortcuts, home, ui, or widget-data with ?path=) read and change the files of your home that set the assistant's persona, your profile, the composer shortcuts, the Home widgets and the look. A read is validated against the JSON Schema at .../wellknown/{name}/schema and falls back to the last valid version (fallback, errors); a write {values, replace} merges the keys it sends (null removes one), takes the version you read in If-Match (409 with current when the file changed) and is refused with errors when the result is invalid; POST .../wellknown/{name}/revert restores the previous valid version as a new change, or the version {rev} that GET .../wellknown/{name}/versions lists (newest first, with who wrote it and in which chat). A checkpoint line of a transcript carries rev, the change set of the turn, and lists the paths it changed. Add-ons: GET /api/chats/{id}/addons lists the add-ons whose tools you may use, each with its switch for that chat, and PUT /api/chats/{id}/addons {off: [ids]} switches the tools of add-ons off for one chat. An add-on whose tools run with the token of one of your connections (credentialed: true) lists connections [{id, name, handle}], enabled_connections (the binding IDs this chat may use), connection_by (chat, agent, binding or only) and needs_connection; the host never picks connections for you: with one it is enabled, with several PUT /api/chats/{id}/connections {addon, connections: [binding ids]} sets the set (an empty list clears it, and choosing switches the add-on on), and until then the tools are not offered to the model. With two or more enabled, every tool of the add-on takes a required connection argument, an enum of the handles of the enabled connections, and a call without it or with another name is answered with a sentence that lists the allowed names; with one enabled the argument is not offered. A connection name must be unique among your credential-only connections of an add-on (400 otherwise). An agent can name a set per add-on (connections {addon: [binding ids]} on the agent); a chat of that agent cannot change it (400). A single binding id string is read as a set of one. GET /api/chats/draft/addons takes connections=addon:binding,... (repeat an add-on for each binding) and POST /api/chats takes switches.connections. Commands: a chat runs commands with bash, served by an add-on that runs commands (isolated) or, when no add-on serves it, by the built-in bash directly on this machine with no isolation and with its network; the add-on list says which (commands_provider, commands_isolation, commands_consent, commands_fallback). With chats_host_exec ask, each command waits: a host_exec_consent event carries {ids: [id], text: command} and POST /api/chats/{id}/host-exec/consent {id, decision: once|deny} answers it (once needs an administrator). Administrators: GET /api/chats/admin/tools lists the built-in tools an add-on may replace (Settings > Tools; change them with POST /api/settings chats_host_exec and chats_tool_providers) and GET /api/chats/admin/host-exec/events?limit= reads the audit of the commands run on this machine. Network of the isolated commands: the guest has no network device and reaches HTTP and HTTPS only through a proxy that asks the server about each connection. GET /api/chats/{id}/egress returns {preset, ceiling, effective, applies, applies_reason, addon, registries, rules, cards} (applies is false when commands run on this machine, which has the server's network; addon is the add-on that runs the commands and draws the chat's network controls), PUT .../{id}/egress {preset: off|registries|all} sets the chat's preset (400 egress_above_ceiling above the administrator's chats_egress_ceiling), DELETE .../{id}/egress/rules/{host}/{port} removes an allow or deny rule, an egress_consent event {ids: [card id], text: host:port} asks for a host the chat does not allow (the command waits up to 60 seconds) and POST .../{id}/egress/consent {id, decision: once|chat|deny} answers it (allowing a host inside your network needs an administrator), and egress_card events say that the chat's network changed. Administrators: GET /api/chats/admin/egress reads the chats_egress_* settings in force, the registries and the counts of the last 24 hours, and GET /api/chats/admin/egress/events?limit=&user=&chat=&verdict=&host= reads the audit. Saved agents: GET and POST /api/chats/agents, GET PUT DELETE .../agents/{id}: an agent is {name, description, instructions, model, mcp_servers, addons, collections, skills, network, folder}; folder {path, mode read|write} gives it one folder of your files (\"/\" is all of them; not LocalAI, Chats or a shared folder; 400 otherwise), kept by the folder itself so a rename or move is followed, shown back as {path, name, mode, state ok|missing|unset}; a write without folder keeps it and folder {path: \"\"} clears it; network (off, registries or all; off by default) is the network of the isolated commands of its trigger and connector runs, capped by chats_egress_ceiling, which never show a card; the lists are exact grants (an empty list grants nothing) and a chat gets only what you may use at that moment. POST /api/chats {agent_id} starts a chat with one (the agent's model replaces model when it has one). GET .../agents/{id}/export returns the definition as a JSON file (version 1, kind localai-agent; version 2 with folder {path, mode} for an agent with a folder, which a server that knows only version 1 refuses), POST .../agents/import takes that file (JSON body or multipart file field) and saves a new agent (a taken name gets a number, an unavailable model is dropped, a folder you do not have leaves the agent unset until you choose one; warnings say so), GET .../agents/templates lists starter agents and POST .../agents/templates/{id}/create saves one, GET .../agents/{id}/runs lists the chats that ran with the agent (yours, trigger runs, connector runs) with last_turn and counts. Inbox: GET /api/chats/notifications?unread=1 lists the notifications of your trigger runs (kind run_ended, run_failed or needs_attention) with the unread count, GET .../notifications/count counts them, POST .../notifications/read {ids} or {all: true} marks them read, DELETE .../notifications {ids} removes some, or {all: true} empties your inbox ({all: true, read_only: true} removes only the read ones; the answer has deleted and the unread count left), the list also gives total (everything kept, since limit, at most 500, can cut the list), and GET .../notifications/events streams new ones as server-sent events plus a notifications_changed event when notifications were read or deleted (from any tab or frontend), after which a client reads the list again. A user only ever reaches their own notifications. Opening a chat (GET /api/chats/{id}) marks its notifications read. Triggers: GET and POST /api/chats/triggers, GET PUT DELETE .../triggers/{id}, POST .../{id}/run (start now), .../{id}/enable and .../{id}/disable, GET .../{id}/runs, GET .../triggers/preview?when=&tz= (next times of a schedule). A trigger is {name, prompt, kind schedule|file, schedule + time_zone, or folder + quiet_seconds, daily_cap, enabled, agent_id, model, source_chat_id}: it starts a chat as you by itself, on a schedule (\"daily 08:00\", \"weekdays 09:30\", \"weekly mon,thu 09:30\", \"monthly 15 08:00\", \"once 2026-12-24 18:00\" or five cron fields) or when a file arrives in the folder; a chat it starts has origin trigger. A trigger also has plan {reads, writes, widget} (what a run reads, the file it rewrites and the Home widget that shows it), cost {estimated_tokens, average_tokens, runs, max_runs_per_day, remote} and last_done, and each run its tokens; acts (act.files, act.addons, act.addons:, act.send, act.spend: what a run may do with nobody present, within its agent's grants; a run without any only reads) is set in the body of a create or replace (left out keeps it, [] clears it) and read back with acts_held, read_only and read_only_why; a file trigger waits min_gap_seconds (60 by default) after a run before a file that changed again starts another. The four trigger tools (propose_trigger, list_triggers, update_trigger, delete_trigger) are the built-in tool group triggers: on in a chat a person starts, switched together with PUT .../{id}/builtins {off: [\"triggers\"]}, listed in GET .../{id}/tools and GET .../draft/tools with usable and reason (not usable in a run a trigger or connector started, for a user without the triggers feature; not listed when the server runs no triggers), and never given to a sub-agent. A model can propose a trigger in a chat (propose_trigger; a schedule from a chat runs at most once an hour); GET .../triggers/proposals?chat= lists them, PUT .../triggers/proposals/{id} edits one (it stays a proposal), POST .../triggers/proposals/{id}/confirm {time_zone} turns one into a trigger that is on, DELETE dismisses it (the answer is kept: GET .../triggers/proposals/{id} returns {state pending|accepted|declined|undone, trigger, answered_at, can_undo, undo_blocked}, POST .../undo takes an accepted one back unless the trigger ran or was edited since (409 says why) and POST .../reopen restores a declined or undone one); the chat carries a proposal as a card {kind trigger_proposal, id} on the tool line (tool_end event and transcript) and as a trigger_proposed event. A model can also ask to change a trigger of yours (list_triggers gives each a ref; update_trigger edits, pauses or resumes, delete_trigger deletes): an edit or a delete waits for you, and a pause or a resume applies at once; the chat carries it as a card {kind trigger_change, id}. GET .../triggers/changes/{id} returns {id, op update|delete|pause|resume, state pending|applied|dismissed|undone, trigger_id, before, after, fields, answered_at, stale, can_undo, undo_blocked, last_run}, POST .../changes/{id}/apply makes it (409 when the trigger changed since), DELETE dismisses it and POST .../changes/{id}/undo puts the trigger back as it was, for a day after it applied (a deleted trigger comes back with its id, without its run history). Proposed changes: a model asks to change your memory or an agent profile with propose_change {kind memory|profile, scope user|agent|channel, target, content, summary} (in the built-in tool group changes; revert_change {id} undoes an applied one). Nothing is written until you confirm. GET /api/chats/changes?status= lists the proposals (id, kind, scope, target, summary, content, source {kind, id, chat}, status pending|applied|dismissed|expired|reverted|stale, rev), GET .../changes/{id} reads one, POST .../changes/{id}/confirm writes it as one change set, or with a body {content} your own text in its place (409 when the file changed since, the proposal expired after 24 hours or it was answered), POST .../changes/{id}/dismiss answers no, POST .../changes/{id}/undo restores the file (409 when it was edited since) and GET .../changes/audit lists who proposed, confirmed or reverted what, with a hash and never the text. GET /api/chats/context-files lists the notes the host puts in the prompt (limits, and groups of {source, scope, key, max_bytes, max_lines, files}) for the Memory tab of Your settings; it only reads. At most 10 proposals wait per user and 5 are made per hour per agent. A chat that runs a saved agent cannot propose until the agent is granted it: the agent's builtins list holds memory.write, profile.write, skills.write, pages.write or dashboard.write (GET /api/chats/agents/builtin-grants lists every grant with title, description and sensitive; none is on by default). propose_change also takes kind skill (a SKILL.md; a skill with locked: true in its frontmatter is refused), page (target is the page name, files is {path: text} with an index.html) and dashboard (the full home.json, checked against its schema). Browser push: GET /api/chats/push/key returns the server's VAPID public key, POST /api/chats/push/subscribe takes a browser PushSubscription {endpoint, keys: {p256dh, auth}} (an https endpoint on a public address) and DELETE .../push/subscribe {endpoint} removes it; a notification is then pushed with its title and link only. These answer 409 push_off when the server keeps nothing across a restart. Conversion: at start the server converts the old chat page, agents, tasks, skills and connectors (a connector is listed as dropped; add the bot again in the Connectors page); GET /api/chats/conversion lists what happened to your items (kind, name, status converted|skipped|failed|dropped, reason) and gives an administrator the totals. Converted triggers and connectors are switched off. Connectors: GET /api/chats/connectors/kinds lists the platforms that an installed add-on serves (each kind comes with the add-on that serves it), GET and POST /api/chats/connectors, GET PUT DELETE .../connectors/{id}, POST .../{id}/enable, .../disable and .../test (asks the platform who the bot is), GET .../{id}/senders (who wrote to the bot) and POST .../{id}/senders/{sender}/allow. POST .../{id}/pairing makes a one-use pairing code (valid ten minutes) that you send as a direct message to the bot from an allowlisted account to pair it (a pairing lasts 90 days, at most three senders), and DELETE .../{id}/paired/{sender} ends one. A binding also has caps (the capabilities you enabled, from a closed list), grants [{role, caps}] (roles that the kind declares in roles and permissions of the kinds list; never a sensitive capability) and paired. A binding is {kind (the platform, telegram when empty; set at create only), name, token (write-only, sealed, never returned; has_token says it is stored), agent_id (required: an agent of yours that names a model), enabled, accept_any, allow [chat IDs, user IDs or @usernames], notify_chat}: a person who messages the bot talks to that agent in a conversation of their own, as you, with only the agent's grants. The allowlist is empty at first, so nobody is accepted; a sender who is not accepted is ignored and logged. accept_any lets anyone who can message the bot use the agent; the create or update that turns it on must also send confirm_accept_any: \"accept any message\" (else 400). Files and skills answer 409 with the reason when the server has no data path. Administrators: GET /api/chats/admin/agents, .../admin/triggers and .../admin/connectors list what every user has, each with its owner and never a token or instructions; GET .../admin/triggers/{id}/runs lists the runs of a trigger, POST .../admin/triggers/{id}/disable and .../admin/connectors/{id}/disable switch one off, and DELETE .../admin/agents/{id}, .../admin/triggers/{id} and .../admin/connectors/{id} remove one. No MCP tool maps to these routes: they are the user's own chat and files, not administration. GET POST /api/chats/mcp-connectors, GET PUT DELETE /api/chats/mcp-connectors/{id} and POST .../enable, .../disable, .../test manage your MCP connectors: MCP servers you define (transport http, or stdio for administrators outside distributed mode), stored per user, with token, headers and env sealed and never returned; a test answers 200 with ok and the tool names or the error. Addresses on private networks are refused at connect time unless an administrator sets mcp_connectors_allow_private. A chat with no agent gets your enabled connectors; a saved agent names one as connector: in mcp_servers. GET /api/chats/{id}/tools lists the built-in tool groups, add-ons, model MCP servers and connectors of a chat with their switches. The built-in groups are on until PUT /api/chats/{id}/builtins {off: [...]} switches them off for that chat: shell (bash, whoever serves it; commands then says switched_off), web, files (the file tools and read_document), media (describe_image and transcribe_audio), collections, subagents (spawn_agent) and triggers; load_skill and the goal and loop tools have no switch, and a sub-agent follows its parent's switches; PUT /api/chats/{id}/mcp {off: [server name or connector:]} switches MCP servers and connectors off for that chat. GET /api/chats/{id}/mcp/prompts and .../mcp/resources list the prompts and resources of the servers the chat can use now; POST .../mcp/prompts {server, name, arguments} and POST .../mcp/resources/read {server, uri} return their text (403 mcp_refused for a server the chat cannot use); POST .../{id}/messages takes context: [{server, name, arguments} or {server, uri}] to add them to a message. MCP Apps: a tool that declares an interactive page (_meta.ui.resourceUri) gives its tool_end event and its transcript line an app {server, tool, resource_uri, visibility}; the transcript line also holds the call input and result. GET /api/chats/{id}/mcp-apps/resource?server=&uri= reads the ui:// page (html, mime_type, meta with csp, permissions and prefersBorder) for a server the chat may use now and a resource a tool of that server declares, and POST /api/chats/{id}/mcp-apps/call {server, tool, arguments} runs a tool of the same server whose visibility includes app, for the page to call; both answer 403 mcp_app_refused for anything else and need the MCP feature. Loops repeat a prompt inside a chat: POST .../{id}/loops {interval, prompt} starts one (interval such as 5m, at least chats_loop_min_interval; empty for a self-paced loop whose model calls schedule_next or loop_done after each run), GET .../{id}/loops lists them (also the loops field of GET /api/chats/{id}), PUT .../{id}/loops/{loopId} {state: paused|active} pauses, resumes or confirms one (the first loop of a chat is pending until confirmed), DELETE .../{id}/loops/{loopId} or DELETE .../{id}/loops stops them; a loops event tells other tabs, and a user line a loop sent carries loop {id, run}. A loop runs in its own chat and as often as every minute; a trigger starts a new chat at most once an hour."}, + {Name: "chats", Description: "Chats the server keeps: steering, sub-agents and live events", Tags: []string{"chats"}, Intro: "POST /api/chats starts a chat with a model; PATCH /api/chats/{id} {title, model, settings} renames it (1 to 80 characters), moves it to another model (history stays) or sets its settings {system_prompt, temperature, top_p, top_k, reasoning_effort, context_size}, which apply from the next turn (a new title or model needs an idle chat: 409 otherwise). POST .../{id}/regenerate answers the last message again, POST .../{id}/messages/{seq}/edit {text} replaces one of your messages and runs again from it (later lines are removed), POST .../{id}/fork {from_seq, title} copies a chat up to a line into a new chat, GET /api/chats/search?q= finds chats by title or text, GET .../{id}/export?format=markdown|json returns a chat as a file and POST /api/chats/import {chat, model} stores a JSON export as a new chat. Events also carry usage {used, limit, completion, tokens_per_second, model, node, prompt_ms} after each model request, for a context meter (node only when the server exposes the node header, prompt_ms only when the backend reports timings). Sub-agents: GET /api/chats/{id}/agents lists the sub-agent runs of a chat with running, last_turn and the background limit (from the shared store), GET .../{id}/agents/{agent_id}/messages reads one run's transcript and POST .../{id}/agents/{agent_id}/stop stops it; sub_agent_started and sub_agent_done events mark them in the parent's stream. A chat that PUT .../{id}/builtins {on: [\"saved_agents\"]} switched on (off by default; needs the agents feature and the chats_saved_agents setting) also gets list_agents and the agent argument of spawn_agent (a saved agent's id or name): the sub-agent then runs with that agent's instructions, model and grants. POST .../{id}/messages sends a message: with no turn running it starts one; with a turn running it waits (started false, pending_id) and reaches the model, together with any others and in order, at the model's next request. GET .../{id} lists the waiting messages; PATCH and DELETE .../{id}/pending/{pid} edit or cancel one until it is delivered (409 after). After a stop or a failed turn the waiting messages stay; POST .../{id}/send-now starts a turn from them and POST .../{id}/stop ends a running turn. Goals: PUT .../{id}/goal {text} sets a goal for the chat (it replaces the one it had, returned as replaced) and starts a turn on it when none runs; while the goal is active every turn's prompt carries it, the model gets goal_done {summary} and goal_blocked {reason}, and when a turn ends by itself the server starts the next one, until the goal is done or blocked, you stop it (POST .../{id}/stop pauses the goal), or chats_goal_max_turns (20 by default) automatic turns or chats_goal_max_steps (400) model steps pause it. Your messages still steer it: they go first. GET .../{id}/goal reads it {text, state active|paused|done|blocked, paused_by user|turn_limit|step_limit|error|interrupted, note, turns, max_turns, steps, max_steps}, DELETE .../{id}/goal clears it and POST .../{id}/goal/resume resumes a paused or blocked one with new counts; GET .../{id} carries it as goal, a goal event says it changed, and the transcript has goal lines (role goal, goal_event set|replaced|continue|paused|resumed|done|blocked|cleared). A chat that a trigger, a connector, a job or a page started, and a sub-agent run, cannot have a goal. A turn whose tool loop reaches the step limit (the runtime setting chats_max_iterations, 40 by default, or the model's agent.max_iterations) ends with last_turn paused after a paused event {steps}: the model answered from what it had, and a new message (for example \"Continue\") goes on. GET .../{id}/messages reads the transcript (a user line has text, the words typed, and attachments [{path, name, size, mime, version, missing}], the files it carried, with path inside your home for the files API; a waiting message has the same two fields; the model still gets the paths of the files with the message) and GET .../{id}/events streams live server-sent events, so read the transcript first. A chat is named after its first message. A conversation belongs to its user; another user's id is always 404. POST /api/chats/import/browser takes the chats the old chat page kept in the browser (the value of localStorage localai_chats_data) and stores them as yours; a chat that is there already is skipped. Needs the chats feature, which is on by default. Saved agents need the agents feature, triggers the triggers feature and connectors the connectors feature; all three are off by default for a user and an administrator grants them (an administrator has them all). The server can switch the chats off (LOCALAI_DISABLE_CHATS: every /api/chats path then answers 404 chats_off), and the triggers or the connectors alone (LOCALAI_DISABLE_TRIGGERS, LOCALAI_DISABLE_CONNECTORS: their routes answer 409 triggers_off or connectors_off); GET /api/features says what runs. Your files: GET /api/chats/files?path= lists a folder of your workspace (paths are inside your home, \"/\"), GET and PUT /api/chats/files/content?path= download and upload a file (a second upload that does not name the version it started from keeps both files and reports conflict_of), POST .../files/folders and .../files/move create and rename, DELETE .../files?path= moves to the Trash, GET .../files/trash lists deletions (each with files, folders and bytes, and totals for the whole Trash) and POST .../files/undo {rev} restores one, DELETE .../files/trash empties the Trash for good and DELETE .../files/trash/{rev} deletes one entry for good (neither can be undone; they touch only your own Trash and the agent tools never do this), GET .../files/history lists changes, GET .../files/search?q= searches names, text files and the text of PDF, Word, Excel and PowerPoint files (a document hit has document, page, sheet or slide; indexing counts documents still being read), and GET .../files/preview?path= reads a .xlsx, .xlsm, .csv, .tsv, .docx or .pptx file of up to 20 MiB for a preview (kind table: sheets of at most 200 rows by 30 columns with total_rows and total_cols; kind document: headings, list items, paragraphs and tables {type table, rows}; kind slides: titles, text and notes; 415 for another type). POST .../{id}/messages also takes multipart/form-data (text and files) to attach files (with a turn running the files are saved at once and the message waits with their paths): images go to a vision model inside the message (image_url parts; other models get the path and describe_image), videos to a model that takes video, and the text of PDF, Word, Excel, PowerPoint and EPUB files is added to the message (the model reads the rest with read_document). Skills: GET /api/chats/skills?chat= lists the skills of the five layers (folder, personal, shared, addon, builtin; an addon skill names its add-on in addon and addon_title) with enabled and shadowed, POST creates one {layer personal|shared, name, description, body} (shared needs an administrator), GET PUT DELETE .../skills/{layer}/{name} read, change and delete (to the Trash), PUT /api/chats/{id}/skills {off} switches skills off for one chat. Well-known files: GET and PUT /api/chats/wellknown/{name} (persona, user, shortcuts, home, ui, or widget-data with ?path=) read and change the files of your home that set the assistant's persona, your profile, the composer shortcuts, the Home widgets and the look. A read is validated against the JSON Schema at .../wellknown/{name}/schema and falls back to the last valid version (fallback, errors); a write {values, replace} merges the keys it sends (null removes one), takes the version you read in If-Match (409 with current when the file changed) and is refused with errors when the result is invalid; POST .../wellknown/{name}/revert restores the previous valid version as a new change, or the version {rev} that GET .../wellknown/{name}/versions lists (newest first, with who wrote it and in which chat). A checkpoint line of a transcript carries rev, the change set of the turn, and lists the paths it changed. Add-ons: GET /api/chats/{id}/addons lists the add-ons whose tools you may use, each with its switch for that chat, and PUT /api/chats/{id}/addons {off: [ids]} switches the tools of add-ons off for one chat. An add-on whose tools run with the token of one of your connections (credentialed: true) lists connections [{id, name, handle}], enabled_connections (the binding IDs this chat may use), connection_by (chat, agent, binding or only) and needs_connection; the host never picks connections for you: with one it is enabled, with several PUT /api/chats/{id}/connections {addon, connections: [binding ids]} sets the set (an empty list clears it, and choosing switches the add-on on), and until then the tools are not offered to the model. With two or more enabled, every tool of the add-on takes a required connection argument, an enum of the handles of the enabled connections, and a call without it or with another name is answered with a sentence that lists the allowed names; with one enabled the argument is not offered. A connection name must be unique among your credential-only connections of an add-on (400 otherwise). An agent can name a set per add-on (connections {addon: [binding ids]} on the agent); a chat of that agent cannot change it (400). A single binding id string is read as a set of one. GET /api/chats/draft/addons takes connections=addon:binding,... (repeat an add-on for each binding) and POST /api/chats takes switches.connections. Commands: a chat runs commands with bash, served by an add-on that runs commands (isolated) or, when no add-on serves it, by the built-in bash directly on this machine with no isolation and with its network; the add-on list says which (commands_provider, commands_isolation, commands_consent, commands_fallback). With chats_host_exec ask, each command waits: a host_exec_consent event carries {ids: [id], text: command} and POST /api/chats/{id}/host-exec/consent {id, decision: once|deny} answers it (once needs an administrator). Administrators: GET /api/chats/admin/tools lists the built-in tools an add-on may replace (Settings > Tools; change them with POST /api/settings chats_host_exec and chats_tool_providers) and GET /api/chats/admin/host-exec/events?limit= reads the audit of the commands run on this machine. Network of the isolated commands: the guest has no network device and reaches HTTP and HTTPS only through a proxy that asks the server about each connection. GET /api/chats/{id}/egress returns {preset, ceiling, effective, applies, applies_reason, addon, registries, rules, cards} (applies is false when commands run on this machine, which has the server's network; addon is the add-on that runs the commands and draws the chat's network controls), PUT .../{id}/egress {preset: off|registries|all} sets the chat's preset (400 egress_above_ceiling above the administrator's chats_egress_ceiling), DELETE .../{id}/egress/rules/{host}/{port} removes an allow or deny rule, an egress_consent event {ids: [card id], text: host:port} asks for a host the chat does not allow (the command waits up to 60 seconds) and POST .../{id}/egress/consent {id, decision: once|chat|deny} answers it (allowing a host inside your network needs an administrator), and egress_card events say that the chat's network changed. Administrators: GET /api/chats/admin/egress reads the chats_egress_* settings in force, the registries and the counts of the last 24 hours, and GET /api/chats/admin/egress/events?limit=&user=&chat=&verdict=&host= reads the audit. Saved agents: GET and POST /api/chats/agents, GET PUT DELETE .../agents/{id}: an agent is {name, description, instructions, model, mcp_servers, addons, collections, skills, network, folder}; folder {path, mode read|write} gives it one folder of your files (\"/\" is all of them; not LocalAI, Chats or a shared folder; 400 otherwise), kept by the folder itself so a rename or move is followed, shown back as {path, name, mode, state ok|missing|unset}; a write without folder keeps it and folder {path: \"\"} clears it; network (off, registries or all; off by default) is the network of the isolated commands of its trigger and connector runs, capped by chats_egress_ceiling, which never show a card; the lists are exact grants (an empty list grants nothing) and a chat gets only what you may use at that moment. POST /api/chats {agent_id} starts a chat with one (the agent's model replaces model when it has one). GET .../agents/{id}/export returns the definition as a JSON file (version 1, kind localai-agent; version 2 with folder {path, mode} for an agent with a folder, which a server that knows only version 1 refuses), POST .../agents/import takes that file (JSON body or multipart file field) and saves a new agent (a taken name gets a number, an unavailable model is dropped, a folder you do not have leaves the agent unset until you choose one; warnings say so), GET .../agents/templates lists starter agents and POST .../agents/templates/{id}/create saves one, GET .../agents/{id}/runs lists the chats that ran with the agent (yours, trigger runs, connector runs) with last_turn and counts. Inbox: GET /api/chats/notifications?unread=1 lists the notifications of your trigger runs (kind run_ended, run_failed or needs_attention) with the unread count, GET .../notifications/count counts them, POST .../notifications/read {ids} or {all: true} marks them read, DELETE .../notifications {ids} removes some, or {all: true} empties your inbox ({all: true, read_only: true} removes only the read ones; the answer has deleted and the unread count left), the list also gives total (everything kept, since limit, at most 500, can cut the list), and GET .../notifications/events streams new ones as server-sent events plus a notifications_changed event when notifications were read or deleted (from any tab or frontend), after which a client reads the list again. A user only ever reaches their own notifications. Opening a chat (GET /api/chats/{id}) marks its notifications read. Triggers: GET and POST /api/chats/triggers, GET PUT DELETE .../triggers/{id}, POST .../{id}/run (start now), .../{id}/enable and .../{id}/disable, GET .../{id}/runs, GET .../triggers/preview?when=&tz= (next times of a schedule). A trigger is {name, prompt, kind schedule|file, schedule + time_zone, or folder + quiet_seconds, daily_cap, enabled, agent_id, model, source_chat_id}: it starts a chat as you by itself, on a schedule (\"daily 08:00\", \"weekdays 09:30\", \"weekly mon,thu 09:30\", \"monthly 15 08:00\", \"once 2026-12-24 18:00\" or five cron fields) or when a file arrives in the folder; a chat it starts has origin trigger. A trigger also has plan {reads, writes, widget} (what a run reads, the file it rewrites and the Home widget that shows it), cost {estimated_tokens, average_tokens, runs, max_runs_per_day, remote} and last_done, and each run its tokens; acts (act.files, act.addons, act.addons:, act.send, act.spend: what a run may do with nobody present, within its agent's grants; a run without any only reads) is set in the body of a create or replace (left out keeps it, [] clears it) and read back with acts_held, read_only and read_only_why; a file trigger waits min_gap_seconds (60 by default) after a run before a file that changed again starts another. The four trigger tools (propose_trigger, list_triggers, update_trigger, delete_trigger) are the built-in tool group triggers: on in a chat a person starts, switched together with PUT .../{id}/builtins {off: [\"triggers\"]}, listed in GET .../{id}/tools and GET .../draft/tools with usable and reason (not usable in a run a trigger or connector started, for a user without the triggers feature; not listed when the server runs no triggers), and never given to a sub-agent. A model can propose a trigger in a chat (propose_trigger; a schedule from a chat runs at most once an hour); GET .../triggers/proposals?chat= lists them, PUT .../triggers/proposals/{id} edits one (it stays a proposal), POST .../triggers/proposals/{id}/confirm {time_zone} turns one into a trigger that is on, DELETE dismisses it (the answer is kept: GET .../triggers/proposals/{id} returns {state pending|accepted|declined|undone, trigger, answered_at, can_undo, undo_blocked}, POST .../undo takes an accepted one back unless the trigger ran or was edited since (409 says why) and POST .../reopen restores a declined or undone one); the chat carries a proposal as a card {kind trigger_proposal, id} on the tool line (tool_end event and transcript) and as a trigger_proposed event. A model can also ask to change a trigger of yours (list_triggers gives each a ref; update_trigger edits, pauses or resumes, delete_trigger deletes): an edit or a delete waits for you, and a pause or a resume applies at once; the chat carries it as a card {kind trigger_change, id}. GET .../triggers/changes/{id} returns {id, op update|delete|pause|resume, state pending|applied|dismissed|undone, trigger_id, before, after, fields, answered_at, stale, can_undo, undo_blocked, last_run}, POST .../changes/{id}/apply makes it (409 when the trigger changed since), DELETE dismisses it and POST .../changes/{id}/undo puts the trigger back as it was, for a day after it applied (a deleted trigger comes back with its id, without its run history). Proposed changes: a model asks to change your memory or an agent profile with propose_change {kind memory|profile, scope user|agent|channel, target, content, summary} (in the built-in tool group changes; revert_change {id} undoes an applied one). Nothing is written until you confirm. GET /api/chats/changes?status= lists the proposals (id, kind, scope, target, summary, content, source {kind, id, chat}, status pending|applied|dismissed|expired|reverted|stale, rev), GET .../changes/{id} reads one, POST .../changes/{id}/confirm writes it as one change set, or with a body {content} your own text in its place (409 when the file changed since, the proposal expired after 24 hours or it was answered), POST .../changes/{id}/dismiss answers no, POST .../changes/{id}/undo restores the file (409 when it was edited since) and GET .../changes/audit lists who proposed, confirmed or reverted what, with a hash and never the text. GET /api/chats/context-files lists the notes the host puts in the prompt (limits, and groups of {source, scope, key, max_bytes, max_lines, files}) for the Memory tab of Your settings; it only reads. Memory is built in: GET and PUT /api/chats/memory read and set {enabled, save_mode} (memory On or Off for all your chats and agents; save_mode ask, auto or never), GET /api/chats/memory/export?scope=&key= downloads the notes as a zip, and a chat is private (it neither reads nor saves memory) while its memory group is switched off with PUT .../{id}/builtins {off: [\"memory\"]}; GET .../{id}/tools has memory {on, code, blocked_by, fix} that says which level turned memory off. Off sends nothing to the prompt and offers no note tool, and your notes stay. At most 10 proposals wait per user and 5 are made per hour per agent. A chat that runs a saved agent cannot propose until the agent is granted it: the agent's builtins list holds memory.write, profile.write, skills.write, pages.write or dashboard.write (GET /api/chats/agents/builtin-grants lists every grant with title, description and sensitive; none is on by default). propose_change also takes kind skill (a SKILL.md; a skill with locked: true in its frontmatter is refused), page (target is the page name, files is {path: text} with an index.html) and dashboard (the full home.json, checked against its schema). Browser push: GET /api/chats/push/key returns the server's VAPID public key, POST /api/chats/push/subscribe takes a browser PushSubscription {endpoint, keys: {p256dh, auth}} (an https endpoint on a public address) and DELETE .../push/subscribe {endpoint} removes it; a notification is then pushed with its title and link only. These answer 409 push_off when the server keeps nothing across a restart. Conversion: at start the server converts the old chat page, agents, tasks, skills and connectors (a connector is listed as dropped; add the bot again in the Connectors page); GET /api/chats/conversion lists what happened to your items (kind, name, status converted|skipped|failed|dropped, reason) and gives an administrator the totals. Converted triggers and connectors are switched off. Connectors: GET /api/chats/connectors/kinds lists the platforms that an installed add-on serves (each kind comes with the add-on that serves it), GET and POST /api/chats/connectors, GET PUT DELETE .../connectors/{id}, POST .../{id}/enable, .../disable and .../test (asks the platform who the bot is), GET .../{id}/senders (who wrote to the bot) and POST .../{id}/senders/{sender}/allow. POST .../{id}/pairing makes a one-use pairing code (valid ten minutes) that you send as a direct message to the bot from an allowlisted account to pair it (a pairing lasts 90 days, at most three senders), and DELETE .../{id}/paired/{sender} ends one. A binding also has caps (the capabilities you enabled, from a closed list), grants [{role, caps}] (roles that the kind declares in roles and permissions of the kinds list; never a sensitive capability) and paired. A binding is {kind (the platform, telegram when empty; set at create only), name, token (write-only, sealed, never returned; has_token says it is stored), agent_id (required: an agent of yours that names a model), enabled, accept_any, allow [chat IDs, user IDs or @usernames], notify_chat}: a person who messages the bot talks to that agent in a conversation of their own, as you, with only the agent's grants. The allowlist is empty at first, so nobody is accepted; a sender who is not accepted is ignored and logged. accept_any lets anyone who can message the bot use the agent; the create or update that turns it on must also send confirm_accept_any: \"accept any message\" (else 400). Files and skills answer 409 with the reason when the server has no data path. Administrators: GET /api/chats/admin/agents, .../admin/triggers and .../admin/connectors list what every user has, each with its owner and never a token or instructions; GET .../admin/triggers/{id}/runs lists the runs of a trigger, POST .../admin/triggers/{id}/disable and .../admin/connectors/{id}/disable switch one off, and DELETE .../admin/agents/{id}, .../admin/triggers/{id} and .../admin/connectors/{id} remove one. No MCP tool maps to these routes: they are the user's own chat and files, not administration. GET POST /api/chats/mcp-connectors, GET PUT DELETE /api/chats/mcp-connectors/{id} and POST .../enable, .../disable, .../test manage your MCP connectors: MCP servers you define (transport http, or stdio for administrators outside distributed mode), stored per user, with token, headers and env sealed and never returned; a test answers 200 with ok and the tool names or the error. Addresses on private networks are refused at connect time unless an administrator sets mcp_connectors_allow_private. A chat with no agent gets your enabled connectors; a saved agent names one as connector: in mcp_servers. GET /api/chats/{id}/tools lists the built-in tool groups, add-ons, model MCP servers and connectors of a chat with their switches. The built-in groups are on until PUT /api/chats/{id}/builtins {off: [...]} switches them off for that chat: shell (bash, whoever serves it; commands then says switched_off), web, files (the file tools and read_document), media (describe_image and transcribe_audio), collections, subagents (spawn_agent), triggers and memory; load_skill and the goal and loop tools have no switch, and a sub-agent follows its parent's switches; PUT /api/chats/{id}/mcp {off: [server name or connector:]} switches MCP servers and connectors off for that chat. GET /api/chats/{id}/mcp/prompts and .../mcp/resources list the prompts and resources of the servers the chat can use now; POST .../mcp/prompts {server, name, arguments} and POST .../mcp/resources/read {server, uri} return their text (403 mcp_refused for a server the chat cannot use); POST .../{id}/messages takes context: [{server, name, arguments} or {server, uri}] to add them to a message. MCP Apps: a tool that declares an interactive page (_meta.ui.resourceUri) gives its tool_end event and its transcript line an app {server, tool, resource_uri, visibility}; the transcript line also holds the call input and result. GET /api/chats/{id}/mcp-apps/resource?server=&uri= reads the ui:// page (html, mime_type, meta with csp, permissions and prefersBorder) for a server the chat may use now and a resource a tool of that server declares, and POST /api/chats/{id}/mcp-apps/call {server, tool, arguments} runs a tool of the same server whose visibility includes app, for the page to call; both answer 403 mcp_app_refused for anything else and need the MCP feature. Loops repeat a prompt inside a chat: POST .../{id}/loops {interval, prompt} starts one (interval such as 5m, at least chats_loop_min_interval; empty for a self-paced loop whose model calls schedule_next or loop_done after each run), GET .../{id}/loops lists them (also the loops field of GET /api/chats/{id}), PUT .../{id}/loops/{loopId} {state: paused|active} pauses, resumes or confirms one (the first loop of a chat is pending until confirmed), DELETE .../{id}/loops/{loopId} or DELETE .../{id}/loops stops them; a loops event tells other tabs, and a user line a loop sent carries loop {id, run}. A loop runs in its own chat and as often as every minute; a trigger starts a new chat at most once an hour."}, {Name: "pages", Description: "Pages: HTML pages in your workspace that the UI shows in a sandboxed frame", Tags: []string{"pages"}, Intro: "A page is the folder pages// of your home (name ^[a-z0-9][a-z0-9-]{0,63}$) with an index.html, an optional page.json {title, icon, description, widget: {size small|medium|large, title}} and its data files; a page with a widget block can be a tile on Home (a widget {type: page, page: , folder?: } in LocalAI/home.json, at most 3), where it runs with localai.page.mode \"widget\". GET /api/chats/pages lists them (source own, author you or assistant, updated_at, updated_by); GET /api/chats/pages/{name} returns the page with its HTML (at most 1 MiB). The bridge of a page: GET /api/chats/pages/{name}/files?dir=, GET and PUT /api/chats/pages/{name}/file (text files of at most 1 MiB, paths relative to the page's folder; a page writes data files only, code and document files such as .js, .css, .html, index and page.json are refused with 403; writes are rate limited; GET .../file?path=&encoding=base64 reads an image of the folder as base64, which the UI puts into the page), and POST /api/chats/pages/{name}/chat {text, chat_id, model} to put a message in a chat after the person confirmed it. GET /api/chats/page-starters and POST /api/chats/page-starters/{id}/copy copy a starter page. Write a page with the files API or from a chat. Shared pages: GET /api/chats/shared-pages lists the pages of the folders an administrator shared with you (folder, folder_id, author_name, hash, code_files, consent none, allowed or changed; the hash covers index.html, page.json and code_files, the scripts, style sheets and modules the page names, so a change to any of them asks again; enabled false when an administrator turned shared pages off). GET /api/chats/shared-pages/{folder_id}/{name} returns one with its HTML and your consent; PUT .../consent {hash, once} allows that version (once gives a visit token), DELETE .../consent revokes it. The bridge of a shared page is .../files, .../file and .../chat as for your own pages, each with the header X-LocalAI-Page-Hash (the version that runs) and X-LocalAI-Page-Visit for a visit allowed once; without a consent it answers 403 page_not_allowed, a read of a file of code by a frame of an older version answers 409, and a read-only grant cannot write. Needs the chats and pages features (pages is off by default for users who are not administrators)."}, {Name: "addons", Description: "Owner-routed addon lifecycle and response relays", Tags: []string{"Addons"}, Intro: "POST /api/addons/dispatch submits typed lifecycle operations or reads actor-bound operation-status. POST /api/addons/relay serves status and coarse lifecycle progress on the receiving frontend. Named authentication and the addons feature are required; lifecycle operations require administration. Preserve operationId after outcome_unknown and poll status, never submit a new mutation. An add-on that refuses a request as invalid answers 400 invalid_request from every route, never an outage: nothing was applied, so correct the request instead of repeating it. Resource and output routes deny access until host admission and resolution providers are installed. Data and schema mutations are stored proposals: POST /api/addons/proposals previews and stores one (nothing is applied), GET /api/addons/proposals/{id} reads it, GET /api/addons/proposals?state=pending lists the caller's own pending proposals (newest first, at most 32; where a proposal made through the OpenAI-compatible chat route, an API client or an MCP session waits for the proposer's own browser session; the chats of /api/chats, their agents and their triggers apply a change at once because turning the add-on on for the chat is the consent, and are not proposals), POST .../cancel withdraws one and POST .../reconcile resolves an unknown outcome by receipt lookup, and when no receipt can be found POST .../resolve lets the resource owner's own browser session abandon it with a reason (same confirmation-surface rules as confirm; nothing is applied again). The owner role finds what to resolve with GET /api/addons/resources/{resourceId}/proposals?state=unresolved (same rules, X-LocalAI-Confirm equals the resource ID): it lists the proposal ID, proposer, effects, digest and age of every unresolved proposal on the resource, never its arguments. A write blocked by an unresolved outcome is denied (403) and, for the owner role only, names it as outcome_blocked with blockedBy. POST .../confirm applies exactly the stored operation once and is accepted only from the proposer's own browser session (same-origin fetch, X-LocalAI-Confirm header equal to the proposal ID, digest of what was shown); API keys, MCP sessions and tool or agent calls can propose but never confirm, and no MCP tool maps to confirm. Sharing and database deletion are never proposals. A read that returns a file result gives private artifact IDs, never addon handles: GET /api/addons/artifacts/{id} returns the transfer state, name, size and expiry, and GET /api/addons/artifacts/{id}/download streams the complete file as an attachment. Only the requesting principal can read or download an artifact, an unknown, foreign or expired ID is the same 403, access to every source resource is checked again at completion and while streaming, and a disabled addon denies downloads until it is enabled again. Only GET /backends/library/updates has an MCP tool, list_backend_library_updates; no MCP tool maps to the other routes. The Apps surface: GET /api/addons/apps lists the installed, enabled addons any caller with the addons feature may use, with title, description, icon URL, version, generation, state (running, starting, failed or stopped; an observation separate from enabled), a main page path only when the manifest declares a valid one, and a bounded tool summary; administrators also see disabled addons and per-app controls. GET /api/addons/apps/{id}/icon?generation=N serves the verified icon of a visible app. POST /api/addons/apps/{id}/start and .../stop are administrator-only, take {operationId, generation} with the generation that was listed, deny a stale generation and keep the lifecycle rules (preserve operationId after outcome_unknown). No MCP tool maps to the Apps routes. The listed ui entry of an app gives the same-origin URLs of its UI code: GET /api/addons/{id}/assets/{digest}/{path} streams one declared file (the entry module or a style sheet) of the current installed package, relayed from the owner, with Cache-Control private, max-age=31536000, immutable and nosniff. The digest is the package sha256 in lower-case hex; a file the manifest does not declare, a traversal, another addon, an older package and a disabled or uninstalled addon are all one 403, never cached. A credential in the URL is never accepted; the browser uses its same-origin session. No MCP tool maps to the asset route. Management (administrators only, through POST /api/addons/dispatch): the reads catalog-search, catalog-details, catalog-sources and addons-installed return catalog entries, one resolved entry, the configured libraries with their last fetch state and the installed addons with verified package facts, health, retained generations and storage; catalog-source-save stores or removes a library by reference only (a secret in place of a reference is denied) and catalog-source-refresh fetches one again; upgrade installs a newer release of an installed addon. Removing a library never touches installed addons. The installed view has an update object computed by the owner's update check (state, candidate version and digest, check and last-success time, reasons, redacted error class); an add-on the owner cannot answer for has none, and a failed check is never up-to-date. No MCP tool maps to these actions. UI-only resource management (never tools; no MCP tool maps to any of it): GET /api/addons/resources?addonId=tables lists the resources the caller owns or that were shared with the caller, with the caller's own role, the display name, the owner's display name for a shared one and, for the owner role only, the number of people it is shared with and the revision. POST /api/addons/resources creates one by the person's own action (operationId makes it idempotent; the arguments are the add-on's create tool arguments), POST .../{resourceId}/rename and .../delete rename and delete it (the owner role only; generation and revision must be the listed ones or the call is 409 stale; delete needs confirm equal to the display name, removes the data and every grant and never uninstalls the add-on), GET .../{resourceId}/grants, PUT/DELETE .../grants/{userId} and GET .../{resourceId}/users?q= list, set (viewer or editor, whole resource) and remove grants and search the people it can be shared with (immutable account ID and display name only; local-owner mode answers 409 sharing_disabled). All of them accept only the owner's own browser session with the X-LocalAI-Confirm header equal to the resource ID (the operation ID for a creation); API keys are refused. POST .../{resourceId}/apply changes the data or the schema by the person's own action with no card, through a write or schema tool the add-on declares ({operationId, generation, revision, tool, arguments} with the revision the caller read; editors write rows, only the owner changes the schema; 409 stale when the generation or revision moved; the same operationId with the same request is one change; reads, creations, rename, delete and undeclared tools are refused). POST .../resources/{resourceId}/changes/{changeId}/undo ({operationId, generation}) undoes one change an add-on tool made at once from a chat, an agent or a trigger, by the change set the add-on kept for it; the change ID is the one the tool result named (undoable true, changeId, undo). It is the person's own action in a browser tab and accepts only the browser session with X-LocalAI-Confirm equal to the resource ID: an API key, an MCP session or a tool call is refused, so a model can never undo work. The role is the one the change needed (editors undo data changes, only the owner role undoes a schema change or a creation). It is applied against the revision the add-on just reported: when the resource changed after the change the answer is 409 stale with the words of what changed and nothing is overwritten. The other refusals are typed: not_found (404), expired (410, after 7 days), already_undone (409) and cannot_undo (409, for example a job that finished); the same operationId with the same change is one undo. GET .../resources/{resourceId}/operations/{operationId}?kind=rename|delete|apply|undo reads the receipt of a rename, a deletion, a change or an undo after outcome_unknown, which carries the original operationId. GET/PUT/DELETE /api/addons/conversations/{conversationId}/selection/{addonId} keep the resource a conversation works on for the authenticated user; a chat tool call that names no resourceId uses it, authorized again by the owner on every call, and with none selected the tool answers an error result with code select_resource. Data recovery (administrators only, never tools; no MCP tool maps to any of it): GET /api/addons/{id}/recovery returns the recovery state of an add-on's data (ready, upgrading, restoring, needs-recovery or purging), its backups newest first with size, reason, mode and last verification result, the retention policy and the receipt of the last restore. POST /api/addons/{id}/recovery/backup makes a verified, consistent backup (the add-on is stopped while its SQLite files are copied with VACUUM INTO, admission is blocked and pending proposals are invalidated; nothing is left behind on failure; 503 insufficient_storage when it does not fit), .../verify reads one completely, .../restore restores one (the add-on must not be running, confirm is the add-on ID, mapping names the account that takes the data of a backup made in local-owner mode, the answer carries warnings and no share is given back that was removed since the backup or whose account is gone; 409 needs_recovery, addon_running, backup_corrupt, incompatible_format, identity_changed, mapping_required or downgrade_unsupported), .../delete-backup removes one (never the only verified copy, 409 sole_recovery_copy) and .../prune applies the retention policy. POST /api/addons/{id}/purge removes retained data by explicit scope (data, backups, recoveryCopies) with confirm equal to the add-on ID, for an add-on that is uninstalled or disabled; it is not a database deletion or an uninstall. Every mutation accepts only the administrator's own browser session (same-origin fetch, X-LocalAI-Confirm equal to the operationId); a repeated operationId returns the recorded result and never runs again; after outcome_unknown read the state and keep the operationId. An upgrade of an add-on that has data is backed up, migrated under exclusive ownership and published only when healthy; a failed migration that changes the data format leaves the add-on in needs-recovery with the previous package and the verified backup retained. Updates (never tools; no MCP tool maps to any of it): GET /api/addons/{id}/update reads the owner's update status of an installed add-on for every caller with the addons feature who may see it (administrators any installed add-on, others an enabled one; the same 403 otherwise). The state is unknown, up-to-date, update-available, incompatible (reasons: platform, host-version, api-version, protocol-version, sdk-version, data-format), check-failed (a redacted error class and lastSuccess, the time the library was last read; never shown as up-to-date) or integrity-error (an immutable version was listed again with another digest; never an update). A moving channel (development, a branch, latest) compares resolved digests, so a changed digest with the same version label is an update; an immutable version selects the highest compatible greater version of the same major. The library and the channel the add-on came from never change by themselves. Callers who are not administrators get the state, versions and times only. The route never uses the network. POST /api/addons/{id}/update/check (administrators, own browser session, X-LocalAI-Confirm equal to the operationId) fetches the library again and returns the status; it is rate limited and answers throttled true when it did not fetch, and it applies nothing. POST /api/addons/{id}/upgrade (administrators, own browser session) is the only request that applies an update: it carries operationId, expectedDigest (the installed package the caller saw) and candidateDigest (the candidate the check resolved), and the owner re-resolves, re-checks compatibility and refuses before it records anything with 409 stale_installed, candidate_changed, incompatible, integrity_error or upgrade_conflict (the same transition under another operationId); 503 check_failed when the library cannot be read. A failure keeps the last good generation or enters the recovery state; the previous installation is never deleted. The apps list marks an app update: update-pending from the owner's cached check only and never reaches a library. Host access (administrators only, never tools; no MCP tool maps to any of it): GET /api/addons/host-grants reads the policy and what each add-on holds (administrator session or API key): the deployment floor (policy file and flags, read-only), the deny rules by source, the blanket approval switch, and per add-on the host operations its verified manifest requests (required and optional) with a plain title, text and risk and whether each is allowed, not_allowed, blocked or unsupported, its source (deployment, install, upgrade, edit or blanket), the grants the manifest no longer requests and an upgrade that waits for approval. POST /api/addons/host-grants/preview {sourceId, addonId, version} stages the release, reads the verified manifest and answers what an install would ask for (held, grantable, blocked by a deny rule, unsupported), the package digest, canInstall, the grants revision and a single-use nonce (five minutes, bound to the credential); it changes no state. Every write accepts only the administrator's own browser session (same-origin fetch; X-LocalAI-Confirm equal to addonId@digest for the install, the add-on ID for an edit or a revoke, blanket for the blanket switch and deny for the deny rules; operationId is optional on all but the install, and the host makes one); an API key, bearer token, MCP session or tool call is refused with 403 not_human_surface, and in a split deployment the receiving frontend forwards that decision to the owner as a signed field and only the owner enforces and stores. POST /api/addons/host-grants/install {operationId, action install|upgrade, sourceId, addonId, version, expectedDigest and candidateDigest for an upgrade, approve {digest, ops, revision, nonce}} installs with the approval: the owner stages the package again and grants only the operations that are approved, declared by that verified manifest, registered by the host and not denied (409 approval_stale for another package or a used or expired nonce, 409 revision_conflict for a changed document, 403 blocked_by_policy when a required operation is denied, 403 approval_required when one is neither held nor approved). The plain dispatch install carries no approval, so an agent that installs cannot grant. PUT /api/addons/host-grants/{addonId} {revision, ops, keep} sets the operations the add-on keeps through the UI (keep names held blanket grants to relabel as the administrator's own); DELETE /api/addons/host-grants/{addonId}?revision= revokes them all; PUT /api/addons/host-grants/deny {revision, deny, dryRun} replaces the UI deny rules (a deny beats every grant; the rules of the file and flags are not editable here), and with dryRun true it answers which add-ons would lose which operations and which would stop, from an administrator without the browser session; PUT /api/addons/host-grants/blanket {revision, mode off|official|any, includeOptional, applyToInstalled, revokeOnOff, confirm} turns the blanket approval on or off: with it on, an install or upgrade grants the operations its verified manifest declares as required (and optional ones with includeOptional) without the dialog, never an undeclared one and never past a deny rule; mode any covers every library the administrator configured, so ANY LIBRARY ENTRY RECEIVES THE OPERATIONS ITS MANIFEST DECLARES with no review; turning it on or wider needs confirm equal to \"grant official add-ons what they declare\" or \"grant any library add-on what it declares\"; turning it off keeps the grants it made unless revokeOnOff is true. A write takes effect for the next host call with no restart. A stale revision is 409 revision_conflict; repeating an operationId with the same request returns the same result and with another request is 409 operation_conflict. A deployment can lock the store (403 locked_by_deployment) or fix the blanket mode. Install from a file (administrators only, never tools; no MCP tool maps to any of it): POST /api/addons/files uploads an add-on zip made by make addons-zip as a multipart part named file (an administrator session or API key; staging runs nothing). The owner verifies the zip against the digest manifest it carries, refuses unsafe paths, links, wrong executable bits, too many or too large files and a wrong platform with a typed error (bad_zip, unsafe_path, unsupported_entry, too_large, too_many_files, compression_ratio, bad_manifest, digest_mismatch, wrong_platform, bad_mode), keeps the file for one hour for the uploading user and answers with fileId, sha256, addonId, version, platform, the host operations the manifest asks for, relation (new, upgrade, same or downgrade) and the origin local file, unsigned: nothing signs a local file. GET and DELETE /api/addons/files/{fileId} read and remove it. The install is POST /api/addons/host-grants/preview and /install with sourceId local-file and version equal to the fileId (action upgrade when the add-on is installed; downgrade true confirms an older version; the same version is refused); it needs the administrators own browser session like every host access write."}, {Name: "library-sources", Description: "Library sources of add-ons, backends and models", Tags: []string{"library"}, Intro: "Administrators only. GET /api/library/sources lists every source of the add-on, backend and model kinds, shipped defaults and removed defaults (tombstones) included, with kind, id, name, url, mirrors, enabled, default, removed, suspended, a conflict message and the last fetch state. POST /api/library/sources adds a source of your own ({kind: addon|backend|model, url, id, name, format: library|gallery}); the same kind and URL twice is a 409. POST /api/library/sources/{id}/enable and /disable turn one on or off (enabling a removed default brings it back). PUT /api/library/sources/{id} replaces the mirrors of a model or backend source ({mirrors: [..]}); POST also takes mirrors. Each mirror is an http(s), github:, oci:// or file:// address, at most 8. DELETE /api/library/sources/{id} removes a source; a shipped default stays as a tombstone so it does not return at the next start. The routes work on the store that `local-ai library` edits. On a frontend (--addons-role=frontend) the routes forward to the add-on owner, which is the only writer; they answer 503 when the owner is not reachable or runs on another platform. `GET /backends/library/updates` and `POST /backends/library/install` forward the same way. The runtime setting official_libraries (default on) turns every shipped default off at once: they stay listed with suspended true. No MCP tool maps to these routes."}, diff --git a/core/http/endpoints/studio/mcpconnectors.go b/core/http/endpoints/studio/mcpconnectors.go index dfa46b72f1..a796814aa9 100644 --- a/core/http/endpoints/studio/mcpconnectors.go +++ b/core/http/endpoints/studio/mcpconnectors.go @@ -217,6 +217,7 @@ func writeToolsPanel(c echo.Context, p studiosvc.ToolsPanel) error { Usable: g.Usable, Reason: g.Reason, Provider: g.Provider, Providers: g.Providers, Title: g.Title, Description: g.Description, WhenOff: g.WhenOff}) } + out.Memory = memoryStateOut(p.Memory) for _, a := range p.Addons { out.Addons = append(out.Addons, addonOut(a)) } @@ -234,7 +235,7 @@ func writeToolsPanel(c echo.Context, p studiosvc.ToolsPanel) error { // ListChatTools lists what a chat can use besides its built-in tools. // @Summary List the tools a chat can switch on and off. -// @Description The groups of built-in tools (shell, web, files, media, collections, subagents, triggers and saved_agents; a group the server does not offer is absent, and shell is listed even when nothing can run commands, not usable with the reason), add-ons, the MCP servers of the chat's model and your MCP connectors, each with the chat's switch and whether it can be used now (with the reason when not). A chat with a saved agent lists only what the agent grants. fallbacks lists the built-in tools (bash, web_search, web_fetch) that an installed add-on replaces but that no add-on serves in this chat now, with the add-on and whether the built-in serves instead. +// @Description The groups of built-in tools (shell, web, files, media, collections, subagents, triggers, memory and saved_agents; a group the server does not offer is absent, and shell is listed even when nothing can run commands, not usable with the reason), add-ons, the MCP servers of the chat's model and your MCP connectors, each with the chat's switch and whether it can be used now (with the reason when not). A chat with a saved agent lists only what the agent grants. memory says whether memory works in the chat and, when it does not, the level that turned it off (code, blocked_by) and where to turn it on (fix). fallbacks lists the built-in tools (bash, web_search, web_fetch) that an installed add-on replaces but that no add-on serves in this chat now, with the add-on and whether the built-in serves instead. // @Tags chats // @Param id path string true "conversation id" // @Success 200 {object} schema.ChatsToolsPanel "The tools and their switches" @@ -246,7 +247,7 @@ func (h *Handlers) ListChatTools(c echo.Context) error { return toolsPanel(c, h) // SetChatBuiltins sets the groups of built-in tools a chat has switched off, // and the opt-in groups it has switched on. // @Summary Switch groups of built-in tools on and off for a chat. -// @Description off replaces the earlier list of groups that are off. The groups are shell (bash, whether the Sandbox add-on or the built-in bash on this machine serves it), web (web_search and web_fetch), files (list_files, read_file, write_file, edit_file, move_file, delete_file, search_files and read_document), media (describe_image and transcribe_audio), collections (list_collections and search_collection), subagents (spawn_agent), triggers (propose_trigger, list_triggers, update_trigger and delete_trigger) and saved_agents. A sub-agent follows the switches of its parent chat. An opt-in group (opt_in in the list, such as saved_agents: list_agents and the agent argument of spawn_agent) is off until on names it; on replaces the earlier list of opt-in groups that are on, and an opt-in group in off is ignored. Omit on to keep it. A switch never adds what the administrator has off or the user may not use. The next turn of the chat gets the tools that are on. +// @Description off replaces the earlier list of groups that are off. The groups are shell (bash, whether the Sandbox add-on or the built-in bash on this machine serves it), web (web_search and web_fetch), files (list_files, read_file, write_file, edit_file, move_file, delete_file, search_files and read_document), media (describe_image and transcribe_audio), collections (list_collections and search_collection), subagents (spawn_agent), triggers (propose_trigger, list_triggers, update_trigger and delete_trigger), memory (your notes: off makes the chat a private chat that neither reads nor saves memory) and saved_agents. A sub-agent follows the switches of its parent chat. An opt-in group (opt_in in the list, such as saved_agents: list_agents and the agent argument of spawn_agent) is off until on names it; on replaces the earlier list of opt-in groups that are on, and an opt-in group in off is ignored. Omit on to keep it. A switch never adds what the administrator has off or the user may not use. The next turn of the chat gets the tools that are on. // @Tags chats // @Param id path string true "conversation id" // @Param request body schema.ChatsBuiltinSwitches true "the groups that are off" diff --git a/core/http/endpoints/studio/memory.go b/core/http/endpoints/studio/memory.go new file mode 100644 index 0000000000..e2893a421b --- /dev/null +++ b/core/http/endpoints/studio/memory.go @@ -0,0 +1,86 @@ +package studio + +import ( + "bytes" + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" +) + +var memoryRules = []errRule{ + fixed(http.StatusServiceUnavailable, "unavailable", "the memory settings cannot be stored on this server", studiosvc.ErrMemorySettings), + fixed(http.StatusNotFound, typeNotFound, "there are no notes to export", studiosvc.ErrNoNotes), +} + +func failMemory(c echo.Context, err error) error { return mapErr(c, err, memoryRules, failFor) } + +func memorySettingsOut(m studiosvc.MemorySettings) schema.ChatsMemorySettings { + return schema.ChatsMemorySettings{Enabled: m.Enabled, EnabledSet: m.EnabledSet, SaveMode: m.SaveMode, + SaveModeSet: m.SaveModeSet, Provider: m.Provider, Available: m.Available} +} + +func memoryStateOut(m studiosvc.MemoryReason) schema.ChatsMemoryState { + out := schema.ChatsMemoryState{On: m.On, Code: m.Code, BlockedBy: m.By, Reason: m.Reason} + if m.Fix != nil { + out.Fix = &schema.ChatsMemoryFix{Kind: m.Fix.Kind, Target: m.Fix.Target, Field: m.Fix.Field} + } + return out +} + +// GetMemory reads your memory choices. +// @Summary Read your memory settings. +// @Description Memory is the short notes the assistant reads before it answers and may save. enabled is the switch for all your chats and agents: a private chat (the memory group of a chat, see PUT /api/chats/{id}/builtins), an agent that turns memory off, or this switch off each make memory off in a chat, and the narrowest wins; a saved agent cannot widen your choice. enabled_set is false until you choose; before that memory is on, unless you had disabled the Memory add-on to stop it. save_mode is how a note is saved (ask, auto or never) and applies as the strictest rule with the rules of the host; a chat from a messaging app always asks. Your notes stay and can be listed, exported and deleted while memory is off. +// @Tags chats +// @Success 200 {object} schema.ChatsMemorySettings "Your memory settings" +// @Router /api/chats/memory [get] +func (h *Handlers) GetMemory(c echo.Context) error { + m, err := h.svc.MemorySettings(c.Request().Context(), userID(c)) + if err != nil { + return failMemory(c, err) + } + return c.JSON(http.StatusOK, memorySettingsOut(m)) +} + +// PutMemory changes your memory choices. +// @Summary Change your memory settings. +// @Description Sets enabled (memory On or Off for every chat and agent) and save_mode (ask, auto or never). A field left out stays as it is. Off means nothing is put in the prompt, the model has no tool or skill for notes, nothing is proposed or saved (also by triggers and other runs that start by themselves) and the model is told in one line that memory is off. The settings are kept per person with the account, so every frontend of a cluster reads the same answer. 400 for a value that is not allowed. +// @Tags chats +// @Accept json +// @Param request body schema.ChatsMemoryRequest true "the choices to change" +// @Success 200 {object} schema.ChatsMemorySettings "Your memory settings now in force" +// @Failure 400 {object} schema.ErrorResponse "A value is not allowed" +// @Failure 503 {object} schema.ErrorResponse "The settings cannot be stored on this server" +// @Router /api/chats/memory [put] +func (h *Handlers) PutMemory(c echo.Context) error { + req, ok := bind[schema.ChatsMemoryRequest](c, "the body must be {enabled, save_mode}") + if !ok { + return nil + } + m, err := h.svc.SetMemory(c.Request().Context(), userID(c), studiosvc.MemoryUpdate{Enabled: req.Enabled, SaveMode: req.SaveMode}) + if err != nil { + return failMemory(c, err) + } + return c.JSON(http.StatusOK, memorySettingsOut(m)) +} + +// ExportMemory downloads your notes. +// @Summary Download your memory notes as a zip file. +// @Description The Markdown files of your memory folders (yours, each agent's and each messaging conversation's) with their paths. scope (user, agent or channel) and key (the agent ID or the conversation folder name, as GET /api/chats/context-files lists them) narrow it to one group. It works while memory is off. 404 when there is nothing to export. +// @Tags chats +// @Produce application/zip +// @Param scope query string false "user, agent or channel" +// @Param key query string false "the agent ID or the conversation folder, with scope" +// @Success 200 {file} file "A zip file of the notes" +// @Failure 404 {object} schema.ErrorResponse "No notes" +// @Router /api/chats/memory/export [get] +func (h *Handlers) ExportMemory(c echo.Context) error { + var buf bytes.Buffer + if _, err := h.svc.ExportMemory(c.Request().Context(), userID(c), &buf, c.QueryParam("scope"), c.QueryParam("key")); err != nil { + return failMemory(c, err) + } + c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="memory-notes.zip"`) + return c.Blob(http.StatusOK, "application/zip", buf.Bytes()) +} diff --git a/core/http/react-ui/e2e/memory-off.spec.js b/core/http/react-ui/e2e/memory-off.spec.js new file mode 100644 index 0000000000..cdb154c25d --- /dev/null +++ b/core/http/react-ui/e2e/memory-off.spec.js @@ -0,0 +1,251 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' +import { installAgents } from './agents-fixtures.js' + +// Memory On or Off: the switch and the save levels of the Memory tab of Your +// settings, and the memory row of the chat Tools menu (a private chat). The API +// is stubbed by a small model of what the server keeps. + +const SHOTS = process.env.MEMORY_SHOTS || '' +async function shot(page, name, options = {}) { + if (!SHOTS) return + for (const theme of ['light', 'dark']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(500) + await page.screenshot({ path: `${SHOTS}/${name}-${theme}-${page.viewportSize().width}.png`, ...options }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light')) +} + +const LIMITS = { index_lines: 40, index_bytes: 2048, profile_bytes: 4096, turn_bytes: 6144 } +const NOTES = { 'Memory/index.md': '- home: lives in Rome -> home.md\n- units: metric, 24-hour clock -> units.md\n', 'Memory/home.md': 'Rome.\n', 'Memory/units.md': 'Metric.\n' } + +async function serveAccount(page, { enabled = true, save = 'ask', fail = false } = {}) { + const s = { enabled, save, puts: [], exports: 0, removed: [] } + await mockChat(page) + await page.route('**/api/chats/memory', (route) => { + const req = route.request() + if (req.method() === 'PUT') { + const body = req.postDataJSON() + s.puts.push(body) + if (fail) return route.fulfill({ status: 503, json: { error: { message: 'the memory settings cannot be stored on this server' } } }) + if (typeof body.enabled === 'boolean') s.enabled = body.enabled + if (body.save_mode) s.save = body.save_mode + } + return route.fulfill({ json: { enabled: s.enabled, enabled_set: s.puts.length > 0, save_mode: s.save, save_mode_set: false, provider: 'builtin', available: true } }) + }) + await page.route('**/api/chats/memory/export*', (route) => { s.exports++; return route.fulfill({ status: 200, contentType: 'application/zip', body: 'PK' }) }) + await page.route('**/api/chats/context-files', route => route.fulfill({ json: { + limits: LIMITS, + groups: [{ source: 'memory', scope: 'user', max_bytes: 2048, max_lines: 40, files: Object.entries(NOTES).map(([p, t]) => ({ path: p, name: p.split('/').pop(), size: t.length, ...(p.endsWith('index.md') ? { index: true, lines: 2 } : {}) })) }], + } })) + await page.route('**/api/chats/files?*', route => route.fulfill({ json: [] })) + await page.route('**/api/chats/files/content*', (route) => { + if (route.request().method() === 'DELETE') s.removed.push(1) + return route.fulfill({ status: 200, body: '' }) + }) + await page.route(/\/api\/chats\/files(\?.*)?$/, (route) => { + if (route.request().method() === 'DELETE') { s.removed.push(new URL(route.request().url()).searchParams.get('path')); return route.fulfill({ json: { moved: 1 } }) } + return route.fulfill({ json: [] }) + }) + return s +} + +test.describe('Settings, Memory: the switch and how a note is saved', () => { + test('puts the On or Off switch first and stores a change at once', async ({ page }) => { + const s = await serveAccount(page) + await page.goto('/app/account?tab=memory') + const card = page.getByTestId('memory-switch-card') + await expect(card).toHaveAttribute('data-state', 'on') + await expect(card.getByRole('heading', { name: 'Memory is on' })).toBeVisible() + const sw = page.getByRole('switch', { name: 'Memory is on' }) + await expect(sw).toHaveAttribute('aria-checked', 'true') + // The switch is above everything else on the page. + const top = async (id) => (await page.getByTestId(id).boundingBox()).y + expect(await top('memory-switch-card')).toBeLessThan(await top('memory-save-card')) + expect(await top('memory-save-card')).toBeLessThan(await top('memory-notes-section')) + await shot(page, 'settings-memory-on', { fullPage: true }) + + await sw.click() + await expect.poll(() => s.puts).toEqual([{ enabled: false }]) + await expect(card).toHaveAttribute('data-state', 'off') + await expect(page.getByRole('switch', { name: 'Memory is off' })).toHaveAttribute('aria-checked', 'false') + await expect(page.getByText('Your notes stay, and you can still read, export and delete them.')).toBeVisible() + // The notes stay viewable while memory is off. + await expect(page.getByTestId('memory-group')).toBeVisible() + await expect(page.getByTestId('memory-export')).toBeEnabled() + await shot(page, 'settings-memory-off', { fullPage: true }) + + await page.getByRole('switch', { name: 'Memory is off' }).click() + await expect.poll(() => s.puts.at(-1)).toEqual({ enabled: true }) + await expect(card).toHaveAttribute('data-state', 'on') + }) + + test('lists the three save levels, marks the default and stores the one picked', async ({ page }) => { + const s = await serveAccount(page) + await page.goto('/app/account?tab=memory') + const radios = page.getByRole('radio') + await expect(radios).toHaveCount(3) + await expect(page.getByRole('radio', { name: /Ask me first/ })).toBeChecked() + await expect(page.getByTestId('memory-save-ask')).toContainText('Default') + await page.getByRole('radio', { name: /Save it and tell me/ }).click() + await expect.poll(() => s.puts).toEqual([{ save_mode: 'auto' }]) + await expect(page.getByRole('radio', { name: /Save it and tell me/ })).toBeChecked() + await page.getByRole('radio', { name: /Never/ }).click() + await expect.poll(() => s.puts.at(-1)).toEqual({ save_mode: 'never' }) + }) + + test('a choice the server did not store is not shown as stored', async ({ page }) => { + const s = await serveAccount(page, { fail: true }) + await page.goto('/app/account?tab=memory') + await page.getByRole('switch', { name: 'Memory is on' }).click() + await expect(page.getByTestId('memory-settings-save-error')).toContainText('cannot be stored') + await expect(page.getByTestId('memory-switch-card')).toHaveAttribute('data-state', 'on') + expect(s.puts).toEqual([{ enabled: false }]) + }) + + test('exports the notes, and forgets everything only after a confirm step', async ({ page }) => { + const s = await serveAccount(page) + await page.goto('/app/account?tab=memory') + const download = page.waitForEvent('download') + await page.getByTestId('memory-export').click() + expect((await download).url()).toMatch(/\/api\/chats\/memory\/export$/) + await page.getByTestId('memory-clear').click() + await expect(page.getByTestId('memory-clear-confirm')).toBeVisible() + expect(s.removed).toEqual([]) + await shot(page, 'settings-memory-forget', { fullPage: true }) + await page.getByTestId('memory-clear-yes').click() + await expect.poll(() => s.removed.length).toBe(3) + }) + + test('has no horizontal scroll and keeps the switch reachable on a phone', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await serveAccount(page) + await page.goto('/app/account?tab=memory') + await expect(page.getByRole('switch', { name: 'Memory is on' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + await shot(page, 'settings-memory-on', { fullPage: true }) + }) +}) + +const chat = { id: 'c1', title: 'Quotes', model: 'qwen3-8b', kind: 'chat', created: '2026-10-25T10:00:00Z', updated: '2026-10-25T10:00:00Z', running: false, last_turn: '', pending: [] } +const MEMORY_ROW = { group: 'memory', tools: ['context_read'], off: false, usable: true, provider: 'builtin', providers: { context_read: 'builtin' }, + title: 'Memory', description: 'The model can read your notes before it answers and save new ones. Turn it off to make this a private chat: nothing is read from your notes or saved to them. Your other chats are not affected.', + when_off: 'This is a private chat. The model neither reads nor saves your notes.' } + +async function serveChat(page, { user = false, agent = false } = {}) { + const state = { off: false, puts: [] } + const memory = () => { + if (state.off) return { on: false, code: 'memory_off_chat', blocked_by: 'chat', reason: 'Memory is off in this chat: it is a private chat.', fix: { kind: 'open_tools_menu', target: 'memory' } } + if (user) return { on: false, code: 'memory_off_user', blocked_by: 'user', reason: 'Memory is off in your settings.', fix: { kind: 'open_memory_settings', target: 'memory' } } + if (agent) return { on: false, code: 'memory_off_agent', blocked_by: 'agent', reason: 'Memory is off for the agent of this chat.' } + return { on: true } + } + const tools = () => ({ addons: [], builtins: [ + { group: 'web', tools: ['web_search'], off: false, usable: true, provider: 'builtin', providers: { web_search: 'builtin' } }, + { ...MEMORY_ROW, off: state.off, usable: !user, reason: user ? 'memory is off in your settings' : undefined }, + ], off: [], servers: [], connectors: [], memory: memory() }) + await mockChat(page) + await page.route('**/api/chats/c1/events', route => route.abort()) + await page.route(/\/api\/chats\/c1\/messages(\?.*)?$/, route => route.fulfill({ json: { messages: [] } })) + await page.route(/\/api\/chats\/c1(\?.*)?$/, route => route.fulfill({ json: chat })) + await page.route('**/api/chats/c1/addons', route => route.fulfill({ json: { addons: [] } })) + await page.route('**/api/chats/c1/tools', route => route.fulfill({ json: tools() })) + await page.route('**/api/chats/c1/builtins', (route) => { + const body = route.request().postDataJSON() + state.puts.push(body) + state.off = (body.off || []).includes('memory') + return route.fulfill({ json: tools() }) + }) + return state +} + +const open = async (page) => { + await page.goto('/app/chats/c1') + await page.getByTestId('chat-tools-button').click() + await expect(page.getByTestId('chat-tools-menu')).toBeVisible() +} + +test.describe('Chat Tools menu: memory and the private chat', () => { + test('a Memory row with a plain effect line, on by default', async ({ page }) => { + await serveChat(page) + await open(page) + const sw = page.getByRole('switch', { name: 'Memory', exact: true }) + await expect(sw).toHaveAttribute('aria-checked', 'true') + await expect(page.getByTestId('chat-tools-item').filter({ has: sw })).toContainText('Turn it off to make this a private chat') + await expect(page.getByTestId('chat-memory-line')).toHaveCount(0) + await expect(page.getByTestId('chat-memory-chip')).toHaveCount(0) + await shot(page, 'chat-tools-memory-on') + }) + + test('switching it off makes a private chat, remembered by the server, with a chip and one line', async ({ page }) => { + const s = await serveChat(page) + await open(page) + await page.getByRole('switch', { name: 'Memory', exact: true }).click() + await expect.poll(() => s.puts).toEqual([{ off: ['memory'] }]) + await expect(page.getByRole('switch', { name: 'Memory', exact: true })).toHaveAttribute('aria-checked', 'false') + await expect(page.getByTestId('chat-tools-item').filter({ has: page.getByRole('switch', { name: 'Memory', exact: true }) })).toContainText('This is a private chat') + await expect(page.getByTestId('chat-memory-line')).toContainText('Memory is off here.') + await expect(page.getByTestId('chat-memory-line')).toContainText('private chat') + await expect(page.getByTestId('chat-memory-line')).toContainText('Turn it on in the Tools menu') + await expect(page.getByTestId('chat-memory-chip')).toHaveText('Private chat') + await shot(page, 'chat-tools-memory-private') + await page.getByRole('switch', { name: 'Memory', exact: true }).click() + await expect.poll(() => s.puts.at(-1)).toEqual({ off: [] }) + await expect(page.getByTestId('chat-memory-chip')).toHaveCount(0) + }) + + test('when memory is off in the settings the row cannot be used and the line says where to turn it on', async ({ page }) => { + await serveChat(page, { user: true }) + await open(page) + const sw = page.getByRole('switch', { name: 'Memory', exact: true }) + await expect(sw).toBeDisabled() + await expect(sw).toHaveAccessibleDescription(/Off in your settings/) + const line = page.getByTestId('chat-memory-line') + await expect(line).toHaveAttribute('data-code', 'user') + await expect(line).toContainText('Memory is off here.') + await expect(line.getByRole('link', { name: 'Settings, Memory' })).toHaveAttribute('href', /\/app\/account\?tab=memory/) + await expect(page.getByTestId('chat-memory-chip')).toHaveText('Memory off') + await shot(page, 'chat-tools-memory-user-off') + }) + + test('a chat of an agent that turned memory off says so', async ({ page }) => { + await serveChat(page, { agent: true }) + await open(page) + await expect(page.getByTestId('chat-memory-line')).toContainText('The agent of this chat has memory switched off.') + }) + + test('fits a phone: the line, the chip and the switch stay inside the screen', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await serveChat(page, { user: true }) + await page.goto('/app/chats/c1') + await page.getByTestId('chat-tools-button').click() + await expect(page.getByTestId('chat-memory-line')).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + await shot(page, 'chat-tools-memory-user-off') + }) +}) + +test.describe('Agent form: the memory switch of the person', () => { + const memory = (page, enabled) => page.route('**/api/chats/memory', route => route.fulfill({ json: { enabled, enabled_set: true, save_mode: 'ask', save_mode_set: false, provider: 'builtin', available: true } })) + + test('says, read only, that memory is off in the settings of the person', async ({ page }) => { + await installAgents(page) + await memory(page, false) + await page.goto('/app/agents/new') + await page.locator('[data-section="Tools"] .ag-fold__button').click() + const line = page.getByTestId('agent-memory-off') + await expect(line).toContainText('Memory is off in your settings') + await expect(line.getByRole('link', { name: 'Settings, Memory' })).toHaveAttribute('href', /\/app\/account\?tab=memory/) + await shot(page, 'agent-form-memory-off', { fullPage: true }) + }) + + test('says nothing while memory is on', async ({ page }) => { + await installAgents(page) + await memory(page, true) + await page.goto('/app/agents/new') + await page.locator('[data-section="Tools"] .ag-fold__button').click() + await expect(page.getByTestId('agent-tools-panel')).toBeVisible() + await expect(page.getByTestId('agent-memory-off')).toHaveCount(0) + }) +}) diff --git a/core/http/react-ui/public/locales/de/agents.json b/core/http/react-ui/public/locales/de/agents.json index 8af1cdeed2..6353c903ff 100644 --- a/core/http/react-ui/public/locales/de/agents.json +++ b/core/http/react-ui/public/locales/de/agents.json @@ -148,7 +148,9 @@ "triggers_run": "Trigger aus Messaging-Apps starten lassen", "talkback_channels": "Auf bekannten Kanälen antworten", "files_upload": "Dateien aus Messaging-Apps empfangen" - } + }, + "memoryOffUser": "Das Gedächtnis ist in deinen Einstellungen aus. Dieser Agent liest und speichert deshalb keine Notizen, egal was du hier einstellst.", + "memoryOffFix": "Einstellungen, Gedächtnis" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/de/chats.json b/core/http/react-ui/public/locales/de/chats.json index 25918886b9..a14431e8c3 100644 --- a/core/http/react-ui/public/locales/de/chats.json +++ b/core/http/react-ui/public/locales/de/chats.json @@ -328,7 +328,8 @@ "media": "Bilder und Audio", "collections": "Wissen", "subagents": "Sub-Agenten", - "changes": "Vorgeschlagene Änderungen" + "changes": "Vorgeschlagene Änderungen", + "memory": "Gedächtnis" }, "builtinBy": "{{tools}} · eingebaut", "builtinByAddon": "{{tools}} · bedient von {{provider}}", @@ -350,7 +351,8 @@ "subagents": "Das Modell kann für eine Aufgabe einen Sub-Agenten starten und dessen Bericht erhalten.", "saved_agents": "Das Modell kann einen deiner gespeicherten Agenten als Sub-Agenten starten.", "triggers": "Das Modell kann Trigger vorschlagen, auflisten, ändern und löschen. Du bestätigst jeden neuen Trigger und jede Änderung.", - "changes": "Das Modell kann Änderungen an deinen Notizen, seinen Regeln, Skills, Seiten und am Dashboard vorschlagen. Du bestätigst jede einzelne." + "changes": "Das Modell kann Änderungen an deinen Notizen, seinen Regeln, Skills, Seiten und am Dashboard vorschlagen. Du bestätigst jede einzelne.", + "memory": "Das Modell kann deine Notizen lesen, bevor es antwortet, und neue speichern. Schalte es aus, um den Chat privat zu machen: nichts wird aus deinen Notizen gelesen oder darin gespeichert. Deine anderen Chats bleiben unberührt." }, "whenOff": { "shell": "Das Modell kann Dateien schreiben, aber nichts ausführen.", @@ -361,7 +363,8 @@ "subagents": "Das Modell arbeitet allein.", "saved_agents": "Das Modell kann deine gespeicherten Agenten weder sehen noch starten.", "triggers": "Das Modell kann keine Trigger vorschlagen oder verwalten.", - "changes": "Das Modell kann keine Änderungen an deinen Notizen, seinen Regeln, Skills, Seiten oder am Dashboard vorschlagen." + "changes": "Das Modell kann keine Änderungen an deinen Notizen, seinen Regeln, Skills, Seiten oder am Dashboard vorschlagen.", + "memory": "Das ist ein privater Chat. Das Modell liest und speichert deine Notizen nicht." }, "addonDescribe_one": "Gibt dem Modell {{count}} Werkzeug dieses Add-ons.", "addonDescribe_other": "Gibt dem Modell {{count}} Werkzeuge dieses Add-ons.", @@ -817,7 +820,7 @@ "deleteYes": "Löschen", "confirmDelete": "{{name}} löschen?", "deleted": "{{name}} in den Papierkorb verschoben.", - "clear": "Alle Notizen löschen", + "clear": "Alles vergessen", "clearAsk_one": "{{count}} Datei löschen? Sie kommt in den Papierkorb und lässt sich unter Dateien wiederherstellen.", "clearAsk_other": "{{count}} Dateien löschen? Sie kommen in den Papierkorb und lassen sich unter Dateien wiederherstellen.", "clearYes_one": "{{count}} Datei löschen", @@ -846,5 +849,50 @@ "declared": "Er wurde so eingerichtet, dass er nur liest und berichtet. Nichts, was hier angehakt wird, ändert das.", "binding": "Eine Verbindung im Nur-Lesen-Modus hat ihn erstellt. Nichts, was hier angehakt wird, ändert das." } + }, + "memorySettings": { + "titleOn": "Gedächtnis ist an", + "titleOff": "Gedächtnis ist aus", + "ledeOn": "Deine Chats führen kurze Notizen über dich und lesen sie, bevor sie antworten. Schalte es aus, damit nirgends mehr gelesen oder gespeichert wird. Deine Notizen bleiben, wo sie sind.", + "ledeOff": "Aus deinen Notizen wird nichts gelesen und nichts darin gespeichert, in keinem Chat und bei keinem Agenten. Der Assistent sagt, dass das Gedächtnis aus ist, wenn du ihn bittest, sich etwas zu merken. Deine Notizen bleiben, und du kannst sie weiter lesen, exportieren und löschen.", + "on": "An", + "off": "Aus", + "unavailable": "Notizen sind Dateien, und Dateien sind auf diesem Server aus. Das Gedächtnis kann hier nicht arbeiten.", + "saveTitle": "Eine Notiz speichern", + "saveLede": "Was passiert, wenn der Assistent etwas behalten will. Die strengste Regel gilt immer.", + "save": { + "ask": { + "title": "Erst fragen", + "body": "Eine Karte wartet im Chat. Du bestätigst, bearbeitest oder verwirfst sie." + }, + "auto": { + "title": "Speichern und Bescheid geben", + "body": "In deinen eigenen Chats wird die Notiz sofort gespeichert. Der Chat zeigt „Im Gedächtnis gespeichert“ mit Rückgängig. Bis zu 5 Notizen pro Antwort. Eine lange Notiz oder eine, die wie ein Geheimnis aussieht, wartet trotzdem auf dich." + }, + "never": { + "title": "Nie", + "body": "Der Assistent kann keine Notizen speichern. Er sagt dir, dass das Speichern aus ist." + } + }, + "default": "Standard", + "saveNote": "Chats aus Messaging-Apps fragen immer zuerst. Hintergrundläufe und Trigger speichern nie eine Notiz. Ein gespeicherter Agent darf mehr fragen, aber nie weniger als du hier gewählt hast.", + "saveFailed": "Deine Auswahl konnte nicht gespeichert werden.", + "loadError": "Die Gedächtnis-Einstellungen konnten nicht geladen werden.", + "notesTitle": "Notizen verwalten", + "notesLede": "Lesen, bearbeiten, exportieren oder löschen. Das geht auch, wenn das Gedächtnis aus ist.", + "export": "Exportieren" + }, + "memoryOff": { + "title": "Das Gedächtnis ist hier aus.", + "line": { + "chat": "Das ist ein privater Chat: nichts wird gelesen oder gespeichert. Deine Notizen bleiben unberührt. Schalte es im Werkzeugmenü an.", + "user": "Kein Chat und kein Agent liest oder speichert Notizen, und deine Notizen bleiben unberührt.", + "agent": "Beim Agenten dieses Chats ist das Gedächtnis ausgeschaltet.", + "files": "Notizen sind Dateien, und Dateien sind auf diesem Server aus." + }, + "chip": "Gedächtnis aus", + "chipPrivate": "Privater Chat", + "fixUser": "Einstellungen, Gedächtnis", + "userRow": "In deinen Einstellungen aus. Schalte es unter Einstellungen, Gedächtnis an." } } diff --git a/core/http/react-ui/public/locales/en/agents.json b/core/http/react-ui/public/locales/en/agents.json index 38902236f5..e354627377 100644 --- a/core/http/react-ui/public/locales/en/agents.json +++ b/core/http/react-ui/public/locales/en/agents.json @@ -251,7 +251,9 @@ "triggers_run": "Let triggers start from messaging apps", "talkback_channels": "Reply on known channels", "files_upload": "Receive files from messaging apps" - } + }, + "memoryOffUser": "Memory is off in your settings, so this agent reads and saves no notes, whatever you set here.", + "memoryOffFix": "Settings, Memory" }, "status": { "title": "Status", diff --git a/core/http/react-ui/public/locales/en/chats.json b/core/http/react-ui/public/locales/en/chats.json index 0de1b70b13..2542a576df 100644 --- a/core/http/react-ui/public/locales/en/chats.json +++ b/core/http/react-ui/public/locales/en/chats.json @@ -376,7 +376,8 @@ "media": "Images and audio", "collections": "Knowledge", "subagents": "Sub-agents", - "changes": "Suggested changes" + "changes": "Suggested changes", + "memory": "Memory" }, "builtinBy": "{{tools}} · built-in", "builtinByAddon": "{{tools}} · served by {{provider}}", @@ -452,7 +453,8 @@ "subagents": "Lets the model start a sub-agent for one task and get its report.", "saved_agents": "Lets the model start one of your saved agents as a sub-agent.", "triggers": "Lets the model propose, list, change and delete triggers. You confirm each new trigger or change.", - "changes": "Lets the model propose edits to your notes, its rules, skills, pages and dashboard. You confirm each one." + "changes": "Lets the model propose edits to your notes, its rules, skills, pages and dashboard. You confirm each one.", + "memory": "The model can read your notes before it answers and save new ones. Turn it off to make this a private chat: nothing is read from your notes or saved to them. Your other chats are not affected." }, "whenOff": { "shell": "The model can write files but cannot run anything.", @@ -463,7 +465,8 @@ "subagents": "The model works alone.", "saved_agents": "The model cannot see or start your saved agents.", "triggers": "The model cannot propose or manage triggers.", - "changes": "The model cannot propose edits to your notes, its rules, skills, pages or dashboard." + "changes": "The model cannot propose edits to your notes, its rules, skills, pages or dashboard.", + "memory": "This is a private chat. The model neither reads nor saves your notes." }, "addonDescribe_one": "Gives the model {{count}} tool from this add-on.", "addonDescribe_other": "Gives the model {{count}} tools from this add-on.", @@ -1139,7 +1142,7 @@ "deleteYes": "Delete", "confirmDelete": "Delete {{name}}?", "deleted": "Moved {{name}} to the Trash.", - "clear": "Clear all notes", + "clear": "Forget everything", "clearAsk_one": "Delete {{count}} file? It moves to the Trash and you can restore it from Files.", "clearAsk_other": "Delete {{count}} files? They move to the Trash and you can restore them from Files.", "clearYes_one": "Delete {{count}} file", @@ -1168,5 +1171,50 @@ "declared": "It was set up to read and report only. Nothing ticked here changes that.", "binding": "A connection in read only mode made it. Nothing ticked here changes that." } + }, + "memorySettings": { + "titleOn": "Memory is on", + "titleOff": "Memory is off", + "ledeOn": "Your chats keep short notes about you and read them before they answer. Turn it off to stop reading and saving everywhere. Your notes stay where they are.", + "ledeOff": "Nothing is read from your notes or saved to them, in any chat or agent. The assistant says that memory is off if you ask it to remember something. Your notes stay, and you can still read, export and delete them.", + "on": "On", + "off": "Off", + "unavailable": "Notes are files, and files are off on this server, so memory cannot work here.", + "saveTitle": "Saving a note", + "saveLede": "What happens when the assistant wants to keep something. The strictest rule always wins.", + "save": { + "ask": { + "title": "Ask me first", + "body": "A card waits in the chat. You confirm, edit or dismiss it." + }, + "auto": { + "title": "Save it and tell me", + "body": "In your own chats the note is saved at once. The chat shows \"Saved to memory\" with an Undo. Up to 5 notes a reply. A long note, or one that looks like a secret, still waits for you." + }, + "never": { + "title": "Never", + "body": "The assistant cannot save notes. It tells you that saving is off." + } + }, + "default": "Default", + "saveNote": "Chats from messaging apps always ask first. Background runs and triggers never save a note. A saved agent can ask more, never less, than you chose here.", + "saveFailed": "Could not store your choice.", + "loadError": "Could not load your memory settings.", + "notesTitle": "Manage your notes", + "notesLede": "Read, edit, export or delete them. This works while memory is off.", + "export": "Export" + }, + "memoryOff": { + "title": "Memory is off here.", + "line": { + "chat": "This is a private chat: nothing is read or saved. Your notes are untouched. Turn it on in the Tools menu.", + "user": "No chat or agent reads or saves notes, and your notes are untouched.", + "agent": "The agent of this chat has memory switched off.", + "files": "Notes are files, and files are off on this server." + }, + "chip": "Memory off", + "chipPrivate": "Private chat", + "fixUser": "Settings, Memory", + "userRow": "Off in your settings. Turn it on in Settings, Memory." } } diff --git a/core/http/react-ui/public/locales/es/agents.json b/core/http/react-ui/public/locales/es/agents.json index 2c02a4311b..37e31d8871 100644 --- a/core/http/react-ui/public/locales/es/agents.json +++ b/core/http/react-ui/public/locales/es/agents.json @@ -148,7 +148,9 @@ "triggers_run": "Dejar que los disparadores empiecen desde aplicaciones de mensajería", "talkback_channels": "Responder en canales conocidos", "files_upload": "Recibir archivos de aplicaciones de mensajería" - } + }, + "memoryOffUser": "La memoria está desactivada en tus ajustes, así que este agente no lee ni guarda notas, hagas lo que hagas aquí.", + "memoryOffFix": "Ajustes, Memoria" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/es/chats.json b/core/http/react-ui/public/locales/es/chats.json index 3adff15eb5..5383532708 100644 --- a/core/http/react-ui/public/locales/es/chats.json +++ b/core/http/react-ui/public/locales/es/chats.json @@ -328,7 +328,8 @@ "media": "Imágenes y audio", "collections": "Conocimiento", "subagents": "Subagentes", - "changes": "Cambios sugeridos" + "changes": "Cambios sugeridos", + "memory": "Memoria" }, "builtinBy": "{{tools}} · integradas", "builtinByAddon": "{{tools}} · servidas por {{provider}}", @@ -350,7 +351,8 @@ "subagents": "El modelo puede iniciar un subagente para una tarea y recibir su informe.", "saved_agents": "El modelo puede iniciar uno de tus agentes guardados como subagente.", "triggers": "El modelo puede proponer, listar, cambiar y borrar disparadores. Tú confirmas cada disparador nuevo o cambio.", - "changes": "El modelo puede proponer cambios en tus notas, sus reglas, skills, páginas y el panel. Tú confirmas cada uno." + "changes": "El modelo puede proponer cambios en tus notas, sus reglas, skills, páginas y el panel. Tú confirmas cada uno.", + "memory": "El modelo puede leer tus notas antes de responder y guardar otras nuevas. Desactívala para hacer de este un chat privado: no se lee nada de tus notas ni se guarda nada en ellas. Tus otros chats no cambian." }, "whenOff": { "shell": "El modelo puede escribir archivos pero no puede ejecutar nada.", @@ -361,7 +363,8 @@ "subagents": "El modelo trabaja solo.", "saved_agents": "El modelo no puede ver ni iniciar tus agentes guardados.", "triggers": "El modelo no puede proponer ni gestionar disparadores.", - "changes": "El modelo no puede proponer cambios en tus notas, sus reglas, skills, páginas ni el panel." + "changes": "El modelo no puede proponer cambios en tus notas, sus reglas, skills, páginas ni el panel.", + "memory": "Es un chat privado. El modelo ni lee ni guarda tus notas." }, "addonDescribe_one": "Da al modelo {{count}} herramienta de este add-on.", "addonDescribe_other": "Da al modelo {{count}} herramientas de este add-on.", @@ -817,7 +820,7 @@ "deleteYes": "Eliminar", "confirmDelete": "¿Eliminar {{name}}?", "deleted": "{{name}} movido a la Papelera.", - "clear": "Borrar todas las notas", + "clear": "Olvidar todo", "clearAsk_one": "¿Eliminar {{count}} archivo? Va a la Papelera y puedes restaurarlo desde Archivos.", "clearAsk_other": "¿Eliminar {{count}} archivos? Van a la Papelera y puedes restaurarlos desde Archivos.", "clearYes_one": "Eliminar {{count}} archivo", @@ -846,5 +849,50 @@ "declared": "Se creó para leer e informar solamente. Nada de lo que marques aquí lo cambia.", "binding": "La creó una conexión en modo de solo lectura. Nada de lo que marques aquí lo cambia." } + }, + "memorySettings": { + "titleOn": "La memoria está activada", + "titleOff": "La memoria está desactivada", + "ledeOn": "Tus chats guardan notas breves sobre ti y las leen antes de responder. Desactívala para dejar de leer y guardar en todas partes. Tus notas se quedan donde están.", + "ledeOff": "No se lee nada de tus notas ni se guarda nada en ellas, en ningún chat ni agente. El asistente dice que la memoria está desactivada si le pides que recuerde algo. Tus notas se quedan y puedes seguir leyéndolas, exportándolas y borrándolas.", + "on": "Sí", + "off": "No", + "unavailable": "Las notas son archivos, y los archivos están desactivados en este servidor, así que la memoria no puede funcionar aquí.", + "saveTitle": "Guardar una nota", + "saveLede": "Qué pasa cuando el asistente quiere conservar algo. Siempre gana la regla más estricta.", + "save": { + "ask": { + "title": "Preguntarme antes", + "body": "Una tarjeta espera en el chat. Tú la confirmas, la editas o la descartas." + }, + "auto": { + "title": "Guardar y avisarme", + "body": "En tus propios chats la nota se guarda al instante. El chat muestra «Guardado en la memoria» con Deshacer. Hasta 5 notas por respuesta. Una nota larga, o una que parece un secreto, sigue esperando tu confirmación." + }, + "never": { + "title": "Nunca", + "body": "El asistente no puede guardar notas. Te dice que guardar está desactivado." + } + }, + "default": "Predeterminado", + "saveNote": "Los chats de apps de mensajería siempre preguntan antes. Las ejecuciones en segundo plano y los disparadores nunca guardan una nota. Un agente guardado puede preguntar más, nunca menos, de lo que elijas aquí.", + "saveFailed": "No se pudo guardar tu elección.", + "loadError": "No se pudieron cargar los ajustes de memoria.", + "notesTitle": "Gestionar tus notas", + "notesLede": "Léelas, edítalas, expórtalas o bórralas. Funciona aunque la memoria esté desactivada.", + "export": "Exportar" + }, + "memoryOff": { + "title": "La memoria está desactivada aquí.", + "line": { + "chat": "Es un chat privado: no se lee ni se guarda nada. Tus notas no se tocan. Actívala en el menú de herramientas.", + "user": "Ningún chat ni agente lee o guarda notas, y tus notas no se tocan.", + "agent": "El agente de este chat tiene la memoria desactivada.", + "files": "Las notas son archivos, y los archivos están desactivados en este servidor." + }, + "chip": "Memoria off", + "chipPrivate": "Chat privado", + "fixUser": "Ajustes, Memoria", + "userRow": "Desactivada en tus ajustes. Actívala en Ajustes, Memoria." } } diff --git a/core/http/react-ui/public/locales/fr/agents.json b/core/http/react-ui/public/locales/fr/agents.json index 6ed4b255a3..623b570971 100644 --- a/core/http/react-ui/public/locales/fr/agents.json +++ b/core/http/react-ui/public/locales/fr/agents.json @@ -148,7 +148,9 @@ "triggers_run": "Laisser les déclencheurs démarrer depuis les applications de messagerie", "talkback_channels": "Répondre sur les canaux connus", "files_upload": "Recevoir des fichiers des applications de messagerie" - } + }, + "memoryOffUser": "La mémoire est désactivée dans vos réglages : cet agent ne lit ni n'enregistre de notes, quoi que vous choisissiez ici.", + "memoryOffFix": "Réglages, Mémoire" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/fr/chats.json b/core/http/react-ui/public/locales/fr/chats.json index a8504f77d3..37b109d11b 100644 --- a/core/http/react-ui/public/locales/fr/chats.json +++ b/core/http/react-ui/public/locales/fr/chats.json @@ -328,7 +328,8 @@ "media": "Images et audio", "collections": "Connaissances", "subagents": "Sous-agents", - "changes": "Modifications suggérées" + "changes": "Modifications suggérées", + "memory": "Mémoire" }, "builtinBy": "{{tools}} · intégrés", "builtinByAddon": "{{tools}} · fournis par {{provider}}", @@ -350,7 +351,8 @@ "subagents": "Le modèle peut lancer un sous-agent pour une tâche et recevoir son rapport.", "saved_agents": "Le modèle peut lancer l'un de vos agents enregistrés comme sous-agent.", "triggers": "Le modèle peut proposer, lister, modifier et supprimer des déclencheurs. Vous confirmez chaque nouveau déclencheur ou changement.", - "changes": "Le modèle peut proposer des modifications de vos notes, de ses règles, des skills, des pages et du tableau de bord. Vous confirmez chacune." + "changes": "Le modèle peut proposer des modifications de vos notes, de ses règles, des skills, des pages et du tableau de bord. Vous confirmez chacune.", + "memory": "Le modèle peut lire vos notes avant de répondre et en enregistrer de nouvelles. Désactivez-la pour rendre cette conversation privée : rien n'est lu dans vos notes ni enregistré dedans. Vos autres conversations ne changent pas." }, "whenOff": { "shell": "Le modèle peut écrire des fichiers mais ne peut rien exécuter.", @@ -361,7 +363,8 @@ "subagents": "Le modèle travaille seul.", "saved_agents": "Le modèle ne peut ni voir ni lancer vos agents enregistrés.", "triggers": "Le modèle ne peut ni proposer ni gérer de déclencheurs.", - "changes": "Le modèle ne peut pas proposer de modifications de vos notes, de ses règles, des skills, des pages ou du tableau de bord." + "changes": "Le modèle ne peut pas proposer de modifications de vos notes, de ses règles, des skills, des pages ou du tableau de bord.", + "memory": "C'est une conversation privée. Le modèle ne lit ni n'enregistre vos notes." }, "addonDescribe_one": "Donne au modèle {{count}} outil de cet add-on.", "addonDescribe_other": "Donne au modèle {{count}} outils de cet add-on.", @@ -817,7 +820,7 @@ "deleteYes": "Supprimer", "confirmDelete": "Supprimer {{name}} ?", "deleted": "{{name}} déplacé dans la corbeille.", - "clear": "Effacer toutes les notes", + "clear": "Tout oublier", "clearAsk_one": "Supprimer {{count}} fichier ? Il va dans la corbeille et se restaure depuis Fichiers.", "clearAsk_other": "Supprimer {{count}} fichiers ? Ils vont dans la corbeille et se restaurent depuis Fichiers.", "clearYes_one": "Supprimer {{count}} fichier", @@ -846,5 +849,50 @@ "declared": "Il a été créé pour lire et rendre compte seulement. Rien de ce qui est coché ici ne change cela.", "binding": "Une connexion en lecture seule l'a créé. Rien de ce qui est coché ici ne change cela." } + }, + "memorySettings": { + "titleOn": "La mémoire est activée", + "titleOff": "La mémoire est désactivée", + "ledeOn": "Vos conversations gardent de courtes notes sur vous et les lisent avant de répondre. Désactivez-la pour ne plus rien lire ni enregistrer nulle part. Vos notes restent où elles sont.", + "ledeOff": "Rien n'est lu dans vos notes ni enregistré dedans, dans aucune conversation ni aucun agent. L'assistant dit que la mémoire est désactivée si vous lui demandez de retenir quelque chose. Vos notes restent, et vous pouvez toujours les lire, les exporter et les supprimer.", + "on": "Oui", + "off": "Non", + "unavailable": "Les notes sont des fichiers, et les fichiers sont désactivés sur ce serveur : la mémoire ne peut pas fonctionner ici.", + "saveTitle": "Enregistrer une note", + "saveLede": "Ce qui se passe quand l'assistant veut retenir quelque chose. La règle la plus stricte l'emporte toujours.", + "save": { + "ask": { + "title": "Me demander d'abord", + "body": "Une carte attend dans la conversation. Vous la confirmez, la modifiez ou l'écartez." + }, + "auto": { + "title": "Enregistrer et me prévenir", + "body": "Dans vos propres conversations, la note est enregistrée tout de suite. La conversation affiche « Enregistré dans la mémoire » avec Annuler. Jusqu'à 5 notes par réponse. Une note longue, ou qui ressemble à un secret, attend quand même votre accord." + }, + "never": { + "title": "Jamais", + "body": "L'assistant ne peut pas enregistrer de notes. Il vous dit que l'enregistrement est désactivé." + } + }, + "default": "Par défaut", + "saveNote": "Les conversations des applications de messagerie demandent toujours d'abord. Les exécutions en arrière-plan et les déclencheurs n'enregistrent jamais de note. Un agent enregistré peut demander plus, jamais moins, que ce que vous choisissez ici.", + "saveFailed": "Impossible d'enregistrer votre choix.", + "loadError": "Impossible de charger vos réglages de mémoire.", + "notesTitle": "Gérer vos notes", + "notesLede": "Lisez, modifiez, exportez ou supprimez-les. Cela marche même quand la mémoire est désactivée.", + "export": "Exporter" + }, + "memoryOff": { + "title": "La mémoire est désactivée ici.", + "line": { + "chat": "C'est une conversation privée : rien n'est lu ni enregistré. Vos notes ne sont pas touchées. Activez-la dans le menu des outils.", + "user": "Aucune conversation ni aucun agent ne lit ou n'enregistre de notes, et vos notes ne sont pas touchées.", + "agent": "L'agent de cette conversation a désactivé la mémoire.", + "files": "Les notes sont des fichiers, et les fichiers sont désactivés sur ce serveur." + }, + "chip": "Mémoire off", + "chipPrivate": "Conversation privée", + "fixUser": "Réglages, Mémoire", + "userRow": "Désactivée dans vos réglages. Activez-la dans Réglages, Mémoire." } } diff --git a/core/http/react-ui/public/locales/id/agents.json b/core/http/react-ui/public/locales/id/agents.json index be6e9c844e..eba121e804 100644 --- a/core/http/react-ui/public/locales/id/agents.json +++ b/core/http/react-ui/public/locales/id/agents.json @@ -148,7 +148,9 @@ "triggers_run": "Biarkan pemicu dimulai dari aplikasi pesan", "talkback_channels": "Membalas di kanal yang dikenal", "files_upload": "Menerima file dari aplikasi pesan" - } + }, + "memoryOffUser": "Memori nonaktif di pengaturan Anda, jadi agen ini tidak membaca atau menyimpan catatan, apa pun yang Anda atur di sini.", + "memoryOffFix": "Pengaturan, Memori" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/id/chats.json b/core/http/react-ui/public/locales/id/chats.json index 0c4b34ed1a..204a562f47 100644 --- a/core/http/react-ui/public/locales/id/chats.json +++ b/core/http/react-ui/public/locales/id/chats.json @@ -328,7 +328,8 @@ "media": "Gambar dan audio", "collections": "Pengetahuan", "subagents": "Sub-agen", - "changes": "Perubahan yang disarankan" + "changes": "Perubahan yang disarankan", + "memory": "Memori" }, "builtinBy": "{{tools}} · bawaan", "builtinByAddon": "{{tools}} · dilayani oleh {{provider}}", @@ -350,7 +351,8 @@ "subagents": "Model dapat memulai sub-agen untuk satu tugas dan menerima laporannya.", "saved_agents": "Model dapat memulai salah satu agen tersimpan Anda sebagai sub-agen.", "triggers": "Model dapat mengusulkan, mencantumkan, mengubah, dan menghapus pemicu. Anda mengonfirmasi setiap pemicu atau perubahan baru.", - "changes": "Model dapat mengusulkan perubahan pada catatan Anda, aturannya, skill, halaman, dan dasbor. Anda mengonfirmasi setiap perubahan." + "changes": "Model dapat mengusulkan perubahan pada catatan Anda, aturannya, skill, halaman, dan dasbor. Anda mengonfirmasi setiap perubahan.", + "memory": "Model dapat membaca catatan Anda sebelum menjawab dan menyimpan yang baru. Matikan untuk menjadikan ini obrolan pribadi: tidak ada yang dibaca dari catatan Anda atau disimpan ke dalamnya. Obrolan Anda yang lain tidak terpengaruh." }, "whenOff": { "shell": "Model dapat menulis berkas tetapi tidak dapat menjalankan apa pun.", @@ -361,7 +363,8 @@ "subagents": "Model bekerja sendiri.", "saved_agents": "Model tidak dapat melihat atau memulai agen tersimpan Anda.", "triggers": "Model tidak dapat mengusulkan atau mengelola pemicu.", - "changes": "Model tidak dapat mengusulkan perubahan pada catatan Anda, aturannya, skill, halaman, atau dasbor." + "changes": "Model tidak dapat mengusulkan perubahan pada catatan Anda, aturannya, skill, halaman, atau dasbor.", + "memory": "Ini obrolan pribadi. Model tidak membaca maupun menyimpan catatan Anda." }, "addonDescribe_one": "Memberi model {{count}} alat dari add-on ini.", "addonDescribe_other": "Memberi model {{count}} alat dari add-on ini.", @@ -817,7 +820,7 @@ "deleteYes": "Hapus", "confirmDelete": "Hapus {{name}}?", "deleted": "{{name}} dipindahkan ke Tempat Sampah.", - "clear": "Hapus semua catatan", + "clear": "Lupakan semuanya", "clearAsk_one": "Hapus {{count}} berkas? Berkas dipindahkan ke Tempat Sampah dan dapat dipulihkan dari Berkas.", "clearAsk_other": "Hapus {{count}} berkas? Berkas dipindahkan ke Tempat Sampah dan dapat dipulihkan dari Berkas.", "clearYes_one": "Hapus {{count}} berkas", @@ -846,5 +849,50 @@ "declared": "Ini dibuat untuk hanya membaca dan melapor. Centangan di sini tidak mengubahnya.", "binding": "Dibuat oleh koneksi dalam mode hanya baca. Centangan di sini tidak mengubahnya." } + }, + "memorySettings": { + "titleOn": "Memori aktif", + "titleOff": "Memori nonaktif", + "ledeOn": "Obrolan Anda menyimpan catatan singkat tentang Anda dan membacanya sebelum menjawab. Matikan untuk berhenti membaca dan menyimpan di mana pun. Catatan Anda tetap di tempatnya.", + "ledeOff": "Tidak ada yang dibaca dari catatan Anda atau disimpan ke dalamnya, di obrolan atau agen mana pun. Asisten akan mengatakan bahwa memori nonaktif jika Anda memintanya mengingat sesuatu. Catatan Anda tetap ada, dan Anda masih bisa membaca, mengekspor, dan menghapusnya.", + "on": "Aktif", + "off": "Mati", + "unavailable": "Catatan adalah berkas, dan berkas dinonaktifkan di server ini, jadi memori tidak dapat bekerja di sini.", + "saveTitle": "Menyimpan catatan", + "saveLede": "Apa yang terjadi saat asisten ingin menyimpan sesuatu. Aturan yang paling ketat selalu menang.", + "save": { + "ask": { + "title": "Tanya saya dulu", + "body": "Sebuah kartu menunggu di obrolan. Anda mengonfirmasi, mengedit, atau menolaknya." + }, + "auto": { + "title": "Simpan dan beri tahu saya", + "body": "Di obrolan Anda sendiri, catatan langsung disimpan. Obrolan menampilkan \"Disimpan ke memori\" dengan Urungkan. Hingga 5 catatan per balasan. Catatan yang panjang, atau yang tampak seperti rahasia, tetap menunggu Anda." + }, + "never": { + "title": "Jangan pernah", + "body": "Asisten tidak dapat menyimpan catatan. Ia akan memberi tahu bahwa penyimpanan nonaktif." + } + }, + "default": "Bawaan", + "saveNote": "Obrolan dari aplikasi pesan selalu bertanya dulu. Proses latar belakang dan pemicu tidak pernah menyimpan catatan. Agen tersimpan boleh bertanya lebih banyak, tidak pernah lebih sedikit, daripada pilihan Anda di sini.", + "saveFailed": "Pilihan Anda tidak dapat disimpan.", + "loadError": "Pengaturan memori Anda tidak dapat dimuat.", + "notesTitle": "Kelola catatan Anda", + "notesLede": "Baca, edit, ekspor, atau hapus. Ini berfungsi saat memori nonaktif.", + "export": "Ekspor" + }, + "memoryOff": { + "title": "Memori nonaktif di sini.", + "line": { + "chat": "Ini obrolan pribadi: tidak ada yang dibaca atau disimpan. Catatan Anda tidak disentuh. Aktifkan di menu Alat.", + "user": "Tidak ada obrolan atau agen yang membaca atau menyimpan catatan, dan catatan Anda tidak disentuh.", + "agent": "Agen obrolan ini menonaktifkan memori.", + "files": "Catatan adalah berkas, dan berkas dinonaktifkan di server ini." + }, + "chip": "Memori mati", + "chipPrivate": "Obrolan pribadi", + "fixUser": "Pengaturan, Memori", + "userRow": "Nonaktif di pengaturan Anda. Aktifkan di Pengaturan, Memori." } } diff --git a/core/http/react-ui/public/locales/it/agents.json b/core/http/react-ui/public/locales/it/agents.json index ddc16d3826..576c25ecca 100644 --- a/core/http/react-ui/public/locales/it/agents.json +++ b/core/http/react-ui/public/locales/it/agents.json @@ -148,7 +148,9 @@ "triggers_run": "Lascia partire i trigger dalle app di messaggistica", "talkback_channels": "Rispondere sui canali noti", "files_upload": "Ricevere file dalle app di messaggistica" - } + }, + "memoryOffUser": "La memoria è disattivata nelle tue impostazioni, quindi questo agente non legge né salva note, qualunque cosa imposti qui.", + "memoryOffFix": "Impostazioni, Memoria" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/it/chats.json b/core/http/react-ui/public/locales/it/chats.json index 2742c9d1da..22dea9cf7c 100644 --- a/core/http/react-ui/public/locales/it/chats.json +++ b/core/http/react-ui/public/locales/it/chats.json @@ -328,7 +328,8 @@ "media": "Immagini e audio", "collections": "Conoscenza", "subagents": "Sotto-agenti", - "changes": "Modifiche suggerite" + "changes": "Modifiche suggerite", + "memory": "Memoria" }, "builtinBy": "{{tools}} · integrati", "builtinByAddon": "{{tools}} · servito da {{provider}}", @@ -350,7 +351,8 @@ "subagents": "Il modello può avviare un sotto-agente per un compito e ricevere il suo rapporto.", "saved_agents": "Il modello può avviare uno dei tuoi agenti salvati come sotto-agente.", "triggers": "Il modello può proporre, elencare, modificare ed eliminare trigger. Confermi tu ogni nuovo trigger o modifica.", - "changes": "Il modello può proporre modifiche alle tue note, alle sue regole, alle skill, alle pagine e alla dashboard. Confermi tu ognuna." + "changes": "Il modello può proporre modifiche alle tue note, alle sue regole, alle skill, alle pagine e alla dashboard. Confermi tu ognuna.", + "memory": "Il modello può leggere le tue note prima di rispondere e salvarne di nuove. Disattivala per rendere questa una chat privata: non si legge nulla dalle tue note e non si salva nulla in esse. Le altre chat non cambiano." }, "whenOff": { "shell": "Il modello può scrivere file ma non può eseguire nulla.", @@ -361,7 +363,8 @@ "subagents": "Il modello lavora da solo.", "saved_agents": "Il modello non può vedere né avviare i tuoi agenti salvati.", "triggers": "Il modello non può proporre né gestire trigger.", - "changes": "Il modello non può proporre modifiche alle tue note, alle sue regole, alle skill, alle pagine o alla dashboard." + "changes": "Il modello non può proporre modifiche alle tue note, alle sue regole, alle skill, alle pagine o alla dashboard.", + "memory": "È una chat privata. Il modello non legge né salva le tue note." }, "addonDescribe_one": "Dà al modello {{count}} strumento di questo add-on.", "addonDescribe_other": "Dà al modello {{count}} strumenti di questo add-on.", @@ -817,7 +820,7 @@ "deleteYes": "Elimina", "confirmDelete": "Eliminare {{name}}?", "deleted": "{{name}} spostato nel Cestino.", - "clear": "Cancella tutte le note", + "clear": "Dimentica tutto", "clearAsk_one": "Eliminare {{count}} file? Va nel Cestino e puoi ripristinarlo da File.", "clearAsk_other": "Eliminare {{count}} file? Vanno nel Cestino e puoi ripristinarli da File.", "clearYes_one": "Elimina {{count}} file", @@ -846,5 +849,50 @@ "declared": "È stato creato per leggere e riferire soltanto. Nulla di ciò che spunti qui lo cambia.", "binding": "L'ha creato una connessione in sola lettura. Nulla di ciò che spunti qui lo cambia." } + }, + "memorySettings": { + "titleOn": "La memoria è attiva", + "titleOff": "La memoria è disattivata", + "ledeOn": "Le tue chat conservano brevi note su di te e le leggono prima di rispondere. Disattivala per smettere di leggere e salvare ovunque. Le tue note restano dove sono.", + "ledeOff": "Non si legge nulla dalle tue note e non si salva nulla in esse, in nessuna chat o agente. L'assistente dice che la memoria è disattivata se gli chiedi di ricordare qualcosa. Le tue note restano e puoi ancora leggerle, esportarle ed eliminarle.", + "on": "Sì", + "off": "No", + "unavailable": "Le note sono file, e i file sono disattivati su questo server, quindi la memoria qui non può funzionare.", + "saveTitle": "Salvare una nota", + "saveLede": "Cosa succede quando l'assistente vuole conservare qualcosa. Vince sempre la regola più severa.", + "save": { + "ask": { + "title": "Chiedimi prima", + "body": "Una scheda aspetta nella chat. Tu la confermi, la modifichi o la scarti." + }, + "auto": { + "title": "Salva e avvisami", + "body": "Nelle tue chat la nota viene salvata subito. La chat mostra «Salvato in memoria» con Annulla. Fino a 5 note per risposta. Una nota lunga, o che sembra un segreto, aspetta comunque te." + }, + "never": { + "title": "Mai", + "body": "L'assistente non può salvare note. Ti dice che il salvataggio è disattivato." + } + }, + "default": "Predefinito", + "saveNote": "Le chat delle app di messaggistica chiedono sempre prima. Le esecuzioni in background e i trigger non salvano mai una nota. Un agente salvato può chiedere di più, mai di meno, di quanto scegli qui.", + "saveFailed": "Non è stato possibile salvare la tua scelta.", + "loadError": "Non è stato possibile caricare le impostazioni della memoria.", + "notesTitle": "Gestisci le tue note", + "notesLede": "Leggile, modificale, esportale o eliminale. Funziona anche con la memoria disattivata.", + "export": "Esporta" + }, + "memoryOff": { + "title": "La memoria è disattivata qui.", + "line": { + "chat": "È una chat privata: non si legge né si salva nulla. Le tue note non vengono toccate. Attivala nel menu degli strumenti.", + "user": "Nessuna chat o agente legge o salva note, e le tue note non vengono toccate.", + "agent": "L'agente di questa chat ha disattivato la memoria.", + "files": "Le note sono file, e i file sono disattivati su questo server." + }, + "chip": "Memoria off", + "chipPrivate": "Chat privata", + "fixUser": "Impostazioni, Memoria", + "userRow": "Disattivata nelle tue impostazioni. Attivala in Impostazioni, Memoria." } } diff --git a/core/http/react-ui/public/locales/ko/agents.json b/core/http/react-ui/public/locales/ko/agents.json index d9cbb4517d..40f8fd6fe0 100644 --- a/core/http/react-ui/public/locales/ko/agents.json +++ b/core/http/react-ui/public/locales/ko/agents.json @@ -148,7 +148,9 @@ "triggers_run": "메신저 앱에서 트리거 시작 허용", "talkback_channels": "알려진 채널에 답장", "files_upload": "메신저 앱에서 파일 받기" - } + }, + "memoryOffUser": "설정에서 메모리가 꺼져 있어 여기서 어떻게 정하든 이 에이전트는 메모를 읽거나 저장하지 않습니다.", + "memoryOffFix": "설정, 메모리" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/ko/chats.json b/core/http/react-ui/public/locales/ko/chats.json index 093e5363a9..1d5d89a5d9 100644 --- a/core/http/react-ui/public/locales/ko/chats.json +++ b/core/http/react-ui/public/locales/ko/chats.json @@ -328,7 +328,8 @@ "media": "이미지와 오디오", "collections": "지식", "subagents": "하위 에이전트", - "changes": "제안된 변경" + "changes": "제안된 변경", + "memory": "메모리" }, "builtinBy": "{{tools}} · 기본 제공", "builtinByAddon": "{{tools}} · {{provider}}이(가) 제공", @@ -350,7 +351,8 @@ "subagents": "모델이 작업 하나를 위해 하위 에이전트를 시작하고 보고를 받을 수 있습니다.", "saved_agents": "모델이 저장된 에이전트 중 하나를 하위 에이전트로 시작할 수 있습니다.", "triggers": "모델이 트리거를 제안하고, 나열하고, 변경하고, 삭제할 수 있습니다. 새 트리거나 변경은 매번 직접 확인합니다.", - "changes": "모델이 내 노트, 모델 자신의 규칙, 스킬, 페이지, 대시보드의 변경을 제안할 수 있습니다. 매번 직접 확인합니다." + "changes": "모델이 내 노트, 모델 자신의 규칙, 스킬, 페이지, 대시보드의 변경을 제안할 수 있습니다. 매번 직접 확인합니다.", + "memory": "모델이 답하기 전에 메모를 읽고 새 메모를 저장할 수 있습니다. 끄면 이 채팅은 비공개가 되어 메모를 읽거나 저장하지 않습니다. 다른 채팅에는 영향이 없습니다." }, "whenOff": { "shell": "모델이 파일은 쓸 수 있지만 아무것도 실행할 수 없습니다.", @@ -361,7 +363,8 @@ "subagents": "모델이 혼자 일합니다.", "saved_agents": "모델이 저장된 에이전트를 보거나 시작할 수 없습니다.", "triggers": "모델이 트리거를 제안하거나 관리할 수 없습니다.", - "changes": "모델이 노트, 모델 자신의 규칙, 스킬, 페이지, 대시보드의 변경을 제안할 수 없습니다." + "changes": "모델이 노트, 모델 자신의 규칙, 스킬, 페이지, 대시보드의 변경을 제안할 수 없습니다.", + "memory": "비공개 채팅입니다. 모델이 메모를 읽거나 저장하지 않습니다." }, "addonDescribe_one": "이 애드온의 도구 {{count}}개를 모델에 제공합니다.", "addonDescribe_other": "이 애드온의 도구 {{count}}개를 모델에 제공합니다.", @@ -817,7 +820,7 @@ "deleteYes": "삭제", "confirmDelete": "{{name}}을(를) 삭제할까요?", "deleted": "{{name}}을(를) 휴지통으로 옮겼습니다.", - "clear": "모든 메모 지우기", + "clear": "모두 잊기", "clearAsk_one": "파일 {{count}}개를 삭제할까요? 휴지통으로 이동하며 파일에서 복원할 수 있습니다.", "clearAsk_other": "파일 {{count}}개를 삭제할까요? 휴지통으로 이동하며 파일에서 복원할 수 있습니다.", "clearYes_one": "파일 {{count}}개 삭제", @@ -846,5 +849,50 @@ "declared": "읽고 보고만 하도록 만들어졌습니다. 여기서 선택해도 바뀌지 않습니다.", "binding": "읽기 전용 모드의 연결이 만들었습니다. 여기서 선택해도 바뀌지 않습니다." } + }, + "memorySettings": { + "titleOn": "메모리가 켜져 있습니다", + "titleOff": "메모리가 꺼져 있습니다", + "ledeOn": "채팅이 나에 대한 짧은 메모를 보관하고 답하기 전에 읽습니다. 끄면 어디서도 읽거나 저장하지 않습니다. 메모는 그대로 남습니다.", + "ledeOff": "어떤 채팅이나 에이전트에서도 메모를 읽거나 저장하지 않습니다. 기억해 달라고 하면 어시스턴트가 메모리가 꺼져 있다고 말합니다. 메모는 남아 있고, 계속 읽고 내보내고 삭제할 수 있습니다.", + "on": "켬", + "off": "끔", + "unavailable": "메모는 파일이며 이 서버에서는 파일이 꺼져 있어 메모리가 동작할 수 없습니다.", + "saveTitle": "메모 저장", + "saveLede": "어시스턴트가 무언가를 남기려 할 때 일어나는 일입니다. 가장 엄격한 규칙이 항상 우선합니다.", + "save": { + "ask": { + "title": "먼저 물어보기", + "body": "채팅에 카드가 대기합니다. 확인하거나 수정하거나 취소할 수 있습니다." + }, + "auto": { + "title": "저장하고 알려주기", + "body": "내 채팅에서는 메모가 바로 저장됩니다. 채팅에 \"메모리에 저장됨\"과 실행 취소가 표시됩니다. 답변당 최대 5개. 긴 메모나 비밀처럼 보이는 메모는 여전히 확인을 기다립니다." + }, + "never": { + "title": "안 함", + "body": "어시스턴트가 메모를 저장할 수 없습니다. 저장이 꺼져 있다고 알려줍니다." + } + }, + "default": "기본값", + "saveNote": "메시징 앱의 채팅은 항상 먼저 묻습니다. 백그라운드 실행과 트리거는 메모를 저장하지 않습니다. 저장된 에이전트는 여기서 고른 것보다 더 물어볼 수는 있어도 덜 묻지는 않습니다.", + "saveFailed": "선택을 저장하지 못했습니다.", + "loadError": "메모리 설정을 불러오지 못했습니다.", + "notesTitle": "메모 관리", + "notesLede": "읽고, 수정하고, 내보내고, 삭제할 수 있습니다. 메모리가 꺼져 있어도 동작합니다.", + "export": "내보내기" + }, + "memoryOff": { + "title": "여기서는 메모리가 꺼져 있습니다.", + "line": { + "chat": "비공개 채팅입니다. 아무것도 읽거나 저장하지 않으며 메모는 그대로입니다. 도구 메뉴에서 켤 수 있습니다.", + "user": "어떤 채팅이나 에이전트도 메모를 읽거나 저장하지 않으며 메모는 그대로입니다.", + "agent": "이 채팅의 에이전트는 메모리를 껐습니다.", + "files": "메모는 파일이며 이 서버에서는 파일이 꺼져 있습니다." + }, + "chip": "메모리 꺼짐", + "chipPrivate": "비공개 채팅", + "fixUser": "설정, 메모리", + "userRow": "설정에서 꺼져 있습니다. 설정, 메모리에서 켜세요." } } diff --git a/core/http/react-ui/public/locales/pt-BR/agents.json b/core/http/react-ui/public/locales/pt-BR/agents.json index 5c2dda36a6..4aeb379960 100644 --- a/core/http/react-ui/public/locales/pt-BR/agents.json +++ b/core/http/react-ui/public/locales/pt-BR/agents.json @@ -148,7 +148,9 @@ "triggers_run": "Deixar gatilhos começarem de aplicativos de mensagens", "talkback_channels": "Responder em canais conhecidos", "files_upload": "Receber arquivos de aplicativos de mensagens" - } + }, + "memoryOffUser": "A memória está desativada nas suas configurações, então este agente não lê nem salva notas, não importa o que você defina aqui.", + "memoryOffFix": "Configurações, Memória" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/pt-BR/chats.json b/core/http/react-ui/public/locales/pt-BR/chats.json index 94fdd9ee95..b422b5abf2 100644 --- a/core/http/react-ui/public/locales/pt-BR/chats.json +++ b/core/http/react-ui/public/locales/pt-BR/chats.json @@ -328,7 +328,8 @@ "media": "Imagens e áudio", "collections": "Conhecimento", "subagents": "Subagentes", - "changes": "Alterações sugeridas" + "changes": "Alterações sugeridas", + "memory": "Memória" }, "builtinBy": "{{tools}} · integradas", "builtinByAddon": "{{tools}} · servidas por {{provider}}", @@ -350,7 +351,8 @@ "subagents": "O modelo pode iniciar um subagente para uma tarefa e receber o relatório dele.", "saved_agents": "O modelo pode iniciar um dos seus agentes salvos como subagente.", "triggers": "O modelo pode propor, listar, alterar e excluir gatilhos. Você confirma cada novo gatilho ou alteração.", - "changes": "O modelo pode propor alterações nas suas notas, nas regras dele, em skills, páginas e no painel. Você confirma cada uma." + "changes": "O modelo pode propor alterações nas suas notas, nas regras dele, em skills, páginas e no painel. Você confirma cada uma.", + "memory": "O modelo pode ler suas notas antes de responder e salvar novas. Desative para tornar este um chat privado: nada é lido das suas notas nem salvo nelas. Seus outros chats não mudam." }, "whenOff": { "shell": "O modelo pode escrever arquivos, mas não pode executar nada.", @@ -361,7 +363,8 @@ "subagents": "O modelo trabalha sozinho.", "saved_agents": "O modelo não pode ver nem iniciar seus agentes salvos.", "triggers": "O modelo não pode propor nem gerenciar gatilhos.", - "changes": "O modelo não pode propor alterações nas suas notas, nas regras dele, em skills, páginas ou no painel." + "changes": "O modelo não pode propor alterações nas suas notas, nas regras dele, em skills, páginas ou no painel.", + "memory": "Este é um chat privado. O modelo não lê nem salva suas notas." }, "addonDescribe_one": "Dá ao modelo {{count}} ferramenta deste add-on.", "addonDescribe_other": "Dá ao modelo {{count}} ferramentas deste add-on.", @@ -817,7 +820,7 @@ "deleteYes": "Excluir", "confirmDelete": "Excluir {{name}}?", "deleted": "{{name}} movido para a Lixeira.", - "clear": "Limpar todas as notas", + "clear": "Esquecer tudo", "clearAsk_one": "Excluir {{count}} arquivo? Ele vai para a Lixeira e você pode restaurá-lo em Arquivos.", "clearAsk_other": "Excluir {{count}} arquivos? Eles vão para a Lixeira e você pode restaurá-los em Arquivos.", "clearYes_one": "Excluir {{count}} arquivo", @@ -846,5 +849,50 @@ "declared": "Foi criado para apenas ler e relatar. Nada que você marque aqui muda isso.", "binding": "Foi criado por uma conexão em modo somente leitura. Nada que você marque aqui muda isso." } + }, + "memorySettings": { + "titleOn": "A memória está ativada", + "titleOff": "A memória está desativada", + "ledeOn": "Seus chats guardam notas curtas sobre você e as leem antes de responder. Desative para parar de ler e salvar em qualquer lugar. Suas notas ficam onde estão.", + "ledeOff": "Nada é lido das suas notas nem salvo nelas, em nenhum chat ou agente. O assistente diz que a memória está desativada se você pedir para ele lembrar de algo. Suas notas ficam, e você ainda pode lê-las, exportá-las e excluí-las.", + "on": "Sim", + "off": "Não", + "unavailable": "As notas são arquivos, e os arquivos estão desativados neste servidor, então a memória não pode funcionar aqui.", + "saveTitle": "Salvar uma nota", + "saveLede": "O que acontece quando o assistente quer guardar algo. A regra mais rígida sempre vence.", + "save": { + "ask": { + "title": "Perguntar antes", + "body": "Um cartão espera no chat. Você confirma, edita ou descarta." + }, + "auto": { + "title": "Salvar e me avisar", + "body": "Nos seus próprios chats a nota é salva na hora. O chat mostra \"Salvo na memória\" com Desfazer. Até 5 notas por resposta. Uma nota longa, ou que parece um segredo, ainda espera por você." + }, + "never": { + "title": "Nunca", + "body": "O assistente não pode salvar notas. Ele diz que salvar está desativado." + } + }, + "default": "Padrão", + "saveNote": "Chats de apps de mensagens sempre perguntam antes. Execuções em segundo plano e gatilhos nunca salvam uma nota. Um agente salvo pode perguntar mais, nunca menos, do que você escolheu aqui.", + "saveFailed": "Não foi possível salvar sua escolha.", + "loadError": "Não foi possível carregar suas configurações de memória.", + "notesTitle": "Gerenciar suas notas", + "notesLede": "Leia, edite, exporte ou exclua. Funciona com a memória desativada.", + "export": "Exportar" + }, + "memoryOff": { + "title": "A memória está desativada aqui.", + "line": { + "chat": "Este é um chat privado: nada é lido nem salvo. Suas notas não são tocadas. Ative no menu de ferramentas.", + "user": "Nenhum chat ou agente lê ou salva notas, e suas notas não são tocadas.", + "agent": "O agente deste chat desativou a memória.", + "files": "As notas são arquivos, e os arquivos estão desativados neste servidor." + }, + "chip": "Memória off", + "chipPrivate": "Chat privado", + "fixUser": "Configurações, Memória", + "userRow": "Desativada nas suas configurações. Ative em Configurações, Memória." } } diff --git a/core/http/react-ui/public/locales/zh-CN/agents.json b/core/http/react-ui/public/locales/zh-CN/agents.json index 480793ea0e..a40657b8d6 100644 --- a/core/http/react-ui/public/locales/zh-CN/agents.json +++ b/core/http/react-ui/public/locales/zh-CN/agents.json @@ -148,7 +148,9 @@ "triggers_run": "允许触发器从消息应用启动", "talkback_channels": "在已知频道回复", "files_upload": "从消息应用接收文件" - } + }, + "memoryOffUser": "你的设置中记忆已关闭,因此无论这里如何设置,此智能体都不会读取或保存笔记。", + "memoryOffFix": "设置,记忆" }, "run": { "workingLine": "Working" diff --git a/core/http/react-ui/public/locales/zh-CN/chats.json b/core/http/react-ui/public/locales/zh-CN/chats.json index ee7800230d..f7f271c4d1 100644 --- a/core/http/react-ui/public/locales/zh-CN/chats.json +++ b/core/http/react-ui/public/locales/zh-CN/chats.json @@ -328,7 +328,8 @@ "media": "图片和音频", "collections": "知识库", "subagents": "子代理", - "changes": "建议的更改" + "changes": "建议的更改", + "memory": "记忆" }, "builtinBy": "{{tools}} · 内置", "builtinByAddon": "{{tools}} · 由 {{provider}} 提供", @@ -350,7 +351,8 @@ "subagents": "模型可以为一项任务启动子智能体并获取其报告。", "saved_agents": "模型可以把你已保存的智能体之一作为子智能体启动。", "triggers": "模型可以提议、列出、修改和删除触发器。每个新触发器或修改都由你确认。", - "changes": "模型可以提议修改你的笔记、它自己的规则、技能、页面和仪表板。每一项都由你确认。" + "changes": "模型可以提议修改你的笔记、它自己的规则、技能、页面和仪表板。每一项都由你确认。", + "memory": "模型可以在回答前读取你的笔记并保存新的笔记。关闭后此聊天为私密聊天:不读取也不保存你的笔记。你的其他聊天不受影响。" }, "whenOff": { "shell": "模型可以写文件,但不能运行任何东西。", @@ -361,7 +363,8 @@ "subagents": "模型独自工作。", "saved_agents": "模型看不到也无法启动你保存的智能体。", "triggers": "模型无法提议或管理触发器。", - "changes": "模型无法提议修改你的笔记、它自己的规则、技能、页面或仪表板。" + "changes": "模型无法提议修改你的笔记、它自己的规则、技能、页面或仪表板。", + "memory": "这是私密聊天。模型不会读取或保存你的笔记。" }, "addonDescribe_one": "向模型提供此附加组件的 {{count}} 个工具。", "addonDescribe_other": "向模型提供此附加组件的 {{count}} 个工具。", @@ -817,7 +820,7 @@ "deleteYes": "删除", "confirmDelete": "删除 {{name}}?", "deleted": "已将 {{name}} 移到回收站。", - "clear": "清除所有笔记", + "clear": "全部忘记", "clearAsk_one": "删除 {{count}} 个文件?文件会移到回收站,可在“文件”中恢复。", "clearAsk_other": "删除 {{count}} 个文件?文件会移到回收站,可在“文件”中恢复。", "clearYes_one": "删除 {{count}} 个文件", @@ -846,5 +849,50 @@ "declared": "它被设置为只读取并汇报。在此勾选不会改变这一点。", "binding": "它由只读模式的连接创建。在此勾选不会改变这一点。" } + }, + "memorySettings": { + "titleOn": "记忆已开启", + "titleOff": "记忆已关闭", + "ledeOn": "你的聊天会保留关于你的简短笔记,并在回答前读取。关闭后,任何地方都不再读取或保存。笔记保持原样。", + "ledeOff": "在任何聊天或智能体中,都不会读取或保存你的笔记。如果你让助手记住某事,它会说明记忆已关闭。笔记仍然保留,你仍可查看、导出和删除。", + "on": "开", + "off": "关", + "unavailable": "笔记是文件,而此服务器已关闭文件,因此记忆无法在这里工作。", + "saveTitle": "保存笔记", + "saveLede": "助手想要保留某些内容时会发生什么。始终以最严格的规则为准。", + "save": { + "ask": { + "title": "先问我", + "body": "聊天中会出现一张卡片。你可以确认、编辑或忽略。" + }, + "auto": { + "title": "保存并告知我", + "body": "在你自己的聊天中,笔记会立即保存。聊天会显示“已保存到记忆”和撤销。每次回复最多 5 条。过长或看起来像机密的笔记仍需你确认。" + }, + "never": { + "title": "从不", + "body": "助手不能保存笔记,并会告诉你保存已关闭。" + } + }, + "default": "默认", + "saveNote": "来自消息应用的聊天总是先询问。后台运行和触发器从不保存笔记。已保存的智能体可以比这里的选择问得更多,但不会更少。", + "saveFailed": "无法保存你的选择。", + "loadError": "无法加载你的记忆设置。", + "notesTitle": "管理你的笔记", + "notesLede": "可以查看、编辑、导出或删除。记忆关闭时同样可用。", + "export": "导出" + }, + "memoryOff": { + "title": "此处记忆已关闭。", + "line": { + "chat": "这是私密聊天:不读取也不保存任何内容,你的笔记不受影响。可在工具菜单中开启。", + "user": "没有任何聊天或智能体会读取或保存笔记,你的笔记不受影响。", + "agent": "此聊天的智能体已关闭记忆。", + "files": "笔记是文件,而此服务器已关闭文件。" + }, + "chip": "记忆已关", + "chipPrivate": "私密聊天", + "fixUser": "设置,记忆", + "userRow": "已在你的设置中关闭。请在“设置,记忆”中开启。" } } diff --git a/core/http/react-ui/src/components/MemorySettings.jsx b/core/http/react-ui/src/components/MemorySettings.jsx new file mode 100644 index 0000000000..123a75f4f3 --- /dev/null +++ b/core/http/react-ui/src/components/MemorySettings.jsx @@ -0,0 +1,85 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { useId } from 'react' +import { useTranslation } from 'react-i18next' +import { useMemorySettings } from '../hooks/useMemorySettings' +import Icon from './Icon' +import { Callout, LoadError, SkeletonRows, Switch } from './ui' +import './memorysettings.css' + +// The ways a note is saved, in the order they are listed. The value is what the +// server stores; the default is the one a person who never chose gets. +export const SAVE_LEVELS = [ + { value: 'ask', isDefault: true }, + { value: 'auto' }, + { value: 'never' }, +] + +// Settings > Memory, the controls: the On or Off switch first, then how a note +// is saved. The notes themselves (MemoryTab) sit below. The page is laid out in +// sections that share one card, so the provider choice, the rule for messaging +// apps and the scope of an agent can take their place later without a new +// page. +export default function MemorySettings({ addToast, children }) { + const { t } = useTranslation('chats') + const { settings, error, saving, reload, change } = useMemorySettings() + const titleId = useId() + const ledeId = useId() + const saveId = useId() + const notesId = useId() + + const notes = ( +
+
+

{t('memorySettings.notesTitle')}

+

{t('memorySettings.notesLede')}

+
+ {children} +
+ ) + if (error && !settings) return
{notes}
+ if (!settings) return
{notes}
+ + const on = settings.enabled + const store = async (body) => { + if (!(await change(body))) addToast?.(t('memorySettings.saveFailed'), 'error') + } + + return ( +
+
+
+

{on ? t('memorySettings.titleOn') : t('memorySettings.titleOff')}

+

{on ? t('memorySettings.ledeOn') : t('memorySettings.ledeOff')}

+ {!settings.available &&

{t('memorySettings.unavailable')}

} +
+
+ + store({ enabled: next })} /> + +
+
+ {error && {error.message}} + +
+

{t('memorySettings.saveTitle')}

+

{t('memorySettings.saveLede')}

+
+ {SAVE_LEVELS.map((lv) => { + const checked = settings.save_mode === lv.value + return ( + + ) + })} +
+

{' '}{t('memorySettings.saveNote')}

+
+ {notes} +
+ ) +} diff --git a/core/http/react-ui/src/components/MemoryTab.jsx b/core/http/react-ui/src/components/MemoryTab.jsx index c4f1b9e54e..e1ef269e34 100644 --- a/core/http/react-ui/src/components/MemoryTab.jsx +++ b/core/http/react-ui/src/components/MemoryTab.jsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useId, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' -import { chatChangesApi, filesApi } from '../utils/api' +import { chatChangesApi, chatMemoryApi, filesApi } from '../utils/api' +import { downloadFrom } from '../utils/chatThread' import { byteText } from '../utils/changeProposal' import { allFiles, groupId, groupKind, indexFits, indexUse } from '../utils/memoryFiles' import Icon from './Icon' @@ -120,7 +121,7 @@ function groupTitle(g, t) { // every source the host knows (the user's, each agent's, each connector // conversation's) and the agent profiles. The owner can read, edit and delete // them here; a model changes them only through a confirmed proposal. -export default function MemoryTab({ addToast }) { +export default function MemoryTab({ addToast, embedded = false }) { const { t } = useTranslation('chats') const [data, setData] = useState(null) const [error, setError] = useState(null) @@ -168,7 +169,7 @@ export default function MemoryTab({ addToast }) { const lim = data.limits || {} return (
-

{t('memoryPage.lede')}

+ {!embedded &&

{t('memoryPage.lede')}

}

{t('memoryPage.limits', { lines: lim.index_lines, index: byteText(lim.index_bytes), profile: byteText(lim.profile_bytes), turn: byteText(lim.turn_bytes) })}

@@ -192,6 +193,7 @@ export default function MemoryTab({ addToast }) { ))}
+ {clear ? ( b.group === MEMORY_GROUP)) // A chat whose agent has a folder reaches only that folder with its file tools. const scopeLine = agentFolderLine(pickedAgent, t) const tools = ( @@ -140,14 +143,15 @@ export default function ChatComposer({ icon="plug" label={t('tools.label')} title={t('tools.title')} heading={t('tools.heading')} note={chatId ? t('tools.note') : t('tools.draftNote')} groups={toolGroups} error={switches.error} testId="chat-tools" - notice={scopeLine || commands.info || network || switches.fallbacks.length > 0 - ? <>{scopeLine ?

{scopeLine}

: null}{commands.info ? : null}{network ? : null} + notice={scopeLine || commands.info || network || switches.fallbacks.length > 0 || memoryIsOff + ? <>{scopeLine ?

{scopeLine}

: null}{commands.info ? : null}{network ? : null} : null} footer={switches.connectors.length > 0 || switches.servers.length > 0 ? : null} /> + {toolsExtra} diff --git a/core/http/react-ui/src/components/chat/MemoryNotice.jsx b/core/http/react-ui/src/components/chat/MemoryNotice.jsx new file mode 100644 index 0000000000..cc6236bfd6 --- /dev/null +++ b/core/http/react-ui/src/components/chat/MemoryNotice.jsx @@ -0,0 +1,38 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import Icon from '../Icon' +import { memoryFix, memoryOff, MEMORY_GROUP } from '../../utils/memoryState' +import './memorynotice.css' + +// The one line that says memory is off here, and why, and where to turn it on. +// It sits at the top of the Tools menu while memory is off. +export function MemoryLine({ memory, builtins }) { + const { t } = useTranslation('chats') + const off = memoryOff(memory, (builtins || []).find((b) => b.group === MEMORY_GROUP)) + if (!off) return null + const fix = memoryFix(off) + return ( +

+ + + {t('memoryOff.title')}{' '}{t(`memoryOff.line.${off.kind}`)} + {fix ? <>{' '}{t(`memoryOff.${fix.key}`)} : null} + +

+ ) +} + +// A small pill next to the Tools menu of the composer: "Private chat" for a +// chat that switched memory off, "Memory off" for the levels above. +export function MemoryChip({ memory, builtins }) { + const { t } = useTranslation('chats') + const off = memoryOff(memory, (builtins || []).find((b) => b.group === MEMORY_GROUP)) + if (!off) return null + const text = off.kind === 'chat' ? t('memoryOff.chipPrivate') : t('memoryOff.chip') + return ( + + {text} + + ) +} diff --git a/core/http/react-ui/src/components/chat/memorynotice.css b/core/http/react-ui/src/components/chat/memorynotice.css new file mode 100644 index 0000000000..bd108a2cba --- /dev/null +++ b/core/http/react-ui/src/components/chat/memorynotice.css @@ -0,0 +1,4 @@ +/* The line and the chip that say memory is off in a chat. */ +.mem-line { display: flex; align-items: flex-start; gap: var(--sp-2); margin: 0; padding: var(--sp-2) var(--sp-3); border-radius: var(--dk-radius-2); background: var(--dk-inset); color: var(--dk-text); font-size: var(--text-xs); line-height: 1.4; } +.mem-line__icon { flex: none; margin-top: 2px; color: var(--dk-muted); } +.mem-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: var(--dk-radius-3); background: var(--dk-inset); box-shadow: inset 0 0 0 1px var(--dk-edge); color: var(--dk-text); font-size: var(--text-xs); font-weight: 600; white-space: nowrap; } diff --git a/core/http/react-ui/src/components/memorysettings.css b/core/http/react-ui/src/components/memorysettings.css new file mode 100644 index 0000000000..a39cb3ef4c --- /dev/null +++ b/core/http/react-ui/src/components/memorysettings.css @@ -0,0 +1,27 @@ +/* Settings > Memory: the switch and how a note is saved. */ +.ms-page { display: grid; gap: var(--sp-4); max-width: 720px; } +.ms-card { display: grid; gap: var(--sp-3); padding: var(--sp-4); border-radius: var(--dk-radius-2); background: var(--dk-card); box-shadow: inset 0 0 0 1px var(--dk-edge); } +.ms-h { margin: 0; font-size: var(--text-md, 15px); color: var(--dk-text); } +.ms-p { margin: 0; font-size: var(--text-sm); color: var(--dk-muted); max-width: 60ch; } +.ms-p--warn { color: var(--dk-warning-text, var(--dk-text)); } +.ms-p--note { display: flex; align-items: flex-start; gap: var(--sp-2); font-size: var(--text-xs); } +.ms-switch { grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--sp-4); } +.ms-switch--off { background: var(--dk-inset); } +.ms-switch__text { display: grid; gap: var(--sp-1); min-width: 0; } +.ms-switch__ctl { display: flex; align-items: center; gap: var(--sp-2); } +.ms-switch__word { font-size: var(--text-sm); font-weight: 600; color: var(--dk-text); } +.ms-levels { display: grid; gap: var(--sp-2); } +.ms-level { display: flex; align-items: flex-start; gap: var(--sp-3); padding: var(--sp-3); border-radius: var(--dk-radius-2); box-shadow: inset 0 0 0 1px var(--dk-edge); cursor: pointer; } +.ms-level--on { background: var(--dk-accent-wash); box-shadow: inset 0 0 0 1px var(--dk-accent); } +.ms-level input { margin-top: 3px; accent-color: var(--dk-accent); flex: none; } +.ms-level:focus-within { outline: 2px solid var(--focus-ring-color); outline-offset: 2px; } +.ms-level__text { display: grid; gap: 2px; min-width: 0; } +.ms-level__title { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); font-weight: 600; color: var(--dk-text); } +.ms-level__body { font-size: var(--text-sm); color: var(--dk-muted); overflow-wrap: anywhere; } +.ms-tag { padding: 0 6px; border-radius: var(--dk-radius-3); background: var(--dk-inset); color: var(--dk-muted); font-size: var(--text-xs); font-weight: 400; } +.ms-notes { display: grid; gap: var(--sp-3); } +.ms-notes-head { display: grid; gap: var(--sp-1); } +@media (max-width: 560px) { + .ms-switch { grid-template-columns: minmax(0, 1fr); } + .ms-switch__ctl { justify-self: start; } +} diff --git a/core/http/react-ui/src/components/memorytab.css b/core/http/react-ui/src/components/memorytab.css index fc26c0ba78..4f7bcd9404 100644 --- a/core/http/react-ui/src/components/memorytab.css +++ b/core/http/react-ui/src/components/memorytab.css @@ -19,7 +19,7 @@ .mt-panel { display: grid; gap: var(--sp-2); padding: 0 0 var(--sp-3); } .mt-text { margin: 0; padding: var(--sp-3); max-height: 260px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; border-radius: var(--dk-radius-2); background: var(--dk-inset); font-family: var(--font-mono); font-size: var(--text-xs); } .mt-edit { width: 100%; font-family: var(--font-mono); font-size: var(--text-xs); resize: vertical; } -.mt-foot { display: flex; } +.mt-foot { display: flex; flex-wrap: wrap; gap: var(--sp-2); } .mt-edit { height: auto; padding: var(--sp-2) var(--sp-3); } .mt-help { margin: var(--sp-1) 0 var(--sp-2); font-size: var(--text-xs); color: var(--dk-muted); } .mt-help--err { color: var(--dk-error-text, var(--dk-error)); } diff --git a/core/http/react-ui/src/hooks/useChatSwitches.js b/core/http/react-ui/src/hooks/useChatSwitches.js index 845e40c2a5..ef9f17f8ff 100644 --- a/core/http/react-ui/src/hooks/useChatSwitches.js +++ b/core/http/react-ui/src/hooks/useChatSwitches.js @@ -44,6 +44,9 @@ export function useChatSwitches(chatId, filesOn, draft = null, actorSession = '' const builtinsRef = useRef(builtins) // The built-in tools an installed add-on replaces but cannot serve here now. const [fallbacks, setFallbacks] = useState([]) + // Whether memory works in the chat and which level turned it off (the memory + // object of the tools list), or null before the list is read. + const [memory, setMemory] = useState(null) const [skills, setSkills] = useState([]) // The built-in skills an add-on replaces now: shown dimmed, with no switch. const [replaced, setReplaced] = useState([]) @@ -63,7 +66,7 @@ export function useChatSwitches(chatId, filesOn, draft = null, actorSession = '' useEffect(() => { setError('') setSkillsError('') - if (!chatId && !draftOn) { setAddons([]); setCommands(noCommands); setMcp(noMCP); setBuiltins([]); setFallbacks([]); setSkills([]); setReplaced([]); return undefined } + if (!chatId && !draftOn) { setAddons([]); setCommands(noCommands); setMcp(noMCP); setBuiltins([]); setFallbacks([]); setMemory(null); setSkills([]); setReplaced([]); return undefined } let live = true const epoch = ++commandEpoch.current setCommands(noCommands) @@ -80,8 +83,9 @@ export function useChatSwitches(chatId, filesOn, draft = null, actorSession = '' setMcp(draftOn ? { servers: got.servers, connectors: got.connectors, off: got.mcpOff } : m) setBuiltins(got.builtins) setFallbacks(res.fallbacks || []) + setMemory(res.memory || null) }) - .catch(() => { if (live) { setMcp(noMCP); setBuiltins([]); setFallbacks([]) } }) + .catch(() => { if (live) { setMcp(noMCP); setBuiltins([]); setFallbacks([]); setMemory(null) } }) return () => { live = false } }, [chatId, filesOn, draftOn, draftModel, draftAgent, actorSession]) @@ -252,6 +256,7 @@ export function useChatSwitches(chatId, filesOn, draft = null, actorSession = '' putBuiltins(res.builtins || []) putMcp(mcpOf(res)) setFallbacks(res.fallbacks || []) + setMemory(res.memory || null) } catch (e) { if (epoch !== commandEpoch.current) return setCommands(noCommands) @@ -309,7 +314,7 @@ export function useChatSwitches(chatId, filesOn, draft = null, actorSession = '' return { draftSwitches, resetDraft, draft: draftOn, commandsRev, - addons, builtins, fallbacks, commands: commands.context === context ? commands : noCommands, servers: mcp.servers, connectors: mcp.connectors, skills, replacedSkills: replaced, error, skillsError, skillsBusy: skillsBusy > 0, + addons, builtins, fallbacks, memory, commands: commands.context === context ? commands : noCommands, servers: mcp.servers, connectors: mcp.connectors, skills, replacedSkills: replaced, error, skillsError, skillsBusy: skillsBusy > 0, counts: toolsCounts({ builtins, addons, servers: mcp.servers, connectors: mcp.connectors }), toggleAddon, toggleBuiltin, toggleMCP, toggleSkill, setSkillsOn, setAddonsOn, setBuiltinsOn, setMCPOn, setConnections, } diff --git a/core/http/react-ui/src/hooks/useMemorySettings.js b/core/http/react-ui/src/hooks/useMemorySettings.js new file mode 100644 index 0000000000..f8b7011cae --- /dev/null +++ b/core/http/react-ui/src/hooks/useMemorySettings.js @@ -0,0 +1,39 @@ +import { useCallback, useEffect, useState } from 'react' +import { chatMemoryApi } from '../utils/api' + +// Your memory choices (GET and PUT /api/chats/memory): the switch for all +// chats and how a note is saved. A change goes to the server first and the +// screen shows what the server answers, so the page never shows a choice that +// was not stored. +export function useMemorySettings() { + const [settings, setSettings] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + + const load = useCallback(async () => { + try { + setSettings(await chatMemoryApi.get()) + setError(null) + } catch (e) { + setError(e) + } + }, []) + useEffect(() => { load() }, [load]) + + // Returns true when the change was stored. + const change = useCallback(async (body) => { + setSaving(true) + try { + setSettings(await chatMemoryApi.set(body)) + setError(null) + return true + } catch (e) { + setError(e) + return false + } finally { + setSaving(false) + } + }, []) + + return { settings, error, saving, reload: load, change } +} diff --git a/core/http/react-ui/src/hooks/useToolGroups.js b/core/http/react-ui/src/hooks/useToolGroups.js index b1302339c6..61ee97ca8f 100644 --- a/core/http/react-ui/src/hooks/useToolGroups.js +++ b/core/http/react-ui/src/hooks/useToolGroups.js @@ -25,7 +25,8 @@ import { GRANT_SECTIONS, connectionsMissing, grantKey, placeGrants } from '../ut export const SAVED_AGENTS = 'saved_agents' export const SHELL = 'shell' export const SUBAGENTS = 'subagents' -const BUILTIN_ORDER = [SHELL, 'files', 'web', 'media', 'collections', SUBAGENTS, SAVED_AGENTS, 'triggers', 'changes'] +export const MEMORY = 'memory' +const BUILTIN_ORDER = [SHELL, 'files', 'web', 'media', 'collections', MEMORY, SUBAGENTS, SAVED_AGENTS, 'triggers', 'changes'] export const builtinRank = (g) => { const i = BUILTIN_ORDER.indexOf(g) return i < 0 ? BUILTIN_ORDER.length : i @@ -72,6 +73,14 @@ export function useToolGroups(switches, { commandsInfo = null, commandState = tr return { chip, note: tc(`tools.commands.${unknownTextKey(selected.code)}`) } } + // The memory row: while the person's own switch is off the row cannot be + // used and says where to turn it on; a chat that switched it off is a + // private chat. + const memoryNote = (b) => { + if (b.usable !== false) return b.off ? { chip: tc('memoryOff.chipPrivate') } : {} + return { reason: tc('memoryOff.userRow') } + } + const builtinRow = (b) => ({ key: `b-${b.group}`, id: b.group, optIn: !!b.opt_in, label: tc(`tools.builtin.${b.group}`, { defaultValue: b.title || b.group }), @@ -79,7 +88,7 @@ export function useToolGroups(switches, { commandsInfo = null, commandState = tr whenOff: tc(`tools.whenOff.${b.group}`, { defaultValue: b.when_off || '' }), title: tc('tools.builtinBy', { tools: (b.tools || []).join(', ') }), on: !b.off, usable: b.usable, reason: b.reason, onToggle: (on) => switches.toggleBuiltin(b.group, on), - ...(b.group === SHELL ? shellNote(b) : builtinNote(b)), + ...(b.group === SHELL ? shellNote(b) : b.group === MEMORY ? memoryNote(b) : builtinNote(b)), }) // A built-in row with the options that refine it. const withOptions = (row) => { diff --git a/core/http/react-ui/src/pages/Account.jsx b/core/http/react-ui/src/pages/Account.jsx index 8727eeb965..0a5210e5fc 100644 --- a/core/http/react-ui/src/pages/Account.jsx +++ b/core/http/react-ui/src/pages/Account.jsx @@ -10,6 +10,7 @@ import { Button, Callout, EmptyState, LoadError, PageHeader, SkeletonRows, useLo import ApiKeysPanel from '../components/users/ApiKeysPanel' import { AboutYouTab, AppearanceTab } from '../components/wellknown/SettingsTabs' import MemoryTab from '../components/MemoryTab' +import MemorySettings from '../components/MemorySettings' import './access.css' const TABS = [ @@ -300,7 +301,7 @@ export default function Account() { {tab === 'keys' && } {tab === 'usage' && } {tab === 'about' && } - {tab === 'memory' && } + {tab === 'memory' && } {tab === 'appearance' && }
diff --git a/core/http/react-ui/src/pages/AgentCreate.jsx b/core/http/react-ui/src/pages/AgentCreate.jsx index d5ffbd675b..c79d1ad813 100644 --- a/core/http/react-ui/src/pages/AgentCreate.jsx +++ b/core/http/react-ui/src/pages/AgentCreate.jsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next' import { agentCollectionsApi, chatAgentsApi, modelMCPServersApi } from '../utils/api' import { useAgentTools } from '../hooks/useAgentTools' import { useToolGroups } from '../hooks/useToolGroups' +import { useMemorySettings } from '../hooks/useMemorySettings' import { AGENT_TEMPLATES, diffConfig, displayValue, maskSecrets, summarise } from '../utils/agentConfigTools' import { agentPayload, effectiveModes, emptyAgent, folderRows, folderState, foldersUnchosen, MAX_FOLDERS, missingConnectorGrants, setGrant } from '../utils/chatAgents' import { downloadAgentFile } from '../utils/agentFiles' @@ -131,6 +132,8 @@ export default function AgentCreate() { // The Tools and Skills lists are the ones the Tools menu of a chat draws // (SwitchPanel), with the agent's grants and switches applied to them. const tools = useAgentTools(form, updateField) + // The person's own memory switch, read only here: an agent cannot widen it. + const memoryUser = useMemorySettings() const toolGroups = useToolGroups(tools, { commandState: false, forAgent: true }) // The definition exactly as it is saved. The preview shows this object and @@ -332,6 +335,11 @@ export default function AgentCreate() { return ( <>

{t('create.toolsHelp')}

+ {memoryUser.settings && memoryUser.settings.enabled === false && ( + + {t('create.memoryOffUser')}{' '}{t('create.memoryOffFix')} + + )} {missingGrants.length > 0 && ( fetchJSON('/api/chats/context-files'), } +// Your memory choices: the On or Off switch for all chats, and how a note is +// saved. exportUrl is the address that downloads the notes as a zip file. +export const chatMemoryApi = { + get: () => fetchJSON('/api/chats/memory'), + set: (body) => fetchJSON('/api/chats/memory', { method: 'PUT', body: JSON.stringify(body) }), + exportUrl: (scope, key) => apiUrl(`/api/chats/memory/export${q({ scope, key })}`), +} + // The inbox: what ended while the user was elsewhere. export const chatNotificationsApi = { list: (unread, limit = 50) => fetchJSON(`/api/chats/notifications${q({ unread: unread ? 1 : undefined, limit })}`), diff --git a/core/http/react-ui/src/utils/memoryState.js b/core/http/react-ui/src/utils/memoryState.js new file mode 100644 index 0000000000..01e6489e54 --- /dev/null +++ b/core/http/react-ui/src/utils/memoryState.js @@ -0,0 +1,25 @@ +// Whether memory works in a chat, for the screen. The server says it in the +// memory object of the tools list ({ on, code, blocked_by, fix }); the memory +// group of the list is the chat's own switch, which a draft changes before the +// server hears of it. The narrowest level that is off wins, as on the server: +// this chat, then the agent, then the person's switch, then the server. + +export const MEMORY_GROUP = 'memory' + +// null while memory works, else { kind, by } with kind one of chat, agent, +// user or files. +export function memoryOff(memory, group) { + if (group?.off) return { kind: 'chat', by: 'chat' } + if (!memory || memory.on !== false) return null + const kind = { memory_off_chat: 'chat', memory_off_agent: 'agent', memory_off_user: 'user', memory_off_files: 'files' }[memory.code] || 'user' + return { kind, by: memory.blocked_by || kind } +} + +// Where to turn memory on again: a link for the levels the person can reach +// from the page that shows the line, or none (the chat's own switch is in the +// Tools menu right there). +export function memoryFix(off) { + if (!off) return null + if (off.kind === 'user') return { to: '/app/account?tab=memory', key: 'fixUser' } + return null +} diff --git a/core/http/react-ui/tests/unit/composer-render.test.mjs b/core/http/react-ui/tests/unit/composer-render.test.mjs index 161c3582cf..e721a3b0ae 100644 --- a/core/http/react-ui/tests/unit/composer-render.test.mjs +++ b/core/http/react-ui/tests/unit/composer-render.test.mjs @@ -23,6 +23,7 @@ const scope = { HomeComposer: props => { captured = props; return React.createElement('textarea', { defaultValue: props.message }) }, useToolGroups: () => [], useChatGrants: () => null, SAVED_AGENTS: 'saved_agents', SUBAGENTS: 'subagents', CommandsChip: leaf, CommandsNotice: leaf, ToolFallbacks: leaf, ChatToolsControls: leaf, + MemoryChip: leaf, MemoryLine: leaf, memoryOff: () => null, MEMORY_GROUP: 'memory', } const compiled = ts.transpileModule(source.replace(/^import .*\n/gm, '').replace('export default function', 'function'), { compilerOptions: { jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, fileName: 'ChatComposer.jsx', diff --git a/core/http/react-ui/tests/unit/memorystate.test.mjs b/core/http/react-ui/tests/unit/memorystate.test.mjs new file mode 100644 index 0000000000..b2d4ec0a0a --- /dev/null +++ b/core/http/react-ui/tests/unit/memorystate.test.mjs @@ -0,0 +1,27 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { memoryFix, memoryOff } from '../../src/utils/memoryState.js' + +test('memory state: memory that works is not off', () => { + assert.equal(memoryOff({ on: true }, { group: 'memory', off: false }), null) + assert.equal(memoryOff(null, undefined), null) +}) + +test('memory state: a chat that switched the group off is a private chat, whatever else says', () => { + assert.deepEqual(memoryOff({ on: true }, { group: 'memory', off: true }), { kind: 'chat', by: 'chat' }) + assert.deepEqual(memoryOff({ on: false, code: 'memory_off_user', blocked_by: 'user' }, { group: 'memory', off: true }), { kind: 'chat', by: 'chat' }) +}) + +test('memory state: the level the server names is the one shown', () => { + assert.equal(memoryOff({ on: false, code: 'memory_off_user', blocked_by: 'user' }, undefined).kind, 'user') + assert.equal(memoryOff({ on: false, code: 'memory_off_agent', blocked_by: 'agent' }, undefined).kind, 'agent') + assert.equal(memoryOff({ on: false, code: 'memory_off_files', blocked_by: 'server' }, undefined).kind, 'files') + assert.equal(memoryOff({ on: false, code: 'memory_off_chat', blocked_by: 'chat' }, undefined).kind, 'chat') + assert.equal(memoryOff({ on: false, code: 'something_new' }, undefined).kind, 'user', 'an unknown code reads as the safe, widest level') +}) + +test('memory state: only the level above the chat has a link', () => { + assert.deepEqual(memoryFix({ kind: 'user' }), { to: '/app/account?tab=memory', key: 'fixUser' }) + assert.equal(memoryFix({ kind: 'chat' }), null) + assert.equal(memoryFix(null), null) +}) diff --git a/core/http/routes/studio.go b/core/http/routes/studio.go index f1e67e6086..1fb4479cbe 100644 --- a/core/http/routes/studio.go +++ b/core/http/routes/studio.go @@ -181,6 +181,9 @@ func RegisterStudioRoutes(e *echo.Echo, svc *studiosvc.Service, models studioend // files are the chats' workspace. e.GET("/api/workspace/events", h.WorkspaceEvents) e.GET(base+"/context-files", h.ListContextFiles) + e.GET(base+"/memory", h.GetMemory) + e.PUT(base+"/memory", h.PutMemory) + e.GET(base+"/memory/export", h.ExportMemory) e.GET(base+"/changes", h.ListChanges) e.GET(base+"/changes/audit", h.ChangeAudit) e.GET(base+"/changes/:id", h.GetChange) diff --git a/core/schema/chats.go b/core/schema/chats.go index 316e6867bb..74e178bfa0 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -1900,6 +1900,58 @@ type ChatsToolsPanel struct { // that no add-on serves in this chat now: the add-on is stopped, off for // the chat, not granted by its agent, or cannot run. Fallbacks []ChatsToolFallback `json:"fallbacks,omitempty"` + // Memory says whether memory works in this chat and, when it does not, + // which level turned it off and where to turn it on again. + Memory ChatsMemoryState `json:"memory"` +} + +// ChatsMemoryState is whether memory works in one chat. The narrowest level +// that is off wins: this chat (a private chat), the agent of the chat, your +// memory switch, then the server. +type ChatsMemoryState struct { + // On is true when the chat reads and may save notes. + On bool `json:"on" example:"false"` + // Code names the level that is off: memory_off_chat, memory_off_agent, + // memory_off_user or memory_off_files. Absent when on. + Code string `json:"code,omitempty" example:"memory_off_chat"` + // BlockedBy is who holds that switch: chat, agent, user or server. + BlockedBy string `json:"blocked_by,omitempty" example:"chat"` + // Reason is an English sentence for logs; the interface words it by Code. + Reason string `json:"reason,omitempty"` + // Fix says where to turn memory on again: kind open_tools_menu, + // open_memory_settings, open_agent or open_server_setting. + Fix *ChatsMemoryFix `json:"fix,omitempty"` +} + +// ChatsMemoryFix points at the one place that turns memory on again. +type ChatsMemoryFix struct { + Kind string `json:"kind" example:"open_memory_settings"` + Target string `json:"target" example:"memory"` + Field string `json:"field,omitempty"` +} + +// ChatsMemorySettings is your memory choices. +type ChatsMemorySettings struct { + // Enabled is memory On or Off for all your chats and agents. + Enabled bool `json:"enabled" example:"true"` + // EnabledSet is true once you chose. Before that memory is on, unless you + // had disabled the Memory add-on to stop it. + EnabledSet bool `json:"enabled_set" example:"false"` + // SaveMode is how a note is saved: ask, auto or never. + SaveMode string `json:"save_mode" example:"ask"` + // SaveModeSet is true once you chose in this setting. + SaveModeSet bool `json:"save_mode_set" example:"false"` + // Provider is what serves memory: builtin. + Provider string `json:"provider" example:"builtin"` + // Available is false when the server has no files, which notes need. + Available bool `json:"available" example:"true"` +} + +// ChatsMemoryRequest changes your memory choices. A field left out stays as +// it is. +type ChatsMemoryRequest struct { + Enabled *bool `json:"enabled,omitempty" example:"false"` + SaveMode *string `json:"save_mode,omitempty" example:"ask"` } // ChatsToolFallback is one built-in tool whose replacing add-on cannot serve diff --git a/core/services/studio/groupinfo.go b/core/services/studio/groupinfo.go index c9fd8a35f8..b58f4f335e 100644 --- a/core/services/studio/groupinfo.go +++ b/core/services/studio/groupinfo.go @@ -54,6 +54,11 @@ func (s *Service) describeGroups() { "A new trigger or a change waits on a card in the chat until you confirm it; pausing or resuming a trigger applies at once and you can undo it.", WhenOff: "The model cannot create or manage triggers. A run that a trigger or a connector started never has them.", }, + builtins.GroupMemory: { + Title: "Memory", + Description: "The model can read your notes before it answers and save new ones. Turn it off to make this a private chat: nothing is read from your notes or saved to them. Your other chats are not affected.", + WhenOff: "This is a private chat. The model neither reads nor saves your notes.", + }, builtins.GroupChanges: { Title: "Suggested changes", Description: "The model can propose an edit to your notes, its standing rules, skills, pages and dashboard, and undo an edit you confirmed. " + diff --git a/core/services/studio/mcpconnectors.go b/core/services/studio/mcpconnectors.go index b2b31800a0..7faafb1419 100644 --- a/core/services/studio/mcpconnectors.go +++ b/core/services/studio/mcpconnectors.go @@ -110,6 +110,9 @@ type ToolsPanel struct { // Fallbacks are the built-in tools an installed add-on replaces but no // add-on serves in this chat now. Fallbacks []ToolFallback + // Memory says whether memory works in this chat and, when it does not, + // which level turned it off and where to turn it on. + Memory MemoryReason } // Tools lists what a chat can use, with its switches. @@ -135,7 +138,7 @@ func (s *Service) toolsFor(ctx context.Context, user string, rec session.ChatRec return ToolsPanel{}, err } } - p := ToolsPanel{Off: append([]string{}, rec.McpOff...), Builtins: s.builtinGroups(ctx, rec, shadowing)} + p := ToolsPanel{Off: append([]string{}, rec.McpOff...), Builtins: s.builtinGroups(ctx, rec, shadowing), Memory: s.ChatMemory(ctx, rec)} if s.d.AddonTools != nil && (sc == nil || !sc.deleted) { p.Fallbacks, err = s.toolFallbacks(ctx, rec, shadowing, s.shadowersFor(ctx, principal(user))) if err != nil { diff --git a/core/services/studio/memory.go b/core/services/studio/memory.go index a0acce410f..eb23c1b5ec 100644 --- a/core/services/studio/memory.go +++ b/core/services/studio/memory.go @@ -121,14 +121,19 @@ func memoryOff(code, by string) MemoryState { return MemoryState{Code: code, By: // is the Memory add-on, for the person who never chose. func (s *Service) memoryState(ctx context.Context, rec session.ChatRecord, legacy legacyMemory) MemoryState { switch { - case !s.d.Files.Available(): - return memoryOff(MemoryOffFiles, "server") + case slices.Contains(rec.BuiltinsOff, builtins.GroupMemory): + // A private chat. + return memoryOff(MemoryOffChat, "chat") case slices.Contains(rec.AddonsOff, MemoryAddonID): // The chat switched the Memory add-on off: the same switch, in the // words it had before memory was built in. return memoryOff(MemoryOffChat, "chat") + case slices.Contains(s.agentOff(ctx, rec), builtins.GroupMemory): + return memoryOff(MemoryOffAgent, "agent") case !s.memoryUserOn(ctx, rec.Principal, legacy): return memoryOff(MemoryOffUser, "user") + case !s.d.Files.Available(): + return memoryOff(MemoryOffFiles, "server") } return MemoryState{On: true} } @@ -203,3 +208,90 @@ func (s *Service) memoryWritable(ctx context.Context, c builtins.Chat) bool { legacy := legacyMemoryOf(s.addonContextSets(ctx, c.Actor.Principal)) return s.memoryState(ctx, rec, legacy).On && !slices.Contains(c.GroupsOff, builtins.GroupMemory) } + +// memoryOffLine is the one line the model reads while memory is off for a +// chat: it hears that nothing is read or saved and what to say when asked. +func memoryOffLine(st MemoryState) string { + where := "in the user's settings" + switch st.By { + case "chat": + where = "for this chat, which is private" + case "agent": + where = "for this agent" + } + return "Memory is off " + where + ", so notes are neither read nor saved. If asked to remember something, say that memory is off and that the user can turn it on again; do not try to save it any other way." +} + +// MemoryReason is a memory state worded for a person, with where to fix it. +type MemoryReason struct { + On bool `json:"on"` + // Code is a stable name of the level that is off: memory_off_chat, + // memory_off_agent, memory_off_user or memory_off_files. Empty when on. + Code string `json:"code,omitempty"` + // By is who holds that switch: chat, agent, user or server. + By string `json:"blocked_by,omitempty"` + // Reason is an English sentence for logs and tests. + Reason string `json:"reason,omitempty"` + // Fix says where to turn it on again. + Fix *Fix `json:"fix,omitempty"` +} + +// The fixes of a memory switch that is off. +const ( + // FixOpenMemorySettings points at the Memory tab of the account settings. + FixOpenMemorySettings = "open_memory_settings" + // FixOpenToolsMenu points at the Tools menu of the chat. + FixOpenToolsMenu = "open_tools_menu" +) + +// memoryReason words a state. +func memoryReason(st MemoryState, rec session.ChatRecord) MemoryReason { + out := MemoryReason{On: st.On, Code: st.Code, By: st.By} + switch st.Code { + case MemoryOffChat: + out.Reason = "Memory is off in this chat: it is a private chat." + out.Fix = &Fix{Kind: FixOpenToolsMenu, Target: "memory"} + case MemoryOffAgent: + out.Reason = "Memory is off for the agent of this chat." + out.Fix = &Fix{Kind: FixOpenAgent, Target: rec.AgentID, Field: "tools:" + builtins.GroupMemory} + case MemoryOffUser: + out.Reason = "Memory is off in your settings." + out.Fix = &Fix{Kind: FixOpenMemorySettings, Target: "memory"} + case MemoryOffFiles: + out.Reason = "Memory needs files, and files are off on this server." + out.Fix = &Fix{Kind: FixOpenServerSetting, Target: "files"} + } + return out +} + +// ChatMemory says whether memory works in a chat and, when it does not, which +// level turned it off. +func (s *Service) ChatMemory(ctx context.Context, rec session.ChatRecord) MemoryReason { + legacy := legacyMemoryOf(s.addonContextSets(ctx, rec.Principal)) + return memoryReason(s.memoryState(ctx, rec, legacy), rec) +} + +// registerMemoryGroup puts memory in the Tools menu as one switch group. The +// group has no tool of its own to build: the host reads the switch when it +// builds the prompt, so a private chat has no notes in it and no context_read. +// Build only says whether the switch can work now, and why not. +func (s *Service) registerMemoryGroup() error { + if !s.d.Files.Available() { + return nil // notes are files: without them there is no switch to show + } + return s.builtins.Register(builtins.Tool{ + ID: contextfiles.ToolName, Group: builtins.GroupMemory, Label: "Memory", External: true, Unlisted: true, + Description: "Read the notes of the user's memory.", + Build: func(ctx context.Context, c builtins.Chat) (session.Tool, bool, string) { + switch { + case !s.d.Files.Available(): + return session.Tool{}, false, "memory needs files, and files are off on this server" + case c.SubAgent: + return session.Tool{}, false, "a sub-agent follows the memory of its parent chat" + case !s.memoryUserOn(ctx, c.Actor.Principal, legacyMemoryOf(s.addonContextSets(ctx, c.Actor.Principal))): + return session.Tool{}, false, "memory is off in your settings" + } + return session.Tool{}, true, "" + }, + }) +} diff --git a/core/services/studio/memory_internal_test.go b/core/services/studio/memory_internal_test.go index 196c97d254..75c3d8cda3 100644 --- a/core/services/studio/memory_internal_test.go +++ b/core/services/studio/memory_internal_test.go @@ -1,6 +1,8 @@ package studio import ( + "archive/zip" + "bytes" "context" "encoding/json" "path/filepath" @@ -141,6 +143,12 @@ var _ = Describe("memory served by the host", func() { ag, _ := agents.NewJSON("") br = &memBroker{} svc, err = New(Deps{Store: st, Provider: provider.New(), StateDir: GinkgoT().TempDir(), Files: f, Agents: ag, AddonTools: br, + SetUserSetting: func(_ context.Context, _, addon, key, value string) error { + smu.Lock() + defer smu.Unlock() + stored[addon+"/"+key] = value + return nil + }, UserSetting: func(_ context.Context, _, addon, key string) (string, bool, error) { smu.Lock() defer smu.Unlock() @@ -268,4 +276,173 @@ var _ = Describe("memory served by the host", func() { Expect(mode()).To(Equal(SaveNever), "an unknown value is not a choice") }) }) + + Describe("Off and private chats", func() { + hasTool := func(tt session.TurnTools, name string) bool { + for _, t := range tt.Tools { + if t.Name == name { + return true + } + } + return false + } + offLine := "Memory is off" + expectOff := func(tt session.TurnTools, why string) { + GinkgoHelper() + Expect(tt.Prompt).NotTo(ContainSubstring("lives in Rome"), why) + Expect(tt.Prompt).NotTo(ContainSubstring(contextfiles.Header), why) + Expect(tt.Prompt).To(ContainSubstring(offLine), why) + Expect(strings.Count(tt.Prompt, offLine)).To(Equal(1), why) + Expect(hasTool(tt, contextfiles.ToolName)).To(BeFalse(), why) + for _, t := range tt.Tools { + if t.Name == "propose_change" { + // The other kinds of change stay; the memory kind goes. + Expect(string(t.Schema)).NotTo(ContainSubstring(`"memory"`), why+": no note is proposed") + } + } + } + + It("offers notes and the save tool while memory is on", func() { + tt := turn(CreateInput{Model: "m"}) + Expect(tt.Prompt).To(ContainSubstring("lives in Rome")) + Expect(tt.Prompt).NotTo(ContainSubstring(offLine)) + var schema string + for _, t := range tt.Tools { + if t.Name == "propose_change" { + schema = string(t.Schema) + } + } + Expect(schema).To(ContainSubstring(`"memory"`)) + }) + + It("turns everything off for a private chat, and only that chat", func() { + expectOff(turn(CreateInput{Model: "m", Switches: Switches{BuiltinsOff: []string{"memory"}}}), "private chat") + Expect(turn(CreateInput{Model: "m"}).Prompt).To(ContainSubstring("lives in Rome")) + }) + + It("turns everything off for a person who switched memory off, whatever else says", func() { + setting(HostSettingsID, MemoryEnabledKey, MemoryOff) + expectOff(turn(CreateInput{Model: "m"}), "user off") + agent, err := svc.SaveAgentWithFolder(ctx, "alice", agents.Agent{Name: "Granted", Model: "m", Addons: []string{"memory"}, + Builtins: []string{contract.CapMemoryWrite}}, nil) + Expect(err).NotTo(HaveOccurred()) + expectOff(turn(CreateInput{Model: "m", AgentID: agent.ID}), "a saved agent cannot widen the choice of the person") + // Nothing in the chat can widen it either. + expectOff(turn(CreateInput{Model: "m", Switches: Switches{AddonsOff: nil, BuiltinsOff: nil}}), "chat switches only narrow") + }) + + It("honours it in a run that started by itself", func() { + setting(HostSettingsID, MemoryEnabledKey, MemoryOff) + rec, err := svc.CreateAs(ctx, "alice", CreateInput{Model: "m"}, Origin{Kind: session.OriginTrigger, ID: "t1"}) + Expect(err).NotTo(HaveOccurred()) + x, err := svc.ChatAccess(ctx, "alice", rec.ID) + Expect(err).NotTo(HaveOccurred()) + tt, err := svc.chatTools(ctx, x) + Expect(err).NotTo(HaveOccurred()) + Expect(tt.Prompt).NotTo(ContainSubstring("lives in Rome")) + Expect(tt.Prompt).To(ContainSubstring(offLine)) + }) + + It("honours the memory switch of the agent", func() { + agent, err := svc.SaveAgentWithFolder(ctx, "alice", agents.Agent{Name: "Quiet", Model: "m", Addons: []string{"memory"}, + Builtins: []string{contract.CapMemoryWrite}, BuiltinsOff: []string{"memory"}}, nil) + Expect(err).NotTo(HaveOccurred()) + tt := turn(CreateInput{Model: "m", AgentID: agent.ID}) + Expect(tt.Prompt).NotTo(ContainSubstring("lives in Rome")) + Expect(tt.Prompt).To(ContainSubstring("for this agent")) + }) + + It("takes the two memory skills away while memory is off", func() { + rec, err := svc.CreateAs(ctx, "alice", CreateInput{Model: "m", Switches: Switches{BuiltinsOff: []string{"memory"}}}, Origin{}) + Expect(err).NotTo(HaveOccurred()) + list, _, err := svc.Skills(ctx, "alice", rec.ID) + Expect(err).NotTo(HaveOccurred()) + seen := 0 + for _, sk := range list { + if sk.Name == "memory" || sk.Name == "memory-check-own-files" { + seen++ + Expect(sk.Off).To(BeTrue(), sk.Name) + } + } + Expect(seen).To(Equal(2)) + }) + + It("says which level is off, and where to turn it on", func() { + rec, err := svc.CreateAs(ctx, "alice", CreateInput{Model: "m", Switches: Switches{BuiltinsOff: []string{"memory"}}}, Origin{}) + Expect(err).NotTo(HaveOccurred()) + r := svc.ChatMemory(ctx, rec) + Expect(r.On).To(BeFalse()) + Expect(r.Code).To(Equal(MemoryOffChat)) + Expect(r.By).To(Equal("chat")) + Expect(r.Fix).To(Equal(&Fix{Kind: FixOpenToolsMenu, Target: "memory"})) + setting(HostSettingsID, MemoryEnabledKey, MemoryOff) + plain, err := svc.CreateAs(ctx, "alice", CreateInput{Model: "m"}, Origin{}) + Expect(err).NotTo(HaveOccurred()) + r = svc.ChatMemory(ctx, plain) + Expect(r.Code).To(Equal(MemoryOffUser)) + Expect(r.Fix.Kind).To(Equal(FixOpenMemorySettings)) + Expect(svc.ChatMemory(ctx, rec).Code).To(Equal(MemoryOffChat), "the narrowest level is the one named") + }) + + It("lists the memory group in the Tools menu, off for a private chat and unusable when the person switched it off", func() { + panel, err := svc.DraftTools(ctx, "alice", "m", "") + Expect(err).NotTo(HaveOccurred()) + var got *BuiltinGroup + for i := range panel.Builtins { + if panel.Builtins[i].Group == "memory" { + got = &panel.Builtins[i] + } + } + Expect(got).NotTo(BeNil()) + Expect(got.Off).To(BeFalse()) + Expect(got.Usable).To(BeTrue()) + Expect(got.Title).To(Equal("Memory")) + setting(HostSettingsID, MemoryEnabledKey, MemoryOff) + panel, err = svc.DraftTools(ctx, "alice", "m", "") + Expect(err).NotTo(HaveOccurred()) + for _, g := range panel.Builtins { + if g.Group == "memory" { + Expect(g.Usable).To(BeFalse()) + Expect(g.Reason).To(ContainSubstring("off in your settings")) + } + } + Expect(panel.Memory.Code).To(Equal(MemoryOffUser)) + }) + + It("keeps the notes viewable and exportable while memory is off", func() { + setting(HostSettingsID, MemoryEnabledKey, MemoryOff) + list, err := svc.ListContextFiles(ctx, "alice") + Expect(err).NotTo(HaveOccurred()) + Expect(list.Groups).NotTo(BeEmpty()) + var buf bytes.Buffer + n, err := svc.ExportMemory(ctx, "alice", &buf, "", "") + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(Equal(2)) + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + Expect(err).NotTo(HaveOccurred()) + var names []string + for _, f := range zr.File { + names = append(names, f.Name) + } + Expect(names).To(ConsistOf("Memory/index.md", "Memory/home.md")) + _, err = svc.ExportMemory(ctx, "alice", &bytes.Buffer{}, "agent", "nobody") + Expect(err).To(MatchError(ErrNoNotes)) + }) + + It("stores the switch and the save level with the person's settings", func() { + m, err := svc.MemorySettings(ctx, "alice") + Expect(err).NotTo(HaveOccurred()) + Expect(m).To(Equal(MemorySettings{Enabled: true, SaveMode: SaveAsk, Provider: "builtin", Available: true})) + off, auto := false, SaveAuto + m, err = svc.SetMemory(ctx, "alice", MemoryUpdate{Enabled: &off, SaveMode: &auto}) + Expect(err).NotTo(HaveOccurred()) + Expect(m.Enabled).To(BeFalse()) + Expect(m.EnabledSet).To(BeTrue()) + Expect(m.SaveMode).To(Equal(SaveAuto)) + Expect(m.SaveModeSet).To(BeTrue()) + bad := "always" + _, err = svc.SetMemory(ctx, "alice", MemoryUpdate{SaveMode: &bad}) + Expect(err).To(MatchError(ErrInvalidSettings)) + }) + }) }) diff --git a/core/services/studio/memorysettings.go b/core/services/studio/memorysettings.go new file mode 100644 index 0000000000..988bd20a1e --- /dev/null +++ b/core/services/studio/memorysettings.go @@ -0,0 +1,137 @@ +package studio + +import ( + "archive/zip" + "context" + "errors" + "fmt" + "io" + "path" + "strings" + + "github.com/mudler/LocalAI/core/services/studio/contextfiles" +) + +// ErrMemorySettings means the memory settings cannot be stored. +var ErrMemorySettings = errors.New("studio: the memory settings cannot be stored on this server") + +// MemorySettings is the person's own memory choices, as the Memory tab shows +// them. The server keeps them with the person's other settings and every +// frontend reads the same answer. +type MemorySettings struct { + // Enabled is the switch in force: memory On or Off for every chat of the + // person. A chat, a messaging connection or an agent can only narrow it. + Enabled bool `json:"enabled"` + // EnabledSet is true once the person chose. Before that the switch is on, + // unless they had disabled the Memory add-on to stop memory. + EnabledSet bool `json:"enabled_set"` + // SaveMode is how a note is saved: ask, auto or never. + SaveMode string `json:"save_mode"` + // SaveModeSet is true once the person chose in this setting. Before that + // the value they gave on the page of the Memory add-on, if any, applies. + SaveModeSet bool `json:"save_mode_set"` + // Provider says what serves memory: "builtin". + Provider string `json:"provider"` + // Available is false when the server has no files: notes are files. + Available bool `json:"available"` +} + +// MemoryUpdate is a change to the settings. A nil field is left alone. +type MemoryUpdate struct { + Enabled *bool + SaveMode *string +} + +// MemorySettings reads the memory settings of a person. +func (s *Service) MemorySettings(ctx context.Context, user string) (MemorySettings, error) { + legacy := legacyMemoryOf(s.addonContextSets(ctx, user)) + out := MemorySettings{Provider: s.memory.ID(), Available: s.d.Files.Available(), SaveMode: SaveAsk} + out.Enabled = s.memoryUserOn(ctx, user, legacy) + if s.d.UserSetting != nil { + if v, ok, err := s.d.UserSetting(ctx, principal(user), HostSettingsID, MemoryEnabledKey); err == nil && ok && (v == MemoryOn || v == MemoryOff) { + out.EnabledSet = true + } + if v, ok, err := s.d.UserSetting(ctx, principal(user), HostSettingsID, MemorySaveKey); err == nil && ok && ValidSaveMode(v) { + out.SaveModeSet = true + } + } + out.SaveMode = s.changes.storedSaveMode(ctx, user) + return out, nil +} + +// SetMemory stores the choices of a person. A value that is not one of the +// allowed ones is ErrInvalidSettings. +func (s *Service) SetMemory(ctx context.Context, user string, in MemoryUpdate) (MemorySettings, error) { + if in.SaveMode != nil && !ValidSaveMode(*in.SaveMode) { + return MemorySettings{}, fmt.Errorf("%w: save_mode must be ask, auto or never", ErrInvalidSettings) + } + if (in.Enabled != nil || in.SaveMode != nil) && s.d.SetUserSetting == nil { + return MemorySettings{}, ErrMemorySettings + } + if in.Enabled != nil { + v := MemoryOff + if *in.Enabled { + v = MemoryOn + } + if err := s.d.SetUserSetting(ctx, principal(user), HostSettingsID, MemoryEnabledKey, v); err != nil { + return MemorySettings{}, fmt.Errorf("%w: %v", ErrMemorySettings, err) + } + } + if in.SaveMode != nil { + if err := s.d.SetUserSetting(ctx, principal(user), HostSettingsID, MemorySaveKey, *in.SaveMode); err != nil { + return MemorySettings{}, fmt.Errorf("%w: %v", ErrMemorySettings, err) + } + } + return s.MemorySettings(ctx, user) +} + +// ErrNoNotes means a scope has no notes to export. +var ErrNoNotes = errors.New("studio: there are no notes to export") + +// ExportMemory writes the notes of a person as a zip file: the Markdown files +// of the memory folders, with their paths in the home. scope and key narrow it +// to one group, as the context files list names them (user; agent and the +// agent ID; channel and the folder name); empty exports all. It works while +// memory is off: the notes belong to the person. +func (s *Service) ExportMemory(ctx context.Context, user string, w io.Writer, scope, key string) (int, error) { + list, err := s.ListContextFiles(ctx, user) + if err != nil { + return 0, err + } + var paths []string + for _, g := range list.Groups { + if g.Source != MemoryAddonID { + continue + } + if scope != "" && (string(g.Scope) != scope || g.Key != key) { + continue + } + for _, f := range g.Files { + paths = append(paths, f.Path) + } + } + if len(paths) == 0 { + return 0, ErrNoNotes + } + zw := zip.NewWriter(w) + rd := homeReader{f: s.d.Files} + n := 0 + for _, p := range paths { + data, err := rd.ReadFile(ctx, principal(user), p, 1<<20) + if err != nil { + if errors.Is(err, contextfiles.ErrNotExist) { + continue + } + return n, err + } + fw, err := zw.Create(strings.TrimPrefix(path.Clean(p), "/")) + if err != nil { + return n, err + } + if _, err := fw.Write(data); err != nil { + return n, err + } + n++ + } + return n, zw.Close() +} diff --git a/core/services/studio/providerguard/guard_test.go b/core/services/studio/providerguard/guard_test.go index a6e3a2cefc..23e7a3fc05 100644 --- a/core/services/studio/providerguard/guard_test.go +++ b/core/services/studio/providerguard/guard_test.go @@ -63,6 +63,8 @@ const ( ) var allowed = []entry{ + {ui + "hooks/useToolGroups.js", "memory", "the switch group of the built-in memory"}, + {ui + "utils/memoryState.js", "memory", "the switch group of the built-in memory"}, // Memory is built into the host. The add-on of that name is the alias of // the built-in: its index files and skills are the host's now, and the // host ignores its copies. diff --git a/core/services/studio/service.go b/core/services/studio/service.go index 9068f3d36a..fd7647ca46 100644 --- a/core/services/studio/service.go +++ b/core/services/studio/service.go @@ -472,6 +472,9 @@ func New(d Deps) (*Service, error) { if err := s.registerToolGroups(); err != nil { return nil, err } + if err := s.registerMemoryGroup(); err != nil { + return nil, err + } s.memory = builtinMemory{} s.ctxfiles = contextfiles.NewLoader(contextfiles.NewRegistry(), homeReader{f: d.Files}, log) s.shared = sharedfolders.New(sharedfolders.Options{Files: d.Files, Consents: d.PageConsents, Audit: d.SharedFolderAudit, Users: s.folderUsers()}) diff --git a/core/services/studio/subagent.go b/core/services/studio/subagent.go index 9584bc9890..ec9139d020 100644 --- a/core/services/studio/subagent.go +++ b/core/services/studio/subagent.go @@ -152,6 +152,11 @@ func (s *Service) chatTools(ctx context.Context, x session.Access) (session.Turn } notes, notesKey = s.contextTurnFor(ctx, rec, tools, extra, !(legacy.Installed && legacy.Running)) tools = append(tools, s.contextTools(rec, tools, extra)...) + if st := s.memoryState(ctx, rec, legacy); !st.On && st.Code != MemoryOffFiles { + // Off: the model hears it in one line, and has no note and no tool. + line := memoryOffLine(st) + notes, notesKey = joinBlocks(notes, line), notesKey+line + } } if lead := joinBlocks(persona, notes); lead != "" { prompt = strings.TrimSpace(lead + "\n\n" + prompt) diff --git a/core/services/studio/webtools_test.go b/core/services/studio/webtools_test.go index 48ddc438b7..f1bc47c7d1 100644 --- a/core/services/studio/webtools_test.go +++ b/core/services/studio/webtools_test.go @@ -238,7 +238,7 @@ var _ = Describe("the one registry of built-in tools", func() { if t.ID == "load_skill" || t.ID == "goal_done" || t.ID == "goal_blocked" || t.ID == studio.SendMessageName { Expect(t.Group).To(BeEmpty(), t.ID) } else if t.External { - Expect(t.Group).To(BeElementOf(builtins.GroupFiles, builtins.GroupMedia, builtins.GroupCollections, builtins.GroupSubAgents), t.ID) + Expect(t.Group).To(BeElementOf(builtins.GroupFiles, builtins.GroupMedia, builtins.GroupCollections, builtins.GroupSubAgents, builtins.GroupMemory), t.ID) } else { Expect(t.Group).To(BeElementOf(builtins.GroupTriggers, builtins.GroupChanges), t.ID) } diff --git a/docs/content/features/chats.md b/docs/content/features/chats.md index f26b71d61c..7b14494297 100644 --- a/docs/content/features/chats.md +++ b/docs/content/features/chats.md @@ -1343,6 +1343,7 @@ it off, so a chat that never touches the menu works as before: | **Sub-agents** | `subagents` | `spawn_agent` | | **Saved agents** | `saved_agents` | `list_agents` and the `agent` argument of `spawn_agent` (opt-in) | | **Triggers** | `triggers` | `propose_trigger`, `list_triggers`, `update_trigger` and `delete_trigger` | +| **Memory** | `memory` | Your notes: the model reads them before it answers and may save new ones (see [Memory](/features/short-memory/)). Switch it off to make the chat private: it neither reads nor saves memory | | **Suggested changes** | `changes` | `propose_change` and `revert_change`: a model asks to change your notes, an agent profile, a skill, a page or the dashboard, and you confirm (see [change proposals](/features/addons/#change-proposals-from-chats)); for memory notes only, you can choose in your account settings to have them saved at once with an Undo, or never (see [Memory](/features/short-memory/#choose-how-a-note-is-saved)) | ```bash diff --git a/docs/content/features/short-memory.md b/docs/content/features/short-memory.md index bcaacedd65..182e50a172 100644 --- a/docs/content/features/short-memory.md +++ b/docs/content/features/short-memory.md @@ -33,6 +33,54 @@ There are three kinds of notes, each in the Memory folder of your files: An index has at most 40 lines and 2 KiB. All indexes of a turn together have at most 6 KiB. The details of a topic sit in a file next to the index. +## Turn memory off + +Memory has one switch, **On** or **Off**, at the top of the **Memory** tab of your +account settings (**Your settings**, then **Memory**). It is on until you turn it off. + +When memory is off: + +- Nothing from your notes is put in the prompt, in any chat, agent, trigger or run that + starts by itself. +- The model has no memory tool or skill: it cannot read a note, propose one or save one. +- The model is told in one line that memory is off. If you ask it to remember something, + it says that memory is off and that you can turn it on again. +- Your notes stay where they are. You can still read, edit, export and delete them on the + **Memory** tab while memory is off. + +The narrowest level wins, and a wider level never overrides a narrower one: + +1. **This chat** is private (see below). +2. **The agent** of the chat turns memory off in its **Tools** list. +3. **Your switch** in the account settings is off. +4. **The server** has files off, because notes are files. + +A saved agent cannot widen your choice: with your switch off, an agent that is allowed to +save notes still reads and saves none. The agent form shows "Memory is off in your +settings" as a read-only line. + +### Private chat + +In the **Tools** menu of a chat, the **Memory** row has a switch. Turn it off to make the +chat private: it neither reads nor saves memory. The server remembers it for that chat, +and a chip "Private chat" and a line in the Tools menu say so. Your other chats are not +affected, and notes saved earlier are not removed. It is the same switch as the group +`memory` of `PUT /api/chats/{id}/builtins`; `GET /api/chats/{id}/tools` has a `memory` +object ({`on`, `code`, `blocked_by`, `fix`}) that names the level that is off. + +### Export and forget + +On the **Memory** tab, **Export** downloads your notes as a zip file +(`GET /api/chats/memory/export`). **Forget everything** moves the notes to the Trash after +you confirm; you can restore them from Files. Both work while memory is off. + +### The API + +`GET /api/chats/memory` reads `{enabled, enabled_set, save_mode, save_mode_set, provider, +available}` and `PUT /api/chats/memory` with `{enabled, save_mode}` changes them. The server +keeps them with your other settings, so every frontend of a cluster reads the same answer. +Before you choose, memory is on, unless you had disabled the old Memory add-on to stop it. + ## Saving and forgetting When you ask the model to remember something, it calls `propose_change`. By default diff --git a/swagger/docs.go b/swagger/docs.go index 9b608e80e3..a8f2982dd4 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -7027,6 +7027,104 @@ const docTemplate = `{ } } }, + "/api/chats/memory": { + "get": { + "description": "Memory is the short notes the assistant reads before it answers and may save. enabled is the switch for all your chats and agents: a private chat (the memory group of a chat, see PUT /api/chats/{id}/builtins), an agent that turns memory off, or this switch off each make memory off in a chat, and the narrowest wins; a saved agent cannot widen your choice. enabled_set is false until you choose; before that memory is on, unless you had disabled the Memory add-on to stop it. save_mode is how a note is saved (ask, auto or never) and applies as the strictest rule with the rules of the host; a chat from a messaging app always asks. Your notes stay and can be listed, exported and deleted while memory is off.", + "tags": [ + "chats" + ], + "summary": "Read your memory settings.", + "responses": { + "200": { + "description": "Your memory settings", + "schema": { + "$ref": "#/definitions/schema.ChatsMemorySettings" + } + } + } + }, + "put": { + "description": "Sets enabled (memory On or Off for every chat and agent) and save_mode (ask, auto or never). A field left out stays as it is. Off means nothing is put in the prompt, the model has no tool or skill for notes, nothing is proposed or saved (also by triggers and other runs that start by themselves) and the model is told in one line that memory is off. The settings are kept per person with the account, so every frontend of a cluster reads the same answer. 400 for a value that is not allowed.", + "consumes": [ + "application/json" + ], + "tags": [ + "chats" + ], + "summary": "Change your memory settings.", + "parameters": [ + { + "description": "the choices to change", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.ChatsMemoryRequest" + } + } + ], + "responses": { + "200": { + "description": "Your memory settings now in force", + "schema": { + "$ref": "#/definitions/schema.ChatsMemorySettings" + } + }, + "400": { + "description": "A value is not allowed", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + }, + "503": { + "description": "The settings cannot be stored on this server", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + } + } + } + }, + "/api/chats/memory/export": { + "get": { + "description": "The Markdown files of your memory folders (yours, each agent's and each messaging conversation's) with their paths. scope (user, agent or channel) and key (the agent ID or the conversation folder name, as GET /api/chats/context-files lists them) narrow it to one group. It works while memory is off. 404 when there is nothing to export.", + "produces": [ + "application/zip" + ], + "tags": [ + "chats" + ], + "summary": "Download your memory notes as a zip file.", + "parameters": [ + { + "type": "string", + "description": "user, agent or channel", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "description": "the agent ID or the conversation folder, with scope", + "name": "key", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A zip file of the notes", + "schema": { + "type": "file" + } + }, + "404": { + "description": "No notes", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + } + } + } + }, "/api/chats/notifications": { "get": { "description": "The inbox: one item for each run of a trigger that finished, failed or needs you. Newest first. It also returns the number unread and the total kept.", @@ -9334,7 +9432,7 @@ const docTemplate = `{ }, "/api/chats/{id}/builtins": { "put": { - "description": "off replaces the earlier list of groups that are off. The groups are shell (bash, whether the Sandbox add-on or the built-in bash on this machine serves it), web (web_search and web_fetch), files (list_files, read_file, write_file, edit_file, move_file, delete_file, search_files and read_document), media (describe_image and transcribe_audio), collections (list_collections and search_collection), subagents (spawn_agent), triggers (propose_trigger, list_triggers, update_trigger and delete_trigger) and saved_agents. A sub-agent follows the switches of its parent chat. An opt-in group (opt_in in the list, such as saved_agents: list_agents and the agent argument of spawn_agent) is off until on names it; on replaces the earlier list of opt-in groups that are on, and an opt-in group in off is ignored. Omit on to keep it. A switch never adds what the administrator has off or the user may not use. The next turn of the chat gets the tools that are on.", + "description": "off replaces the earlier list of groups that are off. The groups are shell (bash, whether the Sandbox add-on or the built-in bash on this machine serves it), web (web_search and web_fetch), files (list_files, read_file, write_file, edit_file, move_file, delete_file, search_files and read_document), media (describe_image and transcribe_audio), collections (list_collections and search_collection), subagents (spawn_agent), triggers (propose_trigger, list_triggers, update_trigger and delete_trigger), memory (your notes: off makes the chat a private chat that neither reads nor saves memory) and saved_agents. A sub-agent follows the switches of its parent chat. An opt-in group (opt_in in the list, such as saved_agents: list_agents and the agent argument of spawn_agent) is off until on names it; on replaces the earlier list of opt-in groups that are on, and an opt-in group in off is ignored. Omit on to keep it. A switch never adds what the administrator has off or the user may not use. The next turn of the chat gets the tools that are on.", "tags": [ "chats" ], @@ -10890,7 +10988,7 @@ const docTemplate = `{ }, "/api/chats/{id}/tools": { "get": { - "description": "The groups of built-in tools (shell, web, files, media, collections, subagents, triggers and saved_agents; a group the server does not offer is absent, and shell is listed even when nothing can run commands, not usable with the reason), add-ons, the MCP servers of the chat's model and your MCP connectors, each with the chat's switch and whether it can be used now (with the reason when not). A chat with a saved agent lists only what the agent grants. fallbacks lists the built-in tools (bash, web_search, web_fetch) that an installed add-on replaces but that no add-on serves in this chat now, with the add-on and whether the built-in serves instead.", + "description": "The groups of built-in tools (shell, web, files, media, collections, subagents, triggers, memory and saved_agents; a group the server does not offer is absent, and shell is listed even when nothing can run commands, not usable with the reason), add-ons, the MCP servers of the chat's model and your MCP connectors, each with the chat's switch and whether it can be used now (with the reason when not). A chat with a saved agent lists only what the agent grants. memory says whether memory works in the chat and, when it does not, the level that turned it off (code, blocked_by) and where to turn it on (fix). fallbacks lists the built-in tools (bash, web_search, web_fetch) that an installed add-on replaces but that no add-on serves in this chat now, with the add-on and whether the built-in serves instead.", "tags": [ "chats" ], @@ -23893,6 +23991,102 @@ const docTemplate = `{ } } }, + "schema.ChatsMemoryFix": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "kind": { + "type": "string", + "example": "open_memory_settings" + }, + "target": { + "type": "string", + "example": "memory" + } + } + }, + "schema.ChatsMemoryRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "example": false + }, + "save_mode": { + "type": "string", + "example": "ask" + } + } + }, + "schema.ChatsMemorySettings": { + "type": "object", + "properties": { + "available": { + "description": "Available is false when the server has no files, which notes need.", + "type": "boolean", + "example": true + }, + "enabled": { + "description": "Enabled is memory On or Off for all your chats and agents.", + "type": "boolean", + "example": true + }, + "enabled_set": { + "description": "EnabledSet is true once you chose. Before that memory is on, unless you\nhad disabled the Memory add-on to stop it.", + "type": "boolean", + "example": false + }, + "provider": { + "description": "Provider is what serves memory: builtin.", + "type": "string", + "example": "builtin" + }, + "save_mode": { + "description": "SaveMode is how a note is saved: ask, auto or never.", + "type": "string", + "example": "ask" + }, + "save_mode_set": { + "description": "SaveModeSet is true once you chose in this setting.", + "type": "boolean", + "example": false + } + } + }, + "schema.ChatsMemoryState": { + "type": "object", + "properties": { + "blocked_by": { + "description": "BlockedBy is who holds that switch: chat, agent, user or server.", + "type": "string", + "example": "chat" + }, + "code": { + "description": "Code names the level that is off: memory_off_chat, memory_off_agent,\nmemory_off_user or memory_off_files. Absent when on.", + "type": "string", + "example": "memory_off_chat" + }, + "fix": { + "description": "Fix says where to turn memory on again: kind open_tools_menu,\nopen_memory_settings, open_agent or open_server_setting.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsMemoryFix" + } + ] + }, + "on": { + "description": "On is true when the chat reads and may save notes.", + "type": "boolean", + "example": false + }, + "reason": { + "description": "Reason is an English sentence for logs; the interface words it by Code.", + "type": "string" + } + } + }, "schema.ChatsMessage": { "type": "object", "properties": { @@ -24674,6 +24868,14 @@ const docTemplate = `{ "$ref": "#/definitions/schema.ChatsToolFallback" } }, + "memory": { + "description": "Memory says whether memory works in this chat and, when it does not,\nwhich level turned it off and where to turn it on again.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsMemoryState" + } + ] + }, "off": { "description": "Off is the list of MCP switches in force.", "type": "array", diff --git a/swagger/swagger.json b/swagger/swagger.json index 19ebc621ad..7247cb19d0 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -7024,6 +7024,104 @@ } } }, + "/api/chats/memory": { + "get": { + "description": "Memory is the short notes the assistant reads before it answers and may save. enabled is the switch for all your chats and agents: a private chat (the memory group of a chat, see PUT /api/chats/{id}/builtins), an agent that turns memory off, or this switch off each make memory off in a chat, and the narrowest wins; a saved agent cannot widen your choice. enabled_set is false until you choose; before that memory is on, unless you had disabled the Memory add-on to stop it. save_mode is how a note is saved (ask, auto or never) and applies as the strictest rule with the rules of the host; a chat from a messaging app always asks. Your notes stay and can be listed, exported and deleted while memory is off.", + "tags": [ + "chats" + ], + "summary": "Read your memory settings.", + "responses": { + "200": { + "description": "Your memory settings", + "schema": { + "$ref": "#/definitions/schema.ChatsMemorySettings" + } + } + } + }, + "put": { + "description": "Sets enabled (memory On or Off for every chat and agent) and save_mode (ask, auto or never). A field left out stays as it is. Off means nothing is put in the prompt, the model has no tool or skill for notes, nothing is proposed or saved (also by triggers and other runs that start by themselves) and the model is told in one line that memory is off. The settings are kept per person with the account, so every frontend of a cluster reads the same answer. 400 for a value that is not allowed.", + "consumes": [ + "application/json" + ], + "tags": [ + "chats" + ], + "summary": "Change your memory settings.", + "parameters": [ + { + "description": "the choices to change", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.ChatsMemoryRequest" + } + } + ], + "responses": { + "200": { + "description": "Your memory settings now in force", + "schema": { + "$ref": "#/definitions/schema.ChatsMemorySettings" + } + }, + "400": { + "description": "A value is not allowed", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + }, + "503": { + "description": "The settings cannot be stored on this server", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + } + } + } + }, + "/api/chats/memory/export": { + "get": { + "description": "The Markdown files of your memory folders (yours, each agent's and each messaging conversation's) with their paths. scope (user, agent or channel) and key (the agent ID or the conversation folder name, as GET /api/chats/context-files lists them) narrow it to one group. It works while memory is off. 404 when there is nothing to export.", + "produces": [ + "application/zip" + ], + "tags": [ + "chats" + ], + "summary": "Download your memory notes as a zip file.", + "parameters": [ + { + "type": "string", + "description": "user, agent or channel", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "description": "the agent ID or the conversation folder, with scope", + "name": "key", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A zip file of the notes", + "schema": { + "type": "file" + } + }, + "404": { + "description": "No notes", + "schema": { + "$ref": "#/definitions/schema.ErrorResponse" + } + } + } + } + }, "/api/chats/notifications": { "get": { "description": "The inbox: one item for each run of a trigger that finished, failed or needs you. Newest first. It also returns the number unread and the total kept.", @@ -9331,7 +9429,7 @@ }, "/api/chats/{id}/builtins": { "put": { - "description": "off replaces the earlier list of groups that are off. The groups are shell (bash, whether the Sandbox add-on or the built-in bash on this machine serves it), web (web_search and web_fetch), files (list_files, read_file, write_file, edit_file, move_file, delete_file, search_files and read_document), media (describe_image and transcribe_audio), collections (list_collections and search_collection), subagents (spawn_agent), triggers (propose_trigger, list_triggers, update_trigger and delete_trigger) and saved_agents. A sub-agent follows the switches of its parent chat. An opt-in group (opt_in in the list, such as saved_agents: list_agents and the agent argument of spawn_agent) is off until on names it; on replaces the earlier list of opt-in groups that are on, and an opt-in group in off is ignored. Omit on to keep it. A switch never adds what the administrator has off or the user may not use. The next turn of the chat gets the tools that are on.", + "description": "off replaces the earlier list of groups that are off. The groups are shell (bash, whether the Sandbox add-on or the built-in bash on this machine serves it), web (web_search and web_fetch), files (list_files, read_file, write_file, edit_file, move_file, delete_file, search_files and read_document), media (describe_image and transcribe_audio), collections (list_collections and search_collection), subagents (spawn_agent), triggers (propose_trigger, list_triggers, update_trigger and delete_trigger), memory (your notes: off makes the chat a private chat that neither reads nor saves memory) and saved_agents. A sub-agent follows the switches of its parent chat. An opt-in group (opt_in in the list, such as saved_agents: list_agents and the agent argument of spawn_agent) is off until on names it; on replaces the earlier list of opt-in groups that are on, and an opt-in group in off is ignored. Omit on to keep it. A switch never adds what the administrator has off or the user may not use. The next turn of the chat gets the tools that are on.", "tags": [ "chats" ], @@ -10887,7 +10985,7 @@ }, "/api/chats/{id}/tools": { "get": { - "description": "The groups of built-in tools (shell, web, files, media, collections, subagents, triggers and saved_agents; a group the server does not offer is absent, and shell is listed even when nothing can run commands, not usable with the reason), add-ons, the MCP servers of the chat's model and your MCP connectors, each with the chat's switch and whether it can be used now (with the reason when not). A chat with a saved agent lists only what the agent grants. fallbacks lists the built-in tools (bash, web_search, web_fetch) that an installed add-on replaces but that no add-on serves in this chat now, with the add-on and whether the built-in serves instead.", + "description": "The groups of built-in tools (shell, web, files, media, collections, subagents, triggers, memory and saved_agents; a group the server does not offer is absent, and shell is listed even when nothing can run commands, not usable with the reason), add-ons, the MCP servers of the chat's model and your MCP connectors, each with the chat's switch and whether it can be used now (with the reason when not). A chat with a saved agent lists only what the agent grants. memory says whether memory works in the chat and, when it does not, the level that turned it off (code, blocked_by) and where to turn it on (fix). fallbacks lists the built-in tools (bash, web_search, web_fetch) that an installed add-on replaces but that no add-on serves in this chat now, with the add-on and whether the built-in serves instead.", "tags": [ "chats" ], @@ -23890,6 +23988,102 @@ } } }, + "schema.ChatsMemoryFix": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "kind": { + "type": "string", + "example": "open_memory_settings" + }, + "target": { + "type": "string", + "example": "memory" + } + } + }, + "schema.ChatsMemoryRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "example": false + }, + "save_mode": { + "type": "string", + "example": "ask" + } + } + }, + "schema.ChatsMemorySettings": { + "type": "object", + "properties": { + "available": { + "description": "Available is false when the server has no files, which notes need.", + "type": "boolean", + "example": true + }, + "enabled": { + "description": "Enabled is memory On or Off for all your chats and agents.", + "type": "boolean", + "example": true + }, + "enabled_set": { + "description": "EnabledSet is true once you chose. Before that memory is on, unless you\nhad disabled the Memory add-on to stop it.", + "type": "boolean", + "example": false + }, + "provider": { + "description": "Provider is what serves memory: builtin.", + "type": "string", + "example": "builtin" + }, + "save_mode": { + "description": "SaveMode is how a note is saved: ask, auto or never.", + "type": "string", + "example": "ask" + }, + "save_mode_set": { + "description": "SaveModeSet is true once you chose in this setting.", + "type": "boolean", + "example": false + } + } + }, + "schema.ChatsMemoryState": { + "type": "object", + "properties": { + "blocked_by": { + "description": "BlockedBy is who holds that switch: chat, agent, user or server.", + "type": "string", + "example": "chat" + }, + "code": { + "description": "Code names the level that is off: memory_off_chat, memory_off_agent,\nmemory_off_user or memory_off_files. Absent when on.", + "type": "string", + "example": "memory_off_chat" + }, + "fix": { + "description": "Fix says where to turn memory on again: kind open_tools_menu,\nopen_memory_settings, open_agent or open_server_setting.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsMemoryFix" + } + ] + }, + "on": { + "description": "On is true when the chat reads and may save notes.", + "type": "boolean", + "example": false + }, + "reason": { + "description": "Reason is an English sentence for logs; the interface words it by Code.", + "type": "string" + } + } + }, "schema.ChatsMessage": { "type": "object", "properties": { @@ -24671,6 +24865,14 @@ "$ref": "#/definitions/schema.ChatsToolFallback" } }, + "memory": { + "description": "Memory says whether memory works in this chat and, when it does not,\nwhich level turned it off and where to turn it on again.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsMemoryState" + } + ] + }, "off": { "description": "Off is the list of MCP switches in force.", "type": "array", diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 7b48af8458..1d9829d10f 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -6397,6 +6397,83 @@ definitions: type: string type: array type: object + schema.ChatsMemoryFix: + properties: + field: + type: string + kind: + example: open_memory_settings + type: string + target: + example: memory + type: string + type: object + schema.ChatsMemoryRequest: + properties: + enabled: + example: false + type: boolean + save_mode: + example: ask + type: string + type: object + schema.ChatsMemorySettings: + properties: + available: + description: Available is false when the server has no files, which notes + need. + example: true + type: boolean + enabled: + description: Enabled is memory On or Off for all your chats and agents. + example: true + type: boolean + enabled_set: + description: |- + EnabledSet is true once you chose. Before that memory is on, unless you + had disabled the Memory add-on to stop it. + example: false + type: boolean + provider: + description: 'Provider is what serves memory: builtin.' + example: builtin + type: string + save_mode: + description: 'SaveMode is how a note is saved: ask, auto or never.' + example: ask + type: string + save_mode_set: + description: SaveModeSet is true once you chose in this setting. + example: false + type: boolean + type: object + schema.ChatsMemoryState: + properties: + blocked_by: + description: 'BlockedBy is who holds that switch: chat, agent, user or server.' + example: chat + type: string + code: + description: |- + Code names the level that is off: memory_off_chat, memory_off_agent, + memory_off_user or memory_off_files. Absent when on. + example: memory_off_chat + type: string + fix: + allOf: + - $ref: '#/definitions/schema.ChatsMemoryFix' + description: |- + Fix says where to turn memory on again: kind open_tools_menu, + open_memory_settings, open_agent or open_server_setting. + "on": + description: On is true when the chat reads and may save notes. + example: false + type: boolean + reason: + description: Reason is an English sentence for logs; the interface words it + by Code. + type: string + type: object schema.ChatsMessage: properties: after: @@ -6998,6 +7075,12 @@ definitions: items: $ref: '#/definitions/schema.ChatsToolFallback' type: array + memory: + allOf: + - $ref: '#/definitions/schema.ChatsMemoryState' + description: |- + Memory says whether memory works in this chat and, when it does not, + which level turned it off and where to turn it on again. "off": description: Off is the list of MCP switches in force. items: @@ -14850,10 +14933,11 @@ paths: write_file, edit_file, move_file, delete_file, search_files and read_document), media (describe_image and transcribe_audio), collections (list_collections and search_collection), subagents (spawn_agent), triggers (propose_trigger, - list_triggers, update_trigger and delete_trigger) and saved_agents. A sub-agent - follows the switches of its parent chat. An opt-in group (opt_in in the list, - such as saved_agents: list_agents and the agent argument of spawn_agent) is - off until on names it; on replaces the earlier list of opt-in groups that + list_triggers, update_trigger and delete_trigger), memory (your notes: off + makes the chat a private chat that neither reads nor saves memory) and saved_agents. + A sub-agent follows the switches of its parent chat. An opt-in group (opt_in + in the list, such as saved_agents: list_agents and the agent argument of spawn_agent) + is off until on names it; on replaces the earlier list of opt-in groups that are on, and an opt-in group in off is ignored. Omit on to keep it. A switch never adds what the administrator has off or the user may not use. The next turn of the chat gets the tools that are on.' @@ -16012,14 +16096,16 @@ paths: /api/chats/{id}/tools: get: description: The groups of built-in tools (shell, web, files, media, collections, - subagents, triggers and saved_agents; a group the server does not offer is - absent, and shell is listed even when nothing can run commands, not usable - with the reason), add-ons, the MCP servers of the chat's model and your MCP - connectors, each with the chat's switch and whether it can be used now (with - the reason when not). A chat with a saved agent lists only what the agent - grants. fallbacks lists the built-in tools (bash, web_search, web_fetch) that - an installed add-on replaces but that no add-on serves in this chat now, with - the add-on and whether the built-in serves instead. + subagents, triggers, memory and saved_agents; a group the server does not + offer is absent, and shell is listed even when nothing can run commands, not + usable with the reason), add-ons, the MCP servers of the chat's model and + your MCP connectors, each with the chat's switch and whether it can be used + now (with the reason when not). A chat with a saved agent lists only what + the agent grants. memory says whether memory works in the chat and, when it + does not, the level that turned it off (code, blocked_by) and where to turn + it on (fix). fallbacks lists the built-in tools (bash, web_search, web_fetch) + that an installed add-on replaces but that no add-on serves in this chat now, + with the add-on and whether the built-in serves instead. parameters: - description: conversation id in: path @@ -17912,6 +17998,89 @@ paths: summary: Test an MCP connector. tags: - chats + /api/chats/memory: + get: + description: 'Memory is the short notes the assistant reads before it answers + and may save. enabled is the switch for all your chats and agents: a private + chat (the memory group of a chat, see PUT /api/chats/{id}/builtins), an agent + that turns memory off, or this switch off each make memory off in a chat, + and the narrowest wins; a saved agent cannot widen your choice. enabled_set + is false until you choose; before that memory is on, unless you had disabled + the Memory add-on to stop it. save_mode is how a note is saved (ask, auto + or never) and applies as the strictest rule with the rules of the host; a + chat from a messaging app always asks. Your notes stay and can be listed, + exported and deleted while memory is off.' + responses: + "200": + description: Your memory settings + schema: + $ref: '#/definitions/schema.ChatsMemorySettings' + summary: Read your memory settings. + tags: + - chats + put: + consumes: + - application/json + description: Sets enabled (memory On or Off for every chat and agent) and save_mode + (ask, auto or never). A field left out stays as it is. Off means nothing is + put in the prompt, the model has no tool or skill for notes, nothing is proposed + or saved (also by triggers and other runs that start by themselves) and the + model is told in one line that memory is off. The settings are kept per person + with the account, so every frontend of a cluster reads the same answer. 400 + for a value that is not allowed. + parameters: + - description: the choices to change + in: body + name: request + required: true + schema: + $ref: '#/definitions/schema.ChatsMemoryRequest' + responses: + "200": + description: Your memory settings now in force + schema: + $ref: '#/definitions/schema.ChatsMemorySettings' + "400": + description: A value is not allowed + schema: + $ref: '#/definitions/schema.ErrorResponse' + "503": + description: The settings cannot be stored on this server + schema: + $ref: '#/definitions/schema.ErrorResponse' + summary: Change your memory settings. + tags: + - chats + /api/chats/memory/export: + get: + description: The Markdown files of your memory folders (yours, each agent's + and each messaging conversation's) with their paths. scope (user, agent or + channel) and key (the agent ID or the conversation folder name, as GET /api/chats/context-files + lists them) narrow it to one group. It works while memory is off. 404 when + there is nothing to export. + parameters: + - description: user, agent or channel + in: query + name: scope + type: string + - description: the agent ID or the conversation folder, with scope + in: query + name: key + type: string + produces: + - application/zip + responses: + "200": + description: A zip file of the notes + schema: + type: file + "404": + description: No notes + schema: + $ref: '#/definitions/schema.ErrorResponse' + summary: Download your memory notes as a zip file. + tags: + - chats /api/chats/notifications: delete: description: Name the ids, or pass all to empty your inbox; with all, read_only