Enhance knowledge base functionality and integrate S3 storage support

- Add new models for `KnowledgeDocument` and `KnowledgeChunk` to manage document ingestion and chunking.
- Implement S3-compatible storage integration for knowledge documents, allowing for file uploads and retrieval.
- Introduce API endpoints for managing knowledge bases and documents, including creation, deletion, and searching.
- Update frontend components to support knowledge base configuration and document management, improving user interaction.
- Enhance backend services for knowledge processing and retrieval, ensuring robust handling of document statuses and errors.
This commit is contained in:
Xin Wang
2026-07-12 13:58:47 +08:00
parent 01c563a3e7
commit de58f30014
21 changed files with 995 additions and 34 deletions

View File

@@ -2,3 +2,6 @@
PUBLIC_IP=182.92.86.220
TURN_SECRET=change-me-to-a-long-random-string
TURN_URLS=turn:182.92.86.220:3478?transport=udp,turn:182.92.86.220:3478?transport=tcp
S3_ACCESS_KEY=rustfsadmin
S3_SECRET_KEY=rustfsadmin
S3_BUCKET=ai-video

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Local state created by docker-compose services.
/data/
/logs/

View File

@@ -12,6 +12,15 @@ PORT=8000
# 前端开发地址,允许跨域(公网部署时加上实际前端 origin)
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# ---- RustFS / S3-compatible storage ----
S3_ENDPOINT_URL=http://localhost:9000
S3_ACCESS_KEY=rustfsadmin
S3_SECRET_KEY=rustfsadmin
S3_BUCKET=ai-video
S3_REGION=us-east-1
KNOWLEDGE_MAX_FILE_BYTES=20971520
KNOWLEDGE_TOP_K=5
# ---- WebRTC TURN(公网跨网语音预览;本地开发留空) ----
# 与 docker compose --profile remote 的 coturn 配套。云安全组需放行 UDP 3478 与 49152-49200。
# PUBLIC_IP 填云主机公网 IP(compose 里给 coturn --external-ip 用,见项目根 .env)。

View File

@@ -134,8 +134,9 @@ docker compose up # 前台起 pg + api(:8000)+ ui(:3030),日志
docker compose up -d # 后台起;看日志 docker compose logs -f api
docker compose down # 停止全部
# 可选:对象存储 / 后台任务
docker compose --profile data up # + rustfs(S3) / redis
# 知识库依赖 RustFS默认 compose 会一起启动。Redis 仍是可选服务。
docker compose up -d postgres rustfs api ui
docker compose --profile data up -d redis
# 可选:公网部署(WebRTC 需 TURN)
docker compose --profile remote up -d
```
@@ -143,6 +144,20 @@ docker compose --profile remote up -d
> 首次 `up` 会构建 api 镜像(装全量 `requirements.txt`,含 pipecat,较慢)。
> 之后改 Python 代码靠 `--reload` 热更新,不用重建;只有改 `requirements.txt` 才 `docker compose build api`。
## 极简知识库 MVP
知识库入口为前端「组件 / 知识库」。使用前先在「组件 / 模型」配置并启用
一个 Embedding 资源,然后:
1. 创建知识库并选择 Embedding 模型。
2. 上传 PDF、DOCX、TXT、Markdown、CSV、JSON 或 HTML或者直接添加文字。
3. 文档状态会从 `pending` 变为 `processing`,最终进入 `ready``failed`
4. 失败记录会保留,可在页面重试;完成后可预览分块并使用「检索测试」验证召回。
5. 在提示词助手的 pipeline 模式下选择知识库。每轮 LLM 推理前会自动检索并注入相关片段。
当前后台处理使用 FastAPI 进程内任务,适合 MVP。服务重启时未完成任务会转为失败
可在页面重试;需要多实例或高吞吐时再迁移到 Redis/ARQ worker。
## 待联调 / TODO
- [ ] 联调 Pipecat 1.3.0 语音链路与各 OpenAI 兼容服务

View File

@@ -17,6 +17,7 @@ from contextlib import asynccontextmanager
import settings
import uvicorn
from db.session import sync_default_tools, sync_interface_definitions
from services.knowledge import recover_interrupted_documents
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -38,6 +39,7 @@ from routes import (
async def lifespan(_app: FastAPI):
await sync_interface_definitions()
await sync_default_tools()
await recover_interrupted_documents()
yield

View File

@@ -22,6 +22,7 @@ from sqlalchemy import (
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from pgvector.sqlalchemy import Vector
class Base(DeclarativeBase):
@@ -93,6 +94,49 @@ class KnowledgeBase(Base):
)
class KnowledgeDocument(Base):
"""A file or pasted text ingested into one knowledge base."""
__tablename__ = "knowledge_documents"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
knowledge_base_id: Mapped[str] = mapped_column(
String(40), ForeignKey("knowledge_bases.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(255))
source_type: Mapped[str] = mapped_column(String(16)) # file|text
mime_type: Mapped[str] = mapped_column(String(128), default="text/plain")
storage_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(16), default="processing")
error_message: Mapped[str] = mapped_column(String(2048), default="")
chunk_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class KnowledgeChunk(Base):
"""Searchable text chunk; Vector has no fixed dimension to support configured models."""
__tablename__ = "knowledge_chunks"
__table_args__ = (
UniqueConstraint("document_id", "chunk_index", name="uq_knowledge_chunk_position"),
)
id: Mapped[str] = mapped_column(String(40), primary_key=True)
knowledge_base_id: Mapped[str] = mapped_column(
String(40), ForeignKey("knowledge_bases.id", ondelete="CASCADE"), index=True
)
document_id: Mapped[str] = mapped_column(
String(40), ForeignKey("knowledge_documents.id", ondelete="CASCADE"), index=True
)
chunk_index: Mapped[int] = mapped_column(Integer)
content: Mapped[str] = mapped_column(Text)
embedding: Mapped[list[float]] = mapped_column(Vector())
class Assistant(Base):
"""助手(单表,无版本化)。type 为可变普通列,5 种类型共用此表。

View File

@@ -0,0 +1,55 @@
"""add knowledge documents and vector chunks
Revision ID: 20260712_0005
Revises: 20260712_0004
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from pgvector.sqlalchemy import Vector
revision: str = "20260712_0005"
down_revision: str | Sequence[str] | None = "20260712_0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
op.create_table(
"knowledge_documents",
sa.Column("id", sa.String(40), primary_key=True),
sa.Column("knowledge_base_id", sa.String(40), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("source_type", sa.String(16), nullable=False),
sa.Column("mime_type", sa.String(128), server_default="text/plain", nullable=False),
sa.Column("storage_key", sa.String(1024), nullable=True),
sa.Column("size_bytes", sa.Integer(), server_default="0", nullable=False),
sa.Column("status", sa.String(16), server_default="processing", nullable=False),
sa.Column("error_message", sa.String(2048), server_default="", nullable=False),
sa.Column("chunk_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["knowledge_base_id"], ["knowledge_bases.id"], ondelete="CASCADE"),
)
op.create_index("ix_knowledge_documents_knowledge_base_id", "knowledge_documents", ["knowledge_base_id"])
op.create_table(
"knowledge_chunks",
sa.Column("id", sa.String(40), primary_key=True),
sa.Column("knowledge_base_id", sa.String(40), nullable=False),
sa.Column("document_id", sa.String(40), nullable=False),
sa.Column("chunk_index", sa.Integer(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("embedding", Vector(), nullable=False),
sa.ForeignKeyConstraint(["knowledge_base_id"], ["knowledge_bases.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["document_id"], ["knowledge_documents.id"], ondelete="CASCADE"),
sa.UniqueConstraint("document_id", "chunk_index", name="uq_knowledge_chunk_position"),
)
op.create_index("ix_knowledge_chunks_knowledge_base_id", "knowledge_chunks", ["knowledge_base_id"])
op.create_index("ix_knowledge_chunks_document_id", "knowledge_chunks", ["document_id"])
def downgrade() -> None:
op.drop_table("knowledge_chunks")
op.drop_table("knowledge_documents")

View File

@@ -75,6 +75,7 @@ class AssistantConfig(BaseModel):
# Prompt assistant reusable tools. Execution remains type-specific in the pipeline.
tools: list[RuntimeTool] = Field(default_factory=list)
knowledge_base_id: str | None = None
# workflow 类型:节点图(nodes/edges)。非 workflow 为空,引擎据此决定是否启用。
graph: dict = {}

View File

@@ -24,3 +24,8 @@ sqlalchemy[asyncio]>=2.0
alembic>=1.13
asyncpg
greenlet # SQLAlchemy 异步运行时必需(部分平台不会自动带上)
pgvector
boto3
python-multipart
pypdf
python-docx

View File

@@ -6,6 +6,7 @@ from db.models import (
Assistant,
AssistantModelBinding,
AssistantToolBinding,
KnowledgeBase,
ModelResource,
Tool,
)
@@ -52,6 +53,14 @@ async def _validate_vision_model(
raise HTTPException(400, "视觉模型必须支持图片输入")
async def _validate_knowledge_base(session: AsyncSession, body: AssistantUpsert) -> None:
if body.runtime_mode != "pipeline" or body.type not in {"prompt", "workflow"}:
body.knowledge_base_id = None
return
if body.knowledge_base_id and not await session.get(KnowledgeBase, body.knowledge_base_id):
raise HTTPException(400, "知识库不存在")
async def _sync_bindings(
session: AsyncSession, assistant_id: str, resource_ids: dict[str, str]
) -> None:
@@ -166,6 +175,7 @@ async def create_assistant(
):
_validate_workflow(body)
await _validate_vision_model(session, body)
await _validate_knowledge_base(session, body)
data = body.model_dump()
resource_ids = data.pop("model_resource_ids")
tool_ids = data.pop("tool_ids")
@@ -235,6 +245,7 @@ async def update_assistant(
raise HTTPException(404, "助手不存在")
_validate_workflow(body)
await _validate_vision_model(session, body)
await _validate_knowledge_base(session, body)
data = body.model_dump()
resource_ids = data.pop("model_resource_ids")
tool_ids = data.pop("tool_ids")

View File

@@ -6,11 +6,16 @@ KB 自身引用一个 Embedding 模型资源。被助手引用时禁止删除
import uuid
from db.models import KnowledgeBase, ModelResource
from db.models import Assistant, KnowledgeBase, KnowledgeChunk, KnowledgeDocument, ModelResource
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import KnowledgeBaseOut, KnowledgeBaseUpsert
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile
from schemas import (
KnowledgeBaseOut, KnowledgeBaseUpsert, KnowledgeChunkOut, KnowledgeDocumentOut,
KnowledgeSearchIn, KnowledgeTextIn,
)
from services.auth import require_admin
from services.knowledge import create_document, delete_storage_object, process_document, search
import settings
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -43,6 +48,16 @@ def _to_out(kb: KnowledgeBase) -> KnowledgeBaseOut:
)
def _document_out(document: KnowledgeDocument) -> KnowledgeDocumentOut:
return KnowledgeDocumentOut(
id=document.id, knowledge_base_id=document.knowledge_base_id,
name=document.name, source_type=document.source_type, mime_type=document.mime_type,
size_bytes=document.size_bytes, status=document.status,
error_message=document.error_message, chunk_count=document.chunk_count,
created_at=document.created_at.isoformat() if document.created_at else None,
)
@router.get("", response_model=list[KnowledgeBaseOut])
async def list_knowledge_bases(session: AsyncSession = Depends(get_session)):
rows = (
@@ -83,6 +98,12 @@ async def update_knowledge_base(
if not kb:
raise HTTPException(404, "知识库不存在")
await _validate_embedding_resource(session, body.embedding_model_resource_id)
if kb.embedding_model_resource_id != body.embedding_model_resource_id:
has_document = (await session.execute(
select(KnowledgeDocument.id).where(KnowledgeDocument.knowledge_base_id == kb_id).limit(1)
)).scalar_one_or_none()
if has_document:
raise HTTPException(409, "知识库已有文档,不能更换 Embedding 模型;请新建知识库")
for k, v in body.model_dump().items():
setattr(kb, k, v)
await session.commit()
@@ -97,6 +118,19 @@ async def delete_knowledge_base(
kb = await session.get(KnowledgeBase, kb_id)
if not kb:
raise HTTPException(404, "知识库不存在")
referenced = (await session.execute(
select(Assistant.id).where(Assistant.knowledge_base_id == kb_id).limit(1)
)).scalar_one_or_none()
if referenced:
raise HTTPException(409, "知识库正被助手引用,无法删除")
documents = (await session.execute(
select(KnowledgeDocument).where(KnowledgeDocument.knowledge_base_id == kb_id)
)).scalars().all()
for document in documents:
try:
await delete_storage_object(document)
except Exception:
pass
try:
await session.delete(kb)
await session.commit()
@@ -105,3 +139,119 @@ async def delete_knowledge_base(
await session.rollback()
raise HTTPException(409, "知识库正被助手引用,无法删除")
return {"ok": True}
@router.get("/{kb_id}/documents", response_model=list[KnowledgeDocumentOut])
async def list_documents(kb_id: str, session: AsyncSession = Depends(get_session)):
if not await session.get(KnowledgeBase, kb_id):
raise HTTPException(404, "知识库不存在")
rows = (await session.execute(
select(KnowledgeDocument).where(KnowledgeDocument.knowledge_base_id == kb_id)
.order_by(KnowledgeDocument.created_at.desc())
)).scalars().all()
return [_document_out(row) for row in rows]
@router.post("/{kb_id}/documents/text", response_model=KnowledgeDocumentOut, status_code=202)
async def add_text(
kb_id: str, body: KnowledgeTextIn, background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session),
):
kb = await session.get(KnowledgeBase, kb_id)
if not kb:
raise HTTPException(404, "知识库不存在")
try:
document = await create_document(
session, kb, name=body.name, source_type="text",
raw_data=body.content.encode("utf-8"), mime_type="text/plain",
)
background_tasks.add_task(process_document, document.id)
return _document_out(document)
except Exception as exc:
raise HTTPException(400, f"文字入库失败: {exc}") from exc
@router.post("/{kb_id}/documents/file", response_model=KnowledgeDocumentOut, status_code=202)
async def add_file(
kb_id: str,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
name: str = Form(default=""),
session: AsyncSession = Depends(get_session),
):
kb = await session.get(KnowledgeBase, kb_id)
if not kb:
raise HTTPException(404, "知识库不存在")
data = await file.read(settings.KNOWLEDGE_MAX_FILE_BYTES + 1)
if len(data) > settings.KNOWLEDGE_MAX_FILE_BYTES:
limit_mb = settings.KNOWLEDGE_MAX_FILE_BYTES // 1024 // 1024
raise HTTPException(413, f"文件不能超过 {limit_mb} MB")
filename = file.filename or "未命名文件"
try:
document = await create_document(
session, kb, name=name.strip() or filename, source_type="file",
raw_data=data, mime_type=file.content_type or "application/octet-stream",
)
background_tasks.add_task(process_document, document.id)
return _document_out(document)
except Exception as exc:
raise HTTPException(400, f"文件入库失败: {exc}") from exc
@router.delete("/{kb_id}/documents/{document_id}")
async def delete_document(kb_id: str, document_id: str, session: AsyncSession = Depends(get_session)):
document = await session.get(KnowledgeDocument, document_id)
if not document or document.knowledge_base_id != kb_id:
raise HTTPException(404, "文档不存在")
try:
await delete_storage_object(document)
finally:
await session.delete(document)
await session.commit()
return {"ok": True}
@router.post(
"/{kb_id}/documents/{document_id}/retry",
response_model=KnowledgeDocumentOut,
status_code=202,
)
async def retry_document(
kb_id: str, document_id: str, background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session),
):
document = await session.get(KnowledgeDocument, document_id)
if not document or document.knowledge_base_id != kb_id:
raise HTTPException(404, "文档不存在")
if document.status in {"pending", "processing"}:
raise HTTPException(409, "文档正在处理中")
document.status = "pending"
document.error_message = ""
await session.commit()
await session.refresh(document)
background_tasks.add_task(process_document, document.id)
return _document_out(document)
@router.get("/{kb_id}/documents/{document_id}/chunks", response_model=list[KnowledgeChunkOut])
async def list_document_chunks(
kb_id: str, document_id: str, session: AsyncSession = Depends(get_session)
):
document = await session.get(KnowledgeDocument, document_id)
if not document or document.knowledge_base_id != kb_id:
raise HTTPException(404, "文档不存在")
chunks = (await session.execute(
select(KnowledgeChunk).where(KnowledgeChunk.document_id == document_id)
.order_by(KnowledgeChunk.chunk_index).limit(100)
)).scalars().all()
return [KnowledgeChunkOut(id=row.id, chunk_index=row.chunk_index, content=row.content) for row in chunks]
@router.post("/{kb_id}/search")
async def search_knowledge_base(
kb_id: str, body: KnowledgeSearchIn, session: AsyncSession = Depends(get_session)
):
try:
return await search(session, kb_id, body.query, body.top_k)
except Exception as exc:
raise HTTPException(400, f"检索失败: {exc}") from exc

View File

@@ -192,6 +192,35 @@ class KnowledgeBaseOut(KnowledgeBaseUpsert):
updated_at: str | None = None
class KnowledgeTextIn(CamelModel):
name: str = Field(min_length=1, max_length=255)
content: str = Field(min_length=1)
class KnowledgeDocumentOut(CamelModel):
id: str
knowledge_base_id: str
name: str
source_type: str
mime_type: str
size_bytes: int
status: str
error_message: str = ""
chunk_count: int
created_at: str | None = None
class KnowledgeSearchIn(CamelModel):
query: str = Field(min_length=1)
top_k: int = Field(default=5, ge=1, le=20)
class KnowledgeChunkOut(CamelModel):
id: str
chunk_index: int
content: str
# ---------- 接口定义驱动的统一模型资源 ----------
class InterfaceDefinitionOut(CamelModel):
interface_type: str

View File

@@ -137,6 +137,7 @@ async def resolve_runtime_config(
enableInterrupt=assistant.enable_interrupt,
turnConfig=assistant.turn_config or {},
tools=await _tools_for(session, assistant),
knowledge_base_id=assistant.knowledge_base_id,
# workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话
graph=(assistant.graph or {}) if assistant.type == "workflow" else {},
# 外部托管类型连接信息(DB 存真 key,直接注入)

View File

@@ -0,0 +1,235 @@
"""Minimal knowledge ingestion and retrieval, isolated from the voice pipeline."""
import asyncio
from io import BytesIO
from pathlib import Path
import re
import uuid
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from docx import Document as DocxDocument
from openai import AsyncOpenAI
from pypdf import PdfReader
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
import settings
from db.models import KnowledgeBase, KnowledgeChunk, KnowledgeDocument, ModelResource
def _s3_client():
return boto3.client(
"s3",
endpoint_url=settings.S3_ENDPOINT_URL,
aws_access_key_id=settings.S3_ACCESS_KEY,
aws_secret_access_key=settings.S3_SECRET_KEY,
region_name=settings.S3_REGION,
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
def _ensure_bucket_and_put(key: str, data: bytes, mime_type: str) -> None:
client = _s3_client()
try:
client.head_bucket(Bucket=settings.S3_BUCKET)
except ClientError as exc:
status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
if status != 404:
raise
client.create_bucket(Bucket=settings.S3_BUCKET)
client.put_object(Bucket=settings.S3_BUCKET, Key=key, Body=data, ContentType=mime_type)
def _delete_object(key: str) -> None:
_s3_client().delete_object(Bucket=settings.S3_BUCKET, Key=key)
def _get_object(key: str) -> bytes:
response = _s3_client().get_object(Bucket=settings.S3_BUCKET, Key=key)
return response["Body"].read()
def extract_text(filename: str, data: bytes) -> str:
suffix = Path(filename).suffix.lower()
if suffix == ".pdf":
return "\n".join(page.extract_text() or "" for page in PdfReader(BytesIO(data)).pages)
if suffix == ".docx":
document = DocxDocument(BytesIO(data))
return "\n".join(paragraph.text for paragraph in document.paragraphs)
if suffix in {".txt", ".md", ".csv", ".json", ".html", ".htm"}:
return data.decode("utf-8", errors="replace")
raise ValueError("暂仅支持 PDF、DOCX、TXT、Markdown、CSV、JSON 和 HTML 文件")
def split_text(text: str, chunk_size: int = 800, overlap: int = 120) -> list[str]:
normalized = re.sub(r"[ \t]+", " ", text).strip()
if not normalized:
return []
chunks: list[str] = []
start = 0
while start < len(normalized):
end = min(start + chunk_size, len(normalized))
if end < len(normalized):
boundary = max(normalized.rfind("\n", start, end), normalized.rfind("", start, end))
if boundary > start + chunk_size // 2:
end = boundary + 1
chunks.append(normalized[start:end].strip())
if end >= len(normalized):
break
start = max(end - overlap, start + 1)
return [chunk for chunk in chunks if chunk]
async def _embedding_resource(session: AsyncSession, kb: KnowledgeBase) -> ModelResource:
resource = (
await session.get(ModelResource, kb.embedding_model_resource_id)
if kb.embedding_model_resource_id
else None
)
if not resource or resource.capability != "Embedding" or not resource.enabled:
raise ValueError("请先为知识库选择已启用的 Embedding 模型")
return resource
async def _embed(session: AsyncSession, kb: KnowledgeBase, texts: list[str]) -> list[list[float]]:
resource = await _embedding_resource(session, kb)
values, secrets = resource.values or {}, resource.secrets or {}
client = AsyncOpenAI(
api_key=str(secrets.get("apiKey") or ""),
base_url=str(values.get("apiUrl") or "") or None,
)
try:
response = await client.embeddings.create(
model=str(values.get("modelId") or "text-embedding-3-small"), input=texts
)
return [item.embedding for item in response.data]
finally:
await client.close()
async def create_document(
session: AsyncSession,
kb: KnowledgeBase,
*,
name: str,
source_type: str,
raw_data: bytes,
mime_type: str = "text/plain",
) -> KnowledgeDocument:
document_id = f"doc_{uuid.uuid4().hex[:12]}"
safe_name = Path(name).name
extension = ".txt" if source_type == "text" else Path(safe_name).suffix
storage_key = f"knowledge/{kb.id}/{document_id}/source{extension}"
await asyncio.to_thread(_ensure_bucket_and_put, storage_key, raw_data, mime_type)
document = KnowledgeDocument(
id=document_id,
knowledge_base_id=kb.id,
name=name,
source_type=source_type,
mime_type=mime_type,
storage_key=storage_key,
size_bytes=len(raw_data),
status="pending",
)
session.add(document)
await session.commit()
await session.refresh(document)
return document
async def process_document(document_id: str) -> None:
"""Process one persisted source. Safe to call again after a failure."""
from db.session import SessionLocal
async with SessionLocal() as session:
document = await session.get(KnowledgeDocument, document_id)
if not document:
return
kb = await session.get(KnowledgeBase, document.knowledge_base_id)
if not kb or not document.storage_key:
return
document.status = "processing"
document.error_message = ""
document.chunk_count = 0
await session.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
await session.commit()
try:
data = await asyncio.to_thread(_get_object, document.storage_key)
text = (
data.decode("utf-8", errors="replace")
if document.source_type == "text"
else await asyncio.to_thread(extract_text, document.name, data)
)
chunks = split_text(text)
if not chunks:
raise ValueError("文档中没有可入库的文字")
embeddings: list[list[float]] = []
for start in range(0, len(chunks), 64):
embeddings.extend(await _embed(session, kb, chunks[start : start + 64]))
if len(embeddings) != len(chunks):
raise ValueError("Embedding 服务返回的向量数量与分块数量不一致")
for index, (content, embedding) in enumerate(zip(chunks, embeddings)):
session.add(KnowledgeChunk(
id=f"chunk_{uuid.uuid4().hex[:12]}", knowledge_base_id=kb.id,
document_id=document.id, chunk_index=index, content=content, embedding=embedding,
))
document.chunk_count = len(chunks)
document.status = "ready"
await session.commit()
except Exception as exc:
await session.rollback()
document = await session.get(KnowledgeDocument, document_id)
if document:
document.status = "failed"
document.error_message = str(exc)[:2048]
document.chunk_count = 0
await session.commit()
async def recover_interrupted_documents() -> None:
"""Make restart-interrupted work visible and retryable."""
from db.session import SessionLocal
async with SessionLocal() as session:
await session.execute(
update(KnowledgeDocument)
.where(KnowledgeDocument.status.in_(["pending", "processing"]))
.values(status="failed", error_message="服务重启导致处理被中断,请点击重试")
)
await session.commit()
async def search(session: AsyncSession, kb_id: str, query: str, top_k: int | None = None) -> list[dict]:
kb = await session.get(KnowledgeBase, kb_id)
if not kb:
return []
has_chunks = (await session.execute(
select(KnowledgeChunk.id)
.join(KnowledgeDocument, KnowledgeDocument.id == KnowledgeChunk.document_id)
.where(KnowledgeChunk.knowledge_base_id == kb_id, KnowledgeDocument.status == "ready")
.limit(1)
)).scalar_one_or_none()
if not has_chunks:
return []
query_embedding = (await _embed(session, kb, [query]))[0]
distance = KnowledgeChunk.embedding.cosine_distance(query_embedding)
rows = (await session.execute(
select(KnowledgeChunk, KnowledgeDocument.name, distance.label("distance"))
.join(KnowledgeDocument, KnowledgeDocument.id == KnowledgeChunk.document_id)
.where(KnowledgeChunk.knowledge_base_id == kb_id, KnowledgeDocument.status == "ready")
.order_by(distance)
.limit(top_k or settings.KNOWLEDGE_TOP_K)
)).all()
return [
{"content": chunk.content, "document": name, "score": round(max(0.0, 1.0 - float(dist)), 4)}
for chunk, name, dist in rows
]
async def delete_storage_object(document: KnowledgeDocument) -> None:
if document.storage_key:
await asyncio.to_thread(_delete_object, document.storage_key)

View File

@@ -27,6 +27,8 @@ from services.pipecat.service_factory import (
create_stt,
create_tts,
)
from db.session import SessionLocal
from services.knowledge import search as search_knowledge
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
@@ -36,6 +38,7 @@ from pipecat.frames.frames import (
InterruptionFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMContextFrame,
LLMTextFrame,
LLMMessagesAppendFrame,
OutputTransportMessageUrgentFrame,
@@ -79,6 +82,12 @@ VISION_ANALYSIS_SYSTEM_PROMPT = (
"你是一个视觉理解模型。请只根据图片内容和用户问题给出准确、简洁的中文观察结果。"
"如果画面不足以判断,请明确说明不确定。"
)
KNOWLEDGE_TOOL_NAME = "search_knowledge_base"
KNOWLEDGE_SYSTEM_HINT = (
"你已连接内部知识库。系统会在每轮用户问题前自动提供相关资料;"
"回答资料事实时只根据检索内容,资料不足要明确说明。"
)
KNOWLEDGE_CONTEXT_MARKER = "<!-- knowledge-context -->"
def _require(value: str, label: str) -> str:
@@ -309,6 +318,54 @@ class ConversationHistoryProcessor(FrameProcessor):
await self._recorder.record_transport_message(frame.message)
class KnowledgeRetrievalProcessor(FrameProcessor):
"""Retrieve before local LLM inference without changing Pipecat internals."""
def __init__(self, knowledge_base_id: str | None):
super().__init__()
self._knowledge_base_id = knowledge_base_id
self._last_signature = ""
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if not self._knowledge_base_id or not isinstance(frame, LLMContextFrame):
await self.push_frame(frame, direction)
return
messages = frame.context.get_messages()
user_messages = [message for message in messages if message.get("role") == "user"]
if not user_messages:
await self.push_frame(frame, direction)
return
query = str(user_messages[-1].get("content") or "").strip()
signature = f"{len(user_messages)}:{query}"
if not query or signature == self._last_signature:
await self.push_frame(frame, direction)
return
self._last_signature = signature
try:
async with SessionLocal() as session:
results = await search_knowledge(session, self._knowledge_base_id, query)
except Exception as exc:
logger.warning(f"自动知识库检索失败: {exc}")
results = []
sources = "\n\n".join(
f"[{index + 1}] 来源:{item['document']}(相关度 {item['score']}\n{item['content']}"
for index, item in enumerate(results)
) or "未检索到相关资料。"
block = f"{KNOWLEDGE_CONTEXT_MARKER}\n当前问题的知识库检索结果:\n{sources}"
system_message = next((message for message in messages if message.get("role") == "system"), None)
if system_message is None:
messages.insert(0, {"role": "system", "content": block})
else:
content = str(system_message.get("content") or "")
base = content.split(KNOWLEDGE_CONTEXT_MARKER, 1)[0].rstrip()
system_message["content"] = f"{base}\n\n{block}" if base else block
await self.push_frame(frame, direction)
class PassthroughLLMAssistantAggregator(LLMAssistantAggregator):
"""聚合 LLM 回复进上下文,同时继续把回复帧交给下游 TTS。"""
@@ -432,11 +489,12 @@ async def run_pipeline(
call_end = CallEndCoordinator(queue_call_end)
def with_vision_hint(text: str) -> str:
if not vision_enabled:
return text
if not text:
return VISION_SYSTEM_HINT
return f"{text}\n\n{VISION_SYSTEM_HINT}"
hints = []
if vision_enabled:
hints.append(VISION_SYSTEM_HINT)
if cfg.knowledge_base_id:
hints.append(KNOWLEDGE_SYSTEM_HINT)
return "\n\n".join(part for part in [text, *hints] if part)
context = LLMContext(
messages=[{"role": "system", "content": with_vision_hint(system_content)}]
@@ -460,6 +518,7 @@ async def run_pipeline(
assistant_aggregator = PassthroughLLMAssistantAggregator(context)
text_input = TextInputProcessor(should_ignore_input=lambda: call_end.ending)
vision_capture = VisionCaptureProcessor()
knowledge_retrieval = KnowledgeRetrievalProcessor(cfg.knowledge_base_id)
vision_native_mode = vision_enabled and _vision_uses_main_llm(cfg)
vision_state: dict[str, str | None] = {"client_id": None}
vision_schema = FunctionSchema(
@@ -476,6 +535,27 @@ async def run_pipeline(
},
required=["question"],
)
knowledge_schema = FunctionSchema(
name=KNOWLEDGE_TOOL_NAME,
description="在当前助手绑定的知识库中检索与问题最相关的资料片段。",
properties={
"query": {"type": "string", "description": "用于检索的完整问题或关键词"}
},
required=["query"],
)
async def search_bound_knowledge(params: FunctionCallParams):
query = str(params.arguments.get("query") or "").strip()
if not query or not cfg.knowledge_base_id:
await params.result_callback({"status": "error", "message": "检索问题为空或未绑定知识库"})
return
try:
async with SessionLocal() as session:
results = await search_knowledge(session, cfg.knowledge_base_id, query)
await params.result_callback({"status": "ok", "results": results})
except Exception as exc:
logger.exception(f"知识库检索失败: {exc}")
await params.result_callback({"status": "error", "message": "知识库检索暂时不可用"})
async def fetch_user_image(params: FunctionCallParams):
question = str(params.arguments.get("question") or "请描述当前画面。")
@@ -538,11 +618,15 @@ async def run_pipeline(
if vision_enabled:
llm.register_function(VISION_TOOL_NAME, fetch_user_image)
if cfg.knowledge_base_id:
llm.register_function(KNOWLEDGE_TOOL_NAME, search_bound_knowledge)
def set_visible_tools(schemas: list[FunctionSchema] | None = None) -> None:
tools = list(schemas or [])
if vision_enabled:
tools.append(vision_schema)
if cfg.knowledge_base_id:
tools.append(knowledge_schema)
if tools:
context.set_tools(ToolsSchema(standard_tools=tools))
else:
@@ -561,6 +645,7 @@ async def run_pipeline(
text_input,
stt,
user_aggregator,
knowledge_retrieval,
llm,
# Aggregate the streamed LLM text before TTS. On interruption,
# Pipecat commits the generated prefix immediately instead of

View File

@@ -46,3 +46,12 @@ TURN_SECRET = os.getenv("TURN_SECRET", "")
TURN_USERNAME = os.getenv("TURN_USERNAME", "")
TURN_PASSWORD = os.getenv("TURN_PASSWORD", "")
TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400"))
# ---- S3-compatible object storage (RustFS in local compose) ----
S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL", "http://localhost:9000")
S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "rustfsadmin")
S3_SECRET_KEY = os.getenv("S3_SECRET_KEY", "rustfsadmin")
S3_BUCKET = os.getenv("S3_BUCKET", "ai-video")
S3_REGION = os.getenv("S3_REGION", "us-east-1")
KNOWLEDGE_MAX_FILE_BYTES = int(os.getenv("KNOWLEDGE_MAX_FILE_BYTES", "20971520"))
KNOWLEDGE_TOP_K = int(os.getenv("KNOWLEDGE_TOP_K", "5"))

View File

@@ -0,0 +1,23 @@
import unittest
from services.knowledge import extract_text, split_text
class KnowledgeTextTest(unittest.TestCase):
def test_extracts_utf8_text(self):
self.assertEqual(extract_text("说明.txt", "你好,知识库".encode()), "你好,知识库")
def test_split_text_keeps_overlap_and_all_content(self):
text = "第一段。" * 250
chunks = split_text(text, chunk_size=120, overlap=20)
self.assertGreater(len(chunks), 1)
self.assertTrue(all(chunk for chunk in chunks))
self.assertLessEqual(max(map(len, chunks)), 120)
def test_rejects_unsupported_binary_file(self):
with self.assertRaisesRegex(ValueError, "暂仅支持"):
extract_text("archive.zip", b"data")
if __name__ == "__main__":
unittest.main()

View File

@@ -52,6 +52,10 @@ services:
# WebRTC TURN(公网部署:设 PUBLIC_IP + TURN_SECRET,并 docker compose --profile remote up)
TURN_URLS: "${TURN_URLS:-}"
TURN_SECRET: "${TURN_SECRET:-}"
S3_ENDPOINT_URL: "http://rustfs:9000"
S3_ACCESS_KEY: "${S3_ACCESS_KEY:-rustfsadmin}"
S3_SECRET_KEY: "${S3_SECRET_KEY:-rustfsadmin}"
S3_BUCKET: "${S3_BUCKET:-ai-video}"
ports:
- "8000:8000"
depends_on:
@@ -96,8 +100,8 @@ services:
# RustFS:S3 兼容对象存储(MinIO 替代,Rust 实现)
# 9000 = S3 API,9001 = Web 控制台。默认账号 rustfsadmin/rustfsadmin。
rustfs:
# image: registry.cn-hangzhou.aliyuncs.com/qiluo-images/rustfs:latest
image: rustfs/rustfs:latest
profiles: ["data"]
environment:
RUSTFS_VOLUMES: /data # 单盘单节点(开发够用)
RUSTFS_ADDRESS: 0.0.0.0:9000 # S3 API
@@ -108,10 +112,11 @@ services:
RUSTFS_ACCESS_KEY: "${S3_ACCESS_KEY:-rustfsadmin}"
RUSTFS_SECRET_KEY: "${S3_SECRET_KEY:-rustfsadmin}"
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
- "9000:9000"
- "9001:9001"
volumes:
- rustfs-data:/data
- ./data/rustfs:/data
- ./logs/rustfs:/logs
networks: [app-network]
# ---- 可选(profile: remote):WebRTC 公网穿透 ----
@@ -172,7 +177,6 @@ services:
volumes:
postgres_data:
redis_data:
rustfs-data:
networks:
app-network:

View File

@@ -664,7 +664,7 @@ export function AssistantPage(props: AssistantPageProps) {
...(form.voice ? { TTS: form.voice } : {}),
...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}),
},
knowledgeBaseId: form.knowledgeBase || null,
knowledgeBaseId: form.runtimeMode === "pipeline" ? form.knowledgeBase || null : null,
toolIds: form.toolIds,
prompt: form.prompt,
}),
@@ -1900,18 +1900,20 @@ export function AssistantPage(props: AssistantPageProps) {
/>
</SectionCard>
<SectionCard
icon={<Database size={18} />}
title="知识库配置"
description="选择助手回答时可检索的业务知识来源"
>
<ResourceSelectField
value={form.knowledgeBase}
onChange={(value) => updateForm("knowledgeBase", value)}
options={kbOptions}
noneLabel="无"
/>
</SectionCard>
{form.runtimeMode === "pipeline" && (
<SectionCard
icon={<Database size={18} />}
title="知识库配置"
description="选择助手回答时可检索的业务知识来源"
>
<ResourceSelectField
value={form.knowledgeBase}
onChange={(value) => updateForm("knowledgeBase", value)}
options={kbOptions}
noneLabel="无"
/>
</SectionCard>
)}
<SectionCard
icon={<Wrench size={18} />}

View File

@@ -1,10 +1,221 @@
import { PlaceholderPage } from "./PlaceholderPage";
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Database, Eye, FileText, Loader2, Pencil, Plus, RefreshCw,
Search, Trash2, Upload,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
knowledgeBasesApi, modelResourcesApi, type KnowledgeBase,
type KnowledgeChunk, type KnowledgeDocument, type KnowledgeSearchResult,
type ModelResource,
} from "@/lib/api";
type BaseDialogMode = "create" | "edit" | null;
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function statusLabel(status: string) {
return ({ pending: "等待处理", processing: "处理中", ready: "已完成", failed: "失败" } as Record<string, string>)[status] ?? status;
}
function statusClass(status: string) {
if (status === "ready") return "bg-emerald-500/10 text-emerald-600";
if (status === "failed") return "bg-destructive/10 text-destructive";
return "bg-amber-500/10 text-amber-600";
}
export function ComponentsKnowledgePage() {
const [bases, setBases] = useState<KnowledgeBase[]>([]);
const [models, setModels] = useState<ModelResource[]>([]);
const [selectedId, setSelectedId] = useState("");
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [baseDialog, setBaseDialog] = useState<BaseDialogMode>(null);
const [textOpen, setTextOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewTitle, setPreviewTitle] = useState("");
const [chunks, setChunks] = useState<KnowledgeChunk[]>([]);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [embeddingId, setEmbeddingId] = useState("");
const [textName, setTextName] = useState("");
const [content, setContent] = useState("");
const [documentQuery, setDocumentQuery] = useState("");
const [testQuery, setTestQuery] = useState("");
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<KnowledgeSearchResult[]>([]);
const selected = bases.find((item) => item.id === selectedId);
const filteredDocuments = useMemo(() => {
const query = documentQuery.trim().toLowerCase();
return query ? documents.filter((item) => item.name.toLowerCase().includes(query)) : documents;
}, [documentQuery, documents]);
const loadBases = useCallback(async () => {
const [nextBases, resources] = await Promise.all([
knowledgeBasesApi.list(), modelResourcesApi.list(),
]);
setBases(nextBases);
setModels(resources.filter((item) => item.capability === "Embedding" && item.enabled));
setSelectedId((current) => nextBases.some((item) => item.id === current) ? current : nextBases[0]?.id || "");
}, []);
const loadDocuments = useCallback(async (kbId: string) => {
setDocuments(await knowledgeBasesApi.documents(kbId));
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial remote data load
void loadBases().catch((cause) => setError(cause instanceof Error ? cause.message : "加载失败"));
}, [loadBases]);
useEffect(() => {
if (!selectedId) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- selection-driven remote data load
void loadDocuments(selectedId).catch((cause) => setError(cause.message));
}, [loadDocuments, selectedId]);
useEffect(() => {
if (!selectedId || !documents.some((item) => ["pending", "processing"].includes(item.status))) return;
const timer = window.setInterval(() => {
void loadDocuments(selectedId).catch(() => undefined);
}, 2000);
return () => window.clearInterval(timer);
}, [documents, loadDocuments, selectedId]);
function openCreate() {
setName(""); setDescription(""); setEmbeddingId(""); setBaseDialog("create");
}
function openEdit() {
if (!selected) return;
setName(selected.name); setDescription(selected.description);
setEmbeddingId(selected.embeddingModelResourceId || ""); setBaseDialog("edit");
}
async function saveBase() {
setBusy(true); setError("");
try {
const body = { name: name.trim(), description, embeddingModelResourceId: embeddingId || null };
const saved = baseDialog === "edit" && selected
? await knowledgeBasesApi.update(selected.id, body)
: await knowledgeBasesApi.create(body);
setBaseDialog(null); await loadBases(); setSelectedId(saved.id);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "保存失败");
} finally { setBusy(false); }
}
async function removeBase() {
if (!selected || !window.confirm(`确定删除知识库“${selected.name}”及其全部文档吗?`)) return;
setBusy(true); setError("");
try { await knowledgeBasesApi.remove(selected.id); setDocuments([]); await loadBases(); }
catch (cause) { setError(cause instanceof Error ? cause.message : "删除失败"); }
finally { setBusy(false); }
}
async function addText() {
if (!selectedId) return;
setBusy(true); setError("");
try {
await knowledgeBasesApi.addText(selectedId, { name: textName.trim(), content });
setTextOpen(false); setTextName(""); setContent(""); await loadDocuments(selectedId);
} catch (cause) { setError(cause instanceof Error ? cause.message : "入库失败"); }
finally { setBusy(false); }
}
async function addFile(file?: File) {
if (!file || !selectedId) return;
setBusy(true); setError("");
try { await knowledgeBasesApi.addFile(selectedId, file); await loadDocuments(selectedId); }
catch (cause) { setError(cause instanceof Error ? cause.message : "上传失败"); }
finally { setBusy(false); }
}
async function retryDocument(documentId: string) {
setError("");
try { await knowledgeBasesApi.retryDocument(selectedId, documentId); await loadDocuments(selectedId); }
catch (cause) { setError(cause instanceof Error ? cause.message : "重试失败"); }
}
async function removeDocument(document: KnowledgeDocument) {
if (!window.confirm(`确定删除“${document.name}”吗?`)) return;
try { await knowledgeBasesApi.removeDocument(selectedId, document.id); await loadDocuments(selectedId); }
catch (cause) { setError(cause instanceof Error ? cause.message : "删除失败"); }
}
async function previewDocument(document: KnowledgeDocument) {
setPreviewTitle(document.name); setChunks([]); setPreviewOpen(true);
try { setChunks(await knowledgeBasesApi.chunks(selectedId, document.id)); }
catch (cause) { setError(cause instanceof Error ? cause.message : "读取分块失败"); }
}
async function testSearch() {
if (!selectedId || !testQuery.trim()) return;
setSearching(true); setError("");
try { setSearchResults(await knowledgeBasesApi.search(selectedId, testQuery.trim())); }
catch (cause) { setError(cause instanceof Error ? cause.message : "检索失败"); }
finally { setSearching(false); }
}
return (
<PlaceholderPage
title="知识库"
description="统一管理大语言模型、语音识别、声音资源、知识库与工具插件。"
/>
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<div><h1 className="text-2xl font-semibold"></h1><p className="mt-1 text-sm text-muted-foreground"> pipeline </p></div>
<Button className="gap-2" onClick={openCreate}><Plus size={16} /></Button>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{error}</div>}
<div className="grid gap-5 lg:grid-cols-[280px_1fr]">
<Card><CardHeader><CardTitle className="text-base"></CardTitle></CardHeader><CardContent className="space-y-2">
{bases.length === 0 && <p className="text-sm text-muted-foreground"></p>}
{bases.map((base) => (
<button key={base.id} onClick={() => { setSelectedId(base.id); setSearchResults([]); }} className={`w-full rounded-lg border p-3 text-left text-sm transition-colors ${selectedId === base.id ? "border-primary bg-primary/5" : "border-hairline hover:bg-surface-strong"}`}>
<div className="flex items-center gap-2 font-medium"><Database size={15}/>{base.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{base.description || base.id}</div>
</button>
))}
</CardContent></Card>
<div className="space-y-5">
<Card><CardHeader><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle className="text-base">{selected?.name || "请选择知识库"}</CardTitle>{selected?.description && <p className="mt-1 text-xs text-muted-foreground">{selected.description}</p>}</div>{selected && <div className="flex flex-wrap gap-2"><Button variant="ghost" size="sm" onClick={openEdit}><Pencil size={14}/></Button><Button variant="ghost" size="sm" onClick={() => void removeBase()}><Trash2 size={14}/></Button><Button variant="outline" size="sm" onClick={() => setTextOpen(true)}><FileText size={15}/></Button><label className="inline-flex cursor-pointer items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground"><Upload size={15}/>{busy ? "上传中" : "上传文件"}<input hidden type="file" accept=".pdf,.docx,.txt,.md,.csv,.json,.html,.htm" disabled={busy} onChange={(event) => { void addFile(event.target.files?.[0]); event.target.value = ""; }}/></label></div>}</div></CardHeader>
<CardContent>
{selected && <div className="relative mb-4"><Search className="absolute left-3 top-2.5 text-muted-foreground" size={15}/><Input className="pl-9" placeholder="搜索文档名称" value={documentQuery} onChange={(event) => setDocumentQuery(event.target.value)}/></div>}
<div className="space-y-2">
{selected && filteredDocuments.length === 0 && <p className="py-6 text-center text-sm text-muted-foreground"></p>}
{filteredDocuments.map((document) => (
<div key={document.id} className="rounded-lg border border-hairline p-3">
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="truncate text-sm font-medium">{document.name}</span><span className={`rounded-full px-2 py-0.5 text-xs ${statusClass(document.status)}`}>{statusLabel(document.status)}</span></div><div className="mt-1 text-xs text-muted-foreground">{document.sourceType === "file" ? "文件" : "文字"} · {formatBytes(document.sizeBytes)} · {document.chunkCount} </div></div><div className="flex shrink-0 gap-1">{document.status === "failed" && <Button variant="ghost" size="icon" title="重新处理" onClick={() => void retryDocument(document.id)}><RefreshCw size={15}/></Button>}{document.status === "ready" && <Button variant="ghost" size="icon" title="查看分块" onClick={() => void previewDocument(document)}><Eye size={15}/></Button>}<Button variant="ghost" size="icon" title="删除" onClick={() => void removeDocument(document)}><Trash2 size={15}/></Button></div></div>
{document.status === "processing" && <div className="mt-2 flex items-center gap-2 text-xs text-amber-600"><Loader2 className="animate-spin" size={13}/></div>}
{document.errorMessage && <p className="mt-2 rounded bg-destructive/5 p-2 text-xs text-destructive">{document.errorMessage}</p>}
</div>
))}
</div>
</CardContent>
</Card>
{selected && <Card><CardHeader><CardTitle className="text-base"></CardTitle><p className="text-xs text-muted-foreground"></p></CardHeader><CardContent><div className="flex gap-2"><Input placeholder="输入问题,例如:退款规则是什么?" value={testQuery} onChange={(event) => setTestQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void testSearch(); }}/><Button disabled={searching || !testQuery.trim()} onClick={() => void testSearch()}>{searching ? <Loader2 className="animate-spin" size={15}/> : <Search size={15}/>}</Button></div><div className="mt-4 space-y-3">{searchResults.map((result, index) => <div key={`${result.document}-${index}`} className="rounded-lg border border-hairline p-3"><div className="mb-2 flex justify-between text-xs text-muted-foreground"><span>{result.document}</span><span> {result.score}</span></div><p className="whitespace-pre-wrap text-sm leading-6">{result.content}</p></div>)}</div></CardContent></Card>}
</div>
</div>
<Dialog open={baseDialog !== null} onOpenChange={(open) => !open && setBaseDialog(null)}><DialogContent><DialogHeader><DialogTitle>{baseDialog === "edit" ? "编辑知识库" : "新建知识库"}</DialogTitle></DialogHeader><div className="space-y-4"><Input placeholder="知识库名称" value={name} onChange={(event) => setName(event.target.value)}/><Textarea placeholder="用途说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)}/><Select value={embeddingId} onValueChange={setEmbeddingId}><SelectTrigger><SelectValue placeholder="选择 Embedding 模型"/></SelectTrigger><SelectContent>{models.map((model) => <SelectItem key={model.id} value={model.id}>{model.name}</SelectItem>)}</SelectContent></Select>{baseDialog === "edit" && documents.length > 0 && <p className="text-xs text-muted-foreground"> Embedding </p>}</div><DialogFooter><Button disabled={busy || !name.trim() || !embeddingId} onClick={() => void saveBase()}>{busy ? <Loader2 className="animate-spin" size={15}/> : null}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={textOpen} onOpenChange={setTextOpen}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><div className="space-y-4"><Input placeholder="内容名称" value={textName} onChange={(event) => setTextName(event.target.value)}/><Textarea rows={10} placeholder="粘贴需要入库的内容" value={content} onChange={(event) => setContent(event.target.value)}/></div><DialogFooter><Button disabled={busy || !textName.trim() || !content.trim()} onClick={() => void addText()}></Button></DialogFooter></DialogContent></Dialog>
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}><DialogContent className="sm:max-w-3xl"><DialogHeader><DialogTitle>{previewTitle} · </DialogTitle></DialogHeader><div className="max-h-[60vh] space-y-3 overflow-y-auto">{chunks.length === 0 ? <p className="text-sm text-muted-foreground"></p> : chunks.map((chunk) => <div key={chunk.id} className="rounded-lg border border-hairline p-3"><div className="mb-2 text-xs text-muted-foreground"> #{chunk.chunkIndex + 1}</div><p className="whitespace-pre-wrap text-sm leading-6">{chunk.content}</p></div>)}</div></DialogContent></Dialog>
</div>
);
}

View File

@@ -12,8 +12,9 @@ export const API_BASE =
export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding" | "Agent";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const isFormData = init?.body instanceof FormData;
const res = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json" },
headers: isFormData ? undefined : { "Content-Type": "application/json" },
credentials: "include",
...init,
});
@@ -344,8 +345,71 @@ export type KnowledgeBase = {
updatedAt?: string | null;
};
export type KnowledgeDocument = {
id: string;
knowledgeBaseId: string;
name: string;
sourceType: "file" | "text";
mimeType: string;
sizeBytes: number;
status: string;
errorMessage: string;
chunkCount: number;
createdAt?: string | null;
};
export type KnowledgeSearchResult = {
content: string;
document: string;
score: number;
};
export type KnowledgeChunk = {
id: string;
chunkIndex: number;
content: string;
};
export type KnowledgeBaseUpsert = Pick<
KnowledgeBase,
"name" | "description" | "embeddingModelResourceId"
>;
export const knowledgeBasesApi = {
list: () => request<KnowledgeBase[]>("/api/knowledge-bases"),
create: (body: KnowledgeBaseUpsert) =>
request<KnowledgeBase>("/api/knowledge-bases", { method: "POST", body: JSON.stringify(body) }),
update: (id: string, body: KnowledgeBaseUpsert) =>
request<KnowledgeBase>(`/api/knowledge-bases/${id}`, { method: "PUT", body: JSON.stringify(body) }),
remove: (id: string) =>
request<{ ok: boolean }>(`/api/knowledge-bases/${id}`, { method: "DELETE" }),
documents: (id: string) =>
request<KnowledgeDocument[]>(`/api/knowledge-bases/${id}/documents`),
addText: (id: string, body: { name: string; content: string }) =>
request<KnowledgeDocument>(`/api/knowledge-bases/${id}/documents/text`, {
method: "POST", body: JSON.stringify(body),
}),
addFile: (id: string, file: File) => {
const body = new FormData();
body.append("file", file);
return request<KnowledgeDocument>(`/api/knowledge-bases/${id}/documents/file`, {
method: "POST", body,
});
},
removeDocument: (id: string, documentId: string) =>
request<{ ok: boolean }>(`/api/knowledge-bases/${id}/documents/${documentId}`, {
method: "DELETE",
}),
retryDocument: (id: string, documentId: string) =>
request<KnowledgeDocument>(`/api/knowledge-bases/${id}/documents/${documentId}/retry`, {
method: "POST",
}),
chunks: (id: string, documentId: string) =>
request<KnowledgeChunk[]>(`/api/knowledge-bases/${id}/documents/${documentId}/chunks`),
search: (id: string, query: string, topK = 5) =>
request<KnowledgeSearchResult[]>(`/api/knowledge-bases/${id}/search`, {
method: "POST", body: JSON.stringify({ query, topK }),
}),
};
// ---------- 工作流节点类型目录 ----------