diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 16ebac7a8..1eae0f17e 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -29,6 +29,7 @@ from astrbot.core.astr_main_agent_resources import ( TOOL_CALL_PROMPT_SKILLS_LIKE_MODE, ) from astrbot.core.conversation_mgr import Conversation +from astrbot.core.db import BaseDatabase from astrbot.core.message.components import File, Image, Record, Reply, Video from astrbot.core.persona_error_reply import ( extract_persona_custom_error_message_from_persona, @@ -73,7 +74,6 @@ from astrbot.core.tools.computer_tools import ( RollbackSkillReleaseTool, RunBrowserSkillTool, SyncSkillReleaseTool, - normalize_umo_for_workspace, ) from astrbot.core.tools.cron_tools import FutureTaskTool from astrbot.core.tools.knowledge_base_tools import ( @@ -115,6 +115,10 @@ from astrbot.core.utils.quoted_message_parser import ( extract_quoted_message_text, ) from astrbot.core.utils.string_utils import normalize_and_dedupe_strings +from astrbot.core.workspace import ( + normalize_umo_for_workspace, + resolve_workspace_root_for_umo, +) LLM_ERROR_MESSAGE_EXTRA_KEY = "_llm_error_message" WEEKDAY_NAMES = ( @@ -357,41 +361,63 @@ def _apply_prompt_prefix(req: ProviderRequest, cfg: dict) -> None: req.prompt = f"{prefix}{req.prompt}" -def _get_workspace_path_for_umo(umo: str) -> Path: - normalized_umo = normalize_umo_for_workspace(umo) - return Path(get_astrbot_workspaces_path()) / normalized_umo +async def _get_workspace_path_for_umo(umo: str, plugin_context: Context) -> Path: + """Resolve the workspace path for the current request. + + Args: + umo: Unified message origin. + plugin_context: Star context containing the database instance. + + Returns: + Workspace path used as cwd. + """ + fallback_root = ( + Path(get_astrbot_workspaces_path()) / normalize_umo_for_workspace(umo) + ).resolve(strict=False) + db = getattr(plugin_context, "_db", None) + if not isinstance(db, BaseDatabase): + return fallback_root + try: + return await resolve_workspace_root_for_umo(umo, db) + except Exception: + return fallback_root -def _apply_workspace_extra_prompt( +async def _apply_workspace_extra_prompt( event: AstrMessageEvent, req: ProviderRequest, + plugin_context: Context, ) -> None: - extra_prompt_path = _get_workspace_path_for_umo(event.unified_msg_origin) / ( - "EXTRA_PROMPT.md" + workspace_root = await _get_workspace_path_for_umo( + event.unified_msg_origin, + plugin_context, ) - if not extra_prompt_path.is_file(): - return - - try: - extra_prompt = extra_prompt_path.read_text(encoding="utf-8").strip() - except Exception as exc: # noqa: BLE001 - logger.warning( - "Failed to read workspace extra prompt for umo=%s from %s: %s", - event.unified_msg_origin, - extra_prompt_path, - exc, - ) - return - - if not extra_prompt: + extra_prompts: list[str] = [] + extra_prompt_path = workspace_root / "EXTRA_PROMPT.md" + if extra_prompt_path.is_file(): + try: + extra_prompt = extra_prompt_path.read_text(encoding="utf-8").strip() + except Exception as exc: # noqa: BLE001 + logger.warning( + "Failed to read workspace extra prompt for umo=%s from %s: %s", + event.unified_msg_origin, + extra_prompt_path, + exc, + ) + else: + if extra_prompt: + extra_prompts.append(f"From `{extra_prompt_path}`:\n{extra_prompt}") + + if not extra_prompts: return + extra_prompt_text = "\n\n".join(extra_prompts) req.system_prompt = ( f"{req.system_prompt or ''}\n" "[Workspace Extra Prompt]\n" "The following instructions are loaded from the current workspace " "`EXTRA_PROMPT.md` file.\n" - f"{extra_prompt}\n" + f"{extra_prompt_text}\n" ) @@ -498,13 +524,13 @@ async def _ensure_persona_and_skills( skill_manager = SkillManager() skills = skill_manager.list_skills(active_only=True, runtime=runtime) skills = _filter_skills_for_current_config(skills, cfg) - workspace_skills = ( - skill_manager.list_workspace_skills( - _get_workspace_path_for_umo(event.unified_msg_origin) + workspace_skills: list[SkillInfo] = [] + if runtime == "local": + workspace_root = await _get_workspace_path_for_umo( + event.unified_msg_origin, + plugin_context, ) - if runtime == "local" - else [] - ) + workspace_skills.extend(skill_manager.list_workspace_skills(workspace_root)) if skills or workspace_skills: if persona and persona.get("skills") is not None: @@ -989,7 +1015,7 @@ async def _decorate_llm_request( if tz is None: tz = plugin_context.get_config().get("timezone") _append_system_reminders(event, req, cfg, tz) - _apply_workspace_extra_prompt(event, req) + await _apply_workspace_extra_prompt(event, req, plugin_context) def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None: @@ -1590,10 +1616,14 @@ async def build_main_agent( ) if config.computer_use_runtime == "local": + workspace_root = await _get_workspace_path_for_umo( + event.unified_msg_origin, + plugin_context, + ) + workspace_prompt = f"\nCurrent workspace you can use: `{workspace_root}`\n" tool_prompt += ( - f"\nCurrent workspace you can use: " - f"`{_get_workspace_path_for_umo(event.unified_msg_origin)}`\n" - "Unless the user explicitly specifies a different directory, " + workspace_prompt + + "Unless the user explicitly specifies a different directory, " "perform all file-related operations in this workspace.\n" ) diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index cf37c4866..8e319bc52 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -851,6 +851,8 @@ class BaseDatabase(abc.ABC): title: str, emoji: str | None = "📁", description: str | None = None, + workspace_type: str = "session", + workspace_path: str | None = None, ) -> ChatUIProject: """Create a new ChatUI project.""" ... @@ -877,6 +879,8 @@ class BaseDatabase(abc.ABC): title: str | None = None, emoji: str | None = None, description: str | None = None, + workspace_type: str | None = None, + workspace_path: str | None = None, ) -> None: """Update a ChatUI project.""" ... diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 9a297b34d..5bfbc7d9e 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -447,6 +447,10 @@ class ChatUIProject(TimestampMixin, SQLModel, table=True): """Title of the project""" description: str | None = Field(default=None, max_length=1000) """Description of the project""" + workspace_type: str = Field(default="session", nullable=False, max_length=32) + """Workspace mode: session, project, or custom""" + workspace_path: str | None = Field(default=None, max_length=1024) + """Custom workspace path""" __table_args__ = ( UniqueConstraint( diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index b7706cc51..f59f23419 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -64,6 +64,7 @@ class SQLiteDatabase(BaseDatabase): 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_chatui_project_workspace_columns(conn) await conn.commit() async def _ensure_persona_folder_columns(self, conn) -> None: @@ -128,6 +129,23 @@ class SQLiteDatabase(BaseDatabase): ) ) + async def _ensure_chatui_project_workspace_columns(self, conn) -> None: + """Ensure chatui_projects has workspace configuration columns.""" + result = await conn.execute(text("PRAGMA table_info(chatui_projects)")) + columns = {row[1] for row in result.fetchall()} + + if "workspace_type" not in columns: + await conn.execute( + text( + "ALTER TABLE chatui_projects " + "ADD COLUMN workspace_type VARCHAR(32) NOT NULL DEFAULT 'session'" + ) + ) + if "workspace_path" not in columns: + await conn.execute( + text("ALTER TABLE chatui_projects ADD COLUMN workspace_path VARCHAR") + ) + # ==== # Platform Statistics # ==== @@ -1877,6 +1895,8 @@ class SQLiteDatabase(BaseDatabase): title: str, emoji: str | None = "📁", description: str | None = None, + workspace_type: str = "session", + workspace_path: str | None = None, ) -> ChatUIProject: """Create a new ChatUI project.""" async with self.get_db() as session: @@ -1887,6 +1907,8 @@ class SQLiteDatabase(BaseDatabase): title=title, emoji=emoji, description=description, + workspace_type=workspace_type, + workspace_path=workspace_path, ) session.add(project) await session.flush() @@ -1929,6 +1951,8 @@ class SQLiteDatabase(BaseDatabase): title: str | None = None, emoji: str | None = None, description: str | None = None, + workspace_type: str | None = None, + workspace_path: str | None = None, ) -> None: """Update a ChatUI project.""" async with self.get_db() as session: @@ -1941,6 +1965,9 @@ class SQLiteDatabase(BaseDatabase): values["emoji"] = emoji if description is not None: values["description"] = description + if workspace_type is not None: + values["workspace_type"] = workspace_type + values["workspace_path"] = workspace_path await session.execute( update(ChatUIProject) diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py index 0b6003937..74bc2ad88 100644 --- a/astrbot/core/tools/computer_tools/fs.py +++ b/astrbot/core/tools/computer_tools/fs.py @@ -14,7 +14,7 @@ Behavior when `provider_settings.computer_use_require_admin=True`: implement them and the main agent does not expose them in local mode. - Member + local: read/grep are restricted to `data/skills`, plugin-provided `data/plugins/*/skills`, - `data/workspaces/{normalized_umo}`, and `/tmp/.astrbot`; write/edit are + the current session or project workspace, and `/tmp/.astrbot`; write/edit are restricted to the same local roots except plugin-provided Skills, which are read-only. Upload/download are denied by `check_admin_permission` if invoked. - Admin + sandbox: read/write/edit/grep are not path-restricted by this @@ -28,8 +28,7 @@ When `computer_use_require_admin=False`, member behavior in this module matches admin behavior. Local path resolution rule: -- In local runtime, relative paths are resolved under - `data/workspaces/{normalized_umo}`. +- In local runtime, relative paths are resolved under the primary workspace. - In sandbox runtime, relative paths are passed through unchanged. """ @@ -60,6 +59,7 @@ from .util import ( check_admin_permission, is_local_runtime, normalize_umo_for_workspace, + workspace_root_for_context, ) _COMPUTER_RUNTIME_TOOL_CONFIG = { @@ -77,9 +77,13 @@ def _remote_basename(path: str) -> str: return path.replace("\\", "/").rstrip("/").split("/")[-1] -def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> list[str]: +def _restricted_env_path_labels( + umo: str, + *, + include_plugin_skills: bool, + current_workspace_root: Path | None = None, +) -> list[str]: """Labels for the allowed directories in a local(not sandbox) and restricted(not admin) environment""" - normalized_umo = normalize_umo_for_workspace(umo) labels = [ "data/skills", ] @@ -87,7 +91,7 @@ def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> lis labels.append("data/plugins/*/skills") labels.extend( [ - f"data/workspaces/{normalized_umo}", + str(current_workspace_root or _workspace_root(umo)), get_astrbot_system_tmp_path(), get_astrbot_temp_path(), ] @@ -117,22 +121,28 @@ def _plugin_skill_roots() -> tuple[Path, ...]: ) -def _read_allowed_roots(umo: str) -> tuple[Path, ...]: +def _read_allowed_roots( + umo: str, + current_workspace_root: Path | None = None, +) -> tuple[Path, ...]: """Non-admin users can only read files within these directories (and their subdirectories)""" return ( Path(get_astrbot_skills_path()).resolve(strict=False), *_plugin_skill_roots(), - _workspace_root(umo), + current_workspace_root or _workspace_root(umo), Path(get_astrbot_system_tmp_path()).resolve(strict=False), Path(get_astrbot_temp_path()).resolve(strict=False), ) -def _write_allowed_roots(umo: str) -> tuple[Path, ...]: +def _write_allowed_roots( + umo: str, + current_workspace_root: Path | None = None, +) -> tuple[Path, ...]: """Non-admin users cannot modify plugin-provided Skills.""" return ( Path(get_astrbot_skills_path()).resolve(strict=False), - _workspace_root(umo), + current_workspace_root or _workspace_root(umo), Path(get_astrbot_system_tmp_path()).resolve(strict=False), Path(get_astrbot_temp_path()).resolve(strict=False), ) @@ -149,7 +159,13 @@ def _is_restricted_env(context: ContextWrapper[AstrAgentContext]) -> bool: return require_admin and context.context.event.role != "admin" -def _resolve_tool_path(path: str, *, local_env: bool, umo: str) -> str: +def _resolve_tool_path( + path: str, + *, + local_env: bool, + umo: str, + current_workspace_root: Path | None = None, +) -> str: normalized_path = path.strip() if not normalized_path: return normalized_path @@ -157,16 +173,28 @@ def _resolve_tool_path(path: str, *, local_env: bool, umo: str) -> str: if candidate.is_absolute(): return str(candidate.resolve(strict=False)) if local_env: - return str((_workspace_root(umo) / candidate).resolve(strict=False)) + return str( + ((current_workspace_root or _workspace_root(umo)) / candidate).resolve( + strict=False + ) + ) return normalized_path -def _resolve_user_path(path: str, *, local_env: bool, umo: str) -> Path: +def _resolve_user_path( + path: str, + *, + local_env: bool, + umo: str, + current_workspace_root: Path | None = None, +) -> Path: candidate = Path(path).expanduser() if candidate.is_absolute(): return candidate.resolve(strict=False) if local_env: - return (_workspace_root(umo) / candidate).resolve(strict=False) + return ((current_workspace_root or _workspace_root(umo)) / candidate).resolve( + strict=False + ) return (Path.cwd() / candidate).resolve(strict=False) @@ -175,8 +203,14 @@ def _is_path_within_allowed_roots( *, umo: str, allowed_roots: tuple[Path, ...], + current_workspace_root: Path | None = None, ) -> bool: - resolved = _resolve_user_path(path, local_env=True, umo=umo) + resolved = _resolve_user_path( + path, + local_env=True, + umo=umo, + current_workspace_root=current_workspace_root, + ) return any( resolved == allowed_root or resolved.is_relative_to(allowed_root) for allowed_root in allowed_roots @@ -209,19 +243,34 @@ def _normalize_rw_path( local_env: bool, umo: str, write: bool = False, + current_workspace_root: Path | None = None, ) -> str: - normalized_path = _resolve_tool_path(path, local_env=local_env, umo=umo) + normalized_path = _resolve_tool_path( + path, + local_env=local_env, + umo=umo, + current_workspace_root=current_workspace_root, + ) if not normalized_path: raise ValueError("`path` must be a non-empty string.") if restricted: - allowed_roots = _write_allowed_roots(umo) if write else _read_allowed_roots(umo) + allowed_roots = ( + _write_allowed_roots(umo, current_workspace_root) + if write + else _read_allowed_roots(umo, current_workspace_root) + ) if restricted and not _is_path_within_allowed_roots( normalized_path, umo=umo, allowed_roots=allowed_roots, + current_workspace_root=current_workspace_root, ): allowed = ", ".join( - _restricted_env_path_labels(umo, include_plugin_skills=not write) + _restricted_env_path_labels( + umo, + include_plugin_skills=not write, + current_workspace_root=current_workspace_root, + ) ) access = "Write" if write else "Read" raise PermissionError( @@ -291,6 +340,9 @@ class FileReadTool(FunctionTool): ) -> ToolExecResult: local_env = is_local_runtime(context) restricted = _is_restricted_env(context) + current_workspace_root = ( + await workspace_root_for_context(context) if local_env else None + ) try: normalized_path = ( _normalize_rw_path( @@ -298,6 +350,7 @@ class FileReadTool(FunctionTool): restricted=restricted, local_env=local_env, umo=context.context.event.unified_msg_origin, + current_workspace_root=current_workspace_root, ) if local_env else path.strip() @@ -321,7 +374,10 @@ class FileReadTool(FunctionTool): offset=offset, limit=limit, workspace_dir=( - str(_workspace_root(context.context.event.unified_msg_origin)) + str( + current_workspace_root + or _workspace_root(context.context.event.unified_msg_origin) + ) if local_env else None ), @@ -363,6 +419,9 @@ class FileWriteTool(FunctionTool): ) -> ToolExecResult: local_env = is_local_runtime(context) restricted = _is_restricted_env(context) + current_workspace_root = ( + await workspace_root_for_context(context) if local_env else None + ) try: normalized_path = ( _normalize_rw_path( @@ -371,6 +430,7 @@ class FileWriteTool(FunctionTool): local_env=local_env, umo=context.context.event.unified_msg_origin, write=True, + current_workspace_root=current_workspace_root, ) if local_env else path.strip() @@ -442,6 +502,9 @@ class FileEditTool(FunctionTool): umo = str(context.context.event.unified_msg_origin) local_env = is_local_runtime(context) restricted = _is_restricted_env(context) + current_workspace_root = ( + await workspace_root_for_context(context) if local_env else None + ) try: normalized_path = ( _normalize_rw_path( @@ -450,6 +513,7 @@ class FileEditTool(FunctionTool): local_env=local_env, umo=umo, write=True, + current_workspace_root=current_workspace_root, ) if local_env else path.strip() @@ -599,15 +663,28 @@ class GrepTool(FunctionTool): restricted: bool, local_env: bool, umo: str, + current_workspace_root: Path | None = None, ) -> list[str]: normalized = ( - [_resolve_tool_path(path, local_env=local_env, umo=umo)] if path else [] + [ + _resolve_tool_path( + path, + local_env=local_env, + umo=umo, + current_workspace_root=current_workspace_root, + ) + ] + if path + else [] ) if not normalized: if restricted: - return [str(root) for root in _read_allowed_roots(umo)] + return [ + str(root) + for root in _read_allowed_roots(umo, current_workspace_root) + ] if local_env: - return [str(_workspace_root(umo))] + return [str(current_workspace_root or _workspace_root(umo))] return ["."] if restricted: @@ -617,12 +694,17 @@ class GrepTool(FunctionTool): if not _is_path_within_allowed_roots( path, umo=umo, - allowed_roots=_read_allowed_roots(umo), + allowed_roots=_read_allowed_roots(umo, current_workspace_root), + current_workspace_root=current_workspace_root, ) ] if disallowed: allowed = ", ".join( - _restricted_env_path_labels(umo, include_plugin_skills=True) + _restricted_env_path_labels( + umo, + include_plugin_skills=True, + current_workspace_root=current_workspace_root, + ) ) blocked = ", ".join(disallowed) raise PermissionError( @@ -649,6 +731,9 @@ class GrepTool(FunctionTool): local_env = is_local_runtime(context) restricted = _is_restricted_env(context) + current_workspace_root = ( + await workspace_root_for_context(context) if local_env else None + ) try: search_paths = ( self._normalize_search_paths( @@ -656,6 +741,7 @@ class GrepTool(FunctionTool): restricted=restricted, local_env=local_env, umo=context.context.event.unified_msg_origin, + current_workspace_root=current_workspace_root, ) if local_env else ([path.strip()] if path and path.strip() else ["."]) diff --git a/astrbot/core/tools/computer_tools/python.py b/astrbot/core/tools/computer_tools/python.py index f9500ff7e..b51c225d9 100644 --- a/astrbot/core/tools/computer_tools/python.py +++ b/astrbot/core/tools/computer_tools/python.py @@ -11,7 +11,10 @@ from astrbot.core.computer.computer_client import get_booter, get_local_booter from astrbot.core.message.message_event_result import MessageChain from ..registry import builtin_tool -from .util import check_admin_permission, workspace_root +from .util import ( + check_admin_permission, + workspace_root_for_context, +) _OS_NAME = platform.system() _SANDBOX_PYTHON_TOOL_CONFIG = { @@ -137,9 +140,7 @@ class LocalPythonTool(FunctionTool): else context.tool_call_timeout ) try: - current_workspace_root = workspace_root( - context.context.event.unified_msg_origin - ) + current_workspace_root = await workspace_root_for_context(context) current_workspace_root.mkdir(parents=True, exist_ok=True) result = await sb.python.exec( code, diff --git a/astrbot/core/tools/computer_tools/shell.py b/astrbot/core/tools/computer_tools/shell.py index 1e1acfbf9..88d1d69e4 100644 --- a/astrbot/core/tools/computer_tools/shell.py +++ b/astrbot/core/tools/computer_tools/shell.py @@ -14,7 +14,11 @@ from astrbot.core.computer.computer_client import get_booter from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path from ..registry import builtin_tool -from .util import check_admin_permission, is_local_runtime, workspace_root +from .util import ( + check_admin_permission, + is_local_runtime, + workspace_root_for_context, +) _COMPUTER_RUNTIME_TOOL_CONFIG = { "provider_settings.computer_use_runtime": ("local", "sandbox"), @@ -99,9 +103,7 @@ class ExecuteShellTool(FunctionTool): try: cwd: str | None = None if is_local_runtime(context): - current_workspace_root = workspace_root( - context.context.event.unified_msg_origin - ) + current_workspace_root = await workspace_root_for_context(context) current_workspace_root.mkdir(parents=True, exist_ok=True) cwd = str(current_workspace_root) diff --git a/astrbot/core/tools/computer_tools/util.py b/astrbot/core/tools/computer_tools/util.py index a3930b4c6..9bb71298b 100644 --- a/astrbot/core/tools/computer_tools/util.py +++ b/astrbot/core/tools/computer_tools/util.py @@ -1,20 +1,48 @@ -import re from pathlib import Path from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.db import BaseDatabase from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path - - -def normalize_umo_for_workspace(umo: str) -> str: - normalized = re.sub(r"[^A-Za-z0-9._-]+", "_", umo.strip()) - return normalized or "unknown" +from astrbot.core.workspace import ( + normalize_umo_for_workspace, + resolve_workspace_root_for_umo, +) def workspace_root(umo: str) -> Path: - """Root directory for relative paths in local runtime""" - normalized_umo = normalize_umo_for_workspace(umo) - return (Path(get_astrbot_workspaces_path()) / normalized_umo).resolve(strict=False) + """Return the legacy workspace root for compatibility. + + Args: + umo: Unified message origin. + + Returns: + Legacy per-session workspace root. + """ + return ( + Path(get_astrbot_workspaces_path()) / normalize_umo_for_workspace(umo) + ).resolve(strict=False) + + +async def workspace_root_for_context( + context: ContextWrapper[AstrAgentContext], +) -> Path: + """Resolve the workspace root for a tool call context. + + Args: + context: Tool call context. + + Returns: + Workspace root used as cwd. + """ + umo = context.context.event.unified_msg_origin + db = getattr(context.context.context, "_db", None) + if not isinstance(db, BaseDatabase): + return workspace_root(umo) + try: + return await resolve_workspace_root_for_umo(umo, db) + except Exception: + return workspace_root(umo) def is_local_runtime(context: ContextWrapper[AstrAgentContext]) -> bool: diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index 0f5ac7e5d..7bb7eda26 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -20,6 +20,7 @@ from astrbot.core.tools.computer_tools.util import ( check_admin_permission, is_local_runtime, workspace_root, + workspace_root_for_context, ) from astrbot.core.tools.registry import builtin_tool from astrbot.core.utils.astrbot_path import ( @@ -28,10 +29,13 @@ from astrbot.core.utils.astrbot_path import ( ) -def _file_send_allowed_roots(umo: str | None) -> tuple[Path, ...]: +def _file_send_allowed_roots( + umo: str | None, + current_workspace_root: Path | None = None, +) -> tuple[Path, ...]: roots = [] if umo: - roots.append(workspace_root(umo)) + roots.append(current_workspace_root or workspace_root(umo)) roots.extend( [ Path(get_astrbot_temp_path()).resolve(strict=False), @@ -59,9 +63,10 @@ def _is_restricted_local_env(context: ContextWrapper[AstrAgentContext]) -> bool: def _can_send_local_file( context: ContextWrapper[AstrAgentContext], local_path: Path, + current_workspace_root: Path | None = None, ) -> bool: umo = context.context.event.unified_msg_origin - allowed_roots = _file_send_allowed_roots(umo) + allowed_roots = _file_send_allowed_roots(umo, current_workspace_root) if _is_path_within(local_path, allowed_roots): return True return is_local_runtime(context) and not _is_restricted_local_env(context) @@ -137,12 +142,18 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): if not path: raise FileNotFoundError(f"{component_type} path is empty") + current_workspace_root = ( + await workspace_root_for_context(context) + if is_local_runtime(context) + else None + ) + # Relative host paths are resolved only inside the user's workspace. if not os.path.isabs(path): unified_msg_origin = context.context.event.unified_msg_origin if unified_msg_origin: + ws_path = current_workspace_root or workspace_root(unified_msg_origin) try: - ws_path = workspace_root(unified_msg_origin) ws_candidate = (ws_path / path).resolve(strict=False) if ws_candidate.is_file() and ws_candidate.is_relative_to(ws_path): return str(ws_candidate), False @@ -151,13 +162,16 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): else: local_candidate = Path(path).expanduser().resolve(strict=False) if local_candidate.is_file(): - if _can_send_local_file(context, local_candidate): + if _can_send_local_file( + context, local_candidate, current_workspace_root + ): return str(local_candidate), False if is_local_runtime(context): allowed = ", ".join( str(root) for root in _file_send_allowed_roots( - context.context.event.unified_msg_origin + context.context.event.unified_msg_origin, + current_workspace_root, ) ) raise PermissionError( diff --git a/astrbot/core/workspace.py b/astrbot/core/workspace.py new file mode 100644 index 000000000..00ff5c5f0 --- /dev/null +++ b/astrbot/core/workspace.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from astrbot.core.db import BaseDatabase +from astrbot.core.platform.message_session import MessageSession +from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path + +WORKSPACE_TYPE_SESSION = "session" +WORKSPACE_TYPE_PROJECT = "project" +WORKSPACE_TYPE_CUSTOM = "custom" +WORKSPACE_TYPES = { + WORKSPACE_TYPE_SESSION, + WORKSPACE_TYPE_PROJECT, + WORKSPACE_TYPE_CUSTOM, +} + + +def normalize_umo_for_workspace(umo: str) -> str: + """Normalize a unified message origin into a filesystem-safe name. + + Args: + umo: Unified message origin. + + Returns: + Filesystem-safe workspace directory name. + """ + normalized = re.sub(r"[^A-Za-z0-9._-]+", "_", umo.strip()) + return normalized or "unknown" + + +def normalize_project_workspace_type(value: Any) -> str: + """Normalize stored or incoming project workspace type. + + Args: + value: Raw workspace type value. + + Returns: + A known workspace type. + """ + workspace_type = str(value or WORKSPACE_TYPE_SESSION).strip().lower() + return ( + workspace_type if workspace_type in WORKSPACE_TYPES else WORKSPACE_TYPE_SESSION + ) + + +def normalize_workspace_path(path: Any) -> str | None: + """Normalize a custom workspace path value for storage. + + Args: + path: Raw path value from API or database. + + Returns: + Normalized path string, or None when empty. + """ + if not isinstance(path, str): + return None + value = path.strip() + return value or None + + +def default_workspace_root(umo: str) -> Path: + """Return the legacy per-session workspace root. + + Args: + umo: Unified message origin. + + Returns: + The legacy workspace directory path. + """ + return ( + Path(get_astrbot_workspaces_path()) / normalize_umo_for_workspace(umo) + ).resolve(strict=False) + + +def project_workspace_root(project_id: str) -> Path: + """Return the default shared workspace root for a ChatUI project. + + Args: + project_id: ChatUI project ID. + + Returns: + The project workspace directory path. + """ + safe_project_id = re.sub(r"[^A-Za-z0-9._-]+", "_", project_id.strip()) + return (Path(get_astrbot_workspaces_path()) / f"project_{safe_project_id}").resolve( + strict=False + ) + + +def workspace_path_to_root(path: str) -> Path: + """Resolve a custom workspace path. + + Args: + path: Stored workspace path. Relative values are rooted under AstrBot + workspaces. Absolute values are allowed and resolved as provided. + + Returns: + Absolute resolved path. + + Raises: + ValueError: If a relative path escapes or targets the AstrBot workspaces + root. + """ + workspaces_root = Path(get_astrbot_workspaces_path()).resolve(strict=False) + candidate = Path(path).expanduser() + if candidate.is_absolute(): + return candidate.resolve(strict=False) + + resolved = (workspaces_root / candidate).resolve(strict=False) + if resolved == workspaces_root or not resolved.is_relative_to(workspaces_root): + raise ValueError( + "Relative workspace path must stay within a subdirectory of AstrBot workspaces" + ) + return resolved + + +def resolve_project_workspace_root(project: Any, *, fallback_umo: str) -> Path: + """Resolve the workspace root from a project record. + + Args: + project: Project-like object with workspace fields. + fallback_umo: UMO used when the project keeps legacy session workspaces. + + Returns: + Workspace root used as cwd. + """ + fallback = default_workspace_root(fallback_umo) + workspace_type = normalize_project_workspace_type( + getattr(project, "workspace_type", WORKSPACE_TYPE_SESSION) + ) + if workspace_type == WORKSPACE_TYPE_SESSION: + return fallback + if workspace_type == WORKSPACE_TYPE_PROJECT: + return project_workspace_root(str(project.project_id)) + if workspace_type == WORKSPACE_TYPE_CUSTOM: + workspace_path = normalize_workspace_path( + getattr(project, "workspace_path", None) + ) + if workspace_path: + return workspace_path_to_root(workspace_path) + return fallback + + +def parse_webchat_umo(umo: str) -> tuple[str, str] | None: + """Extract creator and session ID from a webchat UMO. + + Args: + umo: Unified message origin. + + Returns: + Tuple of creator and ChatUI session ID, or None for non-webchat UMO. + """ + try: + message_session = MessageSession.from_str(umo) + except Exception: + return None + + if message_session.platform_name != "webchat": + return None + + parts = message_session.session_id.split("!", 2) + if len(parts) != 3 or parts[0] != "webchat": + return None + return parts[1], parts[2] + + +async def resolve_workspace_root_for_umo( + umo: str, + db: BaseDatabase | None = None, +) -> Path: + """Resolve the workspace root for a UMO. + + Args: + umo: Unified message origin. + db: Optional database instance. Falls back to the global database helper. + + Returns: + Workspace root used as cwd. + """ + parsed = parse_webchat_umo(umo) + if not parsed: + return default_workspace_root(umo) + + creator, session_id = parsed + if db is None: + from astrbot.core import db_helper + + db = db_helper + + project = await db.get_project_by_session(session_id=session_id, creator=creator) + if not project: + return default_workspace_root(umo) + return resolve_project_workspace_root(project, fallback_umo=umo) diff --git a/astrbot/dashboard/schemas.py b/astrbot/dashboard/schemas.py index f37449c53..bd72e1e4b 100644 --- a/astrbot/dashboard/schemas.py +++ b/astrbot/dashboard/schemas.py @@ -95,6 +95,8 @@ class ChatProjectRequest(OpenModel): title: str | None = None emoji: str | None = None description: str | None = None + workspace_type: str | None = None + workspace_path: str | None = None class ChatProjectSessionRequest(OpenModel): diff --git a/astrbot/dashboard/services/chatui_project_service.py b/astrbot/dashboard/services/chatui_project_service.py index 4a928751e..34e711d74 100644 --- a/astrbot/dashboard/services/chatui_project_service.py +++ b/astrbot/dashboard/services/chatui_project_service.py @@ -1,7 +1,17 @@ from __future__ import annotations +import os + from astrbot.core.db import BaseDatabase from astrbot.core.utils.datetime_utils import to_utc_isoformat +from astrbot.core.workspace import ( + WORKSPACE_TYPE_CUSTOM, + WORKSPACE_TYPE_SESSION, + normalize_project_workspace_type, + normalize_workspace_path, + resolve_project_workspace_root, + workspace_path_to_root, +) class ChatUIProjectServiceError(Exception): @@ -17,6 +27,7 @@ class ChatUIProjectService: title = payload.get("title") emoji = payload.get("emoji", "📁") description = payload.get("description") + workspace_type, workspace_path = self._normalize_workspace_config(payload) if not title: raise ChatUIProjectServiceError("Missing key: title") @@ -26,6 +37,8 @@ class ChatUIProjectService: title=title, emoji=emoji, description=description, + workspace_type=workspace_type, + workspace_path=workspace_path, ) return self._serialize_project(project) @@ -53,12 +66,22 @@ class ChatUIProjectService: if not project_id: raise ChatUIProjectServiceError("Missing key: project_id") - await self._get_owned_project(username, project_id) + project = await self._get_owned_project(username, project_id) + workspace_type = None + workspace_path = None + if "workspace_type" in payload or "workspace_path" in payload: + workspace_type, workspace_path = self._normalize_workspace_config( + payload, + fallback_type=project.workspace_type, + fallback_path=project.workspace_path, + ) await self.db.update_chatui_project( project_id=project_id, title=payload.get("title"), emoji=payload.get("emoji"), description=payload.get("description"), + workspace_type=workspace_type, + workspace_path=workspace_path, ) async def delete_project(self, username: str, project_id: str | None) -> None: @@ -136,11 +159,32 @@ class ChatUIProjectService: @staticmethod def _serialize_project(project) -> dict: + workspace_type = normalize_project_workspace_type( + getattr(project, "workspace_type", WORKSPACE_TYPE_SESSION) + ) + workspace_path = normalize_workspace_path( + getattr(project, "workspace_path", None) + ) + resolved_workspace_path = None + if workspace_type != WORKSPACE_TYPE_SESSION: + fallback_umo = f"webchat:FriendMessage:webchat!{project.creator}!default" + try: + resolved_workspace_path = str( + resolve_project_workspace_root( + project, + fallback_umo=fallback_umo, + ) + ) + except ValueError: + resolved_workspace_path = None return { "project_id": project.project_id, "title": project.title, "emoji": project.emoji, "description": project.description, + "workspace_type": workspace_type, + "workspace_path": workspace_path, + "resolved_workspace_path": resolved_workspace_path, "created_at": to_utc_isoformat(project.created_at), "updated_at": to_utc_isoformat(project.updated_at), } @@ -160,3 +204,49 @@ class ChatUIProjectService: @staticmethod def _as_payload(data: object) -> dict: return data if isinstance(data, dict) else {} + + @staticmethod + def _normalize_workspace_config( + payload: dict, + *, + fallback_type: str | None = None, + fallback_path: str | None = None, + ) -> tuple[str, str | None]: + """Normalize project workspace config from request payload. + + Args: + payload: Request payload. + fallback_type: Existing workspace type used when omitted. + fallback_path: Existing workspace path used when omitted. + + Returns: + Normalized workspace type and path. + + Raises: + ChatUIProjectServiceError: If a custom workspace has no usable path. + """ + workspace_type = normalize_project_workspace_type( + payload.get("workspace_type", fallback_type or WORKSPACE_TYPE_SESSION) + ) + raw_path = payload.get("workspace_path", fallback_path) + workspace_path = normalize_workspace_path(raw_path) + if workspace_type != WORKSPACE_TYPE_CUSTOM: + workspace_path = None + return workspace_type, workspace_path + + if not workspace_path: + raise ChatUIProjectServiceError("Custom workspace requires a path") + + try: + workspace_root = workspace_path_to_root(workspace_path) + except ValueError as exc: + raise ChatUIProjectServiceError(str(exc)) from exc + if not workspace_root.exists(): + raise ChatUIProjectServiceError("Custom workspace path does not exist") + if not workspace_root.is_dir(): + raise ChatUIProjectServiceError("Custom workspace path must be a directory") + if not os.access(workspace_root, os.R_OK | os.W_OK | os.X_OK): + raise ChatUIProjectServiceError( + "Custom workspace path requires read, write, and enter permissions" + ) + return workspace_type, workspace_path diff --git a/dashboard/package.json b/dashboard/package.json index b493f1d99..81a353bf2 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -18,6 +18,7 @@ "dependencies": { "@guolao/vue-monaco-editor": "^1.5.4", "@hey-api/client-axios": "0.2.12", + "@lucide/vue": "^1.23.0", "@tiptap/starter-kit": "2.1.7", "@tiptap/vue-3": "2.1.7", "apexcharts": "3.42.0", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 81049e3dd..cae4d6bc1 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@hey-api/client-axios': specifier: 0.2.12 version: 0.2.12(axios@1.13.5) + '@lucide/vue': + specifier: ^1.23.0 + version: 1.23.0(vue@3.3.4) '@tiptap/starter-kit': specifier: 2.1.7 version: 2.1.7(@tiptap/pm@2.27.2) @@ -501,6 +504,11 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@lucide/vue@1.23.0': + resolution: {integrity: sha512-9SIYeY5K+R1iv8F8JUKMGSL7Pck/86BJ8djtZz/lnYsoHtKFUj1H2z6+PvKnC/8blZ8tqTDeDXAoTKobuX80hg==} + peerDependencies: + vue: '>=3.0.1' + '@mdi/font@7.2.96': resolution: {integrity: sha512-e//lmkmpFUMZKhmCY9zdjRe4zNXfbOIJnn6xveHbaV2kSw5aJ5dLXUxcRt1Gxfi7ZYpFLUWlkG2MGSFAiqAu7w==} @@ -3496,6 +3504,10 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@lucide/vue@1.23.0(vue@3.3.4)': + dependencies: + vue: 3.3.4 + '@mdi/font@7.2.96': {} '@mermaid-js/parser@0.6.3': diff --git a/dashboard/public/favicon.svg b/dashboard/public/favicon.svg index db85f3f33..e4f30733b 100644 --- a/dashboard/public/favicon.svg +++ b/dashboard/public/favicon.svg @@ -1 +1,14 @@ - \ No newline at end of file + + + + + + + + diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index 49e94b2bb..4e3d94ffc 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -83,8 +83,12 @@ export type ChatProjectRequest = { title?: string; emoji?: string; description?: string; + workspace_type?: 'session' | 'project' | 'custom'; + workspace_path?: string; }; +export type workspace_type = 'session' | 'project' | 'custom'; + export type ChatRequest = { /** * Caller-declared WebChat sender/session owner. This value is used as the message sender identity and may participate in sender-ID-based command permission checks. Treat chat-scoped API keys as trusted backend credentials and map or validate usernames before accepting end-user input. diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 9ada8cf9c..924632cd6 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -496,6 +496,10 @@ content: "\F024B"; } +.mdi-folder-cog-outline::before { + content: "\F1080"; +} + .mdi-folder-move::before { content: "\F0252"; } @@ -980,10 +984,6 @@ content: "\F062C"; } -.mdi-square-edit-outline::before { - content: "\F090C"; -} - .mdi-star::before { content: "\F04CE"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 6401c409e..ad504744b 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index d1a9fd21c..46f5db9dd 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index c5a425929..3045a3f9f 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -10,125 +10,163 @@ :class="{ collapsed: isSidebarCollapsed }" :permanent="lgAndUp" :temporary="!lgAndUp" - :rail="lgAndUp && sidebarCollapsed" + :rail="lgAndUp && customizer.chatSidebarCollapsed" :width="280" - :rail-width="68" + :rail-width="56" location="left" floating > + + -
-
- {{ - sessionTitle(session) - }} -
- - + +
+ {{ sessionTitle(session) }} +
+ + + + + + +
+
- -
- -
- {{ tm("conversation.noHistory") }} -
+
@@ -505,6 +547,20 @@ import { import { useRoute, useRouter } from "vue-router"; import { useDisplay } from "vuetify"; import { isAxiosError } from "axios"; +import { + Box, + Cable, + Check, + ChevronRight, + Languages, + Moon, + PanelLeft, + Pencil, + Settings, + SquarePen, + Sun, + Trash2, +} from "@lucide/vue"; import { chatApi } from "@/api/v1"; import StyledMenu from "@/components/shared/StyledMenu.vue"; import ProjectDialog, { @@ -514,6 +570,7 @@ import ProjectList, { type Project } from "@/components/chat/ProjectList.vue"; import ProjectView from "@/components/chat/ProjectView.vue"; import ChatInput from "@/components/chat/ChatInput.vue"; import ChatMessageList from "@/components/chat/ChatMessageList.vue"; +import ChatUILogo from "@/components/chat/ChatUILogo.vue"; import type { RegenerateModelSelection } from "@/components/chat/RegenerateMenu.vue"; import ReasoningSidebar from "@/components/chat/ReasoningSidebar.vue"; import ThreadPanel from "@/components/chat/ThreadPanel.vue"; @@ -530,6 +587,7 @@ import { import { useMediaHandling } from "@/composables/useMediaHandling"; import { useRecording } from "@/composables/useRecording"; import { useProjects } from "@/composables/useProjects"; +import { useChatHeaderStore } from "@/stores/chatHeader"; import { useCustomizerStore } from "@/stores/customizer"; import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue"; import { @@ -549,6 +607,7 @@ const props = withDefaults(defineProps<{ chatboxMode?: boolean; active?: boolean const route = useRoute(); const router = useRouter(); const { lgAndUp } = useDisplay(); +const chatHeader = useChatHeaderStore(); const customizer = useCustomizerStore(); const { t } = useI18n(); const { tm } = useModuleI18n("features/chat"); @@ -593,10 +652,11 @@ const { type WorkspaceView = "chat" | "providers"; -const sidebarCollapsed = ref(false); const activeWorkspace = ref("chat"); const projectDialogOpen = ref(false); const editingProject = ref(null); +const projectDialogError = ref(""); +const savingProject = ref(false); const sessionTitleDialogOpen = ref(false); const sessionTitleDraft = ref(""); const editingSessionTitleId = ref(""); @@ -606,6 +666,8 @@ const messageEditDraft = ref(""); const editingMessage = ref(null); const savingMessageEdit = ref(false); const projectSessions = ref([]); +const projectSessionsById = ref>({}); +const loadingProjectSessionIds = ref([]); const loadingSessions = ref(false); const draft = ref(""); const messagesContainer = ref(null); @@ -651,11 +713,20 @@ const chatSidebarDrawer = computed({ }, }); const isSidebarCollapsed = computed(() => - lgAndUp.value ? sidebarCollapsed.value : !customizer.chatSidebarOpen, + lgAndUp.value ? customizer.chatSidebarCollapsed : !customizer.chatSidebarOpen, ); const isProviderWorkspace = computed( () => activeWorkspace.value === "providers", ); + +function toggleChatSidebar() { + if (lgAndUp.value) { + customizer.SET_CHAT_SIDEBAR_COLLAPSED(!customizer.chatSidebarCollapsed); + return; + } + customizer.TOGGLE_CHAT_SIDEBAR(); +} + const activeReasoningParts = computed(() => { if (!activeReasoningTarget.value) return []; const blocks = buildMessageBlocks( @@ -730,6 +801,9 @@ const currentSession = computed( projectSessions.value.find( (session) => session.session_id === currSessionId.value, ) || + Object.values(projectSessionsById.value) + .flat() + .find((session) => session.session_id === currSessionId.value) || null, ); const sessionProject = computed(() => @@ -744,6 +818,21 @@ const selectedProject = computed( (project) => project.project_id === selectedProjectId.value, ) || null, ); +const isEmptyChat = computed( + () => + !isProviderWorkspace.value && + !selectedProject.value && + !loadingMessages.value && + !activeMessages.value.length, +); +const chatHeaderTitle = computed( + () => currentSessionTitle.value || selectedProject.value?.title || "", +); +const chatHeaderSubtitle = computed(() => + currentSessionTitle.value + ? sessionProject.value?.title || selectedProject.value?.title || "" + : "", +); const chatInputReplyTarget = computed(() => replyTarget.value?.id == null ? null @@ -753,8 +842,30 @@ const chatInputReplyTarget = computed(() => }, ); +function getSelectedProviderSelection() { + const inputSelection = inputRef.value?.getCurrentSelection(); + if (inputSelection?.providerId) { + return inputSelection; + } + if (typeof window === "undefined") { + return { providerId: "", modelName: "" }; + } + return { + providerId: localStorage.getItem("selectedProvider") || "", + modelName: localStorage.getItem("selectedProviderModel") || "", + }; +} + provide("isDark", isDark); +watch( + [chatHeaderTitle, chatHeaderSubtitle], + ([title, subtitle]) => { + chatHeader.SET_CONTEXT({ title, subtitle }); + }, + { immediate: true }, +); + onMounted(async () => { loadingSessions.value = true; try { @@ -771,6 +882,7 @@ onMounted(async () => { }); onBeforeUnmount(() => { + chatHeader.CLEAR_CONTEXT(); cleanupMediaCache(); }); @@ -853,11 +965,13 @@ async function startNewChat() { function openCreateProjectDialog() { editingProject.value = null; + projectDialogError.value = ""; projectDialogOpen.value = true; } function openEditProjectDialog(project: Project) { editingProject.value = project; + projectDialogError.value = ""; projectDialogOpen.value = true; } @@ -874,13 +988,40 @@ async function selectProject(projectId: string) { async function loadProjectSessions(projectId = selectedProjectId.value) { if (!projectId) { projectSessions.value = []; - return; + return []; + } + const sessions = await getProjectSessions(projectId); + projectSessionsById.value = { + ...projectSessionsById.value, + [projectId]: sessions, + }; + if (projectId === selectedProjectId.value) { + projectSessions.value = sessions; + } + return sessions; +} + +async function handleProjectToggle(projectId: string, expanded: boolean) { + if (!expanded || projectSessionsById.value[projectId]) return; + if (loadingProjectSessionIds.value.includes(projectId)) return; + loadingProjectSessionIds.value = [...loadingProjectSessionIds.value, projectId]; + try { + await loadProjectSessions(projectId); + } finally { + loadingProjectSessionIds.value = loadingProjectSessionIds.value.filter( + (item) => item !== projectId, + ); } - projectSessions.value = await getProjectSessions(projectId); } async function handleDeleteProject(projectId: string) { await deleteProjectById(projectId); + const nextSessionsById = { ...projectSessionsById.value }; + delete nextSessionsById[projectId]; + projectSessionsById.value = nextSessionsById; + loadingProjectSessionIds.value = loadingProjectSessionIds.value.filter( + (item) => item !== projectId, + ); if (selectedProjectId.value === projectId) { selectedProjectId.value = null; projectSessions.value = []; @@ -915,6 +1056,14 @@ async function saveSessionTitleDialog() { if (projectSession) { projectSession.display_name = displayName; } + Object.values(projectSessionsById.value).forEach((projectSessionList) => { + const cachedProjectSession = projectSessionList.find( + (session) => session.session_id === sessionId, + ); + if (cachedProjectSession) { + cachedProjectSession.display_name = displayName; + } + }); if (refreshProjectSessionsAfterTitleSave.value) { await loadProjectSessions(); } @@ -950,25 +1099,57 @@ async function editProjectSessionTitle(sessionId: string, title: string) { openSessionTitleDialog(sessionId, title, true); } -async function deleteProjectSession(sessionId: string) { +async function deleteProjectSession( + sessionId: string, + projectId = selectedProjectId.value, +) { await deleteSession(sessionId); - await loadProjectSessions(); + if (projectId) { + await loadProjectSessions(projectId); + } else { + await loadProjectSessions(); + } } async function saveProject(formData: ProjectFormData, projectId?: string) { - if (projectId) { - await updateProject( - projectId, - formData.title, - formData.emoji, - formData.description, - ); - return; + savingProject.value = true; + projectDialogError.value = ""; + try { + if (projectId) { + await updateProject( + projectId, + formData.title, + formData.emoji, + formData.description, + formData.workspace_type, + formData.workspace_path, + ); + } else { + await createProject( + formData.title, + formData.emoji, + formData.description, + formData.workspace_type, + formData.workspace_path, + ); + } + projectDialogOpen.value = false; + editingProject.value = null; + } catch (error) { + projectDialogError.value = + error instanceof Error ? error.message : "Failed to save project"; + } finally { + savingProject.value = false; } - - await createProject(formData.title, formData.emoji, formData.description); } +watch(projectDialogOpen, (open) => { + if (!open) { + projectDialogError.value = ""; + savingProject.value = false; + } +}); + async function selectSession(sessionId: string, pushRoute = true) { showChatWorkspace(); selectedProjectId.value = null; @@ -1012,7 +1193,7 @@ async function sendCurrentMessage() { const text = draft.value.trim(); const messageId = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`; const outgoingParts = buildOutgoingParts(text); - const selection = inputRef.value?.getCurrentSelection(); + const selection = getSelectedProviderSelection(); const { userRecord, botRecord } = createLocalExchange({ sessionId, messageId, @@ -1069,8 +1250,28 @@ function buildOutgoingParts(text: string): MessagePart[] { function updateTitleFromText(sessionId: string, text: string) { const session = sessions.value.find((item) => item.session_id === sessionId); - if (!session || session.display_name || !text) return; + const projectSession = projectSessions.value.find( + (item) => item.session_id === sessionId, + ); + const cachedProjectSessions = Object.values(projectSessionsById.value) + .flat() + .filter((item) => item.session_id === sessionId); + if ( + (!session && !projectSession && !cachedProjectSessions.length) || + session?.display_name || + projectSession?.display_name || + cachedProjectSessions.some((item) => item.display_name) || + !text + ) { + return; + } updateSessionTitle(sessionId, text.slice(0, 40)); + if (projectSession) { + projectSession.display_name = text.slice(0, 40); + } + cachedProjectSessions.forEach((item) => { + item.display_name = text.slice(0, 40); + }); } function replyPreview(messageId?: string | number, fallback?: string) { @@ -1127,7 +1328,7 @@ async function saveMessageEdit() { cancelMessageEdit(); if (result.needsRegenerate && result.truncatedAfterMessage) { - const selection = inputRef.value?.getCurrentSelection(); + const selection = getSelectedProviderSelection(); continueEditedMessage({ sessionId: currSessionId.value, sourceRecord: target, @@ -1370,11 +1571,15 @@ function toggleTheme() { diff --git a/dashboard/src/components/chat/ChatUILogo.vue b/dashboard/src/components/chat/ChatUILogo.vue new file mode 100644 index 000000000..bdbb248fa --- /dev/null +++ b/dashboard/src/components/chat/ChatUILogo.vue @@ -0,0 +1,23 @@ + diff --git a/dashboard/src/components/chat/ProjectDialog.vue b/dashboard/src/components/chat/ProjectDialog.vue index 49c0e3dba..3a00d5451 100644 --- a/dashboard/src/components/chat/ProjectDialog.vue +++ b/dashboard/src/components/chat/ProjectDialog.vue @@ -1,33 +1,52 @@ diff --git a/dashboard/src/components/chat/ProjectView.vue b/dashboard/src/components/chat/ProjectView.vue index 7c87f3539..04a499ba8 100644 --- a/dashboard/src/components/chat/ProjectView.vue +++ b/dashboard/src/components/chat/ProjectView.vue @@ -1,74 +1,85 @@ diff --git a/dashboard/src/components/chat/ReasoningSidebar.vue b/dashboard/src/components/chat/ReasoningSidebar.vue index a20a4bb89..b7d276b70 100644 --- a/dashboard/src/components/chat/ReasoningSidebar.vue +++ b/dashboard/src/components/chat/ReasoningSidebar.vue @@ -60,9 +60,10 @@ function close() { diff --git a/dashboard/src/components/shared/StyledMenu.vue b/dashboard/src/components/shared/StyledMenu.vue index d52e96c8e..01c58099b 100644 --- a/dashboard/src/components/shared/StyledMenu.vue +++ b/dashboard/src/components/shared/StyledMenu.vue @@ -7,7 +7,7 @@ @@ -35,9 +35,10 @@ withDefaults(defineProps<{ .styled-menu-card { min-width: 100px; width: fit-content; - border: 1px solid rgba(var(--v-theme-primary), 0.15) !important; + border: 0 !important; background: rgba(var(--v-theme-surface), 0.98) !important; backdrop-filter: blur(10px); + box-shadow: var(--astrbot-menu-shadow, 0 12px 28px rgba(0, 0, 0, 0.08)) !important; } .styled-menu-card-borderless { @@ -70,7 +71,7 @@ withDefaults(defineProps<{ /* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */ .v-theme--PurpleThemeDark .styled-menu-card { background: rgba(var(--v-theme-surface), 0.98) !important; - border: 1px solid rgba(var(--v-theme-primary), 0.2) !important; + border: 0 !important; } .v-theme--PurpleThemeDark .styled-menu-card-borderless { diff --git a/dashboard/src/composables/useProjects.ts b/dashboard/src/composables/useProjects.ts index c80221d12..6d2789f0d 100644 --- a/dashboard/src/composables/useProjects.ts +++ b/dashboard/src/composables/useProjects.ts @@ -2,6 +2,17 @@ import { ref } from 'vue'; import { chatApi } from '@/api/v1'; import type { Project } from '@/components/chat/ProjectList.vue'; +type WorkspaceType = 'session' | 'project' | 'custom'; + +function projectErrorMessage(error: unknown, fallback: string) { + const responseMessage = (error as any)?.response?.data?.message; + if (typeof responseMessage === 'string' && responseMessage.trim()) { + return responseMessage; + } + const message = (error as Error)?.message; + return message || fallback; +} + export function useProjects() { const projects = ref([]); const selectedProjectId = ref(null); @@ -18,34 +29,56 @@ export function useProjects() { } } - async function createProject(title: string, emoji?: string, description?: string) { + async function createProject( + title: string, + emoji?: string, + description?: string, + workspaceType: WorkspaceType = 'project', + workspacePath?: string + ) { try { const res = await chatApi.createProject({ title, emoji: emoji || '📁', - description + description, + workspace_type: workspaceType, + workspace_path: workspacePath }); if (res.data.status === 'ok') { await getProjects(); return res.data.data; } + throw new Error(res.data.message || 'Failed to create project'); } catch (error) { console.error('Failed to create project:', error); + throw new Error(projectErrorMessage(error, 'Failed to create project')); } } - async function updateProject(projectId: string, title?: string, emoji?: string, description?: string) { + async function updateProject( + projectId: string, + title?: string, + emoji?: string, + description?: string, + workspaceType?: WorkspaceType, + workspacePath?: string + ) { try { const res = await chatApi.updateProject(projectId, { title, emoji, - description + description, + workspace_type: workspaceType, + workspace_path: workspacePath }); if (res.data.status === 'ok') { await getProjects(); + return; } + throw new Error(res.data.message || 'Failed to update project'); } catch (error) { console.error('Failed to update project:', error); + throw new Error(projectErrorMessage(error, 'Failed to update project')); } } diff --git a/dashboard/src/i18n/locales/en-US/features/chat.json b/dashboard/src/i18n/locales/en-US/features/chat.json index 2eccb41ba..2ce8adc4b 100644 --- a/dashboard/src/i18n/locales/en-US/features/chat.json +++ b/dashboard/src/i18n/locales/en-US/features/chat.json @@ -2,7 +2,7 @@ "title": "Let's Chat!", "subtitle": "Chat with AI Assistant", "input": { - "placeholder": "Start typing...", + "placeholder": "Ask anything...", "send": "Send", "clear": "Clear", "upload": "Upload File", @@ -31,7 +31,7 @@ "liveMode": "Live Mode" }, "welcome": { - "title": "Welcome to AstrBot", + "title": "Anything on your mind today?", "subtitle": "Your Intelligent Chat Assistant", "quickActions": "Quick Actions", "examples": "Example Questions" @@ -73,6 +73,7 @@ "placeholder": "Ask about this excerpt..." }, "conversation": { + "title": "Conversations", "newConversation": "New Conversation", "noHistory": "No conversation history", "systemStatus": "System Status", @@ -131,7 +132,16 @@ "name": "Project Name", "emoji": "Icon (Emoji)", "description": "Description (Optional)", + "workspace": { + "type": "Workspace", + "session": "Per-session workspace", + "project": "Shared project workspace", + "custom": "Custom path", + "path": "Workspace path" + }, + "loadingSessions": "Loading conversations...", "noSessions": "No conversations in this project", + "noProjects": "No projects", "confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted." }, "time": { diff --git a/dashboard/src/i18n/locales/ru-RU/features/chat.json b/dashboard/src/i18n/locales/ru-RU/features/chat.json index fe0958e7d..ec12ce886 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/chat.json +++ b/dashboard/src/i18n/locales/ru-RU/features/chat.json @@ -2,7 +2,7 @@ "title": "Давай пообщаемся!", "subtitle": "Общение с AI-помощником", "input": { - "placeholder": "Введите сообщение...", + "placeholder": "Спросите что угодно...", "send": "Отправить", "clear": "Очистить", "upload": "Загрузить файл", @@ -31,7 +31,7 @@ "liveMode": "Общение в реальном времени" }, "welcome": { - "title": "Добро пожаловать в AstrBot", + "title": "О чем поговорим сегодня?", "subtitle": "Ваш умный помощник", "quickActions": "Быстрые действия", "examples": "Примеры вопросов" @@ -73,6 +73,7 @@ "placeholder": "Спросить об этом фрагменте..." }, "conversation": { + "title": "Диалоги", "newConversation": "Новый чат", "noHistory": "История диалогов пуста", "systemStatus": "Статус системы", @@ -131,7 +132,16 @@ "name": "Имя проекта", "emoji": "Иконка (Emoji)", "description": "Описание проекта (опционально)", + "workspace": { + "type": "Рабочая область", + "session": "Отдельно для каждого чата", + "project": "Общая область проекта", + "custom": "Пользовательский путь", + "path": "Путь рабочей области" + }, + "loadingSessions": "Загрузка диалогов...", "noSessions": "В этом проекте пока нет диалогов", + "noProjects": "Проектов пока нет", "confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены." }, "time": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/chat.json b/dashboard/src/i18n/locales/zh-CN/features/chat.json index 803283bc4..4adaaf6e0 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/chat.json +++ b/dashboard/src/i18n/locales/zh-CN/features/chat.json @@ -2,7 +2,7 @@ "title": "聊天吧!", "subtitle": "与AI助手进行对话", "input": { - "placeholder": "开始输入...", + "placeholder": "想问什么都可以", "send": "发送", "clear": "清空", "upload": "上传文件", @@ -31,7 +31,7 @@ "liveMode": "实时对话" }, "welcome": { - "title": "欢迎使用 AstrBot", + "title": "今天想聊点什么?", "subtitle": "您的智能对话助手", "quickActions": "快速操作", "examples": "示例问题" @@ -73,6 +73,7 @@ "placeholder": "继续追问这段内容..." }, "conversation": { + "title": "对话", "newConversation": "新的聊天", "noHistory": "暂无对话历史", "systemStatus": "系统状态", @@ -131,7 +132,16 @@ "name": "项目名称", "emoji": "图标 (Emoji)", "description": "项目描述(可选)", + "workspace": { + "type": "工作区", + "session": "按会话分开", + "project": "项目共享工作区", + "custom": "自定义路径", + "path": "工作区路径" + }, + "loadingSessions": "加载对话中...", "noSessions": "该项目暂无对话", + "noProjects": "暂无项目", "confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。" }, "time": { diff --git a/dashboard/src/layouts/full/FullLayout.vue b/dashboard/src/layouts/full/FullLayout.vue index 08ea74151..19f0da45d 100644 --- a/dashboard/src/layouts/full/FullLayout.vue +++ b/dashboard/src/layouts/full/FullLayout.vue @@ -109,8 +109,9 @@ onMounted(() => { @@ -159,4 +160,8 @@ onMounted(() => { height: 100% !important; overflow: hidden !important; } + +.chat-main { + padding-top: 0 !important; +} diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index 2090cc08a..e5d89180f 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -1,5 +1,6 @@