Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

859 lines
23 KiB
TypeScript

import { apiFetch, apiUrl } from "@/lib/api";
import { invalidateClientCache, withClientCache } from "@/lib/client-cache";
const KNOWLEDGE_CACHE_PREFIX = "knowledge:";
export interface KnowledgeBaseSummary {
id?: string;
name: string;
is_default?: boolean;
status?: string;
path?: string;
metadata?: Record<string, unknown>;
progress?: Record<string, unknown>;
statistics?: Record<string, unknown>;
source?: "admin" | "user";
assigned?: boolean;
read_only?: boolean;
provenance_label?: string;
available?: boolean;
}
export interface RagProviderSummary {
id: string;
name: string;
description: string;
/** Whether the engine is ready to use (e.g. its API key is set). */
configured?: boolean;
/** Whether the engine needs an API key configured before use. */
requires_api_key?: boolean;
/** Retrieval modes this engine supports (empty for mode-less engines). */
modes?: string[];
/** The active default retrieval mode for this engine. */
default_mode?: string;
/** Whether an existing index for this engine can be linked in place. */
linkable?: boolean;
}
export interface PageIndexConfig {
api_base_url: string;
api_key_set: boolean;
configured: boolean;
}
export interface LlamaIndexConfig {
version: number;
/** "hybrid" (BM25 + vector fusion) or "vector" only. */
retrieval_profile: "hybrid" | "vector";
/** Default number of chunks a query returns. */
top_k: number;
vector_top_k_multiplier: number;
bm25_top_k_multiplier: number;
/** Chunk geometry — applies to documents indexed after the change. */
chunk_size: number;
chunk_overlap: number;
}
export interface GraphRagConfig {
version: number;
response_type: string;
community_level: number;
dynamic_community_selection: boolean;
}
export interface LightRagConfig {
version: number;
top_k: number;
response_type: string;
}
export interface PreflightCheck {
key: string;
label: string;
ok: boolean;
detail: string;
/** Optional checks don't gate overall readiness (e.g. BM25, vision). */
optional: boolean;
}
export interface EnginePreflight {
ok: boolean;
checks: PreflightCheck[];
}
export interface ModelOption {
profile_id: string;
profile_name: string;
model_id: string;
label: string;
model: string;
detail: string;
}
export interface ModelKindOptions {
active: { profile_id: string | null; model_id: string | null };
options: ModelOption[];
}
/** Map of service kind ("llm" | "embedding") → its options + active selection. */
export type ModelOptionsByKind = Record<string, ModelKindOptions>;
export interface KnowledgeUploadPolicy {
extensions: string[];
accept: string;
max_file_size_bytes: number;
}
export interface KnowledgeBaseFile {
/** POSIX path relative to the KB's raw/ root (may include folders). */
name: string;
/** "folder" entries are organizational only; default "file". */
type?: "file" | "folder";
size?: number;
modified?: number;
mime_type?: string | null;
}
const IMAGE_UPLOAD_EXTENSIONS = [
".bmp",
".gif",
".jpeg",
".jpg",
".png",
".tif",
".tiff",
".webp",
];
const IMAGE_UPLOAD_MIME_TYPES = [
"image/bmp",
"image/gif",
"image/jpeg",
"image/png",
"image/tiff",
"image/webp",
];
function normalizeUploadPolicy(data: unknown): KnowledgeUploadPolicy {
const payload = data as Partial<KnowledgeUploadPolicy> | null | undefined;
const extensions = Array.from(
new Set([
...(Array.isArray(payload?.extensions) ? payload.extensions : []),
...IMAGE_UPLOAD_EXTENSIONS,
]),
).sort();
const serverAccept =
typeof payload?.accept === "string"
? payload.accept
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const accept = Array.from(
new Set([...serverAccept, ...extensions, ...IMAGE_UPLOAD_MIME_TYPES]),
).join(",");
return {
extensions,
accept,
max_file_size_bytes:
typeof payload?.max_file_size_bytes === "number"
? payload.max_file_size_bytes
: 200 * 1024 * 1024,
};
}
export async function listKnowledgeBases(options?: { force?: boolean }) {
return withClientCache<KnowledgeBaseSummary[]>(
`${KNOWLEDGE_CACHE_PREFIX}list`,
async () => {
const response = await apiFetch(apiUrl("/api/v1/knowledge/list"), {
cache: "no-store",
});
const data = await response.json();
return Array.isArray(data)
? data
: Array.isArray(data?.knowledge_bases)
? data.knowledge_bases
: [];
},
{
force: options?.force,
},
);
}
export async function listRagProviders(options?: { force?: boolean }) {
return withClientCache<RagProviderSummary[]>(
`${KNOWLEDGE_CACHE_PREFIX}providers`,
async () => {
const response = await apiFetch(
apiUrl("/api/v1/knowledge/rag-providers"),
{
cache: "no-store",
},
);
const data = await response.json();
return Array.isArray(data?.providers) ? data.providers : [];
},
{
force: options?.force,
},
);
}
export async function getKnowledgeUploadPolicy(options?: { force?: boolean }) {
return withClientCache<KnowledgeUploadPolicy>(
`${KNOWLEDGE_CACHE_PREFIX}upload-policy`,
async () => {
const response = await apiFetch(
apiUrl("/api/v1/knowledge/supported-file-types"),
{
cache: "no-store",
},
);
const data = await response.json();
return normalizeUploadPolicy(data);
},
{
force: options?.force,
},
);
}
export function invalidateKnowledgeCaches() {
invalidateClientCache(KNOWLEDGE_CACHE_PREFIX);
}
const PAGEINDEX_CONFIG_PATH =
"/api/v1/knowledge/rag-pipelines/pageindex/config";
export async function getPageIndexConfig(options?: {
force?: boolean;
}): Promise<PageIndexConfig> {
return withClientCache<PageIndexConfig>(
`${KNOWLEDGE_CACHE_PREFIX}pageindex-config`,
async () => {
const response = await apiFetch(apiUrl(PAGEINDEX_CONFIG_PATH), {
cache: "no-store",
});
if (!response.ok) {
throw new Error(
await readErrorDetail(response, "Failed to read PageIndex config"),
);
}
return (await response.json()) as PageIndexConfig;
},
{ force: options?.force, ttlMs: 15_000 },
);
}
export async function updatePageIndexConfig(payload: {
/** Omit to keep the stored key, "" to clear it, any value to replace it. */
api_key?: string;
api_base_url?: string;
}): Promise<PageIndexConfig> {
const res = await apiFetch(apiUrl(PAGEINDEX_CONFIG_PATH), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(
await readErrorDetail(res, "Failed to update PageIndex config"),
);
}
// The provider list's `configured` flag depends on this; refresh it.
invalidateKnowledgeCaches();
return (await res.json()) as PageIndexConfig;
}
const LLAMAINDEX_CONFIG_PATH =
"/api/v1/knowledge/rag-pipelines/llamaindex/config";
export async function getLlamaIndexConfig(options?: {
force?: boolean;
}): Promise<LlamaIndexConfig> {
return withClientCache<LlamaIndexConfig>(
`${KNOWLEDGE_CACHE_PREFIX}llamaindex-config`,
async () => {
const response = await apiFetch(apiUrl(LLAMAINDEX_CONFIG_PATH), {
cache: "no-store",
});
if (!response.ok) {
throw new Error(
await readErrorDetail(response, "Failed to read LlamaIndex config"),
);
}
return (await response.json()) as LlamaIndexConfig;
},
{ force: options?.force, ttlMs: 15_000 },
);
}
export async function updateLlamaIndexConfig(
payload: Partial<Omit<LlamaIndexConfig, "version">>,
): Promise<LlamaIndexConfig> {
const res = await apiFetch(apiUrl(LLAMAINDEX_CONFIG_PATH), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(
await readErrorDetail(res, "Failed to update LlamaIndex config"),
);
}
invalidateKnowledgeCaches();
return (await res.json()) as LlamaIndexConfig;
}
async function getEngineConfig<T>(
provider: string,
cacheKey: string,
options?: { force?: boolean },
): Promise<T> {
return withClientCache<T>(
`${KNOWLEDGE_CACHE_PREFIX}${cacheKey}`,
async () => {
const response = await apiFetch(
apiUrl(`/api/v1/knowledge/rag-pipelines/${provider}/config`),
{ cache: "no-store" },
);
if (!response.ok) {
throw new Error(
await readErrorDetail(response, `Failed to read ${provider} config`),
);
}
return (await response.json()) as T;
},
{ force: options?.force, ttlMs: 15_000 },
);
}
async function updateEngineConfig<T>(
provider: string,
payload: Record<string, unknown>,
): Promise<T> {
const res = await apiFetch(
apiUrl(`/api/v1/knowledge/rag-pipelines/${provider}/config`),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) {
throw new Error(
await readErrorDetail(res, `Failed to update ${provider} config`),
);
}
invalidateKnowledgeCaches();
return (await res.json()) as T;
}
export const getGraphRagConfig = (options?: { force?: boolean }) =>
getEngineConfig<GraphRagConfig>("graphrag", "graphrag-config", options);
export const updateGraphRagConfig = (
payload: Partial<Omit<GraphRagConfig, "version">>,
) => updateEngineConfig<GraphRagConfig>("graphrag", payload);
export const getLightRagConfig = (options?: { force?: boolean }) =>
getEngineConfig<LightRagConfig>("lightrag", "lightrag-config", options);
export const updateLightRagConfig = (
payload: Partial<Omit<LightRagConfig, "version">>,
) => updateEngineConfig<LightRagConfig>("lightrag", payload);
export async function getEnginePreflight(
provider: string,
): Promise<EnginePreflight> {
const res = await apiFetch(
apiUrl(`/api/v1/knowledge/rag-pipelines/${provider}/preflight`),
{ cache: "no-store" },
);
if (!res.ok) {
throw new Error(await readErrorDetail(res, "Failed to check environment"));
}
return (await res.json()) as EnginePreflight;
}
export async function getEngineModelOptions(
kinds: string[],
): Promise<ModelOptionsByKind> {
const res = await apiFetch(
apiUrl(
`/api/v1/knowledge/rag-pipelines/model-options?kinds=${encodeURIComponent(
kinds.join(","),
)}`,
),
{ cache: "no-store" },
);
if (!res.ok) {
throw new Error(await readErrorDetail(res, "Failed to read model options"));
}
return (await res.json()) as ModelOptionsByKind;
}
export async function setEngineActiveModel(
kind: string,
profileId: string,
modelId: string,
): Promise<ModelKindOptions> {
const res = await apiFetch(
apiUrl("/api/v1/knowledge/rag-pipelines/active-model"),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind, profile_id: profileId, model_id: modelId }),
},
);
if (!res.ok) {
throw new Error(await readErrorDetail(res, "Failed to switch model"));
}
invalidateKnowledgeCaches();
return (await res.json()) as ModelKindOptions;
}
export async function updateRagProviderMode(
provider: string,
mode: string,
): Promise<{ provider: string; mode: string }> {
const res = await apiFetch(
apiUrl(
`/api/v1/knowledge/rag-providers/${encodeURIComponent(provider)}/mode`,
),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
},
);
if (!res.ok) {
throw new Error(
await readErrorDetail(res, "Failed to update retrieval mode"),
);
}
// The provider list's `default_mode` depends on this; refresh it.
invalidateKnowledgeCaches();
return (await res.json()) as { provider: string; mode: string };
}
function withDockerUpgradeHint(
detail: string,
status: number,
action: string,
): string {
if (status === 404 && detail.trim().toLowerCase() === "not found") {
return `${action} endpoint not found (404). The web UI may be newer than the backend API. If using Docker, pull and recreate the container, then retry.`;
}
return detail;
}
export async function listKnowledgeBaseFiles(
name: string,
options?: { force?: boolean },
): Promise<KnowledgeBaseFile[]> {
return withClientCache<KnowledgeBaseFile[]>(
`${KNOWLEDGE_CACHE_PREFIX}files:${name}`,
async () => {
const response = await apiFetch(
apiUrl(`/api/v1/knowledge/${encodeURIComponent(name)}/files`),
{ cache: "no-store" },
);
if (!response.ok) {
const detail = await readErrorDetail(
response,
`Failed to list files (${response.status})`,
);
throw new Error(
withDockerUpgradeHint(
detail,
response.status,
"Knowledge file listing",
),
);
}
const data = await response.json();
return Array.isArray(data?.files) ? data.files : [];
},
{ force: options?.force, ttlMs: 15_000 },
);
}
/** Build the `/api/v1/...` path for a raw KB file (caller can pass to apiUrl()). */
export function knowledgeBaseFilePath(
kbName: string,
filename: string,
): string {
return `/api/v1/knowledge/${encodeURIComponent(kbName)}/files/${filename
.split("/")
.map(encodeURIComponent)
.join("/")}`;
}
/** Build the `/api/v1/...` path for extracted plain-text preview of a raw KB file. */
export function knowledgeBaseFilePreviewTextPath(
kbName: string,
filename: string,
): string {
return `/api/v1/knowledge/${encodeURIComponent(kbName)}/file-preview-text/${filename
.split("/")
.map(encodeURIComponent)
.join("/")}`;
}
export interface KnowledgeTaskResponse {
task_id?: string;
message?: string;
noop?: boolean;
}
async function readErrorDetail(
res: Response,
fallback: string,
): Promise<string> {
try {
const body = await res.json();
if (body?.detail) return String(body.detail);
} catch {
// body wasn't JSON; fall through
}
return fallback;
}
// A folder upload's File objects carry `webkitRelativePath` (e.g.
// "Papers/2024/a.pdf"); single-file picks leave it "". We forward it as
// `rel_paths` so the backend preserves the folder layout under raw/.
function appendFilesWithPaths(form: FormData, files: File[]): void {
files.forEach((file) => {
form.append("files", file);
form.append("rel_paths", file.webkitRelativePath || "");
});
}
export async function createKnowledgeBase(payload: {
name: string;
provider: string;
files: File[];
}): Promise<KnowledgeTaskResponse> {
const form = new FormData();
form.append("name", payload.name);
form.append("rag_provider", payload.provider);
appendFilesWithPaths(form, payload.files);
const res = await apiFetch(apiUrl("/api/v1/knowledge/create"), {
method: "POST",
body: form,
});
if (!res.ok) {
throw new Error(
await readErrorDetail(res, "Failed to create knowledge base"),
);
}
invalidateKnowledgeCaches();
return (await res.json()) as KnowledgeTaskResponse;
}
export async function connectObsidianVault(payload: {
name: string;
vaultPath: string;
}): Promise<{ status: string; name: string; vault_path: string }> {
const res = await apiFetch(apiUrl("/api/v1/knowledge/connect-obsidian"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: payload.name, vault_path: payload.vaultPath }),
});
if (!res.ok) {
throw new Error(
await readErrorDetail(res, "Failed to connect Obsidian vault"),
);
}
invalidateKnowledgeCaches();
return (await res.json()) as {
status: string;
name: string;
vault_path: string;
};
}
export interface LinkedFolderProbe {
/** Whether the folder holds a ready index for the chosen engine. */
ok: boolean;
provider: string;
external_path: string;
version: string | null;
doc_count: number | null;
embedding: {
/** null when compatibility could not be verified. */
compatible: boolean | null;
index_model: string | null;
current_model: string | null;
};
warnings: string[];
/** Set when the folder cannot be linked at all (no index, wrong engine, …). */
error: string | null;
}
export async function probeLinkedFolder(payload: {
folderPath: string;
provider: string;
}): Promise<LinkedFolderProbe> {
const res = await apiFetch(apiUrl("/api/v1/knowledge/probe-folder"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
folder_path: payload.folderPath,
rag_provider: payload.provider,
}),
});
if (!res.ok) {
throw new Error(await readErrorDetail(res, "Failed to inspect folder"));
}
return (await res.json()) as LinkedFolderProbe;
}
export async function connectLinkedFolder(payload: {
name: string;
folderPath: string;
provider: string;
}): Promise<{
status: string;
name: string;
external_path: string;
rag_provider: string;
warnings: string[];
}> {
const res = await apiFetch(apiUrl("/api/v1/knowledge/connect-folder"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: payload.name,
folder_path: payload.folderPath,
rag_provider: payload.provider,
}),
});
if (!res.ok) {
throw new Error(await readErrorDetail(res, "Failed to link folder"));
}
invalidateKnowledgeCaches();
return (await res.json()) as {
status: string;
name: string;
external_path: string;
rag_provider: string;
warnings: string[];
};
}
export interface LightRagServerProbe {
/** Reachable, a LightRAG server, and (if required) the API key is accepted. */
ok: boolean;
base_url: string;
reachable: boolean;
auth_required: boolean;
auth_ok: boolean;
core_version: string | null;
api_version: string | null;
/** Set when the server can't be connected (unreachable, bad key, …). */
error: string | null;
}
export async function probeLightRagServer(payload: {
serverUrl: string;
apiKey?: string;
}): Promise<LightRagServerProbe> {
const res = await apiFetch(
apiUrl("/api/v1/knowledge/probe-lightrag-server"),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
server_url: payload.serverUrl,
api_key: payload.apiKey ?? "",
}),
},
);
if (!res.ok) {
throw new Error(
await readErrorDetail(res, "Failed to reach LightRAG server"),
);
}
return (await res.json()) as LightRagServerProbe;
}
export async function connectLightRagServer(payload: {
name: string;
serverUrl: string;
apiKey?: string;
mode?: string;
}): Promise<{
status: string;
name: string;
server_url: string;
rag_provider: string;
}> {
const res = await apiFetch(
apiUrl("/api/v1/knowledge/connect-lightrag-server"),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: payload.name,
server_url: payload.serverUrl,
api_key: payload.apiKey ?? "",
search_mode: payload.mode ?? "",
}),
},
);
if (!res.ok) {
throw new Error(
await readErrorDetail(res, "Failed to connect LightRAG server"),
);
}
invalidateKnowledgeCaches();
return (await res.json()) as {
status: string;
name: string;
server_url: string;
rag_provider: string;
};
}
export async function uploadKnowledgeBaseFiles(
name: string,
files: File[],
options?: { provider?: string },
): Promise<KnowledgeTaskResponse> {
const form = new FormData();
appendFilesWithPaths(form, files);
if (options?.provider) form.append("rag_provider", options.provider);
const res = await apiFetch(
apiUrl(`/api/v1/knowledge/${encodeURIComponent(name)}/upload`),
{ method: "POST", body: form },
);
if (!res.ok) {
throw new Error(await readErrorDetail(res, "Failed to upload files"));
}
invalidateKnowledgeCaches();
return (await res.json()) as KnowledgeTaskResponse;
}
export async function createKbFolder(
name: string,
path: string,
): Promise<void> {
const res = await apiFetch(
apiUrl(`/api/v1/knowledge/${encodeURIComponent(name)}/folders`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
},
);
if (!res.ok) {
throw new Error(await readErrorDetail(res, "Failed to create folder"));
}
invalidateKnowledgeCaches();
}
export async function moveKbFile(
name: string,
source: string,
destFolder: string,
): Promise<void> {
const res = await apiFetch(
apiUrl(`/api/v1/knowledge/${encodeURIComponent(name)}/files/move`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source, dest_folder: destFolder }),
},
);
if (!res.ok) {
throw new Error(await readErrorDetail(res, "Failed to move file"));
}
invalidateKnowledgeCaches();
}
/**
* Delete a single raw document from a KB. Works even while the KB is in an
* error state, so an unparseable file can be dropped without rebuilding the
* whole base. `was_indexed` signals whether a re-index is needed to purge the
* file's vectors from retrieval.
*/
export async function deleteKbFile(
name: string,
filename: string,
): Promise<{ was_indexed: boolean }> {
const res = await apiFetch(apiUrl(knowledgeBaseFilePath(name, filename)), {
method: "DELETE",
});
if (!res.ok) {
throw new Error(await readErrorDetail(res, "Failed to delete file"));
}
invalidateKnowledgeCaches();
return (await res.json()) as { was_indexed: boolean };
}
export async function setDefaultKnowledgeBase(name: string): Promise<void> {
const res = await apiFetch(
apiUrl(`/api/v1/knowledge/default/${encodeURIComponent(name)}`),
{ method: "PUT" },
);
if (!res.ok) {
throw new Error(await readErrorDetail(res, "Failed to set default"));
}
invalidateKnowledgeCaches();
}
export async function reindexKnowledgeBase(
name: string,
): Promise<KnowledgeTaskResponse> {
const res = await apiFetch(
apiUrl(`/api/v1/knowledge/${encodeURIComponent(name)}/reindex`),
{ method: "POST" },
);
if (!res.ok) {
const detail = await readErrorDetail(
res,
`Re-index failed (${res.status})`,
);
throw new Error(
withDockerUpgradeHint(detail, res.status, "Knowledge re-index"),
);
}
invalidateKnowledgeCaches();
return (await res.json()) as KnowledgeTaskResponse;
}
export async function retryKnowledgeBase(
name: string,
): Promise<KnowledgeTaskResponse> {
const res = await apiFetch(
apiUrl(`/api/v1/knowledge/${encodeURIComponent(name)}/retry`),
{ method: "POST" },
);
if (!res.ok) {
const detail = await readErrorDetail(res, `Retry failed (${res.status})`);
throw new Error(
withDockerUpgradeHint(detail, res.status, "Knowledge retry"),
);
}
invalidateKnowledgeCaches();
return (await res.json()) as KnowledgeTaskResponse;
}
export async function deleteKnowledgeBase(name: string): Promise<void> {
const res = await apiFetch(
apiUrl(`/api/v1/knowledge/${encodeURIComponent(name)}`),
{ method: "DELETE" },
);
if (!res.ok) {
throw new Error(
await readErrorDetail(res, `Delete failed (${res.status})`),
);
}
invalidateKnowledgeCaches();
}