diff --git a/backend/app/api/groups.py b/backend/app/api/groups.py index f642d5e3a..14a2afba4 100644 --- a/backend/app/api/groups.py +++ b/backend/app/api/groups.py @@ -5,13 +5,16 @@ import uuid from datetime import UTC, datetime from typing import Annotated, Any, Literal +from urllib.parse import quote -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from fastapi.responses import Response +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import exists, select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.security import get_current_user +from app.core.security import decode_access_token, get_current_user from app.database import get_db from app.models.agent import Agent from app.models.agent_run import AgentRun @@ -38,6 +41,7 @@ from app.services.group_message_service import GroupMessageServiceError from app.services.group_realtime import publish_group_message_created from app.services.participant_identity import get_or_create_user_participant +from app.services.storage import guess_content_type router = APIRouter(prefix="/api/groups", tags=["groups"]) @@ -188,6 +192,14 @@ class GroupWorkspaceEntryOut(BaseModel): version_token: str | None = None +class GroupWorkspaceUploadOut(BaseModel): + path: str + size: int + version_token: str + modified_at: str | None = None + revision_id: uuid.UUID | None = None + + class GroupSessionSummaryOut(BaseModel): version: int summary: str @@ -1403,6 +1415,130 @@ async def put_group_workspace_file( return _text_file_out(value) +@router.post("/{group_id}/workspace/upload", response_model=GroupWorkspaceUploadOut) +async def upload_group_workspace_file( + group_id: uuid.UUID, + path: Annotated[str, Query(min_length=1, max_length=500)], + file: UploadFile = File(...), + expected_version_token: Annotated[str | None, Query()] = None, + require_absent: Annotated[bool, Query()] = False, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Upload one group workspace file without converting binary bytes to text.""" + tenant_id = _tenant_id(current_user) + participant = await _current_participant(db, current_user) + try: + value = await group_file_service.write_workspace_binary_file( + db, + tenant_id=tenant_id, + group_id=group_id, + actor_participant_id=participant.id, + path=path, + content=await file.read(), + content_type=guess_content_type(path), + expected_version_token=expected_version_token, + require_absent=require_absent, + ) + except GroupChatServiceError as exc: + raise _translate_domain_error(exc) from exc + except GroupFileServiceError as exc: + raise _translate_file_error(exc) from exc + _stage_audit( + db, + current_user=current_user, + action="group:workspace_write", + tenant_id=tenant_id, + group_id=group_id, + details={ + "path": value.path, + "revision_id": str(value.revision_id) if value.revision_id else None, + }, + ) + return GroupWorkspaceUploadOut( + path=value.path, + size=len(value.content), + version_token=value.version_token, + modified_at=value.modified_at, + revision_id=value.revision_id, + ) + + +async def _download_user( + *, + token: str, + credentials: HTTPAuthorizationCredentials | None, + db: AsyncSession, +) -> User: + jwt_token = credentials.credentials if credentials is not None else token + if not jwt_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + ) + payload = decode_access_token(jwt_token) + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + try: + parsed_user_id = uuid.UUID(user_id) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc + result = await db.execute(select(User).where(User.id == parsed_user_id)) + user = result.scalar_one_or_none() + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive", + ) + return user + + +@router.get("/{group_id}/workspace/download") +async def download_group_workspace_file( + group_id: uuid.UUID, + path: Annotated[str, Query(min_length=1, max_length=500)], + token: str = "", + inline: bool = False, + credentials: HTTPAuthorizationCredentials | None = Depends(HTTPBearer(auto_error=False)), + db: AsyncSession = Depends(get_db), +): + """Download a group workspace file with membership authorization.""" + current_user = await _download_user(token=token, credentials=credentials, db=db) + tenant_id = _tenant_id(current_user) + participant = await _current_participant(db, current_user) + try: + value = await group_file_service.read_workspace_binary_file( + db, + tenant_id=tenant_id, + group_id=group_id, + actor_participant_id=participant.id, + path=path, + ) + except GroupChatServiceError as exc: + raise _translate_domain_error(exc) from exc + except GroupFileServiceError as exc: + raise _translate_file_error(exc) from exc + filename = value.path.rsplit("/", 1)[-1] + media_type = guess_content_type(filename) + inline_media_types = { + "image/gif", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/webp", + } + allow_inline = inline and media_type in inline_media_types + disposition = "inline" if allow_inline else "attachment" + headers = { + "Content-Disposition": f"{disposition}; filename*=UTF-8''{quote(filename)}", + "X-Content-Type-Options": "nosniff", + } + if allow_inline and media_type == "image/svg+xml": + headers["Content-Security-Policy"] = "sandbox; default-src 'none'; style-src 'unsafe-inline'" + return Response(content=value.content, media_type=media_type, headers=headers) + + @router.delete("/{group_id}/workspace/file", status_code=status.HTTP_204_NO_CONTENT) async def delete_group_workspace_file( group_id: uuid.UUID, diff --git a/backend/app/services/group_file_service.py b/backend/app/services/group_file_service.py index e8b5b31fa..c852e74c3 100644 --- a/backend/app/services/group_file_service.py +++ b/backend/app/services/group_file_service.py @@ -17,8 +17,15 @@ from app.models.participant import Participant from app.services import group_chat_service from app.services.storage import get_storage_backend, normalize_storage_key -from app.services.storage_runtime.base import StorageEntry, StorageVersion, WriteCondition +from app.services.storage_runtime.base import ( + StorageEntry, + StorageVersion, + WriteCondition, + content_hash_bytes, +) from app.services.workspace_collaboration import ( + BINARY_REVISION_EXTENSIONS, + MAX_REVISION_TEXT_BYTES, content_hash, finalize_group_runtime_revision, get_group_runtime_revision, @@ -48,6 +55,17 @@ class GroupTextFile: revision_id: uuid.UUID | None = None +@dataclass(frozen=True, slots=True) +class GroupBinaryFile: + """Business-level view of one group workspace binary file.""" + + path: str + content: bytes + version_token: str + modified_at: str | None + revision_id: uuid.UUID | None = None + + @dataclass(frozen=True, slots=True) class GroupWorkspaceEntry: """One immediate child in the group workspace.""" @@ -184,6 +202,23 @@ def _validate_text(content: str) -> str: return content +async def _revision_text(storage, key: str, business_path: str) -> str | None: + """Return exact revision text without decoding binary data into database TEXT.""" + raw = await storage.read_bytes(key) + filename = business_path.rsplit("/", 1)[-1] + suffix = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if ( + suffix in BINARY_REVISION_EXTENSIONS + or len(raw) > MAX_REVISION_TEXT_BYTES + or b"\x00" in raw + ): + return None + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return None + + def _runtime_revision_path(path: str) -> str: return _revision_path("workspace", path) @@ -548,7 +583,7 @@ async def _write_text( "Group file already exists at this path", ) before = ( - await storage.read_text(key, encoding="utf-8", errors="replace") + await _revision_text(storage, key, business_path) if current.exists and not current.is_dir else None ) @@ -607,7 +642,7 @@ async def _delete_text( current = await storage.get_version(key) if not current.exists or current.is_dir: raise GroupFileServiceError("group_file_not_found", "Group file not found") - before = await storage.read_text(key, encoding="utf-8", errors="replace") + before = await _revision_text(storage, key, revision_path) result = await storage.delete_if_match( key, condition=( @@ -912,6 +947,36 @@ async def read_workspace_file( ) +async def read_workspace_binary_file( + db: AsyncSession, + *, + tenant_id: uuid.UUID, + group_id: uuid.UUID, + actor_participant_id: uuid.UUID, + path: str, +) -> GroupBinaryFile: + """Read one group workspace file as exact bytes.""" + await _authorize_actor( + db, + tenant_id=tenant_id, + group_id=group_id, + actor_participant_id=actor_participant_id, + ) + normalized, key = _workspace_key(group_id, path, allow_empty=False) + storage = get_storage_backend() + version = await storage.get_version(key) + if not version.exists: + raise GroupFileServiceError("group_file_not_found", "Group file not found") + if version.is_dir: + raise GroupFileServiceError("group_file_not_readable", "Path is a directory") + return GroupBinaryFile( + path=normalized, + content=await storage.read_bytes(key), + version_token=version.token, + modified_at=version.modified_at or None, + ) + + async def write_workspace_file( db: AsyncSession, *, @@ -946,6 +1011,82 @@ async def write_workspace_file( ) +async def write_workspace_binary_file( + db: AsyncSession, + *, + tenant_id: uuid.UUID, + group_id: uuid.UUID, + actor_participant_id: uuid.UUID, + path: str, + content: bytes, + content_type: str, + expected_version_token: str | None = None, + require_absent: bool = False, + session_id: uuid.UUID | None = None, +) -> GroupBinaryFile: + """Create or replace one group workspace file without text transcoding.""" + actor = await _authorize_actor( + db, + tenant_id=tenant_id, + group_id=group_id, + actor_participant_id=actor_participant_id, + ) + normalized, key = _workspace_key(group_id, path, allow_empty=False) + if require_absent and expected_version_token is not None: + raise GroupFileServiceError( + "group_file_write_condition_invalid", + "A create-only write cannot also provide a version token", + ) + storage = get_storage_backend() + current = await storage.get_version(key) + if current.is_dir: + raise GroupFileServiceError("group_file_conflict", "Group path is a directory") + before = ( + await _revision_text(storage, key, normalized) + if current.exists and not current.is_dir + else None + ) + result = await storage.write_bytes_if_match( + key, + content, + condition=( + WriteCondition(require_absent=True) + if require_absent + else ( + WriteCondition(version_token=expected_version_token) + if expected_version_token is not None + else None + ) + ), + content_type=content_type, + ) + if not result.ok: + raise GroupFileServiceError( + "group_file_conflict", + "Group file changed before this write completed", + ) + revision = await record_group_revision( + db, + group_id=group_id, + path=_revision_path("workspace", normalized), + operation="write", + actor_type=actor.type, + actor_id=actor.ref_id, + before_content=before, + after_content=None, + content_hash_override=content_hash_bytes(content), + session_id=str(session_id) if session_id is not None else None, + ) + updated = result.current_version or await storage.get_version(key) + return GroupBinaryFile( + path=normalized, + content=content, + version_token=updated.token, + modified_at=updated.modified_at or None, + revision_id=revision.id if revision is not None else None, + ) + + async def _delete_empty_workspace_directory( db: AsyncSession, *, @@ -1069,6 +1210,7 @@ async def delete_workspace_file( __all__ = [ "GroupFileServiceError", + "GroupBinaryFile", "GroupTextFile", "GroupWorkspaceEntry", "delete_agent_memory", @@ -1078,7 +1220,9 @@ async def delete_workspace_file( "read_agent_memory", "read_announcement", "read_workspace_file", + "read_workspace_binary_file", "write_agent_memory", "write_announcement", "write_workspace_file", + "write_workspace_binary_file", ] diff --git a/backend/app/services/workspace_collaboration.py b/backend/app/services/workspace_collaboration.py index 2f6c88bf3..d0dec57e4 100644 --- a/backend/app/services/workspace_collaboration.py +++ b/backend/app/services/workspace_collaboration.py @@ -225,6 +225,7 @@ async def _record_scoped_revision( actor_id: uuid.UUID | None, before_content: str | None, after_content: str | None, + content_hash_override: str | None = None, session_id: str | None = None, merge_user_autosave: bool = False, ) -> WorkspaceFileRevision | None: @@ -245,7 +246,11 @@ async def _record_scoped_revision( after_content = after_content.replace("\x00", "") if after_content is not None else None before = before_content or "" after = after_content or "" - if before == after and operation not in {"delete", "move_source", "move_destination"}: + if ( + before == after + and content_hash_override is None + and operation not in {"delete", "move_source", "move_destination"} + ): return None group_key = None @@ -274,7 +279,7 @@ async def _record_scoped_revision( existing = existing_result.scalar_one_or_none() if existing: existing.after_content = after - existing.content_hash = content_hash(after) + existing.content_hash = content_hash_override or content_hash(after) existing.session_id = session_id or existing.session_id await db.flush() return existing @@ -290,7 +295,7 @@ async def _record_scoped_revision( session_id=session_id, before_content=before_content, after_content=after_content, - content_hash=content_hash(after_content), + content_hash=content_hash_override or content_hash(after_content), group_key=group_key, ) db.add(revision) @@ -338,6 +343,7 @@ async def record_group_revision( actor_id: uuid.UUID | None, before_content: str | None, after_content: str | None, + content_hash_override: str | None = None, session_id: str | None = None, ) -> WorkspaceFileRevision | None: """Record a group-scoped file revision without creating a second history table.""" @@ -352,6 +358,7 @@ async def record_group_revision( actor_id=actor_id, before_content=before_content, after_content=after_content, + content_hash_override=content_hash_override, session_id=session_id, ) diff --git a/backend/tests/test_group_api.py b/backend/tests/test_group_api.py index 294047df6..e4caf7b6b 100644 --- a/backend/tests/test_group_api.py +++ b/backend/tests/test_group_api.py @@ -112,6 +112,8 @@ def test_group_router_exposes_management_and_read_state_boundaries() -> None: assert ("GET", "/api/groups/{group_id}/workspace/file") in routes assert ("PUT", "/api/groups/{group_id}/workspace/file") in routes assert ("DELETE", "/api/groups/{group_id}/workspace/file") in routes + assert ("POST", "/api/groups/{group_id}/workspace/upload") in routes + assert ("GET", "/api/groups/{group_id}/workspace/download") in routes assert ("PATCH", "/api/groups/{group_id}/members/{member_id}") not in routes @@ -221,6 +223,110 @@ async def fake_write(_db, **kwargs): assert "require_absent" not in groups_api.GroupTextFileIn.model_fields +@pytest.mark.asyncio +async def test_workspace_binary_upload_preserves_conditions_and_stages_audit(monkeypatch) -> None: + tenant_id = uuid.uuid4() + user = _user(tenant_id) + participant = _participant(user) + group = _group(tenant_id, participant.id) + db = _RecordingDB() + calls = [] + + async def fake_participant(_db, _user): + return participant + + async def fake_write(_db, **kwargs): + calls.append(kwargs) + return SimpleNamespace( + path=kwargs["path"], + content=kwargs["content"], + version_token="binary-v1", + modified_at="now", + revision_id=uuid.uuid4(), + ) + + class _Upload: + async def read(self): + return b"%PDF-1.7\n\x00payload" + + monkeypatch.setattr(groups_api, "_current_participant", fake_participant) + monkeypatch.setattr(groups_api.group_file_service, "write_workspace_binary_file", fake_write) + + result = await groups_api.upload_group_workspace_file( + group.id, + path="reports/final.pdf", + file=_Upload(), + expected_version_token="binary-v0", + require_absent=False, + current_user=user, + db=db, + ) + + assert result.path == "reports/final.pdf" + assert result.size == len(b"%PDF-1.7\n\x00payload") + assert calls == [ + { + "tenant_id": tenant_id, + "group_id": group.id, + "actor_participant_id": participant.id, + "path": "reports/final.pdf", + "content": b"%PDF-1.7\n\x00payload", + "content_type": "application/pdf", + "expected_version_token": "binary-v0", + "require_absent": False, + } + ] + audit = next(value for value in db.added if isinstance(value, AuditLog)) + assert audit.action == "group:workspace_write" + assert audit.details["path"] == "reports/final.pdf" + + +@pytest.mark.asyncio +async def test_workspace_download_returns_exact_bytes_after_group_authorization(monkeypatch) -> None: + tenant_id = uuid.uuid4() + user = _user(tenant_id) + participant = _participant(user) + group = _group(tenant_id, participant.id) + db = _RecordingDB() + calls = [] + + async def fake_download_user(**kwargs): + assert kwargs["token"] == "download-token" + return user + + async def fake_participant(_db, _user): + return participant + + async def fake_read(_db, **kwargs): + calls.append(kwargs) + return SimpleNamespace(path="images/chart.png", content=b"\x89PNG\r\n") + + monkeypatch.setattr(groups_api, "_download_user", fake_download_user) + monkeypatch.setattr(groups_api, "_current_participant", fake_participant) + monkeypatch.setattr(groups_api.group_file_service, "read_workspace_binary_file", fake_read) + + response = await groups_api.download_group_workspace_file( + group.id, + path="images/chart.png", + token="download-token", + inline=True, + credentials=None, + db=db, + ) + + assert response.body == b"\x89PNG\r\n" + assert response.media_type == "image/png" + assert response.headers["content-disposition"].startswith("inline;") + assert calls == [ + { + "tenant_id": tenant_id, + "group_id": group.id, + "actor_participant_id": participant.id, + "path": "images/chart.png", + } + ] + + @pytest.mark.asyncio async def test_create_group_stages_domain_change_and_audit_in_one_transaction(monkeypatch) -> None: tenant_id = uuid.uuid4() diff --git a/backend/tests/test_group_file_service.py b/backend/tests/test_group_file_service.py index 5c74e871d..b15cdd6f0 100644 --- a/backend/tests/test_group_file_service.py +++ b/backend/tests/test_group_file_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import uuid import pytest @@ -243,6 +244,65 @@ async def test_group_workspace_create_can_require_the_path_to_be_absent( assert await storage.read_text(f"groups/{group_id}/workspace/notes.md") == "existing" +@pytest.mark.asyncio +async def test_group_workspace_binary_file_preserves_bytes_version_and_revision( + monkeypatch, + tmp_path, +) -> None: + tenant_id = uuid.uuid4() + group_id = uuid.uuid4() + actor = _participant("user") + db = _RecordingDB() + storage = _stub_storage_and_authorization(monkeypatch, tmp_path, actor) + content = b"%PDF-1.7\n\x00binary-payload" + + written = await group_file_service.write_workspace_binary_file( + db, + tenant_id=tenant_id, + group_id=group_id, + actor_participant_id=actor.id, + path="reports/final.pdf", + content=content, + content_type="application/pdf", + require_absent=True, + ) + + assert written.path == "reports/final.pdf" + assert written.content == content + assert written.version_token + assert await storage.read_bytes( + f"groups/{group_id}/workspace/reports/final.pdf" + ) == content + revision = next(value for value in db.added if isinstance(value, WorkspaceFileRevision)) + assert revision.path == "workspace/reports/final.pdf" + assert revision.before_content is None + assert revision.after_content is None + assert revision.content_hash == hashlib.sha256(content).hexdigest() + + read_back = await group_file_service.read_workspace_binary_file( + db, + tenant_id=tenant_id, + group_id=group_id, + actor_participant_id=actor.id, + path="reports/final.pdf", + ) + assert read_back.content == content + assert read_back.version_token == written.version_token + + with pytest.raises(group_file_service.GroupFileServiceError) as conflict: + await group_file_service.write_workspace_binary_file( + db, + tenant_id=tenant_id, + group_id=group_id, + actor_participant_id=actor.id, + path="reports/final.pdf", + content=b"replacement", + content_type="application/pdf", + require_absent=True, + ) + assert conflict.value.code == "group_file_conflict" + + @pytest.mark.asyncio async def test_group_workspace_deletes_empty_directory_but_rejects_non_empty_directory( monkeypatch, diff --git a/frontend/src/components/FileBrowser.tsx b/frontend/src/components/FileBrowser.tsx index 8680f48a8..fddb31e05 100644 --- a/frontend/src/components/FileBrowser.tsx +++ b/frontend/src/components/FileBrowser.tsx @@ -10,6 +10,10 @@ import { IconDownload, IconEdit, IconFolder, IconFolderPlus, IconTrash, IconUplo import MarkdownRenderer from './MarkdownRenderer'; import { useDropZone } from '../hooks/useDropZone'; import { formatFileSize } from '../utils/formatFileSize'; +import { + WORKSPACE_TEXT_UPLOAD_EXTENSIONS, + WORKSPACE_UPLOAD_ACCEPT, +} from '../utils/workspaceFileFormats.ts'; // ─── Types ───────────────────────────────────────────── @@ -26,7 +30,7 @@ export interface FileBrowserApi { write: (path: string, content: string) => Promise; delete: (path: string) => Promise; upload?: (file: File, path: string, onProgress?: (pct: number) => void) => Promise; - downloadUrl?: (path: string) => string; + downloadUrl?: (path: string, options?: { inline?: boolean }) => string; } export interface FileBrowserProps { @@ -50,7 +54,7 @@ export interface FileBrowserProps { // ─── Text file detection ─────────────────────────────── -const TEXT_EXTS = ['.txt', '.md', '.csv', '.json', '.xml', '.yaml', '.yml', '.js', '.ts', '.py', '.html', '.css', '.sh', '.log', '.gitkeep', '.env']; +const TEXT_EXTS = WORKSPACE_TEXT_UPLOAD_EXTENSIONS; function isTextFile(name: string): boolean { const n = name.toLowerCase(); @@ -74,7 +78,7 @@ export default function FileBrowser({ features = {}, fileFilter, singleFile, - uploadAccept = '.pdf,.docx,.xlsx,.pptx,.txt,.md,.csv,.json,.xml,.yaml,.yml,.js,.ts,.py,.html,.css,.sh,.log,.png,.jpg,.jpeg,.gif,.svg,.webp', + uploadAccept = WORKSPACE_UPLOAD_ACCEPT, title, readOnly = false, onRefresh, @@ -189,6 +193,10 @@ export default function FileBrowser({ useEffect(() => { if (!viewing || singleFile) return; + if (!isTextFile(viewing)) { + setContent(''); + return; + } api.read(viewing).then(data => { setContent(data.content || ''); }).catch((err: any) => { @@ -471,7 +479,7 @@ export default function FileBrowser({
{api.downloadUrl ? ( {viewing.split('/').pop()} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 0aede0dbe..3bab935b1 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -2152,7 +2152,7 @@ "announcementPlaceholder": "Group goals, collaboration rules, what agents should do here...", "workspace": "Files", "workspaceNote": "The group workspace is shared by every session in this group. Agents drop their outputs here too.", - "workspaceUploadTextOnly": "The group workspace currently accepts UTF-8 text files only.", + "workspaceUploadUnsupported": "The group workspace does not support this file format.", "workspaceUploadInvalidName": "File names cannot contain path separators.", "workspaceUploadInvalidUtf8": "The file is not valid UTF-8 text and was not uploaded.", "memory": "Memory", diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index 4b546d804..6ce9dd741 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -2277,7 +2277,7 @@ "announcementPlaceholder": "写下群目标、协作规则和对智能体的要求...", "workspace": "文件", "workspaceNote": "群 workspace 是全群共享的文件区,群内所有会话共用同一份。智能体的产物也会放在这里。", - "workspaceUploadTextOnly": "群文件区当前只支持 UTF-8 文本文件。", + "workspaceUploadUnsupported": "群文件区不支持这种文件格式。", "workspaceUploadInvalidName": "文件名不能包含路径分隔符。", "workspaceUploadInvalidUtf8": "文件不是有效的 UTF-8 文本,未上传。", "memory": "记忆", diff --git a/frontend/src/pages/agent-detail/AgentDetailPage.tsx b/frontend/src/pages/agent-detail/AgentDetailPage.tsx index cb65430bb..c5b6ca1ec 100644 --- a/frontend/src/pages/agent-detail/AgentDetailPage.tsx +++ b/frontend/src/pages/agent-detail/AgentDetailPage.tsx @@ -6371,7 +6371,7 @@ export default function AgentDetailPage() { write: (p, c) => fileApi.write(id!, p, c), delete: (p) => fileApi.delete(id!, p), upload: (file, path, onProgress) => fileApi.upload(id!, file, path + '/', onProgress), - downloadUrl: (p) => fileApi.downloadUrl(id!, p), + downloadUrl: (p, options) => fileApi.downloadUrl(id!, p, options), }; return ; })() diff --git a/frontend/src/pages/groups/GroupWorkspaceTab.tsx b/frontend/src/pages/groups/GroupWorkspaceTab.tsx index fdc4a9203..c58f40ba2 100644 --- a/frontend/src/pages/groups/GroupWorkspaceTab.tsx +++ b/frontend/src/pages/groups/GroupWorkspaceTab.tsx @@ -6,6 +6,7 @@ import { GROUP_WORKSPACE_UPLOAD_ACCEPT, GroupWorkspaceUploadError, groupWorkspaceUploadPath, + isGroupWorkspaceTextUpload, readGroupWorkspaceTextUpload, } from './groupWorkspaceUpload'; import { createVersionedFileAdapter } from './versionedFileAdapter'; @@ -47,15 +48,30 @@ export default function GroupWorkspaceTab({ groupId }: { groupId: string }) { read: versioned.read, write: versioned.write, delete: versioned.delete, + downloadUrl: (path, options) => + groupApi.downloadWorkspaceUrl(groupId, path, options), upload: async (file, currentPath, onProgress) => { try { const path = groupWorkspaceUploadPath(currentPath, file.name); + const snapshot = versioned.snapshot(path); + + if (!isGroupWorkspaceTextUpload(file.name)) { + const saved = await groupApi.uploadWorkspaceFile( + groupId, + path, + file, + snapshot.versionToken, + !snapshot.known, + onProgress, + ); + versioned.remember(path, saved.version_token); + return saved; + } + onProgress?.(10); const content = await readGroupWorkspaceTextUpload(file); onProgress?.(40); - const snapshot = versioned.snapshot(path); - const saved = await groupApi.saveWorkspaceFile( groupId, path, @@ -71,7 +87,7 @@ export default function GroupWorkspaceTab({ groupId }: { groupId: string }) { const message = error.code === 'invalid_name' ? t('groups.workspaceUploadInvalidName', '文件名不能包含路径分隔符') : error.code === 'unsupported_type' - ? t('groups.workspaceUploadTextOnly', '群文件区当前只支持 UTF-8 文本文件') + ? t('groups.workspaceUploadUnsupported', '群文件区不支持这种文件格式') : t('groups.workspaceUploadInvalidUtf8', '文件不是有效的 UTF-8 文本,未上传') throw new Error(message); } diff --git a/frontend/src/pages/groups/groupWorkspaceUpload.ts b/frontend/src/pages/groups/groupWorkspaceUpload.ts index 4777f6914..9d84d7b37 100644 --- a/frontend/src/pages/groups/groupWorkspaceUpload.ts +++ b/frontend/src/pages/groups/groupWorkspaceUpload.ts @@ -1,9 +1,14 @@ -export const GROUP_WORKSPACE_TEXT_EXTENSIONS = [ - '.txt', '.md', '.csv', '.json', '.xml', '.yaml', '.yml', '.js', '.ts', '.py', - '.html', '.css', '.sh', '.log', '.gitkeep', '.env', -] as const; +import { + WORKSPACE_BINARY_UPLOAD_EXTENSIONS, + WORKSPACE_TEXT_UPLOAD_EXTENSIONS, + WORKSPACE_UPLOAD_ACCEPT, + WORKSPACE_UPLOAD_EXTENSIONS, +} from '../../utils/workspaceFileFormats.ts'; -export const GROUP_WORKSPACE_UPLOAD_ACCEPT = GROUP_WORKSPACE_TEXT_EXTENSIONS.join(','); +export const GROUP_WORKSPACE_TEXT_EXTENSIONS = WORKSPACE_TEXT_UPLOAD_EXTENSIONS; +export const GROUP_WORKSPACE_BINARY_EXTENSIONS = WORKSPACE_BINARY_UPLOAD_EXTENSIONS; +export const GROUP_WORKSPACE_UPLOAD_EXTENSIONS = WORKSPACE_UPLOAD_EXTENSIONS; +export const GROUP_WORKSPACE_UPLOAD_ACCEPT = WORKSPACE_UPLOAD_ACCEPT; export type GroupWorkspaceUploadErrorCode = | 'invalid_name' @@ -20,7 +25,7 @@ export class GroupWorkspaceUploadError extends Error { } } -function isSupportedTextName(name: string): boolean { +export function isGroupWorkspaceTextUpload(name: string): boolean { const lower = name.toLowerCase(); const base = lower.split('/').pop() || ''; return GROUP_WORKSPACE_TEXT_EXTENSIONS.some((extension) => lower.endsWith(extension)) @@ -28,11 +33,17 @@ function isSupportedTextName(name: string): boolean { || base.startsWith('.'); } +function isSupportedUploadName(name: string): boolean { + const lower = name.toLowerCase(); + return isGroupWorkspaceTextUpload(name) + || GROUP_WORKSPACE_BINARY_EXTENSIONS.some((extension) => lower.endsWith(extension)); +} + export function groupWorkspaceUploadPath(directory: string, fileName: string): string { if (!fileName || fileName === '.' || fileName === '..' || /[\\/]/.test(fileName)) { throw new GroupWorkspaceUploadError('invalid_name'); } - if (!isSupportedTextName(fileName)) { + if (!isSupportedUploadName(fileName)) { throw new GroupWorkspaceUploadError('unsupported_type'); } return directory ? `${directory.replace(/\/$/, '')}/${fileName}` : fileName; diff --git a/frontend/src/services/groupApi.ts b/frontend/src/services/groupApi.ts index cdba24251..4045b0e58 100644 --- a/frontend/src/services/groupApi.ts +++ b/frontend/src/services/groupApi.ts @@ -1,6 +1,6 @@ /** Group chat API client — /api/groups. */ -import { fetchJson } from './api'; +import { fetchJson, uploadFileWithProgress } from './api'; import type { Group, GroupMember, @@ -190,6 +190,40 @@ export const groupApi = { }), }), + uploadWorkspaceFile: ( + groupId: string, + path: string, + file: File, + expectedVersionToken?: string | null, + requireAbsent = false, + onProgress?: (percent: number) => void, + ) => uploadFileWithProgress( + `/groups/${groupId}/workspace/upload${qs({ + path, + expected_version_token: expectedVersionToken ?? undefined, + require_absent: requireAbsent ? 'true' : undefined, + })}`, + file, + onProgress, + ).promise as Promise<{ + path: string; + size: number; + version_token: string; + modified_at?: string | null; + revision_id?: string | null; + }>, + + downloadWorkspaceUrl: ( + groupId: string, + path: string, + options?: { inline?: boolean }, + ) => { + const token = localStorage.getItem('token'); + const params = new URLSearchParams({ path, token: token || '' }); + if (options?.inline) params.set('inline', 'true'); + return `/api/groups/${groupId}/workspace/download?${params.toString()}`; + }, + deleteWorkspaceFile: ( groupId: string, path: string, diff --git a/frontend/src/utils/workspaceFileFormats.ts b/frontend/src/utils/workspaceFileFormats.ts new file mode 100644 index 000000000..bfdb862db --- /dev/null +++ b/frontend/src/utils/workspaceFileFormats.ts @@ -0,0 +1,16 @@ +export const WORKSPACE_TEXT_UPLOAD_EXTENSIONS = [ + '.txt', '.md', '.csv', '.json', '.xml', '.yaml', '.yml', '.js', '.ts', '.py', + '.html', '.css', '.sh', '.log', '.gitkeep', '.env', +] as const; + +export const WORKSPACE_BINARY_UPLOAD_EXTENSIONS = [ + '.pdf', '.docx', '.xlsx', '.pptx', + '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', +] as const; + +export const WORKSPACE_UPLOAD_EXTENSIONS = [ + ...WORKSPACE_TEXT_UPLOAD_EXTENSIONS, + ...WORKSPACE_BINARY_UPLOAD_EXTENSIONS, +] as const; + +export const WORKSPACE_UPLOAD_ACCEPT = WORKSPACE_UPLOAD_EXTENSIONS.join(','); diff --git a/frontend/tests/groupApiContract.test.mjs b/frontend/tests/groupApiContract.test.mjs index b0b3e32e9..1a292a69b 100644 --- a/frontend/tests/groupApiContract.test.mjs +++ b/frontend/tests/groupApiContract.test.mjs @@ -27,3 +27,10 @@ test('group runs expose exact state and explicit cancellation by run id', () => assert.match(source, /runState:[\s\S]*sessions\/\$\{sessionId\}\/runs\/\$\{runId\}/); assert.match(source, /cancelRun:[\s\S]*runs\/\$\{runId\}\/cancel/); }); + +test('group workspace exposes binary upload progress and authenticated download URLs', () => { + assert.match(source, /uploadWorkspaceFile:[\s\S]*uploadFileWithProgress/); + assert.match(source, /workspace\/upload/); + assert.match(source, /downloadWorkspaceUrl:[\s\S]*workspace\/download/); + assert.match(source, /localStorage\.getItem\('token'\)/); +}); diff --git a/frontend/tests/versionedFileAdapter.test.mjs b/frontend/tests/versionedFileAdapter.test.mjs index c089e6a7b..0e5569924 100644 --- a/frontend/tests/versionedFileAdapter.test.mjs +++ b/frontend/tests/versionedFileAdapter.test.mjs @@ -3,8 +3,10 @@ import test from 'node:test'; import { createVersionedFileAdapter } from '../src/pages/groups/versionedFileAdapter.ts'; import { + GROUP_WORKSPACE_UPLOAD_EXTENSIONS, GroupWorkspaceUploadError, groupWorkspaceUploadPath, + isGroupWorkspaceTextUpload, readGroupWorkspaceTextUpload, } from '../src/pages/groups/groupWorkspaceUpload.ts'; @@ -98,7 +100,16 @@ test('group workspace uploads stay in the current directory and decode exact UTF assert.equal(content, '# 周报\n'); }); -test('group workspace upload rejects traversal, binary extensions, and invalid UTF-8', async () => { +test('group workspace accepts the single-agent formats and classifies binary files', async () => { + for (const extension of ['.pdf', '.docx', '.xlsx', '.pptx', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp']) { + assert.ok(GROUP_WORKSPACE_UPLOAD_EXTENSIONS.includes(extension)); + assert.equal(groupWorkspaceUploadPath('shared', `sample${extension}`), `shared/sample${extension}`); + assert.equal(isGroupWorkspaceTextUpload(`sample${extension}`), false); + } + assert.equal(isGroupWorkspaceTextUpload('notes.md'), true); +}); + +test('group workspace upload rejects traversal, unsupported formats, and invalid UTF-8', async () => { assert.throws( () => groupWorkspaceUploadPath('reports', '../secret.md'), (error) => error instanceof GroupWorkspaceUploadError && error.code === 'invalid_name',