Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dashboard/src/api/modules/knowledgeBases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface KnowledgeBase {
embedding_model: string;
embedding_dim: number;
doc_count: number;
max_documents: number;
created_at: number;
updated_at: number;
}
Expand Down Expand Up @@ -151,6 +152,7 @@ export const knowledgeBasesApi = {
default_open?: boolean;
shared?: boolean;
icon_name?: string;
max_documents?: number;
}) =>
request<KnowledgeBase>("/knowledge-bases", {
method: "POST",
Expand All @@ -165,6 +167,7 @@ export const knowledgeBasesApi = {
default_open?: boolean;
shared?: boolean;
icon_name?: string;
max_documents?: number;
},
) =>
request<KnowledgeBase>(`/knowledge-bases/${id}`, {
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@
"documents": "Documents",
"documentLimit": "{{count}} / {{max}} documents",
"documentLimitReached": "This knowledge base already contains the maximum of {{count}} documents.",
"maxDocuments": "Document limit",
"maxDocumentsHint": "Maximum number of files in this knowledge base. 0 = unlimited, default 100.",
"documentTooLarge": "Each document must be at most {{sizeMb}} MB.",
"baseLimitReached": "You can create at most {{count}} knowledge bases.",
"uploadHint": "Supports md / txt / pdf / docx / pptx. Max {{sizeMb}} MB per file.",
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@
"documents": "文档",
"documentLimit": "{{count}} / {{max}} 个文档",
"documentLimitReached": "此知识库已达到 {{count}} 个文档的上限。",
"maxDocuments": "文档数量上限",
"maxDocumentsHint": "本知识库可容纳的最大文档数,0 表示不限制,默认为 100。",
"documentTooLarge": "单个文档不能超过 {{sizeMb}} MB。",
"baseLimitReached": "每个用户最多可创建 {{count}} 个知识库。",
"uploadHint": "支持 md / txt / pdf / docx / pptx,单文件不超过 {{sizeMb}} MB。",
Expand Down
29 changes: 25 additions & 4 deletions dashboard/src/pages/KnowledgeBases/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Empty,
Form,
Input,
InputNumber,
List,
Modal,
Popconfirm,
Expand Down Expand Up @@ -94,6 +95,7 @@ type BaseFormValues = {
name: string;
description?: string;
icon_name?: string;
max_documents?: number;
};

type DocsViewMode = "card" | "table";
Expand Down Expand Up @@ -327,7 +329,8 @@ export default function KnowledgeBasesPage() {
: 0;
const atBaseLimit = ownedBaseCount >= limits.max_bases_per_owner;
const fileCount = documents.filter((document) => !document.is_dir).length;
const isAtDocumentLimit = fileCount >= limits.max_docs_per_kb;
const isAtDocumentLimit =
fileCount >= (selected?.max_documents ?? limits.max_docs_per_kb);
const folderEntries = documents
.filter((document) =>
isDirectKnowledgeChild(document.path || document.filename, currentFolder),
Expand Down Expand Up @@ -598,6 +601,7 @@ export default function KnowledgeBasesPage() {
name: "",
description: "",
icon_name: "book-open",
max_documents: 100,
});
setDefaultOpenChecked(false);
setSharedChecked(false);
Expand All @@ -611,6 +615,7 @@ export default function KnowledgeBasesPage() {
name: selected.name,
description: selected.description,
icon_name: selected.icon_name || undefined,
max_documents: selected.max_documents,
});
setDefaultOpenChecked(selected.default_open);
setSharedChecked(selected.shared);
Expand Down Expand Up @@ -813,7 +818,10 @@ export default function KnowledgeBasesPage() {

const uploadDocuments = async (files: FileList | null) => {
if (!selected || !files || !usable || isAtDocumentLimit) return;
const remaining = Math.max(0, limits.max_docs_per_kb - fileCount);
const remaining = Math.max(
0,
(selected?.max_documents ?? limits.max_docs_per_kb) - fileCount,
);
const chosen = Array.from(files).slice(0, remaining);
const oversized = chosen.filter(
(file) => file.size > limits.max_document_bytes,
Expand Down Expand Up @@ -1476,7 +1484,7 @@ export default function KnowledgeBasesPage() {
<span className={skillStyles.gridCount}>
{t("knowledgeBases.documentLimit", {
count: fileCount,
max: limits.max_docs_per_kb,
max: selected?.max_documents ?? limits.max_docs_per_kb,
})}
</span>
<div className={skillStyles.gridToolbarRight}>
Expand Down Expand Up @@ -1607,7 +1615,7 @@ export default function KnowledgeBasesPage() {
type="info"
showIcon
message={t("knowledgeBases.documentLimitReached", {
count: limits.max_docs_per_kb,
count: selected?.max_documents ?? limits.max_docs_per_kb,
})}
/>
) : null}
Expand Down Expand Up @@ -1937,6 +1945,19 @@ export default function KnowledgeBasesPage() {
showCount
/>
</Form.Item>
<Form.Item
name="max_documents"
label={t("knowledgeBases.maxDocuments")}
extra={t("knowledgeBases.maxDocumentsHint")}
>
<InputNumber
min={0}
max={10000}
step={1}
precision={0}
style={{ width: "100%" }}
/>
</Form.Item>
<Form.Item name="icon_name" label={t("knowledgeBases.icon")}>
<KnowledgeIconPicker />
</Form.Item>
Expand Down
14 changes: 14 additions & 0 deletions src/octop/api/routers/knowledge_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ class CreateBaseBody(BaseModel):
default_open: bool = False
shared: bool = False
icon_name: str = Field(default="", max_length=64)
max_documents: int | None = Field(
default=None,
ge=0,
le=10_000,
description="Per-base document limit. 0 = unlimited, default 100.",
)


class CreateFolderBody(BaseModel):
Expand Down Expand Up @@ -98,6 +104,12 @@ class UpdateBaseBody(BaseModel):
default_open: bool | None = None
shared: bool | None = None
icon_name: str | None = Field(default=None, max_length=64)
max_documents: int | None = Field(
default=None,
ge=0,
le=10_000,
description="Per-base document limit. 0 = unlimited.",
)


class RenameDocumentBody(BaseModel):
Expand Down Expand Up @@ -418,6 +430,7 @@ async def create_base(
default_open=body.default_open,
shared=body.shared,
icon_name=body.icon_name.strip(),
max_documents=body.max_documents if body.max_documents is not None else MAX_DOCS_PER_KB,
)
return _base_payload(server, base)
except Exception as exc:
Expand Down Expand Up @@ -480,6 +493,7 @@ async def update_base(
default_open=body.default_open,
shared=body.shared,
icon_name=body.icon_name.strip() if body.icon_name is not None else None,
max_documents=body.max_documents,
is_admin=_is_admin(user),
),
)
Expand Down
2 changes: 2 additions & 0 deletions src/octop/infra/db/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,8 @@ def _ensure_knowledge_bases_schema(db: DatabasePool) -> None:
"""Create or rebuild knowledge tables to the integer-PK identity schema."""
_rebuild_knowledge_identity_schema(db)
_drop_knowledge_base_members(db)
# Schema v10: per-knowledge-base configurable document limit.
_ensure_column(db, "knowledge_bases", "max_documents", "INTEGER NOT NULL DEFAULT 100")


def _ensure_sso_oidc_schema(db: DatabasePool) -> None:
Expand Down
7 changes: 7 additions & 0 deletions src/octop/infra/db/migrations/010_kb_max_documents.pg.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Schema v10: per-knowledge-base configurable document limit.
-- PostgreSQL: ADD COLUMN IF NOT EXISTS is safe (skips if already present).

ALTER TABLE knowledge_bases
ADD COLUMN IF NOT EXISTS max_documents INTEGER NOT NULL DEFAULT 100;

UPDATE _schema_version SET version = 10;
6 changes: 6 additions & 0 deletions src/octop/infra/db/migrations/010_kb_max_documents.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Schema v10: per-knowledge-base configurable document limit.
-- The column is added by migrate.py::_ensure_knowledge_bases_schema so that
-- boot-time repair covers pre-v10 databases. This file only bumps _schema_version.
-- 100 is the previous system-wide default; 0 means unlimited.

UPDATE _schema_version SET version = 10;
79 changes: 58 additions & 21 deletions src/octop/infra/db/repos/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,16 @@ class KnowledgeBaseRow:
embedding_model: str
embedding_dim: int
doc_count: int
max_documents: int
created_at: int
updated_at: int

@classmethod
def from_row(cls, r: DbRow) -> KnowledgeBaseRow:
# Schema v10 adds max_documents. Fall back to 100 for pre-v10 DBs.
# sqlite3.Row has no __contains__; use keys() (like users.py).
keys = frozenset(r.keys()) if hasattr(r, "keys") else frozenset()
max_doc = int(r["max_documents"]) if "max_documents" in keys else 100
return cls(
id=str(r["knowledge_base_id"]),
pk=int(r["id"]),
Expand All @@ -48,6 +53,7 @@ def from_row(cls, r: DbRow) -> KnowledgeBaseRow:
embedding_model=r["embedding_model"],
embedding_dim=r["embedding_dim"],
doc_count=r["doc_count"],
max_documents=max_doc,
created_at=r["created_at"],
updated_at=r["updated_at"],
)
Expand Down Expand Up @@ -112,29 +118,54 @@ def create_base(
icon_name: str = "",
embedding_model: str = "",
embedding_dim: int = 0,
max_documents: int | None = None,
) -> KnowledgeBaseRow:
kb_id = self._allocate_base_id()
ts = now_ts()
with self._db.transaction() as conn:
conn.execute(
"INSERT INTO knowledge_bases("
"knowledge_base_id, owner_user_id, name, description, default_open, shared, "
"icon_name, embedding_model, embedding_dim, doc_count, created_at, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
(
kb_id,
owner_user_id,
name,
description,
bool_int(default_open),
bool_int(shared),
icon_name,
embedding_model,
embedding_dim,
ts,
ts,
),
)
if max_documents is None:
# Rely on the column DEFAULT (100) for max_documents.
conn.execute(
"INSERT INTO knowledge_bases("
"knowledge_base_id, owner_user_id, name, description, default_open, shared, "
"icon_name, embedding_model, embedding_dim, doc_count, created_at, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
(
kb_id,
owner_user_id,
name,
description,
bool_int(default_open),
bool_int(shared),
icon_name,
embedding_model,
embedding_dim,
ts,
ts,
),
)
else:
conn.execute(
"INSERT INTO knowledge_bases("
"knowledge_base_id, owner_user_id, name, description, default_open, shared, "
"icon_name, embedding_model, embedding_dim, doc_count, max_documents, "
"created_at, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
(
kb_id,
owner_user_id,
name,
description,
bool_int(default_open),
bool_int(shared),
icon_name,
embedding_model,
embedding_dim,
max_documents,
ts,
ts,
),
)
row = self.get_base(kb_id)
if row is None:
raise RuntimeError(f"knowledge base insert failed: {kb_id}")
Expand Down Expand Up @@ -183,6 +214,7 @@ def update_base(
embedding_model: str | None = None,
embedding_dim: int | None = None,
doc_count: int | None = None,
max_documents: int | None = None,
) -> None:
fields, params = partial_updates(
[
Expand All @@ -194,6 +226,7 @@ def update_base(
("embedding_model", embedding_model),
("embedding_dim", embedding_dim),
("doc_count", doc_count),
("max_documents", max_documents),
]
)
if not fields:
Expand Down Expand Up @@ -264,8 +297,12 @@ def create_document(
self.ensure_folder(kb_id, folder)
doc_id = new_ulid()
ts = now_ts()
# Treat both None (caller did not specify) and 0 (per-base "unlimited"
# sentinel) as unbounded. Otherwise 0 would be enforced literally as
# "at most 0 documents" and reject every create.
enforce_limit = max_documents is not None and max_documents > 0
with self._db.transaction() as conn:
if max_documents is not None:
if enforce_limit:
cursor = conn.execute(
"UPDATE knowledge_bases SET doc_count = doc_count + 1, updated_at = ? "
"WHERE knowledge_base_id = ? AND doc_count < ?",
Expand All @@ -280,7 +317,7 @@ def create_document(
") VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, '', 0, ?, ?)",
(doc_id, kb_id, rel, name, content_type, byte_size, content_hash, status, ts, ts),
)
if max_documents is None:
if not enforce_limit:
conn.execute(
"UPDATE knowledge_bases SET doc_count = doc_count + 1, updated_at = ? "
"WHERE knowledge_base_id = ?",
Expand Down
Loading
Loading