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
248 lines
7.1 KiB
TypeScript
248 lines
7.1 KiB
TypeScript
import type { TFunction } from "i18next";
|
|
|
|
export interface KnowledgeUploadPolicy {
|
|
extensions: string[];
|
|
accept: string;
|
|
max_file_size_bytes: number;
|
|
}
|
|
|
|
export const DEFAULT_UPLOAD_POLICY: KnowledgeUploadPolicy = {
|
|
extensions: [],
|
|
accept: "",
|
|
max_file_size_bytes: 200 * 1024 * 1024,
|
|
};
|
|
|
|
export interface ProgressInfo {
|
|
task_id?: string;
|
|
stage?: string;
|
|
message?: string;
|
|
current?: number;
|
|
total?: number;
|
|
percent?: number;
|
|
progress_percent?: number;
|
|
indexed_count?: number;
|
|
index_changed?: boolean;
|
|
index_action?: string;
|
|
}
|
|
|
|
export interface IndexVersion {
|
|
signature?: string;
|
|
model?: string;
|
|
dimension?: number;
|
|
binding?: string;
|
|
created_at?: string;
|
|
ready?: boolean;
|
|
legacy?: boolean;
|
|
}
|
|
|
|
export interface KnowledgeBase {
|
|
id?: string;
|
|
name: string;
|
|
is_default?: boolean;
|
|
status?: string;
|
|
path?: string;
|
|
metadata?: {
|
|
created_at?: string;
|
|
last_updated?: string;
|
|
last_indexed_at?: string;
|
|
last_indexed_count?: number;
|
|
last_indexed_action?: string;
|
|
rag_provider?: string;
|
|
needs_reindex?: boolean;
|
|
embedding_model?: string;
|
|
embedding_dim?: number;
|
|
embedding_mismatch?: boolean;
|
|
/** Connected-source kind (e.g. "obsidian", "subagent"); absent for ordinary indexed KBs. */
|
|
type?: string;
|
|
/** Absolute path of a connected Obsidian vault (when type === "obsidian"). */
|
|
vault_path?: string;
|
|
/** Backend of a connected subagent (when type === "subagent"): "claude_code" | "codex" | "partner". */
|
|
agent_kind?: string;
|
|
/** Bound partner id when agent_kind === "partner". */
|
|
partner_id?: string;
|
|
};
|
|
progress?: ProgressInfo;
|
|
statistics?: {
|
|
raw_documents?: number;
|
|
images?: number;
|
|
content_lists?: number;
|
|
rag_provider?: string;
|
|
rag_initialized?: boolean;
|
|
needs_reindex?: boolean;
|
|
status?: string;
|
|
progress?: ProgressInfo;
|
|
index_versions?: IndexVersion[];
|
|
active_signature?: string | null;
|
|
active_match?: boolean;
|
|
};
|
|
source?: "admin" | "user";
|
|
assigned?: boolean;
|
|
read_only?: boolean;
|
|
provenance_label?: string;
|
|
available?: boolean;
|
|
}
|
|
|
|
export interface ValidatedSelectionFile {
|
|
id: string;
|
|
file: File;
|
|
extension: string;
|
|
sizeLabel: string;
|
|
valid: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
export interface ValidatedFileSelection {
|
|
items: ValidatedSelectionFile[];
|
|
validFiles: File[];
|
|
invalidFiles: ValidatedSelectionFile[];
|
|
totalBytes: number;
|
|
}
|
|
|
|
export const formatFileSize = (bytes: number): string => {
|
|
if (bytes >= 1024 * 1024 * 1024)
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${bytes} B`;
|
|
};
|
|
|
|
export const getFileExtension = (filename: string): string => {
|
|
const index = filename.lastIndexOf(".");
|
|
return index >= 0 ? filename.slice(index).toLowerCase() : "";
|
|
};
|
|
|
|
export const selectionFileId = (file: File): string =>
|
|
`${file.name}:${file.size}:${file.lastModified}`;
|
|
|
|
export const mergeSelectedFiles = (
|
|
existing: File[],
|
|
incoming: File[],
|
|
): File[] => {
|
|
const merged = new Map<string, File>();
|
|
[...existing, ...incoming].forEach((file) => {
|
|
merged.set(selectionFileId(file), file);
|
|
});
|
|
return Array.from(merged.values());
|
|
};
|
|
|
|
const parseKnowledgeTimestamp = (value?: string): Date | null => {
|
|
if (!value) return null;
|
|
const normalized = value.includes("T") ? value : value.replace(" ", "T");
|
|
const parsed = new Date(normalized);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
};
|
|
|
|
export const formatKnowledgeTimestamp = (value?: string): string | null => {
|
|
const parsed = parseKnowledgeTimestamp(value);
|
|
return parsed ? parsed.toLocaleString() : value || null;
|
|
};
|
|
|
|
/** The retrieval engine a KB is bound to. Connected vaults badge by source. */
|
|
export const kbProvider = (kb: KnowledgeBase): string => {
|
|
if (kb.metadata?.type === "obsidian") return "obsidian";
|
|
return (
|
|
(kb.statistics?.rag_provider as string | undefined) ||
|
|
(kb.metadata?.rag_provider as string | undefined) ||
|
|
"llamaindex"
|
|
);
|
|
};
|
|
|
|
/** Source-document count for a KB, or null when unknown. */
|
|
export const kbDocCount = (kb: KnowledgeBase): number | null => {
|
|
const raw = kb.statistics?.raw_documents;
|
|
if (typeof raw === "number") return raw;
|
|
const indexed = kb.metadata?.last_indexed_count;
|
|
return typeof indexed === "number" ? indexed : null;
|
|
};
|
|
|
|
export const resolveKbStatus = (kb: KnowledgeBase): string =>
|
|
kb.status ?? kb.statistics?.status ?? "unknown";
|
|
|
|
export const kbNeedsReindex = (kb: KnowledgeBase): boolean =>
|
|
Boolean(kb.statistics?.needs_reindex) ||
|
|
resolveKbStatus(kb) === "needs_reindex";
|
|
|
|
export const kbIsUploadable = (kb: KnowledgeBase): boolean =>
|
|
resolveKbStatus(kb) === "ready" && !kbNeedsReindex(kb);
|
|
|
|
export const kbCanReindex = (kb: KnowledgeBase): boolean => {
|
|
const status = resolveKbStatus(kb);
|
|
const hasSourceFiles =
|
|
typeof kb.statistics?.raw_documents === "number"
|
|
? kb.statistics.raw_documents > 0
|
|
: true;
|
|
if (!hasSourceFiles) return false;
|
|
if (status === "error") return true;
|
|
return (
|
|
Boolean(kb.statistics?.needs_reindex) ||
|
|
kb.statistics?.active_match === false
|
|
);
|
|
};
|
|
|
|
const LIVE_PROGRESS_STAGES = new Set([
|
|
"initializing",
|
|
"starting",
|
|
"processing_documents",
|
|
"processing_file",
|
|
]);
|
|
|
|
export const kbHasLiveProgress = (kb: KnowledgeBase): boolean => {
|
|
const status = resolveKbStatus(kb);
|
|
if (status === "ready" || status === "error" || status === "needs_reindex") {
|
|
return false;
|
|
}
|
|
const stage = kb.progress?.stage;
|
|
if (!stage) return false;
|
|
if (stage === "completed" || stage === "error") return false;
|
|
return LIVE_PROGRESS_STAGES.has(stage);
|
|
};
|
|
|
|
export const resolveProgressPercent = (progress?: ProgressInfo): number => {
|
|
const directPercent = progress?.progress_percent ?? progress?.percent;
|
|
if (typeof directPercent === "number") return directPercent;
|
|
|
|
const current = progress?.current ?? 0;
|
|
const total = progress?.total ?? 0;
|
|
if (!current || !total) return 0;
|
|
return Math.round((current / total) * 100);
|
|
};
|
|
|
|
export function validateFiles(
|
|
files: File[],
|
|
uploadPolicy: KnowledgeUploadPolicy,
|
|
t: TFunction,
|
|
): ValidatedFileSelection {
|
|
const allowedExtensions = new Set(
|
|
uploadPolicy.extensions.map((ext) => ext.toLowerCase()),
|
|
);
|
|
|
|
const items = files.map((file) => {
|
|
const extension = getFileExtension(file.name);
|
|
let error: string | null = null;
|
|
|
|
if (allowedExtensions.size > 0 && !allowedExtensions.has(extension)) {
|
|
error = t("Unsupported file type");
|
|
} else if (file.size > uploadPolicy.max_file_size_bytes) {
|
|
error = t("This file exceeds the maximum size of {{size}}.", {
|
|
size: formatFileSize(uploadPolicy.max_file_size_bytes),
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: selectionFileId(file),
|
|
file,
|
|
extension: extension || t("No extension"),
|
|
sizeLabel: formatFileSize(file.size),
|
|
valid: !error,
|
|
error,
|
|
};
|
|
});
|
|
|
|
return {
|
|
items,
|
|
validFiles: items.filter((item) => item.valid).map((item) => item.file),
|
|
invalidFiles: items.filter((item) => !item.valid),
|
|
totalBytes: files.reduce((total, file) => total + file.size, 0),
|
|
};
|
|
}
|