mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 16:52:26 +00:00
carry pasted text through queue, copy and export
Three places still read a message as if all its text were in `content`, so a
paste that crossed the threshold went missing from each.
The Queue button was gated on `canQueueCurrentPrompt`, which is false for any
attachment, and `onQueueClick` returned early on empty composer text. Only the
Enter path got the pasted-text branch in 16d1b72, so a mouse or touch user could
not queue what the keyboard could. Both now go through `queuePastedTextPrompt`.
`CopyButton` copies `getCopyText()`, which reads content alone, so copying a
paste-only turn produced nothing and a turn with an instruction produced only
the instruction. It now appends the pasted bodies.
`messageToText`, `messageToMarkdown` and `messageToPlainText` flatten attachment
text verbatim, so Markdown, CSV, JSONL and fine-tuning exports carried the
`<pasted_text name=... bytes=...>` marker that the same text never had when it
fitted inline. `unwrapPastedTextContent` strips it and returns anything else
untouched, so the `<attachment ...>` wrapper a real file has always exported
with is unchanged.
`attachmentsPastedText` joins with a blank line rather than a space now that
copy uses it; Chat Search collapses whitespace either way.
This commit is contained in:
parent
fdb7792820
commit
b5dc502523
5 changed files with 65 additions and 12 deletions
|
|
@ -36,6 +36,7 @@ import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
|
|||
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
|
||||
import { ChatDictationBar } from "@/components/assistant-ui/chat-dictation-bar";
|
||||
import {
|
||||
attachmentsPastedText,
|
||||
isPastedTextFile,
|
||||
pasteClipboardFiles,
|
||||
pasteLongTextAsFile,
|
||||
|
|
@ -3706,9 +3707,20 @@ const Composer: FC<{
|
|||
}
|
||||
// disableQueue (project new-chat composer) also blocks the queue
|
||||
// button, so a running thread shows Stop instead of Queue.
|
||||
queueDisabled={disableQueue || !canQueueCurrentPrompt}
|
||||
queueDisabled={
|
||||
disableQueue ||
|
||||
!(canQueueCurrentPrompt || canQueuePastedTextPrompt)
|
||||
}
|
||||
onQueueClick={() => {
|
||||
if (disableQueue) return;
|
||||
// Same pasted-text path the Enter key takes, or the button
|
||||
// would refuse what submitting the form accepts.
|
||||
if (
|
||||
canQueuePastedTextPrompt &&
|
||||
queuePastedTextPrompt(true)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const queuedPrompt = composerText.trim();
|
||||
if (queuedPrompt.length === 0) {
|
||||
return;
|
||||
|
|
@ -6067,7 +6079,11 @@ const CopyButton: FC = () => {
|
|||
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = aui.message().getCopyText();
|
||||
// getCopyText reads content only, and a long paste sits in an attachment.
|
||||
const pasted = attachmentsPastedText(aui.message().getState().attachments);
|
||||
const text = [aui.message().getCopyText(), pasted]
|
||||
.filter((part) => part.length > 0)
|
||||
.join("\n\n");
|
||||
if (await copyToClipboard(text)) {
|
||||
setCopied(true);
|
||||
if (resetTimeoutRef.current) {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export { pasteClipboardFiles } from "./utils/clipboard-files";
|
|||
export {
|
||||
PASTED_TEXT_PREVIEW_MAX_CHARS,
|
||||
attachmentContentText,
|
||||
attachmentsPastedText,
|
||||
createPastedTextFile,
|
||||
isPastedTextContent,
|
||||
isPastedTextFile,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import { toolResultModelText } from "../api/chat-adapter";
|
|||
import { usePlusMenuPrefsStore } from "../stores/plus-menu-prefs-store";
|
||||
import type { ThreadRecord, MessageRecord } from "../types";
|
||||
import { createConversationMarkdownExporter } from "../utils/conversation-markdown-export";
|
||||
import { unwrapPastedTextContent } from "../utils/pasted-text.ts";
|
||||
import {
|
||||
contentBlocksToMarkdownBlocks,
|
||||
renderConversationBlocks,
|
||||
|
|
@ -254,7 +255,11 @@ function messageToText(msg: { content: unknown; attachments?: unknown }): string
|
|||
if (Array.isArray(msg.attachments)) {
|
||||
for (const attachment of msg.attachments as Array<{ content?: unknown }>) {
|
||||
if (!attachment?.content) continue;
|
||||
const attText = contentBlocksToText(attachment.content);
|
||||
// A paste carries a wrapper the same text never had when it fitted
|
||||
// inline, so strip it rather than exporting the marker.
|
||||
const attText = unwrapPastedTextContent(
|
||||
contentBlocksToText(attachment.content),
|
||||
);
|
||||
if (attText) parts.push(attText);
|
||||
}
|
||||
}
|
||||
|
|
@ -273,6 +278,10 @@ function messageToMarkdown(msg: { content: unknown; attachments?: unknown }): st
|
|||
...contentBlocksToMarkdownBlocks(
|
||||
attachment.content,
|
||||
normalizeToolResult,
|
||||
).map((block) =>
|
||||
block.kind === "text"
|
||||
? { ...block, text: unwrapPastedTextContent(block.text) }
|
||||
: block,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -623,7 +632,8 @@ function messageToPlainText(msg: {
|
|||
}
|
||||
const block = b as Record<string, unknown>;
|
||||
if (block.type === "text" && typeof block.text === "string" && block.text) {
|
||||
parts.push(block.text);
|
||||
// No-op for message content, which never carries the paste wrapper.
|
||||
parts.push(unwrapPastedTextContent(block.text));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -222,17 +222,25 @@ export function attachmentsSample(
|
|||
return "";
|
||||
}
|
||||
|
||||
/** The whole body of a sent paste, unwrapped. Empty for any other content. */
|
||||
export function pastedTextContentBody(content: string): string {
|
||||
if (!isPastedTextContent(content)) return "";
|
||||
/**
|
||||
* A pasted body without its wrapper, for anything that shows the text as the
|
||||
* user pasted it: copy, exports, fine-tuning rows. Other content is untouched.
|
||||
*/
|
||||
export function unwrapPastedTextContent(content: string): string {
|
||||
if (!isPastedTextContent(content)) return content;
|
||||
const { start, end } = attachmentBodyRange(content);
|
||||
return content.slice(start, end);
|
||||
}
|
||||
|
||||
/** The whole body of a sent paste, unwrapped. Empty for any other content. */
|
||||
export function pastedTextContentBody(content: string): string {
|
||||
return isPastedTextContent(content) ? unwrapPastedTextContent(content) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Every pasted body on a message. Chat Search reads `content` only, so without
|
||||
* this a paste stops being findable the moment it moves into an attachment.
|
||||
* Other attachment types were never indexed and stay that way.
|
||||
* Every pasted body on a message, for the paths that read `content` alone and
|
||||
* would otherwise lose the paste: Chat Search and copying a message. Other
|
||||
* attachment types are not included, since neither path ever had them.
|
||||
*/
|
||||
export function attachmentsPastedText(
|
||||
attachments: readonly AttachmentLike[] | undefined,
|
||||
|
|
@ -245,7 +253,7 @@ export function attachmentsPastedText(
|
|||
if (body.length > 0) bodies.push(body);
|
||||
}
|
||||
}
|
||||
return bodies.join(" ");
|
||||
return bodies.join("\n\n");
|
||||
}
|
||||
|
||||
function clipboardText(clipboardData: DataTransfer): string {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
pastedTextOf,
|
||||
pastedTextPreview,
|
||||
shouldAttachPastedText,
|
||||
unwrapPastedTextContent,
|
||||
} from "../src/features/chat/utils/pasted-text.ts";
|
||||
|
||||
type ClipboardStub = {
|
||||
|
|
@ -296,10 +297,27 @@ test("chat search still finds what the paste moved out of the message", () => {
|
|||
{ content: [{ type: "text", text: sent }] },
|
||||
{ content: [{ type: "text", text: second }] },
|
||||
]),
|
||||
`${body} second body`,
|
||||
`${body}\n\nsecond body`,
|
||||
);
|
||||
});
|
||||
|
||||
test("copies and exports carry the paste, not its wrapper", () => {
|
||||
const body = "Deploy log\nline two";
|
||||
const sent = attachmentContentText("Deploy log.txt", body, true, 19);
|
||||
|
||||
// The marker is an implementation detail; the same text pasted below the
|
||||
// threshold never had one.
|
||||
assert.equal(unwrapPastedTextContent(sent), body);
|
||||
assert.ok(!unwrapPastedTextContent(sent).includes("pasted_text"));
|
||||
|
||||
// Everything else is handed back untouched, including the wrapper a real
|
||||
// attachment has always exported with.
|
||||
const attached = attachmentContentText("notes.txt", body, false);
|
||||
assert.equal(unwrapPastedTextContent(attached), attached);
|
||||
assert.equal(unwrapPastedTextContent("bare text"), "bare text");
|
||||
assert.equal(unwrapPastedTextContent(""), "");
|
||||
});
|
||||
|
||||
test("a sample of unwrapped content is left as it is", () => {
|
||||
// PDF and DOCX adapters write their own prefix with no wrapper.
|
||||
assert.equal(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue