feat: 深度研究支持独立新报告与原文改写,并完善沙箱、模型管理与全文改写
新报告不再覆盖当前文件卡片,写入时打开沙箱;步骤条仅在改写原文时位于卡片上方。同时补齐改写本地回退、模型管理与会商第三步相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
11f928bc95
commit
a5a9c952e8
63 files changed
+4113
-929
No files matched your search
@@ -346,15 +346,22 @@ export function AgentGallery() {
|
||||
请输入访问密码,验证通过后才能查看其中的智能体。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<form
|
||||
className="flex flex-col gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSubmitPassword();
|
||||
}}
|
||||
>
|
||||
<label className="sr-only" htmlFor="private-square-access-code">
|
||||
访问密码
|
||||
</label>
|
||||
<Input
|
||||
id="private-square-access-code"
|
||||
type="password"
|
||||
placeholder="访问密码"
|
||||
value={passwordInput}
|
||||
onChange={(e) => setPasswordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmitPassword();
|
||||
}}
|
||||
// This is a one-off access code, not a login credential. Tell
|
||||
// browsers / password managers not to save or autofill it (which
|
||||
// would also stop them from spilling a saved account into the
|
||||
@@ -365,8 +372,8 @@ export function AgentGallery() {
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
|
||||
</div>
|
||||
<Button type="submit">进入私密广场</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
import type { CompactShortcutRouteParam } from "@/core/system-settings/api";
|
||||
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
import { buildLightAppUrl, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
|
||||
|
||||
/**
|
||||
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
|
||||
@@ -47,6 +48,34 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
|
||||
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
|
||||
function appendShortcutPathParams(url: string, params: CompactShortcutRouteParam[]) {
|
||||
const pathSegments = params
|
||||
.filter((param) => param.paramType === "path")
|
||||
.map((param) =>
|
||||
resolveRouteParamValue({ ...param, paramType: "login" }).trim(),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.map((value) => encodeURIComponent(value));
|
||||
if (pathSegments.length === 0) return url;
|
||||
|
||||
const hashIndex = url.indexOf("#");
|
||||
if (hashIndex < 0) {
|
||||
const [path, query = ""] = url.split("?", 2);
|
||||
return `${path.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
const beforeHash = url.slice(0, hashIndex);
|
||||
const fragment = url.slice(hashIndex + 1);
|
||||
const [routePath, query = ""] = fragment.split("?", 2);
|
||||
return `${beforeHash}#${routePath.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function isQueryShortcutParam(
|
||||
param: CompactShortcutRouteParam,
|
||||
): param is CompactShortcutRouteParam & { paramType: "login" | "theme" } {
|
||||
return param.paramType === "login" || param.paramType === "theme";
|
||||
}
|
||||
|
||||
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const buttons = (appearance?.compact_shortcut_buttons ?? [])
|
||||
@@ -69,9 +98,10 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
|
||||
)}
|
||||
title={button.label}
|
||||
onClick={() => {
|
||||
const routeParams = (button.route_params ?? []).map((param) => ({ ...param }));
|
||||
const url = buildLightAppUrl({
|
||||
url: button.url,
|
||||
routeParams: button.route_params.map((param) => ({ ...param })),
|
||||
url: appendShortcutPathParams(button.url, routeParams),
|
||||
routeParams: routeParams.filter(isQueryShortcutParam),
|
||||
});
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
|
||||
@@ -43,7 +43,11 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
|
||||
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
|
||||
import { useCapabilities as useResearchCapabilities } from "@/strategy-components/api/deep-research";
|
||||
import {
|
||||
useCapabilities as useResearchCapabilities,
|
||||
useResearchSources,
|
||||
type DeepResearchSource,
|
||||
} from "@/strategy-components/api/deep-research";
|
||||
import {
|
||||
listDurableDocumentRewrites,
|
||||
streamDurableDocumentRewriteJob,
|
||||
@@ -54,10 +58,17 @@ import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||
import { useAgent } from "@/core/agents/hooks";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import {
|
||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import {
|
||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||
DOCUMENT_REWRITE_STARTED_EVENT,
|
||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import {
|
||||
clearArtifactRewriteFallback,
|
||||
completeArtifactRewriteFallback,
|
||||
primeArtifactRewriteFallback,
|
||||
} from "@/core/document-rewrite/local-fallback";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
} from "@/core/artifacts/download";
|
||||
@@ -100,7 +111,34 @@ import { useArtifacts } from "./context";
|
||||
import { SpreadsheetPreview } from "./spreadsheet-preview";
|
||||
import { SkillInstallDialog } from "./skill-install-dialog";
|
||||
|
||||
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
|
||||
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
|
||||
const RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\]\]|\[\s*来源\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\])/gi;
|
||||
const TRAILING_RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::][^\]\n]*(?:\]?)|\[\s*来源\s*[::][^\]\n]*)$/i;
|
||||
|
||||
/** Keep durable source ids in Markdown while presenting plain numeric citations. */
|
||||
function formatResearchCitationsForPreview(
|
||||
content: string,
|
||||
sources: DeepResearchSource[],
|
||||
): string {
|
||||
const citationNumbers = new Map(
|
||||
sources.map((source, index) => [source.id, index + 1]),
|
||||
);
|
||||
let nextCitationNumber = citationNumbers.size + 1;
|
||||
|
||||
return content
|
||||
.replace(RESEARCH_SOURCE_MARKER_RE, (_marker, legacyId: string, displayId: string) => {
|
||||
const sourceId = legacyId || displayId;
|
||||
let citationNumber = citationNumbers.get(sourceId);
|
||||
if (!citationNumber) {
|
||||
citationNumber = nextCitationNumber;
|
||||
nextCitationNumber += 1;
|
||||
citationNumbers.set(sourceId, citationNumber);
|
||||
}
|
||||
return `[${citationNumber}]`;
|
||||
})
|
||||
// Hide a citation token while it is still arriving over the live stream.
|
||||
.replace(TRAILING_RESEARCH_SOURCE_MARKER_RE, "");
|
||||
}
|
||||
|
||||
async function sha256Content(content: string): Promise<string | undefined> {
|
||||
if (!globalThis.crypto?.subtle) return undefined;
|
||||
@@ -286,7 +324,13 @@ export function ArtifactFileDetail({
|
||||
language === "mermaid"
|
||||
);
|
||||
}, [language]);
|
||||
const { content, isApplyingEdit } = useArtifactContent({
|
||||
const {
|
||||
content,
|
||||
isApplyingEdit,
|
||||
isLoading: isArtifactContentLoading,
|
||||
error: artifactContentError,
|
||||
refetch: refetchArtifactContent,
|
||||
} = useArtifactContent({
|
||||
threadId,
|
||||
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
||||
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
||||
@@ -326,10 +370,19 @@ export function ArtifactFileDetail({
|
||||
const documentRewriteActive = documentRewrite.isActive;
|
||||
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||
const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits;
|
||||
const displayContent = documentRewriteActive
|
||||
? documentRewrite.run.draft
|
||||
: (canEditArtifact ? draftContent : (content ?? ""));
|
||||
const displayContent = documentRewriteActive
|
||||
? documentRewrite.run.draft
|
||||
: (canEditArtifact ? draftContent : (content ?? ""));
|
||||
const artifactContentPending =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
content === undefined &&
|
||||
isArtifactContentLoading;
|
||||
const artifactContentUnavailable =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
content === undefined &&
|
||||
!isArtifactContentLoading;
|
||||
const isAutoPreviewLanguage =
|
||||
language === "html" || language === "markdown" || language === "mermaid";
|
||||
const isPreviewStreaming =
|
||||
@@ -385,10 +438,12 @@ export function ArtifactFileDetail({
|
||||
if (
|
||||
!canEditArtifact ||
|
||||
!threadId ||
|
||||
!filepath ||
|
||||
isMock ||
|
||||
useWriteFileStreaming ||
|
||||
documentRewriteActive
|
||||
!filepath ||
|
||||
isMock ||
|
||||
useWriteFileStreaming ||
|
||||
documentRewriteActive ||
|
||||
content === undefined ||
|
||||
artifactContentError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -468,12 +523,13 @@ export function ArtifactFileDetail({
|
||||
case "commit_started":
|
||||
rewritePresentation.update(presentationId, { stage: "committing" });
|
||||
break;
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: event.content,
|
||||
versionId: event.versionId ?? null,
|
||||
});
|
||||
break;
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: event.content,
|
||||
versionId: event.versionId ?? null,
|
||||
committedHash: event.committedHash,
|
||||
});
|
||||
break;
|
||||
case "done":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "completed",
|
||||
@@ -486,9 +542,9 @@ export function ArtifactFileDetail({
|
||||
case "cancelled":
|
||||
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
|
||||
break;
|
||||
case "error":
|
||||
rewritePresentation.update(presentationId, { stage: "error", error: event.message });
|
||||
break;
|
||||
case "error":
|
||||
console.error("AI 全文改写历史恢复失败:", event.message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -501,12 +557,15 @@ export function ArtifactFileDetail({
|
||||
if (disposed || !latest) return;
|
||||
// Restore prior terminal rewrites into the ordinary message timeline in
|
||||
// chronological order. The newest job is handled below because it may
|
||||
// still be active and needs its SSE stream resumed.
|
||||
for (const historical of jobs.slice(1).reverse()) {
|
||||
const historicalResult = historical.result;
|
||||
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
|
||||
? historicalResult.summary as DocumentRewriteSummary
|
||||
: null;
|
||||
// still be active and needs its SSE stream resumed.
|
||||
for (const historical of jobs.slice(1).reverse()) {
|
||||
const historicalResult = historical.result;
|
||||
const historicalCommittedHash = typeof historicalResult.committedHash === "string"
|
||||
? historicalResult.committedHash
|
||||
: null;
|
||||
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
|
||||
? historicalResult.summary as DocumentRewriteSummary
|
||||
: null;
|
||||
const historicalPresentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
@@ -517,8 +576,9 @@ export function ArtifactFileDetail({
|
||||
generateImages: historical.request.generateImages,
|
||||
maxGeneratedImages: historical.request.maxGeneratedImages,
|
||||
originalContent: historical.request.originalContent,
|
||||
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
|
||||
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
|
||||
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
|
||||
committedHash: historicalCommittedHash,
|
||||
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
|
||||
? historicalResult.requirementsPlan
|
||||
: "",
|
||||
});
|
||||
@@ -529,7 +589,7 @@ export function ArtifactFileDetail({
|
||||
: historical.status === "cancelled" ? "cancelled" : "error",
|
||||
summary: historicalSummary,
|
||||
warnings: historicalSummary?.validationWarnings ?? [],
|
||||
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
|
||||
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
|
||||
error: historicalResult.status === "conflict"
|
||||
? "文件在改写期间已变化,未覆盖原文件。"
|
||||
: historical.status === "cancelled"
|
||||
@@ -538,12 +598,28 @@ export function ArtifactFileDetail({
|
||||
});
|
||||
}
|
||||
const result = latest.result;
|
||||
const summary = result.summary && typeof result.summary === "object"
|
||||
? result.summary as DocumentRewriteSummary
|
||||
: null;
|
||||
const originalContent = latest.request.originalContent || draftContent;
|
||||
const rewrittenContent = typeof result.content === "string" ? result.content : null;
|
||||
const presentationId = rewritePresentation?.create({
|
||||
const summary = result.summary && typeof result.summary === "object"
|
||||
? result.summary as DocumentRewriteSummary
|
||||
: null;
|
||||
const originalContent = latest.request.originalContent || draftContent;
|
||||
const rewrittenContent = typeof result.content === "string" ? result.content : null;
|
||||
const versionSnapshotFailed = result.versionSnapshotFailed === true;
|
||||
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
|
||||
if (!versionSnapshotFailed) {
|
||||
clearArtifactRewriteFallback(threadId, filepath);
|
||||
}
|
||||
const localFallbackAvailable =
|
||||
versionSnapshotFailed &&
|
||||
latest.status === "completed" &&
|
||||
result.status !== "conflict" &&
|
||||
committedHash !== null &&
|
||||
completeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent,
|
||||
committedHash,
|
||||
});
|
||||
const presentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
fileName: getFileName(filepath),
|
||||
@@ -552,8 +628,10 @@ export function ArtifactFileDetail({
|
||||
modelName: latest.request.modelName,
|
||||
generateImages: latest.request.generateImages,
|
||||
maxGeneratedImages: latest.request.maxGeneratedImages,
|
||||
originalContent,
|
||||
rewrittenContent,
|
||||
originalContent,
|
||||
rewrittenContent,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||||
});
|
||||
if (!presentationId || !rewritePresentation) return;
|
||||
@@ -564,12 +642,19 @@ export function ArtifactFileDetail({
|
||||
stage: result.status === "conflict" ? "conflict" : "completed",
|
||||
summary,
|
||||
warnings: summary?.validationWarnings ?? [],
|
||||
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
||||
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
|
||||
});
|
||||
if (rewrittenContent && result.status !== "conflict") {
|
||||
setDraftContent(rewrittenContent);
|
||||
setHasLocalEdits(false);
|
||||
if (rewrittenContent && result.status !== "conflict") {
|
||||
setDraftContent(rewrittenContent);
|
||||
setHasLocalEdits(versionSnapshotFailed);
|
||||
if (versionSnapshotFailed) {
|
||||
console.error(
|
||||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||
);
|
||||
}
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||
{ content: rewrittenContent, url: undefined },
|
||||
@@ -603,10 +688,28 @@ export function ArtifactFileDetail({
|
||||
signal,
|
||||
),
|
||||
onEvent: (event) => updatePresentation(presentationId, event),
|
||||
onCommitted: (rewritten) => {
|
||||
setDraftContent(rewritten);
|
||||
setHasLocalEdits(false);
|
||||
rewritePresentation.update(presentationId, { rewrittenContent: rewritten });
|
||||
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
||||
setDraftContent(rewritten);
|
||||
setHasLocalEdits(versionSnapshotFailed);
|
||||
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent,
|
||||
committedHash,
|
||||
});
|
||||
if (!versionSnapshotFailed) {
|
||||
clearArtifactRewriteFallback(threadId, filepath);
|
||||
}
|
||||
if (versionSnapshotFailed) {
|
||||
console.error(
|
||||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||
);
|
||||
}
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
});
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||
{ content: rewritten, url: undefined },
|
||||
@@ -621,9 +724,11 @@ export function ArtifactFileDetail({
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [
|
||||
canEditArtifact,
|
||||
documentRewrite,
|
||||
}, [
|
||||
artifactContentError,
|
||||
canEditArtifact,
|
||||
content,
|
||||
documentRewrite,
|
||||
documentRewriteActive,
|
||||
draftContent,
|
||||
filepath,
|
||||
@@ -675,8 +780,29 @@ export function ArtifactFileDetail({
|
||||
rewritePresentation.update(presentationId, { thinkingText: thinking });
|
||||
}, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRewriteUndo = (event: Event) => {
|
||||
useEffect(() => {
|
||||
const handleLocalRewriteUndoGuard = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{
|
||||
source?: { kind?: string; id?: string; path?: string };
|
||||
rewrittenContent?: string | null;
|
||||
unchanged?: boolean;
|
||||
}>).detail;
|
||||
if (
|
||||
detail?.source?.kind !== "artifact" ||
|
||||
detail.source.id !== threadId ||
|
||||
detail.source.path !== filepath ||
|
||||
typeof detail.rewrittenContent !== "string"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
detail.unchanged = draftContent === detail.rewrittenContent;
|
||||
};
|
||||
window.addEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard);
|
||||
return () => window.removeEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard);
|
||||
}, [draftContent, filepath, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRewriteUndo = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{
|
||||
source?: { kind?: string; id?: string; path?: string };
|
||||
content?: string;
|
||||
@@ -902,9 +1028,11 @@ export function ArtifactFileDetail({
|
||||
});
|
||||
setHasLocalEdits(false);
|
||||
toast.success("Markdown 已保存");
|
||||
} catch (error) {
|
||||
console.error("Failed to save artifact:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
|
||||
} catch (error) {
|
||||
console.error("Failed to save artifact:", error);
|
||||
// Keep the draft and leave the button enabled. The caller can retry when
|
||||
// the artifact endpoint recovers; this must never reset visible text.
|
||||
setHasLocalEdits(true);
|
||||
}
|
||||
}, [
|
||||
canEditArtifact,
|
||||
@@ -918,25 +1046,47 @@ export function ArtifactFileDetail({
|
||||
|
||||
const handleStartDocumentRewrite = useCallback(
|
||||
async (input: {
|
||||
instruction: string;
|
||||
style: "formal_analysis" | "professional" | "concise" | "readable";
|
||||
modelName: string | null;
|
||||
generateImages: boolean;
|
||||
instruction: string;
|
||||
style: "formal_analysis" | "professional" | "concise" | "readable";
|
||||
modelName: string | null;
|
||||
reportOutline: string;
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
}) => {
|
||||
if (!canUseFullDocumentRewrite) {
|
||||
toast.error(
|
||||
hasLocalEdits
|
||||
? "请先保存当前 Markdown 修改后再进行全文改写。"
|
||||
: "当前文件暂不支持全文改写。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDocumentRewriteDialogOpen(false);
|
||||
setAllowAutoViewSwitch(false);
|
||||
setAutoScrollEnabled(true);
|
||||
setViewMode("code");
|
||||
const expectedHash = await sha256Content(draftContent);
|
||||
if (!canEditArtifact) {
|
||||
toast.error("当前文件暂不支持全文改写。");
|
||||
return;
|
||||
}
|
||||
setDocumentRewriteDialogOpen(false);
|
||||
setAllowAutoViewSwitch(false);
|
||||
setAutoScrollEnabled(true);
|
||||
setViewMode("code");
|
||||
|
||||
// Do not make users manually save before using AI rewrite. Persist their
|
||||
// current draft first so the rewrite starts from exactly what they see,
|
||||
// and the optimistic hash remains valid on the server.
|
||||
if (hasLocalEdits) {
|
||||
try {
|
||||
await updateArtifact.mutateAsync({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
content: draftContent,
|
||||
});
|
||||
setHasLocalEdits(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save Markdown before AI rewrite:", error);
|
||||
setHasLocalEdits(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
primeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent: draftContent,
|
||||
});
|
||||
const expectedHash = await sha256Content(draftContent);
|
||||
const presentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
@@ -947,16 +1097,22 @@ export function ArtifactFileDetail({
|
||||
generateImages: input.generateImages,
|
||||
maxGeneratedImages: input.maxGeneratedImages,
|
||||
originalContent: draftContent,
|
||||
});
|
||||
setDocumentRewritePresentationId(presentationId ?? null);
|
||||
void documentRewrite.start(
|
||||
});
|
||||
setDocumentRewritePresentationId(presentationId ?? null);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DOCUMENT_REWRITE_STARTED_EVENT, {
|
||||
detail: { threadId },
|
||||
}),
|
||||
);
|
||||
void documentRewrite.start(
|
||||
{
|
||||
threadId,
|
||||
path: filepath,
|
||||
instruction: input.instruction,
|
||||
style: input.style,
|
||||
modelName: input.modelName ?? undefined,
|
||||
generateImages: input.generateImages,
|
||||
instruction: input.instruction,
|
||||
style: input.style,
|
||||
modelName: input.modelName ?? undefined,
|
||||
reportOutline: input.reportOutline || undefined,
|
||||
generateImages: input.generateImages,
|
||||
maxGeneratedImages: input.maxGeneratedImages,
|
||||
expectedHash,
|
||||
},
|
||||
@@ -1032,10 +1188,11 @@ export function ArtifactFileDetail({
|
||||
case "commit_started":
|
||||
rewritePresentation.update(presentationId, { stage: "committing" });
|
||||
break;
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
versionId: event.versionId ?? null,
|
||||
});
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
versionId: event.versionId ?? null,
|
||||
committedHash: event.committedHash,
|
||||
});
|
||||
break;
|
||||
case "done":
|
||||
rewritePresentation.update(presentationId, {
|
||||
@@ -1049,21 +1206,33 @@ export function ArtifactFileDetail({
|
||||
error: event.message,
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error: event.message,
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
// The sandbox retains the visible draft and exposes manual
|
||||
// save instead of showing an error state to the user.
|
||||
console.error("AI 全文改写接口失败:", event.message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
onCommitted: (rewritten) => {
|
||||
setDraftContent(rewritten);
|
||||
setHasLocalEdits(false);
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, { rewrittenContent: rewritten });
|
||||
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
||||
setDraftContent(rewritten);
|
||||
setHasLocalEdits(versionSnapshotFailed);
|
||||
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent: draftContent,
|
||||
committedHash,
|
||||
});
|
||||
if (!versionSnapshotFailed) {
|
||||
clearArtifactRewriteFallback(threadId, filepath);
|
||||
}
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
});
|
||||
}
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
@@ -1071,21 +1240,51 @@ export function ArtifactFileDetail({
|
||||
},
|
||||
{ content: rewritten, url: undefined },
|
||||
);
|
||||
toast.success("Markdown 全文改写完成,已安全替换原文件。");
|
||||
},
|
||||
},
|
||||
);
|
||||
if (versionSnapshotFailed) {
|
||||
console.error(
|
||||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||
);
|
||||
} else {
|
||||
toast.success("Markdown 全文改写完成,已安全替换原文件。");
|
||||
}
|
||||
},
|
||||
onFailure: ({ content, error }) => {
|
||||
// The rewrite transport can fail after the server has already
|
||||
// streamed text (or before the first token on a 404). Keep that
|
||||
// visible content in the sandbox, make Save actionable, and leave
|
||||
// diagnostics to the developer console only.
|
||||
const retainedContent = content || draftContent;
|
||||
setDraftContent(retainedContent);
|
||||
setHasLocalEdits(true);
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
},
|
||||
{ content: retainedContent, url: undefined },
|
||||
);
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "completed",
|
||||
rewrittenContent: retainedContent,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[
|
||||
canUseFullDocumentRewrite,
|
||||
draftContent,
|
||||
documentRewrite,
|
||||
filepath,
|
||||
hasLocalEdits,
|
||||
isMock,
|
||||
queryClient,
|
||||
rewritePresentation,
|
||||
threadId,
|
||||
canEditArtifact,
|
||||
draftContent,
|
||||
documentRewrite,
|
||||
filepath,
|
||||
hasLocalEdits,
|
||||
isMock,
|
||||
queryClient,
|
||||
rewritePresentation,
|
||||
threadId,
|
||||
updateArtifact,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1331,13 +1530,15 @@ export function ArtifactFileDetail({
|
||||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||
tooltip={
|
||||
documentRewriteActive
|
||||
? "正在生成临时草稿,原文件尚未被覆盖"
|
||||
: hasLocalEdits
|
||||
? "请先保存当前 Markdown 修改"
|
||||
: "使用 AI 重写当前完整 Markdown"
|
||||
? "正在生成临时草稿,原文件尚未被覆盖"
|
||||
: hasLocalEdits
|
||||
? "将先保存当前 Markdown 修改,再开始改写"
|
||||
: "使用 AI 重写当前完整 Markdown"
|
||||
}
|
||||
disabled={documentRewriteActive || !canUseFullDocumentRewrite}
|
||||
onClick={() => setDocumentRewriteDialogOpen(true)}
|
||||
// Keep the entry available after a completed rewrite and when
|
||||
// the user has local edits. Starting with local edits saves
|
||||
// them automatically in handleStartDocumentRewrite.
|
||||
onClick={() => setDocumentRewriteDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||
@@ -1509,7 +1710,28 @@ export function ArtifactFileDetail({
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
{artifactContentPending && viewMode === "code" && (
|
||||
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
|
||||
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
|
||||
正在加载文档内容…
|
||||
</div>
|
||||
)}
|
||||
{artifactContentUnavailable && viewMode === "code" && (
|
||||
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 px-6 text-center text-sm">
|
||||
<FileType2Icon aria-hidden="true" className="size-6" />
|
||||
<p>{artifactContentError ? "暂时无法读取文档内容。" : "当前文档没有可显示的内容。"}</p>
|
||||
<button
|
||||
className="text-primary rounded-md px-2 py-1 font-medium underline-offset-4 hover:underline focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
void refetchArtifactContent();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
@@ -1724,12 +1946,21 @@ export function ArtifactFilePreview({
|
||||
researchSessionId?: string;
|
||||
animated?: boolean;
|
||||
activeCursor?: boolean;
|
||||
}) {
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
|
||||
const [mermaidError, setMermaidError] = useState<string | null>(null);
|
||||
const markdownComponents = useMemo(() => {
|
||||
const anchorResolver = createMarkdownAnchorResolver(content ?? "");
|
||||
}) {
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
|
||||
const [mermaidError, setMermaidError] = useState<string | null>(null);
|
||||
const researchSourcesQuery = useResearchSources(
|
||||
language === "markdown" ? (researchSessionId ?? null) : null,
|
||||
);
|
||||
const previewContent = useMemo(
|
||||
() => researchSessionId
|
||||
? formatResearchCitationsForPreview(content ?? "", researchSourcesQuery.data?.items ?? [])
|
||||
: (content ?? ""),
|
||||
[content, researchSessionId, researchSourcesQuery.data?.items],
|
||||
);
|
||||
const markdownComponents = useMemo(() => {
|
||||
const anchorResolver = createMarkdownAnchorResolver(previewContent);
|
||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
const Tag = `h${level}` as const;
|
||||
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
|
||||
@@ -1785,7 +2016,7 @@ export function ArtifactFilePreview({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [content, publicShareCode, researchSessionId, threadId]);
|
||||
}, [previewContent, publicShareCode, researchSessionId, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== "html") {
|
||||
@@ -1874,8 +2105,8 @@ export function ArtifactFilePreview({
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
{previewContent}
|
||||
</Streamdown>
|
||||
{activeCursor && (
|
||||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,6 +29,7 @@ export function ArtifactFileList({
|
||||
threadId,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
fileDescriptions,
|
||||
}: {
|
||||
className?: string;
|
||||
files: string[];
|
||||
@@ -36,6 +37,8 @@ export function ArtifactFileList({
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Optional host download action for virtual/session-owned artifacts. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
/** Optional per-file secondary text, such as the report structure used. */
|
||||
fileDescriptions?: Record<string, ReactNode>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isMock, publicShareCode } = useThread();
|
||||
@@ -102,10 +105,10 @@ export function ArtifactFileList({
|
||||
<div className="absolute top-2 -left-0.5">
|
||||
{getFileIcon(file, "size-6")}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription className="min-w-0 pl-8 text-xs">
|
||||
{getFileExtensionDisplayName(file)} file
|
||||
</CardDescription>
|
||||
</CardTitle>
|
||||
<CardDescription className="min-w-0 pl-8 text-xs">
|
||||
{fileDescriptions?.[file] ?? `${getFileExtensionDisplayName(file)} file`}
|
||||
</CardDescription>
|
||||
<CardAction className="row-span-1 self-center">
|
||||
{file.endsWith(".skill") && (
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ImagePlusIcon, SparklesIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FilePenLineIcon, ImagePlusIcon, SparklesIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -23,6 +23,20 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
|
||||
import { useReportStructures } from "@/strategy-components/api/report-structures";
|
||||
|
||||
const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__";
|
||||
const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。";
|
||||
const DEFAULT_GENERATE_INSTRUCTION = "基于已选资料按所选报告结构生成一篇新的完整报告;保持事实可核验,不补充没有依据的信息。";
|
||||
|
||||
export interface DocumentRewriteDialogInput {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
reportOutline: string;
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
}
|
||||
|
||||
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
|
||||
{
|
||||
@@ -54,7 +68,13 @@ export function DocumentRewriteDialog({
|
||||
charCount,
|
||||
imageGenerationAvailable = false,
|
||||
maxGeneratedImages = 4,
|
||||
initialReportOutline,
|
||||
operationLabel,
|
||||
operationDescription,
|
||||
submitLabel,
|
||||
operation,
|
||||
onStart,
|
||||
onRewriteOriginal,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -62,64 +82,106 @@ export function DocumentRewriteDialog({
|
||||
charCount: number;
|
||||
imageGenerationAvailable?: boolean;
|
||||
maxGeneratedImages?: number;
|
||||
onStart: (input: {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
}) => void;
|
||||
/** A saved report structure selected before the dialog was opened. */
|
||||
initialReportOutline?: string;
|
||||
/** Optional copy for flows that create a new document instead of replacing one. */
|
||||
operationLabel?: string;
|
||||
operationDescription?: string;
|
||||
submitLabel?: string;
|
||||
/** ``generate`` creates an independent report; ``rewrite`` edits this report in place. */
|
||||
operation?: "rewrite" | "generate";
|
||||
onStart: (input: DocumentRewriteDialogInput) => void;
|
||||
/** Optional secondary action for generation flows that rewrites the source report in place. */
|
||||
onRewriteOriginal?: (input: DocumentRewriteDialogInput) => void;
|
||||
}) {
|
||||
const [instruction, setInstruction] = useState(
|
||||
"统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。",
|
||||
);
|
||||
const isNewReportGeneration = operation === "generate";
|
||||
const [instruction, setInstruction] = useState(DEFAULT_REWRITE_INSTRUCTION);
|
||||
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE);
|
||||
const [generateImages, setGenerateImages] = useState(false);
|
||||
const [imageCount, setImageCount] = useState("2");
|
||||
const reportStructuresQuery = useReportStructures({ status: "enabled" });
|
||||
const reportStructures = reportStructuresQuery.data ?? [];
|
||||
const selectedReportStructure = reportStructures.find((item) => item.id === reportStructureId);
|
||||
const initialReportStructureId = useMemo(() => {
|
||||
const outline = initialReportOutline?.trim();
|
||||
if (!outline) return null;
|
||||
return reportStructures.find((item) => item.content.trim() === outline)?.id ?? null;
|
||||
}, [initialReportOutline, reportStructures]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setInstruction(isNewReportGeneration ? DEFAULT_GENERATE_INSTRUCTION : DEFAULT_REWRITE_INSTRUCTION);
|
||||
setStyle("formal_analysis");
|
||||
setModelName(null);
|
||||
setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE);
|
||||
setGenerateImages(false);
|
||||
setImageCount("2");
|
||||
}, [open]);
|
||||
}, [isNewReportGeneration, open]);
|
||||
|
||||
// The structures query may resolve after the dialog opens. Only seed the
|
||||
// value while the user still has the default selection, so a manual choice
|
||||
// is never overwritten by a query refresh.
|
||||
useEffect(() => {
|
||||
if (!open || !initialReportStructureId) return;
|
||||
setReportStructureId((current) => (
|
||||
current === KEEP_CURRENT_REPORT_STRUCTURE ? initialReportStructureId : current
|
||||
));
|
||||
}, [initialReportStructureId, open]);
|
||||
|
||||
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
|
||||
const canStart = instruction.trim().length > 0;
|
||||
const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => {
|
||||
if (!canStart) return;
|
||||
handler({
|
||||
instruction: instruction.trim(),
|
||||
style,
|
||||
modelName,
|
||||
reportOutline: selectedReportStructure?.content ?? "",
|
||||
generateImages,
|
||||
maxGeneratedImages: Math.min(
|
||||
Math.max(Number(imageCount) || 1, 1),
|
||||
Math.max(Math.min(maxGeneratedImages, 4), 1),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-5 text-primary" />
|
||||
AI 全文改写
|
||||
{operationLabel ?? "AI 全文改写"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。
|
||||
{operationDescription ?? "将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm">
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-muted-foreground mt-1">原文约 {charCount.toLocaleString()} 字</div>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
{isNewReportGeneration ? "来源报告" : "原文"}约 {charCount.toLocaleString()} 字
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
改写要求
|
||||
{isNewReportGeneration ? "生成要求" : "改写要求"}
|
||||
<Textarea
|
||||
value={instruction}
|
||||
onChange={(event) => setInstruction(event.target.value)}
|
||||
placeholder="例如:统一为正式行业分析语气,减少重复,不新增外部事实。"
|
||||
placeholder={isNewReportGeneration
|
||||
? "例如:突出政策影响,并按所选结构组织章节;仅使用已选资料。"
|
||||
: "例如:统一为正式行业分析语气,减少重复,不新增外部事实。"}
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
改写风格
|
||||
{isNewReportGeneration ? "报告风格" : "改写风格"}
|
||||
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -135,6 +197,36 @@ export function DocumentRewriteDialog({
|
||||
<span className="text-muted-foreground text-xs">{selectedStyle?.description}</span>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
报告结构
|
||||
<Select value={reportStructureId} onValueChange={setReportStructureId}>
|
||||
<SelectTrigger aria-label="报告结构">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={KEEP_CURRENT_REPORT_STRUCTURE}>沿用当前结构</SelectItem>
|
||||
{reportStructuresQuery.isLoading && (
|
||||
<SelectItem value="__report_structures_loading__" disabled>
|
||||
正在加载结构…
|
||||
</SelectItem>
|
||||
)}
|
||||
{reportStructuresQuery.isError && (
|
||||
<SelectItem value="__report_structures_error__" disabled>
|
||||
报告结构库加载失败
|
||||
</SelectItem>
|
||||
)}
|
||||
{reportStructures.map((structure) => (
|
||||
<SelectItem key={structure.id} value={structure.id}>
|
||||
{structure.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{selectedReportStructure?.name || "默认保留原文的章节安排。"}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
模型
|
||||
<ModelNameSelect
|
||||
@@ -143,7 +235,9 @@ export function DocumentRewriteDialog({
|
||||
triggerClassName="h-10 text-sm"
|
||||
defaultLabel="系统默认模型"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">仅影响本次全文改写。</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
仅影响本次{isNewReportGeneration ? "新报告生成" : "全文改写"}。
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -155,14 +249,16 @@ export function DocumentRewriteDialog({
|
||||
<ImagePlusIcon className="size-4" />
|
||||
</span>
|
||||
<span className="grid gap-0.5">
|
||||
<span className="text-sm font-medium">改写后补充配图</span>
|
||||
<span className="text-sm font-medium">
|
||||
{isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs leading-5">
|
||||
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<Switch
|
||||
aria-label="改写后补充配图"
|
||||
aria-label={isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
|
||||
checked={generateImages}
|
||||
id="document-rewrite-images"
|
||||
onCheckedChange={setGenerateImages}
|
||||
@@ -193,24 +289,22 @@ export function DocumentRewriteDialog({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
{isNewReportGeneration && onRewriteOriginal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canStart}
|
||||
onClick={() => startWith(onRewriteOriginal)}
|
||||
>
|
||||
<FilePenLineIcon />
|
||||
改写原文
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!canStart}
|
||||
onClick={() => {
|
||||
if (!canStart) return;
|
||||
onStart({
|
||||
instruction: instruction.trim(),
|
||||
style,
|
||||
modelName,
|
||||
generateImages,
|
||||
maxGeneratedImages: Math.min(
|
||||
Math.max(Number(imageCount) || 1, 1),
|
||||
Math.max(Math.min(maxGeneratedImages, 4), 1),
|
||||
),
|
||||
});
|
||||
}}
|
||||
onClick={() => startWith(onStart)}
|
||||
>
|
||||
<SparklesIcon />
|
||||
开始改写
|
||||
{submitLabel ?? "开始改写"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronsDownIcon,
|
||||
FilePenLineIcon,
|
||||
LightbulbIcon,
|
||||
Loader2Icon,
|
||||
Undo2Icon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,17 +20,26 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { updateArtifactContent } from "@/core/artifacts/api";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import {
|
||||
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
|
||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||
type DocumentRewritePresentation,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import {
|
||||
clearArtifactRewriteFallback,
|
||||
loadArtifactRewriteFallback,
|
||||
sha256DocumentContent,
|
||||
} from "@/core/document-rewrite/local-fallback";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
|
||||
|
||||
const STEPS = [
|
||||
const REWRITE_STEPS = [
|
||||
{ key: "snapshot", label: "固定原文版本" },
|
||||
{ key: "requirements", label: "理解改写要求" },
|
||||
{ key: "writing", label: "流式重写 Markdown" },
|
||||
@@ -38,6 +48,15 @@ const STEPS = [
|
||||
{ key: "comparison", label: "对比原文并生成说明" },
|
||||
] as const;
|
||||
|
||||
const GENERATE_STEPS = [
|
||||
{ key: "snapshot", label: "固定来源报告快照" },
|
||||
{ key: "requirements", label: "确认用户生成需求" },
|
||||
{ key: "writing", label: "流式生成 Markdown" },
|
||||
{ key: "illustrations", label: "生成文档配图" },
|
||||
{ key: "validation", label: "校验新报告" },
|
||||
{ key: "comparison", label: "生成报告说明" },
|
||||
] as const;
|
||||
|
||||
function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
|
||||
switch (stage) {
|
||||
case "snapshot":
|
||||
@@ -68,11 +87,75 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
|
||||
|
||||
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
|
||||
const [open, setOpen] = useState(active);
|
||||
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
|
||||
const contentRef = useRef<HTMLPreElement>(null);
|
||||
const autoScrollEnabledRef = useRef(true);
|
||||
const previousActiveRef = useRef(active);
|
||||
const lastAutoScrollTopRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && text) setOpen(true);
|
||||
}, [active, text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !previousActiveRef.current) {
|
||||
autoScrollEnabledRef.current = true;
|
||||
lastAutoScrollTopRef.current = null;
|
||||
setAutoScrollPaused(false);
|
||||
}
|
||||
previousActiveRef.current = active;
|
||||
}, [active]);
|
||||
|
||||
const scrollToLatestThinking = useCallback(() => {
|
||||
if (!autoScrollEnabledRef.current) return;
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
content.scrollTop = content.scrollHeight;
|
||||
lastAutoScrollTopRef.current = content.scrollTop;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!active || !open || !text) return;
|
||||
scrollToLatestThinking();
|
||||
}, [active, open, scrollToLatestThinking, text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !open || !text) return;
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
let frame: number | null = null;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
scrollToLatestThinking();
|
||||
});
|
||||
});
|
||||
observer.observe(content, { childList: true, characterData: true, subtree: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [active, open, scrollToLatestThinking, text]);
|
||||
|
||||
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
|
||||
if (!active || !autoScrollEnabledRef.current) return;
|
||||
const lastAutoScrollTop = lastAutoScrollTopRef.current;
|
||||
if (
|
||||
lastAutoScrollTop === null ||
|
||||
Math.abs(event.currentTarget.scrollTop - lastAutoScrollTop) > 1
|
||||
) {
|
||||
autoScrollEnabledRef.current = false;
|
||||
setAutoScrollPaused(true);
|
||||
}
|
||||
};
|
||||
|
||||
const resumeAutoScroll = () => {
|
||||
autoScrollEnabledRef.current = true;
|
||||
setAutoScrollPaused(false);
|
||||
requestAnimationFrame(scrollToLatestThinking);
|
||||
};
|
||||
|
||||
if (!text.trim()) return null;
|
||||
return (
|
||||
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
|
||||
@@ -94,9 +177,29 @@ function RewriteThinkingTrace({ text, active }: { text: string; active: boolean
|
||||
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
|
||||
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
|
||||
</p>
|
||||
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
|
||||
{text}
|
||||
</pre>
|
||||
<div className="relative mt-1">
|
||||
<pre
|
||||
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
|
||||
onScroll={pauseAutoScrollOnManualScroll}
|
||||
ref={contentRef}
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
{active && autoScrollPaused && (
|
||||
<Button
|
||||
aria-label="恢复思考内容自动下滑"
|
||||
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
|
||||
onClick={resumeAutoScroll}
|
||||
size="sm"
|
||||
title="恢复自动下滑"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronsDownIcon className="size-3.5" />
|
||||
自动下滑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
@@ -107,6 +210,8 @@ export function DocumentRewriteTimeline({
|
||||
}: {
|
||||
item: DocumentRewritePresentation;
|
||||
}) {
|
||||
const isNewReportGeneration = item.operation === "generate";
|
||||
const steps = isNewReportGeneration ? GENERATE_STEPS : REWRITE_STEPS;
|
||||
const current = stepPosition(item.stage);
|
||||
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
|
||||
const completed = item.stage === "completed";
|
||||
@@ -119,9 +224,79 @@ export function DocumentRewriteTimeline({
|
||||
|
||||
const reapplying = Boolean(item.revertedAt);
|
||||
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
||||
const canUseLocalFallback =
|
||||
!reapplying &&
|
||||
!activeVersionId &&
|
||||
item.source.kind === "artifact" &&
|
||||
typeof item.source.path === "string" &&
|
||||
item.localFallbackAvailable &&
|
||||
typeof item.committedHash === "string";
|
||||
|
||||
const handleUndo = async () => {
|
||||
if (!activeVersionId || undoing) return;
|
||||
if ((!activeVersionId && !canUseLocalFallback) || undoing) return;
|
||||
if (canUseLocalFallback && item.source.kind === "artifact" && item.source.path && item.committedHash) {
|
||||
setUndoing(true);
|
||||
try {
|
||||
const fallback = loadArtifactRewriteFallback({
|
||||
threadId: item.source.id,
|
||||
path: item.source.path,
|
||||
committedHash: item.committedHash,
|
||||
});
|
||||
if (!fallback) {
|
||||
console.error("本地 AI 全文改写撤销备份不可用,未修改当前文件。");
|
||||
return;
|
||||
}
|
||||
const currentResponse = await apiFetch(
|
||||
urlOfArtifact({ filepath: item.source.path, threadId: item.source.id }),
|
||||
);
|
||||
if (!currentResponse.ok) {
|
||||
throw new Error(`读取当前文件失败(${currentResponse.status})。`);
|
||||
}
|
||||
const currentHash = await sha256DocumentContent(await currentResponse.text());
|
||||
if (!currentHash || currentHash !== fallback.committedHash) {
|
||||
console.error("当前文件已在改写后发生变化,已跳过本地 AI 全文改写撤销。");
|
||||
return;
|
||||
}
|
||||
const guard = new CustomEvent<{
|
||||
source: DocumentRewritePresentation["source"];
|
||||
rewrittenContent: string | null;
|
||||
unchanged: boolean;
|
||||
}>(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, {
|
||||
detail: {
|
||||
source: item.source,
|
||||
rewrittenContent: item.rewrittenContent,
|
||||
unchanged: false,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(guard);
|
||||
if (!guard.detail.unchanged) {
|
||||
console.error("沙箱中存在改写后的未保存编辑,已跳过本地 AI 全文改写撤销。");
|
||||
return;
|
||||
}
|
||||
await updateArtifactContent({
|
||||
filepath: item.source.path,
|
||||
threadId: item.source.id,
|
||||
content: fallback.originalContent,
|
||||
});
|
||||
clearArtifactRewriteFallback(item.source.id, item.source.path);
|
||||
presentation?.update(item.id, {
|
||||
localFallbackAvailable: false,
|
||||
revertedAt: new Date().toISOString(),
|
||||
});
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
|
||||
detail: { source: item.source, content: fallback.originalContent },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("本地 AI 全文改写撤销失败:", error);
|
||||
} finally {
|
||||
setUndoing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeVersionId) return;
|
||||
const baseUrl = getBackendBaseURL();
|
||||
const endpoint =
|
||||
item.source.kind === "artifact"
|
||||
@@ -158,7 +333,15 @@ export function DocumentRewriteTimeline({
|
||||
detail: { source: item.source, content },
|
||||
}),
|
||||
);
|
||||
toast.success(reapplying ? "已重新应用本次 AI 全文改写版本。" : "已恢复到本次 AI 全文改写前的版本。");
|
||||
toast.success(
|
||||
isNewReportGeneration
|
||||
? reapplying
|
||||
? "已重新应用本次生成的新报告版本。"
|
||||
: "已恢复本次生成前的新报告快照。"
|
||||
: reapplying
|
||||
? "已重新应用本次 AI 全文改写版本。"
|
||||
: "已恢复到本次 AI 全文改写前的版本。",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。");
|
||||
} finally {
|
||||
@@ -174,7 +357,9 @@ export function DocumentRewriteTimeline({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold">AI 全文改写 · {item.fileName}</h3>
|
||||
<h3 className="font-semibold">
|
||||
{isNewReportGeneration ? "生成新报告" : "AI 全文改写"} · {item.fileName}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs",
|
||||
@@ -185,7 +370,7 @@ export function DocumentRewriteTimeline({
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{completed ? "已完成" : failed ? "未覆盖原文件" : "进行中"}
|
||||
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
@@ -195,7 +380,7 @@ export function DocumentRewriteTimeline({
|
||||
</header>
|
||||
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
{STEPS.map((step, index) => {
|
||||
{steps.map((step, index) => {
|
||||
const active = !isTerminal(item.stage) && index === current;
|
||||
const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
|
||||
const illustrationFailed =
|
||||
@@ -228,7 +413,7 @@ export function DocumentRewriteTimeline({
|
||||
<div className="text-sm font-medium">{step.label}</div>
|
||||
{step.key === "snapshot" && item.originalCharCount > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
已冻结 {item.originalCharCount.toLocaleString()} 字原文快照。
|
||||
已冻结 {item.originalCharCount.toLocaleString()} 字{isNewReportGeneration ? "来源报告" : "原文"}快照。
|
||||
</p>
|
||||
)}
|
||||
{step.key === "requirements" && (
|
||||
@@ -242,18 +427,28 @@ export function DocumentRewriteTimeline({
|
||||
</div>
|
||||
) : active ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在识别改写目标、保留约束与执行方式…
|
||||
{isNewReportGeneration
|
||||
? "正在确认新报告的结构、资料范围与生成要求…"
|
||||
: "正在识别改写目标、保留约束与执行方式…"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
已根据改写要求确定执行计划。
|
||||
{isNewReportGeneration ? "已根据所选结构确定生成计划。" : "已根据改写要求确定执行计划。"}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{step.key === "writing" && item.writtenCharCount > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
|
||||
</p>
|
||||
{step.key === "writing" && (
|
||||
item.writtenCharCount > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
|
||||
</p>
|
||||
) : active ? (
|
||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||
{isNewReportGeneration
|
||||
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
|
||||
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
|
||||
</p>
|
||||
) : null
|
||||
)}
|
||||
{step.key === "illustrations" && (
|
||||
illustrationSkipped ? (
|
||||
@@ -292,9 +487,10 @@ export function DocumentRewriteTimeline({
|
||||
|
||||
{item.summary && (
|
||||
<div className="border-t px-5 py-4 text-sm leading-6">
|
||||
<div className="mb-2 font-semibold">改写说明</div>
|
||||
<div className="mb-2 font-semibold">{isNewReportGeneration ? "生成说明" : "改写说明"}</div>
|
||||
<p>
|
||||
原文 {item.summary.originalCharCount.toLocaleString()} 字,改写后{" "}
|
||||
{isNewReportGeneration ? "来源报告" : "原文"} {item.summary.originalCharCount.toLocaleString()} 字,
|
||||
{isNewReportGeneration ? "新报告" : "改写后"}{" "}
|
||||
{item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "}
|
||||
{item.summary.changedBlockCount} 个内容块。
|
||||
</p>
|
||||
@@ -340,7 +536,25 @@ export function DocumentRewriteTimeline({
|
||||
查看前后对比
|
||||
</Button>
|
||||
)}
|
||||
{completed && activeVersionId && (
|
||||
{isNewReportGeneration && completed && item.source.kind === "deep_research_report" && (
|
||||
<Button
|
||||
className="mt-3 mr-2"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT, {
|
||||
detail: { sessionId: item.source.id },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<FilePenLineIcon className="mr-1.5 size-3.5" />
|
||||
改写新报告
|
||||
</Button>
|
||||
)}
|
||||
{completed && (activeVersionId || canUseLocalFallback) && (
|
||||
<Button
|
||||
className="mt-3"
|
||||
disabled={undoing}
|
||||
@@ -350,12 +564,19 @@ export function DocumentRewriteTimeline({
|
||||
variant="outline"
|
||||
>
|
||||
{undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />}
|
||||
{reapplying ? "重新应用此次改写" : "撤销此次改写"}
|
||||
{isNewReportGeneration
|
||||
? reapplying
|
||||
? "重新应用本次生成"
|
||||
: "撤销本次生成"
|
||||
: reapplying
|
||||
? "重新应用此次改写"
|
||||
: "撤销此次改写"}
|
||||
</Button>
|
||||
)}
|
||||
{item.revertedAt && (
|
||||
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
已恢复为改写前版本。{item.redoVersionId ? "可重新应用本次改写。" : ""}
|
||||
已恢复为{isNewReportGeneration ? "生成前" : "改写前"}版本。
|
||||
{item.redoVersionId ? `可重新应用本次${isNewReportGeneration ? "生成" : "改写"}。` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
BookOpenTextIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronsDownIcon,
|
||||
ChevronUp,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
SquareTerminalIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type AnchorHTMLAttributes } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type AnchorHTMLAttributes } from "react";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
@@ -603,13 +604,64 @@ function DeepResearchProgressStep({
|
||||
? args.label
|
||||
: "正在撰写研究报告";
|
||||
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
|
||||
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
|
||||
const active = args.status !== "complete";
|
||||
const [thinkingOpen, setThinkingOpen] = useState(false);
|
||||
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
|
||||
const active = args.status !== "complete";
|
||||
const [thinkingOpen, setThinkingOpen] = useState(false);
|
||||
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
|
||||
const thinkingContentRef = useRef<HTMLPreElement>(null);
|
||||
const autoScrollEnabledRef = useRef(true);
|
||||
const programmaticScrollRef = useRef(false);
|
||||
const previousActiveRef = useRef(active);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && thinking) setThinkingOpen(true);
|
||||
}, [active, thinking]);
|
||||
useEffect(() => {
|
||||
if (active && thinking) setThinkingOpen(true);
|
||||
}, [active, thinking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !previousActiveRef.current) {
|
||||
autoScrollEnabledRef.current = true;
|
||||
setAutoScrollPaused(false);
|
||||
}
|
||||
previousActiveRef.current = active;
|
||||
}, [active]);
|
||||
|
||||
const scrollToLatestThinking = useCallback(() => {
|
||||
if (!autoScrollEnabledRef.current) return;
|
||||
const content = thinkingContentRef.current;
|
||||
if (!content) return;
|
||||
// Scroll events do not identify their initiator reliably across browsers.
|
||||
// Mark this assignment so it never disables following as if it were a
|
||||
// user drag; a user-originated scroll away from the bottom still pauses it.
|
||||
programmaticScrollRef.current = true;
|
||||
content.scrollTop = content.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
programmaticScrollRef.current = false;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!active || !thinkingOpen || !thinking) return;
|
||||
scrollToLatestThinking();
|
||||
// A second frame covers the browser's delayed reflow of a growing <pre>.
|
||||
const frame = requestAnimationFrame(scrollToLatestThinking);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [active, thinking, thinkingOpen, scrollToLatestThinking]);
|
||||
|
||||
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
|
||||
if (!active || !autoScrollEnabledRef.current || programmaticScrollRef.current) return;
|
||||
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
|
||||
// Being at the bottom is still the follow state. Only an actual move away
|
||||
// from it (wheel, touch, keyboard or scrollbar drag) pauses auto-follow.
|
||||
if (scrollTop + clientHeight >= scrollHeight - 4) return;
|
||||
autoScrollEnabledRef.current = false;
|
||||
setAutoScrollPaused(true);
|
||||
};
|
||||
|
||||
const resumeAutoScroll = () => {
|
||||
autoScrollEnabledRef.current = true;
|
||||
setAutoScrollPaused(false);
|
||||
requestAnimationFrame(scrollToLatestThinking);
|
||||
};
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
@@ -652,9 +704,29 @@ function DeepResearchProgressStep({
|
||||
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
|
||||
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
|
||||
</p>
|
||||
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
|
||||
{thinking}
|
||||
</pre>
|
||||
<div className="relative mt-1">
|
||||
<pre
|
||||
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
|
||||
onScroll={pauseAutoScrollOnManualScroll}
|
||||
ref={thinkingContentRef}
|
||||
>
|
||||
{thinking}
|
||||
</pre>
|
||||
{active && autoScrollPaused && (
|
||||
<Button
|
||||
aria-label="恢复思考内容自动下滑"
|
||||
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
|
||||
onClick={resumeAutoScroll}
|
||||
size="sm"
|
||||
title="恢复自动下滑"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronsDownIcon className="size-3.5" />
|
||||
自动下滑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context";
|
||||
import {
|
||||
DOCUMENT_REWRITE_STARTED_EVENT,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
@@ -89,6 +92,22 @@ function ScrollToBottomOnUserMessage({
|
||||
}
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentRewriteStarted = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ threadId?: string }>).detail;
|
||||
if (detail?.threadId !== threadId) return;
|
||||
// The presentation card is inserted by a context update in the same
|
||||
// interaction. Two frames let React commit it before the smooth scroll.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
void scrollToBottom("smooth");
|
||||
});
|
||||
});
|
||||
};
|
||||
window.addEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
|
||||
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
|
||||
}, [scrollToBottom, threadId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -115,6 +134,7 @@ export function MessageList({
|
||||
researchApprovalSlot,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
afterDocumentRewriteItems,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
@@ -147,6 +167,8 @@ export function MessageList({
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
/** Optional host content placed immediately after document-rewrite timelines. */
|
||||
afterDocumentRewriteItems?: React.ReactNode;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
@@ -470,7 +492,15 @@ export function MessageList({
|
||||
)}
|
||||
>
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{groupMessages(messages, (group) => {
|
||||
{(() => {
|
||||
const rewriteItemsAboveFile = documentRewriteItems.filter(
|
||||
(item) => item.operation !== "generate",
|
||||
);
|
||||
const rewriteItemsBelowFile = documentRewriteItems.filter(
|
||||
(item) => item.operation === "generate",
|
||||
);
|
||||
let didRenderRewriteAboveFile = false;
|
||||
const grouped = groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
||||
@@ -608,8 +638,15 @@ export function MessageList({
|
||||
files.push(...presentFiles);
|
||||
}
|
||||
}
|
||||
const rewriteAbove = !didRenderRewriteAboveFile
|
||||
? rewriteItemsAboveFile
|
||||
: [];
|
||||
didRenderRewriteAboveFile = true;
|
||||
return (
|
||||
<div className="w-full" key={group.id}>
|
||||
{rewriteAbove.map((item) => (
|
||||
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
|
||||
))}
|
||||
{group.messages[0] && hasContent(group.messages[0]) && (
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(group.messages[0])}
|
||||
@@ -752,10 +789,20 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{documentRewriteItems.map((item) => (
|
||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||
))}
|
||||
});
|
||||
const leftoverRewriteItems = didRenderRewriteAboveFile
|
||||
? rewriteItemsBelowFile
|
||||
: documentRewriteItems;
|
||||
return (
|
||||
<>
|
||||
{grouped}
|
||||
{leftoverRewriteItems.map((item) => (
|
||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||
))}
|
||||
{afterDocumentRewriteItems}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</MessageMetricsProvider>
|
||||
|
||||
@@ -117,7 +117,7 @@ function normalizeShortcutDraft(
|
||||
? button.route_params
|
||||
: [newShortcutParam()]
|
||||
).map((param) => ({
|
||||
key: param.key || "",
|
||||
key: param.paramType === "path" ? "" : param.key || "",
|
||||
paramType: param.paramType || "login",
|
||||
source: param.source || "access_token",
|
||||
customValue: param.customValue ?? "",
|
||||
@@ -132,8 +132,18 @@ function cleanShortcutParams(
|
||||
params: CompactShortcutRouteParam[],
|
||||
): CompactShortcutRouteParam[] {
|
||||
return params
|
||||
.filter((param) => param.key.trim())
|
||||
.filter((param) => param.paramType === "path" || param.key.trim())
|
||||
.map((param) => {
|
||||
if (param.paramType === "path") {
|
||||
const source = param.source || "access_token";
|
||||
return {
|
||||
key: "",
|
||||
paramType: "path" as const,
|
||||
source,
|
||||
customValue:
|
||||
source === "other" ? (param.customValue ?? "").trim() : undefined,
|
||||
};
|
||||
}
|
||||
const base: CompactShortcutRouteParam = {
|
||||
key: param.key.trim(),
|
||||
paramType: param.paramType,
|
||||
@@ -354,6 +364,13 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
return;
|
||||
}
|
||||
for (const param of item.route_params) {
|
||||
if (param.paramType === "path") {
|
||||
if (param.source === "other" && !param.customValue?.trim()) {
|
||||
toast.error("路径参数自定义值不可为空");
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!param.key.trim()) {
|
||||
toast.error("路由参数键名不可为空");
|
||||
return;
|
||||
@@ -548,7 +565,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="顶部快捷按钮(管理员)"
|
||||
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
|
||||
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停,以及登录、主题和路径参数。"
|
||||
>
|
||||
<CompactShortcutSettings
|
||||
buttons={shortcutDraft}
|
||||
@@ -915,7 +932,7 @@ function CompactShortcutSettings({
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。
|
||||
已配置 {buttons.length} 个按钮;支持追加登录、主题及路径参数。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAdd}>
|
||||
@@ -1012,7 +1029,7 @@ function CompactShortcutSettings({
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<ExternalLink className="size-4" />
|
||||
路由参数
|
||||
@@ -1028,13 +1045,13 @@ function CompactShortcutSettings({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{button.route_params.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
|
||||
不追加额外参数。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{button.route_params.map((param, paramIndex) => {
|
||||
{button.route_params.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
|
||||
不追加额外参数。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{button.route_params.map((param, paramIndex) => {
|
||||
const themeMap =
|
||||
param.paramType === "theme"
|
||||
? parseThemeMap(param.themeValue)
|
||||
@@ -1044,26 +1061,36 @@ function CompactShortcutSettings({
|
||||
key={`${button.id}-${paramIndex}`}
|
||||
className="space-y-2 rounded-md border bg-background p-2"
|
||||
>
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]">
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="参数名 key"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
param.paramType === "path"
|
||||
? "md:grid-cols-[130px_minmax(180px,1fr)_32px]"
|
||||
: "md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]",
|
||||
)}
|
||||
>
|
||||
{param.paramType !== "path" && (
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="参数名 key"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
value={param.paramType}
|
||||
onValueChange={(value) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
paramType: value as "login" | "theme",
|
||||
paramType: value as "login" | "theme" | "path",
|
||||
key: value === "path" ? "" : param.key,
|
||||
source:
|
||||
value === "login"
|
||||
? param.source || "access_token"
|
||||
: undefined,
|
||||
value === "theme"
|
||||
? undefined
|
||||
: param.source || "access_token",
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -1073,9 +1100,10 @@ function CompactShortcutSettings({
|
||||
<SelectContent>
|
||||
<SelectItem value="login">登录参数</SelectItem>
|
||||
<SelectItem value="theme">主题参数</SelectItem>
|
||||
<SelectItem value="path">路径参数</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{param.paramType === "login" ? (
|
||||
{(param.paramType === "login" || param.paramType === "path") ? (
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Select
|
||||
value={param.source || "access_token"}
|
||||
@@ -1101,6 +1129,11 @@ function CompactShortcutSettings({
|
||||
</Select>
|
||||
{param.source === "other" && (
|
||||
<Input
|
||||
// This config value becomes a query parameter;
|
||||
// it is not a login form field. A password input
|
||||
// makes Chrome invoke password-manager heuristics
|
||||
// whenever the settings dialog opens.
|
||||
type="text"
|
||||
value={param.customValue ?? ""}
|
||||
onChange={(event) =>
|
||||
onPatchParam(button.id, paramIndex, {
|
||||
@@ -1109,6 +1142,8 @@ function CompactShortcutSettings({
|
||||
}
|
||||
placeholder="自定义值"
|
||||
className="w-32 text-xs"
|
||||
autoComplete="off"
|
||||
aria-label="自定义参数值"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1131,6 +1166,12 @@ function CompactShortcutSettings({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{param.paramType === "path" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
不填写参数名,按添加顺序拼到链接或 Hash 路由的路径末尾。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{param.paramType === "theme" && (
|
||||
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
|
||||
{APP_THEME_COLORS.map((color) => (
|
||||
@@ -1164,9 +1205,9 @@ function CompactShortcutSettings({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -100,13 +100,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/deep-research",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
|
||||
},
|
||||
{
|
||||
id: "workspace-enterprise-research",
|
||||
label: "企业深度研究",
|
||||
icon: FileSearch,
|
||||
path: "/page/workspace/enterprise-research",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/enterprise-research"),
|
||||
},
|
||||
// {
|
||||
// id: "workspace-enterprise-research",
|
||||
// label: "企业深度研究",
|
||||
// icon: FileSearch,
|
||||
// path: "/page/workspace/enterprise-research",
|
||||
// match: (pathname) => pathname.startsWith("/page/workspace/enterprise-research"),
|
||||
// },
|
||||
{
|
||||
id: "workspace-roundtable-planning",
|
||||
label: "多智能体规划",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BriefcaseIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
CpuIcon,
|
||||
DatabaseZapIcon,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
@@ -227,6 +228,14 @@ export function WorkspaceNavMenu() {
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/models")}
|
||||
>
|
||||
<CpuIcon />
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
|
||||
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
|
||||
{isAdmin && (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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",
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -103,7 +103,9 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__suspend-button {
|
||||
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__suspend-button,
|
||||
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__pause-button,
|
||||
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__resume-button {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ export interface DocumentRewriteRequest {
|
||||
instruction: string;
|
||||
style?: DocumentRewriteStyle;
|
||||
modelName?: string;
|
||||
/** Optional Markdown chapter structure selected from the report-structure library. */
|
||||
reportOutline?: string;
|
||||
/** Generate locally saved illustration assets after the prose rewrite. */
|
||||
generateImages?: boolean;
|
||||
/** Bounded server-side to 1–4. Ignored unless generateImages is true. */
|
||||
@@ -175,8 +177,15 @@ export type DocumentRewriteStreamEvent =
|
||||
| { type: "validation_completed"; passed: boolean; warnings: string[] }
|
||||
| { type: "comparison_ready"; summary: DocumentRewriteSummary }
|
||||
| { type: "commit_started" }
|
||||
| { type: "committed"; content: string; committedHash: string; versionId?: string }
|
||||
| { type: "done"; committedHash: string; versionId?: string }
|
||||
| {
|
||||
type: "committed";
|
||||
content: string;
|
||||
committedHash: string;
|
||||
versionId?: string;
|
||||
/** The rewrite is usable, but its optional undo snapshot was not saved. */
|
||||
versionSnapshotFailed?: boolean;
|
||||
}
|
||||
| { type: "done"; committedHash: string; versionId?: string; versionSnapshotFailed?: boolean }
|
||||
| { type: "conflict"; message: string }
|
||||
| { type: "cancelled"; message: string }
|
||||
| { type: "error"; message: string; stage?: string };
|
||||
@@ -192,6 +201,7 @@ export interface DurableDocumentRewriteJob {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
reportOutline: string;
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
originalContent: string;
|
||||
@@ -290,12 +300,14 @@ async function readDocumentRewriteEvent(
|
||||
content: String(parsed.content ?? ""),
|
||||
committedHash: String(parsed.committedHash ?? ""),
|
||||
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
|
||||
versionSnapshotFailed: parsed.versionSnapshotFailed === true,
|
||||
};
|
||||
case "done":
|
||||
return {
|
||||
type: "done",
|
||||
committedHash: String(parsed.committedHash ?? ""),
|
||||
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
|
||||
versionSnapshotFailed: parsed.versionSnapshotFailed === true,
|
||||
};
|
||||
case "conflict":
|
||||
return { type: "conflict", message: String(parsed.message ?? "文件内容已变化") };
|
||||
@@ -403,6 +415,7 @@ export async function listDurableDocumentRewrites(
|
||||
instruction: String(request.instruction ?? ""),
|
||||
style: (request.style ?? "professional") as DocumentRewriteStyle,
|
||||
modelName: typeof request.modelName === "string" ? request.modelName : null,
|
||||
reportOutline: typeof request.reportOutline === "string" ? request.reportOutline : "",
|
||||
generateImages: request.generateImages === true,
|
||||
maxGeneratedImages: Number(request.maxGeneratedImages ?? 2),
|
||||
originalContent: String(request.originalContent ?? ""),
|
||||
|
||||
@@ -46,8 +46,6 @@ const WRITING_STATUS_PILL: Record<string, { label: string; className: string; sp
|
||||
error: { label: "出错", className: "text-rose-500" },
|
||||
};
|
||||
|
||||
const SHOW_NOTEBOOK_SPACE_ENTRIES = false;
|
||||
|
||||
/** 单条历史记录项 —— 样式参考多智能体会商的 HistoryDropdown 列表项(圆角卡片 + 状态药丸 + 操作按钮)。 */
|
||||
function WritingHistoryItem({
|
||||
session,
|
||||
@@ -253,8 +251,7 @@ export function ResearchSidebar({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 py-4">
|
||||
{SHOW_NOTEBOOK_SPACE_ENTRIES && (
|
||||
<section className="flex flex-col">
|
||||
<section className="flex flex-col">
|
||||
<div className="mb-3 flex items-center justify-between px-2">
|
||||
<h2 className="text-[15px] font-bold tracking-wide text-slate-400">
|
||||
笔记本
|
||||
@@ -350,8 +347,7 @@ export function ResearchSidebar({
|
||||
<span>所有空间</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 「历史记录」列表(样式参考多智能体会商):跟随侧栏自然向下滚动、不固定在底部。
|
||||
点击打开历史回看(显示到右侧区域),✏️/双击改名,🗑删除,「挂起中」点击看进度。 */}
|
||||
|
||||
@@ -76,6 +76,9 @@ export function useDocumentRewrite() {
|
||||
const thinkingFrameRef = useRef<number | null>(null);
|
||||
const draftThinkFilterRef = useRef(new InlineThinkTagFilter());
|
||||
const requirementsThinkFilterRef = useRef(new InlineThinkTagFilter());
|
||||
// Kept outside React state so an HTTP/SSE failure can hand the last visible
|
||||
// text back to the sandbox without briefly restoring the original document.
|
||||
const latestDraftRef = useRef("");
|
||||
|
||||
const flushDelta = useCallback(() => {
|
||||
if (frameRef.current !== null) {
|
||||
@@ -161,6 +164,7 @@ export function useDocumentRewrite() {
|
||||
const { text: visibleDelta, thinking } = draftThinkFilterRef.current.pushParts(delta);
|
||||
enqueueThinking(thinking);
|
||||
if (!visibleDelta) return;
|
||||
latestDraftRef.current += visibleDelta;
|
||||
pendingDeltaRef.current += visibleDelta;
|
||||
pendingWrittenCharCountRef.current = writtenCharCount;
|
||||
if (frameRef.current !== null) return;
|
||||
@@ -210,6 +214,7 @@ export function useDocumentRewrite() {
|
||||
pendingThinkingRef.current = "";
|
||||
draftThinkFilterRef.current = new InlineThinkTagFilter();
|
||||
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
|
||||
latestDraftRef.current = "";
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
@@ -229,7 +234,12 @@ export function useDocumentRewrite() {
|
||||
async (
|
||||
request: DocumentRewriteRequest,
|
||||
options?: {
|
||||
onCommitted?: (content: string) => void;
|
||||
onCommitted?: (
|
||||
content: string,
|
||||
result: { versionSnapshotFailed: boolean; committedHash: string },
|
||||
) => void;
|
||||
/** Preserve the visible text locally when the rewrite request cannot finish. */
|
||||
onFailure?: (result: { content: string; error: string }) => void;
|
||||
onEvent?: (event: DocumentRewriteStreamEvent) => void;
|
||||
stream?: (
|
||||
signal: AbortSignal,
|
||||
@@ -250,6 +260,7 @@ export function useDocumentRewrite() {
|
||||
pendingThinkingRef.current = "";
|
||||
draftThinkFilterRef.current = new InlineThinkTagFilter();
|
||||
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
|
||||
latestDraftRef.current = "";
|
||||
setRun({
|
||||
...INITIAL_RUN,
|
||||
stage: "snapshot",
|
||||
@@ -329,12 +340,16 @@ export function useDocumentRewrite() {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
setRun((previous) => ({
|
||||
{
|
||||
const checkpointContent = draftThinkFilterRef.current.replace(event.content);
|
||||
latestDraftRef.current = checkpointContent;
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "writing",
|
||||
draft: draftThinkFilterRef.current.replace(event.content),
|
||||
draft: checkpointContent,
|
||||
writtenCharCount: stripInlineThinkTags(event.content).replace(/\s/g, "").length,
|
||||
}));
|
||||
}));
|
||||
}
|
||||
break;
|
||||
case "illustration_started":
|
||||
case "illustration_generated":
|
||||
@@ -367,13 +382,17 @@ export function useDocumentRewrite() {
|
||||
{
|
||||
const committedContent =
|
||||
draftThinkFilterRef.current.replace(event.content) + draftThinkFilterRef.current.finish();
|
||||
latestDraftRef.current = committedContent;
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
draft: committedContent,
|
||||
writtenCharCount: committedContent.replace(/\s/g, "").length,
|
||||
versionId: event.versionId ?? previous.versionId,
|
||||
}));
|
||||
options?.onCommitted?.(committedContent);
|
||||
options?.onCommitted?.(committedContent, {
|
||||
versionSnapshotFailed: event.versionSnapshotFailed === true,
|
||||
committedHash: event.committedHash,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "done":
|
||||
@@ -410,6 +429,7 @@ export function useDocumentRewrite() {
|
||||
flushRequirements();
|
||||
flushThinking();
|
||||
receivedTerminalEvent = true;
|
||||
options?.onFailure?.({ content: latestDraftRef.current, error: event.message });
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "error",
|
||||
@@ -424,10 +444,12 @@ export function useDocumentRewrite() {
|
||||
flushDelta();
|
||||
flushRequirements();
|
||||
flushThinking();
|
||||
const errorMessage = "改写任务连接已结束,但未收到完成状态;原文件未被修改。";
|
||||
options?.onFailure?.({ content: latestDraftRef.current, error: errorMessage });
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "error",
|
||||
error: "改写任务连接已结束,但未收到完成状态;原文件未被修改。",
|
||||
error: errorMessage,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -435,10 +457,12 @@ export function useDocumentRewrite() {
|
||||
flushDelta();
|
||||
flushRequirements();
|
||||
flushThinking();
|
||||
const errorMessage = error instanceof Error ? error.message : "全文改写失败";
|
||||
options?.onFailure?.({ content: latestDraftRef.current, error: errorMessage });
|
||||
setRun((previous) => ({
|
||||
...previous,
|
||||
stage: "error",
|
||||
error: error instanceof Error ? error.message : "全文改写失败",
|
||||
error: errorMessage,
|
||||
}));
|
||||
} finally {
|
||||
if (controllerRef.current === controller) {
|
||||
|
||||
@@ -394,10 +394,7 @@ function AIWritingLayout({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写作页主体(侧栏 + 内容区)。放在 Provider 内部,以便读 useAIWriting 状态:
|
||||
* 进入写作(status !== idle 或查看历史)后自动收起左侧研究模板栏,让写作区更宽。
|
||||
*/
|
||||
/** 写作页主体(左侧工作空间 + 内容区)。 */
|
||||
function AIWritingBody({
|
||||
hideAdvancedFields,
|
||||
initialNotebookId,
|
||||
@@ -408,8 +405,6 @@ function AIWritingBody({
|
||||
hideAdvancedFields: boolean;
|
||||
compactChrome?: boolean;
|
||||
} & NotebookProps) {
|
||||
const { state } = useAIWriting();
|
||||
const { isHistoryMode } = useAIWritingHistory();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const routeBase = useAIWritingRouteBase();
|
||||
@@ -437,10 +432,9 @@ function AIWritingBody({
|
||||
|
||||
const agentsActive = !externalNotebookMode && sidebarTab === 'agents';
|
||||
|
||||
// 研究模板侧栏在「写作配置(idle)」与「历史回看」两态都展示(点历史记录后保留侧栏、
|
||||
// 历史内容显示到右侧区域);写作进行中收起;嵌入态(studioToken)始终不展示。
|
||||
const showSidebar =
|
||||
!externalNotebookMode && (state.status === 'idle' || isHistoryMode || agentsActive);
|
||||
// 常规 AI 写作页始终保留左侧工作空间,方便在写作过程中继续切换课题、查看历史或使用智能体。
|
||||
// 只有外部笔记本嵌入态才隐藏,避免占用嵌入页面的内容空间。
|
||||
const showSidebar = !externalNotebookMode;
|
||||
const compactAppearanceActions = compactChrome ? (
|
||||
<AIWritingCompactAppearanceActions />
|
||||
) : null;
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
BotIcon,
|
||||
ChevronDownIcon,
|
||||
CircleAlertIcon,
|
||||
CpuIcon,
|
||||
EyeIcon,
|
||||
GripVerticalIcon,
|
||||
KeyRoundIcon,
|
||||
LoaderCircleIcon,
|
||||
PlusIcon,
|
||||
@@ -31,6 +50,7 @@ import {
|
||||
type ModelSavePayload,
|
||||
useDeleteManagedModel,
|
||||
useManagedModels,
|
||||
useReorderManagedModels,
|
||||
useSaveManagedModel,
|
||||
useTestManagedModel,
|
||||
} from "@/core/model-management";
|
||||
@@ -170,6 +190,103 @@ function ModelCapabilityBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function SortableModelCard({
|
||||
model,
|
||||
isDefault,
|
||||
dragEnabled,
|
||||
onConfigure,
|
||||
onDelete,
|
||||
}: {
|
||||
model: ManagedModel;
|
||||
isDefault: boolean;
|
||||
dragEnabled: boolean;
|
||||
onConfigure: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: model.name,
|
||||
disabled: !dragEnabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.45 : 1,
|
||||
}}
|
||||
className={`rounded-lg border border-border bg-background p-4 transition-[border-color,box-shadow] ${
|
||||
dragEnabled ? "hover:border-primary/35 hover:shadow-sm" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`mt-0.5 shrink-0 rounded p-1 text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
dragEnabled
|
||||
? "cursor-grab touch-none hover:bg-muted hover:text-foreground active:cursor-grabbing"
|
||||
: "cursor-not-allowed opacity-40"
|
||||
}`}
|
||||
title={dragEnabled ? "拖动调整顺序" : "请清空搜索和筛选后再调整顺序"}
|
||||
aria-label={`${model.display_name || model.name}:${dragEnabled ? "拖动调整顺序" : "当前不可调整顺序"}`}
|
||||
{...(dragEnabled ? attributes : {})}
|
||||
{...(dragEnabled ? listeners : {})}
|
||||
>
|
||||
<GripVerticalIcon className="size-4" aria-hidden />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2>
|
||||
{isDefault && <Tag theme="primary" variant="light" size="small">默认模型</Tag>}
|
||||
<Tag theme="success" variant="light" size="small">config.yaml</Tag>
|
||||
</div>
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<TButton theme="default" variant="outline" size="small" onClick={onConfigure}>
|
||||
配置
|
||||
</TButton>
|
||||
<TButton
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label={`删除模型 ${model.display_name || model.name}`}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2Icon className="mr-1 size-3.5" />删除
|
||||
</TButton>
|
||||
</div>
|
||||
</div>
|
||||
{model.description ? (
|
||||
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p>
|
||||
) : (
|
||||
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
|
||||
)}
|
||||
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<span className="truncate" title={model.use}>驱动:{model.use}</span>
|
||||
<span className="truncate" title={model.model}>模型:{model.model}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{model.api_key_configured && (
|
||||
<ModelCapabilityBadge icon={KeyRoundIcon} label="已配置密钥" className="bg-muted text-foreground" />
|
||||
)}
|
||||
{model.supports_thinking && (
|
||||
<ModelCapabilityBadge icon={SparklesIcon} label="思考" className="bg-primary/10 text-primary" />
|
||||
)}
|
||||
{model.supports_vision && (
|
||||
<ModelCapabilityBadge icon={EyeIcon} label="视觉" className="bg-amber-500/10 text-amber-700 dark:text-amber-300" />
|
||||
)}
|
||||
{model.supports_reasoning_effort && (
|
||||
<ModelCapabilityBadge label="推理档位" className="bg-muted text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function showGlobalMessage(
|
||||
theme: "success" | "error" | "warning",
|
||||
content: string,
|
||||
@@ -274,15 +391,25 @@ export default function ModelManagementPage() {
|
||||
const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all");
|
||||
const [editor, setEditor] = useState<ManagedModel | null | "new">(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ManagedModel | null>(null);
|
||||
const [pendingOrder, setPendingOrder] = useState<string[] | null>(null);
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
const remove = useDeleteManagedModel();
|
||||
const reorder = useReorderManagedModels();
|
||||
const sourceModels = data?.models ?? [];
|
||||
const orderedModels = useMemo(() => {
|
||||
if (!pendingOrder || pendingOrder.length !== sourceModels.length) return sourceModels;
|
||||
const byName = new Map(sourceModels.map((model) => [model.name, model]));
|
||||
const reordered = pendingOrder.map((name) => byName.get(name)).filter((model): model is ManagedModel => Boolean(model));
|
||||
return reordered.length === sourceModels.length ? reordered : sourceModels;
|
||||
}, [pendingOrder, sourceModels]);
|
||||
const models = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
return (data?.models ?? []).filter((model) => {
|
||||
return orderedModels.filter((model) => {
|
||||
const matchesFilter = filter === "all" || (filter === "thinking" ? model.supports_thinking : model.supports_vision);
|
||||
const matchesSearch = !keyword || [model.name, model.display_name, model.model, model.use].filter(Boolean).join(" ").toLowerCase().includes(keyword);
|
||||
return matchesFilter && matchesSearch;
|
||||
});
|
||||
}, [data?.models, filter, search]);
|
||||
}, [filter, orderedModels, search]);
|
||||
const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]);
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget)
|
||||
@@ -297,6 +424,28 @@ export default function ModelManagementPage() {
|
||||
showGlobalMessage("error", error instanceof Error ? error.message : "删除模型失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const dragEnabled = filter === "all" && !search.trim() && !reorder.isPending;
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
|
||||
setActiveDragId(null);
|
||||
if (!dragEnabled || !over || active.id === over.id) return;
|
||||
const oldIndex = orderedModels.findIndex((model) => model.name === active.id);
|
||||
const newIndex = orderedModels.findIndex((model) => model.name === over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
|
||||
const nextOrder = arrayMove(orderedModels.map((model) => model.name), oldIndex, newIndex);
|
||||
setPendingOrder(nextOrder);
|
||||
try {
|
||||
await reorder.mutateAsync(nextOrder);
|
||||
showGlobalMessage("success", `模型顺序已更新,“${nextOrder[0]}”现在是默认模型。`);
|
||||
} catch (error) {
|
||||
setPendingOrder(null);
|
||||
showGlobalMessage("error", error instanceof Error ? error.message : "调整模型顺序失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
@@ -375,73 +524,39 @@ export default function ModelManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && models.length > 0 && (
|
||||
<div className="grid gap-3 p-4 lg:grid-cols-2">
|
||||
{models.map((model) => (
|
||||
<article key={model.name} className="rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/35">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2>
|
||||
<Tag theme="success" variant="light" size="small">config.yaml</Tag>
|
||||
</div>
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<TButton theme="default" variant="outline" size="small" onClick={() => setEditor(model)}>
|
||||
配置
|
||||
</TButton>
|
||||
<TButton
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label={"删除模型 " + (model.display_name || model.name)}
|
||||
onClick={() => setDeleteTarget(model)}
|
||||
>
|
||||
<Trash2Icon className="mr-1 size-3.5" />删除
|
||||
</TButton>
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={({ active }) => setActiveDragId(String(active.id))}
|
||||
onDragCancel={() => setActiveDragId(null)}
|
||||
onDragEnd={(event) => void handleDragEnd(event)}
|
||||
>
|
||||
<div className="border-b bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
|
||||
拖动卡片左侧手柄即可调整顺序;排在第一位的模型会作为默认模型。
|
||||
{!dragEnabled && " 请清空搜索和筛选条件后再调整顺序。"}
|
||||
</div>
|
||||
<SortableContext items={models.map((model) => model.name)} strategy={rectSortingStrategy}>
|
||||
<div className="grid gap-3 p-4 lg:grid-cols-2">
|
||||
{models.map((model) => (
|
||||
<SortableModelCard
|
||||
key={model.name}
|
||||
model={model}
|
||||
isDefault={model.name === orderedModels[0]?.name}
|
||||
dragEnabled={dragEnabled}
|
||||
onConfigure={() => setEditor(model)}
|
||||
onDelete={() => setDeleteTarget(model)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeDragId ? (
|
||||
<div className="rounded-lg border border-primary/30 bg-background px-4 py-3 text-sm font-medium shadow-lg">
|
||||
{orderedModels.find((model) => model.name === activeDragId)?.display_name || activeDragId}
|
||||
</div>
|
||||
{model.description ? (
|
||||
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p>
|
||||
) : (
|
||||
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
|
||||
)}
|
||||
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<span className="truncate" title={model.use}>驱动:{model.use}</span>
|
||||
<span className="truncate" title={model.model}>模型:{model.model}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{model.api_key_configured && (
|
||||
<ModelCapabilityBadge
|
||||
icon={KeyRoundIcon}
|
||||
label="已配置密钥"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
)}
|
||||
{model.supports_thinking && (
|
||||
<ModelCapabilityBadge
|
||||
icon={SparklesIcon}
|
||||
label="思考"
|
||||
className="bg-primary/10 text-primary"
|
||||
/>
|
||||
)}
|
||||
{model.supports_vision && (
|
||||
<ModelCapabilityBadge
|
||||
icon={EyeIcon}
|
||||
label="视觉"
|
||||
className="bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
/>
|
||||
)}
|
||||
{model.supports_reasoning_effort && (
|
||||
<ModelCapabilityBadge
|
||||
label="推理档位"
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle,CheckCircle2, Loader2Icon, ShieldIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
@@ -86,6 +86,11 @@ export default function PrivateSquareSettingsPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
};
|
||||
|
||||
const handleClearPassword = async () => {
|
||||
if (!window.confirm("确认清除私密广场密码?清除后任何人都将无法访问私密广场。")) {
|
||||
return;
|
||||
@@ -123,7 +128,18 @@ export default function PrivateSquareSettingsPage({
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<form
|
||||
className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="text"
|
||||
name="private-square-admin"
|
||||
autoComplete="username"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">启用私密广场</div>
|
||||
@@ -135,7 +151,7 @@ export default function PrivateSquareSettingsPage({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">
|
||||
<label className="text-sm font-medium" htmlFor="private-square-password">
|
||||
访问密码
|
||||
{data?.password_set ? (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
@@ -146,25 +162,31 @@ export default function PrivateSquareSettingsPage({
|
||||
(尚未设置)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</label>
|
||||
<Input
|
||||
id="private-square-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={
|
||||
data?.password_set ? "留空表示不修改密码" : "请输入访问密码"
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">确认密码</p>
|
||||
<label className="text-sm font-medium" htmlFor="private-square-confirm-password">
|
||||
确认密码
|
||||
</label>
|
||||
<Input
|
||||
id="private-square-confirm-password"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="再次输入新密码"
|
||||
disabled={!password}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -179,14 +201,14 @@ export default function PrivateSquareSettingsPage({
|
||||
<ShieldIcon className="mr-1.5 size-4" />
|
||||
清除密码
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
<Button type="submit" disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -195,8 +195,12 @@ function matchingReportStructureId(
|
||||
return structures.find((structure) => structure.content.trim() === normalized)?.id ?? null;
|
||||
}
|
||||
|
||||
/** Shared by the initial settings dialog and the write-confirmation card. */
|
||||
export function ReportOutlineEditor({
|
||||
/**
|
||||
* The concise structure picker is deliberately usable in the confirmation
|
||||
* card. Users should not have to open advanced settings merely to choose how
|
||||
* their report is organised.
|
||||
*/
|
||||
export function ReportStructureSelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
@@ -228,9 +232,9 @@ export function ReportOutlineEditor({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={`${id}-template`} className="block text-xs font-medium">
|
||||
报告结构模板
|
||||
<div>
|
||||
<label htmlFor={`${id}-template`} className="mb-1.5 block text-xs font-medium">
|
||||
报告结构
|
||||
</label>
|
||||
<Select value={templateValue} onValueChange={selectTemplate}>
|
||||
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
|
||||
@@ -251,22 +255,46 @@ export function ReportOutlineEditor({
|
||||
<SelectItem value={CUSTOM_REPORT_STRUCTURE_VALUE}>自定义结构</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||
{selectedStructureId
|
||||
? "已选工作流结构;可在高级配置中继续编辑章节内容。"
|
||||
: "当前使用自定义结构;可在高级配置中编辑章节内容。"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared by the initial settings dialog and the write-confirmation card. */
|
||||
export function ReportOutlineEditor({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
showStructureSelect = true,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** The confirmation card already exposes its picker in the primary form. */
|
||||
showStructureSelect?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{showStructureSelect && (
|
||||
<ReportStructureSelect id={id} value={value} onChange={onChange} />
|
||||
)}
|
||||
<label htmlFor={id} className="block pt-1 text-xs font-medium">
|
||||
报告结构(可编辑)
|
||||
</label>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setCustomSelected(true);
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
maxLength={4000}
|
||||
className="min-h-28 resize-y text-xs"
|
||||
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据"}
|
||||
/>
|
||||
<p className="text-[10px] leading-4 text-muted-foreground">
|
||||
选择工作流结构会替换下方内容;选择“自定义”会清空后从头编辑。支持 Markdown 标题或编号列表。
|
||||
支持 Markdown 标题或编号列表。选择“自定义结构”会清空后从头编辑。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -430,7 +458,7 @@ export function ReportConfigCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium">报告模式</span>
|
||||
<Select value={mode} onValueChange={(value) => setMode(value as ResearchMode)}>
|
||||
@@ -443,6 +471,11 @@ export function ReportConfigCard({
|
||||
</Select>
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">{MODE_HINTS[mode]}</span>
|
||||
</label>
|
||||
<ReportStructureSelect
|
||||
id="report-card-structure"
|
||||
value={options.customOutline}
|
||||
onChange={(value) => update("customOutline", value)}
|
||||
/>
|
||||
{imageGenerationAvailable && (
|
||||
<div>
|
||||
<span className="mb-1.5 block text-xs font-medium">报告配图</span>
|
||||
@@ -468,7 +501,7 @@ export function ReportConfigCard({
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
高级配置
|
||||
<span className="font-normal text-muted-foreground">语言、语气、自定义结构等</span>
|
||||
<span className="font-normal text-muted-foreground">语言、语气、检索预算与结构细调</span>
|
||||
<ChevronRight className={`ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform ${advancedOpen ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
@@ -509,6 +542,7 @@ export function ReportConfigCard({
|
||||
id="report-card-outline"
|
||||
value={options.customOutline}
|
||||
onChange={(value) => update("customOutline", value)}
|
||||
showStructureSelect={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end sm:col-span-2">
|
||||
|
||||
@@ -57,9 +57,20 @@ interface ResearchSandboxPanelProps {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
reportOutline: string;
|
||||
/** Regeneration forks the report session before it starts the rewrite. */
|
||||
createNewReport?: boolean;
|
||||
}) => void;
|
||||
documentRewriteActive?: boolean;
|
||||
/** Distinguishes a new report generation from an in-place full rewrite. */
|
||||
documentRewriteOperation?: "rewrite" | "generate" | null;
|
||||
onCancelDocumentRewrite?: () => void;
|
||||
/** A completed-report card can request opening the same safe rewrite dialog. */
|
||||
documentRewriteDialogRequest?: number;
|
||||
/** Controls whether a requested dialog creates another report or rewrites this one. */
|
||||
documentRewriteDialogPurpose?: "rewrite" | "regenerate";
|
||||
/** Outline selected from the research workflow, used to preselect a saved structure. */
|
||||
initialDocumentRewriteOutline?: string;
|
||||
reportCharCount?: number;
|
||||
}
|
||||
|
||||
@@ -73,12 +84,18 @@ export function ResearchSandboxPanel({
|
||||
onDownload,
|
||||
onDocumentRewriteStart,
|
||||
documentRewriteActive = false,
|
||||
documentRewriteOperation = null,
|
||||
onCancelDocumentRewrite,
|
||||
documentRewriteDialogRequest = 0,
|
||||
documentRewriteDialogPurpose = "rewrite",
|
||||
initialDocumentRewriteOutline,
|
||||
reportCharCount = 0,
|
||||
}: ResearchSandboxPanelProps) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const { selectedArtifact, setOpen, deselect } = useArtifacts();
|
||||
const [rewriteDialogOpen, setRewriteDialogOpen] = useState(false);
|
||||
const [rewriteDialogPurpose, setRewriteDialogPurpose] = useState<"rewrite" | "regenerate">("rewrite");
|
||||
const generatingNewReport = documentRewriteOperation === "generate";
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutRef.current) return;
|
||||
@@ -108,6 +125,13 @@ export function ResearchSandboxPanel({
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (documentRewriteDialogRequest > 0) {
|
||||
setRewriteDialogPurpose(documentRewriteDialogPurpose);
|
||||
setRewriteDialogOpen(true);
|
||||
}
|
||||
}, [documentRewriteDialogPurpose, documentRewriteDialogRequest]);
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
|
||||
<ResizablePanelGroup
|
||||
@@ -135,12 +159,12 @@ export function ResearchSandboxPanel({
|
||||
id="artifacts"
|
||||
>
|
||||
<div className="relative h-full bg-[#fffdfa] p-4">
|
||||
{selectedArtifact && previewThreadId ? (
|
||||
{selectedArtifact && previewThreadId && stubMessages.length > 0 ? (
|
||||
<>
|
||||
{documentRewriteActive && onCancelDocumentRewrite && (
|
||||
<div className="absolute right-36 bottom-7 z-30">
|
||||
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
|
||||
停止改写
|
||||
停止{generatingNewReport ? "生成" : "改写"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -155,14 +179,22 @@ export function ResearchSandboxPanel({
|
||||
onDocumentRewriteStart ? (
|
||||
<ArtifactAction
|
||||
icon={documentRewriteActive ? Loader2Icon : SparklesIcon}
|
||||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||
label={documentRewriteActive
|
||||
? generatingNewReport
|
||||
? "正在生成新报告"
|
||||
: "正在全文改写"
|
||||
: "AI 全文改写"}
|
||||
tooltip={
|
||||
documentRewriteActive
|
||||
? "正在生成临时草稿,原报告尚未被覆盖"
|
||||
? generatingNewReport
|
||||
? "正在生成独立的新报告,当前报告不会被覆盖"
|
||||
: "正在生成临时草稿,原报告尚未被覆盖"
|
||||
: "使用 AI 重写完整 report.md"
|
||||
}
|
||||
disabled={documentRewriteActive}
|
||||
onClick={() => setRewriteDialogOpen(true)}
|
||||
onClick={() => {
|
||||
setRewriteDialogPurpose("rewrite");
|
||||
setRewriteDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
@@ -178,7 +210,11 @@ export function ResearchSandboxPanel({
|
||||
<ConversationEmptyState
|
||||
icon={<FileTextIcon />}
|
||||
title="report.md"
|
||||
description="报告开始撰写后将在此实时预览"
|
||||
description={
|
||||
documentRewriteActive && generatingNewReport
|
||||
? "正在生成新报告,正文将在模型首个字符输出后显示"
|
||||
: "报告开始撰写后将在此实时预览"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -191,9 +227,30 @@ export function ResearchSandboxPanel({
|
||||
onOpenChange={setRewriteDialogOpen}
|
||||
fileName="report.md"
|
||||
charCount={reportCharCount}
|
||||
initialReportOutline={initialDocumentRewriteOutline}
|
||||
operationLabel={rewriteDialogPurpose === "regenerate" ? "确认新报告生成要求" : undefined}
|
||||
operationDescription={
|
||||
rewriteDialogPurpose === "regenerate"
|
||||
? "请确认新报告的结构、资料范围和生成要求;确认后会创建一篇独立的新报告,当前报告不会被覆盖。"
|
||||
: undefined
|
||||
}
|
||||
submitLabel={rewriteDialogPurpose === "regenerate" ? "确认并生成新报告" : undefined}
|
||||
operation={rewriteDialogPurpose === "regenerate" ? "generate" : "rewrite"}
|
||||
onRewriteOriginal={rewriteDialogPurpose === "regenerate"
|
||||
? (input) => {
|
||||
setRewriteDialogOpen(false);
|
||||
onDocumentRewriteStart({
|
||||
...input,
|
||||
createNewReport: false,
|
||||
});
|
||||
}
|
||||
: undefined}
|
||||
onStart={(input) => {
|
||||
setRewriteDialogOpen(false);
|
||||
onDocumentRewriteStart(input);
|
||||
onDocumentRewriteStart({
|
||||
...input,
|
||||
createNewReport: rewriteDialogPurpose === "regenerate",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -532,11 +532,27 @@ export function WorkflowConfigButton({
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const structuresQuery = useReportStructures();
|
||||
const selectedWorkflowTitle = useMemo(() => {
|
||||
const content = appliedContent?.trim();
|
||||
if (!content) return null;
|
||||
return structuresQuery.data?.find((structure) => structure.content.trim() === content)?.title ?? null;
|
||||
}, [appliedContent, structuresQuery.data]);
|
||||
const buttonLabel = selectedWorkflowTitle || "工作流配置";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant={variant} size={size} className={className} disabled={disabled} onClick={() => setOpen(true)}>
|
||||
<Button
|
||||
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
size={size}
|
||||
title={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
||||
variant={variant}
|
||||
>
|
||||
<Workflow className="mr-1.5 h-3.5 w-3.5" />
|
||||
工作流配置
|
||||
<span className="max-w-40 truncate">{buttonLabel}</span>
|
||||
</Button>
|
||||
<WorkflowConfigDialog
|
||||
open={open}
|
||||
|
||||
@@ -43,13 +43,28 @@ interface UseReportSandboxOptions {
|
||||
report: string;
|
||||
/** 报告是否正在流式写入 —— 作为 stub thread 的 isLoading。 */
|
||||
writing: boolean;
|
||||
/** First visible Markdown has arrived; the host can expand the right panel then. */
|
||||
onFirstReportContent?: () => void;
|
||||
/**
|
||||
* A structural regeneration writes a sibling file in the background. Leave
|
||||
* the currently displayed report in the sandbox until the user opens that
|
||||
* new card; do not steal the panel for the incoming stream.
|
||||
*/
|
||||
autoOpen?: boolean;
|
||||
}
|
||||
|
||||
export function useReportSandbox({ threadKey, report, writing }: UseReportSandboxOptions) {
|
||||
export function useReportSandbox({
|
||||
threadKey,
|
||||
report,
|
||||
writing,
|
||||
onFirstReportContent,
|
||||
autoOpen = true,
|
||||
}: UseReportSandboxOptions) {
|
||||
const { select, setOpen, openStreamedArtifact } = useArtifacts();
|
||||
const [stubMessage, setStubMessage] = useState<ReportStubAiMessage | null>(null);
|
||||
// 每个 threadKey 只自动展开一次;刷新/切换会话后的重开由用户或 openReport 触发。
|
||||
// Each writing pass opens only once, once it has actual Markdown content.
|
||||
const autoOpenedRef = useRef(false);
|
||||
const wasWritingRef = useRef(writing);
|
||||
|
||||
const messageId = `dr-report-${threadKey}`;
|
||||
const toolCallId = `dr-report-${threadKey}-write`;
|
||||
@@ -67,6 +82,11 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
|
||||
setStubMessage(null);
|
||||
}, [threadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (writing && !wasWritingRef.current) autoOpenedRef.current = false;
|
||||
wasWritingRef.current = writing;
|
||||
}, [writing]);
|
||||
|
||||
// 每帧 upsert stub(带防抹空:新帧为空而旧内容非空时保留旧内容)。
|
||||
useEffect(() => {
|
||||
if (!report) return;
|
||||
@@ -91,10 +111,11 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
|
||||
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
|
||||
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
|
||||
useEffect(() => {
|
||||
if (!report.trim() || !writing || autoOpenedRef.current) return;
|
||||
if (!autoOpen || !report.trim() || !writing || autoOpenedRef.current) return;
|
||||
autoOpenedRef.current = true;
|
||||
onFirstReportContent?.();
|
||||
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
|
||||
}, [openStreamedArtifact, report, virtualUrl, writing]);
|
||||
}, [autoOpen, onFirstReportContent, openStreamedArtifact, report, virtualUrl, writing]);
|
||||
|
||||
/** 手动打开(头部「报告」按钮 / 完成卡「查看报告」):始终选中虚拟 URL。 */
|
||||
const openReport = useCallback(() => {
|
||||
@@ -102,9 +123,18 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
|
||||
setOpen(true);
|
||||
}, [select, setOpen, virtualUrl]);
|
||||
|
||||
// Effects clear the previous message after a thread switch. Do not wait
|
||||
// for that post-render effect before changing what is rendered: a report
|
||||
// regeneration must show an empty new file immediately, never one frame of
|
||||
// the previous report while the model is still preparing its first token.
|
||||
const currentStubMessage = stubMessage?.id === messageId ? stubMessage : null;
|
||||
|
||||
return {
|
||||
virtualUrl,
|
||||
stubMessages: useMemo(() => (stubMessage ? [stubMessage] : []), [stubMessage]),
|
||||
stubMessages: useMemo(
|
||||
() => (currentStubMessage ? [currentStubMessage] : []),
|
||||
[currentStubMessage],
|
||||
),
|
||||
openReport,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
Loader2,
|
||||
Network,
|
||||
Paperclip,
|
||||
Pause,
|
||||
Play,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -103,6 +105,7 @@ export interface Step2PanelProps {
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
step2RoundtableDialogues: Step2Dialogue[];
|
||||
isInitializing: boolean;
|
||||
isRoundTableRunning: boolean;
|
||||
isOrchestrating: boolean;
|
||||
pendingClarification: ClarificationPayload | null;
|
||||
clarificationDraft: string;
|
||||
@@ -153,6 +156,7 @@ export interface Step2PanelProps {
|
||||
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
// ─── Handlers ─────────────────────────────────────────────────
|
||||
onPauseRoundtable: () => void;
|
||||
onResumeRoundtable: () => void;
|
||||
onHandleInjectToRoundtable: (files?: File[]) => void;
|
||||
onSubmitClarification: (answer: string) => void;
|
||||
onSavePersona: (id: number) => void;
|
||||
@@ -173,6 +177,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
headerRightSlot,
|
||||
step2RoundtableDialogues,
|
||||
isInitializing,
|
||||
isRoundTableRunning,
|
||||
isOrchestrating,
|
||||
pendingClarification,
|
||||
clarificationDraft,
|
||||
@@ -204,6 +209,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
setAgentConfigs,
|
||||
setSelectedAgentId,
|
||||
onPauseRoundtable,
|
||||
onResumeRoundtable,
|
||||
onHandleInjectToRoundtable,
|
||||
onSubmitClarification,
|
||||
onSavePersona,
|
||||
@@ -476,7 +482,38 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* 后台挂起:查看历史记录时隐藏(isViewingHistory),
|
||||
{!isRoundTableRunning ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResumeRoundtable}
|
||||
title="恢复自动研讨"
|
||||
className={
|
||||
useStep1InputAppearance
|
||||
? "eightbf-roundtable__resume-button"
|
||||
: "bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm"
|
||||
}
|
||||
>
|
||||
{!useStep1InputAppearance && (
|
||||
<Play className="w-3.5 h-3.5 fill-emerald-800" />
|
||||
)}
|
||||
恢复会商
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPauseRoundtable}
|
||||
title="暂停当前会商编排,可稍后恢复"
|
||||
className={
|
||||
useStep1InputAppearance
|
||||
? "eightbf-roundtable__pause-button"
|
||||
: "bg-amber-100 hover:bg-amber-200 text-amber-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
|
||||
}
|
||||
>
|
||||
{!useStep1InputAppearance && <Pause className="w-3.5 h-3.5" />}
|
||||
暂停会商
|
||||
</button>
|
||||
)}
|
||||
{/* 后台挂起:放在暂停/恢复按钮右侧。查看历史记录时隐藏(isViewingHistory),
|
||||
重新从第一步进入第二步研讨时(isViewingHistory 为假)正常显示。 */}
|
||||
{SHOW_BACKGROUND_SUSPEND && !isViewingHistory && (
|
||||
<button
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、
|
||||
* 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -21,11 +21,15 @@ import {
|
||||
Loader2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Paperclip,
|
||||
Pause,
|
||||
RefreshCw,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -204,11 +208,13 @@ export function Step3SummaryMode({
|
||||
? "正在初始化总结线程…"
|
||||
: phase === "streaming"
|
||||
? "方案总结报告智能体撰写中…"
|
||||
: phase === "done"
|
||||
? "总结完成"
|
||||
: phase === "error"
|
||||
? "总结失败"
|
||||
: "待总结";
|
||||
: phase === "paused"
|
||||
? "已暂停"
|
||||
: phase === "done"
|
||||
? "总结完成"
|
||||
: phase === "error"
|
||||
? "总结失败"
|
||||
: "待总结";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col text-slate-800">
|
||||
@@ -222,6 +228,7 @@ export function Step3SummaryMode({
|
||||
{(phase === "creating" || phase === "streaming") && (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{phase === "paused" && <Pause className="w-3 h-3 text-amber-600" />}
|
||||
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
|
||||
{statusLabel}
|
||||
</span>
|
||||
@@ -521,6 +528,7 @@ function AgentProgressPanel({
|
||||
const [askText, setAskText] = useState("");
|
||||
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
|
||||
const [askFiles, setAskFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addAskFiles = (picked: File[]) => {
|
||||
if (picked.length > 0) {
|
||||
@@ -530,6 +538,10 @@ function AgentProgressPanel({
|
||||
});
|
||||
}
|
||||
};
|
||||
const pickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
addAskFiles(Array.from(e.target.files ?? []));
|
||||
e.target.value = "";
|
||||
};
|
||||
const removeFile = (idx: number) => setAskFiles((prev) => prev.filter((_, i) => i !== idx));
|
||||
|
||||
const submitAsk = () => {
|
||||
@@ -540,6 +552,10 @@ function AgentProgressPanel({
|
||||
setAskFiles([]);
|
||||
onAsk(text, hasFiles ? askFiles : undefined);
|
||||
};
|
||||
const handleAskSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
submitAsk();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -567,7 +583,7 @@ function AgentProgressPanel({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0 flex items-center gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
|
||||
报
|
||||
@@ -577,6 +593,44 @@ function AgentProgressPanel({
|
||||
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<select
|
||||
id="step3-summary-model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={busy || availableModels.length === 0}
|
||||
title={
|
||||
availableModels.length === 0
|
||||
? "未在 config.yaml 中配置任何模型"
|
||||
: busy
|
||||
? "撰写中不可切换;完成后或重新生成时生效"
|
||||
: "切换撰写使用的推理模型(下次生成生效)"
|
||||
}
|
||||
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
||||
>
|
||||
{availableModels.length === 0 && (
|
||||
<option value="">未配置模型</option>
|
||||
)}
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.display_name && m.display_name !== m.name
|
||||
? `${m.display_name} (${m.name})`
|
||||
: m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPause}
|
||||
title="暂停当前生成(沙箱保留已生成内容,之后可继续修改或重新生成)"
|
||||
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
暂停
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -631,6 +685,13 @@ function AgentProgressPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "paused" && (
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-amber-600">
|
||||
<Pause className="w-4 h-4" />
|
||||
已暂停。可在下方输入修改要求继续,或点右上角「重新生成」。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "done" && (
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
@@ -648,38 +709,114 @@ function AgentProgressPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="position-roundtable-intent-page eightbf-roundtable-step3-input shrink-0">
|
||||
<PositionIntentInput
|
||||
value={askText}
|
||||
onValueChange={setAskText}
|
||||
attachedFiles={askFiles}
|
||||
onAddFiles={addAskFiles}
|
||||
onRemoveFile={removeFile}
|
||||
charactersLeft={Math.max(0, 500 - askText.length)}
|
||||
isSending={busy}
|
||||
canSend={askText.trim().length > 0 || askFiles.length > 0}
|
||||
pendingClarification={false}
|
||||
onSend={submitAsk}
|
||||
onStop={onPause}
|
||||
onFollowUp={() => undefined}
|
||||
onSkip={() => undefined}
|
||||
hideUtilityActions
|
||||
onEnterRoundtable={() => undefined}
|
||||
enterButtonLabel=""
|
||||
intentReady={null}
|
||||
hideEnterRoundtable
|
||||
onManualSave={() => undefined}
|
||||
onTriggerToast={() => undefined}
|
||||
modelOptions={availableModels.map((model) => ({
|
||||
label: model.display_name && model.display_name !== model.name
|
||||
? `${model.display_name} (${model.name})`
|
||||
: model.name,
|
||||
value: model.name,
|
||||
}))}
|
||||
selectedModel={selectedModel}
|
||||
onSelectedModelChange={setSelectedModel}
|
||||
/>
|
||||
</div>
|
||||
{useStep1Chrome ? (
|
||||
<div className="position-roundtable-intent-page eightbf-roundtable-step3-input shrink-0">
|
||||
<PositionIntentInput
|
||||
value={askText}
|
||||
onValueChange={setAskText}
|
||||
attachedFiles={askFiles}
|
||||
onAddFiles={addAskFiles}
|
||||
onRemoveFile={removeFile}
|
||||
charactersLeft={Math.max(0, 500 - askText.length)}
|
||||
isSending={busy}
|
||||
canSend={askText.trim().length > 0 || askFiles.length > 0}
|
||||
pendingClarification={false}
|
||||
onSend={submitAsk}
|
||||
onStop={onPause}
|
||||
onFollowUp={() => undefined}
|
||||
onSkip={() => undefined}
|
||||
hideUtilityActions
|
||||
onEnterRoundtable={() => undefined}
|
||||
enterButtonLabel=""
|
||||
intentReady={null}
|
||||
hideEnterRoundtable
|
||||
onManualSave={() => undefined}
|
||||
onTriggerToast={() => undefined}
|
||||
modelOptions={availableModels.map((model) => ({
|
||||
label: model.display_name && model.display_name !== model.name
|
||||
? `${model.display_name} (${model.name})`
|
||||
: model.name,
|
||||
value: model.name,
|
||||
}))}
|
||||
selectedModel={selectedModel}
|
||||
onSelectedModelChange={setSelectedModel}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
|
||||
{askFiles.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{askFiles.map((file, idx) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${idx}`}
|
||||
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
|
||||
>
|
||||
<FileText className="w-3 h-3 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-400">{formatBytes(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(idx)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
|
||||
title="移除"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
|
||||
<AutoGrowTextarea
|
||||
maxHeight={224}
|
||||
value={askText}
|
||||
onChange={(e) => setAskText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={
|
||||
busy
|
||||
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
|
||||
: "继续对话:改报告(如「执行路线写细些」)或提问"
|
||||
}
|
||||
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
|
||||
/>
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={pickFiles} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传参考文件(智能体将读取文件内容辅助总结/修改)"
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white border border-slate-200 text-slate-500 hover:text-primary hover:bg-slate-50 transition active:scale-95"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
{busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPause}
|
||||
title="暂停当前生成"
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={askText.trim().length === 0 && askFiles.length === 0}
|
||||
title={busy ? "打断当前轮并发送" : "发送(智能体自行判断改报告 or 回答)"}
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ const SUMMARY_AGENT_NAME = "roundtable-summary";
|
||||
/** 结构化抽取智能体 id —— 入库轮由它(走 ingest run policy)真正调用入库技能。 */
|
||||
const STRUCTURE_AGENT_NAME = "roundtable-structure";
|
||||
|
||||
export type SummaryPhase = "idle" | "creating" | "streaming" | "done" | "error";
|
||||
export type SummaryPhase = "idle" | "creating" | "streaming" | "paused" | "done" | "error";
|
||||
|
||||
interface UseStep3SummaryOptions {
|
||||
intentReady: ReportIntent | null;
|
||||
@@ -1883,12 +1883,15 @@ export function useStep3Summary(
|
||||
[pendingFlowClarification, ask],
|
||||
);
|
||||
|
||||
/** 暂停当前生成:中断在途请求 + 把流式气泡收尾为「已暂停」+ 状态回 done。 */
|
||||
/** 暂停当前生成:中断在途请求 + 把流式气泡收尾为「已暂停」。 */
|
||||
const pause = useCallback(() => {
|
||||
if (!runningRef.current) return;
|
||||
abort();
|
||||
flowAborterRef.current?.abort();
|
||||
flowAborterRef.current = null;
|
||||
ingestRunRef.current += 1;
|
||||
freezeStreaming("已暂停");
|
||||
setPhase((p) => (p === "creating" || p === "streaming" ? "done" : p));
|
||||
setPhase("paused");
|
||||
setFlowPhase((p) => (p === "extracting" || p === "validating" ? "idle" : p));
|
||||
}, [abort, freezeStreaming]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
|
||||
@@ -2537,6 +2537,7 @@ export default function RoundtablePlanningPage({
|
||||
headerRightSlot={step2HeaderRightSlot}
|
||||
step2RoundtableDialogues={step2.step2RoundtableDialogues}
|
||||
isInitializing={step2.isInitializing}
|
||||
isRoundTableRunning={step2.isRoundTableRunning}
|
||||
isOrchestrating={step2.isOrchestrating}
|
||||
pendingClarification={step2.pendingClarification}
|
||||
clarificationDraft={step2.clarificationDraft}
|
||||
@@ -2564,6 +2565,7 @@ export default function RoundtablePlanningPage({
|
||||
openArtifactFromStep={step2.openArtifactFromStep}
|
||||
openArtifactPreview={step2.openArtifactPreview}
|
||||
onPauseRoundtable={step2.pauseRoundtable}
|
||||
onResumeRoundtable={step2.resumeRoundtable}
|
||||
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
|
||||
setClarificationDraft={step2.setClarificationDraft}
|
||||
setCustomAgentInput={step2.setCustomAgentInput}
|
||||
|
||||
@@ -339,9 +339,10 @@ export interface StartJobOptions {
|
||||
requestId?: string;
|
||||
/**
|
||||
* "chat" harvests the collector-agent conversation's materials as the pool;
|
||||
* "legacy" (default) keeps the runner-driven search tiers.
|
||||
* "legacy" (default) keeps the runner-driven search tiers; "regenerate"
|
||||
* runs the same report engine again over the variant's selected source pool.
|
||||
*/
|
||||
entry?: "chat" | "legacy";
|
||||
entry?: "chat" | "legacy" | "regenerate";
|
||||
/** Report-config card choices merged over the frozen session snapshot. */
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
@@ -629,6 +630,36 @@ export function streamResearchRewrite(
|
||||
return streamResearchMessage(sessionId, instruction, handlers, signal, "rewrite");
|
||||
}
|
||||
|
||||
export async function listReportVariants(parentSessionId: string): Promise<DeepResearchSession[]> {
|
||||
const res = await apiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(parentSessionId)}/report-variants`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
const data = await readJson<WireRecord>(res, "listReportVariants");
|
||||
return asItems(data.items).map(normalizeSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork a completed report before generating it under another structure.
|
||||
* The returned session owns its own report and selected-source snapshot, so
|
||||
* its durable rewrite cannot replace the parent report.
|
||||
*/
|
||||
export async function createResearchReportVariant(
|
||||
sessionId: string,
|
||||
input: { reportOutline?: string },
|
||||
): Promise<DeepResearchSession> {
|
||||
const response = await apiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/report-variants`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
const data = await readJson<WireRecord>(response, "createResearchReportVariant");
|
||||
return normalizeSession(data);
|
||||
}
|
||||
|
||||
export async function cancelFullResearchReportRewrite(jobId: string): Promise<void> {
|
||||
await cancelJob(jobId);
|
||||
}
|
||||
@@ -636,7 +667,13 @@ export async function cancelFullResearchReportRewrite(jobId: string): Promise<vo
|
||||
export interface LatestFullReportRewrite {
|
||||
job: DeepResearchJob | null;
|
||||
result: Record<string, unknown>;
|
||||
request: { instruction: string; style: DocumentRewriteStyle; modelName?: string | null };
|
||||
request: {
|
||||
operation: "rewrite" | "generate";
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName?: string | null;
|
||||
reportOutline?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLatestFullResearchReportRewrite(
|
||||
@@ -653,9 +690,11 @@ export async function getLatestFullResearchReportRewrite(
|
||||
job: jobRecord ? normalizeJob(jobRecord) : null,
|
||||
result: asRecord(data.result),
|
||||
request: {
|
||||
operation: request.operation === "generate" ? "generate" : "rewrite",
|
||||
instruction: String(request.instruction ?? ""),
|
||||
style: (request.style ?? "formal_analysis") as DocumentRewriteStyle,
|
||||
modelName: typeof request.modelName === "string" ? request.modelName : null,
|
||||
reportOutline: typeof request.reportOutline === "string" ? request.reportOutline : "",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -745,6 +784,11 @@ export async function* joinFullResearchReportRewrite(
|
||||
modelName: String(raw.modelName ?? "deep_research_smart_model"),
|
||||
plan: typeof raw.plan === "string" ? raw.plan : undefined,
|
||||
};
|
||||
} else if (type === "thinking") {
|
||||
// Deep Research uses the same shared rewrite hook/timeline as
|
||||
// sandbox Markdown. Preserve provider reasoning chunks instead
|
||||
// of silently dropping them in this custom durable-SSE adapter.
|
||||
yield { type, chunk: String(raw.chunk ?? "") };
|
||||
} else if (type === "rewrite_delta") {
|
||||
yield { type, delta: String(raw.delta ?? ""), charCount: Number(raw.charCount ?? 0) };
|
||||
} else if (type === "rewrite_checkpoint") {
|
||||
@@ -767,12 +811,14 @@ export async function* joinFullResearchReportRewrite(
|
||||
content: String(raw.content ?? ""),
|
||||
committedHash: String(raw.committedHash ?? ""),
|
||||
versionId: typeof raw.versionId === "string" ? raw.versionId : undefined,
|
||||
versionSnapshotFailed: raw.versionSnapshotFailed === true,
|
||||
};
|
||||
} else if (type === "done") {
|
||||
yield {
|
||||
type,
|
||||
committedHash: String(raw.committedHash ?? ""),
|
||||
versionId: typeof raw.versionId === "string" ? raw.versionId : undefined,
|
||||
versionSnapshotFailed: raw.versionSnapshotFailed === true,
|
||||
};
|
||||
terminal = true;
|
||||
} else if (type === "conflict") {
|
||||
@@ -808,7 +854,13 @@ export async function* joinFullResearchReportRewrite(
|
||||
/** Start and join the sandbox's durable complete-report rewrite. */
|
||||
export async function* streamFullResearchReportRewrite(
|
||||
sessionId: string,
|
||||
input: { instruction: string; style: DocumentRewriteStyle; modelName?: string },
|
||||
input: {
|
||||
operation?: "rewrite" | "generate";
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName?: string;
|
||||
reportOutline?: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
onJobStarted?: (jobId: string) => void,
|
||||
): AsyncGenerator<DocumentRewriteStreamEvent> {
|
||||
@@ -877,6 +929,7 @@ export const DEEP_RESEARCH_SESSION_KEY = "deep-research-session";
|
||||
export const DEEP_RESEARCH_SOURCES_KEY = "deep-research-sources";
|
||||
export const DEEP_RESEARCH_MESSAGES_KEY = "deep-research-messages";
|
||||
export const DEEP_RESEARCH_CAPABILITIES_KEY = "deep-research-capabilities";
|
||||
export const DEEP_RESEARCH_REPORT_VARIANTS_KEY = "deep-research-report-variants";
|
||||
|
||||
export function useResearchSessions(limit = 50) {
|
||||
return useQuery({
|
||||
@@ -896,6 +949,15 @@ export function useResearchSession(id: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useResearchReportVariants(parentSessionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: [DEEP_RESEARCH_REPORT_VARIANTS_KEY, parentSessionId],
|
||||
queryFn: () => listReportVariants(parentSessionId!),
|
||||
enabled: !!parentSessionId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useResearchSources(sessionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: [DEEP_RESEARCH_SOURCES_KEY, sessionId],
|
||||
@@ -939,7 +1001,7 @@ export function useResearchActions() {
|
||||
config,
|
||||
}: {
|
||||
sessionId: string;
|
||||
entry?: "chat" | "legacy";
|
||||
entry?: "chat" | "legacy" | "regenerate";
|
||||
config?: Record<string, unknown>;
|
||||
}) => startJob(sessionId, { entry, config }),
|
||||
onSuccess: (_result, { sessionId }) => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/light-apps";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { authStorageGet } from "@/core/auth/scoped-storage";
|
||||
import { readCurrentExternalAccessTokenValue } from "@/core/auth/external-access-token";
|
||||
|
||||
@@ -26,13 +27,14 @@ export function mountLabel(value: string | null | undefined): string {
|
||||
/**
|
||||
* 登录参数可选来源;值均可在 userInfo 中取到,"other" 表示自填。
|
||||
* accessToken / yUserId 取登录路由透传值(写入 userInfo 的同名键);
|
||||
* userName / gateDepartName / typeName 取 consumer 接口 userDetailByYuserId 的返回值。
|
||||
* userName 优先取当前鉴权会话的原始登录名;gateDepartName / typeName 取 consumer
|
||||
* 接口 userDetailByYuserId 的返回值。
|
||||
*/
|
||||
export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: "access_token", value: "access_token" },
|
||||
{ label: "accessToken(兼容旧配置)", value: "accessToken" },
|
||||
{ label: "yUserId", value: "yUserId" },
|
||||
{ label: "userName", value: "userName" },
|
||||
{ label: "userName(当前登录账号)", value: "userName" },
|
||||
{ label: "gateDepartName", value: "gateDepartName" },
|
||||
{ label: "typeName", value: "typeName" },
|
||||
{ label: "其他(自定义值)", value: "other" },
|
||||
@@ -152,6 +154,25 @@ export function resolveRouteParamValue(
|
||||
return param.customValue ?? "";
|
||||
}
|
||||
if (param.source) {
|
||||
// `userInfo.userName` can belong to a prior external-login flow (for
|
||||
// example a stale "guest" value). Route parameters must follow the user
|
||||
// currently authenticated in DeerFlow, so prefer the backend's original
|
||||
// login username and only fall back to userInfo for older backends.
|
||||
if (param.source === "userName") {
|
||||
const auth = getStoredAuth();
|
||||
const loginUsername = auth?.username?.trim();
|
||||
if (loginUsername) return loginUsername;
|
||||
|
||||
const accountName = (auth?.email ?? "").split("@")[0]?.trim() || auth?.user_id;
|
||||
if (accountName) return accountName;
|
||||
|
||||
const userInfoName = userInfo.userName;
|
||||
if (typeof userInfoName === "string" && userInfoName.trim()) {
|
||||
return userInfoName.trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
const value = userInfo[param.source];
|
||||
if (param.source === "access_token" || param.source === "accessToken") {
|
||||
return readCurrentExternalAccessTokenValue(userInfo);
|
||||
|
||||
Reference in new issue
Block a user