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

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

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
"""
Copyright 2024-2026 ReplyPier
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.
"""
from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, status, Header, Request, Query
from fastapi.responses import FileResponse, JSONResponse
from typing import Optional, Tuple
import uuid
import os
from datetime import datetime
from urllib.parse import quote
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.s3 import upload_file_to_s3, get_s3_signed_url
from app.core.local_file_urls import verify_local_signature
from app.core.logger import get_logger
from app.core.security import verify_conversation_token
from app.models import User
from app.core.auth import get_current_user
from app.database import get_db
logger = get_logger(__name__)
router = APIRouter()
# Allowed file types and size limits
ALLOWED_IMAGE_TYPES = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'}
ALLOWED_DOCUMENT_TYPES = {'application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain', 'text/csv', 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}
ALLOWED_FILE_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_DOCUMENT_TYPES
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB
async def get_current_user_or_widget(
request: Request,
db: Session = Depends(get_db),
authorization: Optional[str] = Header(None),
x_conversation_token: Optional[str] = Header(None)
) -> dict:
"""
Get current user from JWT token, cookies, or widget conversation token
Returns dict with user info or widget info
"""
# Try to get user from cookies or Authorization header (admin/agent users)
try:
# Call get_current_user with the request - it handles both cookies and headers
user = await get_current_user(request, None, db)
if user:
return {
"type": "user",
"user_id": str(user.id),
"org_id": str(user.organization_id)
}
except HTTPException:
# User auth failed, continue to try other methods
pass
except Exception as e:
logger.error(f"Error authenticating user: {str(e)}")
# Try to get widget info from conversation token
if x_conversation_token:
try:
payload = verify_conversation_token(x_conversation_token)
if payload:
return {
"type": "widget",
"widget_id": payload.get("widget_id"),
"org_id": payload.get("org_id"),
"customer_id": payload.get("customer_id")
}
except Exception as e:
logger.error(f"Error verifying conversation token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
def _not_found() -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
def attachment_key(file_path: str) -> Tuple[str, str]:
"""Parse a download path into (org_id, storage key), or 404.
Uploads only ever land at `chat_attachments/<org_id>/<uuid>.<ext>` (see
FileUploadService.upload_file), so anything else is not a servable
attachment. Matching that shape exactly rules out traversal: no `..`
segment can survive a three-part match on a UUID directory.
"""
# Accept the stored form ("/uploads/chat_attachments/...") and the bare key.
clean_path = file_path.lstrip('/')
if clean_path.startswith('uploads/'):
clean_path = clean_path[len('uploads/'):]
parts = clean_path.split('/')
if len(parts) != 3 or parts[0] != 'chat_attachments' or not parts[2] or parts[2] in ('.', '..'):
raise _not_found()
try:
path_org_id = str(uuid.UUID(parts[1]))
except ValueError:
raise _not_found()
return path_org_id, '/'.join(('chat_attachments', path_org_id, parts[2]))
def authorized_attachment_key(file_path: str, org_id: Optional[str]) -> str:
"""Resolve a download path to a storage key the caller's org may read."""
if not org_id:
raise _not_found()
path_org_id, key = attachment_key(file_path)
# 404 rather than 403: whether another org's attachment exists is itself
# something the caller shouldn't learn.
if path_org_id != str(org_id):
raise _not_found()
return key
# Visitor-uploaded files (SVG included) are served from the API origin, so one
# opened directly must not run script there. Only scripts are blocked: a
# sandbox CSP would also stop the browser's PDF viewer.
ATTACHMENT_HEADERS = {
"Content-Security-Policy": "script-src 'none'",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, max-age=300",
}
def _inline_attachment(path: str, filename: str) -> FileResponse:
return FileResponse(path, content_disposition_type="inline", filename=filename,
headers=ATTACHMENT_HEADERS)
@router.get("/local/{file_path:path}")
async def serve_signed_local_file(
file_path: str,
exp: Optional[int] = Query(None),
sig: Optional[str] = Query(None),
):
"""Serve a local-storage attachment through a link from sign_local_key.
The signature is the authorization, as with an S3 presigned URL, because
the widget loads attachments with plain <img>/<a> tags.
"""
_, key = attachment_key(file_path)
if not verify_local_signature(key, exp, sig):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or expired link")
path = os.path.join("uploads", key)
if not os.path.isfile(path):
raise _not_found()
return _inline_attachment(path, os.path.basename(key))
@router.get("/download/{file_path:path}")
async def download_file(
file_path: str,
request: Request,
db: Session = Depends(get_db),
authorization: Optional[str] = Header(None),
x_conversation_token: Optional[str] = Header(None)
):
"""
Download/serve a file from AWS S3 or local storage
Usage: GET /api/v1/files/download/chat_attachments/org-id/filename.jpg
"""
try:
from fastapi.responses import StreamingResponse
from app.core.s3 import get_s3_client
# Authentication is required, and the result is what authorizes the
# read below. This used to swallow the failure and serve the file
# anyway, making every org's attachments world-readable by path.
auth_info = await get_current_user_or_widget(
request, db, authorization, x_conversation_token)
storage_key = authorized_attachment_key(file_path, auth_info.get("org_id"))
if not settings.S3_FILE_STORAGE:
local_file_path = os.path.join("uploads", storage_key)
if not os.path.isfile(local_file_path):
raise _not_found()
return _inline_attachment(local_file_path, os.path.basename(storage_key))
# For AWS S3 - stream file from S3
try:
s3_client = get_s3_client()
except Exception as client_err:
logger.error(f"Failed to create S3 client: {str(client_err)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create S3 client"
)
# S3 keys don't include the /uploads/ prefix
s3_key = storage_key
bucket = settings.S3_BUCKET
# Check if the file exists
try:
s3_client.head_object(Bucket=bucket, Key=s3_key)
except s3_client.exceptions.NoSuchKey:
logger.warning(f"File not found in S3: {s3_key}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
except Exception as head_err:
logger.error(f"Error checking file in S3: {str(head_err)}", exc_info=True)
# Get the object from S3
try:
response = s3_client.get_object(Bucket=bucket, Key=s3_key)
except s3_client.exceptions.NoSuchKey:
logger.warning(f"File not found in S3: {s3_key}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
except Exception as s3_err:
logger.error(f"S3 error: {str(s3_err)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve file from S3"
)
# Stream the file back to client
def generate():
while True:
chunk = response['Body'].read(8192)
if not chunk:
break
yield chunk
# Determine content type
content_type = response.get('ContentType', 'application/octet-stream')
# Extract filename from the validated key
filename = storage_key.split('/')[-1]
logger.info(f"Streaming file from S3: {filename}, content-type: {content_type}")
return StreamingResponse(
generate(),
media_type=content_type,
headers={
**ATTACHMENT_HEADERS,
"Content-Disposition": f"inline; filename*=utf-8''{quote(filename)}",
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error downloading file {file_path}: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to download file"
)
File diff suppressed because it is too large. Load diff
+117
View File
@@ -0,0 +1,117 @@
"""
Copyright 2024-2026 ReplyPier
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.
Storage backend shim shared by upload endpoints: S3 when configured, else the
local uploads/ directory. Only PUBLIC_UPLOAD_FOLDERS are static-mounted under
/api/v1/uploads; private files are reached through signed links.
"""
import os
from typing import Optional
import aiofiles
from app.core.config import settings
from app.core.local_file_urls import sign_local_key
from app.core.s3 import (
delete_file_from_s3,
download_file_from_s3,
get_s3_signed_url,
upload_file_to_s3,
)
async def store_upload(content: bytes, folder: str, file_name: str, content_type: str) -> str:
"""Persist bytes and return the URL to store. S3 mode returns the raw S3
URL, local mode the {API_V1_STR}/uploads/... path; either way, pass it
through resolve_public_url before handing it to a client."""
if settings.S3_FILE_STORAGE:
return await upload_file_to_s3(content, folder, file_name, content_type=content_type)
upload_dir = os.path.join("uploads", folder)
os.makedirs(upload_dir, exist_ok=True)
async with aiofiles.open(os.path.join(upload_dir, file_name), "wb") as f:
await f.write(content)
return f"{settings.API_V1_STR}/uploads/{folder}/{file_name}"
# Local-storage folders anyone may read, static-mounted at {API_V1_STR}/uploads/<folder>:
# agent avatars and agents' profile pictures shown to anonymous widget visitors,
# and help-center logos and article images. Everything else under uploads/ (chat
# attachments, PDF imports awaiting a worker) is private.
PUBLIC_UPLOAD_FOLDERS = ("agents", "user", "help_center")
# Private folders handed to clients as short-lived signed links, the local
# counterpart of an S3 presigned URL.
SIGNED_UPLOAD_FOLDERS = ("chat_attachments",)
def upload_key(stored: str) -> str:
"""Key relative to `uploads/` for a stored local upload URL.
Tolerates every shape the app has ever written: the current
`{API_V1_STR}/uploads/...`, the legacy bare `/uploads/...` that
agent photos, profile pictures and chat attachments carried, and a plain
relative key with no prefix at all.
"""
relative = stored.lstrip("/")
api_prefix = f"{settings.API_V1_STR.strip('/')}/"
if relative.startswith(api_prefix):
relative = relative[len(api_prefix):]
if relative.startswith("uploads/"):
relative = relative[len("uploads/"):]
return relative
def local_upload_path(stored: str) -> str:
"""Filesystem path under `uploads/` for a stored local upload URL."""
return os.path.join("uploads", upload_key(stored))
def signed_upload_key(stored: Optional[str]) -> Optional[str]:
"""The key of a stored local upload that is only served through a signed
link, or None for anything else (S3 and other absolute URLs, public files)."""
if not stored or "://" in stored or stored.startswith(("data:", "blob:")):
return None
key = upload_key(stored)
return key if key.split("/", 1)[0] in SIGNED_UPLOAD_FOLDERS else None
async def load_upload(stored: str) -> bytes:
"""Read back a previously stored upload by the URL/path store_upload
returned. Used by workers running in a different container than the API
(shared local uploads mount or S3)."""
if stored.startswith("http"):
return await download_file_from_s3(stored)
async with aiofiles.open(local_upload_path(stored), "rb") as f:
return await f.read()
async def delete_upload(stored: str) -> None:
"""Best-effort removal of a stored upload (temp import files)."""
try:
if stored.startswith("http"):
await delete_file_from_s3(stored)
return
os.remove(local_upload_path(stored))
except Exception:
pass
async def resolve_public_url(stored_url: str) -> str:
"""Client-facing URL for a stored upload: presigned for S3 objects, a
signed link for private local files, verbatim otherwise."""
if stored_url and settings.S3_FILE_STORAGE and stored_url.startswith("http"):
return await get_s3_signed_url(stored_url)
key = signed_upload_key(stored_url)
return sign_local_key(key) if key else stored_url
+384
View File
@@ -0,0 +1,384 @@
"""
Copyright 2024-2026 ReplyPier
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 base64
import os
import uuid
from typing import Dict, Optional, List, Set
from app.core.config import settings
from app.core.s3 import upload_file_to_s3
from app.services.file_storage import resolve_public_url
from app.core.logger import get_logger
logger = get_logger(__name__)
# Allowed file types and size limits
ALLOWED_IMAGE_TYPES = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'}
ALLOWED_DOCUMENT_TYPES = {'application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain', 'text/csv', 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}
ALLOWED_FILE_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_DOCUMENT_TYPES
# Security: SVG removed from default allowed types due to XSS risks
# Can be enabled per-agent if needed with proper sanitization
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB
# Magic byte signatures for file type validation
# This prevents MIME type spoofing attacks
MAGIC_BYTES = {
# Images
'image/jpeg': [
(b'\xff\xd8\xff\xe0', 0), # JPEG JFIF
(b'\xff\xd8\xff\xe1', 0), # JPEG EXIF
(b'\xff\xd8\xff\xe2', 0), # JPEG ICC
(b'\xff\xd8\xff\xe8', 0), # JPEG SPIFF
(b'\xff\xd8\xff\xdb', 0), # JPEG DQT
(b'\xff\xd8\xff\xee', 0), # JPEG Adobe
],
'image/jpg': [
(b'\xff\xd8\xff\xe0', 0),
(b'\xff\xd8\xff\xe1', 0),
(b'\xff\xd8\xff\xe2', 0),
(b'\xff\xd8\xff\xe8', 0),
(b'\xff\xd8\xff\xdb', 0),
(b'\xff\xd8\xff\xee', 0),
],
'image/png': [
(b'\x89PNG\r\n\x1a\n', 0),
],
'image/gif': [
(b'GIF87a', 0),
(b'GIF89a', 0),
],
'image/webp': [
(b'RIFF', 0), # WebP starts with RIFF, then has WEBP at offset 8
],
# Documents
'application/pdf': [
(b'%PDF-', 0),
],
'application/msword': [
(b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1', 0), # OLE Compound File (DOC)
],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [
(b'PK\x03\x04', 0), # ZIP-based (DOCX)
],
'application/vnd.ms-excel': [
(b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1', 0), # OLE Compound File (XLS)
],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
(b'PK\x03\x04', 0), # ZIP-based (XLSX)
],
'text/plain': [], # Text files don't have magic bytes, validated by content
'text/csv': [], # CSV files don't have magic bytes, validated by content
}
# File extension to MIME type mapping for validation
EXTENSION_TO_MIME = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.txt': 'text/plain',
'.csv': 'text/csv',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}
# Category labels for display
FILE_TYPE_CATEGORIES = {
'images': {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'},
'documents': {'application/pdf'},
'office': {'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'},
'text': {'text/plain', 'text/csv'},
}
class FileUploadService:
"""Service for handling file uploads to S3 or local storage with enhanced security"""
@staticmethod
def validate_magic_bytes(file_content: bytes, claimed_content_type: str) -> tuple[bool, Optional[str]]:
"""
Validate file content against magic byte signatures to prevent MIME type spoofing.
Args:
file_content: The raw binary content of the file
claimed_content_type: The MIME type claimed by the client
Returns:
(is_valid, error_message)
"""
if claimed_content_type not in MAGIC_BYTES:
logger.warning(f"Unknown content type for magic byte validation: {claimed_content_type}")
return False, f"File type '{claimed_content_type}' is not allowed"
signatures = MAGIC_BYTES[claimed_content_type]
# Text files don't have magic bytes - validate as text content
if not signatures:
if claimed_content_type in {'text/plain', 'text/csv'}:
# Check if content is valid text (not binary)
try:
# Try to decode as UTF-8, allowing for some common encodings
sample = file_content[:8192] # Check first 8KB
sample.decode('utf-8')
return True, None
except UnicodeDecodeError:
try:
sample.decode('latin-1')
return True, None
except UnicodeDecodeError:
return False, "File content does not appear to be valid text"
return True, None
# Check for WebP special case (has WEBP at offset 8)
if claimed_content_type == 'image/webp':
if len(file_content) >= 12:
if file_content[:4] == b'RIFF' and file_content[8:12] == b'WEBP':
return True, None
return False, "File content does not match WebP format signature"
# Check against all possible signatures for this MIME type
for signature, offset in signatures:
end_offset = offset + len(signature)
if len(file_content) >= end_offset:
if file_content[offset:end_offset] == signature:
return True, None
return False, f"File content does not match the claimed type '{claimed_content_type}'. The file may be corrupted or mislabeled."
@staticmethod
def validate_extension(filename: str, claimed_content_type: str) -> tuple[bool, Optional[str]]:
"""
Validate that file extension matches the claimed content type.
Args:
filename: The name of the file
claimed_content_type: The MIME type claimed by the client
Returns:
(is_valid, error_message)
"""
ext = os.path.splitext(filename.lower())[1]
if not ext:
return False, "File must have an extension"
if ext not in EXTENSION_TO_MIME:
return False, f"File extension '{ext}' is not allowed. Allowed extensions: {', '.join(sorted(EXTENSION_TO_MIME.keys()))}"
expected_mime = EXTENSION_TO_MIME[ext]
# Handle jpg/jpeg equivalence
if claimed_content_type in {'image/jpeg', 'image/jpg'} and expected_mime in {'image/jpeg', 'image/jpg'}:
return True, None
if expected_mime != claimed_content_type:
return False, f"File extension '{ext}' does not match content type '{claimed_content_type}'"
return True, None
@staticmethod
def validate_file_type(content_type: str, allowed_types: Optional[Set[str]] = None) -> bool:
"""
Validate if the file type is allowed.
Args:
content_type: The MIME type to validate
allowed_types: Optional set of allowed MIME types. If None, uses default ALLOWED_FILE_TYPES.
"""
types_to_check = allowed_types if allowed_types is not None else ALLOWED_FILE_TYPES
return content_type in types_to_check
@staticmethod
def get_allowed_types_for_agent(agent_allowed_types: Optional[List[str]] = None) -> Set[str]:
"""
Get the set of allowed file types for an agent.
Args:
agent_allowed_types: List of category names like ['images', 'documents', 'office', 'text']
If None or empty, returns all default allowed types.
Returns:
Set of MIME types that are allowed
"""
if not agent_allowed_types:
return ALLOWED_FILE_TYPES
allowed = set()
for category in agent_allowed_types:
category_lower = category.lower()
if category_lower in FILE_TYPE_CATEGORIES:
allowed.update(FILE_TYPE_CATEGORIES[category_lower])
# If no valid categories found, return default
if not allowed:
return ALLOWED_FILE_TYPES
return allowed
@staticmethod
def validate_file_size(file_size: int, content_type: str) -> tuple[bool, Optional[str]]:
"""
Validate file size based on content type
Returns: (is_valid, error_message)
"""
if content_type in ALLOWED_IMAGE_TYPES and file_size > MAX_IMAGE_SIZE:
return False, f"Image file size exceeds maximum allowed size of {MAX_IMAGE_SIZE / (1024*1024):.1f}MB"
if file_size > MAX_FILE_SIZE:
return False, f"File size exceeds maximum allowed size of {MAX_FILE_SIZE / (1024*1024):.1f}MB"
return True, None
@staticmethod
def get_friendly_allowed_types_message(allowed_types: Optional[Set[str]] = None) -> str:
"""Generate a user-friendly message listing allowed file types."""
types_to_use = allowed_types if allowed_types is not None else ALLOWED_FILE_TYPES
extensions = []
for ext, mime in EXTENSION_TO_MIME.items():
if mime in types_to_use:
extensions.append(ext.upper().replace('.', ''))
return ', '.join(sorted(set(extensions)))
@staticmethod
async def upload_file(
file_data: dict,
org_id: str,
customer_id: Optional[str] = None,
user_id: Optional[str] = None,
allowed_types: Optional[Set[str]] = None
) -> Dict[str, any]:
"""
Upload a file to S3 or local storage with enhanced security validation.
Args:
file_data: Dict with 'content' (base64), 'filename', 'content_type', 'size'
org_id: Organization ID
customer_id: Optional customer ID (for widget users)
user_id: Optional user ID (for authenticated users)
allowed_types: Optional set of allowed MIME types for this upload
Returns:
Dict with 'file_url', 'signed_url', 'filename', 'content_type', 'size'
Raises:
ValueError: If validation fails or upload fails
"""
try:
filename = file_data.get('filename', '')
content_type = file_data.get('content_type', 'application/octet-stream')
# Validate file data
if not file_data.get('content') or not filename:
raise ValueError("Missing required file data: content and filename")
# Determine which types are allowed
types_allowed = allowed_types if allowed_types is not None else ALLOWED_FILE_TYPES
friendly_types = FileUploadService.get_friendly_allowed_types_message(types_allowed)
# Step 1: Validate extension matches claimed content type
is_valid, error = FileUploadService.validate_extension(filename, content_type)
if not is_valid:
logger.warning(f"Extension validation failed for {filename}: {error}")
raise ValueError(error)
# Step 2: Validate file type is allowed
if not FileUploadService.validate_file_type(content_type, types_allowed):
raise ValueError(f"File type '{content_type}' is not allowed. Allowed types: {friendly_types}")
# Step 3: Decode base64 content
try:
file_content = base64.b64decode(file_data['content'])
except Exception as e:
logger.error(f"Error decoding base64 content: {str(e)}")
raise ValueError("Invalid file content encoding")
file_size = len(file_content)
# Step 4: Validate file size
is_valid, error_message = FileUploadService.validate_file_size(file_size, content_type)
if not is_valid:
raise ValueError(error_message)
# Step 5: SECURITY - Validate magic bytes to prevent MIME spoofing
is_valid, magic_error = FileUploadService.validate_magic_bytes(file_content, content_type)
if not is_valid:
logger.warning(f"Magic byte validation failed for {filename}: {magic_error}")
raise ValueError(f"Security check failed: {magic_error}")
logger.info(f"File validation passed for {filename}: type={content_type}, size={file_size}")
# Generate unique filename with sanitized extension
file_extension = os.path.splitext(filename)[1].lower()
# Additional sanitization: only allow known extensions
if file_extension not in EXTENSION_TO_MIME:
raise ValueError(f"File extension '{file_extension}' is not allowed")
unique_filename = f"{uuid.uuid4()}{file_extension}"
# Determine folder based on auth type
folder = f"chat_attachments/{org_id}"
# Upload to S3 if enabled, otherwise save locally
if settings.S3_FILE_STORAGE:
file_url = await upload_file_to_s3(
file_content=file_content,
folder=folder,
filename=unique_filename,
content_type=content_type
)
else:
# Save locally
upload_dir = os.path.join("uploads", folder)
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, unique_filename)
with open(file_path, "wb") as f:
f.write(file_content)
file_url = f"/uploads/{folder}/{unique_filename}"
signed_url = await resolve_public_url(file_url)
logger.info(f"Successfully uploaded file: {filename} -> {file_url}")
return {
'file_url': file_url,
'signed_url': signed_url,
'filename': filename,
'content_type': content_type,
'size': file_size
}
except ValueError:
# Re-raise validation errors
raise
except Exception as e:
logger.error(f"Error in FileUploadService.upload_file: {str(e)}", exc_info=True)
raise ValueError(f"Failed to upload file: {str(e)}")
+117
View File
@@ -0,0 +1,117 @@
"""
Copyright 2024-2026 ReplyPier
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.
Hardening for user-uploaded images (help-center logo + article images). Every
uploaded raster is:
- size-capped (bytes),
- checked for a real, decodable image (not a polyglot / renamed file),
- guarded against decompression bombs (pixel-count cap read from the header
BEFORE the full image is decoded),
- re-encoded from decoded pixels — which strips EXIF/metadata, trailing bytes
and any appended payload, and normalises the stored format.
SVG is intentionally NOT accepted here: it is active markup and would be stored
XSS on the public help center. Callers that previously took SVG rasterise on the
client (the cropper) or drop it.
"""
import io
from fastapi import HTTPException
from PIL import Image
# Refuse absurd pixel counts before decoding — a few-KB file can claim to be
# tens of thousands of pixels per side and exhaust memory on decode.
_MAX_PIXELS = 40_000_000 # 40 MP
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS
# PIL format -> (content type, extension) for the formats we re-encode to.
_PNG = ("image/png", ".png")
_JPEG = ("image/jpeg", ".jpg")
_FORMAT_CONTENT_TYPE = {
"PNG": "image/png",
"JPEG": "image/jpeg",
"WEBP": "image/webp",
"GIF": "image/gif",
}
def _open_validated(content: bytes, *, max_bytes: int, max_dim: int) -> Image.Image:
"""Open bytes as an image, enforcing byte size, decodability and a pixel /
dimension cap (bomb guard) — all before the pixels are fully decoded."""
if not content:
raise HTTPException(status_code=400, detail="Empty image file.")
if len(content) > max_bytes:
raise HTTPException(status_code=400, detail=f"Image must be {max_bytes // (1024 * 1024)} MB or smaller.")
try:
img = Image.open(io.BytesIO(content))
width, height = img.size # header only; no full decode yet
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=400, detail="Invalid or corrupt image file.")
if width <= 0 or height <= 0:
raise HTTPException(status_code=400, detail="Invalid image dimensions.")
if width > max_dim or height > max_dim or width * height > _MAX_PIXELS:
raise HTTPException(
status_code=400,
detail=f"Image is too large — keep it within {max_dim}×{max_dim} pixels.",
)
try:
img.load() # dimensions are now known-safe, so decoding is bounded
except Exception:
raise HTTPException(status_code=400, detail="Invalid or corrupt image file.")
return img
def sanitize_image(
content: bytes,
*,
allowed_content_types: set[str],
max_bytes: int,
max_dim: int,
fit: int | None = None,
) -> tuple[bytes, str, str]:
"""Validate and re-encode an uploaded raster image.
Returns (safe_bytes, content_type, extension). Raises HTTPException(400) on
anything that is not a clean, in-policy raster image. `fit`, when given,
downscales the longest side to at most `fit` px (aspect preserved).
JPEG re-encodes to JPEG (keeps photos small); everything else re-encodes to
PNG (keeps transparency). Either path strips metadata and any non-pixel
payload because the output is built from decoded pixels only.
"""
img = _open_validated(content, max_bytes=max_bytes, max_dim=max_dim)
detected = _FORMAT_CONTENT_TYPE.get((img.format or "").upper())
if detected is None or detected not in allowed_content_types:
raise HTTPException(
status_code=400,
detail="Unsupported image type. Use PNG, JPEG or WebP.",
)
if fit and max(img.size) > fit:
img.thumbnail((fit, fit), Image.LANCZOS)
out = io.BytesIO()
if detected == "image/jpeg":
img.convert("RGB").save(out, format="JPEG", quality=85, optimize=True)
content_type, ext = _JPEG
else:
img.convert("RGBA").save(out, format="PNG", optimize=True)
content_type, ext = _PNG
return out.getvalue(), content_type, ext
@@ -0,0 +1,512 @@
"""
Copyright 2024-2026 ReplyPier
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 pytest
import base64
import os
import tempfile
from unittest.mock import Mock, patch, AsyncMock, MagicMock
from uuid import uuid4
from app.services.file_upload_service import (
FileUploadService,
ALLOWED_IMAGE_TYPES,
ALLOWED_DOCUMENT_TYPES,
ALLOWED_FILE_TYPES,
MAX_FILE_SIZE,
MAX_IMAGE_SIZE
)
class TestFileUploadServiceValidation:
"""Test file validation methods"""
def test_validate_file_type_allowed_image(self):
"""Test validation of allowed image types"""
for content_type in ALLOWED_IMAGE_TYPES:
assert FileUploadService.validate_file_type(content_type) is True
def test_validate_file_type_allowed_document(self):
"""Test validation of allowed document types"""
for content_type in ALLOWED_DOCUMENT_TYPES:
assert FileUploadService.validate_file_type(content_type) is True
def test_validate_file_type_disallowed(self):
"""Test validation of disallowed file types"""
disallowed_types = [
'video/mp4',
'audio/mp3',
'application/x-executable',
'text/html',
'application/javascript'
]
for content_type in disallowed_types:
assert FileUploadService.validate_file_type(content_type) is False
def test_validate_file_size_image_within_limit(self):
"""Test image file size validation within limits"""
# Test image under image size limit
image_size = MAX_IMAGE_SIZE - 1000 # 1KB under limit
is_valid, error = FileUploadService.validate_file_size(image_size, 'image/jpeg')
assert is_valid is True
assert error is None
def test_validate_file_size_image_exceeds_image_limit(self):
"""Test image file size validation exceeding image limit"""
# Test image over image size limit
image_size = MAX_IMAGE_SIZE + 1000 # 1KB over image limit
is_valid, error = FileUploadService.validate_file_size(image_size, 'image/jpeg')
assert is_valid is False
assert "Image file size exceeds maximum" in error
assert "5.0MB" in error
def test_validate_file_size_document_within_limit(self):
"""Test document file size validation within limits"""
# Test document under general size limit
doc_size = MAX_FILE_SIZE - 1000 # 1KB under limit
is_valid, error = FileUploadService.validate_file_size(doc_size, 'application/pdf')
assert is_valid is True
assert error is None
def test_validate_file_size_document_exceeds_limit(self):
"""Test document file size validation exceeding limit"""
# Test document over general size limit
doc_size = MAX_FILE_SIZE + 1000 # 1KB over limit
is_valid, error = FileUploadService.validate_file_size(doc_size, 'application/pdf')
assert is_valid is False
assert "File size exceeds maximum" in error
assert "10.0MB" in error
def test_validate_file_size_image_exceeds_general_limit(self):
"""Test image file size validation exceeding general limit"""
# Test image over general size limit (but under image limit is irrelevant)
image_size = MAX_FILE_SIZE + 1000 # 1KB over general limit
is_valid, error = FileUploadService.validate_file_size(image_size, 'image/png')
assert is_valid is False
# The service checks image size limit first, so we get image-specific error
assert "Image file size exceeds maximum" in error
assert "5.0MB" in error
class TestFileUploadServiceUpload:
"""Test file upload functionality"""
@pytest.fixture
def sample_file_data(self):
"""Create sample file data for testing"""
content = b"This is a test file content"
encoded_content = base64.b64encode(content).decode('utf-8')
return {
'content': encoded_content,
'filename': 'test_file.txt',
'content_type': 'text/plain',
'size': len(content)
}
@pytest.fixture
def sample_image_data(self):
"""Create sample image data for testing"""
# Simple fake image content
content = b"fake_image_data_" * 100 # Small image
encoded_content = base64.b64encode(content).decode('utf-8')
return {
'content': encoded_content,
'filename': 'test_image.jpg',
'content_type': 'image/jpeg',
'size': len(content)
}
@pytest.fixture
def large_file_data(self):
"""Create large file data that exceeds size limits"""
content = b"x" * (MAX_FILE_SIZE + 1000) # Exceeds max file size
encoded_content = base64.b64encode(content).decode('utf-8')
return {
'content': encoded_content,
'filename': 'large_file.pdf',
'content_type': 'application/pdf',
'size': len(content)
}
@pytest.mark.asyncio
async def test_upload_file_s3_success(self, sample_file_data):
"""Test successful file upload to S3"""
org_id = str(uuid4())
user_id = str(uuid4())
with patch('app.services.file_upload_service.settings') as mock_settings, \
patch('app.services.file_upload_service.upload_file_to_s3') as mock_s3_upload, \
patch('app.services.file_upload_service.resolve_public_url') as mock_signed_url, \
patch('uuid.uuid4') as mock_uuid:
# Configure mocks
mock_settings.S3_FILE_STORAGE = True
mock_uuid.return_value = Mock()
mock_uuid.return_value.__str__ = Mock(return_value='test-uuid-123')
mock_s3_upload.return_value = 'https://s3.amazonaws.com/bucket/chat_attachments/org123/test-uuid-123.txt'
mock_signed_url.return_value = 'https://s3.amazonaws.com/bucket/signed-url'
result = await FileUploadService.upload_file(
file_data=sample_file_data,
org_id=org_id,
user_id=user_id
)
# Verify S3 upload was called
mock_s3_upload.assert_called_once()
call_args = mock_s3_upload.call_args
assert call_args[1]['folder'] == f'chat_attachments/{org_id}'
assert call_args[1]['filename'] == 'test-uuid-123.txt'
assert call_args[1]['content_type'] == 'text/plain'
# Verify signed URL was generated
mock_signed_url.assert_called_once()
# Verify result
assert result['file_url'] == 'https://s3.amazonaws.com/bucket/chat_attachments/org123/test-uuid-123.txt'
assert result['signed_url'] == 'https://s3.amazonaws.com/bucket/signed-url'
assert result['filename'] == 'test_file.txt'
assert result['content_type'] == 'text/plain'
assert result['size'] == len(base64.b64decode(sample_file_data['content']))
@pytest.mark.asyncio
async def test_upload_file_local_success(self, sample_file_data):
"""Test successful file upload to local storage"""
org_id = str(uuid4())
customer_id = str(uuid4())
with patch('app.services.file_upload_service.settings') as mock_settings, \
patch('uuid.uuid4') as mock_uuid, \
patch('os.makedirs') as mock_makedirs, \
patch('builtins.open', create=True) as mock_open:
# Configure mocks
mock_settings.S3_FILE_STORAGE = False
mock_uuid.return_value = Mock()
mock_uuid.return_value.__str__ = Mock(return_value='test-uuid-456')
# Mock file operations
mock_file = MagicMock()
mock_open.return_value.__enter__.return_value = mock_file
result = await FileUploadService.upload_file(
file_data=sample_file_data,
org_id=org_id,
customer_id=customer_id
)
# Verify directory creation
mock_makedirs.assert_called_once_with(f'uploads/chat_attachments/{org_id}', exist_ok=True)
# Verify file write
mock_open.assert_called_once_with(f'uploads/chat_attachments/{org_id}/test-uuid-456.txt', 'wb')
mock_file.write.assert_called_once()
# Verify result
assert result['file_url'] == f'/uploads/chat_attachments/{org_id}/test-uuid-456.txt'
# Local attachments are never static-served: clients get a signed link.
assert result['signed_url'].startswith(
f'/api/v1/files/local/chat_attachments/{org_id}/test-uuid-456.txt?exp=')
assert result['filename'] == 'test_file.txt'
assert result['content_type'] == 'text/plain'
@pytest.mark.asyncio
async def test_upload_file_missing_content(self):
"""Test upload with missing content"""
file_data = {
'filename': 'test.txt',
'content_type': 'text/plain'
# Missing 'content'
}
with pytest.raises(ValueError) as exc_info:
await FileUploadService.upload_file(
file_data=file_data,
org_id=str(uuid4())
)
assert "Missing required file data" in str(exc_info.value)
@pytest.mark.asyncio
async def test_upload_file_missing_filename(self):
"""Test upload with missing filename"""
file_data = {
'content': base64.b64encode(b"test content").decode('utf-8'),
'content_type': 'text/plain'
# Missing 'filename'
}
with pytest.raises(ValueError) as exc_info:
await FileUploadService.upload_file(
file_data=file_data,
org_id=str(uuid4())
)
assert "Missing required file data" in str(exc_info.value)
@pytest.mark.asyncio
async def test_upload_file_invalid_type(self, sample_file_data):
"""Test upload with invalid file type"""
# Modify to invalid type
sample_file_data['content_type'] = 'video/mp4'
sample_file_data['filename'] = 'test.mp4'
with pytest.raises(ValueError) as exc_info:
await FileUploadService.upload_file(
file_data=sample_file_data,
org_id=str(uuid4())
)
# Error should indicate the file type is not allowed
assert "not allowed" in str(exc_info.value) or "File type" in str(exc_info.value)
@pytest.mark.asyncio
async def test_upload_file_invalid_base64(self):
"""Test upload with invalid base64 content"""
file_data = {
'content': 'Z', # Invalid base64 - single character that can't be decoded properly
'filename': 'test.txt',
'content_type': 'text/plain'
}
with pytest.raises(ValueError) as exc_info:
await FileUploadService.upload_file(
file_data=file_data,
org_id=str(uuid4())
)
assert "Invalid file content encoding" in str(exc_info.value)
@pytest.mark.asyncio
async def test_upload_file_size_exceeds_limit(self, large_file_data):
"""Test upload with file size exceeding limits"""
with pytest.raises(ValueError) as exc_info:
await FileUploadService.upload_file(
file_data=large_file_data,
org_id=str(uuid4())
)
assert "File size exceeds maximum" in str(exc_info.value)
@pytest.mark.asyncio
async def test_upload_file_image_size_exceeds_limit(self):
"""Test upload with image size exceeding image limits"""
# Create large image that exceeds image size limit
content = b"x" * (MAX_IMAGE_SIZE + 1000)
encoded_content = base64.b64encode(content).decode('utf-8')
file_data = {
'content': encoded_content,
'filename': 'large_image.jpg',
'content_type': 'image/jpeg'
}
with pytest.raises(ValueError) as exc_info:
await FileUploadService.upload_file(
file_data=file_data,
org_id=str(uuid4())
)
assert "Image file size exceeds maximum" in str(exc_info.value)
@pytest.mark.asyncio
async def test_upload_file_s3_upload_failure(self, sample_file_data):
"""Test handling of S3 upload failure"""
with patch('app.services.file_upload_service.settings') as mock_settings, \
patch('app.services.file_upload_service.upload_file_to_s3') as mock_s3_upload:
mock_settings.S3_FILE_STORAGE = True
mock_s3_upload.side_effect = Exception("S3 upload failed")
with pytest.raises(ValueError) as exc_info:
await FileUploadService.upload_file(
file_data=sample_file_data,
org_id=str(uuid4())
)
assert "Failed to upload file" in str(exc_info.value)
@pytest.mark.asyncio
async def test_upload_file_local_write_failure(self, sample_file_data):
"""Test handling of local file write failure"""
with patch('app.services.file_upload_service.settings') as mock_settings, \
patch('os.makedirs') as mock_makedirs, \
patch('builtins.open', create=True) as mock_open:
mock_settings.S3_FILE_STORAGE = False
mock_open.side_effect = OSError("Permission denied")
with pytest.raises(ValueError) as exc_info:
await FileUploadService.upload_file(
file_data=sample_file_data,
org_id=str(uuid4())
)
assert "Failed to upload file" in str(exc_info.value)
@pytest.mark.asyncio
async def test_upload_file_default_content_type(self):
"""Test upload with default content type when not provided"""
content = b"test content"
file_data = {
'content': base64.b64encode(content).decode('utf-8'),
'filename': 'test.txt'
# No content_type provided - will default to 'application/octet-stream' which is not allowed
}
# Should raise error because default content type is not allowed
with pytest.raises(ValueError) as exc_info:
await FileUploadService.upload_file(
file_data=file_data,
org_id=str(uuid4())
)
# Error could be extension mismatch or file type not allowed
error_str = str(exc_info.value).lower()
assert "not allowed" in error_str or "does not match" in error_str or "file type" in error_str
@pytest.mark.asyncio
async def test_upload_file_folder_structure_user(self, sample_file_data):
"""Test folder structure for user uploads"""
org_id = str(uuid4())
user_id = str(uuid4())
with patch('app.services.file_upload_service.settings') as mock_settings, \
patch('app.services.file_upload_service.upload_file_to_s3') as mock_s3_upload, \
patch('app.services.file_upload_service.resolve_public_url') as mock_signed_url:
mock_settings.S3_FILE_STORAGE = True
mock_s3_upload.return_value = 'https://s3.amazonaws.com/bucket/file.txt'
mock_signed_url.return_value = 'https://s3.amazonaws.com/bucket/signed'
await FileUploadService.upload_file(
file_data=sample_file_data,
org_id=org_id,
user_id=user_id
)
# Verify folder structure
call_args = mock_s3_upload.call_args
assert call_args[1]['folder'] == f'chat_attachments/{org_id}'
@pytest.mark.asyncio
async def test_upload_file_folder_structure_customer(self, sample_file_data):
"""Test folder structure for customer uploads"""
org_id = str(uuid4())
customer_id = str(uuid4())
with patch('app.services.file_upload_service.settings') as mock_settings, \
patch('app.services.file_upload_service.upload_file_to_s3') as mock_s3_upload, \
patch('app.services.file_upload_service.resolve_public_url') as mock_signed_url:
mock_settings.S3_FILE_STORAGE = True
mock_s3_upload.return_value = 'https://s3.amazonaws.com/bucket/file.txt'
mock_signed_url.return_value = 'https://s3.amazonaws.com/bucket/signed'
await FileUploadService.upload_file(
file_data=sample_file_data,
org_id=org_id,
customer_id=customer_id
)
# Verify folder structure (same as user for now)
call_args = mock_s3_upload.call_args
assert call_args[1]['folder'] == f'chat_attachments/{org_id}'
@pytest.mark.asyncio
async def test_upload_file_unique_filename_generation(self, sample_file_data):
"""Test that unique filenames are generated"""
org_id = str(uuid4())
with patch('app.services.file_upload_service.settings') as mock_settings, \
patch('app.services.file_upload_service.upload_file_to_s3') as mock_s3_upload, \
patch('app.services.file_upload_service.resolve_public_url') as mock_signed_url, \
patch('uuid.uuid4') as mock_uuid:
mock_settings.S3_FILE_STORAGE = True
mock_s3_upload.return_value = 'https://s3.amazonaws.com/bucket/file.txt'
mock_signed_url.return_value = 'https://s3.amazonaws.com/bucket/signed'
# Mock UUID generation
mock_uuid.return_value = Mock()
mock_uuid.return_value.__str__ = Mock(return_value='unique-id-789')
await FileUploadService.upload_file(
file_data=sample_file_data,
org_id=org_id
)
# Verify unique filename was generated
call_args = mock_s3_upload.call_args
assert call_args[1]['filename'] == 'unique-id-789.txt'
@pytest.mark.asyncio
async def test_upload_file_no_extension(self):
"""Test upload with filename that has no extension"""
content = b"test content"
file_data = {
'content': base64.b64encode(content).decode('utf-8'),
'filename': 'test_file_no_extension',
'content_type': 'text/plain'
}
# Should raise error because file has no extension
with pytest.raises(ValueError) as exc_info:
await FileUploadService.upload_file(
file_data=file_data,
org_id=str(uuid4())
)
assert "must have an extension" in str(exc_info.value) or "extension" in str(exc_info.value).lower()
class TestFileUploadServiceConstants:
"""Test service constants and configurations"""
def test_allowed_file_types_combination(self):
"""Test that ALLOWED_FILE_TYPES is correct combination"""
expected_types = ALLOWED_IMAGE_TYPES | ALLOWED_DOCUMENT_TYPES
assert ALLOWED_FILE_TYPES == expected_types
def test_file_size_constants(self):
"""Test file size constants are reasonable"""
assert MAX_FILE_SIZE == 10 * 1024 * 1024 # 10MB
assert MAX_IMAGE_SIZE == 5 * 1024 * 1024 # 5MB
assert MAX_IMAGE_SIZE < MAX_FILE_SIZE # Image limit should be smaller
def test_allowed_image_types_content(self):
"""Test allowed image types include common formats (SVG excluded for XSS safety)"""
expected_image_types = {
'image/jpeg', 'image/jpg', 'image/png',
'image/gif', 'image/webp'
}
assert ALLOWED_IMAGE_TYPES == expected_image_types
# Verify SVG is explicitly NOT included
assert 'image/svg+xml' not in ALLOWED_IMAGE_TYPES
def test_allowed_document_types_content(self):
"""Test allowed document types include common formats"""
expected_doc_types = {
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
assert ALLOWED_DOCUMENT_TYPES == expected_doc_types
@@ -0,0 +1,94 @@
"""
Copyright 2024-2026 ReplyPier
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 io
import pytest
from fastapi import HTTPException
from PIL import Image
from app.services.image_security import sanitize_image
RASTER = {"image/png", "image/jpeg", "image/webp"}
def _img_bytes(fmt: str, size=(64, 48), color=(200, 30, 30)) -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, format=fmt)
return buf.getvalue()
def test_png_reencoded_to_png():
out, ctype, ext = sanitize_image(
_img_bytes("PNG"), allowed_content_types=RASTER, max_bytes=2_000_000, max_dim=4000
)
assert ctype == "image/png" and ext == ".png"
assert Image.open(io.BytesIO(out)).format == "PNG"
def test_jpeg_stays_jpeg():
out, ctype, ext = sanitize_image(
_img_bytes("JPEG"), allowed_content_types=RASTER, max_bytes=2_000_000, max_dim=4000
)
assert ctype == "image/jpeg" and ext == ".jpg"
assert Image.open(io.BytesIO(out)).format == "JPEG"
def test_fit_downscales_longest_side():
out, _c, _e = sanitize_image(
_img_bytes("PNG", size=(1200, 600)),
allowed_content_types=RASTER,
max_bytes=2_000_000,
max_dim=4000,
fit=512,
)
w, h = Image.open(io.BytesIO(out)).size
assert max(w, h) == 512 and (w, h) == (512, 256)
def test_rejects_non_image():
with pytest.raises(HTTPException) as e:
sanitize_image(b"not an image", allowed_content_types=RASTER, max_bytes=2_000_000, max_dim=4000)
assert e.value.status_code == 400
def test_rejects_oversized_bytes():
with pytest.raises(HTTPException) as e:
sanitize_image(_img_bytes("PNG"), allowed_content_types=RASTER, max_bytes=10, max_dim=4000)
assert e.value.status_code == 400
def test_rejects_dimensions_over_cap():
with pytest.raises(HTTPException) as e:
sanitize_image(
_img_bytes("PNG", size=(300, 300)),
allowed_content_types=RASTER,
max_bytes=2_000_000,
max_dim=100,
)
assert e.value.status_code == 400
def test_rejects_disallowed_format():
# GIF is a valid image but not in the allowlist here → rejected.
with pytest.raises(HTTPException) as e:
sanitize_image(_img_bytes("GIF"), allowed_content_types=RASTER, max_bytes=2_000_000, max_dim=4000)
assert e.value.status_code == 400
def test_empty_rejected():
with pytest.raises(HTTPException):
sanitize_image(b"", allowed_content_types=RASTER, max_bytes=2_000_000, max_dim=4000)
@@ -0,0 +1,88 @@
"""
Copyright 2024-2026 ReplyPier
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 io
import os
from uuid import uuid4
import pytest
from fastapi import UploadFile
from PIL import Image
from app.api.agent import save_file
from app.api.users import save_upload_file
from app.core.config import settings
from app.services.file_storage import local_upload_path, store_upload
MOUNT = f"{settings.API_V1_STR}/uploads"
def _png_upload(name: str = "a.png") -> UploadFile:
buf = io.BytesIO()
Image.new("RGB", (2, 2), "red").save(buf, format="PNG")
buf.seek(0)
return UploadFile(filename=name, file=buf, headers={"content-type": "image/png"})
class TestLocalUploadPath:
"""The stored-URL → filesystem-path direction has to read every shape the
app has written, because rows predating the normalisation are still bare."""
def test_current_prefixed_shape(self):
assert local_upload_path(f"{MOUNT}/agents/org/a.png") == os.path.join(
"uploads", "agents/org/a.png")
def test_legacy_bare_shape(self):
# What agent photos and profile pictures carried before the migration.
assert local_upload_path("/uploads/agents/org/a.png") == os.path.join(
"uploads", "agents/org/a.png")
def test_relative_key(self):
assert local_upload_path("agents/org/a.png") == os.path.join(
"uploads", "agents/org/a.png")
def test_never_doubles_the_uploads_segment(self):
for stored in (f"{MOUNT}/x.png", "/uploads/x.png", "uploads/x.png", "x.png"):
assert local_upload_path(stored) == os.path.join("uploads", "x.png"), stored
class TestWritersAgree:
"""Every local writer must emit the same prefix, or the frontend resolves
the path against the API origin and 404s off the static mount."""
@pytest.mark.asyncio
async def test_store_upload_prefixes(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(settings, "S3_FILE_STORAGE", False)
stored = await store_upload(b"x", "help_center", "a.png", "image/png")
assert stored.startswith(f"{MOUNT}/")
@pytest.mark.asyncio
async def test_agent_photo_prefixes_and_round_trips(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(settings, "S3_FILE_STORAGE", False)
stored = await save_file(_png_upload(), uuid4())
assert stored.startswith(f"{MOUNT}/")
# The delete path reads it back through local_upload_path.
assert os.path.exists(local_upload_path(stored))
@pytest.mark.asyncio
async def test_profile_pic_prefixes_and_round_trips(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(settings, "S3_FILE_STORAGE", False)
stored = await save_upload_file(_png_upload(), str(uuid4()), str(uuid4()))
assert stored.startswith(f"{MOUNT}/")
assert os.path.exists(local_upload_path(stored))
@@ -0,0 +1,71 @@
/*
Copyright 2024-2026 ReplyPier
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 { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import KnowledgeAddSourceModal from '../../../components/knowledge/KnowledgeAddSourceModal.vue'
const mountModal = async (url: string) => {
const wrapper = mount(KnowledgeAddSourceModal)
await wrapper.find('#kb-add-url').setValue(url)
return wrapper
}
const scopeTitles = (wrapper: ReturnType<typeof mount>) =>
wrapper.findAll('.scope__title').map((node) => node.text())
const scopeOption = (wrapper: ReturnType<typeof mount>, title: string) =>
wrapper.findAll('.scope__opt').find((node) => node.find('.scope__title').text() === title)
describe('KnowledgeAddSourceModal crawl scope', () => {
it('offers no section option for a homepage URL', async () => {
// "Pages under / " is not a section — it is the whole site, which the next
// option already covers.
const wrapper = await mountModal('https://paywithatoa.co.uk')
expect(scopeTitles(wrapper)).toEqual(['仅此页面', '整个站点', '整个域名'])
expect(scopeOption(wrapper, '整个站点')!.text()).toContain('paywithatoa.co.uk 上的所有页面')
})
it('names the section when the URL has a path', async () => {
const wrapper = await mountModal('https://help.example.com/hc/en')
expect(scopeTitles(wrapper)).toContain('本栏目')
expect(scopeOption(wrapper, '本栏目')!.text()).toContain(
'/hc/en/ 下的页面',
)
})
it('falls back to this-site when the section option disappears', async () => {
const wrapper = await mountModal('https://help.example.com/hc/en')
await scopeOption(wrapper, '本栏目')!.trigger('click')
await wrapper.find('#kb-add-url').setValue('https://help.example.com')
await wrapper.find('button.btn--primary').trigger('click')
expect(wrapper.emitted('submit')![0][0]).toMatchObject({
type: 'website',
url: 'https://help.example.com',
scope: 'host',
})
})
it('submits the selected scope', async () => {
const wrapper = await mountModal('https://help.example.com/hc/en')
await scopeOption(wrapper, '本栏目')!.trigger('click')
await wrapper.find('button.btn--primary').trigger('click')
expect(wrapper.emitted('submit')![0][0]).toMatchObject({ scope: 'path' })
})
})
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
vi.mock('@/services/knowledge', () => ({
knowledgeService: {
getKnowledgeByAgent: vi.fn().mockResolvedValue({ knowledge: [], pagination: { total_pages: 0 } }),
getKnowledgeByOrganization: vi
.fn()
.mockResolvedValue({ knowledge: [], pagination: { total_pages: 0 } }),
getAgentQueueItems: vi.fn().mockResolvedValue({ queue_items: [] })
}
}))
import { useKnowledgeManagement } from '@/composables/useKnowledgeManagement'
const setup = () => useKnowledgeManagement('agent-1', 'org-1')
describe('useKnowledgeManagement - URL handling', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('withScheme', () => {
const { withScheme } = setup()
it.each([
['docs.company.com', 'https://docs.company.com'],
['company.co.uk', 'https://company.co.uk'],
[' spaced.com ', 'https://spaced.com'],
['//protocol-relative.com', 'https://protocol-relative.com']
])('defaults the scheme: %s -> %s', (input, expected) => {
expect(withScheme(input)).toBe(expected)
})
it.each([
'https://already.com',
'http://plain.com',
'HTTPS://shouty.com'
])('leaves an existing scheme alone: %s', (input) => {
expect(withScheme(input)).toBe(input)
})
it('leaves an empty value empty', () => {
expect(withScheme(' ')).toBe('')
})
})
describe('isValidUrl', () => {
const { isValidUrl } = setup()
it.each(['https://docs.company.com', 'http://a.co', 'https://x.com/path?q=1'])(
'accepts %s',
(url) => expect(isValidUrl(url)).toBe(true)
)
it.each(['mailto:someone@x.com', 'foo:bar', 'not a url', 'https://'])(
'rejects %s',
(url) => expect(isValidUrl(url)).toBe(false)
)
it.each(['https://localhost', 'https://wiki', 'http://intranet:8080'])(
'accepts the intranet hostnames a self-hosted install indexes: %s',
(url) => expect(isValidUrl(url)).toBe(true)
)
})
describe('handleUrlAdd', () => {
it('stages a scheme-less URL instead of rejecting it', () => {
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
newUrl.value = 'docs.company.com'
handleUrlAdd()
expect(urls.value).toEqual(['https://docs.company.com'])
expect(urlFormError.value).toBeNull()
expect(newUrl.value).toBe('')
})
it('still rejects input that is not a URL at all', () => {
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
newUrl.value = 'not a url'
handleUrlAdd()
expect(urls.value).toEqual([])
expect(urlFormError.value).toBe('请输入有效的 URL')
})
it('clears a previous error when a later attempt succeeds', () => {
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
newUrl.value = 'not a url'
handleUrlAdd()
expect(urlFormError.value).toBe('请输入有效的 URL')
newUrl.value = 'docs.company.com'
handleUrlAdd()
expect(urlFormError.value).toBeNull()
expect(urls.value).toEqual(['https://docs.company.com'])
})
it('does not stage the same URL twice', () => {
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
newUrl.value = 'docs.company.com'
handleUrlAdd()
newUrl.value = 'https://docs.company.com'
handleUrlAdd()
expect(urls.value).toEqual(['https://docs.company.com'])
expect(urlFormError.value).toBe('该 URL 已添加到当前批次')
})
})
})
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
const planLimits = vi.fn()
vi.mock('@/utils/storage', () => ({
subscriptionStorage: { getPlanLimits: () => planLimits() }
}))
import { useStagedSourceCap } from '@/composables/useStagedSourceCap'
const file = (name: string) => new File(['x'], name, { type: 'application/pdf' })
const setup = (cap: number, indexed: number, urls: string[], files: File[]) => {
planLimits.mockReturnValue({ maxKnowledgeSources: cap })
const u = ref(urls)
const f = ref(files)
return { u, f, ...useStagedSourceCap(ref(indexed), u, f) }
}
describe('useStagedSourceCap', () => {
beforeEach(() => planLimits.mockReset())
it('does nothing when the plan sets no cap', () => {
const { u, f, fitToCap } = setup(0, 3, ['a', 'b'], [file('x.pdf')])
expect(fitToCap('urls')).toBeNull()
expect(u.value).toEqual(['a', 'b'])
expect(f.value).toHaveLength(1)
})
it('drops staged files before older URLs, oldest first', () => {
const { u, f, fitToCap } = setup(2, 0, ['a', 'b'], [file('x.pdf')])
expect(fitToCap('urls')).toBeNull()
expect(f.value).toEqual([])
expect(u.value).toEqual(['a', 'b'])
})
it('drops the oldest URL when only URLs are staged', () => {
const { u, fitToCap } = setup(2, 0, ['a', 'b', 'c'], [])
expect(fitToCap('urls')).toBeNull()
expect(u.value).toEqual(['b', 'c'])
})
it('unstages new files that cannot fit and says how many can', () => {
const { u, f, fitToCap } = setup(3, 1, ['a'], [file('x.pdf'), file('y.pdf'), file('z.pdf')])
expect(fitToCap('files', 3)).toBe('你的套餐包含 3 个知识库来源,最多可选 2 个。')
expect(f.value).toEqual([])
expect(u.value).toEqual(['a'])
})
it('reports a cap already filled by indexed sources', () => {
const { u, fitToCap } = setup(1, 1, ['a'], [])
expect(fitToCap('urls')).toBe('你的套餐包含 1 个知识库来源,且已在用。')
expect(u.value).toEqual([])
})
})
@@ -0,0 +1,597 @@
<!--
Copyright 2024-2026 ReplyPier
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.
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { AddSourcePayload, CrawlScope } from '@/composables/useKnowledgeExplorer'
type SourceKind = 'website' | 'sitemap' | 'pdf' | 'text'
const props = withDefaults(
defineProps<{
submitting?: boolean
}>(),
{ submitting: false },
)
const emit = defineEmits<{
(e: 'close'): void
(e: 'submit', payload: AddSourcePayload): void
}>()
const kind = ref<SourceKind>('website')
const url = ref('')
const scope = ref<CrawlScope>('host')
const files = ref<File[]>([])
const title = ref('')
const content = ref('')
const dragOver = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const types: { key: SourceKind; title: string; sub: string }[] = [
{ key: 'website', title: '网站', sub: '抓取单个页面或整个站点' },
{ key: 'sitemap', title: '站点地图', sub: '索引其中列出的所有页面' },
{ key: 'pdf', title: '文档', sub: 'PDF 文件,最大 25 MB' },
{ key: 'text', title: '文本', sub: '直接粘贴内容' },
]
const willQueue = computed(() => kind.value !== 'text')
const submitLabel = computed(() => (kind.value === 'text' ? '添加页面' : '加入抓取队列'))
const canSubmit = computed(() => {
if (props.submitting) return false
if (kind.value === 'website' || kind.value === 'sitemap') return url.value.trim().length > 0
if (kind.value === 'pdf') return files.value.length > 0
return title.value.trim().length > 0 && content.value.trim().length > 0
})
function normalizeUrl(raw: string): string {
const v = raw.trim()
// Leave an explicit scheme alone; otherwise assume https.
return v.includes('://') ? v : `https://${v}`
}
// Parsed form of what's typed so far, used to name the host/section in the
// scope options. Null while the URL is empty or not yet valid.
const parsedUrl = computed<URL | null>(() => {
if (!url.value.trim()) return null
try {
return new URL(normalizeUrl(url.value))
} catch {
return null
}
})
const seedHost = computed(() => parsedUrl.value?.hostname ?? '')
// The section is the URL's own path. Empty for a homepage URL — there is no
// section to speak of, and "pages under /" would just be "This site" again.
const seedSection = computed(() => parsedUrl.value?.pathname.replace(/\/$/, '') ?? '')
const scopes = computed<{ key: CrawlScope; title: string; sub: string }[]>(() => {
const host = seedHost.value
const section = seedSection.value
return [
{ key: 'page', title: '仅此页面', sub: '仅索引上方这个 URL。' },
...(section
? [
{
key: 'path' as CrawlScope,
title: '本栏目',
sub: `仅 ${host} 上 ${section}/ 下的页面。`,
},
]
: []),
{
key: 'host',
title: '整个站点',
sub: host ? `${host} 上的所有页面。` : '同一主机上的所有页面。',
},
{
key: 'domain',
// No domain name in the copy on purpose: working out that
// paywithatoa.co.uk is one registrable domain (not 'co.uk') needs the
// ccTLD list the backend keeps, and a second copy of it would drift.
title: '整个域名',
sub: '也包括其他子域名,如 blog、docs、app。',
},
]
})
// Editing the URL down to a homepage removes the section option; don't leave a
// scope selected that is no longer on screen.
watch(scopes, (options) => {
if (!options.some((option) => option.key === scope.value)) scope.value = 'host'
})
function pickFiles() {
fileInput.value?.click()
}
const isPdf = (file: File) => file.type === 'application/pdf' || /\.pdf$/i.test(file.name)
function onFilesSelected(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) files.value = Array.from(input.files).filter(isPdf)
}
function onDrop(event: DragEvent) {
dragOver.value = false
const dropped = event.dataTransfer?.files
if (!dropped) return
// Only accept PDFs — the backend upload endpoint validates PDF magic bytes.
const pdfs = Array.from(dropped).filter(isPdf)
if (pdfs.length) files.value = pdfs
}
function submit() {
if (!canSubmit.value) return
if (kind.value === 'website') {
emit('submit', { type: 'website', url: normalizeUrl(url.value), scope: scope.value })
} else if (kind.value === 'sitemap') {
emit('submit', { type: 'sitemap', url: normalizeUrl(url.value) })
} else if (kind.value === 'pdf') {
emit('submit', { type: 'pdf', files: files.value })
} else {
emit('submit', { type: 'text', title: title.value.trim(), content: content.value })
}
}
</script>
<template>
<div class="scrim" @click.self="emit('close')">
<div class="modal" role="dialog" aria-modal="true" aria-label="添加知识来源">
<div class="modal__head">
<div>
<h3 class="modal__title">添加知识来源</h3>
<p class="modal__sub">选择来源类型,我们会在后台抓取并建立索引。</p>
</div>
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<div class="types">
<button
v-for="t in types"
:key="t.key"
type="button"
class="type"
:class="{ 'type--active': kind === t.key }"
@click="kind = t.key"
>
<span class="type__title">{{ t.title }}</span>
<span class="type__sub">{{ t.sub }}</span>
</button>
</div>
<div class="body">
<!-- website -->
<template v-if="kind === 'website'">
<label class="field-label" for="kb-add-url">页面 URL</label>
<input id="kb-add-url" v-model="url" class="text-input" type="text"
placeholder="https://docs.yourcompany.com/help" @keyup.enter="submit" />
<label class="field-label">抓取范围</label>
<div class="scope">
<button v-for="opt in scopes" :key="opt.key" type="button" class="scope__opt"
:class="{ 'scope__opt--active': scope === opt.key }" @click="scope = opt.key">
<span class="radio" :class="{ 'radio--on': scope === opt.key }"></span>
<span>
<span class="scope__title">{{ opt.title }}</span>
<span class="scope__sub">{{ opt.sub }}</span>
</span>
</button>
</div>
<p v-if="scope !== 'page'" class="scope__hint">
系统会发现并排队抓取关联页面,数量以套餐上限为准。
</p>
</template>
<!-- sitemap -->
<template v-else-if="kind === 'sitemap'">
<label class="field-label" for="kb-add-sitemap">站点地图 URL</label>
<input id="kb-add-sitemap" v-model="url" class="text-input" type="text"
placeholder="https://yourcompany.com/sitemap.xml" @keyup.enter="submit" />
<div class="note note--teal">
我们会读取站点地图,并将其中列出的每个页面加入抓取队列。较大的站点地图会分批处理。
</div>
</template>
<!-- document -->
<template v-else-if="kind === 'pdf'">
<div
class="drop"
:class="{ 'drop--over': dragOver }"
@click="pickFiles"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="onDrop"
>
<input ref="fileInput" type="file" accept="application/pdf" multiple class="hidden-file"
@change="onFilesSelected" />
<span v-if="files.length" class="drop__name">{{ files.map((f) => f.name).join(', ') }}</span>
<span v-else class="drop__name">拖入 PDF 或点击选择文件</span>
<span class="drop__hint">PDF 文件 · 每个最大 25 MB</span>
</div>
<div class="note note--coral">
我们会提取文本、切分成多个章节,并逐一建立索引。扫描版 PDF 会经过 OCR 处理。
</div>
</template>
<!-- text -->
<template v-else>
<label class="field-label" for="kb-add-title">标题</label>
<input id="kb-add-title" v-model="title" class="text-input" type="text" placeholder="例如:退款政策" />
<label class="field-label" for="kb-add-content">内容</label>
<textarea id="kb-add-content" v-model="content" class="textarea"
placeholder="粘贴或输入希望智能体学习的内容…"></textarea>
</template>
</div>
<div class="foot">
<span class="foot__note">
<span class="foot__dot" :class="willQueue ? 'foot__dot--queue' : 'foot__dot--instant'"></span>
<template v-if="willQueue">已加入抓取队列,索引完成后会通知你。</template>
<template v-else>立即添加,无需抓取。</template>
</span>
<div class="foot__actions">
<button class="btn btn--ghost" type="button" :disabled="submitting" @click="emit('close')">取消</button>
<button class="btn btn--primary" type="button" :disabled="!canSubmit" @click="submit">
{{ submitting ? '添加中…' : submitLabel }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.scrim {
position: fixed;
inset: 0;
z-index: 100;
background: var(--scrim);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 60px 20px;
overflow-y: auto;
}
.modal {
width: 560px;
max-width: 100%;
background: var(--bg2);
border: 1px solid var(--o12);
border-radius: 20px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.modal__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--o08);
}
.modal__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 19px;
color: var(--text);
margin: 0 0 4px;
}
.modal__sub {
font-size: 13px;
color: var(--muted);
margin: 0;
}
.icon-btn {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 9px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
background: var(--o08);
color: var(--text2);
}
.types {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 9px;
padding: 18px 24px 4px;
}
.type {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 13px;
border-radius: 11px;
cursor: pointer;
text-align: left;
background: transparent;
border: 1.5px solid var(--o10);
}
.type:hover {
background: var(--o04);
}
.type--active {
background: var(--accent-bg-06);
border-color: var(--accent-solid);
}
.type__title {
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
}
.type__sub {
font-size: 11.5px;
color: var(--muted2);
line-height: 1.3;
}
.body {
padding: 16px 24px 4px;
min-height: 172px;
}
.field-label {
display: block;
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.07em;
color: var(--muted2);
margin-bottom: 8px;
}
.field-label:not(:first-child) {
margin-top: 16px;
}
.text-input,
.textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 11px;
padding: 12px 14px;
font-size: 14px;
color: var(--text);
outline: none;
font-family: var(--font-sans);
}
.textarea {
min-height: 120px;
line-height: 1.6;
color: var(--text3);
resize: vertical;
}
.text-input:focus,
.textarea:focus {
border-color: var(--accent-border, var(--accent-ink));
}
.scope {
display: flex;
flex-direction: column;
gap: 8px;
}
.scope__opt {
display: flex;
align-items: flex-start;
gap: 11px;
width: 100%;
padding: 12px 13px;
border-radius: 11px;
cursor: pointer;
text-align: left;
background: transparent;
border: 1.5px solid var(--o10);
}
.scope__opt--active {
background: var(--accent-bg-06);
border-color: var(--accent-solid);
}
.radio {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 1px;
border-radius: 50%;
border: 2px solid var(--o20);
}
.radio--on {
border-color: var(--accent-solid);
background: radial-gradient(circle, var(--accent-solid) 0 45%, transparent 48%);
}
.scope__title {
display: block;
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
}
.scope__sub {
display: block;
font-size: 12px;
color: var(--muted2);
margin-top: 1px;
overflow-wrap: anywhere;
}
.scope__hint {
margin: 8px 2px 0;
font-size: 12px;
color: var(--muted2);
}
.drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 30px 20px;
background: var(--bg);
border: 1.5px dashed var(--o20);
border-radius: 14px;
cursor: pointer;
text-align: center;
}
.drop--over {
border-color: var(--c-coral);
background: var(--coral-bg);
}
.hidden-file {
display: none;
}
.drop__name {
font-size: 14px;
font-weight: 600;
color: var(--text2);
word-break: break-word;
}
.drop__hint {
font-size: 12px;
color: var(--muted2);
}
.note {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px 14px;
border-radius: 11px;
font-size: 12.5px;
color: var(--text3);
line-height: 1.5;
margin-top: 14px;
}
.note--teal {
background: var(--teal-bg);
border: 1px solid var(--teal-border);
}
.note--coral {
background: var(--coral-bg);
border: 1px solid var(--coral-border);
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 16px 24px;
border-top: 1px solid var(--o08);
background: var(--surface);
}
.foot__note {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
color: var(--muted);
line-height: 1.4;
}
.foot__dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.foot__dot--queue {
background: var(--c-purple);
}
.foot__dot--instant {
background: var(--c-teal);
}
.foot__actions {
display: flex;
gap: 9px;
flex-shrink: 0;
}
.btn {
padding: 10px 18px;
border-radius: 10px;
font-size: 13.5px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
border: 1px solid transparent;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn--ghost {
background: transparent;
border-color: var(--o12);
color: var(--muted);
}
.btn--ghost:hover:not(:disabled) {
background: var(--o05);
}
.btn--primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.btn--primary:hover:not(:disabled) {
filter: brightness(1.05);
}
</style>
@@ -0,0 +1,505 @@
<!--
Copyright 2024-2026 ReplyPier
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.
-->
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import {
useKnowledgeExplorer,
type ExplorerSource,
type AddSourcePayload,
} from '@/composables/useKnowledgeExplorer'
import KnowledgeSourceTree from './KnowledgeSourceTree.vue'
import KnowledgePageDetail from './KnowledgePageDetail.vue'
import KnowledgePageEditor from './KnowledgePageEditor.vue'
import KnowledgePlanMeters from './KnowledgePlanMeters.vue'
import KnowledgeAddSourceModal from './KnowledgeAddSourceModal.vue'
import KnowledgeLinkModal from './KnowledgeLinkModal.vue'
const props = withDefaults(
defineProps<{
mode: 'agent' | 'org'
organizationId: string
agentId?: string
showPlanMeters?: boolean
title?: string
description?: string
variant?: 'page' | 'section'
}>(),
{
agentId: undefined,
showPlanMeters: false,
title: '',
description: '',
variant: 'section',
},
)
const ex = useKnowledgeExplorer(props.mode, props.agentId, props.organizationId)
// Add-source modal state.
const addOpen = ref(false)
const isSubmittingSource = ref(false)
// A single, reusable confirm dialog for the destructive actions.
interface ConfirmState {
title: string
message: string
actionLabel: string
busyLabel: string
action: () => Promise<void>
}
const confirmState = ref<ConfirmState | null>(null)
const largestSource = computed(() =>
ex.sources.value.reduce<ExplorerSource | null>((max, s) => {
if (!max || (s.pages ?? s.pageStubs).length > (max.pages ?? max.pageStubs).length) return s
return max
}, null),
)
const largestSubpageCount = computed(() => {
const s = largestSource.value
return s ? (s.pages ?? s.pageStubs).length : 0
})
function openAdd() {
addOpen.value = true
}
async function onSubmitSource(payload: AddSourcePayload) {
if (isSubmittingSource.value) return
isSubmittingSource.value = true
try {
const ok = await ex.submitSource(payload)
if (ok) addOpen.value = false
} finally {
isSubmittingSource.value = false
}
}
function askDeleteSource(source: ExplorerSource) {
if (source.queued && source.queuedStatus === 'error') {
// A failed crawl — not an in-flight one to "cancel".
confirmState.value = {
title: '忽略来源',
message: `忽略失败的来源“${source.name}”?`,
actionLabel: '忽略',
busyLabel: '正在忽略…',
action: () => ex.deleteSource(source),
}
} else if (source.queued) {
confirmState.value = {
title: '取消抓取',
message: `取消对“${source.name}”的排队抓取?`,
actionLabel: '取消抓取',
busyLabel: '正在取消…',
action: () => ex.deleteSource(source),
}
} else if (props.mode === 'agent') {
// In an agent's tab, "Remove" detaches the source from this agent but keeps
// it in the organization's knowledge — it does not delete the source.
confirmState.value = {
title: '从智能体移除',
message: `从该智能体移除“${source.name}”?它仍会保留在组织的知识库中。`,
actionLabel: '移除',
busyLabel: '正在移除…',
action: () => ex.unlinkSource(source.id),
}
} else {
confirmState.value = {
title: '删除来源',
message: `删除“${source.name}”及其所有页面?此操作无法撤销。`,
actionLabel: '删除',
busyLabel: '正在删除…',
action: () => ex.deleteSource(source),
}
}
}
function askDeletePage() {
const page = ex.selectedPage.value
if (!page) return
confirmState.value = {
title: '删除页面',
message: `从该来源删除“${page.title}”?此操作无法撤销。`,
actionLabel: '删除',
busyLabel: '正在删除…',
action: () => ex.deletePage(),
}
}
async function runConfirm() {
const state = confirmState.value
if (!state) return
await state.action()
confirmState.value = null
}
onMounted(() => {
ex.refresh()
ex.startPolling()
})
onUnmounted(() => {
ex.stopPolling()
})
</script>
<template>
<div class="explorer">
<header class="explorer__header">
<div class="explorer__heading">
<component :is="variant === 'page' ? 'h1' : 'h3'" class="explorer__title" :class="`explorer__title--${variant}`">
{{ title }}
</component>
<p v-if="description" class="explorer__desc">{{ description }}</p>
</div>
<div class="explorer__actions">
<slot name="actions" />
<button v-if="mode === 'agent'" class="btn btn--ghost" type="button" @click="ex.openLinkPicker">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" /></svg>
关联已有
</button>
<button class="btn btn--primary" type="button" @click="openAdd">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
添加来源
</button>
</div>
</header>
<KnowledgePlanMeters
v-if="showPlanMeters"
class="explorer__meters"
:source-count="ex.sources.value.length"
:largest-source-name="largestSource?.name ?? null"
:largest-subpage-count="largestSubpageCount"
/>
<div v-if="ex.error.value" class="banner" role="alert">
{{ ex.error.value }}
<button class="banner__close" type="button" aria-label="关闭" @click="ex.error.value = null">×</button>
</div>
<div class="grid">
<KnowledgeSourceTree
class="grid__tree"
:sources="ex.filteredSources.value"
:selected-page-id="ex.selectedPageId.value"
:selected-source-id="ex.selectedSourceId.value"
:query="ex.query.value"
:status-of="ex.sourceStatus"
:page-rows-of="ex.pageRows"
@update:query="ex.query.value = $event"
@toggle="ex.toggleSource"
@select="(source, pageId) => ex.selectPage(source, pageId)"
@delete-source="askDeleteSource"
@add-page="ex.startAddPage"
/>
<div class="grid__detail">
<KnowledgePageEditor
v-if="ex.editing.value && (ex.selectedPage.value || ex.isAddingPage.value)"
:title="ex.draftTitle.value"
:content="ex.draftContent.value"
:saving="ex.isSaving.value"
:submit-label="ex.isAddingPage.value ? '添加子页面' : '保存页面'"
:url="ex.draftUrl.value"
:show-url="ex.isAddingPage.value"
@update:title="ex.draftTitle.value = $event"
@update:content="ex.draftContent.value = $event"
@update:url="ex.draftUrl.value = $event"
@save="ex.savePage"
@cancel="ex.cancelEdit"
/>
<KnowledgePageDetail
v-else-if="ex.selectedPage.value && ex.selectedSource.value"
:page="ex.selectedPage.value"
:source-name="ex.selectedSource.value.name"
:source-type="ex.selectedSource.value.type"
:agents="ex.selectedSource.value.agents"
:status="ex.sourceStatus(ex.selectedSource.value)"
:deleting="ex.isDeleting.value"
@edit="ex.startEdit"
@delete="askDeletePage"
/>
<div v-else class="empty">
<div class="empty__orb"></div>
<div class="empty__title">选择一个页面查看</div>
<p class="empty__text">
在左侧选择任一已提取的页面,即可查看内容、编辑或移除,也可以添加自己的来源。
</p>
</div>
</div>
</div>
<div v-if="confirmState" class="confirm" role="dialog" aria-modal="true">
<div class="confirm__card">
<h3 class="confirm__title">{{ confirmState.title }}</h3>
<p class="confirm__msg">{{ confirmState.message }}</p>
<div class="confirm__actions">
<button class="btn btn--ghost" type="button" :disabled="ex.isDeleting.value" @click="confirmState = null">取消</button>
<button class="btn btn--danger-solid" type="button" :disabled="ex.isDeleting.value" @click="runConfirm">
{{ ex.isDeleting.value ? confirmState.busyLabel : confirmState.actionLabel }}
</button>
</div>
</div>
</div>
<KnowledgeAddSourceModal
v-if="addOpen"
:submitting="isSubmittingSource"
@close="addOpen = false"
@submit="onSubmitSource"
/>
<KnowledgeLinkModal
v-if="ex.linkPickerOpen.value"
:sources="ex.orgSources.value"
:linked-ids="ex.linkedSourceIds.value"
:busy-ids="ex.linkingIds.value"
:loading="ex.isLoadingOrgSources.value"
:error="ex.orgSourcesError.value"
@close="ex.linkPickerOpen.value = false"
@link="ex.linkSource"
@unlink="ex.unlinkSource"
/>
</div>
</template>
<style scoped>
.explorer {
display: flex;
flex-direction: column;
min-width: 0;
}
.explorer__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.explorer__heading {
min-width: 0;
}
.explorer__title {
font-family: var(--font-display);
font-weight: 700;
color: var(--text);
margin: 0 0 6px;
}
.explorer__title--page {
font-size: 30px;
letter-spacing: -0.02em;
}
.explorer__title--section {
font-size: 20px;
}
.explorer__desc {
font-size: 14px;
color: var(--muted);
margin: 0;
max-width: 560px;
line-height: 1.55;
}
.explorer__actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.explorer__meters {
margin-bottom: 14px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 47px;
padding: 0 18px;
border-radius: 11px;
font-size: 14px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
border: 1px solid transparent;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn--primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
.btn--ghost {
background: var(--o05);
border-color: var(--o12);
color: var(--text2);
}
.btn--ghost:hover:not(:disabled) { background: var(--o08); }
.btn--danger-solid {
background: var(--c-coral);
color: var(--on-accent-solid);
}
.btn--danger-solid:hover:not(:disabled) { filter: brightness(1.05); }
.banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 14px;
margin-bottom: 16px;
background: var(--error-bg);
border: 1px solid var(--coral-border);
border-radius: 11px;
color: var(--c-coral);
font-size: 13.5px;
}
.banner__close {
background: transparent;
border: none;
color: inherit;
font-size: 18px;
cursor: pointer;
line-height: 1;
}
.grid {
display: grid;
grid-template-columns: 344px 1fr;
gap: 18px;
align-items: stretch;
}
@media (max-width: 860px) {
.grid {
grid-template-columns: 1fr;
}
}
.grid__tree,
.grid__detail {
height: 640px;
}
.grid__detail {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px;
}
.empty__orb {
width: 52px;
height: 52px;
margin-bottom: 18px;
border-radius: 50%;
background: conic-gradient(from 0deg, var(--c-lime), var(--c-purple), var(--c-teal), var(--c-coral), var(--c-lime));
opacity: 0.85;
}
.empty__title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
color: var(--text2);
margin-bottom: 8px;
}
.empty__text {
font-size: 14px;
color: var(--muted);
max-width: 320px;
line-height: 1.55;
margin: 0;
}
.confirm {
position: fixed;
inset: 0;
z-index: 100;
background: var(--scrim);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.confirm__card {
background: var(--surface);
border: 1px solid var(--o12);
border-radius: 16px;
padding: 24px;
max-width: 400px;
width: 100%;
}
.confirm__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 18px;
color: var(--text);
margin: 0 0 8px;
}
.confirm__msg {
font-size: 14px;
color: var(--muted);
line-height: 1.55;
margin: 0 0 20px;
}
.confirm__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -0,0 +1,308 @@
<!--
Copyright 2024-2026 ReplyPier
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.
-->
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { KnowledgeItem } from '@/types/knowledge'
const props = defineProps<{
sources: KnowledgeItem[]
linkedIds: Set<number>
busyIds: Set<number>
loading: boolean
error: string | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'link', knowledgeId: number): void
(e: 'unlink', knowledgeId: number): void
}>()
const query = ref('')
const filteredSources = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return props.sources
return props.sources.filter(
(s) => s.name.toLowerCase().includes(q) || s.type.toLowerCase().includes(q),
)
})
</script>
<template>
<div class="scrim" @click.self="emit('close')">
<div class="modal" role="dialog" aria-modal="true" aria-label="关联已有知识">
<div class="modal__head">
<div>
<h3 class="modal__title">关联已有知识</h3>
<p class="modal__sub">把组织中的知识关联到该智能体,供其使用。</p>
</div>
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<div class="search">
<input v-model="query" class="text-input" type="text" placeholder="搜索知识来源…" />
</div>
<div class="body">
<div v-if="loading" class="state">正在加载知识来源…</div>
<div v-else-if="error" class="state state--error">{{ error }}</div>
<div v-else-if="!filteredSources.length" class="state">
{{ query ? '没有匹配的来源。' : '组织中还没有可关联的知识。' }}
</div>
<ul v-else class="list">
<li v-for="item in filteredSources" :key="item.id" class="row">
<div class="row__main">
<span class="row__name">{{ item.name }}</span>
<span class="row__type">{{ item.type }}</span>
</div>
<button
v-if="linkedIds.has(item.id)"
class="btn btn--linked"
type="button"
:disabled="busyIds.has(item.id)"
@click="emit('unlink', item.id)"
>
{{ busyIds.has(item.id) ? '正在移除…' : '取消关联' }}
</button>
<button
v-else
class="btn btn--primary"
type="button"
:disabled="busyIds.has(item.id)"
@click="emit('link', item.id)"
>
{{ busyIds.has(item.id) ? '正在关联…' : '关联' }}
</button>
</li>
</ul>
</div>
<div class="foot">
<button class="btn btn--ghost" type="button" @click="emit('close')">完成</button>
</div>
</div>
</div>
</template>
<style scoped>
.scrim {
position: fixed;
inset: 0;
z-index: 100;
background: var(--scrim);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 60px 20px;
overflow-y: auto;
}
.modal {
width: 560px;
max-width: 100%;
background: var(--bg2);
border: 1px solid var(--o12);
border-radius: 20px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--o08);
}
.modal__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 19px;
color: var(--text);
margin: 0 0 4px;
}
.modal__sub {
font-size: 13px;
color: var(--muted);
margin: 0;
}
.icon-btn {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 9px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
background: var(--o08);
color: var(--text2);
}
.search {
padding: 16px 24px 4px;
}
.text-input {
width: 100%;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 11px;
padding: 12px 14px;
font-size: 14px;
color: var(--text);
outline: none;
font-family: var(--font-sans);
}
.text-input:focus {
border-color: var(--accent-border, var(--accent-ink));
}
.body {
padding: 12px 24px 4px;
min-height: 180px;
max-height: 340px;
overflow-y: auto;
}
.state {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
font-size: 13.5px;
color: var(--muted);
text-align: center;
}
.state--error {
color: var(--c-coral);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 13px;
border-radius: 11px;
border: 1px solid var(--o10);
background: var(--o04);
}
.row__main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.row__name {
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
word-break: break-word;
}
.row__type {
font-size: 11.5px;
color: var(--muted2);
text-transform: capitalize;
}
.foot {
display: flex;
justify-content: flex-end;
gap: 9px;
padding: 16px 24px;
border-top: 1px solid var(--o08);
background: var(--surface);
}
.btn {
padding: 8px 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
border: 1px solid transparent;
white-space: nowrap;
flex-shrink: 0;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn--primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.btn--primary:hover:not(:disabled) {
filter: brightness(1.05);
}
.btn--linked {
background: var(--o05);
border-color: var(--o12);
color: var(--muted);
}
.btn--linked:hover:not(:disabled) {
background: var(--coral-bg);
border-color: var(--coral-border);
color: var(--c-coral);
}
.btn--ghost {
background: transparent;
border-color: var(--o12);
color: var(--muted);
}
.btn--ghost:hover:not(:disabled) {
background: var(--o05);
}
</style>
@@ -0,0 +1,377 @@
<!--
Copyright 2024-2026 ReplyPier
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.
-->
<script setup lang="ts">
import { computed } from 'vue'
import type { KnowledgeLinkedAgent, KnowledgeSubPage } from '@/types/knowledge'
import type { SourceStatus } from '@/composables/useKnowledgeExplorer'
const props = defineProps<{
page: KnowledgeSubPage
sourceName: string
sourceType: string
agents?: KnowledgeLinkedAgent[]
status: SourceStatus
deleting: boolean
}>()
// First-letter avatar for an agent chip.
const initial = (name: string) => (name.trim()[0] || '?').toUpperCase()
defineEmits<{
(e: 'edit'): void
(e: 'delete'): void
}>()
const statusText: Record<SourceStatus, string> = {
queued: '等待中',
crawling: '抓取中',
synced: '已同步',
error: '需要同步',
}
const paragraphs = computed(() =>
(props.page.content || '')
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean),
)
const href = computed(() => {
const url = props.page.url || ''
return /^https?:/i.test(url) ? url : `https://${url}`
})
const updatedLabel = computed(() => {
if (!props.page.updated_at) return '未知'
const d = new Date(props.page.updated_at)
return Number.isNaN(d.getTime()) ? '未知' : d.toLocaleDateString()
})
</script>
<template>
<div class="detail">
<div class="detail__head">
<div class="crumbs">
<span class="crumbs__src" :title="sourceName">{{ sourceName }}</span>
<span class="crumbs__sep">/</span>
<span class="crumbs__type">{{ sourceType }}</span>
</div>
<div class="detail__title-row">
<div class="detail__title-wrap">
<h2 class="detail__title">{{ page.title }}</h2>
<a class="detail__url" :href="href" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<path d="M15 3h6v6" /><path d="M10 14 21 3" />
</svg>
<span class="detail__url-text">{{ page.url }}</span>
</a>
</div>
<div class="detail__actions">
<button class="btn btn--ghost" type="button" @click="$emit('edit')">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" />
</svg>
编辑
</button>
<button class="btn btn--danger" type="button" :disabled="deleting" title="删除页面"
@click="$emit('delete')">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
<path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
</svg>
</button>
</div>
</div>
<div class="meta">
<span class="meta__item">{{ page.word_count }} 词</span>
<span class="meta__sep"></span>
<span class="meta__item">更新于 {{ updatedLabel }}</span>
<span class="meta__sep"></span>
<span class="pill" :class="`pill--${status}`">
<span class="pill__dot"></span>{{ statusText[status] }}
</span>
</div>
<div v-if="agents && agents.length" class="usedby">
<span class="usedby__label">使用方</span>
<span v-for="agent in agents" :key="agent.id" class="agent-chip" :title="agent.name">
<span class="agent-chip__avatar">{{ initial(agent.name) }}</span>
<span class="agent-chip__name">{{ agent.name }}</span>
</span>
</div>
</div>
<div class="doc">
<p v-for="(para, i) in paragraphs" :key="i">{{ para }}</p>
<div v-if="paragraphs.length === 0" class="doc__empty">
该页面还没有内容,点击 <strong>编辑</strong> 添加内容。
</div>
</div>
</div>
</template>
<style scoped>
.detail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.detail__head {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--o07);
flex-shrink: 0;
}
.crumbs {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted2);
margin-bottom: 12px;
min-width: 0;
}
.crumbs__src {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
}
.crumbs__sep { color: var(--faint); }
.crumbs__type { color: var(--text3); }
.detail__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.detail__title-wrap {
flex: 1;
min-width: 0;
}
.detail__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 22px;
letter-spacing: -0.01em;
color: var(--text);
margin: 0 0 6px;
word-break: break-word;
}
.detail__url {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--c-teal);
text-decoration: none;
max-width: 100%;
}
.detail__url-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border-radius: 9px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
border: 1px solid var(--o12);
background: var(--o05);
color: var(--text2);
}
.btn:hover { background: var(--o08); }
.btn--danger {
width: 34px;
padding: 0;
justify-content: center;
height: 34px;
color: var(--muted);
}
.btn--danger:hover:not(:disabled) {
background: var(--coral-bg);
color: var(--c-coral);
border-color: var(--coral-border);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.meta {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted2);
}
.meta__sep {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--faint);
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 600;
border: 1px solid var(--teal-border);
background: var(--teal-bg);
color: var(--c-teal);
}
.pill__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.usedby {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 14px;
}
.usedby__label {
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted2);
}
.agent-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px 3px 3px;
border-radius: 999px;
border: 1px solid var(--o12);
background: var(--o05);
font-size: 12px;
font-weight: 600;
color: var(--text2);
max-width: 180px;
}
.agent-chip__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--teal-bg);
color: var(--c-teal);
font-size: 10.5px;
font-weight: 700;
flex-shrink: 0;
}
.agent-chip__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pill--crawling {
border-color: var(--warning-border, var(--o12));
background: var(--warning-bg);
color: var(--warning-color);
}
.pill--queued {
border-color: var(--purple-border, var(--o12));
background: var(--purple-bg, var(--o06));
color: var(--c-purple);
}
.pill--error {
border-color: var(--coral-border);
background: var(--coral-bg);
color: var(--c-coral);
}
.doc {
flex: 1;
overflow-y: auto;
padding: 24px 28px;
color: var(--text3);
font-size: 15px;
line-height: 1.7;
font-family: var(--font-sans);
}
.doc p {
margin: 0 0 14px;
white-space: pre-wrap;
}
.doc__empty {
text-align: center;
color: var(--muted2);
font-size: 14px;
padding: 40px 0;
}
</style>
@@ -0,0 +1,216 @@
<!--
Copyright 2024-2026 ReplyPier
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.
-->
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
title: string
content: string
saving: boolean
submitLabel?: string
url?: string
showUrl?: boolean
}>(),
{ submitLabel: '保存页面', url: '', showUrl: false },
)
const emit = defineEmits<{
(e: 'update:title', value: string): void
(e: 'update:content', value: string): void
(e: 'update:url', value: string): void
(e: 'save'): void
(e: 'cancel'): void
}>()
const canSave = computed(() => props.content.trim().length > 0 && !props.saving)
</script>
<template>
<div class="editor">
<div class="editor__body">
<label class="editor__label" for="kb-page-title">页面标题</label>
<input
id="kb-page-title"
class="editor__title"
:value="title"
type="text"
placeholder="页面标题"
@input="emit('update:title', ($event.target as HTMLInputElement).value)"
/>
<template v-if="showUrl">
<label class="editor__label" for="kb-page-url">来源 URL(可选)</label>
<input
id="kb-page-url"
class="editor__title editor__url"
:value="url"
type="url"
placeholder="https://example.com/page"
@input="emit('update:url', ($event.target as HTMLInputElement).value)"
/>
</template>
<label class="editor__label" for="kb-page-content">页面内容</label>
<textarea
id="kb-page-content"
class="editor__content"
:value="content"
placeholder="在此输入页面内容,段落之间留一个空行。"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
></textarea>
</div>
<div class="editor__foot">
<span class="editor__status">
<span class="editor__status-dot"></span>编辑中——保存后重新生成向量
</span>
<div class="editor__buttons">
<button class="btn btn--ghost" type="button" :disabled="saving" @click="emit('cancel')">取消</button>
<button class="btn btn--primary" type="button" :disabled="!canSave" @click="emit('save')">
{{ saving ? '保存中…' : submitLabel }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.editor {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.editor__body {
flex: 1;
overflow-y: auto;
padding: 18px 24px;
display: flex;
flex-direction: column;
}
.editor__label {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.08em;
color: var(--muted2);
margin-bottom: 8px;
}
.editor__title {
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 9px;
outline: none;
font-family: var(--font-display);
font-weight: 700;
font-size: 18px;
color: var(--text);
padding: 10px 12px;
margin-bottom: 16px;
}
.editor__url {
font-family: var(--font-mono);
font-weight: 400;
font-size: 13px;
color: var(--c-teal);
}
.editor__title:focus,
.editor__content:focus {
border-color: var(--accent-border, var(--accent-ink));
}
.editor__content {
flex: 1;
min-height: 320px;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 12px;
padding: 16px 18px;
font-size: 15px;
line-height: 1.7;
color: var(--text3);
outline: none;
resize: vertical;
font-family: var(--font-sans);
}
.editor__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 18px;
border-top: 1px solid var(--o10);
background: var(--bg2);
flex-shrink: 0;
}
.editor__status {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.editor__status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-coral);
}
.editor__buttons {
display: flex;
align-items: center;
gap: 9px;
}
.btn {
padding: 9px 18px;
border-radius: 9px;
font-size: 13.5px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
border: 1px solid transparent;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn--ghost {
background: transparent;
border-color: var(--o12);
color: var(--muted);
}
.btn--ghost:hover:not(:disabled) { background: var(--o05); }
.btn--primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
</style>
@@ -0,0 +1,171 @@
<!--
Copyright 2024-2026 ReplyPier
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.
-->
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
const props = defineProps<{
sourceCount: number
largestSourceName: string | null
largestSubpageCount: number
}>()
const { hasEnterpriseModule, subscriptionStore, initializeSubscriptionStore } =
useEnterpriseFeatures()
onMounted(async () => {
if (!hasEnterpriseModule) return
await initializeSubscriptionStore()
if (!subscriptionStore.value.currentPlan) {
await subscriptionStore.value.fetchCurrentPlan()
}
})
const plan = computed(() => subscriptionStore.value.currentPlan?.plan ?? null)
const maxSources = computed<number | null>(() => plan.value?.max_knowledge_sources ?? null)
const maxSubpages = computed<number | null>(() => plan.value?.max_sub_pages ?? null)
// Clamp a usage ratio to a readable meter width (min 6% so a tiny bar still shows).
function pct(used: number, limit: number | null): number {
if (!limit || limit <= 0) return 0
return Math.max(6, Math.min(100, Math.round((used / limit) * 100)))
}
const sourcesOver = computed(() => maxSources.value !== null && props.sourceCount > maxSources.value)
const subpagesOver = computed(
() => maxSubpages.value !== null && props.largestSubpageCount > maxSubpages.value,
)
</script>
<template>
<div class="meters">
<div class="meter" :class="{ 'meter--over': sourcesOver }">
<div class="meter__head">
<span class="meter__label">知识来源</span>
<span class="meter__value">
{{ sourceCount }}<template v-if="maxSources !== null"> / {{ maxSources }}</template>
</span>
</div>
<div v-if="maxSources !== null" class="meter__track">
<div class="meter__fill" :style="{ width: pct(sourceCount, maxSources) + '%' }"></div>
</div>
<div class="meter__note">
<template v-if="sourcesOver">已超出套餐的来源数量上限,升级后可关联更多来源。</template>
<template v-else-if="maxSources !== null">已关联 {{ sourceCount }} / {{ maxSources }} 个来源。</template>
<template v-else>已关联 {{ sourceCount }} 个来源。</template>
</div>
</div>
<div class="meter" :class="{ 'meter--over': subpagesOver }">
<div class="meter__head">
<span class="meter__label">每个来源的子页面</span>
<span class="meter__value">
<template v-if="maxSubpages !== null">最多 {{ maxSubpages }}</template>
<template v-else>—</template>
</span>
</div>
<div v-if="maxSubpages !== null" class="meter__track">
<div class="meter__fill meter__fill--alt"
:style="{ width: pct(largestSubpageCount, maxSubpages) + '%' }"></div>
</div>
<div class="meter__note">
<template v-if="largestSourceName">
最大来源:{{ largestSourceName }} — {{ largestSubpageCount }}<template v-if="maxSubpages !== null"> / {{ maxSubpages }}</template> 个子页面
</template>
<template v-else>还没有来源。</template>
</div>
</div>
</div>
</template>
<style scoped>
.meters {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
@media (max-width: 640px) {
.meters {
grid-template-columns: 1fr;
}
}
.meter {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 14px;
padding: 16px 18px;
}
.meter__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 11px;
}
.meter__label {
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
}
.meter__value {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--text3);
}
.meter--over .meter__value {
color: var(--c-coral);
}
.meter__track {
height: 7px;
border-radius: 999px;
background: var(--o07);
overflow: hidden;
}
.meter__fill {
height: 100%;
border-radius: 999px;
background: var(--c-teal);
transition: width 0.3s ease;
}
.meter__fill--alt {
background: var(--c-purple);
}
.meter--over .meter__fill {
background: var(--c-coral);
}
.meter__note {
font-size: 12.5px;
color: var(--muted);
margin-top: 10px;
line-height: 1.5;
}
.meter--over .meter__note {
color: var(--c-coral);
}
</style>
@@ -0,0 +1,501 @@
<!--
Copyright 2024-2026 ReplyPier
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.
-->
<script setup lang="ts">
import { computed } from 'vue'
import type { ExplorerSource, SourceStatus } from '@/composables/useKnowledgeExplorer'
interface PageRow {
page_id: string
title: string
words: number | null
}
const props = defineProps<{
sources: ExplorerSource[]
selectedPageId: string | null
selectedSourceId: number | null
query: string
statusOf: (source: ExplorerSource) => SourceStatus
pageRowsOf: (source: ExplorerSource) => PageRow[]
}>()
// Group page rows once per render (the template reads them twice per source).
const rowsBySource = computed(() => {
const map = new Map<number, PageRow[]>()
for (const source of props.sources) map.set(source.id, props.pageRowsOf(source))
return map
})
const emit = defineEmits<{
(e: 'update:query', value: string): void
(e: 'toggle', source: ExplorerSource): void
(e: 'select', source: ExplorerSource, pageId: string): void
(e: 'delete-source', source: ExplorerSource): void
(e: 'add-page', source: ExplorerSource): void
}>()
const statusLabel: Record<SourceStatus, string> = {
queued: '等待中',
crawling: '抓取中',
synced: '已同步',
error: '需要同步',
}
// A queued placeholder's error state is a failed crawl, not a source that
// merely "needs sync".
function queuedLabel(source: ExplorerSource): string {
const status = props.statusOf(source)
return status === 'error' ? '失败' : statusLabel[status]
}
function sourceGlyph(type: string): string {
const t = (type || '').toLowerCase()
if (t.includes('pdf') || t === 'file') return 'P'
if (t.includes('site')) return 'S'
if (t.includes('web')) return 'W'
return 'T'
}
// First-letter avatar for a linked agent.
const agentInitial = (name: string) => (name.trim()[0] || '?').toUpperCase()
// Show at most this many agent avatars on a source row; the rest fold into "+N".
const MAX_AVATARS = 3
</script>
<template>
<div class="tree">
<div class="tree__search">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
:value="query"
type="search"
placeholder="搜索页面…"
aria-label="搜索页面"
@input="emit('update:query', ($event.target as HTMLInputElement).value)"
/>
</div>
<div class="tree__body">
<div v-for="source in sources" :key="source.id" class="src">
<div class="src__row">
<button
class="src__toggle"
type="button"
:aria-expanded="source.queued ? undefined : source.expanded"
:disabled="source.queued"
@click="!source.queued && emit('toggle', source)"
>
<svg v-if="!source.queued" class="chev" :class="{ 'chev--open': source.expanded }" viewBox="0 0 24 24"
width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l6 6-6 6" />
</svg>
<span v-else class="chev-spacer"></span>
<span class="src__glyph" :class="`src__glyph--${sourceGlyph(source.type).toLowerCase()}`">{{ sourceGlyph(source.type) }}</span>
<span class="src__meta">
<span class="src__name" :title="source.name">{{ source.name }}</span>
<span class="src__count" :class="{ 'src__count--error': source.queued && statusOf(source) === 'error' }">
{{ source.queued ? queuedLabel(source) : (source.pages ?? source.pageStubs).length + ' 个子页面' }}
</span>
</span>
</button>
<span
v-if="!source.queued && source.agents.length"
class="agents"
:title="`使用方:${source.agents.map((a) => a.name).join('、')}`"
>
<span
v-for="agent in source.agents.slice(0, MAX_AVATARS)"
:key="agent.id"
class="agents__avatar"
>{{ agentInitial(agent.name) }}</span>
<span v-if="source.agents.length > MAX_AVATARS" class="agents__more">
+{{ source.agents.length - MAX_AVATARS }}
</span>
</span>
<span class="dot" :class="`dot--${statusOf(source)}`" :title="statusLabel[statusOf(source)]"></span>
<button
v-if="!source.queued"
class="src__add"
type="button"
title="为该来源添加子页面"
@click="emit('add-page', source)"
>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
</button>
<button
class="src__del"
type="button"
:title="!source.queued ? '删除此来源' : (statusOf(source) === 'error' ? '忽略失败的来源' : '取消抓取')"
@click="emit('delete-source', source)"
>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
<path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
</svg>
</button>
</div>
<div v-if="source.queued && source.queuedError" class="src__error">
{{ source.queuedError }}
</div>
<div v-if="source.expanded && !source.queued" class="src__pages">
<div v-if="source.loadingContent" class="src__hint">正在加载页面…</div>
<div v-else-if="source.contentError" class="src__hint src__hint--error">{{ source.contentError }}</div>
<template v-else>
<button
v-for="row in (rowsBySource.get(source.id) || [])"
:key="row.page_id"
type="button"
class="pg"
:class="{ 'pg--active': source.id === selectedSourceId && row.page_id === selectedPageId }"
@click="emit('select', source, row.page_id)"
>
<span class="pg__dot"></span>
<span class="pg__title" :title="row.title">{{ row.title }}</span>
<span v-if="row.words !== null" class="pg__words">{{ row.words }} 词</span>
</button>
<div v-if="(rowsBySource.get(source.id) || []).length === 0" class="src__hint">尚未提取任何页面。</div>
<button type="button" class="pg pg--add" @click="emit('add-page', source)">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
添加子页面
</button>
</template>
</div>
</div>
<div v-if="sources.length === 0" class="tree__empty">
<template v-if="query">没有匹配“{{ query }}”的页面。</template>
<template v-else>还没有知识来源。</template>
</div>
</div>
</div>
</template>
<style scoped>
.tree {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
min-height: 0;
}
.tree__search {
display: flex;
align-items: center;
gap: 9px;
margin: 14px;
padding: 0 12px;
background: var(--bg);
border: 1px solid var(--o10);
border-radius: 10px;
color: var(--muted2);
flex-shrink: 0;
}
.tree__search input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-size: 13.5px;
padding: 10px 0;
}
.tree__body {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.src {
margin-bottom: 4px;
}
.src__row {
display: flex;
align-items: center;
gap: 4px;
border-radius: 9px;
padding-right: 6px;
}
.src__row:hover {
background: var(--o04);
}
.src__toggle {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
cursor: pointer;
background: transparent;
border: none;
text-align: left;
color: inherit;
}
.chev {
flex-shrink: 0;
color: var(--muted2);
transition: transform 0.18s ease;
}
.chev-spacer {
width: 14px;
flex-shrink: 0;
}
.src__toggle:disabled {
cursor: default;
}
.chev--open {
transform: rotate(90deg);
}
.src__glyph {
width: 26px;
height: 26px;
flex-shrink: 0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 700;
font-size: 12px;
background: var(--accent-bg-12);
color: var(--accent-ink);
}
.src__glyph--p { background: var(--coral-bg); color: var(--c-coral); }
.src__glyph--s { background: var(--teal-bg); color: var(--c-teal); }
.src__glyph--t { background: var(--purple-bg, var(--o08)); color: var(--c-purple); }
.src__meta {
flex: 1;
min-width: 0;
}
.src__name {
display: block;
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.src__count {
display: block;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted2);
margin-top: 2px;
}
.src__count--error {
color: var(--c-coral);
}
.agents {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.agents__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--teal-bg);
color: var(--c-teal);
font-size: 10px;
font-weight: 700;
border: 1.5px solid var(--surface);
margin-left: -6px;
}
.agents__avatar:first-child {
margin-left: 0;
}
.agents__more {
margin-left: 4px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted2);
}
.dot {
width: 7px;
height: 7px;
flex-shrink: 0;
border-radius: 50%;
background: var(--muted2);
}
.dot--synced { background: var(--c-teal); box-shadow: 0 0 6px var(--c-teal); }
.dot--crawling { background: var(--warning-color); }
.dot--queued { background: var(--c-purple); }
.dot--error { background: var(--c-coral); }
.src__add,
.src__del {
width: 26px;
height: 26px;
flex-shrink: 0;
border-radius: 7px;
background: transparent;
border: 1px solid transparent;
color: var(--muted2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s ease;
}
.src__row:hover .src__add,
.src__row:hover .src__del { opacity: 1; }
.src__add:hover {
background: var(--accent-bg-08);
color: var(--accent-ink);
border-color: var(--accent-border, var(--o12));
}
.src__del:hover {
background: var(--coral-bg);
color: var(--c-coral);
border-color: var(--coral-border);
}
.src__pages {
padding: 2px 0 6px 18px;
}
.pg {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
margin: 1px 0;
border: none;
cursor: pointer;
text-align: left;
border-radius: 8px;
background: transparent;
color: inherit;
}
.pg:hover { background: var(--o04); }
.pg--active { background: var(--accent-bg-08); }
.pg--add {
gap: 8px;
font-size: 12.5px;
font-weight: 600;
font-family: var(--font-sans);
color: var(--muted2);
}
.pg--add:hover {
color: var(--accent-ink);
}
.pg__dot {
width: 6px;
height: 6px;
flex-shrink: 0;
border-radius: 50%;
background: var(--c-teal);
}
.pg__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 500;
color: var(--text3);
}
.pg--active .pg__title {
color: var(--text);
font-weight: 600;
}
.pg__words {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 10px;
color: var(--faint);
}
.src__hint {
padding: 8px 10px;
font-size: 12.5px;
color: var(--muted2);
}
.src__hint--error { color: var(--c-coral); }
.src__error {
margin: 2px 10px 6px 44px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.45;
color: var(--c-coral);
background: var(--coral-bg);
border: 1px solid var(--coral-border);
border-radius: 8px;
}
.tree__empty {
padding: 40px 20px;
text-align: center;
font-size: 13px;
color: var(--muted2);
}
</style>
@@ -0,0 +1,669 @@
/*
Copyright 2024-2026 ReplyPier
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 { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import { showPlanLimitError } from '@/utils/planLimitToast'
import type {
KnowledgeItem,
KnowledgeLinkedAgent,
KnowledgePage,
KnowledgeSubPage,
QueueItem,
} from '@/types/knowledge'
import { knowledgeService } from '@/services/knowledge'
import { basePageId, groupChunksIntoPages, titleFromId } from '@/utils/knowledgePages'
export type ExplorerMode = 'agent' | 'org'
export type SourceStatus = 'queued' | 'crawling' | 'synced' | 'error'
// How far a website crawl may follow links from the URL it starts at.
// 'page' indexes only that URL; the rest map to the backend's crawl scopes.
export type CrawlScope = 'page' | 'path' | 'host' | 'domain'
// What the add-source modal submits; discriminated by `type`.
export type AddSourcePayload =
| { type: 'website'; url: string; scope: CrawlScope }
| { type: 'sitemap'; url: string }
| { type: 'pdf'; files: File[] }
| { type: 'text'; title: string; content: string }
/** A knowledge source enriched with lazily-loaded, grouped sub-page content. */
export interface ExplorerSource {
id: number
name: string
type: string
// Agents this source is linked to (for the "Used by" chips).
agents: KnowledgeLinkedAgent[]
pageStubs: KnowledgePage[]
expanded: boolean
loadingContent: boolean
contentError: string | null
pages: KnowledgeSubPage[] | null
// True for a placeholder built from an in-flight queue item that has not yet
// produced a real Knowledge source (id is the negated queue-item id).
queued?: boolean
// Status carried from the queue item for a placeholder (its display name may
// differ from the queue `source`, so it can't be re-derived by name lookup).
queuedStatus?: SourceStatus
// Failure reason for a failed queue item (e.g. blocked by bot protection).
queuedError?: string | null
}
const POLL_INTERVAL_MS = 10000
/**
* State engine for the master-detail knowledge explorer, shared by the
* agent-editor tab (`mode: 'agent'`) and the dedicated knowledge page
* (`mode: 'org'`). Source list is fetched eagerly; each source's sub-page
* content is grouped from its chunks lazily on first expand/selection.
*/
export function useKnowledgeExplorer(
mode: ExplorerMode,
agentId: string | undefined,
organizationId: string,
) {
const sources = ref<ExplorerSource[]>([])
const queueItems = ref<QueueItem[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const query = ref('')
const selectedSourceId = ref<number | null>(null)
const selectedPageId = ref<string | null>(null)
const editing = ref(false)
const isAddingPage = ref(false)
const draftTitle = ref('')
const draftContent = ref('')
const draftUrl = ref('')
const isSaving = ref(false)
const isDeleting = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
// Source names that were crawling on the previous poll tick, so we can force a
// final content refresh once a crawl finishes.
let crawlingNames = new Set<string>()
// Queue-item ids seen on the previous tick, so we detect a crawl finishing
// (an item leaving the queue) even if it was never observed as active.
let lastQueueIds = new Set<number>()
const queueIds = () => new Set(queueItems.value.map((q) => q.id))
const activeCrawlNames = () =>
new Set(
queueItems.value
.filter((q) => q.status === 'pending' || q.status === 'processing')
.map((q) => q.source),
)
const setsDiffer = (a: Set<number>, b: Set<number>) =>
a.size !== b.size || [...a].some((id) => !b.has(id))
const findSource = (id: number) => sources.value.find((s) => s.id === id)
const toExplorerSource = (item: KnowledgeItem): ExplorerSource => ({
id: item.id,
name: item.name,
type: item.type,
agents: item.agents || [],
pageStubs: item.pages || [],
expanded: false,
loadingContent: false,
contentError: null,
pages: null,
})
// `background` refetches (from the crawl poll) skip the loading flag so any
// bound spinner doesn't flicker every tick.
const fetchSources = async (background = false) => {
if (!background) isLoading.value = true
error.value = null
try {
const response =
mode === 'agent' && agentId
? await knowledgeService.getKnowledgeByAgent(agentId, 1, 100)
: await knowledgeService.getKnowledgeByOrganization(organizationId, 1, 100)
const items: KnowledgeItem[] = response.knowledge || []
// Preserve expand/loaded state across refreshes so a running crawl poll
// does not collapse the tree or drop already-fetched content.
const previous = new Map(sources.value.map((s) => [s.id, s]))
sources.value = items.map((item) => {
const prev = previous.get(item.id)
const next = toExplorerSource(item)
if (prev) {
next.expanded = prev.expanded
next.pages = prev.pages
}
return next
})
} catch (err) {
console.error('Failed to load knowledge sources:', err)
error.value = '加载知识库来源失败'
} finally {
if (!background) isLoading.value = false
}
}
const fetchQueue = async () => {
try {
const response =
mode === 'agent' && agentId
? await knowledgeService.getAgentQueueItems(agentId)
: await knowledgeService.getOrgQueueItems(organizationId)
queueItems.value = response.queue_items || []
} catch (err) {
console.error('Failed to load knowledge queue:', err)
}
}
// A queue item's source_type ('website' | 'pdf_url' | 'pdf_file') mapped to the
// tree's source-kind glyph.
const queueKind = (sourceType: string): string => {
if (sourceType.includes('pdf')) return 'pdf'
if (sourceType.includes('site')) return 'sitemap'
if (sourceType.includes('web')) return 'web'
return 'custom'
}
// A queue item's `source` is a URL for websites but an internal temp path or
// S3 signed URL for PDFs — show a readable filename for the placeholder.
const queueSourceName = (item: QueueItem): string => {
if (!item.source_type.includes('pdf')) return item.source
const path = item.source.split(/[?#]/)[0]
const base = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1))
.replace(/\.[^.]+$/, '') // drop extension
.replace(/^[0-9a-f-]{8,}[_-]/i, '') // drop a leading uuid_ prefix
return base || item.source
}
const queueStatusToSource = (status: string): SourceStatus =>
status === 'failed' ? 'error' : status === 'processing' ? 'crawling' : 'queued'
// Placeholder source for an in-flight queue item (negative id = queue-item id).
const toSyntheticSource = (item: QueueItem): ExplorerSource => ({
id: -item.id,
name: queueSourceName(item),
type: queueKind(item.source_type),
agents: [],
pageStubs: [],
expanded: false,
loadingContent: false,
contentError: null,
pages: [],
queued: true,
queuedStatus: queueStatusToSource(item.status),
queuedError: item.status === 'failed' ? item.error ?? null : null,
})
// Real sources plus a placeholder for every in-flight queue item that has not
// yet produced a real source — so a just-queued crawl shows immediately and is
// replaced by the real source once indexing completes and the poll refetches.
const allSources = computed<ExplorerSource[]>(() => {
const realNames = new Set(sources.value.map((s) => s.name))
const seen = new Set<string>()
const placeholders: ExplorerSource[] = []
for (const item of queueItems.value) {
if (realNames.has(item.source) || seen.has(item.source)) continue
seen.add(item.source)
placeholders.push(toSyntheticSource(item))
}
return [...placeholders, ...sources.value]
})
// Resolve the source by id on every mutation. A concurrent (poll-driven)
// fetchSources() replaces the objects in `sources.value`, so writing to the
// originally-captured `source` would be lost — always re-find the live one.
const loadSourceContent = async (sourceId: number, force = false) => {
const source = findSource(sourceId)
if (!source || source.loadingContent) return
if (source.pages && !force) return
source.loadingContent = true
source.contentError = null
try {
const content = await knowledgeService.getKnowledgeContent(sourceId)
const pages = groupChunksIntoPages(content.chunks || [])
const live = findSource(sourceId)
if (live) live.pages = pages
} catch (err) {
console.error(`Failed to load content for source ${sourceId}:`, err)
const live = findSource(sourceId)
if (live) {
live.contentError = '加载页面失败'
live.pages = []
}
} finally {
const live = findSource(sourceId)
if (live) live.loadingContent = false
}
}
const toggleSource = async (source: ExplorerSource) => {
source.expanded = !source.expanded
if (source.expanded && source.pages === null) {
await loadSourceContent(source.id)
}
}
const selectPage = async (source: ExplorerSource, pageId: string) => {
selectedSourceId.value = source.id
selectedPageId.value = pageId
editing.value = false
isAddingPage.value = false
if (source.pages === null) {
await loadSourceContent(source.id)
}
}
const selectedSource = computed(
() => sources.value.find((s) => s.id === selectedSourceId.value) ?? null,
)
const selectedPage = computed<KnowledgeSubPage | null>(() => {
const source = selectedSource.value
if (!source || !source.pages || selectedPageId.value === null) return null
return source.pages.find((p) => p.page_id === selectedPageId.value) ?? null
})
const sourceStatus = (source: ExplorerSource): SourceStatus => {
// Placeholders carry their own status (their display name differs from the
// queue source, so a name lookup wouldn't find the item).
if (source.queued) return source.queuedStatus ?? 'queued'
const item = queueItems.value.find((q) => q.source === source.name)
if (item?.status === 'failed') return 'error'
if (item?.status === 'processing') return 'crawling'
if (item?.status === 'pending') return 'queued'
return 'synced'
}
// Page rows for the tree: grouped pages once loaded, else lightweight stubs.
const pageRows = (source: ExplorerSource) => {
if (source.pages) {
return source.pages.map((p) => ({
page_id: p.page_id,
title: p.title,
words: p.word_count,
}))
}
return source.pageStubs.map((stub: KnowledgePage) => ({
page_id: stub.subpage,
title: titleFromId(stub.subpage),
words: null as number | null,
}))
}
const normalizedQuery = computed(() => query.value.trim().toLowerCase())
const filteredSources = computed(() => {
const q = normalizedQuery.value
if (!q) return allSources.value
return allSources.value.filter((source) => {
if (source.name.toLowerCase().includes(q)) return true
const rows = pageRows(source)
return rows.some((r) => r.title.toLowerCase().includes(q) || r.page_id.toLowerCase().includes(q))
})
})
const startEdit = () => {
const page = selectedPage.value
if (!page) return
draftTitle.value = page.title
draftContent.value = page.content
isAddingPage.value = false
editing.value = true
}
// Open the editor with empty fields to add a new sub-page to `source`.
const startAddPage = async (source: ExplorerSource) => {
if (source.queued) return
selectedSourceId.value = source.id
selectedPageId.value = null
draftTitle.value = ''
draftContent.value = ''
draftUrl.value = ''
isAddingPage.value = true
editing.value = true
source.expanded = true
if (source.pages === null) await loadSourceContent(source.id)
}
const cancelEdit = () => {
editing.value = false
isAddingPage.value = false
}
const addNewPage = async (source: ExplorerSource) => {
const title = draftTitle.value.trim()
const content = draftContent.value.trim()
if (!title) {
error.value = '请为子页面填写标题'
toast.error('请为子页面填写标题')
return
}
if (!content) {
error.value = '子页面内容不能为空'
return
}
isSaving.value = true
error.value = null
try {
await knowledgeService.addSubpage(source.id, title, content, draftUrl.value.trim() || undefined)
await loadSourceContent(source.id, true)
selectedPageId.value = basePageId(title)
isAddingPage.value = false
editing.value = false
toast.success('子页面已添加')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '添加子页面失败'
console.error('Failed to add sub-page:', err)
error.value = msg
void showPlanLimitError('无法添加子页面', msg)
} finally {
isSaving.value = false
}
}
const savePage = async () => {
const source = selectedSource.value
if (!source) return
if (isAddingPage.value) return addNewPage(source)
const page = selectedPage.value
if (!page) return
const content = draftContent.value.trim()
if (!content) {
error.value = '页面内容不能为空'
return
}
isSaving.value = true
error.value = null
try {
await knowledgeService.updatePage(source.id, page.page_id, content, draftTitle.value.trim())
await loadSourceContent(source.id, true)
editing.value = false
} catch (err: unknown) {
console.error('Failed to save page:', err)
error.value = err instanceof Error ? err.message : '保存页面失败'
} finally {
isSaving.value = false
}
}
const deletePage = async () => {
const source = selectedSource.value
const page = selectedPage.value
if (!source || !page) return
isDeleting.value = true
error.value = null
try {
await knowledgeService.deletePage(source.id, page.page_id)
selectedPageId.value = null
await loadSourceContent(source.id, true)
} catch (err: unknown) {
console.error('Failed to delete page:', err)
error.value = err instanceof Error ? err.message : '删除页面失败'
} finally {
isDeleting.value = false
}
}
const deleteSource = async (source: ExplorerSource) => {
isDeleting.value = true
error.value = null
try {
// A queued placeholder isn't a real source yet — cancel its queue item.
if (source.queued) {
await knowledgeService.deleteQueueItem(-source.id)
} else {
await knowledgeService.deleteKnowledge(source.id)
}
if (selectedSourceId.value === source.id) {
selectedSourceId.value = null
selectedPageId.value = null
}
await Promise.all([fetchSources(), fetchQueue()])
} catch (err: unknown) {
console.error('Failed to delete source:', err)
error.value = err instanceof Error ? err.message : '删除来源失败'
} finally {
isDeleting.value = false
}
}
// ---- Link existing org-level knowledge to this agent (agent mode only) ----
// Org sources are created with agent_id=NULL, so they never auto-link to an
// agent; this picker restores the ability to attach them from the agent tab.
const linkPickerOpen = ref(false)
const orgSources = ref<KnowledgeItem[]>([])
const isLoadingOrgSources = ref(false)
const orgSourcesError = ref<string | null>(null)
// Knowledge ids with a link/unlink request in flight (per-row busy state).
const linkingIds = ref<Set<number>>(new Set())
// Ids already linked to this agent — drives the Link/Unlink toggle per row.
const linkedSourceIds = computed(() => new Set(sources.value.map((s) => s.id)))
const loadOrgSources = async () => {
isLoadingOrgSources.value = true
orgSourcesError.value = null
try {
const response = await knowledgeService.getKnowledgeByOrganization(organizationId, 1, 100)
orgSources.value = response.knowledge || []
} catch (err) {
console.error('Failed to load organization knowledge:', err)
orgSourcesError.value = '加载组织知识库失败'
} finally {
isLoadingOrgSources.value = false
}
}
const openLinkPicker = async () => {
if (mode !== 'agent' || !agentId) return
linkPickerOpen.value = true
await loadOrgSources()
}
// Shared link/unlink runner: guards concurrent clicks per row, keeps the
// busy-state Set in sync, refetches the agent's linked sources, and toasts.
const runLinkOp = async (
knowledgeId: number,
op: (id: number, agentId: string) => Promise<unknown>,
{ successMsg, failMsg, onSuccess }: {
successMsg: string
failMsg: string
onSuccess?: () => void
},
) => {
if (mode !== 'agent' || !agentId || linkingIds.value.has(knowledgeId)) return
linkingIds.value.add(knowledgeId)
try {
await op(knowledgeId, agentId)
onSuccess?.()
await fetchSources()
toast.success(successMsg)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : failMsg
console.error(`${failMsg}:`, err)
toast.error(failMsg, { description: msg })
} finally {
linkingIds.value.delete(knowledgeId)
}
}
const linkSource = (knowledgeId: number) =>
runLinkOp(knowledgeId, (id, aid) => knowledgeService.linkToAgent(id, aid), {
successMsg: '知识已关联到此客服',
failMsg: '无法关联知识',
})
const unlinkSource = (knowledgeId: number) =>
runLinkOp(knowledgeId, (id, aid) => knowledgeService.unlinkFromAgent(id, aid), {
successMsg: '知识已从此客服移除',
failMsg: '无法移除知识',
// Clear the detail pane if the removed source was selected.
onSuccess: () => {
if (selectedSourceId.value === knowledgeId) {
selectedSourceId.value = null
selectedPageId.value = null
}
},
})
const linkedAgentId = mode === 'agent' ? agentId : undefined
// Handle add/urls responses that report duplicates instead of throwing.
const throwIfError = (res: { error?: string } | undefined) => {
if (res?.error) throw new Error(res.error)
}
// Submit a new source from the add-source modal. Resolves true on success.
// Crawl sources (website/sitemap/pdf) are queued and indexed in the
// background; text is indexed immediately. Toasts announce the outcome.
const submitSource = async (payload: AddSourcePayload): Promise<boolean> => {
error.value = null
try {
if (payload.type === 'website') {
throwIfError(
await knowledgeService.addUrls(
organizationId,
[payload.url],
linkedAgentId,
undefined,
// "This page only" is a one-page cap; the others are crawl scopes.
payload.scope === 'page' ? 1 : undefined,
payload.scope === 'page' ? undefined : payload.scope,
),
)
toast.success('已加入抓取队列', {
description: `${payload.url},索引完成后我们会通知你。`,
})
} else if (payload.type === 'sitemap') {
throwIfError(await knowledgeService.addSitemap(organizationId, payload.url, linkedAgentId))
toast.success('已加入抓取队列', {
description: `${payload.url},正在从站点地图中发现页面。`,
})
} else if (payload.type === 'pdf') {
await knowledgeService.uploadPdfFiles(payload.files, organizationId, linkedAgentId)
toast.success('已加入抓取队列', {
description: '文档正在解析和索引中。',
})
} else {
await knowledgeService.addText(organizationId, payload.title, payload.content, linkedAgentId)
toast.success('页面已添加', {
description: `${payload.title} 已加入知识库。`,
})
}
await Promise.all([fetchSources(), fetchQueue()])
return true
} catch (err: unknown) {
// Surface add-source failures via toast only (the inline error banner is
// reserved for the read/edit pane), so the user doesn't see it twice.
const msg = err instanceof Error ? err.message : '添加来源失败'
console.error('Failed to add source:', err)
// Plan caps get the offer toast when one applies; other failures the plain one
void showPlanLimitError('无法添加来源', msg)
return false
}
}
const refresh = async () => {
await Promise.all([fetchSources(), fetchQueue()])
// Baseline the queue snapshot so the first poll can detect a crawl that
// finishes before it is ever observed as active.
lastQueueIds = queueIds()
crawlingNames = activeCrawlNames()
}
const startPolling = () => {
if (pollTimer) return
pollTimer = setInterval(async () => {
await fetchQueue()
const active = activeCrawlNames()
const currentIds = queueIds()
// Reconcile when a crawl is in flight OR the queue changed since the last
// tick (an item was added or finished/removed) — the latter catches a
// crawl that completed within a single poll window.
if (active.size || setsDiffer(currentIds, lastQueueIds)) {
await fetchSources(true)
// Force-refresh the content of any expanded source that is (or just was)
// crawling, so newly-extracted pages appear without a manual re-expand.
const touched = new Set<string>([...active, ...crawlingNames])
for (const source of sources.value) {
if (source.expanded && touched.has(source.name)) {
await loadSourceContent(source.id, true)
}
}
}
crawlingNames = active
lastQueueIds = currentIds
}, POLL_INTERVAL_MS)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
return {
// state
sources,
queueItems,
isLoading,
error,
query,
selectedSourceId,
selectedPageId,
editing,
isAddingPage,
draftTitle,
draftContent,
draftUrl,
isSaving,
isDeleting,
linkPickerOpen,
orgSources,
isLoadingOrgSources,
orgSourcesError,
linkingIds,
// computed
selectedSource,
selectedPage,
filteredSources,
linkedSourceIds,
// helpers for the view
sourceStatus,
pageRows,
// actions
fetchSources,
fetchQueue,
refresh,
toggleSource,
selectPage,
startEdit,
startAddPage,
cancelEdit,
savePage,
deletePage,
deleteSource,
submitSource,
openLinkPicker,
linkSource,
unlinkSource,
startPolling,
stopPolling,
}
}
@@ -0,0 +1,506 @@
/*
Copyright 2024-2026 ReplyPier
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 { ref } from 'vue'
import type { KnowledgeItem, KnowledgePage, QueueItem, KnowledgeContent } from '@/types/knowledge'
import { knowledgeService } from '@/services/knowledge'
export function useKnowledgeManagement(agentId: string, organizationId: string) {
// Knowledge list state
const knowledgeItems = ref<KnowledgeItem[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const totalPages = ref(0)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Queue state
const queueItems = ref<QueueItem[]>([])
const isLoadingQueue = ref(false)
// Content viewing/editing state
const selectedKnowledge = ref<number | null>(null)
const knowledgeContent = ref<KnowledgeContent | null>(null)
const isLoadingContent = ref(false)
const isEditingContent = ref(false)
const editedContent = ref('')
const isSavingContent = ref(false)
const showContentModal = ref(false)
// Modal and upload state
const showKnowledgeModal = ref(false)
const activeTab = ref('pdf')
const files = ref<File[]>([])
const urls = ref<string[]>([])
const newUrl = ref('')
const isUploading = ref(false)
const uploadProgress = ref(0)
const successMessage = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
// Link state
const showLinkModal = ref(false)
const orgKnowledgeItems = ref<KnowledgeItem[]>([])
const orgCurrentPage = ref(1)
const orgTotalPages = ref(0)
// Every source the org has indexed (all pages), for plan-cap checks.
const orgTotalCount = ref(0)
const isLoadingOrg = ref(false)
// Delete state
const showDeleteConfirm = ref(false)
const knowledgeToDelete = ref<number | null>(null)
// Add new state for URL form errors
const urlFormError = ref<string | null>(null)
// Add upload error state
const uploadError = ref<string | null>(null)
// Fetch knowledge data
const fetchKnowledge = async () => {
try {
isLoading.value = true
isLoadingOrg.value = true
error.value = null
// Fetch agent knowledge, org knowledge, and queue items in parallel
const [agentResponse, orgResponse, queueResponse] = await Promise.all([
knowledgeService.getKnowledgeByAgent(agentId, currentPage.value, pageSize.value),
knowledgeService.getKnowledgeByOrganization(
organizationId,
orgCurrentPage.value,
pageSize.value,
),
fetchQueueItems(),
])
// Default to empty: a partial response used to leave these undefined,
// and every later .some()/.length on them threw.
knowledgeItems.value = agentResponse?.knowledge || []
totalPages.value = agentResponse?.pagination?.total_pages || 0
// Update org knowledge
orgKnowledgeItems.value = orgResponse?.knowledge || []
orgTotalPages.value = orgResponse?.pagination?.total_pages || 0
orgTotalCount.value = orgResponse?.pagination?.total_count || 0
} catch (err) {
error.value = '加载知识库来源失败'
console.error(err)
} finally {
isLoading.value = false
isLoadingOrg.value = false
}
}
// Fetch queue items
const fetchQueueItems = async () => {
try {
isLoadingQueue.value = true
const response = await knowledgeService.getAgentQueueItems(agentId)
queueItems.value = response.queue_items || []
return response
} catch (err) {
console.error('Failed to load queue items:', err)
queueItems.value = []
return { queue_items: [] }
} finally {
isLoadingQueue.value = false
}
}
const deleteQueueItem = async (queueId: number) => {
try {
await knowledgeService.deleteQueueItem(queueId)
await fetchQueueItems() // Refresh queue
} catch (err) {
console.error('Failed to delete queue item:', err)
error.value = '删除队列项失败'
}
}
// Pagination handler
const handlePageChange = (page: number) => {
currentPage.value = page
fetchKnowledge()
}
// Date formatting
const formatDate = (dateString: string | null): string => {
if (!dateString) return 'N/A'
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
// Get earliest creation date
const getFirstCreated = (pages: KnowledgePage[]): string | null => {
return pages.reduce(
(earliest, page) => {
if (!page.created_at) return earliest
if (!earliest) return page.created_at
return page.created_at < earliest ? page.created_at : earliest
},
null as string | null,
)
}
// People type "docs.company.com", not "https://docs.company.com". Without a
// scheme new URL() throws and they get "Please enter a valid URL" for an
// address that is perfectly fine, so default the scheme before validating.
const withScheme = (url: string): string => {
const trimmed = url.trim()
if (!trimmed) return trimmed
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed
// Leading slashes would produce https:////host — drop them first.
return `https://${trimmed.replace(/^\/+/, '')}`
}
// URL validation
const isValidUrl = (url: string): boolean => {
try {
const parsed = new URL(url)
// new URL() accepts things like "mailto:x" and "foo:bar"; a crawlable
// source has to be http(s). Deliberately not requiring a dot in the host:
// self-hosted installs index intranet names like https://wiki.
return /^https?:$/.test(parsed.protocol) && !!parsed.hostname
} catch {
return false
}
}
// File handling methods
const triggerFileInput = () => {
fileInput.value?.click()
}
const handleFileSelect = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files) {
files.value = Array.from(input.files)
}
}
// Resolves true when the batch was accepted (or there was nothing to send),
// so a caller like the onboarding wizard can stop instead of moving on.
const handleFileUpload = async (): Promise<boolean> => {
if (!files.value.length) return true
try {
isUploading.value = true
uploadError.value = null // Clear previous errors
const response = await knowledgeService.uploadPdfFiles(
files.value,
organizationId,
agentId,
(progress) => {
uploadProgress.value = progress
},
)
if (response.message) {
successMessage.value = response.message
await fetchKnowledge() // Refresh knowledge list
}
files.value = []
if (fileInput.value) fileInput.value.value = ''
return true
} catch (error: any) {
console.error('Upload failed:', error)
uploadError.value = error.message || '上传文件失败'
return false
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
// URL handling methods
const handleUrlAdd = () => {
if (!newUrl.value) return
// Start clean so the outcome of this attempt is the only thing on screen,
// and so callers can read urlFormError to tell whether it succeeded.
urlFormError.value = null
// Clean the URL, defaulting the scheme when the user left it off
const cleanUrl = withScheme(newUrl.value)
// Basic URL validation
if (!isValidUrl(cleanUrl)) {
urlFormError.value = '请输入有效的 URL'
return
}
// Check if URL already exists in knowledgeItems or orgKnowledgeItems
const urlExistsInAgent = knowledgeItems.value.some((item) => item.name === cleanUrl)
const urlExistsInOrg = orgKnowledgeItems.value.some((item) => item.name === cleanUrl)
if (urlExistsInAgent || urlExistsInOrg) {
urlFormError.value = '该 URL 已存在于你的知识库中'
newUrl.value = ''
return
}
// Check if URL is already in the current batch
if (urls.value.includes(cleanUrl)) {
urlFormError.value = '该 URL 已添加到当前批次'
newUrl.value = ''
return
}
// Add URL to batch
urls.value.push(cleanUrl)
newUrl.value = ''
urlFormError.value = null
}
const removeUrl = (index: number) => {
urls.value.splice(index, 1)
}
// Same contract as handleFileUpload: true when the batch was accepted.
const handleUrlUpload = async (): Promise<boolean> => {
if (!urls.value.length) return true
try {
isUploading.value = true
uploadError.value = null // Clear previous errors
const response = await knowledgeService.addUrls(
organizationId,
urls.value,
agentId,
(progress) => {
uploadProgress.value = progress
},
)
// A duplicate comes back as a 200 with `error` and nothing queued; keep
// the batch staged so the user sees which URL to drop.
if (response.error) {
uploadError.value = response.error
return false
}
if (response.message) {
successMessage.value = response.message
await fetchKnowledge() // Refresh knowledge list
}
urls.value = []
return true
} catch (error: any) {
console.error('URL upload failed:', error)
uploadError.value = error.message || '上传 URL 失败'
return false
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
const fetchOrgKnowledge = async () => {
try {
isLoadingOrg.value = true
const response = await knowledgeService.getKnowledgeByOrganization(
organizationId,
orgCurrentPage.value,
pageSize.value,
)
orgKnowledgeItems.value = response.knowledge
orgTotalPages.value = response.pagination.total_pages
} catch (err) {
console.error(err)
} finally {
isLoadingOrg.value = false
}
}
const handleOrgPageChange = (page: number) => {
orgCurrentPage.value = page
fetchOrgKnowledge()
}
const linkKnowledge = async (knowledgeId: number) => {
try {
await knowledgeService.linkToAgent(knowledgeId, agentId)
await fetchKnowledge() // Refresh agent knowledge
} catch (error) {
console.error('Error linking knowledge:', error)
}
}
const unlinkKnowledge = async (knowledgeId: number) => {
try {
await knowledgeService.unlinkFromAgent(knowledgeId, agentId)
await fetchKnowledge() // Refresh agent knowledge
} catch (error) {
console.error('Error unlinking knowledge:', error)
}
}
// Delete methods
const confirmDelete = (knowledgeId: number) => {
knowledgeToDelete.value = knowledgeId
showDeleteConfirm.value = true
}
const handleDelete = async () => {
if (!knowledgeToDelete.value) return
try {
await knowledgeService.deleteKnowledge(knowledgeToDelete.value)
await fetchKnowledge() // Refresh the list
showDeleteConfirm.value = false
knowledgeToDelete.value = null
} catch (err) {
console.error('Error deleting knowledge:', err)
error.value = '删除知识来源失败'
}
}
const cancelDelete = () => {
showDeleteConfirm.value = false
knowledgeToDelete.value = null
}
// Content management methods
const viewKnowledgeContent = async (knowledgeId: number) => {
try {
selectedKnowledge.value = knowledgeId
isLoadingContent.value = true
showContentModal.value = true
const response = await knowledgeService.getKnowledgeContent(knowledgeId)
knowledgeContent.value = response
// Do not combine chunks - keep them separate for individual editing
editedContent.value = ''
} catch (err) {
console.error('Error loading knowledge content:', err)
error.value = '加载知识内容失败'
} finally {
isLoadingContent.value = false
}
}
const enableContentEditing = () => {
isEditingContent.value = true
}
const cancelContentEditing = () => {
isEditingContent.value = false
}
const saveChunkContent = async (chunkId: string, content: string) => {
if (!selectedKnowledge.value) return
try {
isSavingContent.value = true
await knowledgeService.updateChunkContent(selectedKnowledge.value, chunkId, content)
successMessage.value = '内容块已更新'
// Reload the content to show updated chunk
await viewKnowledgeContent(selectedKnowledge.value)
} catch (err: any) {
console.error('Error saving chunk content:', err)
error.value = err.message || '保存内容块失败'
} finally {
isSavingContent.value = false
}
}
const closeContentModal = () => {
showContentModal.value = false
selectedKnowledge.value = null
knowledgeContent.value = null
isEditingContent.value = false
editedContent.value = ''
}
return {
// State
knowledgeItems,
currentPage,
pageSize,
totalPages,
isLoading,
error,
showKnowledgeModal,
activeTab,
files,
urls,
newUrl,
isUploading,
uploadProgress,
successMessage,
fileInput,
showLinkModal,
orgKnowledgeItems,
orgCurrentPage,
orgTotalPages,
orgTotalCount,
isLoadingOrg,
showDeleteConfirm,
knowledgeToDelete,
urlFormError,
uploadError,
queueItems,
isLoadingQueue,
selectedKnowledge,
knowledgeContent,
isLoadingContent,
isEditingContent,
editedContent,
isSavingContent,
showContentModal,
// Methods
fetchKnowledge,
fetchQueueItems,
deleteQueueItem,
handlePageChange,
formatDate,
getFirstCreated,
isValidUrl,
withScheme,
triggerFileInput,
handleFileSelect,
handleFileUpload,
handleUrlAdd,
removeUrl,
handleUrlUpload,
fetchOrgKnowledge,
handleOrgPageChange,
linkKnowledge,
unlinkKnowledge,
confirmDelete,
handleDelete,
cancelDelete,
viewKnowledgeContent,
enableContentEditing,
cancelContentEditing,
saveChunkContent,
closeContentModal,
}
}
@@ -0,0 +1,69 @@
/*
Copyright 2024-2026 ReplyPier
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 { Ref } from 'vue'
import { subscriptionStorage } from '@/utils/storage'
/**
* Keeps a staged batch of knowledge sources within the plan's source cap.
*
* The server rejects a whole batch that would go over the cap, so staging one
* source too many loses all of them. On a one-source plan the onboarding wizard
* pre-stages the signup domain; a second add then swaps the staged source
* rather than queuing a batch that is bound to fail.
*
* `inUseCount` is every source already counting against the cap (indexed or
* still crawling), mirroring the server's count.
*
* A cap of 0 means the plan sets none we know of (self-hosted, or limits not
* loaded yet) and the server stays the only check.
*/
export function useStagedSourceCap(
inUseCount: Ref<number>,
urls: Ref<string[]>,
files: Ref<File[]>,
) {
// Read on every call: the subscription can land in storage after mount.
const cap = () => subscriptionStorage.getPlanLimits().maxKnowledgeSources
const room = () => cap() - inUseCount.value
const capMessage = (detail: string) =>
`你的套餐包含 ${cap()} 个知识库来源${detail}`
/**
* Call right after staging `added` sources onto the end of `newest`. Older
* staged sources (the other list first, then the front of `newest`) are
* dropped until the batch fits the cap. If the new sources can't fit on
* their own they are unstaged instead and the message to show is returned;
* null means the batch fits.
*/
const fitToCap = (newest: 'urls' | 'files', added = 1): string | null => {
if (cap() <= 0) return null
const latest: Ref<unknown[]> = newest === 'urls' ? urls : files
const other: Ref<unknown[]> = newest === 'urls' ? files : urls
if (added > room()) {
latest.value = latest.value.slice(0, -added)
return room() > 0 ? capMessage(`,最多可选 ${room()} 个。`) : capMessage(',且已在用。')
}
let overflow = latest.value.length + other.value.length - room()
const fromOther = Math.min(Math.max(overflow, 0), other.value.length)
other.value = other.value.slice(fromOther)
overflow -= fromOther
if (overflow > 0) latest.value = latest.value.slice(overflow)
return null
}
return { fitToCap }
}
+112
View File
@@ -0,0 +1,112 @@
/*
Copyright 2024-2026 ReplyPier
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
}
+51
View File
@@ -0,0 +1,51 @@
<!--
Copyright 2024-2026 ReplyPier
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.
-->
<script setup lang="ts">
import { computed } from 'vue'
import DashboardLayout from '@/layouts/DashboardLayout.vue'
import KnowledgeExplorer from '@/components/knowledge/KnowledgeExplorer.vue'
import { userService } from '@/services/user'
const organizationId = computed(() => userService.getCurrentUser()?.organization_id ?? '')
const description =
'这里汇集 AI 智能体学习的所有页面。你可以查看并编辑已提取的内容、添加自己的页面,' +
'并决定哪些内容保留在知识库中。'
</script>
<template>
<DashboardLayout>
<div class="knowledge-view">
<KnowledgeExplorer
v-if="organizationId"
mode="org"
variant="page"
title="知识库"
:description="description"
:organization-id="organizationId"
:show-plan-meters="true"
/>
</div>
</DashboardLayout>
</template>
<style scoped>
.knowledge-view {
max-width: 1240px;
margin: 0 auto;
padding: 32px 24px 60px;
}
</style>