feat: improve wiki document and artifact views

This commit is contained in:
吉鑫升 committed 2026-08-24 14:01:10 +08:00
1 parent 3785345353
commit 49babb6d25
23 files changed
+1728 -329

No files matched your search

+113
View File
@@ -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;
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -437,7 +437,7 @@ export const zhCN: Translations = {
lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
lightgreenDescription: "采用清新绿色的浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
languageTitle: "语言",
+22 -1
View File
@@ -95,6 +95,25 @@ 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()}`;
}
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
if (!recUuid) return "";
@@ -109,7 +128,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,45 @@
import { buildWeKnoraWikiPageUrl } from "@/core/llmwiki/source-links";
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) {
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}](${buildWeKnoraWikiPageUrl(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,
) {
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,
);
}
+146
View File
@@ -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 };
}
+11 -5
View File
@@ -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