From 3f4e446adcdb517543c62d5f8e09ef7cdb5a14f5 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Fri, 31 Jul 2026 23:05:02 +0200 Subject: [PATCH 01/12] gallery: add Qwopus3.6 27B Fusion variants (#11257) Add Q4_K_M and Q8_0 llama.cpp entries for the newly released Qwopus3.6-27B Fusion reasoning and coding merge, with MTP enabled. Assisted-by: Codex:gpt-5 [Hugging Face API] Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> --- gallery/index.yaml | 68 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/gallery/index.yaml b/gallery/index.yaml index 2ba3f4b552fb..7c41c9dccc73 100644 --- a/gallery/index.yaml +++ b/gallery/index.yaml @@ -996,6 +996,74 @@ - filename: llama-cpp/mmproj/Qwen3.6-27B-Fable-Fus-711-UnHeretic-NM-DAU-NEO-MAX-NEO-MTP-Q4_K_M/mmproj-F32.gguf sha256: fdc443e974cad1f61c45af1cfd5580855855ddce0d6c14cc500a5714c486ac1d uri: https://huggingface.co/DavidAU/Qwen3.6-27B-Fable-Fusion-711-Uncensored-Heretic-NM-DAU-NEO-MAX-MTP-GGUF/resolve/main/mmproj-F32.gguf +- &qwopus3-6-27b-fusion + name: "qwopus3.6-27b-fusion" + variants: + - model: qwopus3.6-27b-fusion-q8 + url: "github:mudler/LocalAI/gallery/virtual.yaml@master" + urls: + - https://huggingface.co/Qwen/Qwen3.6-27B + - https://huggingface.co/KyleHessling1/Qwopus3.6-27B-Fusion-GGUF + description: | + Qwopus3.6-27B Fusion is an experimental 27B Qwen3.6 merge that combines the + Qwopus reasoning and coding fine-tunes. It targets agentic coding and + general reasoning, supports the Qwen3.6 262K context window, and retains + the base model's MTP head. This default entry uses the 16.8 GB Q4_K_M GGUF. + license: qwen + tags: + - llm + - gguf + - reasoning + - thinking + - mtp + overrides: + backend: llama-cpp + function: + automatic_tool_parsing_fallback: true + grammar: + disable: true + known_usecases: + - chat + options: + - use_jinja:true + - spec_type:draft-mtp + - spec_n_max:6 + - spec_p_min:0.75 + parameters: + model: llama-cpp/models/Qwopus3.6-27B-Fusion-Q4_K_M/Qwopus3.6-27B-Fusion-Q4_K_M.gguf + template: + use_tokenizer_template: true + files: + - filename: llama-cpp/models/Qwopus3.6-27B-Fusion-Q4_K_M/Qwopus3.6-27B-Fusion-Q4_K_M.gguf + sha256: 6d4e6e9ffba84ffe5166969303d4918f3a31682d75862416827412cee8f20d5d + uri: huggingface://KyleHessling1/Qwopus3.6-27B-Fusion-GGUF/Qwopus3.6-27B-Fusion-Q4_K_M.gguf +- !!merge <<: *qwopus3-6-27b-fusion + name: "qwopus3.6-27b-fusion-q8" + variants: null + description: | + Qwopus3.6-27B Fusion in the higher-fidelity 29.0 GB Q8_0 GGUF format. + It retains the base model's MTP head for speculative decoding. + overrides: + backend: llama-cpp + function: + automatic_tool_parsing_fallback: true + grammar: + disable: true + known_usecases: + - chat + options: + - use_jinja:true + - spec_type:draft-mtp + - spec_n_max:6 + - spec_p_min:0.75 + parameters: + model: llama-cpp/models/Qwopus3.6-27B-Fusion-Q8_0/Qwopus3.6-27B-Fusion-Q8_0.gguf + template: + use_tokenizer_template: true + files: + - filename: llama-cpp/models/Qwopus3.6-27B-Fusion-Q8_0/Qwopus3.6-27B-Fusion-Q8_0.gguf + sha256: 5594e1776b75beedf4a54b933bba386dc83a0883417e3bcd9ef53fdfd120d5b6 + uri: huggingface://KyleHessling1/Qwopus3.6-27B-Fusion-GGUF/Qwopus3.6-27B-Fusion-Q8_0.gguf - name: "minicpm5-1b-claude-opus-fable5-v2-thinking" url: "github:mudler/LocalAI/gallery/virtual.yaml@master" urls: From 4b3978dcba3cdde44fe8e98170de35a36576103c Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Fri, 31 Jul 2026 21:23:35 +0000 Subject: [PATCH 02/12] chore(website): derive the counters from data, refresh them weekly The star, fork, contributor and release counts were typed into the templates by hand, so they only moved when somebody remembered. They had already drifted: stars read 48,042 against 48,067, forks 4,314 against 4,320, and contributors 224 against 225. They move to website/data/stats.yaml, which .github/ci/refresh-site-counters.sh rewrites from the GitHub API, run weekly by a new workflow. The contributors and releases endpoints never report a total, so the script asks for one item per page and reads the count out of the Link header. It refuses to write a zero or a non-number, which is what a rate-limited or failed call looks like, and the workflow commits only when a number actually moved. The Discord count has no API behind it, so the script reads the existing value back and carries it through. The engine count was wrong in a second way. The hero said 18, the section heading said "Eighteen engines", the timeline said "Nineteen engines of our own", and the /engines/ page derived 19 from the data file. All of them now derive from that same file, so they cannot disagree again. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] --- .github/ci/refresh-site-counters.sh | 64 +++++++++++++++++++++ .github/workflows/refresh-site-counters.yml | 44 ++++++++++++++ website/data/stats.yaml | 13 +++++ website/hugo.toml | 5 +- website/layouts/index.html | 22 +++---- website/layouts/partials/nav.html | 2 +- 6 files changed, 135 insertions(+), 15 deletions(-) create mode 100755 .github/ci/refresh-site-counters.sh create mode 100644 .github/workflows/refresh-site-counters.yml create mode 100644 website/data/stats.yaml diff --git a/.github/ci/refresh-site-counters.sh b/.github/ci/refresh-site-counters.sh new file mode 100755 index 000000000000..7b80434d4565 --- /dev/null +++ b/.github/ci/refresh-site-counters.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Refreshes the counters shown on the landing page from the GitHub API. +# +# The numbers used to be typed into the templates by hand, which meant they +# only moved when somebody remembered, and a stale star count on the front +# page is worse than no star count. Everything the API can answer for lives +# in website/data/stats.yaml and is rewritten wholesale by this script. +# +# Anything the API cannot answer for (the Discord member count) is read back +# out of the existing file and carried through untouched. +set -euo pipefail + +REPO="${REPO:-mudler/LocalAI}" +OUT="${OUT:-website/data/stats.yaml}" + +# The contributors and releases endpoints are paginated and never report a +# total. Asking for one item per page makes the last page number equal to the +# item count, which the Link header hands over. +count_via_link_header() { + local path="$1" link last + link=$(gh api -i "${path}?per_page=1" 2>/dev/null | tr -d '\r' | grep -i '^link:' || true) + if [ -z "$link" ]; then + # No Link header means a single page, so count that page directly. + gh api "${path}?per_page=100" --jq 'length' + return + fi + last=$(sed -n 's/.*[?&]page=\([0-9]*\)>; rel="last".*/\1/p' <<<"$link") + [ -n "$last" ] || { gh api "${path}?per_page=100" --jq 'length'; return; } + printf '%s\n' "$last" +} + +read -r stars forks < <(gh api "repos/${REPO}" --jq '"\(.stargazers_count) \(.forks_count)"') +contributors=$(count_via_link_header "repos/${REPO}/contributors") +releases=$(count_via_link_header "repos/${REPO}/releases") + +# Not derivable from the GitHub API, so keep whatever is already on disk. +discord=$(sed -n 's/^discord: *\([0-9]*\).*/\1/p' "$OUT" 2>/dev/null | head -1) +discord="${discord:-0}" + +for n in stars forks contributors releases; do + v="${!n}" + [[ "$v" =~ ^[0-9]+$ ]] && [ "$v" -gt 0 ] || { + echo "refusing to write: ${n} came back as '${v}'" >&2 + exit 1 + } +done + +cat > "$OUT" <Make AI run onevery machine. ★ Star on GitHub
-
0GitHub stars
+
0GitHub stars
0Backends
-
0Engines we wrote
+
0Engines we wrote
0Models, one click
@@ -164,7 +164,7 @@

Nobody was at the keyboard.

Engines we build

-

Eighteen engines, written from scratch.

+

{{ len .Site.Data.engines.engines }} engines, written from scratch.

Most backends wrap somebody else's engine. These do not. They exist because the thing we needed was a 9 GB Python install, or closed, or nobody had built it yet. Each one is a binary and a GGUF file, checked against the reference implementation in CI.

@@ -247,7 +247,7 @@

vllm.cpp

-

All eighteen engines →

+

All {{ len .Site.Data.engines.engines }} engines →

@@ -363,14 +363,14 @@

An agent you can drop on any box you S

Forty-eight thousand stars, and still shipping every week.

LocalAI on Trendshift -

LocalAI has been trending on GitHub repeatedly since it launched, and it is one of the most starred self-hosted AI projects there is. 224 people have contributed code, 3,187 are in the Discord, and the README is kept translated into eight languages because the users are everywhere.

+

LocalAI has been trending on GitHub repeatedly since it launched, and it is one of the most starred self-hosted AI projects there is. {{ lang.FormatNumberCustom 0 .Site.Data.stats.contributors }} people have contributed code, {{ lang.FormatNumberCustom 0 .Site.Data.stats.discord }} are in the Discord, and the README is kept translated into eight languages because the users are everywhere.

-
0Stars
-
0Forks
-
0Contributors
-
0Releases
-
0In Discord
+
0Stars
+
0Forks
+
0Contributors
+
0Releases
+
0In Discord
0Shipping since
@@ -421,7 +421,7 @@

Forty-eight thousand stars, and still employers reads as a customer logo wall, which is a claim we are not making; a sentence keeps it about the people, which is the true one. */}}

Who shows up

-

{{ .Site.Params.contributors }} people have put code in this repository.

+

{{ lang.FormatNumberCustom 0 .Site.Data.stats.contributors }} people have put code in this repository.

{{- $co := slice }} {{- range .Site.Data.ecosystem.contributors.companies }}{{ $co = $co | append (printf "%s" .name) }}{{ end }} {{- $ac := slice }} diff --git a/website/layouts/partials/nav.html b/website/layouts/partials/nav.html index f8837b6ff3c1..71fed539fc44 100644 --- a/website/layouts/partials/nav.html +++ b/website/layouts/partials/nav.html @@ -13,7 +13,7 @@ Docs From aa5a9c483a5d010ba5f6f5c720bee29a34823fb3 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Fri, 31 Jul 2026 21:28:21 +0000 Subject: [PATCH 03/12] fix(website): connect the runtime, the engines and APEX into one thread The page reads as a list of features with nothing joining them, so two things did not land. The engines section never said these are the backends LocalAI loads. The runtime section describes a core that pulls each engine in on demand, and the engines section describes engines written from scratch, and nothing on the page connected the two sentences. Readers were taking parakeet.cpp and the rest for unrelated side projects by the same people. The lede now says whose backends they are before it says anything else. APEX was used as a known term on first appearance, in a section that opened onto a benchmark table. Nothing said what it is or why it follows the engines. It now opens by placing itself in the stack: the engine decides how fast a model runs, the weights decide whether it runs at all, and APEX is the second of those. Then the numbers. Also drops "Most backends wrap somebody else's engine. These do not", which is the machine-written antithesis shape, and fixes a list that broke its own parallel halfway through. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-5[1m] --- website/layouts/index.html | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/website/layouts/index.html b/website/layouts/index.html index 1f7f6f78dcb1..5269e1b717d3 100644 --- a/website/layouts/index.html +++ b/website/layouts/index.html @@ -165,7 +165,10 @@

Nobody was at the keyboard.

Engines we build

{{ len .Site.Data.engines.engines }} engines, written from scratch.

-

Most backends wrap somebody else's engine. These do not. They exist because the thing we needed was a 9 GB Python install, or closed, or nobody had built it yet. Each one is a binary and a GGUF file, checked against the reference implementation in CI.

+ {{/* Names the link back to the runtime section explicitly. Readers were + arriving here and taking these for unrelated side projects, because + nothing on the page said they are the backends the core loads. */}} +

Those backends the core pulls in on demand are mostly ours. Most projects wrap somebody else's engine. We wrote ours, because the thing we needed came as a 9 GB Python install, or was closed, or did not exist. Each one is a binary and a GGUF file, checked against the reference implementation in CI.

@@ -257,7 +260,10 @@

vllm.cpp

APEX quantization

The model you could not fit, on the card you already own.

-

A 35B mixture-of-experts model is 64.6 GB at full precision, which puts it out of reach of every consumer GPU. APEX gets it to 12.2 GB, and it runs at 74 tokens a second, more than twice the speed of the original. Quality barely moves. The file is an ordinary GGUF, so stock llama.cpp opens it with no patches, and 201 of them are already sitting in the LocalAI gallery.

+ {{/* APEX was being used as a known term on first appearance, in a section + that opened straight onto a benchmark table. The first two sentences + say what it is and why it follows the engines. */}} +

The engine decides how fast a model runs. The weights decide whether it runs at all, so we build those too. APEX assigns a different precision to every tensor and every layer: a 35B mixture-of-experts model goes from 64.6 GB, out of reach of any consumer GPU, to 12.2 GB at 74 tokens a second. That is more than twice the speed of the original, and quality barely moves. The file is an ordinary GGUF, so stock llama.cpp opens it with no patches, and 201 of them are already sitting in the LocalAI gallery.

F16 · 64.6 GB30.4 t/s
Q8_0 · 34.4 GB52.5 t/s
From 11403f47978b8f04fb80a4a4a9e49d4bdab120cd Mon Sep 17 00:00:00 2001 From: "mudler's LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:36:46 +0200 Subject: [PATCH 04/12] chore(model-gallery): :arrow_up: update checksum (#11265) :arrow_up: Checksum updates in gallery/index.yaml Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- gallery/index.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/gallery/index.yaml b/gallery/index.yaml index 7c41c9dccc73..617dc2466bb7 100644 --- a/gallery/index.yaml +++ b/gallery/index.yaml @@ -70,7 +70,7 @@ files: - filename: llama-cpp/models/Parable-Qwen3-4B-Claude-Fable-5-Q4_K_M/Parable-Qwen3-4B-Claude-Fable-5-GGUF-Q4_K_M.gguf uri: https://huggingface.co/AnkitAI/Parable-Qwen3-4B-Claude-Fable-5-GGUF/resolve/main/Parable-Qwen3-4B-Claude-Fable-5-GGUF-Q4_K_M.gguf - sha256: c6f991bd243fd1449d50a58a8de7d26bcad35d908bc0193e5a2c2c56ac6b8d5f + sha256: c94b06a912aa901f3da5689754577ad534415efafc50dcee3f389594a153bf38 - name: "parable-granite-4.1-8b-claude-fable-5" url: "github:mudler/LocalAI/gallery/virtual.yaml@master" urls: @@ -1477,7 +1477,7 @@ files: - filename: ds4flash.gguf uri: https://huggingface.co/unsloth/DeepSeek-V4-Flash-GGUF - sha256: ea2be54e4e989cb8cc2a88c0791b15eaa50eb97fb46a89843f6415d7e1a73e33 + sha256: 7d6d1691bc2d02c5a8194afb8bd9b57519343afebf020231c61d9236504b4a5c - name: "qwopus3.6-35b-a3b-coder-mtp" url: "github:mudler/LocalAI/gallery/virtual.yaml@master" urls: @@ -13164,8 +13164,8 @@ model: rfdetr-seg-medium-f16.gguf files: - filename: rfdetr-seg-medium-f16.gguf - sha256: 885d85ed6935495fc50ff464e06b6ea3bd8e8386865852d68a8be0f649d65afe uri: huggingface://mudler/rfdetr-cpp-seg-medium/rfdetr-seg-medium-f16.gguf + sha256: dd7c8da7cf0a2e64a1002f5ff66d7fede45b00e612457f249bcd9d4a0c122566 - name: rfdetr-cpp-seg-large url: github:mudler/LocalAI/gallery/virtual.yaml@master urls: @@ -13193,8 +13193,8 @@ model: rfdetr-seg-large-f16.gguf files: - filename: rfdetr-seg-large-f16.gguf - sha256: 90423066d0791b4ae249f3986cce1f095a1e4090bf46800bf7f9e371ea80d559 uri: huggingface://mudler/rfdetr-cpp-seg-large/rfdetr-seg-large-f16.gguf + sha256: ffc631b8e6115b11bdbb8e876c77aaa3e3e5d8c41c00ce8417ebbf183b1e6404 - name: rfdetr-cpp-seg-xlarge url: github:mudler/LocalAI/gallery/virtual.yaml@master urls: @@ -13222,8 +13222,8 @@ model: rfdetr-seg-xlarge-f16.gguf files: - filename: rfdetr-seg-xlarge-f16.gguf - sha256: 0b82de4a6e65a40bc930979a1a4281cb24de35203d30eeefd797c858101a7bec uri: huggingface://mudler/rfdetr-cpp-seg-xlarge/rfdetr-seg-xlarge-f16.gguf + sha256: 72b6210e255ebb89a8d471c06a6f4b5307205b1a34b9e4292de90d1488da4e26 - name: rfdetr-cpp-seg-2xlarge url: github:mudler/LocalAI/gallery/virtual.yaml@master urls: @@ -13252,8 +13252,8 @@ model: rfdetr-seg-2xlarge-f16.gguf files: - filename: rfdetr-seg-2xlarge-f16.gguf - sha256: 7f957997db23e844194ea8266a95b4adc3deb6d0b71c0924922b20fbdeafa299 uri: huggingface://mudler/rfdetr-cpp-seg-2xlarge/rfdetr-seg-2xlarge-f16.gguf + sha256: 00f3988bdf9a382b06610c200b3938b65a7731d14eafa84b73f6c3b5be4af8d9 - name: edgetam url: github:mudler/LocalAI/gallery/virtual.yaml@master urls: From 22e401b43d23bd41c7e0b959b2d37f0a7640d8c8 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Fri, 31 Jul 2026 23:37:27 +0200 Subject: [PATCH 05/12] docs: fix local Hugo working directory (#11183) Direct repository-root users to the supported make docs target and document the equivalent direct Hugo invocation from docs/. Fixes #10062 Assisted-by: Codex:gpt-5 Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> --- docs/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index b0934f2d45b3..4319675661eb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,9 +27,17 @@ Building and running the site locally requires a recent `extended` version of [H You can find out more about how to install Hugo for your environment in our [Getting started](https://www.docsy.dev/docs/getting-started/#prerequisites-and-installation) guide. -Once you've made your working copy of the site repo, from the repo root folder, run: +From the LocalAI repository root, run: +```bash +make docs ``` + +The Hugo configuration lives in the `docs` directory. To invoke Hugo +directly instead, run: + +```bash +cd docs hugo server ``` From 51c9cc1934a39a4237b3f1a90f652b974604566f Mon Sep 17 00:00:00 2001 From: "mudler's LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:37:41 +0200 Subject: [PATCH 06/12] chore: :arrow_up: Update ikawrakow/ik_llama.cpp to `3f53a059024039358e9fef75b5dc0c99dbcb40f9` (#11262) :arrow_up: Update ikawrakow/ik_llama.cpp Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --- backend/cpp/ik-llama-cpp/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cpp/ik-llama-cpp/Makefile b/backend/cpp/ik-llama-cpp/Makefile index c50d83b387e7..652ce86f3626 100644 --- a/backend/cpp/ik-llama-cpp/Makefile +++ b/backend/cpp/ik-llama-cpp/Makefile @@ -1,5 +1,5 @@ -IK_LLAMA_VERSION?=9992f6b515ee63c7d6f7beee6b8414b0a6d1dd43 +IK_LLAMA_VERSION?=3f53a059024039358e9fef75b5dc0c99dbcb40f9 LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp CMAKE_ARGS?= From 9584377a50955888810c6ff5f0e451d1ab7f5ab4 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Fri, 31 Jul 2026 23:38:03 +0200 Subject: [PATCH 07/12] feat(chat): edit saved conversation messages (#11189) * feat(chat): edit saved conversation messages Add inline edit, save, and cancel controls for stored user and assistant messages without triggering inference. Preserve structured message attachments and cancel edits when streaming starts. Assisted-by: Codex:gpt-5 * test(chat): preserve seeded conversation on reload The saved-message edit test reloads the page to verify persistence, but its init script was replacing localStorage with the original fixture on every navigation. Seed only an empty store so reloads exercise the data written by the application. Assisted-by: Codex:gpt-5 [Codex] --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> --- core/http/react-ui/e2e/forking-chat.spec.js | 74 +++++++++ .../http/react-ui/public/locales/de/chat.json | 4 + .../http/react-ui/public/locales/en/chat.json | 4 + .../http/react-ui/public/locales/es/chat.json | 4 + .../http/react-ui/public/locales/id/chat.json | 4 + .../http/react-ui/public/locales/it/chat.json | 4 + .../http/react-ui/public/locales/ko/chat.json | 4 + .../react-ui/public/locales/zh-CN/chat.json | 4 + core/http/react-ui/src/App.css | 31 ++++ core/http/react-ui/src/pages/Chat.jsx | 143 ++++++++++++++---- docs/content/getting-started/quickstart.md | 4 + 11 files changed, 252 insertions(+), 28 deletions(-) diff --git a/core/http/react-ui/e2e/forking-chat.spec.js b/core/http/react-ui/e2e/forking-chat.spec.js index 6cb0dac341a4..3b26686defa6 100644 --- a/core/http/react-ui/e2e/forking-chat.spec.js +++ b/core/http/react-ui/e2e/forking-chat.spec.js @@ -3,6 +3,7 @@ import { test, expect } from './coverage-fixtures.js' // Seeds two-message chat into localStorage so we don't need a live model. async function seedChat(page, history) { await page.addInitScript((h) => { + if (localStorage.getItem('localai_chats_data')) return const chat = { id: 'seed1', name: 'Seeded Chat', model: 'test-model', history: h, systemPrompt: '', mcpMode: false, mcpServers: [], @@ -33,6 +34,56 @@ const TWO_TURNS = [ { role: 'assistant', content: 'second answer' }, ] +test('saved message edits persist without sending a completion request', async ({ page }) => { + await mockModels(page) + let completionRequests = 0 + await page.route('**/v1/chat/completions', (route) => { + completionRequests++ + route.abort() + }) + await seedChat(page, TWO_TURNS) + await page.goto('/app/chat') + + const firstUser = page.locator('.chat-message-user').first() + await firstUser.hover() + await firstUser.getByTitle('Edit').click() + await firstUser.getByRole('textbox').fill('edited first question') + await firstUser.getByRole('button', { name: 'Save' }).click() + + const firstAssistant = page.locator('.chat-message-assistant').first() + await firstAssistant.hover() + await firstAssistant.getByTitle('Edit').click() + await firstAssistant.getByRole('textbox').fill('edited first answer') + await firstAssistant.getByRole('button', { name: 'Save' }).click() + + await expect(firstUser).toContainText('edited first question') + await expect(firstAssistant).toContainText('edited first answer') + await expect.poll(() => page.evaluate(() => { + const data = JSON.parse(localStorage.getItem('localai_chats_data')) + return data.chats[0].history.slice(0, 2).map(message => message.content) + })).toEqual(['edited first question', 'edited first answer']) + + await page.reload() + await expect(page.locator('.chat-message-user').first()).toContainText('edited first question') + await expect(page.locator('.chat-message-assistant').first()).toContainText('edited first answer') + expect(completionRequests).toBe(0) +}) + +test('cancelling a message edit leaves the original content unchanged', async ({ page }) => { + await mockModels(page) + await seedChat(page, TWO_TURNS) + await page.goto('/app/chat') + + const firstUser = page.locator('.chat-message-user').first() + await firstUser.hover() + await firstUser.getByTitle('Edit').click() + await firstUser.getByRole('textbox').fill('discard this draft') + await firstUser.getByRole('button', { name: 'Cancel' }).click() + + await expect(firstUser).toContainText('first question') + await expect(firstUser).not.toContainText('discard this draft') +}) + test('duplicate creates an independent copy and switches to it', async ({ page }) => { await mockModels(page) await seedChat(page, TWO_TURNS) @@ -112,6 +163,29 @@ const FILE_TURNS = [ { role: 'assistant', content: 'nope, that is it' }, ] +test('editing a file prompt preserves its content blocks and attachment metadata', async ({ page }) => { + await mockModels(page) + await seedChat(page, FILE_TURNS) + await page.goto('/app/chat') + + const firstUser = page.locator('.chat-message-user').first() + await firstUser.hover() + await firstUser.getByTitle('Edit').click() + await firstUser.getByRole('textbox').fill('edited file question') + await firstUser.getByRole('button', { name: 'Save' }).click() + + await expect.poll(() => page.evaluate(() => { + const data = JSON.parse(localStorage.getItem('localai_chats_data')) + return data.chats[0].history[0] + })).toEqual({ + ...FILE_TURNS[0], + content: [ + { type: 'text', text: 'edited file question' }, + FILE_TURNS[0].content[1], + ], + }) +}) + test('regenerating a non-last answer in a fork still sends the uploaded file content', async ({ page }) => { await mockModels(page) let sentMessages = null diff --git a/core/http/react-ui/public/locales/de/chat.json b/core/http/react-ui/public/locales/de/chat.json index 439213811aae..f686fae2f6ed 100644 --- a/core/http/react-ui/public/locales/de/chat.json +++ b/core/http/react-ui/public/locales/de/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "Kopieren", + "edit": "Bearbeiten", + "editMessage": "Nachricht bearbeiten", + "save": "Speichern", + "cancel": "Abbrechen", "regenerate": "Neu generieren", "jumpToLatest": "Jump to latest" }, diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index 4bdc2a3d0d75..fd2cee84de3f 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "Copy", + "edit": "Edit", + "editMessage": "Edit message", + "save": "Save", + "cancel": "Cancel", "regenerate": "Regenerate", "branch": "Branch from here", "jumpToLatest": "Jump to latest" diff --git a/core/http/react-ui/public/locales/es/chat.json b/core/http/react-ui/public/locales/es/chat.json index d5ade75f9bff..cec9d96101ab 100644 --- a/core/http/react-ui/public/locales/es/chat.json +++ b/core/http/react-ui/public/locales/es/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "Copiar", + "edit": "Editar", + "editMessage": "Editar mensaje", + "save": "Guardar", + "cancel": "Cancelar", "regenerate": "Regenerar", "jumpToLatest": "Jump to latest" }, diff --git a/core/http/react-ui/public/locales/id/chat.json b/core/http/react-ui/public/locales/id/chat.json index b9216e325357..8d2834c3b036 100644 --- a/core/http/react-ui/public/locales/id/chat.json +++ b/core/http/react-ui/public/locales/id/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "Salin", + "edit": "Edit", + "editMessage": "Edit pesan", + "save": "Simpan", + "cancel": "Batal", "regenerate": "Hasilkan ulang", "jumpToLatest": "Lompat ke terbaru" }, diff --git a/core/http/react-ui/public/locales/it/chat.json b/core/http/react-ui/public/locales/it/chat.json index 393dc7b01851..e95d18c8b125 100644 --- a/core/http/react-ui/public/locales/it/chat.json +++ b/core/http/react-ui/public/locales/it/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "Copia", + "edit": "Modifica", + "editMessage": "Modifica messaggio", + "save": "Salva", + "cancel": "Annulla", "regenerate": "Rigenera", "jumpToLatest": "Torna in fondo" }, diff --git a/core/http/react-ui/public/locales/ko/chat.json b/core/http/react-ui/public/locales/ko/chat.json index c14404c5fb80..522f844e668c 100644 --- a/core/http/react-ui/public/locales/ko/chat.json +++ b/core/http/react-ui/public/locales/ko/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "복사", + "edit": "편집", + "editMessage": "메시지 편집", + "save": "저장", + "cancel": "취소", "regenerate": "다시 생성", "jumpToLatest": "Jump to latest" }, diff --git a/core/http/react-ui/public/locales/zh-CN/chat.json b/core/http/react-ui/public/locales/zh-CN/chat.json index a96b306b60ef..38694e40a0c2 100644 --- a/core/http/react-ui/public/locales/zh-CN/chat.json +++ b/core/http/react-ui/public/locales/zh-CN/chat.json @@ -71,6 +71,10 @@ }, "actions": { "copy": "复制", + "edit": "编辑", + "editMessage": "编辑消息", + "save": "保存", + "cancel": "取消", "regenerate": "重新生成", "jumpToLatest": "Jump to latest" }, diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index b9e5838ab64a..fa2b89a2c3db 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -3540,6 +3540,37 @@ button.collapsible-header:focus-visible { background: var(--color-primary-light); } +.chat-message-edit { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + min-width: min(32rem, 60vw); +} + +.chat-message-edit-input { + width: 100%; + min-height: 6rem; + resize: vertical; + border: 1px solid var(--color-primary-border); + border-radius: var(--radius-md); + background: var(--color-bg-primary); + color: var(--color-text-primary); + font: inherit; + line-height: 1.5; + padding: var(--spacing-sm); +} + +.chat-message-edit-input:focus { + outline: 2px solid var(--color-primary-light); + outline-offset: 1px; +} + +.chat-message-edit-actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-xs); +} + .chat-message-system { align-self: center; max-width: 90%; diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index 4ab8a864aa57..bc0937281726 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -287,6 +287,24 @@ function UserMessageContent({ content, files }) { ) } +function editableMessageText(message) { + if (typeof message.content === 'string') return message.content + if (!Array.isArray(message.content)) return null + const textBlock = message.content.find(block => block?.type === 'text') + return typeof textBlock?.text === 'string' ? textBlock.text : null +} + +function withEditedMessageText(message, text) { + if (typeof message.content === 'string') return { ...message, content: text } + const textIndex = message.content.findIndex(block => block?.type === 'text') + return { + ...message, + content: message.content.map((block, index) => + index === textIndex ? { ...block, text } : block + ), + } +} + export default function Chat() { const { model: urlModel } = useParams() const { addToast } = useOutletContext() @@ -329,6 +347,8 @@ export default function Chat() { const [clientMCPServers, setClientMCPServers] = useState(() => loadClientMCPServers()) const [confirmDialog, setConfirmDialog] = useState(null) const [completionGlowIdx, setCompletionGlowIdx] = useState(-1) + const [editingMessageIndex, setEditingMessageIndex] = useState(null) + const [messageEditDraft, setMessageEditDraft] = useState('') const prevStreamingRef = useRef(false) const { connect: mcpConnect, disconnect: mcpDisconnect, disconnectAll: mcpDisconnectAll, @@ -545,6 +565,33 @@ export default function Chat() { updateChatSettings(activeChat.id, { clientMCPServers: next }) }, [activeChat, updateChatSettings]) + const startMessageEdit = useCallback((index, message) => { + const text = editableMessageText(message) + if (text === null) return + setEditingMessageIndex(index) + setMessageEditDraft(text) + }, []) + + const cancelMessageEdit = useCallback(() => { + setEditingMessageIndex(null) + setMessageEditDraft('') + }, []) + + const saveMessageEdit = useCallback(() => { + if (!activeChat || isStreaming || editingMessageIndex === null || !messageEditDraft.trim()) return + const message = activeChat.history[editingMessageIndex] + if (!message || editableMessageText(message) === null) return + const history = activeChat.history.map((item, index) => + index === editingMessageIndex ? withEditedMessageText(item, messageEditDraft) : item + ) + updateChatSettings(activeChat.id, { history }) + cancelMessageEdit() + }, [activeChat, isStreaming, editingMessageIndex, messageEditDraft, updateChatSettings, cancelMessageEdit]) + + useEffect(() => { + cancelMessageEdit() + }, [activeChat?.id, isStreaming, cancelMessageEdit]) + // Load initial message from home page const homeDataProcessed = useRef(false) useEffect(() => { @@ -1170,40 +1217,80 @@ export default function Chat() { {msg.role === 'assistant' && activeChat.model && ( {activeChat.model} )} -
- {msg.role === 'user' ? ( - - ) : ( -
- )} -
+ {editingMessageIndex === i ? ( +
+