feat: improve wiki document and artifact views
This commit is contained in:
1 parent
3785345353
commit
49babb6d25
23 files changed
+1728
-329
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;
|
||||
}
|
||||
@@ -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: "语言",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user