feat(knowledge): 知识源维护与 TXT/Markdown/PDF 导入
支持文本、Markdown 与非扫描 PDF 上传解析,展示提取文本与切块结果。
This commit is contained in:
1 parent
4ff934ad98
commit
7937de5d7d
23 files changed
+7545
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user