diff --git a/.agents/ci-caching.md b/.agents/ci-caching.md index 4b490575b349..1aa9c54ac3d0 100644 --- a/.agents/ci-caching.md +++ b/.agents/ci-caching.md @@ -125,7 +125,7 @@ The per-backend prefix match only sees files under a backend's own directory, so | `backend/backend.proto` | nothing if the edit is additive-only, otherwise everything (see below) | | `backend/Dockerfile.` | the Linux entries whose `dockerfile:` names it | | `backend/python/common/` | Python, Linux + Darwin | -| `scripts/build/package-gpu-libs.sh` | Python, Linux only | +| `scripts/build/package-gpu-libs.sh` | every Linux entry (Python, Go and C++ all run it) | | `scripts/build/-darwin.sh` | the Darwin entries that build target routes to | | `.github/workflows/backend_build[_darwin].yml` | everything on that OS | | anything else under `scripts/build/` (except `*_test.sh`) | everything — conservative default for unclassified packaging inputs | diff --git a/.docker/install-base-deps.sh b/.docker/install-base-deps.sh index 2b0e7e0c6a95..4331921ca6e3 100755 --- a/.docker/install-base-deps.sh +++ b/.docker/install-base-deps.sh @@ -113,6 +113,54 @@ if [ "${BUILD_TYPE:-}" = "vulkan" ] && [ "${SKIP_DRIVERS:-false}" = "false" ]; t rm -rf /var/lib/apt/lists/* fi +# --- 2b. Intel graphics driver (BUILD_TYPE=sycl*) --- +# The Intel oneAPI base image brings the compilers and the oneAPI libraries, but +# not the driver that talks to the graphics card. The packaging step copies that +# driver into the backend, so that the backend works on a machine which has no +# Intel graphics packages of its own, for the same reason the Vulkan section +# above installs the Mesa drivers. Install it here so there is something to copy. +# +# Only the sycl builds are covered, because those are the ones whose packaging +# copies the driver. See package_intel_libs in scripts/build/package-gpu-libs.sh. +# +# The driver comes from Intel's own package repository, not from the Ubuntu +# archive. The archive has 23.43 from late 2023, which does not know any card +# released since, so a machine with a recent Intel GPU would end up carrying a +# driver that cannot drive it. Intel's repository has 25.18 for the same Ubuntu +# release. +# +# Anything that goes wrong here fails the build, on purpose. An unreachable +# repository is a passing problem that a retry fixes, whereas carrying a +# different driver than intended, or none, is a difference nobody would notice +# until a user reports an idle GPU. +if case "${BUILD_TYPE:-}" in sycl*) true;; *) false;; esac \ + && [ "${SKIP_DRIVERS:-false}" = "false" ]; then + # Ubuntu release name, which is what the repository is indexed by. + ubuntu_codename=$(. /etc/os-release && echo "${VERSION_CODENAME:-}") + if [ -z "$ubuntu_codename" ]; then + echo "ERROR: cannot tell which Ubuntu release this image is, so cannot pick the Intel driver repository" >&2 + exit 1 + fi + + # The key is armored text, which apt reads directly from a .asc file, so + # there is no need for gnupg here. "unified" is the component Intel ships + # its current driver in. + mkdir -p /usr/share/keyrings + curl -fsSL https://repositories.intel.com/gpu/intel-graphics.key \ + -o /usr/share/keyrings/intel-graphics.asc + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.asc] https://repositories.intel.com/gpu/ubuntu ${ubuntu_codename} unified" \ + > /etc/apt/sources.list.d/intel-graphics.list + apt-get update + # The first package holds the driver OpenCL talks to, the second the driver + # Level Zero talks to. Between them they pull in the compiler and the memory + # manager that both need. + apt-get install -y --no-install-recommends \ + intel-opencl-icd \ + libze-intel-gpu1 + apt-get clean + rm -rf /var/lib/apt/lists/* +fi + # --- 3. CUDA toolkit (BUILD_TYPE=cublas|l4t) --- if { [ "${BUILD_TYPE:-}" = "cublas" ] || [ "${BUILD_TYPE:-}" = "l4t" ]; } && [ "${SKIP_DRIVERS:-false}" = "false" ]; then apt-get update 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" < { + 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 ? ( +
+