This commit is contained in:
fengxiangdebaozi committed 2026-08-21 21:48:29 +08:00
commit 1c0e4e9723
63 files changed
+4113 -929

No files matched your search

+14 -3
View File
@@ -5,7 +5,11 @@ import { useThread } from "@/components/workspace/messages/context";
import { isImageFile } from "../utils/files";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import {
loadArtifactContent,
loadArtifactContentFromLatestWriteFile,
loadArtifactContentFromToolCall,
} from "./loader";
import { isArtifactApiPath } from "./streaming-write-preview";
import { updateArtifactContent } from "./api";
@@ -96,8 +100,14 @@ export function useArtifactContent({
}
return null;
}, [filepath, useWriteFileStreaming, thread]);
const toolCallFallbackContent = useMemo(() => {
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
return undefined;
}
return loadArtifactContentFromLatestWriteFile({ filepath, thread });
}, [filepath, thread, useWriteFileStreaming]);
const { data, isLoading, error } = useQuery({
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
@@ -121,11 +131,12 @@ export function useArtifactContent({
return {
content: useWriteFileStreaming
? content
: (pendingEdit?.content ?? data?.content),
: (pendingEdit?.content ?? data?.content ?? toolCallFallbackContent),
url: useWriteFileStreaming ? undefined : data?.url,
isLoading,
isApplyingEdit: pendingEdit?.kind === "editing",
error,
refetch,
};
}
+56 -3
View File
@@ -46,7 +46,7 @@ export async function loadArtifactContent({
return { content: text, url };
}
export function loadArtifactContentFromToolCall({
export function loadArtifactContentFromToolCall({
url: urlString,
thread,
}: {
@@ -66,5 +66,58 @@ export function loadArtifactContentFromToolCall({
return toolCall.args.content;
}
}
}
}
}
}
function normalizeArtifactPath(path: string): string {
let normalized = path.trim();
const first = normalized[0];
const last = normalized.at(-1);
if (
normalized.length >= 2 &&
((first === '"' && last === '"') ||
(first === "'" && last === "'") ||
(first === "`" && last === "`"))
) {
normalized = normalized.slice(1, -1).trim();
}
return normalized.replace(/\\/g, "/").replace(/^\/+/, "");
}
/**
* Last-resort preview for an output whose artifact HTTP request is unavailable.
*
* A completed `write_file` can be selected before its backing sandbox file is
* visible through the artifacts endpoint (or when a deployment temporarily
* serves a stale mount). The original tool arguments still contain the
* Markdown, so use the newest matching write as a read-only display fallback.
*/
export function loadArtifactContentFromLatestWriteFile({
filepath,
thread,
}: {
filepath: string;
thread: BaseStream<AgentThreadState>;
}): string | undefined {
const normalizedFilepath = normalizeArtifactPath(filepath);
if (!normalizedFilepath) return undefined;
for (const message of [...thread.messages].reverse()) {
if (message.type !== "ai" || !message.tool_calls) continue;
for (const toolCall of [...message.tool_calls].reverse()) {
if (toolCall.name !== "write_file") continue;
if (!toolCall.args || typeof toolCall.args !== "object" || Array.isArray(toolCall.args)) {
continue;
}
const args = toolCall.args as { path?: unknown; content?: unknown };
if (
typeof args.path === "string" &&
typeof args.content === "string" &&
normalizeArtifactPath(args.path) === normalizedFilepath
) {
return args.content;
}
}
}
return undefined;
}
@@ -11,6 +11,8 @@ import { createClientId } from "@/lib/client-id";
export type DocumentRewritePresentation = {
id: string;
threadId: string;
/** A report regeneration is a new document, not an edit of the current one. */
operation: "rewrite" | "generate";
source: {
kind: "artifact" | "deep_research_report";
id: string;
@@ -37,6 +39,10 @@ export type DocumentRewritePresentation = {
originalContent: string;
rewrittenContent: string | null;
versionId: string | null;
/** Hash of the currently committed rewrite, used by the local undo fallback. */
committedHash: string | null;
/** A browser-only undo backup is available because the server snapshot failed. */
localFallbackAvailable: boolean;
/** Inverse snapshot created by a safe undo; enables a one-click reapply. */
redoVersionId: string | null;
revertedAt: string | null;
@@ -49,6 +55,7 @@ type CreatePresentationInput = Omit<
DocumentRewritePresentation,
| "id"
| "stage"
| "operation"
| "generateImages"
| "maxGeneratedImages"
| "generatedImageCount"
@@ -62,14 +69,19 @@ type CreatePresentationInput = Omit<
| "originalContent"
| "rewrittenContent"
| "versionId"
| "committedHash"
| "localFallbackAvailable"
| "redoVersionId"
| "revertedAt"
| "error"
| "createdAt"
| "updatedAt"
> & {
operation?: DocumentRewritePresentation["operation"];
originalContent?: string;
rewrittenContent?: string | null;
committedHash?: string | null;
localFallbackAvailable?: boolean;
requirementsPlan?: string;
generateImages?: boolean;
maxGeneratedImages?: number;
@@ -107,6 +119,7 @@ export function DocumentRewritePresentationProvider({
...input,
id,
stage: "snapshot",
operation: input.operation ?? "rewrite",
generateImages: input.generateImages === true,
maxGeneratedImages: Math.min(Math.max(input.maxGeneratedImages ?? 2, 1), 4),
generatedImageCount: 0,
@@ -120,6 +133,8 @@ export function DocumentRewritePresentationProvider({
originalContent: input.originalContent ?? "",
rewrittenContent: input.rewrittenContent ?? null,
versionId: null,
committedHash: input.committedHash ?? null,
localFallbackAvailable: input.localFallbackAvailable === true,
redoVersionId: null,
revertedAt: null,
error: null,
@@ -180,3 +195,12 @@ export function useDocumentRewritePresentation() {
/** Browser event consumed by the open sandbox after an in-feed undo succeeds. */
export const DOCUMENT_REWRITE_UNDONE_EVENT = "deerflow:document-rewrite-undone";
/** Synchronous guard used before a browser-only undo writes back the original text. */
export const DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT = "deerflow:document-rewrite-local-undo-guard";
/** Sent after the user confirms a rewrite so the conversation can reveal its new step card. */
export const DOCUMENT_REWRITE_STARTED_EVENT = "deerflow:document-rewrite-started";
/** A completed structural-regeneration card requests an in-place rewrite of that new report. */
export const DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT = "deerflow:deep-research-report-rewrite-requested";
@@ -0,0 +1,133 @@
import { getAccountIdentityKey } from "@/core/auth";
import { authStorage } from "@/core/auth/scoped-storage";
const STORAGE_PREFIX = "deerflow.document-rewrite.local-fallback:v1";
// Keep the browser fallback comfortably below the typical 5 MB storage quota.
const MAX_BACKUP_CHARACTERS = 1_000_000;
type PendingArtifactRewriteFallback = {
threadId: string;
path: string;
originalContent: string;
committedHash: string | null;
createdAt: string;
};
export type ArtifactRewriteFallback = PendingArtifactRewriteFallback & {
committedHash: string;
};
function storageKey(threadId: string, path: string) {
const account = getAccountIdentityKey() ?? "anonymous";
return [STORAGE_PREFIX, account, threadId, path]
.map((part) => encodeURIComponent(part))
.join(":");
}
function getStorage(): Storage | null {
if (typeof window === "undefined") return null;
return authStorage();
}
function canPersist(originalContent: string): boolean {
if (originalContent.length <= MAX_BACKUP_CHARACTERS) return true;
console.error("AI 全文改写的本地撤销备份过大,未写入浏览器存储。");
return false;
}
/**
* Save the original text before the durable rewrite starts. It is completed
* with the rewritten file hash only if the server-side undo snapshot fails.
*/
export function primeArtifactRewriteFallback({
threadId,
path,
originalContent,
}: Omit<PendingArtifactRewriteFallback, "committedHash" | "createdAt">): void {
if (!canPersist(originalContent)) return;
try {
getStorage()?.setItem(
storageKey(threadId, path),
JSON.stringify({
threadId,
path,
originalContent,
committedHash: null,
createdAt: new Date().toISOString(),
} satisfies PendingArtifactRewriteFallback),
);
} catch (error) {
console.error("保存 AI 全文改写的本地撤销备份失败:", error);
}
}
/** Mark a primed backup as safe to use for the server snapshot failure path. */
export function completeArtifactRewriteFallback({
threadId,
path,
originalContent,
committedHash,
}: Omit<ArtifactRewriteFallback, "createdAt">): boolean {
if (!committedHash || !canPersist(originalContent)) return false;
try {
const storage = getStorage();
if (!storage) return false;
storage.setItem(
storageKey(threadId, path),
JSON.stringify({
threadId,
path,
originalContent,
committedHash,
createdAt: new Date().toISOString(),
} satisfies ArtifactRewriteFallback),
);
return true;
} catch (error) {
console.error("完成 AI 全文改写的本地撤销备份失败:", error);
return false;
}
}
export function loadArtifactRewriteFallback({
threadId,
path,
committedHash,
}: Pick<ArtifactRewriteFallback, "threadId" | "path" | "committedHash">): ArtifactRewriteFallback | null {
try {
const raw = getStorage()?.getItem(storageKey(threadId, path));
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const fallback = parsed as Partial<ArtifactRewriteFallback>;
if (
fallback.threadId !== threadId ||
fallback.path !== path ||
fallback.committedHash !== committedHash ||
typeof fallback.originalContent !== "string"
) {
return null;
}
return fallback as ArtifactRewriteFallback;
} catch (error) {
console.error("读取 AI 全文改写的本地撤销备份失败:", error);
return null;
}
}
export function clearArtifactRewriteFallback(threadId: string, path: string): void {
try {
getStorage()?.removeItem(storageKey(threadId, path));
} catch (error) {
console.error("清理 AI 全文改写的本地撤销备份失败:", error);
}
}
export async function sha256DocumentContent(content: string): Promise<string | null> {
if (!globalThis.crypto?.subtle) return null;
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(content),
);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
+21 -4
View File
@@ -118,10 +118,10 @@ export function groupMessages<T>(
}
if (message.type === "ai") {
// Deep-research interaction card: virtual ai messages injected by the
// workbench (additional_kwargs.setup_research_approval) render through
// the host-provided slot. Writing phases use ordinary virtual tool calls
// and completed summaries remain normal assistant messages.
// Deep-research interaction card: virtual ai messages injected by the
// workbench (additional_kwargs.setup_research_approval) render through
// the host-provided slot. Writing phases use ordinary virtual tool calls
// and completed summaries remain normal assistant messages.
if (hasResearchApproval(message)) {
groups.push({
id: message.id,
@@ -136,6 +136,15 @@ export function groupMessages<T>(
type: "assistant:present-files",
messages: [message],
});
} else if (hasOnlyDeepResearchProgress(message)) {
// Each deep-research writing bar is its own group. Merging consecutive
// processing messages would pin a new report's in-progress steps onto
// the parent bar, which sits above the original file card.
groups.push({
id: message.id,
type: "assistant:processing",
messages: [message],
});
} else if (hasSubagent(message)) {
groups.push({
id: message.id,
@@ -380,6 +389,14 @@ export function hasPresentFiles(message: Message) {
);
}
export function hasOnlyDeepResearchProgress(message: Message) {
return (
message.type === "ai" &&
Boolean(message.tool_calls?.length) &&
message.tool_calls!.every((toolCall) => toolCall.name === "deep_research_progress")
);
}
export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification";
}
@@ -103,6 +103,16 @@ export async function deleteManagedModel(name: string): Promise<void> {
if (!response.ok) return readError(response, "删除模型失败");
}
export async function reorderManagedModels(names: string[]): Promise<ManagedModel[]> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ names }),
});
if (!response.ok) return readError(response, "调整模型顺序失败");
return response.json() as Promise<ManagedModel[]>;
}
export async function testManagedModel(model: ModelSavePayload): Promise<ModelConnectionTestResult> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/test`, {
method: "POST",
@@ -4,6 +4,7 @@ import {
createManagedModel,
deleteManagedModel,
loadManagedModels,
reorderManagedModels,
testManagedModel,
updateManagedModel,
type ModelSavePayload,
@@ -50,3 +51,16 @@ export function useDeleteManagedModel() {
},
});
}
export function useReorderManagedModels() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: reorderManagedModels,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ["models"] }),
]);
},
});
}
@@ -100,12 +100,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Telescope,
path: "/page/strategy/qa/deep-research",
},
{
id: "qa-enterprise-research",
label: "企业深度研究",
icon: FileSearch,
path: "/page/strategy/qa/enterprise-research",
},
// {
// id: "qa-enterprise-research",
// label: "企业深度研究",
// icon: FileSearch,
// path: "/page/strategy/qa/enterprise-research",
// },
],
},
{
+1 -1
View File
@@ -314,7 +314,7 @@ export interface CustomThemePreset {
}
export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme";
export type CompactShortcutParamType = "login" | "theme" | "path";
export interface CompactShortcutRouteParam {
key: string;