Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts: # frontend-web/src/components/workspace/input-box.tsx # frontend-web/src/components/workspace/skill-display-sources.tsx
This commit is contained in:
commit
389df2ace5
108 files changed
+12491
-3151
No files matched your search
@@ -0,0 +1,113 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface ArtifactListingFile {
|
||||
path: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
mimeType: string | null;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export interface ArtifactListing {
|
||||
threadId: string;
|
||||
files: ArtifactListingFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the thread's outputs directory instead of relying only on artifacts
|
||||
* explicitly surfaced by `present_files`. This is used by file mode so files
|
||||
* written by any tool are still discoverable from the right-hand directory.
|
||||
*/
|
||||
export async function listThreadArtifacts(
|
||||
threadId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactListing> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`,
|
||||
{ method: "GET", signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to list artifacts (${response.status})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
thread_id?: string;
|
||||
files?: Array<{
|
||||
path: string;
|
||||
name: string;
|
||||
size_bytes: number;
|
||||
mime_type: string | null;
|
||||
modified_at: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
return {
|
||||
threadId: data.thread_id ?? threadId,
|
||||
files: (data.files ?? []).map((file) => ({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
sizeBytes: file.size_bytes,
|
||||
mimeType: file.mime_type,
|
||||
modifiedAt: file.modified_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the generated file path represented by a real or streaming artifact. */
|
||||
export function artifactPathname(artifact: string): string {
|
||||
const trimmed = artifact.trim();
|
||||
if (!trimmed.startsWith("write-file:")) {
|
||||
return trimmed.replace(/^\/+/, "");
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(new URL(trimmed).pathname).replace(/^\/+/, "");
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function artifactRelativePath(artifact: string): string {
|
||||
const pathname = artifactPathname(artifact);
|
||||
return pathname.replace(/^mnt\/user-data\/outputs\/?/i, "") || pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the checkpoint order, append files found only on disk from old to new,
|
||||
* and replace a completed streaming URL with its durable path.
|
||||
*/
|
||||
export function mergeArtifactPaths(
|
||||
checkpointArtifacts: string[],
|
||||
discoveredArtifacts: string[],
|
||||
): string[] {
|
||||
const merged: string[] = [];
|
||||
const indexByPath = new Map<string, number>();
|
||||
|
||||
for (const artifact of checkpointArtifacts) {
|
||||
const key = artifactPathname(artifact);
|
||||
if (!key || indexByPath.has(key)) continue;
|
||||
indexByPath.set(key, merged.length);
|
||||
merged.push(artifact);
|
||||
}
|
||||
|
||||
// The listing endpoint returns newest first. Append in chronological order
|
||||
// so the last item remains the newest artifact for the existing auto-open.
|
||||
for (const artifact of [...discoveredArtifacts].reverse()) {
|
||||
const key = artifactPathname(artifact);
|
||||
if (!key) continue;
|
||||
const existingIndex = indexByPath.get(key);
|
||||
if (existingIndex !== undefined) {
|
||||
if (merged[existingIndex]?.startsWith("write-file:")) {
|
||||
merged[existingIndex] = artifact;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
indexByPath.set(key, merged.length);
|
||||
merged.push(artifact);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -49,6 +49,17 @@ function normalizeIdentityPart(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function identityFromLoginName(value: unknown): string | null {
|
||||
const normalized = normalizeIdentityPart(value);
|
||||
if (!normalized) return null;
|
||||
// Explicit email login is domain-specific. Bare username login is the
|
||||
// stable business principal even if the backend's synthetic email domain or
|
||||
// internal UUID changes.
|
||||
return normalized.includes("@")
|
||||
? `email:${normalized}`
|
||||
: `account:${normalized}`;
|
||||
}
|
||||
|
||||
function readUserInfoIdentity(): string | null {
|
||||
let info: Record<string, unknown> | null = null;
|
||||
try {
|
||||
@@ -81,20 +92,24 @@ function readUserInfoIdentity(): string | null {
|
||||
/**
|
||||
* A stable cross-tab identity key for "did the account really change?" checks.
|
||||
*
|
||||
* Some external login paths can re-issue a backend ``user_id`` while resolving
|
||||
* to the same business account. Prefer business-facing identifiers
|
||||
* (email/username/userInfo) and keep ``user_id`` only as the final fallback.
|
||||
* Some external login paths can re-issue a backend ``user_id`` or use a
|
||||
* different synthetic email domain while resolving to the same business
|
||||
* account. Prefer the raw login username returned by the backend, then the
|
||||
* email local-part, and keep ``user_id`` only as the final fallback.
|
||||
*/
|
||||
export function getAccountIdentityKey(
|
||||
auth: LoginByUsernameResponse | null = getStoredAuth(),
|
||||
): string | null {
|
||||
if (!auth) return null;
|
||||
|
||||
const email = normalizeIdentityPart(auth.email);
|
||||
if (email) return `email:${email}`;
|
||||
const loginIdentity = identityFromLoginName(auth.username);
|
||||
if (loginIdentity) return loginIdentity;
|
||||
|
||||
const username = normalizeIdentityPart(auth.username);
|
||||
if (username) return `username:${username}`;
|
||||
const email = normalizeIdentityPart(auth.email);
|
||||
if (email) {
|
||||
const localPart = email.split("@", 1)[0];
|
||||
return localPart ? `account:${localPart}` : `email:${email}`;
|
||||
}
|
||||
|
||||
const userInfoIdentity = readUserInfoIdentity();
|
||||
if (userInfoIdentity) return `userInfo:${userInfoIdentity}`;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAccountIdentityKey } from "@/core/auth";
|
||||
import {
|
||||
AUTH_STATE_CHANGED_EVENT,
|
||||
getAccountDisplayName,
|
||||
getAccountIdentityKey,
|
||||
} from "@/core/auth";
|
||||
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
@@ -14,14 +20,37 @@ function readIdentityKey(): string | null {
|
||||
return getAccountIdentityKey();
|
||||
}
|
||||
|
||||
function freshRouteForAccount(pathname: string): string {
|
||||
// A thread URL belongs to the old account. Keep its workspace/agent context
|
||||
// but move to a valid new conversation before the user can submit into the
|
||||
// stale thread. Other pages can stay in place and are remounted below.
|
||||
const chatMatch = pathname.match(/^(.*\/chats)\/[^/]+\/?$/);
|
||||
return chatMatch ? `${chatMatch[1]}/new` : pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Watches localStorage auth changes from other
|
||||
* tabs and lets the user decide when to refresh on account switches.
|
||||
* Cross-tab auth boundary for normal pages.
|
||||
*
|
||||
* Same-account token re-issuance is ignored. A genuine account change clears
|
||||
* account-scoped query state, remounts the page, and moves an open chat to its
|
||||
* fresh-chat route without a full browser reload. Embedded/isolated tabs keep
|
||||
* their sessionStorage identity and never participate in this synchronization.
|
||||
*/
|
||||
export function AuthSyncWatcher() {
|
||||
export function AuthSyncWatcher({
|
||||
children,
|
||||
disabled = false,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const lastIdentityRef = useRef<string | null>(readIdentityKey());
|
||||
const [revision, setRevision] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
|
||||
// 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
|
||||
if (isEmbedActive() || isIsolatedSession()) return;
|
||||
@@ -36,18 +65,27 @@ export function AuthSyncWatcher() {
|
||||
|
||||
lastIdentityRef.current = nextIdentity;
|
||||
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
|
||||
setRevision((value) => value + 1);
|
||||
|
||||
if (nextIdentity === null) {
|
||||
toast.info("账号已在其它页面退出", {
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前页面已同步退出状态。",
|
||||
});
|
||||
navigate("/login", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning("检测到账号在其它标签页发生变化", {
|
||||
const freshRoute = freshRouteForAccount(location.pathname);
|
||||
if (freshRoute !== location.pathname) {
|
||||
navigate(freshRoute, { replace: true });
|
||||
}
|
||||
toast.info("账号已在页面间自动同步", {
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "刷新同步",
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
description: `当前账号:${getAccountDisplayName()}。无需刷新页面。`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,14 +106,24 @@ export function AuthSyncWatcher() {
|
||||
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
|
||||
}
|
||||
|
||||
function handleSameTabAuthChange() {
|
||||
// `storage` never fires in the tab that called setStoredAuth/clearStoredAuth.
|
||||
// Keep the comparison baseline current so a later same-account token
|
||||
// renewal from another normal page is not compared with the pre-login
|
||||
// `null` identity and falsely classified as an account switch.
|
||||
lastIdentityRef.current = readIdentityKey();
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [disabled, location.pathname, navigate, queryClient]);
|
||||
|
||||
return null;
|
||||
return children ? <Fragment key={revision}>{children}</Fragment> : null;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getAccountIdentityKey,
|
||||
getStoredAuth,
|
||||
} from "@/core/auth";
|
||||
import { isEmbedActive } from "@/core/embed/embed-session";
|
||||
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
|
||||
@@ -65,7 +65,9 @@ export function useLiveAccount(): LiveAccount {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
if (!isEmbedActive()) {
|
||||
// Normal pages share localStorage identity across tabs. Embedded and
|
||||
// isolated pages deliberately keep their per-tab sessionStorage identity.
|
||||
if (!isEmbedActive() && !isIsolatedSession()) {
|
||||
window.addEventListener("storage", onStorage);
|
||||
}
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
||||
|
||||
@@ -456,7 +456,7 @@ export const enUS: Translations = {
|
||||
lightDescription: "Bright palette with higher contrast for daytime.",
|
||||
darkDescription: "Dim palette that reduces glare for focus.",
|
||||
lightblueDescription: "A crisp light palette built around blue.",
|
||||
lightgreenDescription: "A crisp light palette built around WeKnora green.",
|
||||
lightgreenDescription: "A crisp light palette built around fresh green tones.",
|
||||
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
||||
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
|
||||
languageTitle: "Language",
|
||||
|
||||
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
|
||||
lightDescription: "更明亮的配色,适合日间使用。",
|
||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用清新绿色的浅色配色。",
|
||||
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
|
||||
languageTitle: "语言",
|
||||
|
||||
@@ -44,7 +44,7 @@ export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget
|
||||
params.get("wikiSlug")?.trim() ||
|
||||
params.get("slug")?.trim() ||
|
||||
"";
|
||||
return knowledgeBaseId && chunkId
|
||||
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
|
||||
? {
|
||||
knowledgeBaseId,
|
||||
chunkId,
|
||||
@@ -75,6 +75,7 @@ export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnora
|
||||
fromPreview?.documentId ||
|
||||
"";
|
||||
const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || "";
|
||||
if (!wikiSlug && !chunkId && !documentId) return null;
|
||||
return {
|
||||
knowledgeBaseId,
|
||||
chunkId: chunkId || undefined,
|
||||
@@ -94,6 +95,43 @@ export function buildWeKnoraSourceIframeUrl(target: WeKnoraSourceTarget): string
|
||||
return `/#/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight native reader for one Wiki page. Unlike the knowledge-base
|
||||
* detail route this does not create a WeKnora iframe session.
|
||||
*/
|
||||
export function buildWeKnoraWikiPageUrl(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug: string,
|
||||
): string {
|
||||
return `/#${buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)}`;
|
||||
}
|
||||
|
||||
export function buildWeKnoraWikiPagePath(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug: string,
|
||||
): string {
|
||||
const params = new URLSearchParams({ wikiSlug });
|
||||
return `/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** Public, chrome-free Wiki reader intended for third-party iframe embedding. */
|
||||
export function buildPublicKnowledgeWikiPath(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug?: string,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (wikiSlug) params.set("wikiSlug", wikiSlug);
|
||||
const query = params.toString();
|
||||
return `/embed/knowledge/${encodeURIComponent(knowledgeBaseId)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export function buildPublicKnowledgeWikiUrl(
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug?: string,
|
||||
): string {
|
||||
return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId, wikiSlug)}`;
|
||||
}
|
||||
|
||||
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
|
||||
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
|
||||
if (!recUuid) return "";
|
||||
@@ -108,7 +146,9 @@ export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): stri
|
||||
export function openKnowledgeSource(source: KnowledgeSourceLike): boolean {
|
||||
const weknoraTarget = resolveWeKnoraSourceTarget(source);
|
||||
const href = weknoraTarget
|
||||
? buildWeKnoraSourceIframeUrl(weknoraTarget)
|
||||
? weknoraTarget.wikiSlug
|
||||
? buildWeKnoraWikiPageUrl(weknoraTarget.knowledgeBaseId, weknoraTarget.wikiSlug)
|
||||
: buildWeKnoraSourceIframeUrl(weknoraTarget)
|
||||
: buildLegacyKnowledgeSourceUrl(source);
|
||||
if (!href) return false;
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { buildWeKnoraWikiPageUrl } from "@/core/llmwiki/source-links";
|
||||
|
||||
export type WikiPageUrlBuilder = (
|
||||
knowledgeBaseId: string,
|
||||
wikiSlug: string,
|
||||
) => string;
|
||||
|
||||
export function stripWikiFrontmatter(value: string) {
|
||||
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
|
||||
}
|
||||
|
||||
export function safeWikiFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|\r\n]+/g, "_").replace(/\s+/g, " ").trim() || "wiki-page";
|
||||
}
|
||||
|
||||
export function exportableWikiMarkdown(title: string, summary: string, content: string) {
|
||||
const body = content.trim();
|
||||
const hasTitle = /^#\s+/m.test(body);
|
||||
return [hasTitle ? "" : `# ${title}`, summary && !body.includes(summary) ? `> ${summary}` : "", body]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function wikiLinkMarkdown(
|
||||
markdown: string,
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
|
||||
) {
|
||||
const link = (slugValue: string, labelValue?: string) => {
|
||||
const slug = slugValue.trim();
|
||||
const fallbackLabel = slug.split("/").filter(Boolean).at(-1) || slug;
|
||||
const label = (labelValue || fallbackLabel).trim().replace(/\]/g, "\\]");
|
||||
return `[${label}](${buildPageUrl(knowledgeBaseId, slug)})`;
|
||||
};
|
||||
return markdown
|
||||
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label))
|
||||
.replace(/\[\[([^\]\s]+)\s+([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label))
|
||||
.replace(/\[\[([^\]]+)\]\]/g, (_match, slug) => link(slug));
|
||||
}
|
||||
|
||||
export function renderableWikiMarkdown(
|
||||
title: string,
|
||||
content: string,
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl?: WikiPageUrlBuilder,
|
||||
) {
|
||||
const lines = content.trim().split("\n");
|
||||
if (lines[0]?.replace(/^#+\s*/, "").trim() === title.trim()) {
|
||||
lines[0] = `# ${title}`;
|
||||
}
|
||||
return wikiLinkMarkdown(
|
||||
exportableWikiMarkdown(title, "", lines.join("\n")),
|
||||
knowledgeBaseId,
|
||||
buildPageUrl,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { LlmWikiWikiPage } from "@/strategy-components/api/llmwiki";
|
||||
|
||||
export type WikiTreeNode = {
|
||||
key: string;
|
||||
label: string;
|
||||
page: LlmWikiWikiPage | null;
|
||||
children: WikiTreeNode[];
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type WikiTree = {
|
||||
roots: WikiTreeNode[];
|
||||
parentKeysBySlug: Map<string, string[]>;
|
||||
};
|
||||
|
||||
function pageDirectoryParts(page: LlmWikiWikiPage): string[] {
|
||||
const wikiPath = (page.wiki_path || "").split("/").map((item) => item.trim()).filter(Boolean);
|
||||
if (wikiPath.length > 2) return wikiPath.slice(1, -1);
|
||||
const category = page.category_path?.filter(Boolean) || [];
|
||||
if (category.length > 0) return category;
|
||||
const parts = (page.slug || "").split("/").filter(Boolean);
|
||||
return parts.length > 1 ? parts.slice(0, -1) : ["未分类"];
|
||||
}
|
||||
|
||||
export function filterWikiPagesWithAncestors(
|
||||
pages: LlmWikiWikiPage[],
|
||||
query: string,
|
||||
): LlmWikiWikiPage[] {
|
||||
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return pages;
|
||||
const matched = pages.filter((page) => {
|
||||
const haystack = [
|
||||
page.title,
|
||||
page.slug,
|
||||
page.summary,
|
||||
page.page_type,
|
||||
page.parent_slug,
|
||||
...(page.aliases || []),
|
||||
...(page.category_path || []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return words.every((word) => haystack.includes(word));
|
||||
});
|
||||
const bySlug = new Map(pages.map((page) => [page.slug, page]));
|
||||
const result = new Map<string, LlmWikiWikiPage>();
|
||||
for (const page of matched) {
|
||||
let current: LlmWikiWikiPage | undefined = page;
|
||||
const visited = new Set<string>();
|
||||
while (current?.slug && !visited.has(current.slug)) {
|
||||
visited.add(current.slug);
|
||||
result.set(current.slug, current);
|
||||
current = current.parent_slug ? bySlug.get(current.parent_slug) : undefined;
|
||||
}
|
||||
}
|
||||
return Array.from(result.values());
|
||||
}
|
||||
|
||||
function attachNode(parent: WikiTreeNode | null, roots: WikiTreeNode[], node: WikiTreeNode) {
|
||||
const siblings = parent ? parent.children : roots;
|
||||
if (!siblings.some((item) => item.key === node.key)) siblings.push(node);
|
||||
}
|
||||
|
||||
function sortWikiNodes(nodes: WikiTreeNode[]) {
|
||||
nodes.sort((left, right) => {
|
||||
if (!!left.children.length !== !!right.children.length) return left.children.length ? -1 : 1;
|
||||
return left.label.localeCompare(right.label, "zh-Hans-CN");
|
||||
});
|
||||
nodes.forEach((node) => sortWikiNodes(node.children));
|
||||
}
|
||||
|
||||
function countPages(node: WikiTreeNode): number {
|
||||
node.count = (node.page ? 1 : 0) + node.children.reduce((sum, child) => sum + countPages(child), 0);
|
||||
return node.count;
|
||||
}
|
||||
|
||||
export function buildWikiTree(pages: LlmWikiWikiPage[]): WikiTree {
|
||||
const roots: WikiTreeNode[] = [];
|
||||
const pageNodes = new Map<string, WikiTreeNode>();
|
||||
const directoryNodes = new Map<string, WikiTreeNode>();
|
||||
const parentKeysBySlug = new Map<string, string[]>();
|
||||
|
||||
for (const page of pages) {
|
||||
if (!page.slug) continue;
|
||||
pageNodes.set(page.slug, {
|
||||
key: `page:${page.slug}`,
|
||||
label: page.title || page.slug,
|
||||
page,
|
||||
children: [],
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const directoryNode = (path: string[]) => {
|
||||
const key = `dir:${path.join("/")}`;
|
||||
const existing = directoryNodes.get(key);
|
||||
if (existing) return existing;
|
||||
const node: WikiTreeNode = {
|
||||
key,
|
||||
label: path[path.length - 1] || "未分类",
|
||||
page: null,
|
||||
children: [],
|
||||
count: 0,
|
||||
};
|
||||
directoryNodes.set(key, node);
|
||||
return node;
|
||||
};
|
||||
|
||||
for (const page of pages) {
|
||||
const node = pageNodes.get(page.slug);
|
||||
if (!node) continue;
|
||||
const ancestorKeys: string[] = [];
|
||||
const parentSlug = page.parent_slug?.trim();
|
||||
if (parentSlug && pageNodes.has(parentSlug)) {
|
||||
const parent = pageNodes.get(parentSlug)!;
|
||||
attachNode(parent, roots, node);
|
||||
ancestorKeys.push(parent.key);
|
||||
let current = parent.page?.parent_slug ? pageNodes.get(parent.page.parent_slug) : undefined;
|
||||
const seen = new Set<string>([parent.key]);
|
||||
while (current && !seen.has(current.key)) {
|
||||
seen.add(current.key);
|
||||
ancestorKeys.unshift(current.key);
|
||||
current = current.page?.parent_slug ? pageNodes.get(current.page.parent_slug) : undefined;
|
||||
}
|
||||
parentKeysBySlug.set(page.slug, ancestorKeys);
|
||||
continue;
|
||||
}
|
||||
|
||||
let parent: WikiTreeNode | null = null;
|
||||
const path: string[] = [];
|
||||
for (const part of pageDirectoryParts(page)) {
|
||||
path.push(part);
|
||||
const directory = directoryNode(path);
|
||||
attachNode(parent, roots, directory);
|
||||
parent = directory;
|
||||
ancestorKeys.push(directory.key);
|
||||
}
|
||||
attachNode(parent, roots, node);
|
||||
parentKeysBySlug.set(page.slug, ancestorKeys);
|
||||
}
|
||||
|
||||
roots.forEach(countPages);
|
||||
sortWikiNodes(roots);
|
||||
return { roots, parentKeysBySlug };
|
||||
}
|
||||
@@ -4,10 +4,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
notification: {
|
||||
enabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
contextCompressionEnabled: false,
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
artifactDirectoryModeEnabled: false,
|
||||
contextCompressionEnabled: false,
|
||||
citationCleanupWords: [],
|
||||
fontScale: 1,
|
||||
},
|
||||
@@ -43,7 +44,12 @@ export interface LocalSettings {
|
||||
* Global switch for skill result display adapters. When disabled, chat
|
||||
* messages render tool results with the legacy generic search UI only.
|
||||
*/
|
||||
ragModeEnabled: boolean;
|
||||
ragModeEnabled: boolean;
|
||||
/**
|
||||
* When enabled, the artifact panel scans every generated output and keeps
|
||||
* a directory tree visible beside the selected file preview.
|
||||
*/
|
||||
artifactDirectoryModeEnabled: boolean;
|
||||
/**
|
||||
* Per-user switch for context compression (上下文压缩). When enabled, long
|
||||
* conversations are summarized transiently before the model call to save
|
||||
|
||||
@@ -761,17 +761,15 @@ export function useThreadStream({
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A "thread not found" 404 almost always means the URL points at a
|
||||
// thread from a previous login session — either this tab outlived a
|
||||
// logout/re-login or the thread genuinely belongs to a different user.
|
||||
// Re-frame the error around the *current* logged-in identity and bounce
|
||||
// the user to a fresh chat instead of leaving them stuck on the stale
|
||||
// route looking at a raw HTTP 404.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`,
|
||||
);
|
||||
// A "thread not found" 404 means this route cannot accept another turn.
|
||||
// Move to a fresh chat without a full browser reload; the old reload path
|
||||
// made every click look like a crash and could loop indefinitely when a
|
||||
// different tab's cookie kept overriding this tab's Bearer token.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||
);
|
||||
// Prefer staying inside an agent chat context when the user is on an
|
||||
// agent thread URL — otherwise fall back to the global new chat.
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||
@@ -782,18 +780,7 @@ export function useThreadStream({
|
||||
: iframeMatch
|
||||
? "/page/workspace/chats_iframe/new"
|
||||
: "/page/workspace/chats/new";
|
||||
// First navigate to a safe URL so the post-reload page lands on a
|
||||
// valid route. Then full-reload so TanStack Query cache, context
|
||||
// providers and any other state captured under the previous user's
|
||||
// identity get rebuilt against the current login.
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.hash = target;
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 900);
|
||||
} else {
|
||||
navigate(target, { replace: true });
|
||||
}
|
||||
navigate(target, { replace: true });
|
||||
return;
|
||||
}
|
||||
// 上一轮还没结束又发了新问题(409)或并发达到上限(429):
|
||||
|
||||
@@ -13,8 +13,9 @@ import { getStoredAuth } from "@/core/auth";
|
||||
* 404 just leaves the user staring at a scary error. Both the chat stream
|
||||
* (``core/threads/hooks.ts``) and the AI-writing stream
|
||||
* (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors
|
||||
* through here so the UX is identical: detect the login-change 404 and prompt
|
||||
* the user to refresh against the current identity instead of erroring out.
|
||||
* through here so the UX is identical: detect the login-change 404 and reset
|
||||
* the stale in-memory session against the current identity instead of showing
|
||||
* a raw error or reloading the browser page.
|
||||
*/
|
||||
|
||||
/** Normalize an unknown stream error into a human-readable message string. */
|
||||
@@ -108,22 +109,15 @@ export function buildCurrentUserLabel(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-frame a login-change 404 around the current identity and reload the page.
|
||||
* Re-frame a login-change 404 around the current identity and reset its view.
|
||||
*
|
||||
* Shows a friendly toast naming the current login, then full-reloads after a
|
||||
* short delay so the toast is visible and so TanStack Query cache / context
|
||||
* providers / any state captured under the previous user get rebuilt against
|
||||
* the current login. Used by streams whose session id lives in component state
|
||||
* (not the URL) — a plain reload lands them back on a fresh, valid page.
|
||||
* Streams whose session id lives in component state pass a local reset callback
|
||||
* so they can discard the inaccessible session without a browser reload.
|
||||
*/
|
||||
export function notifyThreadAccessChangedAndReload(): void {
|
||||
export function notifyThreadAccessChanged(onReset: () => void): void {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原会话不存在或无权访问,正在为你刷新页面…`,
|
||||
`当前登录用户为 ${userLabel},原会话不存在或无权访问,已为你重置为新会话。`,
|
||||
);
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 900);
|
||||
}
|
||||
onReset();
|
||||
}
|
||||
Reference in new issue
Block a user