Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,13 @@ export interface StorageUsage {
database: StorageCategory;
workingComputerBrowserData: null;
};
trash: { fileCount: number; sizeBytes: number; referencedFileCount: number };
trash: {
fileCount: number;
sizeBytes: number;
referencedFileCount: number;
/** C22; absent on an older 服务电脑. */
referencedSizeBytes?: number | undefined;
};
topChannels: Array<{
id: string;
name: string;
Expand All @@ -585,6 +591,32 @@ export async function getStorageUsage(signal?: AbortSignal): Promise<StorageUsag
return request<StorageUsage>("/api/v1/storage", signal ? { signal } : undefined);
}

export interface GlobalTrashCleanupResult {
removed: number;
retained: Array<{
id: string;
name: string;
referenceCount: { messages: number; tasks: number };
}>;
retainedCount: number;
retainedHasMore: boolean;
freedBytes: number;
channelCount: number;
}

/**
* C22: empty every active channel's 回收站 except referenced files. Retry an unclear result with
* the same `requestKey`; the 服务电脑 replays the saved result instead of deleting again.
*/
export async function cleanupAllTrash(requestKey: string): Promise<GlobalTrashCleanupResult> {
return request<GlobalTrashCleanupResult>("/api/v1/storage/trash/cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestKey }),
signal: AbortSignal.timeout(120000),
});
}

export async function getStorageSettings(signal?: AbortSignal): Promise<StorageSettings> {
return request<StorageSettings>("/api/v1/settings/storage", signal ? { signal } : undefined);
}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/channel-attachment-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
EMPTY_DOCUMENT_EXTRACT_ERROR,
EMPTY_PDF_EXTRACT_ERROR,
getAttachmentImage,
getAttachmentReferences,
getChannelAttachment,
presentAttachmentProcessError,
purgeAttachment,
Expand Down Expand Up @@ -148,6 +149,26 @@ describe("channel attachment display boundary", () => {
await expect(purgeAttachment(channel, "../escape")).rejects.toThrow("标识");
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("reads a file's references from the scoped route and reports a purged file", async () => {
const references = { messages: [], tasks: [], messageCount: 0, taskCount: 0, hasMore: false };
const fetchMock = vi
.fn()
.mockResolvedValueOnce(Response.json(references))
.mockResolvedValueOnce(
Response.json({ error: "attachment_purged", purged: true }, { status: 410 }),
);
vi.stubGlobal("fetch", fetchMock);
const signal = new AbortController().signal;
expect(await getAttachmentReferences(channel, id, signal)).toEqual(references);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
`/api/v1/channels/${channel}/attachments/${id}/references?limit=20`,
);
await expect(getAttachmentReferences(channel, id, signal)).rejects.toBeInstanceOf(
AttachmentPurgedError,
);
await expect(getAttachmentReferences(channel, "../x", signal)).rejects.toThrow("标识");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("rejects denied metadata before locking or reading a hostile body", async () => {
const pull = vi.fn(() => {
throw new Error("must not read denied content");
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/channel-attachment-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,43 @@ export async function cleanupChannelTrash(
);
}

/** C24: who still references a file. Previews are at most 120 characters with markers removed. */
export interface AttachmentReferences {
messages: Array<{
id: string;
createdAt: string;
author: { kind: "owner" | "bot" | "system"; botId?: string };
preview: string;
}>;
tasks: Array<{ runId: string; title: string; status: string; createdAt: string }>;
messageCount: number;
taskCount: number;
hasMore: boolean;
}

export async function getAttachmentReferences(
channelId: string,
attachmentId: string,
signal: AbortSignal,
limit = 20,
): Promise<AttachmentReferences> {
const response = await fetch(
`${attachmentPath(channelId, attachmentId)}/references?limit=${limit}`,
{
credentials: "include",
redirect: "error",
signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]),
},
);
if (response.status === 410) throw new AttachmentPurgedError();
const data = JSON.parse(
new TextDecoder().decode(await readAvailableAttachment(response, 131072)),
) as AttachmentReferences;
if (!Array.isArray(data.messages) || !Array.isArray(data.tasks))
throw new Error("引用列表格式不对。");
return data;
}

export async function downloadAttachment(attachment: UploadedComposerAttachment): Promise<void> {
const desktop = window.openbotDesktop;
if (desktop) {
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/components/AttachmentsManager.css
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,61 @@
color: #8e8e93;
font-size: 12px;
}
/* C24 查看引用: the messages and tasks that keep a 回收站 file, under its row. */
.channel-files-references-toggle {
margin-left: 6px;
padding: 0;
border: 0;
background: transparent;
color: var(--ob-text-secondary);
font: inherit;
cursor: pointer;
}
.channel-files-references-toggle:hover {
color: var(--ob-text);
}
.channel-files-references {
grid-column: 2 / -1;
margin: 0;
padding: 4px;
list-style: none;
border-radius: 12px;
background: var(--ob-surface);
}
.channel-files-references.is-status,
.channel-files-references .is-status {
padding: 8px 10px;
color: var(--ob-text-tertiary);
font-size: 12px;
}
.channel-files-references button {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 7px 10px;
border: 0;
border-radius: 9px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.channel-files-references button:hover:not(:disabled) {
background: var(--ob-fill);
}
.channel-files-references button:disabled {
cursor: default;
}
.channel-files-references strong {
font-size: 12px;
font-weight: 600;
}
.channel-files-references small {
overflow: hidden;
color: var(--ob-text-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
70 changes: 70 additions & 0 deletions apps/web/src/components/AttachmentsManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,73 @@ it("retries an unclear 清空回收站 with the same request key", async () => {
expect(keys[1]).toBe(keys[0]);
expect(document.body.textContent).toContain("已永久删除 1 个文件,释放 2 KB。");
});

it("lists who references a kept file and opens a message or task from it", async () => {
const kept = {
...upload("u333", "渠道数据-8月.xlsx", "2026-09-18T00:00:00.000Z"),
id: "00000000-0000-4000-8000-000000000033",
referenceCount: { messages: 2, tasks: 1 },
};
listed = [kept];
vi.mocked(fetch).mockImplementation(async (url) =>
String(url).includes("/references")
? Response.json({
messages: [
{
id: "m-1",
createdAt: "2026-09-18T02:10:00.000Z",
author: { kind: "bot", botId: "bot-1" },
preview: "整理好了",
},
{
id: "m-2",
createdAt: "2026-09-17T02:10:00.000Z",
author: { kind: "system" },
preview: "例行任务已开始",
},
],
tasks: [
{
runId: "run-9",
title: "汇总渠道",
status: "completed",
createdAt: "2026-09-16T00:00:00.000Z",
},
],
messageCount: 2,
taskCount: 1,
hasMore: false,
})
: Response.json({ attachments: listed }),
);
const showMessage = vi.fn();
const showTask = vi.fn();
const view = await renderComponent(
<AttachmentsManagerDialog
channelId="channel"
initialTab="trash"
botName={(id) => (id === "bot-1" ? "研究助理" : undefined)}
onShowMessage={showMessage}
onShowTask={showTask}
onClose={vi.fn()}
/>,
);
views.push(view);
await interact(async () => undefined);
await interact(() => button("查看引用 ›").click());
await interact(async () => undefined);
const list = document.querySelector(".channel-files-references");
expect(
vi
.mocked(fetch)
.mock.calls.some(([url]) => String(url).endsWith(`${kept.id}/references?limit=20`)),
).toBe(true);
expect(list?.textContent).toContain("研究助理");
expect(list?.textContent).toContain("系统消息");
expect(list?.textContent).toContain("汇总渠道");
const buttons = list?.querySelectorAll("button") ?? [];
await interact(() => (buttons[0] as HTMLButtonElement).click());
expect(showMessage).toHaveBeenCalledWith("m-1");
await interact(() => (buttons[2] as HTMLButtonElement).click());
expect(showTask).toHaveBeenCalledWith("run-9");
});
Loading
Loading