mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-23 15:53:46 +00:00
* Studio: save a chat or reply to project sources * Studio: invalidate the sources probe before and after chat saves * Studio: resolve the reply's project from its thread before saving * Studio: refresh an open Sources panel when a source is saved elsewhere * Studio: refresh the sources panel without resurrecting a deleted row invalidateProjectSources dispatched the refresh event, and the panel calls it before each of its own mutations as well as after. handleRemove drops the row optimistically and then invalidates, so the listener refetched while the DELETE had not been sent yet and the row came straight back; useRagDocuments has no request sequencing, so the staler of the two answers can also land last. Split the two jobs. invalidateProjectSources stays a cache drop. announceProjectSourcesUpdated drops the cache and tells a mounted list to refetch, and only the save path calls it, after the upload. The panel's subscription moves into subscribeProjectSourcesUpdated so the projectId filter and the teardown are testable without a DOM. * Studio: name and report a chat saved to project sources Five things a save got wrong once it left the upload call. The filename. The backend stores the bytes under a uuid and re-sanitises the name for its own metadata, so this was never a path safety problem, but it is what the sources panel lists. Break the Windows device names, which stay reserved through an extension, drop control characters and trailing dots and spaces, clamp to a byte budget on a code point boundary so a surrogate pair is never split, and fall back to chat.md where the backend's own pass would leave just "_.md". Success. The toast fired on upload acceptance; indexing is a job, and the panel hides a document whose job failed, so a failed ingest was silent behind "Saved to project sources." Poll the job and raise the same "Couldn't index X" the panel's own upload path raises. Errors. The upload failure was caught and toasted but never returned, so the callers could not tell a save that worked from one that did not, and a pair save could not stop or report a partial failure. Pair saves. Both halves carried the row's single title, so one click produced two toasts and two sources with the same name. Name each half after the model that answered it, and report the count once. Wording. A reply saved under the chat's own title collided with the whole chat saved under it; mark the reply apart. And an empty conversation reported "No messages in this conversation to export." from something that is not an export. * Studio: cover saving a chat or reply to project sources The reply's destination is the one that matters: it comes from the thread being read, not from the project the sidebar has selected, or a save uploads one project's chat into another project's sources. thread.tsx is TSX node cannot load, so that test reads the source the way rag-availability-marker.test.ts does for the same file; swapping the argument back to activeProjectId fails it. The rest covers the filename rules, the pair naming, and the save itself: one toast per click, a failure that says why, a failed ingest that is not left silent, and a mounted sources list that actually refetches rather than merely being sent an event. The toast stub records instead of swallowing, and the authFetch stub can be given a handler for the one test that needs a response, restored per test so nothing leaks into the suites that expect no network at all. * Studio: tell a compare pair's halves apart when they share a model label The LoRA compare runs one checkpoint with the adapter off and on, so both threads record the same modelId and the two saved sources came out with byte-identical titles. The sources panel renders only the filename, and the backend keys uploads by uuid, so the pair arrived as two indistinguishable rows. Name a colliding half by its modelType, or by position when that is absent; two different models keep the plain name. * Studio: save a reply whole, and name compare halves by pane not arrival getCopyText joins text parts alone, so saving a reply dropped its reasoning, tool calls and citations, and a reply that was only a tool call reported as having no content. Route it through the same conversion the whole-chat save uses. listStoredChatThreads sorts by updatedAt, so the positional fallback numbered the halves by whichever answered last: saving one pair twice could swap the two names. modelType already carries the pane, so name from that and keep position as a last resort only. --------- Co-authored-by: danielhanchen <danielhanchen@gmail.com>
119 lines
4.5 KiB
TypeScript
119 lines
4.5 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { planChatItemSources } from "../src/features/chat/utils/project-source-plan.ts";
|
|
|
|
test("a single chat is saved once, under its own title", () => {
|
|
assert.deepEqual(
|
|
planChatItemSources({ id: "t1", title: "Fix my regex", type: "single" }, []),
|
|
[{ id: "t1", title: "Fix my regex" }],
|
|
);
|
|
});
|
|
|
|
test("each half of a pair is named after the model that answered", () => {
|
|
assert.deepEqual(
|
|
planChatItemSources({ id: "p1", title: "Fix my regex", type: "pair" }, [
|
|
{ id: "t1", modelId: "unsloth/Qwen3-8B-GGUF:Q4_K_M" },
|
|
{ id: "t2", modelId: "openai/gpt-5" },
|
|
]),
|
|
[
|
|
{ id: "t1", title: "Fix my regex - Qwen3-8B-GGUF" },
|
|
{ id: "t2", title: "Fix my regex - gpt-5" },
|
|
],
|
|
);
|
|
});
|
|
|
|
test("an unnamed model falls back to its position, so the two never collide", () => {
|
|
const plans = planChatItemSources(
|
|
{ id: "p1", title: "Fix my regex", type: "pair" },
|
|
[{ id: "t1" }, { id: "t2", modelId: " " }],
|
|
);
|
|
assert.deepEqual(
|
|
plans.map((plan) => plan.title),
|
|
["Fix my regex - 1", "Fix my regex - 2"],
|
|
);
|
|
assert.equal(new Set(plans.map((plan) => plan.title)).size, 2);
|
|
});
|
|
|
|
test("a LoRA compare names the adapter halves apart, not twice the same", () => {
|
|
// The base/lora compare toggles the adapter on one loaded checkpoint, so both
|
|
// threads record the same modelId; the model name alone cannot tell them apart.
|
|
const plans = planChatItemSources(
|
|
{ id: "p1", title: "Fix my regex", type: "pair" },
|
|
[
|
|
{ id: "t1", modelId: "unsloth/Qwen3-8B", modelType: "base" },
|
|
{ id: "t2", modelId: "unsloth/Qwen3-8B", modelType: "lora" },
|
|
],
|
|
);
|
|
assert.deepEqual(plans, [
|
|
{ id: "t1", title: "Fix my regex - Qwen3-8B - base" },
|
|
{ id: "t2", title: "Fix my regex - Qwen3-8B - fine-tuned" },
|
|
]);
|
|
});
|
|
|
|
test("two panes on the same checkpoint fall back to their position", () => {
|
|
// Same repo, different quant: the variant is not part of modelId, and the
|
|
// colon suffix is stripped anyway, so both labels read "Qwen3-8B-GGUF".
|
|
const plans = planChatItemSources(
|
|
{ id: "p1", title: "Fix my regex", type: "pair" },
|
|
[
|
|
{ id: "t1", modelId: "unsloth/Qwen3-8B-GGUF:Q4_K_M", modelType: "model1" },
|
|
{ id: "t2", modelId: "unsloth/Qwen3-8B-GGUF:Q8_0", modelType: "model2" },
|
|
],
|
|
);
|
|
assert.deepEqual(
|
|
plans.map((plan) => plan.title),
|
|
["Fix my regex - Qwen3-8B-GGUF - 1", "Fix my regex - Qwen3-8B-GGUF - 2"],
|
|
);
|
|
assert.equal(new Set(plans.map((plan) => plan.title)).size, 2);
|
|
});
|
|
|
|
test("a pane keeps its number whichever half answered last", () => {
|
|
// listStoredChatThreads sorts by updatedAt, so the pane that finished last
|
|
// arrives first. Numbering by arrival would label model2's source "- 1" and
|
|
// swap the two names between saves of the same pair.
|
|
const panes = [
|
|
{ id: "t1", modelId: "unsloth/Qwen3-8B-GGUF:Q4_K_M", modelType: "model1" },
|
|
{ id: "t2", modelId: "unsloth/Qwen3-8B-GGUF:Q8_0", modelType: "model2" },
|
|
];
|
|
const item = { id: "p1", title: "Fix my regex", type: "pair" };
|
|
const byId = (plans: { id: string; title: string }[]) =>
|
|
Object.fromEntries(plans.map((plan) => [plan.id, plan.title]));
|
|
assert.deepEqual(byId(planChatItemSources(item, [...panes].reverse())), {
|
|
t1: "Fix my regex - Qwen3-8B-GGUF - 1",
|
|
t2: "Fix my regex - Qwen3-8B-GGUF - 2",
|
|
});
|
|
assert.deepEqual(
|
|
byId(planChatItemSources(item, panes)),
|
|
byId(planChatItemSources(item, [...panes].reverse())),
|
|
);
|
|
});
|
|
|
|
test("a LoRA pair keeps its naming when the model name is what collides", () => {
|
|
const halves = [
|
|
{ id: "t1", modelType: "base" },
|
|
{ id: "t2", modelType: "lora" },
|
|
];
|
|
const item = { id: "p1", title: "Fix my regex", type: "pair" };
|
|
// No modelId at all: the label itself falls back to the pane, which must not
|
|
// move with arrival order either.
|
|
assert.deepEqual(planChatItemSources(item, [...halves].reverse()), [
|
|
{ id: "t2", title: "Fix my regex - fine-tuned" },
|
|
{ id: "t1", title: "Fix my regex - base" },
|
|
]);
|
|
});
|
|
|
|
test("a pair with one surviving half keeps the plain title", () => {
|
|
assert.deepEqual(
|
|
planChatItemSources({ id: "p1", title: "Fix my regex", type: "pair" }, [
|
|
{ id: "t1", modelId: "unsloth/Qwen3-8B" },
|
|
]),
|
|
[{ id: "t1", title: "Fix my regex" }],
|
|
);
|
|
assert.deepEqual(
|
|
planChatItemSources({ id: "p1", title: "Fix my regex", type: "pair" }, []),
|
|
[],
|
|
);
|
|
});
|