mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-21 07:05:07 -04:00
Compare commits
11 Commits
worktree-i
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c68818a62e | ||
|
|
11d5bd0cc3 | ||
|
|
12e056e96d | ||
|
|
308aa8908a | ||
|
|
b2d68a53a2 | ||
|
|
e3706c0512 | ||
|
|
1ffd82a050 | ||
|
|
f515168dbe | ||
|
|
ef6ca34513 | ||
|
|
9413c3767f | ||
|
|
3bf3cce232 |
@@ -1,10 +1,10 @@
|
||||
# ds4 backend Makefile.
|
||||
#
|
||||
# Upstream pin lives below as DS4_VERSION?=599e49d253971451f710cb8323344e789906ed6c
|
||||
# Upstream pin lives below as DS4_VERSION?=2606543be7a8c125a32cee37f5d1d85dc78f2fcf
|
||||
# (.github/bump_deps.sh) can find and update it - matches the
|
||||
# llama-cpp / ik-llama-cpp / turboquant convention.
|
||||
|
||||
DS4_VERSION?=599e49d253971451f710cb8323344e789906ed6c
|
||||
DS4_VERSION?=2606543be7a8c125a32cee37f5d1d85dc78f2fcf
|
||||
DS4_REPO?=https://github.com/antirez/ds4
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
IK_LLAMA_VERSION?=77413bc900f9a2bfd8a5407f184427bcc0825f6c
|
||||
IK_LLAMA_VERSION?=11a1fea9e291f12ce2c803a9d7812c30ca806bcf
|
||||
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=67ace021da905e27ecbdf1176b0eef578a5288c0
|
||||
LLAMA_VERSION?=ad277572619fcfb6ddd38f4c6437283a4b2b8636
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -522,12 +522,6 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
|
||||
// n_ctx_checkpoints: max context checkpoints per slot (default: 8)
|
||||
params.n_ctx_checkpoints = 8;
|
||||
|
||||
// llama memory fit fails if we don't provide a buffer for tensor overrides
|
||||
const size_t ntbo = llama_max_tensor_buft_overrides();
|
||||
while (params.tensor_buft_overrides.size() < ntbo) {
|
||||
params.tensor_buft_overrides.push_back({nullptr, nullptr});
|
||||
}
|
||||
|
||||
// decode options. Options are in form optname:optvale, or if booleans only optname.
|
||||
for (int i = 0; i < request->options_size(); i++) {
|
||||
std::string opt = request->options(i);
|
||||
@@ -1081,6 +1075,20 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
|
||||
params.kv_overrides.back().key[0] = 0;
|
||||
}
|
||||
|
||||
// tensor_buft_overrides sentinel termination (mirrors upstream common/arg.cpp).
|
||||
// Real entries are pushed during option parsing; here we pad/terminate so the
|
||||
// model loader sees back().pattern == nullptr (GGML_ASSERT at common.cpp:1543)
|
||||
// and so llama_params_fit has the placeholder slots it requires.
|
||||
{
|
||||
const size_t ntbo = llama_max_tensor_buft_overrides();
|
||||
while (params.tensor_buft_overrides.size() < ntbo) {
|
||||
params.tensor_buft_overrides.push_back({nullptr, nullptr});
|
||||
}
|
||||
}
|
||||
if (!params.speculative.draft.tensor_buft_overrides.empty()) {
|
||||
params.speculative.draft.tensor_buft_overrides.push_back({nullptr, nullptr});
|
||||
}
|
||||
|
||||
// TODO: Add yarn
|
||||
|
||||
if (!request->tensorsplit().empty()) {
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# acestep.cpp version
|
||||
ACESTEP_REPO?=https://github.com/ace-step/acestep.cpp
|
||||
ACESTEP_CPP_VERSION?=e0c8d75a672fca5684c88c68dbf6d12f58754258
|
||||
ACESTEP_CPP_VERSION?=ed53caf164e4492a5620b2e3f2264629cf66da24
|
||||
SO_TARGET?=libgoacestepcpp.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -22,12 +22,11 @@
|
||||
#include <vector>
|
||||
|
||||
// Global model contexts (loaded once, reused across requests)
|
||||
static DiTGGML g_dit = {};
|
||||
static DiTGGMLConfig g_dit_cfg;
|
||||
static VAEGGML g_vae = {};
|
||||
static bool g_dit_loaded = false;
|
||||
static bool g_vae_loaded = false;
|
||||
static bool g_is_turbo = false;
|
||||
static DiTGGML g_dit = {};
|
||||
static VAEGGML g_vae = {};
|
||||
static bool g_dit_loaded = false;
|
||||
static bool g_vae_loaded = false;
|
||||
static bool g_is_turbo = false;
|
||||
|
||||
// Silence latent [15000, 64] — read once from DiT GGUF
|
||||
static std::vector<float> g_silence_full;
|
||||
@@ -72,10 +71,9 @@ int load_model(const char * lm_model_path, const char * text_encoder_path,
|
||||
g_text_enc_path = text_encoder_path;
|
||||
g_dit_path = dit_model_path;
|
||||
|
||||
// Load DiT model
|
||||
// Load DiT model (backend init + config are handled inside dit_ggml_load)
|
||||
fprintf(stderr, "[acestep-cpp] Loading DiT from %s\n", dit_model_path);
|
||||
dit_ggml_init_backend(&g_dit);
|
||||
if (!dit_ggml_load(&g_dit, dit_model_path, g_dit_cfg, nullptr, 0.0f)) {
|
||||
if (!dit_ggml_load(&g_dit, dit_model_path)) {
|
||||
fprintf(stderr, "[acestep-cpp] FATAL: failed to load DiT from %s\n", dit_model_path);
|
||||
return 1;
|
||||
}
|
||||
@@ -149,16 +147,16 @@ int generate_music(const char * caption, const char * lyrics, int bpm,
|
||||
|
||||
// Compute T (latent frames at 25Hz)
|
||||
int T = (int)(duration * FRAMES_PER_SECOND);
|
||||
T = ((T + g_dit_cfg.patch_size - 1) / g_dit_cfg.patch_size) * g_dit_cfg.patch_size;
|
||||
int S = T / g_dit_cfg.patch_size;
|
||||
T = ((T + g_dit.cfg.patch_size - 1) / g_dit.cfg.patch_size) * g_dit.cfg.patch_size;
|
||||
int S = T / g_dit.cfg.patch_size;
|
||||
|
||||
if (T > 15000) {
|
||||
fprintf(stderr, "[acestep-cpp] ERROR: T=%d exceeds max 15000\n", T);
|
||||
return 2;
|
||||
}
|
||||
|
||||
int Oc = g_dit_cfg.out_channels; // 64
|
||||
int ctx_ch = g_dit_cfg.in_channels - Oc; // 128
|
||||
int Oc = g_dit.cfg.out_channels; // 64
|
||||
int ctx_ch = g_dit.cfg.in_channels - Oc; // 128
|
||||
|
||||
fprintf(stderr, "[acestep-cpp] T=%d, S=%d, duration=%.1fs, seed=%d\n", T, S, duration, seed);
|
||||
|
||||
@@ -191,9 +189,8 @@ int generate_music(const char * caption, const char * lyrics, int bpm,
|
||||
|
||||
fprintf(stderr, "[acestep-cpp] caption: %d tokens, lyrics: %d tokens\n", S_text, S_lyric);
|
||||
|
||||
// 4. Text encoder forward
|
||||
// 4. Text encoder forward (backend init handled inside qwen3_load_text_encoder)
|
||||
Qwen3GGML text_enc = {};
|
||||
qwen3_init_backend(&text_enc);
|
||||
if (!qwen3_load_text_encoder(&text_enc, g_text_enc_path.c_str())) {
|
||||
fprintf(stderr, "[acestep-cpp] FATAL: failed to load text encoder\n");
|
||||
return 4;
|
||||
@@ -209,9 +206,8 @@ int generate_music(const char * caption, const char * lyrics, int bpm,
|
||||
std::vector<float> lyric_embed(H_text * S_lyric);
|
||||
qwen3_embed_lookup(&text_enc, lyric_ids.data(), S_lyric, lyric_embed.data());
|
||||
|
||||
// 6. Condition encoder
|
||||
// 6. Condition encoder (backend init handled inside cond_ggml_load)
|
||||
CondGGML cond = {};
|
||||
cond_ggml_init_backend(&cond);
|
||||
if (!cond_ggml_load(&cond, g_dit_path.c_str())) {
|
||||
fprintf(stderr, "[acestep-cpp] FATAL: failed to load condition encoder\n");
|
||||
qwen3_free(&text_enc);
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# stablediffusion.cpp (ggml)
|
||||
STABLEDIFFUSION_GGML_REPO?=https://github.com/leejet/stable-diffusion.cpp
|
||||
STABLEDIFFUSION_GGML_VERSION?=bd17f53b7386fb5f60e8587b75e73c4b2fed3426
|
||||
STABLEDIFFUSION_GGML_VERSION?=5b0267e941cade15bd80089d89838795d9f4baa6
|
||||
|
||||
CMAKE_ARGS+=-DGGML_MAX_NAME=128
|
||||
|
||||
|
||||
@@ -1188,6 +1188,9 @@ int gen_video(sd_vid_gen_params_t *p, int steps, char *dst, float cfg_scale, int
|
||||
p->high_noise_sample_params.scheduler = scheduler;
|
||||
p->high_noise_sample_params.flow_shift = flow_shift;
|
||||
|
||||
// Pin output fps in params; upstream uses it for audio sync (and we also mux at this rate).
|
||||
p->fps = fps;
|
||||
|
||||
// Load init/end reference images if provided (resized to output dims).
|
||||
uint8_t* init_buf = nullptr;
|
||||
uint8_t* end_buf = nullptr;
|
||||
@@ -1206,11 +1209,14 @@ int gen_video(sd_vid_gen_params_t *p, int steps, char *dst, float cfg_scale, int
|
||||
|
||||
// Generate
|
||||
int num_frames_out = 0;
|
||||
sd_image_t* frames = generate_video(sd_c, p, &num_frames_out);
|
||||
sd_image_t* frames = nullptr;
|
||||
sd_audio_t* audio = nullptr;
|
||||
bool ok = generate_video(sd_c, p, &frames, &num_frames_out, &audio);
|
||||
std::free(p);
|
||||
|
||||
if (!frames || num_frames_out == 0) {
|
||||
if (!ok || !frames || num_frames_out == 0) {
|
||||
fprintf(stderr, "generate_video produced no frames\n");
|
||||
if (audio) free_sd_audio(audio);
|
||||
if (init_buf) free(init_buf);
|
||||
if (end_buf) free(end_buf);
|
||||
return 1;
|
||||
@@ -1224,6 +1230,7 @@ int gen_video(sd_vid_gen_params_t *p, int steps, char *dst, float cfg_scale, int
|
||||
if (frames[i].data) free(frames[i].data);
|
||||
}
|
||||
free(frames);
|
||||
if (audio) free_sd_audio(audio);
|
||||
if (init_buf) free(init_buf);
|
||||
if (end_buf) free(end_buf);
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ torch==2.7.1
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
accelerate
|
||||
transformers>=5.8.0
|
||||
transformers>=5.8.1
|
||||
bitsandbytes
|
||||
sentence-transformers==5.4.0
|
||||
sentence-transformers==5.5.0
|
||||
diffusers
|
||||
soundfile
|
||||
protobuf==6.33.5
|
||||
@@ -2,9 +2,9 @@ torch==2.7.1
|
||||
accelerate
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
transformers>=5.8.0
|
||||
transformers>=5.8.1
|
||||
bitsandbytes
|
||||
sentence-transformers==5.4.0
|
||||
sentence-transformers==5.5.0
|
||||
diffusers
|
||||
soundfile
|
||||
protobuf==6.33.5
|
||||
@@ -2,9 +2,9 @@
|
||||
torch==2.9.0
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
transformers>=5.8.0
|
||||
transformers>=5.8.1
|
||||
bitsandbytes
|
||||
sentence-transformers==5.4.0
|
||||
sentence-transformers==5.5.0
|
||||
diffusers
|
||||
soundfile
|
||||
protobuf==6.33.5
|
||||
@@ -1,11 +1,11 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/rocm7.0
|
||||
torch==2.10.0+rocm7.0
|
||||
accelerate
|
||||
transformers>=5.8.0
|
||||
transformers>=5.8.1
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
bitsandbytes
|
||||
sentence-transformers==5.4.0
|
||||
sentence-transformers==5.5.0
|
||||
diffusers
|
||||
soundfile
|
||||
protobuf==6.33.5
|
||||
@@ -3,9 +3,9 @@ torch
|
||||
optimum[openvino]
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
transformers>=5.8.0
|
||||
transformers>=5.8.1
|
||||
bitsandbytes
|
||||
sentence-transformers==5.4.0
|
||||
sentence-transformers==5.5.0
|
||||
diffusers
|
||||
soundfile
|
||||
protobuf==6.33.5
|
||||
@@ -2,9 +2,9 @@ torch==2.7.1
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
accelerate
|
||||
transformers>=5.8.0
|
||||
transformers>=5.8.1
|
||||
bitsandbytes
|
||||
sentence-transformers==5.4.0
|
||||
sentence-transformers==5.5.0
|
||||
diffusers
|
||||
soundfile
|
||||
protobuf==6.33.5
|
||||
|
||||
143
core/http/react-ui/e2e/chat-polling-selection.spec.js
Normal file
143
core/http/react-ui/e2e/chat-polling-selection.spec.js
Normal file
@@ -0,0 +1,143 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Regression coverage for issue #9904:
|
||||
// - /api/operations was polled every 1s and *always* re-rendered the Chat
|
||||
// page, even when the response was unchanged. The reconciliation would
|
||||
// collapse any text selection inside an assistant message.
|
||||
// - The copy button next to each assistant message used navigator.clipboard
|
||||
// without any fallback, which is undefined when the page is served over
|
||||
// plain http (non-secure context) from a remote host.
|
||||
|
||||
async function setupChatPage(page) {
|
||||
await page.route('**/api/models/capabilities', (route) => {
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }],
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// Poll-tracking mock: assert the hook is hammering /api/operations every
|
||||
// ~1s, and always return an empty list so its contents never change.
|
||||
let operationsHits = 0
|
||||
await page.route('**/api/operations', (route) => {
|
||||
operationsHits++
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ operations: [] }),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/v1/chat/completions', (route) => {
|
||||
// One short SSE stream so the chat finishes streaming quickly and we
|
||||
// can interact with a stable assistant message.
|
||||
const body = [
|
||||
'data: {"choices":[{"delta":{"content":"Hello world this is a long assistant reply that we can try to select."},"index":0}]}\n\n',
|
||||
'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
].join('')
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body,
|
||||
})
|
||||
})
|
||||
|
||||
return { getOperationsHits: () => operationsHits }
|
||||
}
|
||||
|
||||
test.describe('Chat - /api/operations polling (#9904)', () => {
|
||||
test('text selection inside an assistant message survives polling', async ({ page }) => {
|
||||
const { getOperationsHits } = await setupChatPage(page)
|
||||
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.locator('.chat-input').fill('Hi')
|
||||
await page.locator('.chat-send-btn').click()
|
||||
|
||||
const assistantContent = page.locator('.chat-message-assistant .chat-message-content').first()
|
||||
await expect(assistantContent).toContainText('Hello world', { timeout: 10_000 })
|
||||
|
||||
// Sanity check: the polling we're regressing against is actually firing.
|
||||
await page.waitForTimeout(2_500)
|
||||
expect(getOperationsHits()).toBeGreaterThan(1)
|
||||
|
||||
// Sanity check that the bug we're guarding against is structurally
|
||||
// possible: count how many times the assistant content node gets
|
||||
// *touched* by React (childList / characterData mutations) over a
|
||||
// 3-second window. Before the fix, every poll re-rendered Chat and
|
||||
// re-set dangerouslySetInnerHTML, triggering a mutation cascade that
|
||||
// collapsed the user's text selection. After the fix, polling with
|
||||
// identical contents must not mutate the DOM at all.
|
||||
const mutationCount = await assistantContent.evaluate((el) => new Promise((resolve) => {
|
||||
let count = 0
|
||||
const obs = new MutationObserver((records) => { count += records.length })
|
||||
obs.observe(el, { childList: true, subtree: true, characterData: true })
|
||||
setTimeout(() => { obs.disconnect(); resolve(count) }, 3_000)
|
||||
}))
|
||||
expect(mutationCount).toBe(0)
|
||||
|
||||
// Same sanity check translated to a user-observable property: a
|
||||
// programmatically created selection survives the polling window.
|
||||
await assistantContent.evaluate((el) => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
const sel = window.getSelection()
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
})
|
||||
|
||||
const initialSelection = await page.evaluate(() => window.getSelection().toString())
|
||||
expect(initialSelection).toContain('Hello world')
|
||||
|
||||
await page.waitForTimeout(2_500)
|
||||
|
||||
const selectionAfterPolling = await page.evaluate(() => window.getSelection().toString())
|
||||
expect(selectionAfterPolling).toBe(initialSelection)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Chat - copy button (#9904)', () => {
|
||||
test('copy button works when navigator.clipboard is unavailable (plain http)', async ({ page }) => {
|
||||
await setupChatPage(page)
|
||||
|
||||
// Simulate a non-secure context: hide navigator.clipboard before any of
|
||||
// our app code touches it. This mirrors what browsers do over plain
|
||||
// http from a remote host.
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true })
|
||||
try {
|
||||
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })
|
||||
} catch { /* some browsers refuse — the secure-context flag is enough */ }
|
||||
})
|
||||
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.locator('.chat-input').fill('Hi')
|
||||
await page.locator('.chat-send-btn').click()
|
||||
|
||||
const assistantBubble = page.locator('.chat-message-assistant .chat-message-bubble').first()
|
||||
await expect(assistantBubble).toContainText('Hello world', { timeout: 10_000 })
|
||||
|
||||
// Spy on document.execCommand so we can confirm the fallback path ran.
|
||||
await page.evaluate(() => {
|
||||
window.__execCommandCalls = []
|
||||
const original = document.execCommand?.bind(document)
|
||||
document.execCommand = (cmd, ...rest) => {
|
||||
window.__execCommandCalls.push(cmd)
|
||||
// execCommand('copy') in a headless browser may return false because
|
||||
// there is no real clipboard, but the fact that we tried is what we
|
||||
// care about for this regression.
|
||||
return original ? original(cmd, ...rest) : false
|
||||
}
|
||||
})
|
||||
|
||||
await assistantBubble.locator('.chat-message-actions button').first().click()
|
||||
|
||||
const execCommandCalls = await page.evaluate(() => window.__execCommandCalls)
|
||||
expect(execCommandCalls).toContain('copy')
|
||||
})
|
||||
})
|
||||
@@ -97,7 +97,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"selectModel": "Bitte wählen Sie ein Modell",
|
||||
"copied": "In die Zwischenablage kopiert"
|
||||
"copied": "In die Zwischenablage kopiert",
|
||||
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen"
|
||||
},
|
||||
"menu": {
|
||||
"trigger": "Chats",
|
||||
|
||||
@@ -97,7 +97,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"selectModel": "Please select a model",
|
||||
"copied": "Copied to clipboard"
|
||||
"copied": "Copied to clipboard",
|
||||
"copyFailed": "Could not copy to clipboard"
|
||||
},
|
||||
"menu": {
|
||||
"trigger": "Chats",
|
||||
|
||||
@@ -97,7 +97,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"selectModel": "Por favor selecciona un modelo",
|
||||
"copied": "Copiado al portapapeles"
|
||||
"copied": "Copiado al portapapeles",
|
||||
"copyFailed": "No se pudo copiar al portapapeles"
|
||||
},
|
||||
"menu": {
|
||||
"trigger": "Chats",
|
||||
|
||||
@@ -97,7 +97,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"selectModel": "Seleziona un modello",
|
||||
"copied": "Copiato negli appunti"
|
||||
"copied": "Copiato negli appunti",
|
||||
"copyFailed": "Impossibile copiare negli appunti"
|
||||
},
|
||||
"menu": {
|
||||
"trigger": "Chat",
|
||||
|
||||
@@ -97,7 +97,8 @@
|
||||
},
|
||||
"toasts": {
|
||||
"selectModel": "请选择一个模型",
|
||||
"copied": "已复制到剪贴板"
|
||||
"copied": "已复制到剪贴板",
|
||||
"copyFailed": "无法复制到剪贴板"
|
||||
},
|
||||
"menu": {
|
||||
"trigger": "聊天",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
import { getArtifactIcon } from '../utils/artifacts'
|
||||
import { safeHref } from '../utils/url'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
import DOMPurify from 'dompurify'
|
||||
import hljs from 'highlight.js'
|
||||
|
||||
@@ -23,11 +24,13 @@ export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }
|
||||
}
|
||||
}, [current, showPreview])
|
||||
|
||||
const handleCopy = () => {
|
||||
const handleCopy = async () => {
|
||||
const text = current.code || current.url || ''
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopySuccess(true)
|
||||
setTimeout(() => setCopySuccess(false), 2000)
|
||||
const ok = await copyToClipboard(text)
|
||||
if (ok) {
|
||||
setCopySuccess(true)
|
||||
setTimeout(() => setCopySuccess(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
|
||||
28
core/http/react-ui/src/hooks/useOperations.js
vendored
28
core/http/react-ui/src/hooks/useOperations.js
vendored
@@ -2,6 +2,14 @@ import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { operationsApi } from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
// Serialize ops into a stable comparison key. Each op is a flat map of
|
||||
// primitives, so JSON.stringify is good enough and stable as long as the
|
||||
// server emits keys in the same order (Go's map iteration into JSON happens
|
||||
// to be stable here because we build an explicit map[string]any).
|
||||
function serializeOps(ops) {
|
||||
return JSON.stringify(ops)
|
||||
}
|
||||
|
||||
export function useOperations(pollInterval = 1000) {
|
||||
const [operations, setOperations] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -11,16 +19,26 @@ export function useOperations(pollInterval = 1000) {
|
||||
|
||||
const previousCountRef = useRef(0)
|
||||
const onAllCompleteRef = useRef(null)
|
||||
// Track the last payload we wrote into state. Each poll otherwise produces
|
||||
// a fresh array reference even when nothing changed, and that re-render
|
||||
// ripples into the Chat page — wiping the user's text selection mid-read
|
||||
// (#9904).
|
||||
const lastSerializedRef = useRef('[]')
|
||||
|
||||
const fetchOperations = useCallback(async () => {
|
||||
if (!isAdmin) {
|
||||
setLoading(false)
|
||||
setLoading((prev) => (prev ? false : prev))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await operationsApi.list()
|
||||
const ops = data?.operations || (Array.isArray(data) ? data : [])
|
||||
setOperations(ops)
|
||||
|
||||
const serialized = serializeOps(ops)
|
||||
if (serialized !== lastSerializedRef.current) {
|
||||
lastSerializedRef.current = serialized
|
||||
setOperations(ops)
|
||||
}
|
||||
|
||||
// Separate active (non-failed) operations from failed ones
|
||||
const activeOps = ops.filter(op => !op.error)
|
||||
@@ -32,11 +50,11 @@ export function useOperations(pollInterval = 1000) {
|
||||
}
|
||||
previousCountRef.current = activeOps.length
|
||||
|
||||
setError(null)
|
||||
setError((prev) => (prev === null ? prev : null))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setError((prev) => (prev === err.message ? prev : err.message))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading((prev) => (prev ? false : prev))
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import ResourceCards from '../components/ResourceCards'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import { useAgentChat } from '../hooks/useAgentChat'
|
||||
import { relativeTime } from '../utils/format'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
function getLastMessagePreview(conv) {
|
||||
if (!conv.messages || conv.messages.length === 0) return ''
|
||||
@@ -390,9 +391,13 @@ export default function AgentChat() {
|
||||
}
|
||||
}
|
||||
|
||||
const copyMessage = (content) => {
|
||||
navigator.clipboard.writeText(content)
|
||||
addToast('Copied to clipboard', 'success', 2000)
|
||||
const copyMessage = async (content) => {
|
||||
const ok = await copyToClipboard(content)
|
||||
addToast(
|
||||
ok ? 'Copied to clipboard' : 'Could not copy to clipboard',
|
||||
ok ? 'success' : 'error',
|
||||
ok ? 2000 : 3000,
|
||||
)
|
||||
}
|
||||
|
||||
const senderToRole = (sender) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import ChatsMenu from '../components/ChatsMenu'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useOperations } from '../hooks/useOperations'
|
||||
import { relativeTime } from '../utils/format'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
function getLastMessagePreview(chat) {
|
||||
if (!chat.history || chat.history.length === 0) return ''
|
||||
@@ -798,10 +799,14 @@ export default function Chat() {
|
||||
}
|
||||
}
|
||||
|
||||
const copyMessage = (content) => {
|
||||
const copyMessage = async (content) => {
|
||||
const text = typeof content === 'string' ? content : content?.[0]?.text || ''
|
||||
navigator.clipboard.writeText(text)
|
||||
addToast(t('toasts.copied'), 'success', 2000)
|
||||
const ok = await copyToClipboard(text)
|
||||
if (ok) {
|
||||
addToast(t('toasts.copied'), 'success', 2000)
|
||||
} else {
|
||||
addToast(t('toasts.copyFailed'), 'error', 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const contextPercent = getContextUsagePercent()
|
||||
|
||||
81
core/http/react-ui/src/utils/clipboard.js
vendored
Normal file
81
core/http/react-ui/src/utils/clipboard.js
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
// Clipboard helper that works in non-secure contexts.
|
||||
//
|
||||
// navigator.clipboard is only defined on https:// origins and on
|
||||
// http://localhost. When LocalAI is served over plain http from a remote
|
||||
// host (LXC + Docker is a common deployment), every page that called
|
||||
// `navigator.clipboard.writeText` silently failed (#9904). This helper
|
||||
// transparently falls back to a hidden-textarea + execCommand('copy')
|
||||
// trick that browsers still honour when the page is not a secure context.
|
||||
//
|
||||
// Returns true on success, false on failure. Callers should use the return
|
||||
// value to drive the success/failure toast — the old code always claimed
|
||||
// success regardless of what actually happened.
|
||||
export async function copyToClipboard(text) {
|
||||
if (text == null) return false
|
||||
const value = typeof text === 'string' ? text : String(text)
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
return true
|
||||
} catch {
|
||||
// Permissions denied, browser refused, etc. — try the fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return legacyCopy(value)
|
||||
}
|
||||
|
||||
function legacyCopy(value) {
|
||||
if (typeof document === 'undefined') return false
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = value
|
||||
// Keep the textarea out of the viewport and out of layout reads. Using
|
||||
// `position: fixed` + a negative offset avoids scrolling the page when
|
||||
// we call .select() below.
|
||||
ta.setAttribute('readonly', '')
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.top = '0'
|
||||
ta.style.left = '-9999px'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
// Preserve the current selection so triggering execCommand doesn't blow
|
||||
// away whatever the user had highlighted on the page.
|
||||
const previousSelection = saveSelection()
|
||||
let ok = false
|
||||
try {
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, value.length)
|
||||
ok = document.execCommand('copy')
|
||||
} catch {
|
||||
ok = false
|
||||
} finally {
|
||||
document.body.removeChild(ta)
|
||||
restoreSelection(previousSelection)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
function saveSelection() {
|
||||
try {
|
||||
const sel = window.getSelection()
|
||||
if (!sel || sel.rangeCount === 0) return null
|
||||
const ranges = []
|
||||
for (let i = 0; i < sel.rangeCount; i++) ranges.push(sel.getRangeAt(i).cloneRange())
|
||||
return ranges
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSelection(ranges) {
|
||||
if (!ranges) return
|
||||
try {
|
||||
const sel = window.getSelection()
|
||||
if (!sel) return
|
||||
sel.removeAllRanges()
|
||||
for (const r of ranges) sel.addRange(r)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
@@ -1170,8 +1170,8 @@
|
||||
use_tokenizer_template: true
|
||||
files:
|
||||
- filename: llama-cpp/models/Qwen_Qwen3.5-35B-A3B-GGUF/Qwen_Qwen3.5-35B-A3B-Q4_K_M.gguf
|
||||
sha256: ac15eef4c742ff7700cba697238b25832087b67578f213d5ea24c9e3a6dc4457
|
||||
uri: https://huggingface.co/bartowski/Qwen_Qwen3.5-35B-A3B-GGUF/resolve/main/Qwen_Qwen3.5-35B-A3B-Q4_K_M.gguf
|
||||
sha256: 2f2df1e8b2e92b642c1850ea1734b341cc8ca5098c42cc0a8b8c436a8d4751ab
|
||||
- filename: llama-cpp/mmproj/Qwen_Qwen3.5-35B-A3B-GGUF/mmproj-Qwen_Qwen3.5-35B-A3B-f16.gguf
|
||||
sha256: 10cf13cb1f8434f30df8fa7e5bde98d542fbf397550cb489dfa9eb8ac7069035
|
||||
uri: https://huggingface.co/bartowski/Qwen_Qwen3.5-35B-A3B-GGUF/resolve/main/mmproj-Qwen_Qwen3.5-35B-A3B-f16.gguf
|
||||
|
||||
Reference in New Issue
Block a user