feat(knowledge): 知识源维护与 TXT/Markdown/PDF 导入

支持文本、Markdown 与非扫描 PDF 上传解析,展示提取文本与切块结果。
This commit is contained in:
WangShen007 committed 2026-09-29 20:35:00 +08:00
1 parent 4388e1d203
commit 53825b0cfb
23 files changed
+7545

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { KnowledgeContentChunk, KnowledgeSubPage } from '@/types/knowledge'
/** `<page>::<n>` — chunk ids written by app/knowledge/chunking.py. */
const CHUNK_ID_SUFFIX = /::\d+$/
/** `<page>_<n>` — chunk ids agno's PDF reader used to write. */
const LEGACY_CHUNK_ID_SUFFIX = /_\d+$/
/**
* Map a chunk id back to its page id by stripping the trailing chunk suffix:
* `<page>::<n>` (app/knowledge/chunking.py) or the legacy `<page>_<n>` that
* agno's PDF reader produced.
*
* Only a numeric suffix is stripped, so real underscores in a page name (e.g.
* `getting_started`) are preserved. Mirrors the backend `PAGE_ID_EXPR`
* (app/knowledge/page_editor.py) exactly, so the id derived here is the same
* `page_id` the backend expects for edit/delete and the `subpage` value it
* returns when listing sources — keep the two in sync.
*/
export function basePageId(id: string): string {
const chunked = id.replace(CHUNK_ID_SUFFIX, '')
if (chunked !== id) return chunked
return id.replace(LEGACY_CHUNK_ID_SUFFIX, '')
}
/**
* What goes between a chunk and the one before it when a page is rebuilt.
* `::n` chunks (app/knowledge/chunking.py) are verbatim slices of the page, so
* nothing: inserting a break would change the text that gets saved back.
* Legacy `_n` chunks were cut on whitespace that was dropped, so a paragraph
* break is the closest reconstruction.
*/
function chunkJoiner(chunkId: string): string {
return CHUNK_ID_SUFFIX.test(chunkId) ? '' : '\n\n'
}
function countWords(text: string): number {
const trimmed = text.trim()
return trimmed ? trimmed.split(/\s+/).length : 0
}
function laterIso(a: string | null, b: string | null): string | null {
if (!a) return b
if (!b) return a
return a > b ? a : b
}
/**
* Group a source's content chunks into sub-pages. Chunks are concatenated in
* their existing order (the backend returns them ordered by `created_at`), and
* page-level url/title/updated_at are taken from the chunk metadata.
*/
export function groupChunksIntoPages(chunks: KnowledgeContentChunk[]): KnowledgeSubPage[] {
const byPage = new Map<string, KnowledgeSubPage>()
const order: string[] = []
for (const chunk of chunks) {
const pageId = basePageId(chunk.id)
const meta = chunk.metadata || {}
const content = chunk.content || ''
let page = byPage.get(pageId)
if (!page) {
page = {
page_id: pageId,
url: typeof meta.url === 'string' && meta.url ? meta.url : pageId,
title: typeof meta.title === 'string' && meta.title ? meta.title : titleFromId(pageId),
content: '',
word_count: 0,
chunk_count: 0,
updated_at: chunk.created_at ?? null,
chunk_ids: [],
}
byPage.set(pageId, page)
order.push(pageId)
}
page.content = page.content ? `${page.content}${chunkJoiner(chunk.id)}${content}` : content
page.chunk_count += 1
page.chunk_ids.push(chunk.id)
page.updated_at = laterIso(page.updated_at, chunk.created_at ?? null)
}
for (const pageId of order) {
const page = byPage.get(pageId)!
page.word_count = countWords(page.content)
}
return order.map((pageId) => byPage.get(pageId)!)
}
/** Human-ish title fallback: last non-empty URL/path segment of the page id. */
export function titleFromId(pageId: string): string {
const cleaned = pageId.replace(/[#?].*$/, '').replace(/\/+$/, '')
const segment = cleaned.split('/').filter(Boolean).pop()
return segment || pageId
}