diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 1800887fb0..cc6b8a4d4a 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -322,6 +322,10 @@ async def insert_attachment( path: str, type: str, mime_type: str, + *, + original_filename: str | None = None, + creator: str | None = None, + session_id: str | None = None, ): """Insert a new attachment record.""" ... diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 0d3b9822a3..5af7d5d819 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -333,9 +333,12 @@ class Attachment(TimestampMixin, SQLModel, table=True): unique=True, default_factory=lambda: str(uuid.uuid4()), ) - path: str = Field(nullable=False) # Path to the file on disk + path: str = Field(nullable=False) # Relative path to the file (e.g., 2026/01/06/xxxx.jpg) type: str = Field(nullable=False) # Type of the file (e.g., 'image', 'file') mime_type: str = Field(nullable=False) # MIME type of the file + original_filename: str = Field(nullable=True, max_length=255) # Original filename before renaming + creator: str | None = Field(default=None, max_length=255) # Username of the uploader + session_id: str | None = Field(default=None, max_length=100) # Session ID that created this attachment __table_args__ = ( UniqueConstraint( diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index d79ac9d703..b050d501d9 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -62,6 +62,7 @@ async def initialize(self) -> None: await self._ensure_persona_skills_column(conn) await self._ensure_persona_custom_error_message_column(conn) await self._ensure_platform_message_history_checkpoint_column(conn) + await self._ensure_attachment_columns(conn) await conn.commit() async def _ensure_persona_folder_columns(self, conn) -> None: @@ -757,7 +758,36 @@ async def delete_webchat_threads_by_parent_message_ids( ) return thread_ids - async def insert_attachment(self, path, type, mime_type): + async def _ensure_attachment_columns(self, conn) -> None: + """Ensure attachments table has original_filename, creator, session_id.""" + result = await conn.execute(text("PRAGMA table_info(attachments)")) + columns = {row[1] for row in result.fetchall()} + + if "original_filename" not in columns: + await conn.execute( + text( + "ALTER TABLE attachments ADD COLUMN original_filename VARCHAR(255)" + ) + ) + if "creator" not in columns: + await conn.execute( + text("ALTER TABLE attachments ADD COLUMN creator VARCHAR(255)") + ) + if "session_id" not in columns: + await conn.execute( + text("ALTER TABLE attachments ADD COLUMN session_id VARCHAR(100)") + ) + + async def insert_attachment( + self, + path, + type, + mime_type, + *, + original_filename=None, + creator=None, + session_id=None, + ): """Insert a new attachment record.""" async with self.get_db() as session: session: AsyncSession @@ -766,8 +796,13 @@ async def insert_attachment(self, path, type, mime_type): path=path, type=type, mime_type=mime_type, + original_filename=original_filename, + creator=creator, + session_id=session_id, ) session.add(new_attachment) + await session.flush() + await session.refresh(new_attachment) return new_attachment async def get_attachment_by_id(self, attachment_id): diff --git a/astrbot/core/platform/sources/webchat/message_parts_helper.py b/astrbot/core/platform/sources/webchat/message_parts_helper.py index 43072ec1c8..f01e4aaf05 100644 --- a/astrbot/core/platform/sources/webchat/message_parts_helper.py +++ b/astrbot/core/platform/sources/webchat/message_parts_helper.py @@ -19,7 +19,7 @@ from astrbot.core.message.message_event_result import MessageChain AttachmentGetter = Callable[[str], Awaitable[Attachment | None]] -AttachmentInserter = Callable[[str, str, str], Awaitable[Attachment | None]] +AttachmentInserter = Callable[..., Awaitable[Attachment | None]] ReplyHistoryGetter = Callable[ [Any], Awaitable[tuple[list[dict], str | None, str | None] | None], @@ -166,6 +166,7 @@ async def build_webchat_message_parts( *, get_attachment_by_id: AttachmentGetter, strict: bool = False, + attachments_dir: str | Path | None = None, ) -> list[dict]: if isinstance(message_payload, str): text = message_payload.strip() @@ -223,11 +224,15 @@ async def build_webchat_message_parts( continue attachment_path = Path(attachment.path) + display_name = attachment.original_filename or attachment_path.name + # Resolve relative paths to absolute for runtime usage + if attachments_dir is not None and not attachment_path.is_absolute(): + attachment_path = Path(attachments_dir) / attachment_path message_parts.append( { "type": attachment.type, "attachment_id": attachment.attachment_id, - "filename": attachment_path.name, + "filename": display_name, "path": str(attachment_path), } ) @@ -312,11 +317,13 @@ async def build_message_chain_from_payload( *, get_attachment_by_id: AttachmentGetter, strict: bool = True, + attachments_dir: str | Path | None = None, ) -> MessageChain: message_parts = await build_webchat_message_parts( message_payload, get_attachment_by_id=get_attachment_by_id, strict=strict, + attachments_dir=attachments_dir, ) components, _, has_content = await parse_webchat_message_parts( message_parts, @@ -334,20 +341,36 @@ async def create_attachment_part_from_existing_file( insert_attachment: AttachmentInserter, attachments_dir: str | Path, fallback_dirs: Sequence[str | Path] = (), + creator: str | None = None, + session_id: str | None = None, ) -> dict | None: basename = Path(filename).name - candidate_paths = [Path(attachments_dir) / basename] + attachments_dir = Path(attachments_dir) + + # Search in attachments_dir (including subdirectories) and fallback_dirs + candidate_paths: list[Path] = [] + if attachments_dir.exists(): + candidate_paths.extend(attachments_dir.rglob(basename)) candidate_paths.extend(Path(p) / basename for p in fallback_dirs) file_path = next((path for path in candidate_paths if path.exists()), None) if not file_path: return None + # Compute relative path if inside attachments_dir, otherwise absolute + try: + rel_path = str(file_path.relative_to(attachments_dir)) + except ValueError: + rel_path = str(file_path) + mime_type, _ = mimetypes.guess_type(str(file_path)) attachment = await insert_attachment( - str(file_path), + rel_path, attach_type, mime_type or "application/octet-stream", + original_filename=basename, + creator=creator, + session_id=session_id, ) if not attachment: return None @@ -355,7 +378,7 @@ async def create_attachment_part_from_existing_file( return { "type": attach_type, "attachment_id": attachment.attachment_id, - "filename": file_path.name, + "filename": basename, } @@ -364,6 +387,7 @@ async def message_chain_to_storage_message_parts( *, insert_attachment: AttachmentInserter, attachments_dir: str | Path, + conversation_id: str, ) -> list[dict]: target_dir = Path(attachments_dir) target_dir.mkdir(parents=True, exist_ok=True) @@ -388,6 +412,7 @@ async def message_chain_to_storage_message_parts( attach_type="image", insert_attachment=insert_attachment, attachments_dir=target_dir, + session_id=conversation_id, ) if attachment_part: parts.append(attachment_part) @@ -400,6 +425,7 @@ async def message_chain_to_storage_message_parts( attach_type="record", insert_attachment=insert_attachment, attachments_dir=target_dir, + session_id=conversation_id, ) if attachment_part: parts.append(attachment_part) @@ -412,6 +438,7 @@ async def message_chain_to_storage_message_parts( attach_type="video", insert_attachment=insert_attachment, attachments_dir=target_dir, + session_id=conversation_id, ) if attachment_part: parts.append(attachment_part) @@ -425,6 +452,7 @@ async def message_chain_to_storage_message_parts( insert_attachment=insert_attachment, attachments_dir=target_dir, display_name=comp.name, + session_id=conversation_id, ) if attachment_part: parts.append(attachment_part) @@ -440,20 +468,33 @@ async def _copy_file_to_attachment_part( insert_attachment: AttachmentInserter, attachments_dir: Path, display_name: str | None = None, + creator: str | None = None, + session_id: str | None = None, ) -> dict | None: + import datetime + src_path = Path(file_path) if not src_path.exists() or not src_path.is_file(): return None suffix = src_path.suffix - target_path = attachments_dir / f"{uuid.uuid4().hex}{suffix}" + random_name = f"{uuid.uuid4().hex}{suffix}" + date_dir = datetime.datetime.now().strftime("%Y/%m/%d") + target_dir = attachments_dir / date_dir + target_dir.mkdir(parents=True, exist_ok=True) + + target_path = target_dir / random_name shutil.copy2(src_path, target_path) + rel_path = str(Path(date_dir) / random_name) mime_type, _ = mimetypes.guess_type(target_path.name) attachment = await insert_attachment( - str(target_path), + rel_path, attach_type, mime_type or "application/octet-stream", + original_filename=display_name or src_path.name, + creator=creator, + session_id=session_id, ) if not attachment: return None diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py index b4d494b343..ffce9a9744 100644 --- a/astrbot/core/platform/sources/webchat/webchat_adapter.py +++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py @@ -141,6 +141,7 @@ async def _save_proactive_message( message_chain, insert_attachment=db_helper.insert_attachment, attachments_dir=self.attachments_dir, + conversation_id=conversation_id, ) if not message_parts: return diff --git a/astrbot/core/utils/api_package.py b/astrbot/core/utils/api_package.py new file mode 100644 index 0000000000..43fc9a4a9e --- /dev/null +++ b/astrbot/core/utils/api_package.py @@ -0,0 +1,99 @@ +import base64 +import hashlib +import hmac +import json +import secrets +from datetime import datetime, timedelta, timezone + +from quart import request + + +class InvalidSignatureError(Exception): + pass + + +def de_package(apikey: str, data: str, noise: str, expiry_date: str, signature: str) -> dict: + """验证签名,解包请求参数""" + if not data: + raise InvalidSignatureError("data is empty") + if not noise: + raise InvalidSignatureError("noise is empty") + if not expiry_date: + raise InvalidSignatureError("expiry_date is empty") + if not signature: + raise InvalidSignatureError("signature is empty") + + date = datetime.fromisoformat(expiry_date) + if date.tzinfo is None: + date = date.astimezone() + if date < datetime.now(timezone.utc): + raise InvalidSignatureError("expiry_date is expired") + + payload = f"{data}{noise}{expiry_date}{apikey}" + computed = hmac.new(apikey.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() + + if not hmac.compare_digest(computed, signature): + raise InvalidSignatureError("signature error") + + try: + decoded_bytes = base64.b64decode(data) + decoded_str = decoded_bytes.decode("utf-8") + result = json.loads(decoded_str) + except Exception as e: + raise InvalidSignatureError(f"failed to decode data: {e}") + + return result + + +def apikey_hash(apikey: str) -> str: + """获取原始apikey的hash值""" + return hashlib.pbkdf2_hmac( + "sha256", + apikey.encode("utf-8"), + b"astrbot_api_key", + 100_000, + ).hex() + + +def en_package(appid: str, apikey: str, data: dict) -> dict: + """apikey需要先用`apikey_hash`后才能传入使用""" + encode_data = base64.b64encode( + json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + ).decode("utf-8") + noise = secrets.token_urlsafe(32) + expiry_date = (datetime.now().astimezone() + timedelta(days=1)).replace(microsecond=0).isoformat() + payload = f"{encode_data}{noise}{expiry_date}{apikey}" + signature = hmac.new(apikey.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() + + return { + "appid": appid, + "data": encode_data, + "noise": noise, + "expiry_date": expiry_date, + "signature": signature, + } + + + +async def request_input(name: list) -> dict: + """按顺序获取输入参数:json -> form -> query -> header""" + + json_data = await request.get_json(silent=True) or {} + form_data = (await request.form).to_dict() or {} + + return_data = {} + for item in name: + if request.method == "POST": + if item in json_data: + return_data[item] = json_data.get(item) + continue + if item in form_data: + return_data[item] = form_data[item] + continue + if item in request.args: + return_data[item] = request.args.get(item) + continue + if item in request.headers: + return_data[item] = request.headers.get(item) + continue + return return_data diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py index fbbd0c7a08..db682bddb6 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -21,6 +21,7 @@ from .subagent import SubAgentRoute from .tools import ToolsRoute from .update import UpdateRoute +from .widget import ChatWidget __all__ = [ "ApiKeyRoute", @@ -46,4 +47,5 @@ "ToolsRoute", "SkillsRoute", "UpdateRoute", + "ChatWidget", ] diff --git a/astrbot/dashboard/routes/api_key.py b/astrbot/dashboard/routes/api_key.py index 4b957fe8ea..a2c8cbd938 100644 --- a/astrbot/dashboard/routes/api_key.py +++ b/astrbot/dashboard/routes/api_key.py @@ -9,7 +9,7 @@ from .route import Response, Route, RouteContext -ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im") +ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im", "chat_widget", "stats") class ApiKeyRoute(Route): diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 5ff1913b9e..9741e2e0c4 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -1,4 +1,5 @@ import asyncio +import datetime import json import os import re @@ -286,13 +287,29 @@ def __init__( self.running_convs: dict[str, bool] = {} + def _resolve_attachment_path(self, path: str) -> Path: + """Resolve an attachment path to an absolute Path. + + Handles both relative paths (stored in DB) and legacy absolute paths. + """ + attachments_dir = Path(self.attachments_dir).resolve(strict=False) + file_path = Path(path) + if not file_path.is_absolute(): + file_path = (attachments_dir / file_path).resolve(strict=False) + return file_path + async def get_file(self): filename = request.args.get("filename") if not filename: return Response().error("Missing key: filename").__dict__ try: - file_path = os.path.join(self.attachments_dir, os.path.basename(filename)) + attachments_dir = Path(self.attachments_dir).resolve(strict=False) + # Support sub-directory paths like 2026/01/06/xxx.jpg + file_path = (attachments_dir / filename).resolve(strict=False) + if not file_path.is_relative_to(attachments_dir): + return Response().error("Invalid file path").__dict__ + real_file_path = os.path.realpath(file_path) real_imgs_dir = os.path.realpath(self.attachments_dir) @@ -318,7 +335,7 @@ async def get_file(self): except (FileNotFoundError, OSError): return Response().error("File access error").__dict__ - async def get_attachment(self): + async def get_attachment(self, session_id: str | None = None): """Get attachment file by attachment_id.""" attachment_id = request.args.get("attachment_id") if not attachment_id: @@ -329,7 +346,20 @@ async def get_attachment(self): if not attachment: return Response().error("Attachment not found").__dict__ - file_path = attachment.path + # 权限检查 + check_ok = False + if not attachment.creator and not attachment.session_id: # 没有绑定创建人和会话的附件,跳过检查 + check_ok = True + if attachment.creator and g.username == attachment.creator: + check_ok = True + if attachment.session_id and session_id == attachment.session_id: + session = await self.db.get_platform_session_by_id(session_id) + if session.creator == g.username: + check_ok = True + if not check_ok: + return Response().error("permission denied").__dict__ + + file_path = self._resolve_attachment_path(attachment.path) real_file_path = os.path.realpath(file_path) return await send_file(real_file_path, mimetype=attachment.mime_type) @@ -344,10 +374,10 @@ async def post_file(self): return Response().error("Missing key: file").__dict__ file = post_data["file"] - filename = _sanitize_upload_filename(file.filename) + original_filename = _sanitize_upload_filename(file.filename) content_type = file.content_type or "application/octet-stream" - # 根据 content_type 判断文件类型并添加扩展名 + # 根据 content_type 判断文件类型 if content_type.startswith("image"): attach_type = "image" elif content_type.startswith("audio"): @@ -357,31 +387,53 @@ async def post_file(self): else: attach_type = "file" + # 生成随机文件名(保留后缀)并按日期目录存储 + suffix = Path(original_filename).suffix + random_name = f"{uuid.uuid4().hex}{suffix}" + date_dir = datetime.datetime.now().strftime("%Y/%m/%d") + attachments_dir = Path(self.attachments_dir).resolve(strict=False) - file_path = (attachments_dir / filename).resolve(strict=False) + target_dir = attachments_dir / date_dir + target_dir.mkdir(parents=True, exist_ok=True) + + file_path = (target_dir / random_name).resolve(strict=False) if not file_path.is_relative_to(attachments_dir): return Response().error("Invalid filename").__dict__ await file.save(str(file_path)) + # 存储相对路径 + rel_path = str(Path(date_dir) / random_name) + + # 获取上传者信息 + username = g.get("username", "guest") + form_data = await request.form + session_id = ( + form_data.get("session_id") + or request.args.get("session_id") + or None + ) + # 创建 attachment 记录 attachment = await self.db.insert_attachment( - path=str(file_path), + path=rel_path, type=attach_type, mime_type=content_type, + original_filename=original_filename, + creator=username, + session_id=session_id, ) if not attachment: return Response().error("Failed to create attachment").__dict__ - filename = os.path.basename(attachment.path) - return ( Response() .ok( data={ "attachment_id": attachment.attachment_id, - "filename": filename, + "filename": random_name, + "original_filename": original_filename, "type": attach_type, } ) @@ -394,6 +446,7 @@ async def _build_user_message_parts(self, message: str | list) -> list[dict]: message, get_attachment_by_id=self.db.get_attachment_by_id, strict=False, + attachments_dir=self.attachments_dir, ) async def _create_attachment_from_file( @@ -1043,9 +1096,10 @@ def build_attachment_saved_event(part: dict | None) -> str | None: response.timeout = None # fix SSE auto disconnect issue return response - async def stop_session(self): + async def stop_session(self, post_data: dict | None = None): """Stop active agent runs for a session.""" - post_data = await request.json + if post_data is None: + post_data = await request.json if post_data is None: return Response().error("Missing JSON body").__dict__ @@ -1277,9 +1331,10 @@ async def get_sessions(self): return Response().ok(data=sessions_data).__dict__ - async def get_session(self): + async def get_session(self, session_id: str | None = None): """Get session information and message history by session_id.""" - session_id = request.args.get("session_id") + if not session_id: + session_id = request.args.get("session_id") if not session_id: return Response().error("Missing key: session_id").__dict__ diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/routes/live_chat.py index d7705882db..b71210b290 100644 --- a/astrbot/dashboard/routes/live_chat.py +++ b/astrbot/dashboard/routes/live_chat.py @@ -682,6 +682,7 @@ async def _build_chat_message_parts(self, message: list[dict]) -> list[dict]: message, get_attachment_by_id=self.db.get_attachment_by_id, strict=False, + attachments_dir=self.attachments_dir, ) async def _handle_message(self, session: LiveChatSession, message: dict) -> None: diff --git a/astrbot/dashboard/routes/open_api.py b/astrbot/dashboard/routes/open_api.py index 52b412b2b5..c24d9f18d1 100644 --- a/astrbot/dashboard/routes/open_api.py +++ b/astrbot/dashboard/routes/open_api.py @@ -4,10 +4,12 @@ from uuid import uuid4 from quart import g, request, websocket +from sqlmodel import select from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase +from astrbot.core.db.po import ProviderStat from astrbot.core.platform.message_session import MessageSesion from astrbot.core.platform.sources.webchat.message_parts_helper import ( build_message_chain_from_payload, @@ -50,6 +52,7 @@ def __init__( ], "/v1/im/message": ("POST", self.send_message), "/v1/im/bots": ("GET", self.get_bots), + "/v1/stats/provider": ("GET", self.get_provider_stats), } self.register_routes() self.app.websocket("/api/v1/chat/ws")(self.chat_ws) @@ -142,8 +145,10 @@ async def _ensure_chat_session( return None - async def chat_send(self): - post_data = await request.get_json(silent=True) or {} + async def chat_send(self, post_data: dict | None = None): + if post_data is None: + post_data = await request.get_json(silent=True) or {} + effective_username, username_err = self._resolve_open_username( post_data.get("username") ) @@ -605,6 +610,7 @@ async def _build_message_chain_from_payload( message_payload, get_attachment_by_id=self.db.get_attachment_by_id, strict=True, + attachments_dir=self.chat_route.attachments_dir, ) async def send_message(self): @@ -661,3 +667,56 @@ async def get_bots(self): ): bot_ids.append(platform_id) return Response().ok(data={"bot_ids": bot_ids}).__dict__ + + async def get_provider_stats(self): + try: + start_id = int(request.args.get("start_id", 0)) + except (TypeError, ValueError): + return Response().error("start_id must be an integer").__dict__ + + try: + size = int(request.args.get("size", 20)) + except (TypeError, ValueError): + return Response().error("size must be an integer").__dict__ + + if size < 1: + size = 1 + if size > 1000: + size = 1000 + + try: + async with self.db.get_db() as session: + result = await session.execute( + select(ProviderStat) + .where(ProviderStat.id > start_id) + .order_by(ProviderStat.id.asc()) + .limit(size) + ) + records = result.scalars().all() + + data = [] + for record in records: + data.append( + { + "id": record.id, + "agent_type": record.agent_type, + "status": record.status, + "umo": record.umo, + "conversation_id": record.conversation_id, + "provider_id": record.provider_id, + "provider_model": record.provider_model, + "token_input_other": record.token_input_other, + "token_input_cached": record.token_input_cached, + "token_output": record.token_output, + "start_time": record.start_time, + "end_time": record.end_time, + "time_to_first_token": record.time_to_first_token, + "created_at": to_utc_isoformat(record.created_at), + "updated_at": to_utc_isoformat(record.updated_at), + } + ) + + return Response().ok(data={"records": data, "count": len(data)}).__dict__ + except Exception as e: + logger.error("Failed to get provider stats: %s", e, exc_info=True) + return Response().error(f"Failed to get provider stats: {e}").__dict__ diff --git a/astrbot/dashboard/routes/widget.py b/astrbot/dashboard/routes/widget.py new file mode 100644 index 0000000000..cc4d83ce17 --- /dev/null +++ b/astrbot/dashboard/routes/widget.py @@ -0,0 +1,71 @@ +from quart import g, request + +from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.db import BaseDatabase + +from .chat import ChatRoute +from .open_api import OpenApiRoute +from .route import Response, Route, RouteContext + + +class ChatWidget(Route): + def __init__( + self, + context: RouteContext, + db: BaseDatabase, + core_lifecycle: AstrBotCoreLifecycle, + chat_route: ChatRoute, + open_api: OpenApiRoute, + ) -> None: + super().__init__(context) + self.db = db + self.core_lifecycle = core_lifecycle + self.chat_route = chat_route + self.open_api = open_api + self.routes = { + "/widget/send": ("POST", self.send), + "/widget/history": ("GET", self.history), + "/widget/file": ("GET", self.file_get), + #"/widget/filename": ("GET", self.filename_get), + "/widget/upload": ("POST", self.file_upload), + "/widget/stop": ("POST", self.stop), + } + self.register_routes() + + async def send(self): + post_data = await request.get_json(silent=True) or {} + api_package = g.api_package + api_package["message"] = post_data.get("message") + api_package["enable_streaming"] = post_data.get("enable_streaming", True) + return await self.open_api.chat_send(api_package) + + async def history(self): + api_package = g.api_package + if not api_package.get("session_id"): + return Response().error("Missing key: session_id").__dict__ + return await self.chat_route.get_session(api_package["session_id"]) + + async def file_upload(self): + api_package = g.api_package + if api_package.get("file_upload"): + return await self.chat_route.post_file() + else: + return Response().error("attachment not enabled").__dict__ + + async def file_get(self): + api_package = g.api_package + if api_package.get("file_upload"): + return await self.chat_route.get_attachment(api_package.get("session_id")) + else: + return Response().error("attachment not enabled").__dict__ + + async def filename_get(self): + api_package = g.api_package + if api_package.get("file_upload"): + return await self.chat_route.get_file() + else: + return Response().error("attachment not enabled").__dict__ + + async def stop(self): + api_package = g.api_package + return await self.chat_route.stop_session(api_package) diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 1211d4f750..6f08ff61ac 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -25,6 +25,7 @@ from astrbot.core.utils.datetime_utils import to_utc_isoformat from astrbot.core.utils.io import get_local_ip_addresses +from ..core.utils import api_package from .plugin_page_auth import PluginPageAuth from .routes import * from .routes.api_key import ALL_OPEN_API_SCOPES @@ -36,6 +37,7 @@ from .routes.session_management import SessionManagementRoute from .routes.subagent import SubAgentRoute from .routes.t2i import T2iRoute +from .routes.widget import ChatWidget # Static assets shipped inside the wheel (built during `hatch build`). _BUNDLED_DIST = Path(__file__).parent / "dist" @@ -180,6 +182,13 @@ def __init__( self.platform_route = PlatformRoute(self.context, core_lifecycle) self.backup_route = BackupRoute(self.context, db, core_lifecycle) self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle) + self.chat_widget = ChatWidget( + self.context, + db, + core_lifecycle, + self.chat_route, + self.open_api_route, + ) self.app.add_url_rule( "/api/plug/", @@ -241,6 +250,14 @@ async def auth_middleware(self): await self.db.touch_api_key(api_key.key_id) return None + if request.path.startswith("/api/widget"): + try: + return await self._auth_middleware_widget() + except Exception as err: + r = jsonify(Response().error(str(err)).__dict__) + r.status_code = 403 + return r + allowed_exact_endpoints = { "/api/auth/login", "/api/auth/logout", @@ -290,6 +307,56 @@ async def auth_middleware(self): r.status_code = 401 return r + async def _auth_middleware_widget(self): + """webchat widget auth""" + post_data = await api_package.request_input( + [ + "appid", + "data", + "noise", + "expiry_date", + "signature", + ] + ) + # 读取apikey + appid = post_data.get("appid") + if not appid: + r = jsonify(Response().error("appid is empty").__dict__) + r.status_code = 403 + return r + api_key = await self.db.get_api_key_by_id(str(appid)) + if not api_key: + r = jsonify(Response().error("Invalid API key").__dict__) + r.status_code = 401 + return r + # 验证 + pkg_data = api_package.de_package( + api_key.key_hash, + post_data.get("data", ""), + post_data.get("noise", ""), + post_data.get("expiry_date", ""), + post_data.get("signature", ""), + ) + if "username" not in pkg_data: + r = jsonify(Response().error("username is required").__dict__) + r.status_code = 401 + return r + username = "widget." + pkg_data["username"] # 增加前缀, + pkg_data["username"] = username + # 设置全局参数 + g.username = username + g.api_package = pkg_data + # 权限 + if isinstance(api_key.scopes, list): + scopes = api_key.scopes + else: + scopes = list(ALL_OPEN_API_SCOPES) + if "*" not in scopes and "chat_widget" not in scopes: + r = jsonify(Response().error("Insufficient API key scope").__dict__) + r.status_code = 403 + return r + return None + @staticmethod def _extract_dashboard_jwt() -> str | None: auth_header = request.headers.get("Authorization", "").strip() @@ -328,6 +395,7 @@ def _get_required_open_api_scope(path: str) -> str | None: "/api/v1/file": "file", "/api/v1/im/message": "im", "/api/v1/im/bots": "im", + "/api/v1/stats/provider": "stats", } return scope_map.get(path) diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index e5cae3da3b..13ba12a23c 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -329,7 +329,7 @@ @remove-file="removeFile" @start-recording="startRecording" @stop-recording="stopRecording" - @paste-image="handlePaste" + @paste-image="(e: ClipboardEvent) => handlePaste(e, currSessionId)" @file-select="handleFilesSelected" @clear-reply="replyTarget = null" /> @@ -413,7 +413,7 @@ @remove-file="removeFile" @start-recording="startRecording" @stop-recording="stopRecording" - @paste-image="handlePaste" + @paste-image="(e: ClipboardEvent) => handlePaste(e, currSessionId)" @file-select="handleFilesSelected" @clear-reply="replyTarget = null" /> @@ -1289,9 +1289,9 @@ async function handleFilesSelected(files: FileList) { const selectedFiles = Array.from(files || []); for (const file of selectedFiles) { if (file.type.startsWith("image/")) { - await processAndUploadImage(file); + await processAndUploadImage(file, currSessionId.value); } else { - await processAndUploadFile(file); + await processAndUploadFile(file, currSessionId.value); } } } diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue index c966d13777..2526652b85 100644 --- a/dashboard/src/components/chat/ChatInput.vue +++ b/dashboard/src/components/chat/ChatInput.vue @@ -176,6 +176,7 @@ @@ -257,6 +259,7 @@ --> (), { @@ -353,6 +360,10 @@ const props = withDefaults(defineProps(), { stagedFiles: () => [], replyTo: null, sendShortcut: "shift_enter", + configSelectorDisabled: false, + providerModelMenuDisabled: false, + uploadFilesDisabled: false, + recordDisabled: false, }); const emit = defineEmits<{ @@ -602,7 +613,7 @@ function handleDragLeave(e: DragEvent) { function handleDrop(e: DragEvent) { isDragging.value = false; - + if (props.uploadFilesDisabled) return; const files = e.dataTransfer?.files; if (files && files.length > 0) { emit("fileSelect", files); diff --git a/dashboard/src/components/chat/ChatMessageList.vue b/dashboard/src/components/chat/ChatMessageList.vue index f19e5d6375..71ee6ac2c8 100644 --- a/dashboard/src/components/chat/ChatMessageList.vue +++ b/dashboard/src/components/chat/ChatMessageList.vue @@ -279,6 +279,22 @@ variant="text" @click="copyMessage(msg)" /> + + +
-
{{ tm("welcome.title") }}
+
{{ welcomeTitle ? welcomeTitle : tm("welcome.title") }}
-
-
-
-
- {{ tm("message.loading") }} -
- - -
-
-
+
@@ -157,8 +45,12 @@ @remove-image="removeImage" @remove-audio="removeAudio" @remove-file="removeFile" - @paste-image="handlePaste" + @paste-image="(e: ClipboardEvent) => handlePaste(e, currSessionId)" @file-select="handleFilesSelected" + :uploadFilesDisabled="!attachmentEnabled" + :providerModelMenuDisabled="widgetModel" + :config-selector-disabled="widgetModel" + :recordDisabled="!attachmentEnabled" /> @@ -186,20 +78,12 @@ import axios from "axios"; import { setCustomComponents } from "markstream-vue"; import "markstream-vue/index.css"; import ChatInput from "@/components/chat/ChatInput.vue"; -import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue"; -import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue"; -import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue"; import RefNode from "@/components/chat/message_list_comps/RefNode.vue"; -import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue"; -import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue"; import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue"; import { useMediaHandling } from "@/composables/useMediaHandling"; +import ChatMessageList from "@/components/chat/ChatMessageList.vue"; import { - displayParts as displayMessageParts, - messageBlocks as buildMessageBlocks, - type MessageDisplayBlock, useMessages, - type ChatRecord, type MessagePart, type TransportMode, } from "@/composables/useMessages"; @@ -208,9 +92,24 @@ import { useModuleI18n } from "@/i18n/composables"; import { useCustomizerStore } from "@/stores/customizer"; import { buildWebchatUmoDetails } from "@/utils/chatConfigBinding"; -const props = withDefaults(defineProps<{ configId?: string | null }>(), { - configId: "default", -}); +const props = withDefaults( + defineProps<{ + configId?: string | null, + widgetModel?: boolean, + apiPackage?: Record | null, + apiPackageData?: Record | null, + attachmentEnabled?: boolean, + welcomeTitle?: string, + }>(), + { + configId: "default", + widgetModel: false, + apiPackage: null, + apiPackageData: null, + attachmentEnabled: true, + welcomeTitle: '', + } +); setCustomComponents("chat-message", { ref: RefNode, @@ -230,7 +129,10 @@ const inputRef = ref | null>(null); const imagePreview = reactive({ visible: false, url: "" }); const isDark = computed(() => customizer.uiTheme === "PurpleThemeDark"); -const customMarkdownTags = ["ref"]; + +if (props.widgetModel) { + currSessionId.value = props.apiPackageData?.session_id ?? ''; +} const { stagedFiles, @@ -245,18 +147,18 @@ const { removeFile, clearStaged, cleanupMediaCache, + chatWidgetSetApiPackage, } = useMediaHandling(); const { sending, activeMessages, isSessionRunning, - isMessageStreaming, - isUserMessage, - messageContent, createLocalExchange, sendMessageStream, stopSession, + widgetSetApiPackage, + loadSessionMessages, } = useMessages({ currentSessionId: currSessionId, onStreamUpdate: () => { @@ -275,6 +177,16 @@ const transportMode = computed(() => onMounted(async () => { await ensureSession(); inputRef.value?.focusInput(); + if (props.widgetModel) { + initializing.value = true; + chatWidgetSetApiPackage(props.apiPackage ?? {}); + widgetSetApiPackage(props.apiPackage ?? {}); + loadSessionMessages(props.apiPackageData?.session_id ?? '') + .then() + .finally(() => { + initializing.value = false + }); + } }); onBeforeUnmount(() => { @@ -314,20 +226,22 @@ async function sendCurrentMessage() { const selection = inputRef.value?.getCurrentSelection(); const { botRecord } = createLocalExchange({ sessionId, messageId, parts }); - draft.value = ""; - clearStaged({ revokeUrls: false }); - scrollToBottom(); - sendMessageStream({ sessionId, messageId, parts, - transport: transportMode.value, + transport: props.widgetModel ? 'sse' : transportMode.value, enableStreaming: enableStreaming.value, - selectedProvider: selection?.providerId || "", - selectedModel: selection?.modelName || "", + selectedProvider: props.widgetModel ? '' : (selection?.providerId || ""), + selectedModel: props.widgetModel ? '' : (selection?.modelName || ""), botRecord, }); + // 等半秒后再清理,有些浏览器清理太快会导致图片显示异常 + setTimeout(() => { + draft.value = ""; + clearStaged({ revokeUrls: false }); + scrollToBottom(); + }, 500) } function buildOutgoingParts(text: string): MessagePart[] { @@ -346,28 +260,6 @@ function buildOutgoingParts(text: string): MessagePart[] { return parts; } -function hasNonReasoningContent(message: ChatRecord) { - return renderBlocks(message).some((block) => block.kind === "content"); -} - -function bubbleParts(message: ChatRecord) { - return displayMessageParts(messageContent(message)); -} - -function renderBlocks(message: ChatRecord): MessageDisplayBlock[] { - if (isUserMessage(message)) { - const parts = bubbleParts(message); - return parts.length ? [{ kind: "content", parts }] : []; - } - return buildMessageBlocks(messageContent(message)); -} - -function hasFollowingContentBlock(message: ChatRecord, blockIndex: number) { - return renderBlocks(message) - .slice(blockIndex + 1) - .some((block) => block.kind === "content"); -} - async function stopCurrentSession() { if (!currSessionId.value) return; await stopSession(currSessionId.value); @@ -377,9 +269,9 @@ async function handleFilesSelected(files: FileList) { const selectedFiles = Array.from(files || []); for (const file of selectedFiles) { if (file.type.startsWith("image/")) { - await processAndUploadImage(file); + await processAndUploadImage(file, currSessionId.value); } else { - await processAndUploadFile(file); + await processAndUploadFile(file, currSessionId.value); } } } @@ -393,70 +285,6 @@ function scrollToBottom() { }); } -function messageRefs(message: ChatRecord) { - const refs = messageContent(message).refs; - if (refs && typeof refs === "object" && Array.isArray(refs.used)) { - return refs as { used?: Array> }; - } - return null; -} - -function partUrl(part: MessagePart) { - if (part.embedded_url) return part.embedded_url; - if (part.embedded_file?.url) return part.embedded_file.url; - if (part.attachment_id) - return `/api/chat/get_attachment?attachment_id=${encodeURIComponent( - part.attachment_id, - )}`; - if (part.filename) - return `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`; - return ""; -} - -function normalizeToolCall(tool: Record) { - const normalized = { ...tool }; - normalized.args = parseJsonSafe(normalized.args || normalized.arguments); - normalized.result = parseJsonSafe(normalized.result); - if (!normalized.ts) normalized.ts = Date.now() / 1000; - if (normalized.result && typeof normalized.result === "object") { - normalized.result = JSON.stringify(normalized.result, null, 2); - } - return normalized; -} - -function isIPythonToolCall(tool: Record) { - const name = String(tool.name || "").toLowerCase(); - return name.includes("python") || name.includes("ipython"); -} - -function toolCallStatusText(tool: Record) { - if (tool.finished_ts) return tm("toolStatus.done"); - return tm("toolStatus.running"); -} - -function formatJson(value: unknown) { - if (typeof value === "string") return value; - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value ?? ""); - } -} - -function parseJsonSafe(value: unknown) { - if (typeof value !== "string") return value; - try { - return JSON.parse(value); - } catch { - return value; - } -} - -function openImage(url: string) { - imagePreview.url = url; - imagePreview.visible = true; -} - function closeImage() { imagePreview.visible = false; imagePreview.url = ""; @@ -499,58 +327,6 @@ function closeImage() { gap: 18px; } -.message-row { - display: flex; -} - -.message-row.from-user { - justify-content: flex-end; -} - -.message-stack { - max-width: 88%; -} - -.from-user .message-stack { - max-width: 70%; -} - -.message-bubble { - border-radius: 8px; - padding: 10px 14px; - line-height: 1.65; - overflow-wrap: anywhere; -} - -.message-bubble.user { - padding: 12px 18px; - border-radius: 1.5rem; - background: rgba(var(--v-theme-primary), 0.12); -} - -.message-bubble.bot { - padding-left: 0; - background: transparent; -} - -.plain-content { - white-space: pre-wrap; -} - -.loading-message, -.tool-call-inline-status { - color: var(--standalone-muted); -} - -.image-part { - display: block; - border: 0; - padding: 0; - margin-top: 8px; - background: transparent; - cursor: zoom-in; -} - .image-part img { max-width: min(360px, 100%); max-height: 320px; @@ -558,48 +334,12 @@ function closeImage() { object-fit: contain; } -.audio-part, -.video-part { - display: block; - max-width: 100%; - margin-top: 8px; -} - -.video-part { - max-height: 320px; - border-radius: 8px; -} - -.file-part { - display: flex; - align-items: center; - gap: 8px; - margin-top: 8px; -} - -.tool-call-block { - margin: 8px 0; - display: flex; - flex-direction: column; - gap: 6px; -} - .message-bubble.bot > .tool-call-block:first-child :deep(.tool-call-card:first-child) { margin-top: 0; } -.unknown-part { - max-width: 100%; - overflow-x: auto; - border-radius: 8px; - padding: 10px; - background: rgba(var(--v-theme-on-surface), 0.06); - font-size: 13px; - line-height: 1.5; -} - .standalone-composer { position: relative; z-index: 1; @@ -639,4 +379,9 @@ function closeImage() { border-radius: 8px; object-fit: contain; } +@media (max-width: 760px) { + .standalone-composer { + padding-bottom: 0; + } +} diff --git a/dashboard/src/composables/useMediaHandling.ts b/dashboard/src/composables/useMediaHandling.ts index 6c81a7ec7a..1e8c2e4530 100644 --- a/dashboard/src/composables/useMediaHandling.ts +++ b/dashboard/src/composables/useMediaHandling.ts @@ -15,6 +15,8 @@ export function useMediaHandling() { const stagedFiles = ref([]); const mediaCache = ref>({}); const pendingFileSignatures = new Set(); + let chatWidgetApi = false; + let chatWidgetApiPackage: Record | null = null; async function getFileSignature(file: File): Promise { if (crypto?.subtle) { @@ -41,11 +43,19 @@ export function useMediaHandling() { return mediaCache.value[filename]; } + let params: Record = { filename }; + if (chatWidgetApi) { + for (const k in chatWidgetApiPackage) + params[k] = chatWidgetApiPackage[k]; + } try { - const response = await axios.get('/api/chat/get_file', { - params: { filename }, - responseType: 'blob' - }); + const response = await axios.get( + chatWidgetApi ? '/api/widget/file' : '/api/chat/get_file', + { + params: params, + responseType: 'blob' + } + ); const blobUrl = URL.createObjectURL(response.data); mediaCache.value[filename] = blobUrl; @@ -56,26 +66,37 @@ export function useMediaHandling() { } } - async function uploadStagedFile(file: File) { + async function uploadStagedFile(file: File, sessionId?: string) { const signature = await getFileSignature(file); if (isDuplicateFile(signature)) return; pendingFileSignatures.add(signature); const formData = new FormData(); formData.append('file', file); + if (sessionId) { + formData.append('session_id', sessionId); + } + if (chatWidgetApi) { + for (const k in chatWidgetApiPackage) + formData.append(k, chatWidgetApiPackage[k]); + } try { - const response = await axios.post('/api/chat/post_file', formData, { - headers: { - 'Content-Type': 'multipart/form-data' + const response = await axios.post( + chatWidgetApi ? '/api/widget/upload' : '/api/chat/post_file', + formData, + { + headers: { + 'Content-Type': 'multipart/form-data' + } } - }); + ); - const { attachment_id, filename, type } = response.data.data; + const { attachment_id, filename, type, original_filename } = response.data.data; stagedFiles.value.push({ attachment_id, - filename, - original_name: file.name, + filename: original_filename || filename, + original_name: original_filename || file.name, url: URL.createObjectURL(file), type, signature @@ -87,15 +108,15 @@ export function useMediaHandling() { } } - async function processAndUploadImage(file: File) { - await uploadStagedFile(file); + async function processAndUploadImage(file: File, sessionId?: string) { + await uploadStagedFile(file, sessionId); } - async function processAndUploadFile(file: File) { - await uploadStagedFile(file); + async function processAndUploadFile(file: File, sessionId?: string) { + await uploadStagedFile(file, sessionId); } - async function handlePaste(event: ClipboardEvent) { + async function handlePaste(event: ClipboardEvent, sessionId?: string) { const items = event.clipboardData?.items; if (!items) return; @@ -103,7 +124,7 @@ export function useMediaHandling() { if (items[i].type.indexOf('image') !== -1) { const file = items[i].getAsFile(); if (file) { - await processAndUploadImage(file); + await processAndUploadImage(file, sessionId); } } } @@ -182,6 +203,11 @@ export function useMediaHandling() { stagedFiles.value.filter(f => f.type !== 'image') ); + function chatWidgetSetApiPackage(apiPackage: Record) { + chatWidgetApi = true; + chatWidgetApiPackage = apiPackage + } + return { stagedImagesUrl, stagedAudioUrl, @@ -195,6 +221,7 @@ export function useMediaHandling() { removeAudio, removeFile, clearStaged, - cleanupMediaCache + cleanupMediaCache, + chatWidgetSetApiPackage, }; } diff --git a/dashboard/src/composables/useMessages.ts b/dashboard/src/composables/useMessages.ts index 51e950c113..1dcf42c08a 100644 --- a/dashboard/src/composables/useMessages.ts +++ b/dashboard/src/composables/useMessages.ts @@ -119,6 +119,8 @@ export function useMessages(options: UseMessagesOptions) { const sessionProjects = reactive>( {}, ); + let chatWidgetApi = false; + let chatWidgetApiPackage: Record | null = null; const activeMessages = computed(() => options.currentSessionId.value @@ -169,10 +171,22 @@ export function useMessages(options: UseMessagesOptions) { let cacheKey: string; if (part.attachment_id) { cacheKey = `att:${part.attachment_id}`; - url = `/api/chat/get_attachment?attachment_id=${encodeURIComponent(part.attachment_id)}`; + if (chatWidgetApi) { + const params = new URLSearchParams(chatWidgetApiPackage ?? {}); + params.append('attachment_id', part.attachment_id) + url = '/api/widget/file?' + params.toString(); + } else { + url = `/api/chat/get_attachment?attachment_id=${encodeURIComponent(part.attachment_id)}`; + } } else if (part.filename) { cacheKey = `file:${part.filename}`; - url = `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`; + if (chatWidgetApi) { + const params = new URLSearchParams(chatWidgetApiPackage ?? {}); + params.append('filename', part.filename) + url = '/api/widget/filename?' + params.toString(); + } else { + url = `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`; + } } else { return; } @@ -208,9 +222,12 @@ export function useMessages(options: UseMessagesOptions) { if (!sessionId) return; loadingMessages.value = true; try { - const response = await axios.get("/api/chat/get_session", { - params: { session_id: sessionId }, - }); + const response = await axios.get( + chatWidgetApi ? "/api/widget/history" : "/api/chat/get_session", + { + params: chatWidgetApi ? Object.assign({ session_id: sessionId }, chatWidgetApiPackage) : { session_id: sessionId }, + } + ); const payload = response.data?.data || {}; const history = payload.history || []; const records = history.map(normalizeHistoryRecord); @@ -445,7 +462,11 @@ export function useMessages(options: UseMessagesOptions) { async function stopSession(sessionId: string) { if (!sessionId) return; - await axios.post("/api/chat/stop", { session_id: sessionId }); + if (chatWidgetApi) { + await axios.post("/api/widget/stop", Object.assign({ session_id: sessionId }, chatWidgetApiPackage)); + } else { + await axios.post("/api/chat/stop", { session_id: sessionId }); + } } function cleanupConnections() { @@ -508,22 +529,26 @@ export function useMessages(options: UseMessagesOptions) { transport: "sse", abort, }; - - fetch("/api/chat/send", { + const headers: Record = {"Content-Type": "application/json",}; + if (!chatWidgetApi) headers.Authorization = `Bearer ${localStorage.getItem("token") || ""}`; + const body: Record = { + session_id: sessionId, + message: parts.map(partToPayload), + enable_streaming: enableStreaming, + selected_provider: selectedProvider, + selected_model: selectedModel, + _skip_user_history: skipUserHistory, + _llm_checkpoint_id: llmCheckpointId || undefined, + }; + if (chatWidgetApi) { + for (const k in chatWidgetApiPackage) { + body[k] = chatWidgetApiPackage[k]; + } + } + fetch(chatWidgetApi ? "/api/widget/send" : "/api/chat/send", { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${localStorage.getItem("token") || ""}`, - }, - body: JSON.stringify({ - session_id: sessionId, - message: parts.map(partToPayload), - enable_streaming: enableStreaming, - selected_provider: selectedProvider, - selected_model: selectedModel, - _skip_user_history: skipUserHistory, - _llm_checkpoint_id: llmCheckpointId || undefined, - }), + headers: headers, + body: JSON.stringify(body), signal: abort.signal, }) .then(async (response) => { @@ -698,6 +723,11 @@ export function useMessages(options: UseMessagesOptions) { } } + function widgetSetApiPackage(apiPackage: Record) { + chatWidgetApi = true; + chatWidgetApiPackage = apiPackage + } + return { loadingMessages, sending, @@ -718,6 +748,8 @@ export function useMessages(options: UseMessagesOptions) { regenerateMessage, stopSession, cleanupConnections, + widgetSetApiPackage, + startSseStream, }; } diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index b497659ee4..6478d419ec 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -190,6 +190,7 @@ "inactive": "Inactive" }, "table": { + "id": "NO", "name": "Name", "prefix": "Prefix", "scopes": "Scopes", diff --git a/dashboard/src/i18n/locales/ru-RU/features/settings.json b/dashboard/src/i18n/locales/ru-RU/features/settings.json index d1100435f4..4c32d0b695 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/settings.json +++ b/dashboard/src/i18n/locales/ru-RU/features/settings.json @@ -190,6 +190,7 @@ "inactive": "Неактивен" }, "table": { + "id": "№", "name": "Имя", "prefix": "Префикс", "scopes": "Права", diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index 092b4a5d9b..92a8adf6ec 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -190,6 +190,7 @@ "inactive": "无效" }, "table": { + "id": "编号", "name": "名称", "prefix": "前缀", "scopes": "权限", diff --git a/dashboard/src/router/ChatWidget.ts b/dashboard/src/router/ChatWidget.ts new file mode 100644 index 0000000000..5e539533eb --- /dev/null +++ b/dashboard/src/router/ChatWidget.ts @@ -0,0 +1,18 @@ +const ChatWidgetRoutes = { + path: "/chatwidget/main", + component: () => import("@/layouts/blank/BlankLayout.vue"), + children: [ + { + name: "ChatWidget", + path: "/chatwidget", + component: () => import("@/views/ChatWidget.vue"), + }, + { + name: "MarkdownPrit", + path: "/markdownPrint", + component: () => import("@/views/print.vue"), + } + ], +}; + +export default ChatWidgetRoutes; diff --git a/dashboard/src/router/index.ts b/dashboard/src/router/index.ts index 32f138ddb6..fc145affc8 100644 --- a/dashboard/src/router/index.ts +++ b/dashboard/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'; import MainRoutes from './MainRoutes'; import AuthRoutes from './AuthRoutes'; import ChatBoxRoutes from './ChatBoxRoutes'; +import ChatWidgetRoutes from "@/router/ChatWidget"; import { useAuthStore } from '@/stores/auth'; import { useRouterLoadingStore } from '@/stores/routerLoading'; @@ -10,7 +11,8 @@ export const router = createRouter({ routes: [ MainRoutes, AuthRoutes, - ChatBoxRoutes + ChatBoxRoutes, + ChatWidgetRoutes, ] }); diff --git a/dashboard/src/utils/print.ts b/dashboard/src/utils/print.ts new file mode 100644 index 0000000000..69b8db793f --- /dev/null +++ b/dashboard/src/utils/print.ts @@ -0,0 +1,23 @@ +import { router } from "@/router"; + +export function printMarkdown(markdown: string) { + if (!markdown) return; + const printWindow = window.open( + router.resolve({ + path: '/markdownPrint' + } + ).href, '_blank'); + const timer = setInterval(() => { + printWindow?.postMessage({ + type: 'PrintData.Send', + data: markdown + }, '*'); + }, 1000); + const clear = (e: any) => { + if (e.data === 'PrintData.Ready') { + clearInterval(timer); + window.removeEventListener('message', clear); + } + }; + window.addEventListener('message', clear); +} \ No newline at end of file diff --git a/dashboard/src/views/ChatWidget.vue b/dashboard/src/views/ChatWidget.vue new file mode 100644 index 0000000000..33c95fe4f1 --- /dev/null +++ b/dashboard/src/views/ChatWidget.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue index a870841c70..480e0c9f0b 100644 --- a/dashboard/src/views/Settings.vue +++ b/dashboard/src/views/Settings.vue @@ -156,6 +156,7 @@ + {{ tm('apiKey.table.id') }} {{ tm('apiKey.table.name') }} {{ tm('apiKey.table.prefix') }} {{ tm('apiKey.table.scopes') }} @@ -167,6 +168,7 @@ + {{ item.key_id }} {{ item.name }} {{ item.key_prefix }} {{ (item.scopes || []).join(', ') }} @@ -294,7 +296,7 @@ const apiKeys = ref([]); const apiKeyCreating = ref(false); const newApiKeyName = ref(''); const newApiKeyExpiresInDays = ref(30); -const newApiKeyScopes = ref(['chat', 'config', 'file', 'im']); +const newApiKeyScopes = ref(['chat', 'config', 'file', 'im', 'chat_widget', 'stats']); const createdApiKeyPlaintext = ref(''); const apiKeyExpiryOptions = computed(() => [ { title: tm('apiKey.expiryOptions.day1'), value: 1 }, @@ -308,7 +310,9 @@ const availableScopes = [ { value: 'chat', label: 'chat' }, { value: 'config', label: 'config' }, { value: 'file', label: 'file' }, - { value: 'im', label: 'im' } + { value: 'im', label: 'im' }, + { value: 'chat_widget', label: 'chat_widget' }, + { value: 'stats', label: 'stats'}, ]; const showToast = (message, color = 'success') => { diff --git a/dashboard/src/views/print.vue b/dashboard/src/views/print.vue new file mode 100644 index 0000000000..17bf2a5ec5 --- /dev/null +++ b/dashboard/src/views/print.vue @@ -0,0 +1,91 @@ + + + + \ No newline at end of file diff --git a/tests/test_api_key_open_api.py b/tests/test_api_key_open_api.py index 8b90e2ff48..9aaa1df028 100644 --- a/tests/test_api_key_open_api.py +++ b/tests/test_api_key_open_api.py @@ -799,5 +799,7 @@ async def test_open_file_upload_requires_file_and_can_upload( upload_data = await upload_res.get_json() assert upload_data["status"] == "ok" assert isinstance(upload_data["data"]["attachment_id"], str) - assert upload_data["data"]["filename"] == "openapi_test.txt" + assert upload_data["data"]["original_filename"] == "openapi_test.txt" + assert upload_data["data"]["filename"] != "openapi_test.txt" + assert upload_data["data"]["type"] == "file" assert upload_data["data"]["type"] == "file"