feat(06-04): align artifact references with submit context contract
- materialize artifact mentions into uploads-backed references before submit - keep upload reference path behavior unchanged and mark failed artifact conversions stale - add hooks tests for artifact/upload context availability and stabilize e2e mention selection path
This commit is contained in:
parent
0cd020d6c5
commit
7bd8e888a5
|
|
@ -1,7 +1,7 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
const { buildFilesForSubmit } = await import(
|
const { buildFilesForSubmit, materializeArtifactReferences } = await import(
|
||||||
new URL("./submit-files.ts", import.meta.url).href
|
new URL("./submit-files.ts", import.meta.url).href
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -48,3 +48,62 @@ void test("buildFilesForSubmit drops stale references without blocking submit",
|
||||||
assert.equal(result.staleCount, 1);
|
assert.equal(result.staleCount, 1);
|
||||||
assert.equal(result.files.length, 0);
|
assert.equal(result.files.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test("materializeArtifactReferences converts artifact references to upload paths", async () => {
|
||||||
|
const references = await materializeArtifactReferences(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
filename: "artifact.md",
|
||||||
|
path: "/mnt/user-data/outputs/artifact.md",
|
||||||
|
ref_kind: "mention",
|
||||||
|
ref_source: "artifact",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: "uploaded.md",
|
||||||
|
path: "/mnt/user-data/uploads/uploaded.md",
|
||||||
|
ref_kind: "mention",
|
||||||
|
ref_source: "upload",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
fetchArtifactBlob: async () =>
|
||||||
|
new Blob(["artifact"], { type: "text/plain" }),
|
||||||
|
uploadArtifact: async () => ({
|
||||||
|
filename: "artifact.md",
|
||||||
|
size: 8,
|
||||||
|
path: "/host/path/artifact.md",
|
||||||
|
virtual_path: "/mnt/user-data/uploads/artifact.md",
|
||||||
|
artifact_url: "/api/threads/t1/artifacts/mnt/user-data/uploads/artifact.md",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(references.length, 2);
|
||||||
|
assert.equal(references[0]?.ref_source, "upload");
|
||||||
|
assert.equal(references[0]?.path, "/mnt/user-data/uploads/artifact.md");
|
||||||
|
assert.equal(references[1]?.path, "/mnt/user-data/uploads/uploaded.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
void test("materializeArtifactReferences marks artifact as stale on upload failure", async () => {
|
||||||
|
const references = await materializeArtifactReferences(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
filename: "broken.md",
|
||||||
|
path: "/mnt/user-data/outputs/broken.md",
|
||||||
|
ref_kind: "mention",
|
||||||
|
ref_source: "artifact",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
fetchArtifactBlob: async () => new Blob(["artifact"]),
|
||||||
|
uploadArtifact: async () => null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(references.length, 1);
|
||||||
|
assert.equal(references[0]?.stale, true);
|
||||||
|
|
||||||
|
const result = buildFilesForSubmit([], references);
|
||||||
|
assert.equal(result.staleCount, 1);
|
||||||
|
assert.equal(result.files.length, 0);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
} from "@/components/ai-elements/prompt-input";
|
} from "@/components/ai-elements/prompt-input";
|
||||||
|
|
||||||
import { getAPIClient } from "../api";
|
import { getAPIClient } from "../api";
|
||||||
|
import { urlOfArtifact } from "../artifacts/utils";
|
||||||
import { getBackendBaseURL } from "../config";
|
import { getBackendBaseURL } from "../config";
|
||||||
import { useI18n } from "../i18n/hooks";
|
import { useI18n } from "../i18n/hooks";
|
||||||
import type { FileInMessage } from "../messages/utils";
|
import type { FileInMessage } from "../messages/utils";
|
||||||
|
|
@ -19,7 +20,7 @@ import type { UploadedFileInfo } from "../uploads";
|
||||||
import { uploadFiles } from "../uploads";
|
import { uploadFiles } from "../uploads";
|
||||||
import type { UploadTarget } from "../uploads/api";
|
import type { UploadTarget } from "../uploads/api";
|
||||||
|
|
||||||
import { buildFilesForSubmit } from "./submit-files";
|
import { buildFilesForSubmit, materializeArtifactReferences } from "./submit-files";
|
||||||
import type {
|
import type {
|
||||||
AgentThread,
|
AgentThread,
|
||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
|
|
@ -59,6 +60,34 @@ const STREAM_CANCEL_PATTERNS = [
|
||||||
/\babort(?:ed|error)?\b/i,
|
/\babort(?:ed|error)?\b/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function convertArtifactReferencesToUploads(
|
||||||
|
threadId: string,
|
||||||
|
references: PromptInputMessage["references"],
|
||||||
|
) {
|
||||||
|
return materializeArtifactReferences(references, {
|
||||||
|
fetchArtifactBlob: async (reference) => {
|
||||||
|
const filepath = reference.path;
|
||||||
|
if (!filepath) {
|
||||||
|
throw new Error("Missing artifact path");
|
||||||
|
}
|
||||||
|
const response = await fetch(
|
||||||
|
urlOfArtifact({
|
||||||
|
filepath,
|
||||||
|
threadId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to read artifact");
|
||||||
|
}
|
||||||
|
return response.blob();
|
||||||
|
},
|
||||||
|
uploadArtifact: async (file) => {
|
||||||
|
const response = await uploadFiles(threadId, [file]);
|
||||||
|
return response.files[0];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function readMessageCandidate(value: unknown): string | null {
|
function readMessageCandidate(value: unknown): string | null {
|
||||||
if (typeof value === "string" && value.trim()) {
|
if (typeof value === "string" && value.trim()) {
|
||||||
return value.trim();
|
return value.trim();
|
||||||
|
|
@ -553,9 +582,15 @@ export function useThreadStream({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build files metadata for submission (single envelope for uploads + references)
|
// Build files metadata for submission (single envelope for uploads + references)
|
||||||
|
const normalizedReferences = resolvedThreadId
|
||||||
|
? await convertArtifactReferencesToUploads(
|
||||||
|
resolvedThreadId,
|
||||||
|
message.references,
|
||||||
|
)
|
||||||
|
: (message.references ?? []);
|
||||||
const { files: filesForSubmit, staleCount } = buildFilesForSubmit(
|
const { files: filesForSubmit, staleCount } = buildFilesForSubmit(
|
||||||
uploadedFileInfo,
|
uploadedFileInfo,
|
||||||
message.references,
|
normalizedReferences,
|
||||||
);
|
);
|
||||||
if (staleCount > 0) {
|
if (staleCount > 0) {
|
||||||
toast.error("部分引用已失效,已自动移除");
|
toast.error("部分引用已失效,已自动移除");
|
||||||
|
|
@ -714,9 +749,12 @@ export function useSubmitThread({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedReferences = threadId
|
||||||
|
? await convertArtifactReferencesToUploads(threadId, message.references)
|
||||||
|
: (message.references ?? []);
|
||||||
const { files: filesForSubmit, staleCount } = buildFilesForSubmit(
|
const { files: filesForSubmit, staleCount } = buildFilesForSubmit(
|
||||||
[],
|
[],
|
||||||
message.references,
|
normalizedReferences,
|
||||||
);
|
);
|
||||||
if (staleCount > 0) {
|
if (staleCount > 0) {
|
||||||
toast.error("部分引用已失效,已自动移除");
|
toast.error("部分引用已失效,已自动移除");
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,51 @@ export type MentionReference = {
|
||||||
stale?: boolean;
|
stale?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ArtifactMaterializer = (
|
||||||
|
file: File,
|
||||||
|
) => Promise<UploadedFileInfo | null | undefined>;
|
||||||
|
type ArtifactBlobFetcher = (reference: MentionReference) => Promise<Blob>;
|
||||||
|
|
||||||
|
export async function materializeArtifactReferences(
|
||||||
|
references: MentionReference[] = [],
|
||||||
|
options: {
|
||||||
|
fetchArtifactBlob: ArtifactBlobFetcher;
|
||||||
|
uploadArtifact: ArtifactMaterializer;
|
||||||
|
},
|
||||||
|
): Promise<MentionReference[]> {
|
||||||
|
const result: MentionReference[] = [];
|
||||||
|
for (const reference of references) {
|
||||||
|
if (
|
||||||
|
reference.ref_source !== "artifact" ||
|
||||||
|
!reference.path ||
|
||||||
|
reference.stale
|
||||||
|
) {
|
||||||
|
result.push(reference);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await options.fetchArtifactBlob(reference);
|
||||||
|
const file = new File([blob], reference.filename, {
|
||||||
|
type: blob.type || "application/octet-stream",
|
||||||
|
});
|
||||||
|
const uploaded = await options.uploadArtifact(file);
|
||||||
|
if (!uploaded?.virtual_path) {
|
||||||
|
result.push({ ...reference, stale: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
...reference,
|
||||||
|
ref_source: "upload",
|
||||||
|
path: uploaded.virtual_path,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
result.push({ ...reference, stale: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildFilesForSubmit(
|
export function buildFilesForSubmit(
|
||||||
uploadedFileInfo: UploadedFileInfo[],
|
uploadedFileInfo: UploadedFileInfo[],
|
||||||
references: MentionReference[] = [],
|
references: MentionReference[] = [],
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,11 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
|
const expander = page.locator("div.absolute.inset-0.z-1.cursor-text");
|
||||||
|
if ((await expander.count()) > 0) {
|
||||||
|
await expander.first().click();
|
||||||
|
}
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
await textarea.click();
|
|
||||||
await textarea.fill("请基于这个文件回答 @");
|
await textarea.fill("请基于这个文件回答 @");
|
||||||
|
|
||||||
const panel = page.getByTestId("mention-candidate-panel").first();
|
const panel = page.getByTestId("mention-candidate-panel").first();
|
||||||
|
|
@ -138,7 +141,7 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
const itemCount = await items.count();
|
const itemCount = await items.count();
|
||||||
testInfo.skip(itemCount === 0, "当前线程没有可引用文件候选。");
|
testInfo.skip(itemCount === 0, "当前线程没有可引用文件候选。");
|
||||||
|
|
||||||
await items.first().click();
|
await textarea.press("Enter");
|
||||||
await expect(textarea).toBeFocused();
|
await expect(textarea).toBeFocused();
|
||||||
await expect(textarea).toHaveValue(/请基于这个文件回答/);
|
await expect(textarea).toHaveValue(/请基于这个文件回答/);
|
||||||
await expect(page.getByTestId("reference-inline-preview")).toBeVisible();
|
await expect(page.getByTestId("reference-inline-preview")).toBeVisible();
|
||||||
|
|
@ -163,8 +166,11 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||||
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
|
const expander = page.locator("div.absolute.inset-0.z-1.cursor-text");
|
||||||
|
if ((await expander.count()) > 0) {
|
||||||
|
await expander.first().click();
|
||||||
|
}
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
await textarea.click();
|
|
||||||
await textarea.fill("请参考这些文件 ");
|
await textarea.fill("请参考这些文件 ");
|
||||||
|
|
||||||
await textarea.type("@");
|
await textarea.type("@");
|
||||||
|
|
@ -178,14 +184,20 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
await textarea.type("@");
|
await textarea.type("@");
|
||||||
const currentPanel = page.getByTestId("mention-candidate-panel").first();
|
const currentPanel = page.getByTestId("mention-candidate-panel").first();
|
||||||
await expect(currentPanel).toBeVisible();
|
await expect(currentPanel).toBeVisible();
|
||||||
await currentPanel.locator("button").nth(i).click();
|
for (let step = 0; step < i; step += 1) {
|
||||||
|
await textarea.press("ArrowDown");
|
||||||
|
}
|
||||||
|
await textarea.press("Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(page.getByLabel("移除引用")).toHaveCount(6);
|
await expect(page.getByLabel("移除引用")).toHaveCount(6);
|
||||||
|
|
||||||
await textarea.type("@");
|
await textarea.type("@");
|
||||||
await expect(panel).toBeVisible();
|
await expect(panel).toBeVisible();
|
||||||
await panel.locator("button").nth(6).click();
|
for (let step = 0; step < 6; step += 1) {
|
||||||
|
await textarea.press("ArrowDown");
|
||||||
|
}
|
||||||
|
await textarea.press("Enter");
|
||||||
|
|
||||||
await expect(page.getByLabel("移除引用")).toHaveCount(6);
|
await expect(page.getByLabel("移除引用")).toHaveCount(6);
|
||||||
await expect(page.getByText("单条消息最多引用 6 个文件")).toBeVisible();
|
await expect(page.getByText("单条消息最多引用 6 个文件")).toBeVisible();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue