diff --git a/backend/alembic/versions/2b3c4d5e6f7g_add_shared_tasks_table.py b/backend/alembic/versions/2b3c4d5e6f7g_add_shared_tasks_table.py new file mode 100644 index 00000000..935faf46 --- /dev/null +++ b/backend/alembic/versions/2b3c4d5e6f7g_add_shared_tasks_table.py @@ -0,0 +1,48 @@ +"""add shared_tasks table for task sharing + +Revision ID: 2b3c4d5e6f7g +Revises: add_subtask_attachments +Create Date: 2025-12-04 12:00:00.000000+08:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2b3c4d5e6f7g' +down_revision: Union[str, Sequence[str], None] = 'add_subtask_attachments' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add shared_tasks table for task sharing functionality.""" + + # Create shared_tasks table + op.execute(""" + CREATE TABLE IF NOT EXISTS shared_tasks ( + id INT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + user_id INT NOT NULL DEFAULT 0 COMMENT '当前用户ID', + original_user_id INT NOT NULL DEFAULT 0 COMMENT '原始任务所有者用户ID', + original_task_id INT NOT NULL DEFAULT 0 COMMENT '原始任务ID', + copied_task_id INT NOT NULL DEFAULT 0 COMMENT '复制后的任务ID', + is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否激活', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (id), + KEY idx_shared_tasks_id (id), + KEY idx_shared_tasks_user_id (user_id), + KEY idx_shared_tasks_original_user_id (original_user_id), + KEY idx_shared_tasks_original_task_id (original_task_id), + KEY idx_shared_tasks_copied_task_id (copied_task_id), + UNIQUE KEY uniq_user_original_task (user_id, original_task_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + +def downgrade() -> None: + """Remove shared_tasks table.""" + op.execute("DROP TABLE IF EXISTS shared_tasks") diff --git a/backend/app/api/endpoints/adapter/chat.py b/backend/app/api/endpoints/adapter/chat.py index de179e16..40a22e57 100644 --- a/backend/app/api/endpoints/adapter/chat.py +++ b/backend/app/api/endpoints/adapter/chat.py @@ -130,7 +130,7 @@ def _should_use_direct_chat(db: Session, team: Kind, user_id: int) -> bool: return True -def _create_task_and_subtasks( +async def _create_task_and_subtasks( db: Session, user: User, team: Kind, @@ -341,6 +341,46 @@ def _create_task_and_subtasks( db.refresh(task) db.refresh(assistant_subtask) + # Initialize Redis chat history from existing subtasks if needed + # This is crucial for shared tasks that were copied with historical messages + if existing_subtasks: + from app.services.chat.session_manager import session_manager + + # Check if history exists in Redis + redis_history = await session_manager.get_chat_history(task_id) + + # If Redis history is empty but we have subtasks, rebuild history from DB + if not redis_history: + logger.info(f"Initializing chat history from DB for task {task_id} with {len(existing_subtasks)} existing subtasks") + history_messages = [] + + # Sort subtasks by message_id to ensure correct order + sorted_subtasks = sorted(existing_subtasks, key=lambda s: s.message_id) + + for subtask in sorted_subtasks: + # Only include completed subtasks with results + if subtask.status == SubtaskStatus.COMPLETED: + if subtask.role == SubtaskRole.USER: + # User message - use prompt field + if subtask.prompt: + history_messages.append({ + "role": "user", + "content": subtask.prompt + }) + elif subtask.role == SubtaskRole.ASSISTANT: + # Assistant message - use result.value field + if subtask.result and isinstance(subtask.result, dict): + content = subtask.result.get("value", "") + if content: + history_messages.append({ + "role": "assistant", + "content": content + }) + + # Save to Redis if we found any history + if history_messages: + await session_manager.save_chat_history(task_id, history_messages) + logger.info(f"Initialized {len(history_messages)} messages in Redis for task {task_id}") return task, assistant_subtask @@ -434,7 +474,7 @@ async def stream_chat( ) # Create task and subtasks (use original message for storage, final_message for LLM) - task, assistant_subtask = _create_task_and_subtasks( + task, assistant_subtask = await _create_task_and_subtasks( db, current_user, team, request.message, request, request.task_id ) diff --git a/backend/app/api/endpoints/adapter/tasks.py b/backend/app/api/endpoints/adapter/tasks.py index ee0787d1..f3410e29 100644 --- a/backend/app/api/endpoints/adapter/tasks.py +++ b/backend/app/api/endpoints/adapter/tasks.py @@ -14,6 +14,13 @@ from app.core.config import settings from app.models.subtask import Subtask, SubtaskRole, SubtaskStatus from app.models.user import User +from app.schemas.shared_task import ( + JoinSharedTaskRequest, + JoinSharedTaskResponse, + PublicSharedTaskResponse, + TaskShareInfo, + TaskShareResponse, +) from app.schemas.task import ( TaskCreate, TaskDetail, @@ -23,6 +30,7 @@ TaskUpdate, ) from app.services.adapters.task_kinds import task_kinds_service +from app.services.shared_task import shared_task_service router = APIRouter() logger = logging.getLogger(__name__) @@ -282,8 +290,7 @@ async def cancel_task( except Exception as e: logger.error( f"Failed to update Chat Shell task {task_id} status: {str(e)}" - ) - +) return {"message": "Chat stopped successfully", "status": "COMPLETED"} else: # No running subtask found, just mark task as completed @@ -323,3 +330,110 @@ async def cancel_task( background_tasks.add_task(call_executor_cancel, task_id) return {"message": "Cancel request accepted", "status": "CANCELLING"} + + +@router.post("/{task_id}/share", response_model=TaskShareResponse) +def share_task( + task_id: int, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db), +): + """ + Generate a share link for a task. + The share link allows others to view the task history and copy it to their task list. + """ + # Validate that the task belongs to the current user + if not shared_task_service.validate_task_exists( + db=db, task_id=task_id, user_id=current_user.id + ): + raise HTTPException( + status_code=404, detail="Task not found or you don't have permission" + ) + + return shared_task_service.share_task( + db=db, task_id=task_id, user_id=current_user.id + ) + + +@router.get("/share/info", response_model=TaskShareInfo) +def get_task_share_info( + share_token: str = Query(..., description="Share token from URL"), + db: Session = Depends(get_db), +): + """ + Get task share information from share token. + This endpoint doesn't require authentication, so anyone with the link can view. + """ + return shared_task_service.get_share_info(db=db, share_token=share_token) + + +@router.get("/share/public", response_model=PublicSharedTaskResponse) +def get_public_shared_task( + token: str = Query(..., description="Share token from URL"), + db: Session = Depends(get_db), +): + """ + Get public shared task data for read-only viewing. + This endpoint doesn't require authentication - anyone with the link can view. + Only returns public data (no sensitive information like team config, bot details, etc.) + """ + return shared_task_service.get_public_shared_task(db=db, share_token=token) + + +@router.post("/share/join", response_model=JoinSharedTaskResponse) +def join_shared_task( + request: JoinSharedTaskRequest, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db), +): + """ + Copy a shared task to the current user's task list. + This creates a new task with all the subtasks (messages) from the shared task. + """ + from app.models.kind import Kind + + # If team_id is provided, validate it belongs to the user + if request.team_id: + user_team = ( + db.query(Kind) + .filter( + Kind.user_id == current_user.id, + Kind.kind == "Team", + Kind.id == request.team_id, + Kind.is_active == True, + ) + .first() + ) + + if not user_team: + raise HTTPException( + status_code=400, + detail="Invalid team_id or team does not belong to you", + ) + else: + # Get user's first active team if not specified + user_team = ( + db.query(Kind) + .filter( + Kind.user_id == current_user.id, + Kind.kind == "Team", + Kind.is_active == True, + ) + .first() + ) + + if not user_team: + raise HTTPException( + status_code=400, + detail="You need to have at least one team to copy a shared task", + ) + + return shared_task_service.join_shared_task( + db=db, + share_token=request.share_token, + user_id=current_user.id, + team_id=user_team.id, + model_id=request.model_id, + force_override_bot_model=request.force_override_bot_model or False, + ) + diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6c834c80..222a017e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -84,7 +84,7 @@ class Settings(BaseSettings): REDIS_URL: str = "redis://127.0.0.1:6379/0" # Team sharing configuration - TEAM_SHARE_BASE_URL: str = "http://localhost:3000" + TEAM_SHARE_BASE_URL: str = "http://localhost:3000/chat" TEAM_SHARE_QUERY_PARAM: str = "teamShare" # AES encryption configuration for share tokens diff --git a/backend/app/models/shared_task.py b/backend/app/models/shared_task.py new file mode 100644 index 00000000..a1e5602d --- /dev/null +++ b/backend/app/models/shared_task.py @@ -0,0 +1,49 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import relationship +from datetime import datetime +from app.db.base import Base + + +class SharedTask(Base): + """ + Shared Task model - Records task sharing relationships + + When a user shares a task, the task's content (including subtasks/messages) + can be copied to another user's task list. + """ + __tablename__ = "shared_tasks" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + # User who joined/copied the shared task + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + + # Original user who created/shared the task + original_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + + # Original task ID that was shared + original_task_id = Column(Integer, ForeignKey("kinds.id", ondelete="CASCADE"), nullable=False, index=True) + + # New task ID created for the user who joined (copied task) + copied_task_id = Column(Integer, ForeignKey("kinds.id", ondelete="CASCADE"), nullable=True, index=True) + + # Whether this share relationship is active + is_active = Column(Boolean, default=True, nullable=False) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user = relationship("User", foreign_keys=[user_id], back_populates="shared_tasks") + original_user = relationship("User", foreign_keys=[original_user_id]) + original_task = relationship("Kind", foreign_keys=[original_task_id]) + copied_task = relationship("Kind", foreign_keys=[copied_task_id]) + + # Unique constraint: one user can only copy the same original task once + __table_args__ = ( + UniqueConstraint('user_id', 'original_task_id', name='uq_user_original_task'), + ) + + def __repr__(self): + return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 5f7f62e5..6178506f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -25,6 +25,9 @@ class User(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + # Relationships + shared_tasks = relationship("SharedTask", foreign_keys="[SharedTask.user_id]", back_populates="user") + __table_args__ = ( { "sqlite_autoincrement": True, diff --git a/backend/app/schemas/shared_task.py b/backend/app/schemas/shared_task.py new file mode 100644 index 00000000..bdb94d14 --- /dev/null +++ b/backend/app/schemas/shared_task.py @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +from datetime import datetime +from typing import Any, List, Optional + +from pydantic import BaseModel, ConfigDict + + +class TaskShareInfo(BaseModel): + """Task share information decoded from token""" + + user_id: int + user_name: str + task_id: int + task_title: str + + +class TaskShareResponse(BaseModel): + """Response for task share creation""" + + share_url: str + share_token: str + + +class JoinSharedTaskRequest(BaseModel): + """Request body for joining a shared task""" + + share_token: str + team_id: Optional[int] = None # Optional: if not provided, use user's first team + model_id: Optional[str] = None # Model name (not database ID) + force_override_bot_model: Optional[bool] = False + + +class JoinSharedTaskResponse(BaseModel): + """Response for joining a shared task""" + + message: str + task_id: int # The copied task ID for the user + + +class SharedTaskCreate(BaseModel): + """Create shared task relationship""" + + user_id: int + original_user_id: int + original_task_id: int + copied_task_id: Optional[int] = None + is_active: bool = True + + +class SharedTaskInDB(BaseModel): + """Shared task model from database""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + original_user_id: int + original_task_id: int + copied_task_id: Optional[int] = None + is_active: bool + created_at: datetime + updated_at: datetime + + +class PublicAttachmentData(BaseModel): + """Public attachment data for read-only viewing""" + + id: int + original_filename: str + file_extension: str + file_size: int + mime_type: str + extracted_text: str + text_length: int + status: str + + +class PublicSubtaskData(BaseModel): + """Public subtask data for read-only viewing""" + + id: int + role: str + prompt: str + result: Optional[Any] = None + status: str + created_at: datetime + updated_at: datetime + attachments: List[PublicAttachmentData] = [] + + +class PublicSharedTaskResponse(BaseModel): + """Public response for viewing shared task (no authentication required)""" + + task_title: str + sharer_name: str + sharer_id: int + subtasks: List[PublicSubtaskData] + created_at: datetime + diff --git a/backend/app/services/adapters/task_kinds.py b/backend/app/services/adapters/task_kinds.py index b96d5fa2..453e2c37 100644 --- a/backend/app/services/adapters/task_kinds.py +++ b/backend/app/services/adapters/task_kinds.py @@ -95,23 +95,29 @@ def create_task_or_append( status_code=400, detail="task already clear, please create a new task", ) - - # Check if task is expired expire_hours = settings.APPEND_CHAT_TASK_EXPIRE_HOURS + # Check if task is expired task_type = ( task_crd.metadata.labels and task_crd.metadata.labels.get("taskType") or "chat" ) + # Only check expiration for code tasks, chat tasks have no expiration if task_type == "code": expire_hours = settings.APPEND_CODE_TASK_EXPIRE_HOURS - if ( - datetime.now() - existing_task.updated_at - ).total_seconds() > expire_hours * 3600: - raise HTTPException( - status_code=400, - detail=f"{task_type} task has expired. You can only append tasks within {expire_hours} hours after last update.", - ) + + task_shell_source = ( + task_crd.chat_shell.labels + and task_crd.chat_shell.labels.get("source") + or None) + if task_shell_source != "chat_shell": + if ( + datetime.now() - existing_task.updated_at + ).total_seconds() > expire_hours * 3600: + raise HTTPException( + status_code=400, + detail=f"{task_type} task has expired. You can only append tasks within {expire_hours} hours after last update.", + ) # Get team reference information from task_crd and validate if team exists team_name = task_crd.spec.teamRef.name diff --git a/backend/app/services/shared_task.py b/backend/app/services/shared_task.py new file mode 100644 index 00000000..9cbf026f --- /dev/null +++ b/backend/app/services/shared_task.py @@ -0,0 +1,630 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +import base64 +import logging +import urllib.parse +from datetime import datetime +from typing import List, Optional + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.kind import Kind +from app.models.shared_task import SharedTask +from app.models.subtask import Subtask +from app.models.subtask_attachment import SubtaskAttachment +from app.models.user import User +from app.schemas.shared_task import ( + JoinSharedTaskResponse, + PublicSharedTaskResponse, + PublicSubtaskData, + SharedTaskCreate, + SharedTaskInDB, + TaskShareInfo, + TaskShareResponse, +) + +logger = logging.getLogger(__name__) + + +class SharedTaskService: + """Service for managing task sharing functionality""" + + def __init__(self): + # Initialize AES key and IV from settings (reuse team share settings) + self.aes_key = settings.SHARE_TOKEN_AES_KEY.encode("utf-8") + self.aes_iv = settings.SHARE_TOKEN_AES_IV.encode("utf-8") + + def _aes_encrypt(self, data: str) -> str: + """Encrypt data using AES-256-CBC""" + cipher = Cipher( + algorithms.AES(self.aes_key), + modes.CBC(self.aes_iv), + backend=default_backend(), + ) + encryptor = cipher.encryptor() + + # Pad the data to 16-byte boundary (AES block size) + padder = padding.PKCS7(128).padder() + padded_data = padder.update(data.encode("utf-8")) + padder.finalize() + + # Encrypt the data + encrypted_bytes = encryptor.update(padded_data) + encryptor.finalize() + + # Return base64 encoded encrypted data + return base64.b64encode(encrypted_bytes).decode("utf-8") + + def _aes_decrypt(self, encrypted_data: str) -> Optional[str]: + """Decrypt data using AES-256-CBC""" + try: + # Decode base64 encrypted data + encrypted_bytes = base64.b64decode(encrypted_data.encode("utf-8")) + + # Create cipher object + cipher = Cipher( + algorithms.AES(self.aes_key), + modes.CBC(self.aes_iv), + backend=default_backend(), + ) + decryptor = cipher.decryptor() + + # Decrypt the data + decrypted_padded_bytes = ( + decryptor.update(encrypted_bytes) + decryptor.finalize() + ) + + # Unpad the data + unpadder = padding.PKCS7(128).unpadder() + decrypted_bytes = ( + unpadder.update(decrypted_padded_bytes) + unpadder.finalize() + ) + + # Return decrypted string + return decrypted_bytes.decode("utf-8") + except Exception: + return None + + def generate_share_token(self, user_id: int, task_id: int) -> str: + """Generate share token based on user and task information using AES encryption""" + # Format: "user_id#task_id" + share_data = f"{user_id}#{task_id}" + # Use AES encryption + share_token = self._aes_encrypt(share_data) + # URL encode the token before returning it + share_token = urllib.parse.quote(share_token) + return share_token + + def decode_share_token( + self, share_token: str, db: Optional[Session] = None + ) -> Optional[TaskShareInfo]: + """Decode share token to get task information using AES decryption""" + try: + # First URL decode the token, then use AES decryption + decoded_token = urllib.parse.unquote(share_token) + share_data_str = self._aes_decrypt(decoded_token) + if not share_data_str: + logger.info("Invalid share token format: %s", share_token) + return None + + # Parse the "user_id#task_id" format + if "#" not in share_data_str: + return None + + user_id_str, task_id_str = share_data_str.split("#", 1) + try: + user_id = int(user_id_str) + task_id = int(task_id_str) + except ValueError: + return None + + # If database session is provided, query user_name and task_title from database + if db is not None: + # Query user name + user = ( + db.query(User) + .filter(User.id == user_id, User.is_active == True) + .first() + ) + + # Query task + task = ( + db.query(Kind) + .filter( + Kind.id == task_id, + Kind.kind == "Task", + Kind.is_active == True, + ) + .first() + ) + + if not user or not task: + logger.info("User or task not found in the database.") + return None + + return TaskShareInfo( + user_id=user_id, + user_name=user.user_name, + task_id=task_id, + task_title=task.name or "Untitled Task", + ) + else: + # Without database session, return basic info with placeholder names + return TaskShareInfo( + user_id=user_id, + user_name=f"User_{user_id}", + task_id=task_id, + task_title=f"Task_{task_id}", + ) + except Exception: + return None + + def generate_share_url(self, share_token: str) -> str: + """Generate share URL with token""" + # Use /shared/task path for public read-only viewing + base_url = settings.TEAM_SHARE_BASE_URL # Reuse the base URL + return f"{base_url}/shared/task?token={share_token}" + + def validate_task_exists(self, db: Session, task_id: int, user_id: int) -> bool: + """Validate that task exists and belongs to user""" + task = ( + db.query(Kind) + .filter( + Kind.id == task_id, + Kind.user_id == user_id, + Kind.kind == "Task", + Kind.is_active == True, + ) + .first() + ) + + return task is not None + + def share_task(self, db: Session, task_id: int, user_id: int) -> TaskShareResponse: + """Generate task share link""" + + # Get task + task = ( + db.query(Kind) + .filter( + Kind.id == task_id, + Kind.user_id == user_id, + Kind.kind == "Task", + Kind.is_active == True, + ) + .first() + ) + + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + + # Generate share token + share_token = self.generate_share_token( + user_id=user_id, + task_id=task_id, + ) + + # Generate share URL + share_url = self.generate_share_url(share_token) + + return TaskShareResponse(share_url=share_url, share_token=share_token) + + def get_share_info(self, db: Session, share_token: str) -> TaskShareInfo: + """Get task share information from token""" + share_info = self.decode_share_token(share_token, db) + + if not share_info: + raise HTTPException(status_code=400, detail="Invalid share token") + + # Validate task still exists and is active + task = ( + db.query(Kind) + .filter( + Kind.id == share_info.task_id, + Kind.user_id == share_info.user_id, + Kind.kind == "Task", + Kind.is_active == True, + ) + .first() + ) + + if not task: + raise HTTPException( + status_code=404, detail="Task not found or no longer available" + ) + + return share_info + + def _copy_task_with_subtasks( + self, db: Session, original_task: Kind, new_user_id: int, new_team_id: int, + model_id: Optional[str] = None, force_override_bot_model: bool = False + ) -> Kind: + """Copy task and all its subtasks to new user""" + from app.schemas.kind import Task, Team + + # Get the new team to get its name and namespace + new_team = ( + db.query(Kind) + .filter( + Kind.id == new_team_id, + Kind.user_id == new_user_id, + Kind.kind == "Team", + Kind.is_active == True, + ) + .first() + ) + + if not new_team: + raise HTTPException( + status_code=400, + detail=f"Team with id {new_team_id} not found", + ) + + # Parse the original task JSON and update the team reference + task_crd = Task.model_validate(original_task.json) + task_crd.spec.teamRef.name = new_team.name + task_crd.spec.teamRef.namespace = new_team.namespace + + # Update model configuration in metadata labels if provided + if model_id or force_override_bot_model: + if not task_crd.metadata.labels: + task_crd.metadata.labels = {} + if model_id: + task_crd.metadata.labels["modelId"] = model_id + if force_override_bot_model: + task_crd.metadata.labels["forceOverrideBotModel"] = "true" + + # Generate unique task name with timestamp to avoid duplicate key errors + timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S%f") + unique_task_name = f"Copy of {original_task.name}-{timestamp}" + + # Create new task with updated team reference + new_task = Kind( + kind="Task", + name=unique_task_name, + user_id=new_user_id, + namespace=original_task.namespace, + json=task_crd.model_dump(mode="json", exclude_none=True), # Use updated JSON + is_active=True, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + + db.add(new_task) + db.flush() # Get new task ID + + # Get all subtasks from original task (ordered by message_id) + original_subtasks = ( + db.query(Subtask) + .filter( + Subtask.task_id == original_task.id, + Subtask.status != "DELETE", + ) + .order_by(Subtask.message_id) + .all() + ) + + # Copy each subtask + for original_subtask in original_subtasks: + new_subtask = Subtask( + user_id=new_user_id, + task_id=new_task.id, + team_id=new_team_id, + title=original_subtask.title, + bot_ids=original_subtask.bot_ids, + role=original_subtask.role, + executor_namespace=original_subtask.executor_namespace, + executor_name=original_subtask.executor_name, + executor_deleted_at=original_subtask.executor_deleted_at, + prompt=original_subtask.prompt, + message_id=original_subtask.message_id, + parent_id=original_subtask.parent_id, + status="COMPLETED", # Set all copied subtasks to COMPLETED + progress=100, # Mark as fully completed + result=original_subtask.result, + error_message=original_subtask.error_message, + # Remove created_at and updated_at to use database defaults (current timestamp) + completed_at=datetime.utcnow(), + ) + + db.add(new_subtask) + db.flush() # Get new subtask ID + + # Copy attachments if any + original_attachments = ( + db.query(SubtaskAttachment) + .filter(SubtaskAttachment.subtask_id == original_subtask.id) + .all() + ) + + for original_attachment in original_attachments: + new_attachment = SubtaskAttachment( + subtask_id=new_subtask.id, + user_id=new_user_id, + original_filename=original_attachment.original_filename, + file_extension=original_attachment.file_extension, + file_size=original_attachment.file_size, + mime_type=original_attachment.mime_type, + binary_data=original_attachment.binary_data, + image_base64=original_attachment.image_base64, + extracted_text=original_attachment.extracted_text, + text_length=original_attachment.text_length, + status=original_attachment.status, + error_message=original_attachment.error_message, + created_at=datetime.utcnow(), + ) + db.add(new_attachment) + + db.commit() + db.refresh(new_task) + + return new_task + + def join_shared_task( + self, db: Session, share_token: str, user_id: int, team_id: int, + model_id: Optional[str] = None, force_override_bot_model: bool = False + ) -> JoinSharedTaskResponse: + """Join a shared task by copying it to user's task list""" + # Decode share token + share_info = self.decode_share_token(share_token, db) + + if not share_info: + raise HTTPException(status_code=400, detail="Invalid share token") + + # Check if share user is the same as current user + if share_info.user_id == user_id: + raise HTTPException( + status_code=400, detail="Cannot copy your own shared task" + ) + + # Validate original task still exists and is active + original_task = ( + db.query(Kind) + .filter( + Kind.id == share_info.task_id, + Kind.user_id == share_info.user_id, + Kind.kind == "Task", + Kind.is_active == True, + ) + .first() + ) + + if not original_task: + raise HTTPException( + status_code=404, detail="Task not found or no longer available" + ) + + # Check if user already has any share record for this task (active or inactive) + existing_share = ( + db.query(SharedTask) + .filter( + SharedTask.user_id == user_id, + SharedTask.original_task_id == share_info.task_id, + ) + .first() + ) + + # If there's an active share record, check if the copied task still exists + if existing_share and existing_share.is_active: + # Verify that the copied task still exists and is active + copied_task_check = ( + db.query(Kind) + .filter( + Kind.id == existing_share.copied_task_id, + Kind.user_id == user_id, + Kind.kind == "Task", + Kind.is_active == True, + ) + .first() + ) + + # If copied task still exists, cannot copy again + if copied_task_check: + raise HTTPException( + status_code=400, + detail="You have already copied this task", + ) + + # Copy the task and all subtasks to new user + copied_task = self._copy_task_with_subtasks( + db=db, + original_task=original_task, + new_user_id=user_id, + new_team_id=team_id, + model_id=model_id, + force_override_bot_model=force_override_bot_model, + ) + + # Update existing share record or create new one + if existing_share: + # Reuse existing record to avoid unique constraint violation + existing_share.copied_task_id = copied_task.id + existing_share.is_active = True + existing_share.updated_at = datetime.utcnow() + shared_task = existing_share + else: + # Create new share relationship record + shared_task = SharedTask( + user_id=user_id, + original_user_id=share_info.user_id, + original_task_id=share_info.task_id, + copied_task_id=copied_task.id, + is_active=True, + ) + db.add(shared_task) + + db.commit() + db.refresh(shared_task) + + return JoinSharedTaskResponse( + message="Successfully copied shared task to your task list", + task_id=copied_task.id, + ) + + def get_user_shared_tasks( + self, db: Session, user_id: int + ) -> List[SharedTaskInDB]: + """Get all shared tasks for a user""" + shared_tasks = ( + db.query(SharedTask) + .filter(SharedTask.user_id == user_id, SharedTask.is_active == True) + .all() + ) + + return [SharedTaskInDB.model_validate(task) for task in shared_tasks] + + def remove_shared_task( + self, db: Session, user_id: int, original_task_id: int + ) -> bool: + """Remove shared task relationship (soft delete)""" + shared_task = ( + db.query(SharedTask) + .filter( + SharedTask.user_id == user_id, + SharedTask.original_task_id == original_task_id, + SharedTask.is_active == True, + ) + .first() + ) + + if not shared_task: + raise HTTPException( + status_code=404, detail="Shared task relationship not found" + ) + + shared_task.is_active = False + shared_task.updated_at = datetime.utcnow() + db.commit() + + return True + + + def get_public_shared_task( + self, db: Session, share_token: str + ) -> PublicSharedTaskResponse: + """Get public shared task data (no authentication required)""" + # First try to decode the token format (without database check) + try: + decoded_token = urllib.parse.unquote(share_token) + share_data_str = self._aes_decrypt(decoded_token) + + if not share_data_str or "#" not in share_data_str: + raise HTTPException( + status_code=400, + detail="Invalid share link format" + ) + + # Parse user_id and task_id + user_id_str, task_id_str = share_data_str.split("#", 1) + try: + user_id = int(user_id_str) + task_id = int(task_id_str) + except ValueError: + raise HTTPException( + status_code=400, + detail="Invalid share link format" + ) + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=400, + detail="Invalid share link format" + ) + + # Now check if task exists and is active + task = ( + db.query(Kind) + .filter( + Kind.id == task_id, + Kind.user_id == user_id, + Kind.kind == "Task", + Kind.is_active == True, + ) + .first() + ) + + if not task: + raise HTTPException( + status_code=404, + detail="This shared task is no longer available. It may have been deleted by the owner." + ) + + # Get user info for sharer name + user = ( + db.query(User) + .filter(User.id == user_id, User.is_active == True) + .first() + ) + + share_info = TaskShareInfo( + user_id=user_id, + user_name=user.user_name if user else f"User_{user_id}", + task_id=task_id, + task_title=task.name or "Untitled Task", + ) + + # Get all subtasks (only public data, no sensitive information) + subtasks = ( + db.query(Subtask) + .filter( + Subtask.task_id == task.id, + Subtask.status != "DELETE", + ) + .order_by(Subtask.message_id) + .all() + ) + + # Convert to public subtask data (exclude sensitive fields) + public_subtasks = [] + for sub in subtasks: + # Get attachments for this subtask + attachments = ( + db.query(SubtaskAttachment) + .filter(SubtaskAttachment.subtask_id == sub.id) + .all() + ) + + # Convert attachments to public format (exclude binary data and image base64) + public_attachments = [ + { + "id": att.id, + "original_filename": att.original_filename, + "file_extension": att.file_extension, + "file_size": att.file_size, + "mime_type": att.mime_type, + "extracted_text": att.extracted_text or "", + "text_length": att.text_length, + "status": att.status.value if hasattr(att.status, 'value') else str(att.status), + } + for att in attachments + ] + + public_subtasks.append( + PublicSubtaskData( + id=sub.id, + role=sub.role, + prompt=sub.prompt or "", + result=sub.result, + status=sub.status, + created_at=sub.created_at, + updated_at=sub.updated_at, + attachments=public_attachments, + ) + ) + + return PublicSharedTaskResponse( + task_title=task.name or "Untitled Task", + sharer_name=share_info.user_name, + sharer_id=share_info.user_id, + subtasks=public_subtasks, + created_at=task.created_at, + ) + + +shared_task_service = SharedTaskService() diff --git a/docs/TASK_SHARING_FEATURE.md b/docs/TASK_SHARING_FEATURE.md new file mode 100644 index 00000000..924e7c11 --- /dev/null +++ b/docs/TASK_SHARING_FEATURE.md @@ -0,0 +1,567 @@ +# 任务分享功能 (Task Sharing Feature) + +## 功能概述 + +任务分享功能允许用户将自己的任务(包含完整的对话历史)分享给其他用户。其他用户可以: +1. **无需登录查看**:通过分享链接直接查看完整的对话历史(只读) +2. **登录后复制**:登录后可以将任务复制到自己的任务列表中,并继续对话 + +这个功能类似于 ChatGPT 的对话分享功能,让团队协作和知识共享更加便捷。 + +## 核心特性 + +### 两种分享模式 + +#### 🌐 公开只读分享(推荐) +- ✅ **无需登录**即可查看完整对话历史 +- ✅ **只读模式**:不包含敏感信息(团队配置、Bot 详情等) +- ✅ **引导登录**:提供"登录并复制"按钮 +- ✅ **安全可控**:独立 API 端点,只返回必要的公开数据 +- ✅ **用户体验好**:清晰的页面提示和操作引导 + +#### 🔒 登录用户复制 +- 🔑 **需要登录**才能访问 +- ✅ **选择团队**:可以选择将任务复制到哪个团队 +- ✅ **完整历史**:包含所有对话、附件和消息链 +- ✅ **自动打开**:复制后自动打开新任务 +- ✅ **列表刷新**:复制后任务列表自动更新 + +### 其他特性 +- ✅ **生成加密分享链接** - 使用AES-256-CBC加密保护分享令牌 +- ✅ **权限控制** - 只有持有分享链接的人才能访问 +- ✅ **防重复复制** - 同一用户不能重复复制同一个任务 +- ✅ **状态管理** - 所有复制的子任务标记为 COMPLETED 状态 + +## 使用流程 + +### 完整分享流程 + +``` +用户 A 分享任务 + ↓ +生成分享链接(加密 token) + ↓ +用户 B 访问 /shared/task?token=xxx(无需登录) + ↓ +查看完整对话历史(只读) + ↓ +点击"登录并复制"按钮 + ↓ +跳转到登录页面 + ↓ +登录成功后自动跳转到 /chat?taskShare=xxx + ↓ +弹出复制确认弹窗,选择团队 + ↓ +点击"复制到我的任务" + ↓ +复制成功,自动打开新任务 + ↓ +任务列表自动刷新 +``` + +### 1. 分享任务 + +1. 在聊天页面选择一个已有对话的任务 +2. 点击消息区域顶部的 **"Share Task"** 按钮 +3. 系统生成加密分享链接 +4. 点击 **"Copy Link"** 复制链接 + +分享链接格式: +``` +http://localhost:3000/shared/task?token=test123dEA%3D%3D +``` + +### 2. 查看分享(无需登录) + +1. 其他用户打开分享链接 +2. 进入公开只读页面 `/shared/task?token=xxx` +3. 可以查看: + - 任务标题 + - 分享者名称 + - 完整对话历史(用户消息 + AI 回复) + - 所有消息的时间顺序 +4. 页面顶部和底部都有"登录并复制"按钮 +5. 页面提示这是只读分享,需要登录才能复制和继续对话 + +### 3. 登录并复制任务 + +1. 点击"登录并复制"按钮 +2. 跳转到登录页面(token 保存在 localStorage) +3. 登录成功后自动跳转到 `/chat?taskShare=xxx` +4. 弹出任务复制确认弹窗,显示: + - 分享者名称 + - 任务标题 + - 复制说明 + - 团队选择下拉框 +5. 选择要复制到的团队 +6. 点击 **"Copy to My Tasks"** 按钮 +7. 复制成功后: + - 任务列表自动刷新 + - 自动跳转到新任务的聊天页面 + - 可以继续基于历史对话进行交互 + +### 4. 继续对话 + +复制的任务包含: +- 原始任务的所有对话历史 +- 所有附件(如果有) +- 完整的消息链 +- **所有子任务状态设为 COMPLETED**(不会重新执行) + +用户可以像操作普通任务一样继续发送消息,基于已有的对话历史进行追加聊天。 + +## 技术实现 + +### 后端实现 + +#### 数据模型 + +**SharedTask 表结构:** +```sql +CREATE TABLE shared_tasks ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, -- 复制任务的用户 + original_user_id INT NOT NULL, -- 原始分享者 + original_task_id INT NOT NULL, -- 原始任务ID + copied_task_id INT, -- 复制后的新任务ID + is_active BOOLEAN DEFAULT TRUE, + created_at DATETIME, + updated_at DATETIME, + UNIQUE(user_id, original_task_id) -- 同一用户只能复制一次 +); +``` + +#### API接口 + +**1. 生成分享链接** +``` +POST /api/tasks/{task_id}/share +需要认证:是 +Response: { + "share_url": "http://localhost:3000/shared/task?token=test123dEA%3D%3D", + "share_token": "test123dEA%3D%3D" +} +``` + +**2. 获取分享信息(用于复制弹窗)** +``` +GET /api/tasks/share/info?share_token= +需要认证:否 +Response: { + "user_id": 1, + "user_name": "Alice", + "task_id": 123, + "task_title": "My Task" +} +``` + +**3. 获取公开分享任务(用于只读页面)** +``` +GET /api/tasks/share/public?token= +需要认证:否(公开访问) +Response: { + "task_title": "My Task", + "sharer_name": "Alice", + "sharer_id": 1, + "subtasks": [ + { + "id": 1, + "role": "USER", + "prompt": "Hello, can you help me?", + "result": null, + "status": "COMPLETED", + "created_at": "2025-12-04T10:00:00Z", + "updated_at": "2025-12-04T10:00:01Z" + }, + { + "id": 2, + "role": "AI", + "prompt": "", + "result": {"value": "Of course! How can I assist you?"}, + "status": "COMPLETED", + "created_at": "2025-12-04T10:00:02Z", + "updated_at": "2025-12-04T10:00:05Z" + } + ], + "created_at": "2025-12-04T10:00:00Z" +} + +注意:此接口只返回公开数据,不包含: +- 团队配置信息 +- Bot 详情 +- 敏感的系统配置 +- 用户个人信息(除了分享者名称) +``` + +**4. 复制分享任务** +``` +POST /api/tasks/share/join +需要认证:是 +Body: { + "share_token": "", + "team_id": 5 // 可选,不提供则使用用户第一个团队 +} +Response: { + "message": "Successfully copied shared task to your task list", + "task_id": 456 // 新任务ID +} +``` + +#### 加密机制 + +- **算法**: AES-256-CBC +- **令牌格式**: `{user_id}#{task_id}` → AES加密 → Base64编码 → URL编码 +- **密钥管理**: 通过环境变量配置 `SHARE_TOKEN_AES_KEY` 和 `SHARE_TOKEN_AES_IV` +- **解密验证**: + 1. URL解码 + 2. Base64解码 + 3. AES解密 + 4. 验证格式和数据库存在性 + +#### 核心服务方法 + +**SharedTaskService (`backend/app/services/shared_task.py`):** + +1. `generate_share_token(user_id, task_id)` - 生成加密分享令牌 +2. `decode_share_token(share_token, db)` - 解密令牌获取任务信息 +3. `generate_share_url(share_token)` - 生成完整分享URL(指向 `/shared/task` 页面) +4. `share_task(db, task_id, user_id)` - 创建任务分享 +5. `get_share_info(db, share_token)` - 获取分享基本信息(用于复制确认弹窗) +6. `get_public_shared_task(db, share_token)` - 获取公开任务数据(用于只读页面,无需认证) +7. `join_shared_task(db, share_token, user_id, team_id)` - 复制任务到用户账户 +8. `_copy_task_with_subtasks(db, original_task, new_user_id, new_team_id)` - 复制任务及所有子任务和附件 + +**任务复制逻辑:** +- 创建新的 Kind 记录(Task 类型),名称前缀 "Copy of" +- 复制所有 Subtask 记录,保持 message_id 和 parent_id 关系 +- **所有子任务状态设为 COMPLETED,progress = 100**(避免重新执行) +- 复制所有 SubtaskAttachment(包括二进制数据和图片) +- 创建 SharedTask 记录,建立复制关系 +- 防止重复复制(UNIQUE 约束) + +### 前端实现 + +#### 页面路由 + +**1. 公开只读分享页面(无需登录)** +- 路由:`/shared/task` +- 文件:`frontend/src/app/shared/task/page.tsx` +- 功能: + - 展示完整对话历史 + - 显示分享者信息和任务标题 + - 使用与聊天页面相同的布局结构(包含侧边栏) + - 简化侧边栏显示当前分享任务和登录引导 + - 提供"登录并复制"按钮(顶部导航栏、侧边栏、底部CTA) + - 点击按钮后保存 token 到 localStorage 并跳转登录 + - 登录后自动跳转到 `/chat?taskShare=xxx` + +**2. 登录用户复制页面** +- 路由:`/chat?taskShare=xxx` +- 组件:`TaskShareHandler`(在 `/chat` 页面中使用) +- 功能: + - 检测 URL 参数 `taskShare` + - 获取团队列表 + - 显示复制确认弹窗 + - 选择团队并执行复制 + - 复制成功后刷新任务列表并打开新任务 + +#### 核心组件 + +**1. SharedTaskPage (`frontend/src/app/shared/task/page.tsx`)** +```typescript +功能: +- 无需登录的公开分享页面 +- 调用 taskApis.getPublicSharedTask(token) 获取数据 +- 使用与聊天页面相同的布局结构(ResizableSidebar + 主内容区) +- 渲染对话历史(用户消息 + AI 回复) +- 使用 MarkdownEditor 渲染 AI 回复 +- 提供"登录并复制"CTA 按钮(多个位置) +- 点击按钮保存 token 到 localStorage,跳转到 /login?redirect=/chat +``` + +**2. PublicTaskSidebar (`frontend/src/features/tasks/components/PublicTaskSidebar.tsx`)** +```typescript +功能: +- 简化侧边栏专为公开分享页面设计 +- 显示 Wegent Logo +- 显示当前分享任务(标题和分享者) +- 提供"Login to see your tasks"按钮 +- 显示只读视图提示信息框 +- 底部提供"Login & Copy Task"按钮 +- 所有按钮都调用同一个 onLoginClick 处理函数 +``` + +**3. TaskShareHandler (`frontend/src/features/tasks/components/TaskShareHandler.tsx`)** +```typescript +功能: +- 检测 URL 参数 taskShare +- 并行获取分享信息和团队列表 +- 显示复制确认弹窗(Modal) +- 团队选择下拉框 +- 自我分享检测(不能复制自己的任务) +- 调用 taskApis.joinSharedTask() 执行复制 +- 复制成功后: + 1. 调用 onTaskCopied() 刷新任务列表 + 2. 导航到 /chat?taskId={newTaskId} + 3. 清理 URL 参数 +``` + +**4. 登录后自动跳转 (`frontend/src/app/(tasks)/chat/page.tsx`)** +```typescript +功能: +- 检查 localStorage 中的 pendingTaskShare +- 如果存在,清除并跳转到 /chat?taskShare={token} +- 这样登录后会自动触发 TaskShareHandler +``` + +**5. MessagesArea 中的分享按钮** +```typescript +功能: +- 在有消息的情况下显示 "Share Task" 按钮 +- 点击调用 taskApis.shareTask(taskId) +- 显示 TaskShareModal 展示分享链接 +- 提供一键复制链接功能 +``` + +#### API 客户端 + +**taskApis (`frontend/src/apis/tasks.ts`):** + +```typescript +shareTask(taskId: number): Promise +getTaskShareInfo(shareToken: string): Promise +getPublicSharedTask(token: string): Promise +joinSharedTask(request: JoinSharedTaskRequest): Promise +``` + +### 状态管理 + +**useTaskContext 集成:** +- `TaskShareHandler` 接收 `onTaskCopied` 回调 +- 复制成功后调用 `refreshTasks()` 更新任务列表 +- 新任务立即出现在侧边栏 + +**URL 参数处理:** +- `taskShare` - 用于触发复制确认弹窗 +- `token` - 用于公开只读页面 +- `taskId` - 用于打开特定任务 + +## 安全考虑 + +### 已实施的安全措施 + +1. **加密令牌** + - 使用 AES-256-CBC 加密 + - URL 编码避免特殊字符问题 + - 令牌无法直接解析出 user_id 和 task_id + +2. **访问控制** + - 公开只读页面:只返回必要的公开数据 + - 复制操作:需要登录认证 + - 团队验证:只能复制到自己的团队 + +3. **防重复机制** + - 数据库 UNIQUE 约束:`(user_id, original_task_id)` + - 同一用户不能重复复制同一任务 + - 前端和后端双重检查 + +4. **数据验证** + - 验证任务存在性和激活状态 + - 验证原始用户存在性 + - 验证分享者不是自己 + +5. **无敏感信息泄露** + - 公开 API 不返回团队配置 + - 不返回 Bot 详细信息 + - 不返回其他用户信息 + +### 潜在风险和改进建议 + +1. **链接有效期** + - 当前:永久有效 + - 建议:添加过期时间或访问次数限制 + +2. **访问日志** + - 当前:无访问记录 + - 建议:记录分享链接的访问情况 + +3. **Rate Limiting** + - 当前:无限制 + - 建议:添加访问频率限制,防止滥用 + +4. **撤销分享** + - 当前:无法撤销已分享的链接 + - 建议:添加撤销分享功能 + +## 用户界面 + +### 分享任务界面 + +![Share Task Button](share-task-button.png) +- 位置:聊天消息区域顶部 +- 样式:带 Share2 图标的按钮 +- 显示条件:有消息时显示 + +### 公开只读分享页面 + +``` +┌──────────────┬──────────────────────────────────────────┐ +│ [Logo] │ [Share Icon] Task Title [Login & Copy]│ +│ Wegent │ Shared by Alice │ +│ ├──────────────────────────────────────────┤ +│ [Login...] │ ℹ️ This is a read-only shared... │ +│ ├──────────────────────────────────────────┤ +│ Shared Task │ │ +│ ┌─────────┐ │ [User Message] │ +│ │ 📝 Task │ │ "Hello, can you help me?" │ +│ │ Title │ │ │ +│ │ by Alice│ │ [AI Response] │ +│ └─────────┘ │ "Of course! How can I assist you?" │ +│ │ │ +│ ℹ️ Read-only│ ...more messages... │ +│ view │ │ +│ Login to... │ │ +│ ├──────────────────────────────────────────┤ +│ │ Want to continue? [Login & Copy Tasks] │ +│ [Login & │ │ +│ Copy Task] │ │ +└──────────────┴──────────────────────────────────────────┘ +``` + +功能说明: +- **左侧边栏**:显示Logo、当前分享任务、只读提示、登录按钮 +- **顶部导航栏**:显示任务标题、分享者、GitHub Star 按钮、登录按钮 +- **主内容区**:只读提示、完整对话历史、底部CTA +- **侧边栏可调整大小**:使用 ResizableSidebar 组件,与聊天页面一致 + +### 复制确认弹窗 + +``` +┌─────────────────────────────────────────────────────┐ +│ Shared Task [X] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ Alice shared the task "My Task" with you │ +│ │ +│ ℹ️ Copying this task will add all conversation │ +│ history to your task list. │ +│ │ +│ Select Team: │ +│ [My Team ▼] │ +│ │ +│ [Cancel] [Copy to My Tasks] │ +└─────────────────────────────────────────────────────┘ +``` + +### 分享链接弹窗 + +``` +┌─────────────────────────────────────────────────────┐ +│ Share Task [X] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ Share this link: │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ https://app.com/shared/task?token=xxx │ │ +│ └───────────────────────────────────────────────┘ │ +│ [Copy Link] [Copied!] │ +│ │ +│ Anyone with this link can view your conversation │ +│ history. They can login to copy and continue. │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +## 测试场景 + +### 功能测试 + +1. **生成分享链接** + - ✅ 点击 Share Task 按钮 + - ✅ 显示分享链接弹窗 + - ✅ 复制链接到剪贴板 + - ✅ 链接格式正确 + +2. **公开查看(未登录)** + - ✅ 访问分享链接 + - ✅ 显示完整对话历史 + - ✅ 显示分享者信息 + - ✅ 不包含敏感信息 + - ✅ 显示"登录并复制"按钮 + +3. **登录流程** + - ✅ 点击"登录并复制" + - ✅ token 保存到 localStorage + - ✅ 跳转到登录页面 + - ✅ 登录成功后自动跳转到 `/chat?taskShare=xxx` + - ✅ localStorage 中的 token 被清除 + +4. **复制任务** + - ✅ 显示复制确认弹窗 + - ✅ 团队列表正确加载 + - ✅ 自动选择第一个团队 + - ✅ 可以切换团队 + - ✅ 点击复制按钮执行复制 + - ✅ 复制成功提示 + - ✅ 任务列表自动刷新 + - ✅ 自动打开新任务 + +5. **复制的任务验证** + - ✅ 包含所有历史消息 + - ✅ 包含所有附件 + - ✅ 消息顺序正确 + - ✅ 任务标题前缀 "Copy of" + - ✅ 所有子任务状态为 COMPLETED + +6. **边界情况** + - ✅ 无效 token 显示错误 + - ✅ 已删除的任务显示错误 + - ✅ 自己分享的任务提示不能复制 + - ✅ 重复复制同一任务被阻止 + - ✅ 无团队时不能复制 + +### 安全测试 + +1. **令牌安全** + - ✅ 令牌加密正确 + - ✅ 无法从令牌反推原始信息 + - ✅ 修改令牌导致解密失败 + +2. **权限控制** + - ✅ 公开页面无需认证 + - ✅ 复制操作需要认证 + - ✅ 不能复制到别人的团队 + +3. **数据隔离** + - ✅ 公开 API 不返回敏感信息 + - ✅ 不同用户的数据隔离 + - ✅ 任务关联正确 + +## 常见问题 + +**Q: 分享链接会过期吗?** +A: 当前版本的分享链接不会过期,只要原始任务和用户存在,链接就一直有效。 + +**Q: 可以分享正在运行中的任务吗?** +A: 可以。但建议等任务完成后再分享,这样接收者能看到完整的对话历史。 + +**Q: 复制的任务会重新执行吗?** +A: 不会。所有复制的子任务状态都设为 COMPLETED,不会重新执行。用户只能基于历史继续新的对话。 + +**Q: 可以撤销分享吗?** +A: 当前版本不支持撤销已分享的链接。建议在分享前确认内容。 + +**Q: 分享链接能被搜索引擎索引吗?** +A: 不会。分享页面没有 SEO 优化,且需要加密 token 才能访问。 + +**Q: 为什么要两个页面?** +A: +- `/shared/task` 提供无需登录的公开访问,用户体验更好 +- `/chat?taskShare=xxx` 用于已登录用户直接复制,功能更完整 +- 这种设计既方便分享查看,又保证了数据安全 + +--- + +**实现完成时间**: 2025-12-04 +**参考**: 类似ChatGPT的分享功能,增加了公开只读页面和登录引导流程 diff --git a/executor_manager/pyproject.toml b/executor_manager/pyproject.toml index aa40d221..297ef1a2 100644 --- a/executor_manager/pyproject.toml +++ b/executor_manager/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "kubernetes==29.0.0", "pydantic==2.11.2", "pyjwt==2.8.0", + "httpx>=0.27.0", "pytz==2024.1", "pyyaml==6.0.1", "requests==2.31.0", diff --git a/executor_manager/uv.lock b/executor_manager/uv.lock index 6646ebd9..9a932885 100644 --- a/executor_manager/uv.lock +++ b/executor_manager/uv.lock @@ -301,6 +301,7 @@ dependencies = [ { name = "apscheduler" }, { name = "cryptography" }, { name = "fastapi" }, + { name = "httpx" }, { name = "jinja2" }, { name = "kubernetes" }, { name = "pydantic" }, @@ -319,6 +320,7 @@ requires-dist = [ { name = "apscheduler", specifier = "==3.10.4" }, { name = "cryptography", specifier = ">=42.0.4" }, { name = "fastapi", specifier = "==0.121.2" }, + { name = "httpx", specifier = ">=0.27.0" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "kubernetes", specifier = "==29.0.0" }, { name = "pydantic", specifier = "==2.11.2" }, @@ -369,6 +371,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" diff --git a/frontend/e2e/tests/settings-github.spec.ts b/frontend/e2e/tests/settings-github.spec.ts index aee76528..b33cdf52 100644 --- a/frontend/e2e/tests/settings-github.spec.ts +++ b/frontend/e2e/tests/settings-github.spec.ts @@ -1,66 +1,71 @@ -import { test, expect } from '../fixtures/test-fixtures' +import { test, expect } from '../fixtures/test-fixtures'; test.describe('Settings - Git Integration', () => { test.beforeEach(async ({ page }) => { // Git integration is on the integrations tab (index 2) - await page.goto('/settings?tab=integrations') - await page.waitForLoadState('networkidle') - }) + await page.goto('/settings?tab=integrations'); + await page.waitForLoadState('networkidle'); + }); test('should access integrations page', async ({ page }) => { // Verify we're on settings page with integrations tab - await expect(page).toHaveURL(/\/settings/) + await expect(page).toHaveURL(/\/settings/); // Wait for integrations content to load - title "Integrations" should be visible - await expect( - page.locator('h2:has-text("Integrations")') - ).toBeVisible({ timeout: 10000 }) - }) + await expect(page.locator('h2:has-text("Integrations")')).toBeVisible({ timeout: 10000 }); + }); test('should display Git integration section', async ({ page }) => { // Look for Git integration section title "Integrations" - await expect( - page.locator('h2:has-text("Integrations")') - ).toBeVisible({ timeout: 10000 }) - }) + await expect(page.locator('h2:has-text("Integrations")')).toBeVisible({ timeout: 10000 }); + }); test('should display token list or empty state', async ({ page }) => { // Wait for page to be fully loaded - await page.waitForLoadState('networkidle') - await page.waitForTimeout(1000) + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); // Look for token list or empty state message "No git tokens configured" // One of these should be visible after loading - const hasTokens = await page.locator('button[title*="Edit"]').isVisible({ timeout: 5000 }).catch(() => false) - const hasEmptyState = await page.locator('text=No git tokens configured').isVisible({ timeout: 1000 }).catch(() => false) + const hasTokens = await page + .locator('button[title*="Edit"]') + .isVisible({ timeout: 5000 }) + .catch(() => false); + const hasEmptyState = await page + .locator('text=No git tokens configured') + .isVisible({ timeout: 1000 }) + .catch(() => false); // Also check for loading state that might still be present - const hasLoadingOrContent = await page.locator('[data-testid="git-tokens"], .git-token-list, h2:has-text("Integrations")').isVisible({ timeout: 1000 }).catch(() => false) + const hasLoadingOrContent = await page + .locator('[data-testid="git-tokens"], .git-token-list, h2:has-text("Integrations")') + .isVisible({ timeout: 1000 }) + .catch(() => false); // Either tokens exist (edit button visible), empty state is shown, or page is still in valid state - expect(hasTokens || hasEmptyState || hasLoadingOrContent).toBeTruthy() - }) + expect(hasTokens || hasEmptyState || hasLoadingOrContent).toBeTruthy(); + }); test('should open add token dialog', async ({ page }) => { // Wait for integrations page to load - await expect(page.locator('h2:has-text("Integrations")')).toBeVisible({ timeout: 10000 }) + await expect(page.locator('h2:has-text("Integrations")')).toBeVisible({ timeout: 10000 }); // "New Token" button should always be visible after page loads - const addTokenButton = page.locator( - 'button:has-text("New Token"), button:has-text("新建")' - ) + const addTokenButton = page.locator('button:has-text("New Token"), button:has-text("新建")'); // Button should be visible - no skip, this is a required UI element - await expect(addTokenButton).toBeVisible({ timeout: 10000 }) + await expect(addTokenButton).toBeVisible({ timeout: 10000 }); - await addTokenButton.click() + await addTokenButton.click(); // Wait for dialog to open and become visible // headlessui Dialog may render hidden initially, wait for content - await page.waitForTimeout(500) + await page.waitForTimeout(500); // Dialog should have data-headlessui-state="open" when visible // Check for the dialog panel content (Dialog.Title has text-xl class) - const dialogContent = page.locator('[role="dialog"] .text-xl, [role="dialog"] [class*="DialogTitle"]') - await expect(dialogContent.first()).toBeVisible({ timeout: 10000 }) - }) -}) + const dialogContent = page.locator( + '[role="dialog"] .text-xl, [role="dialog"] [class*="DialogTitle"]' + ); + await expect(dialogContent.first()).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/frontend/src/apis/tasks.ts b/frontend/src/apis/tasks.ts index 1a4949cb..b4db01c4 100644 --- a/frontend/src/apis/tasks.ts +++ b/frontend/src/apis/tasks.ts @@ -82,6 +82,61 @@ export interface BranchDiffResponse { permalink_url: string; } +// Task Share Types +export interface TaskShareResponse { + share_url: string; + share_token: string; +} + +export interface TaskShareInfo { + user_id: number; + user_name: string; + task_id: number; + task_title: string; +} + +export interface JoinSharedTaskRequest { + share_token: string; + team_id?: number; // Optional: if not provided, backend will use user's first team + model_id?: string; // Model name (not database ID) + force_override_bot_model?: boolean; // Force override bot's predefined model +} + +export interface JoinSharedTaskResponse { + message: string; + task_id: number; // The copied task ID +} + +export interface PublicAttachmentData { + id: number; + original_filename: string; + file_extension: string; + file_size: number; + mime_type: string; + extracted_text: string; + text_length: number; + status: string; +} + +export interface PublicSubtaskData { + id: number; + role: string; + prompt: string; + result?: unknown; + status: string; + created_at: string; + updated_at: string; + attachments: PublicAttachmentData[]; +} + +export interface PublicSharedTaskResponse { + task_title: string; + sharer_name: string; + sharer_id: number; + subtasks: PublicSubtaskData[]; + created_at: string; +} + // Task Services export const taskApis = { @@ -169,4 +224,50 @@ export const taskApis = { query.append('git_domain', params.git_domain); return apiClient.get(`/git/repositories/diff?${query}`); }, + + // Share task - generate share link + shareTask: async (taskId: number): Promise => { + return apiClient.post(`/tasks/${taskId}/share`, {}); + }, + + // Get task share info - doesn't require authentication + getTaskShareInfo: async (shareToken: string): Promise => { + const query = new URLSearchParams(); + query.append('share_token', shareToken); + return apiClient.get(`/tasks/share/info?${query}`); + }, + + // Join shared task - copy task to user's task list + joinSharedTask: async (request: JoinSharedTaskRequest): Promise => { + return apiClient.post('/tasks/share/join', request); + }, + + // Get public shared task - doesn't require authentication + // Use native fetch to avoid authentication interceptor + getPublicSharedTask: async (token: string): Promise => { + const query = new URLSearchParams(); + query.append('token', token); + const response = await fetch(`/api/tasks/share/public?${query}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMsg = errorText; + try { + const json = JSON.parse(errorText); + if (json && typeof json.detail === 'string') { + errorMsg = json.detail; + } + } catch { + // Not JSON, use original text + } + throw new Error(errorMsg); + } + + return response.json(); + }, }; diff --git a/frontend/src/app/(tasks)/chat/page.tsx b/frontend/src/app/(tasks)/chat/page.tsx index f0ac08ee..fe6e01ca 100644 --- a/frontend/src/app/(tasks)/chat/page.tsx +++ b/frontend/src/app/(tasks)/chat/page.tsx @@ -5,7 +5,7 @@ 'use client'; import { Suspense, useState, useEffect } from 'react'; -import { useSearchParams } from 'next/navigation'; +import { useSearchParams, useRouter } from 'next/navigation'; import { teamService } from '@/features/tasks/service/teamService'; import TopNavigation from '@/features/layout/TopNavigation'; import UserMenu from '@/features/layout/UserMenu'; @@ -14,6 +14,7 @@ import ResizableSidebar from '@/features/tasks/components/ResizableSidebar'; import OnboardingTour from '@/features/onboarding/OnboardingTour'; import TaskParamSync from '@/features/tasks/components/TaskParamSync'; import TeamShareHandler from '@/features/tasks/components/TeamShareHandler'; +import TaskShareHandler from '@/features/tasks/components/TaskShareHandler'; import OidcTokenHandler from '@/features/login/components/OidcTokenHandler'; import '@/app/tasks/tasks.css'; import '@/features/common/scrollbar.css'; @@ -22,18 +23,36 @@ import { Team } from '@/types/api'; import ChatArea from '@/features/tasks/components/ChatArea'; import { saveLastTab } from '@/utils/userPreferences'; import { useUser } from '@/features/common/UserContext'; +import { useTaskContext } from '@/features/tasks/contexts/taskContext'; export default function ChatPage() { // Team state from service const { teams, isTeamsLoading, refreshTeams } = teamService.useTeams(); + // Task context for refreshing task list + const { refreshTasks } = useTaskContext(); + // User state for git token check const { user } = useUser(); + // Router for navigation + const router = useRouter(); + // Check for share_id in URL const searchParams = useSearchParams(); const hasShareId = !!searchParams.get('share_id'); + // Check for pending task share from public page (after login) + useEffect(() => { + const pendingToken = localStorage.getItem('pendingTaskShare'); + if (pendingToken) { + // Clear the pending token + localStorage.removeItem('pendingTaskShare'); + // Redirect to chat page with taskShare parameter to trigger the copy modal + router.push(`/chat?taskShare=${pendingToken}`); + } + }, [router]); + // Mobile sidebar state const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); @@ -43,6 +62,13 @@ export default function ChatPage() { // Selected team state for sharing const [selectedTeamForNewTask, setSelectedTeamForNewTask] = useState(null); + // Share button state + const [shareButton, setShareButton] = useState(null); + + const handleShareButtonRender = (button: React.ReactNode) => { + setShareButton(button); + }; + // Check if user has git token const hasGitToken = !!(user?.git_info && user.git_info.length > 0); @@ -85,6 +111,9 @@ export default function ChatPage() { onRefreshTeams={handleRefreshTeams} /> + + + {/* Onboarding tour */} 0} @@ -112,6 +141,7 @@ export default function ChatPage() { variant="with-sidebar" onMobileSidebarToggle={() => setIsMobileSidebarOpen(true)} > + {shareButton} @@ -122,6 +152,7 @@ export default function ChatPage() { selectedTeamForNewTask={selectedTeamForNewTask} showRepositorySelector={false} taskType="chat" + onShareButtonRender={handleShareButtonRender} /> diff --git a/frontend/src/app/(tasks)/code/page.tsx b/frontend/src/app/(tasks)/code/page.tsx index a8f0960b..3e436708 100644 --- a/frontend/src/app/(tasks)/code/page.tsx +++ b/frontend/src/app/(tasks)/code/page.tsx @@ -54,6 +54,13 @@ export default function CodePage() { // Selected team state for sharing const [selectedTeamForNewTask, setSelectedTeamForNewTask] = useState(null); + // Share button state + const [shareButton, setShareButton] = useState(null); + + const handleShareButtonRender = (button: React.ReactNode) => { + setShareButton(button); + }; + // Workbench state - default to true when taskId exists const [isWorkbenchOpen, setIsWorkbenchOpen] = useState(true); @@ -171,6 +178,7 @@ export default function CodePage() { variant="with-sidebar" onMobileSidebarToggle={() => setIsMobileSidebarOpen(true)} > + {shareButton} {hasTaskId && } @@ -197,6 +205,7 @@ export default function CodePage() { isTeamsLoading={isTeamsLoading} selectedTeamForNewTask={selectedTeamForNewTask} taskType="code" + onShareButtonRender={handleShareButtonRender} /> diff --git a/frontend/src/app/shared/task/page.tsx b/frontend/src/app/shared/task/page.tsx new file mode 100644 index 00000000..8e8cefea --- /dev/null +++ b/frontend/src/app/shared/task/page.tsx @@ -0,0 +1,358 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React, { useEffect, useState, Suspense } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { taskApis, PublicSharedTaskResponse } from '@/apis/tasks'; +import { getToken, userApis } from '@/apis/user'; +import { LogIn } from 'lucide-react'; +import { useTheme } from '@/features/theme/ThemeProvider'; +import TopNavigation from '@/features/layout/TopNavigation'; +import { GithubStarButton } from '@/features/layout/GithubStarButton'; +import MessageBubble, { type Message } from '@/features/tasks/components/MessageBubble'; +import { useTranslation } from '@/hooks/useTranslation'; +import type { User } from '@/types/api'; +import '@/features/common/scrollbar.css'; + +/** + * Public shared task page - no authentication required + * Uses the same layout and styling as the chat page for consistency + */ +function SharedTaskContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { theme } = useTheme(); + const { t } = useTranslation('common'); + + const [taskData, setTaskData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [currentUser, setCurrentUser] = useState(null); + + // Check if user is logged in + const isLoggedIn = !!getToken(); + + // Fetch current user if logged in + useEffect(() => { + const fetchUser = async () => { + if (isLoggedIn) { + try { + const user = await userApis.getCurrentUser(); + setCurrentUser(user); + } catch (err) { + console.error('Failed to fetch user:', err); + } + } + }; + fetchUser(); + }, [isLoggedIn]); + + useEffect(() => { + const token = searchParams.get('token'); + + if (!token) { + setError(t('shared_task.error_invalid_link')); + setIsLoading(false); + return; + } + + const fetchSharedTask = async () => { + try { + const data = await taskApis.getPublicSharedTask(token); + setTaskData(data); + } catch (err) { + console.error('Failed to load shared task:', err); + const errorMessage = (err as Error)?.message || ''; + + // Map error messages to i18n keys + if ( + errorMessage.includes('Invalid share link format') || + errorMessage.includes('Invalid share token') + ) { + setError(t('shared_task.error_invalid_link')); + } else if ( + errorMessage.includes('no longer available') || + errorMessage.includes('deleted') + ) { + setError(t('shared_task.error_task_deleted')); + } else { + setError(t('shared_task.error_load_failed')); + } + } finally { + setIsLoading(false); + } + }; + + fetchSharedTask(); + }, [searchParams, t]); + + const handleLoginAndCopy = () => { + const token = searchParams.get('token'); + if (!token) return; + + // Check if user is already logged in + const authToken = getToken(); + + if (authToken) { + // User is logged in, directly navigate to chat with taskShare parameter + // Use encodeURIComponent to ensure proper URL encoding + router.push(`/chat?taskShare=${encodeURIComponent(token)}`); + } else { + // User is not logged in, redirect to login with the full chat URL as redirect target + // This way, after login, the user will be redirected directly to /chat?taskShare=xxx + const redirectTarget = `/chat?taskShare=${encodeURIComponent(token)}`; + router.push(`/login?redirect=${encodeURIComponent(redirectTarget)}`); + } + }; + + // Helper function to convert subtask to Message format for MessageBubble + const convertSubtaskToMessage = (subtask: PublicSharedTaskResponse['subtasks'][0]): Message => { + const isUser = subtask.role === 'USER'; + + // Convert public attachments to Message attachment format + const attachments = + subtask.attachments?.map(att => ({ + id: att.id, + filename: att.original_filename, + file_size: att.file_size, + mime_type: att.mime_type, + status: att.status as 'uploading' | 'parsing' | 'ready' | 'failed', + text_length: att.text_length, + error_message: null, + subtask_id: subtask.id, + file_extension: att.file_extension, + created_at: subtask.created_at, + })) || []; + + // For user messages, use prompt + if (isUser) { + return { + type: 'user', + content: subtask.prompt || '', + timestamp: new Date(subtask.created_at).getTime(), + subtaskStatus: subtask.status, + subtaskId: subtask.id, + attachments, + }; + } + + // For AI messages, extract result value + let resultContent = ''; + if (subtask.result) { + if (typeof subtask.result === 'object') { + const resultObj = subtask.result as { value?: unknown; thinking?: unknown }; + if (resultObj.value !== null && resultObj.value !== undefined && resultObj.value !== '') { + resultContent = String(resultObj.value); + } else { + resultContent = JSON.stringify(subtask.result); + } + } else { + resultContent = String(subtask.result); + } + } else if (subtask.status === 'COMPLETED') { + resultContent = 'Task completed'; + } else if (subtask.status === 'FAILED') { + resultContent = 'Task failed'; + } else { + resultContent = 'Processing...'; + } + + // Add ${$$}$ separator to trigger markdown rendering in MessageBubble + // Format: prompt${$$}$result (empty prompt for shared tasks) + const content = '$' + '{$$}$' + resultContent; + + return { + type: 'ai', + content, + timestamp: new Date(subtask.created_at).getTime(), + botName: 'AI Assistant', + subtaskStatus: subtask.status, + subtaskId: subtask.id, + attachments, + }; + }; + + if (isLoading) { + return ( +
+ + + +
+
+
+

Loading shared conversation...

+
+
+
+ ); + } + + if (error || !taskData) { + // Determine error title and description based on error type + let errorTitle = t('shared_task.error_load_failed'); + let errorDesc = t('shared_task.error_load_failed_desc'); + let errorIcon = '⚠️'; + + if (error) { + if (error.includes(t('shared_task.error_invalid_link'))) { + errorTitle = t('shared_task.error_invalid_link'); + errorDesc = t('shared_task.error_invalid_link_desc'); + errorIcon = '🔗'; + } else if (error.includes(t('shared_task.error_task_deleted'))) { + errorTitle = t('shared_task.error_task_deleted'); + errorDesc = t('shared_task.error_task_deleted_desc'); + errorIcon = '🗑️'; + } + } + + return ( +
+ + + +
+
+ {/* Error Icon */} +
+
+ {errorIcon} +
+
+ + {/* Error Title */} +

+ {errorTitle} +

+ + {/* Error Description */} +

{errorDesc}

+ + {/* Action Button */} +
+ +
+
+
+
+ ); + } + + return ( +
+ {/* Top navigation */} + + + {isLoggedIn && currentUser ? ( +
+ {currentUser.user_name} +
+ ) : ( + + )} +
+ + {/* Main content area */} +
+
+ {/* Task title and sharer info */} +
+

{taskData.task_title}

+

+ {t('shared_task.shared_by')}{' '} + {taskData.sharer_name} +

+
+ + {/* Read-only notice */} + + + 📖 {t('shared_task.read_only_notice')} + + + + {/* Messages area - using MessageBubble component for consistency */} +
+ {taskData.subtasks.map((subtask, index) => { + const message = convertSubtaskToMessage(subtask); + return ( + + ); + })} +
+ + {/* Bottom CTA */} +
+
+
+

+ {isLoggedIn + ? t('shared_task.want_to_continue') + : t('shared_task.login_to_continue_desc')} +

+

{t('shared_task.copy_and_chat')}

+
+ +
+
+
+
+
+ ); +} + +export default function SharedTaskPage() { + return ( + +
+
+
+

Loading...

+
+
+ + } + > + +
+ ); +} diff --git a/frontend/src/features/common/AuthGuard.tsx b/frontend/src/features/common/AuthGuard.tsx index d6ee31b8..af2daa6c 100644 --- a/frontend/src/features/common/AuthGuard.tsx +++ b/frontend/src/features/common/AuthGuard.tsx @@ -29,6 +29,7 @@ export default function AuthGuard({ children }: AuthGuardProps) { '/login/oidc', paths.home.getHref(), paths.auth.password_login.getHref(), + '/shared/task', // Allow public shared task page without authentication ]; if (!allowedPaths.includes(pathname)) { const token = getToken(); diff --git a/frontend/src/features/tasks/components/ChatArea.tsx b/frontend/src/features/tasks/components/ChatArea.tsx index ea852b13..7fbaa442 100644 --- a/frontend/src/features/tasks/components/ChatArea.tsx +++ b/frontend/src/features/tasks/components/ChatArea.tsx @@ -43,6 +43,7 @@ interface ChatAreaProps { selectedTeamForNewTask?: Team | null; showRepositorySelector?: boolean; taskType?: 'chat' | 'code'; + onShareButtonRender?: (button: React.ReactNode) => void; } export default function ChatArea({ @@ -51,6 +52,7 @@ export default function ChatArea({ selectedTeamForNewTask, showRepositorySelector = true, taskType = 'chat', + onShareButtonRender, }: ChatAreaProps) { const { toast } = useToast(); @@ -859,6 +861,7 @@ export default function ChatArea({ pendingAttachment={pendingAttachment} onContentChange={handleMessagesContentChange} streamingSubtaskId={streamingSubtaskId} + onShareButtonRender={onShareButtonRender} /> diff --git a/frontend/src/features/tasks/components/MessagesArea.tsx b/frontend/src/features/tasks/components/MessagesArea.tsx index 70e695a7..99652001 100644 --- a/frontend/src/features/tasks/components/MessagesArea.tsx +++ b/frontend/src/features/tasks/components/MessagesArea.tsx @@ -4,7 +4,7 @@ 'use client'; -import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { useTaskContext } from '../contexts/taskContext'; import type { TaskDetail, @@ -14,15 +14,18 @@ import type { GitBranch, Attachment, } from '@/types/api'; -import { Bot, Copy, Check, Download } from 'lucide-react'; +import { Bot, Copy, Check, Download, Share2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useTranslation } from '@/hooks/useTranslation'; +import { useToast } from '@/hooks/use-toast'; import MarkdownEditor from '@uiw/react-markdown-editor'; import { useTheme } from '@/features/theme/ThemeProvider'; import { useTypewriter } from '@/hooks/useTypewriter'; import { useMultipleStreamingRecovery, type RecoveryState } from '@/hooks/useStreamingRecovery'; import MessageBubble, { type Message } from './MessageBubble'; import AttachmentPreview from './AttachmentPreview'; +import TaskShareModal from './TaskShareModal'; +import { taskApis } from '@/apis/tasks'; interface ResultWithThinking { thinking?: unknown[]; @@ -174,6 +177,8 @@ interface MessagesAreaProps { isStreaming?: boolean; /** Pending user message for optimistic update */ pendingUserMessage?: string | null; + /** Callback to render share button in parent component (e.g., TopNavigation) */ + onShareButtonRender?: (button: React.ReactNode) => void; /** Pending attachment for optimistic update */ pendingAttachment?: Attachment | null; /** Callback to notify parent when content changes and scroll may be needed */ @@ -192,14 +197,49 @@ export default function MessagesArea({ pendingAttachment, onContentChange, streamingSubtaskId, + onShareButtonRender, }: MessagesAreaProps) { const { t } = useTranslation('chat'); + const { toast } = useToast(); const { selectedTaskDetail, refreshSelectedTaskDetail } = useTaskContext(); const { theme } = useTheme(); + // Task share modal state + const [showShareModal, setShowShareModal] = useState(false); + const [shareUrl, setShareUrl] = useState(''); + const [isSharing, setIsSharing] = useState(false); + // Use Typewriter effect for streaming content const displayContent = useTypewriter(streamingContent || ''); + // Handle task share - wrapped in useCallback to prevent infinite loops + const handleShareTask = useCallback(async () => { + if (!selectedTaskDetail?.id) { + toast({ + variant: 'destructive', + title: t('shared_task.no_task_selected'), + description: t('shared_task.no_task_selected_desc'), + }); + return; + } + + setIsSharing(true); + try { + const response = await taskApis.shareTask(selectedTaskDetail.id); + setShareUrl(response.share_url); + setShowShareModal(true); + } catch (err) { + console.error('Failed to share task:', err); + toast({ + variant: 'destructive', + title: t('shared_task.share_failed'), + description: (err as Error)?.message || t('shared_task.share_failed_desc'), + }); + } finally { + setIsSharing(false); + } + }, [selectedTaskDetail?.id, toast, t]); + // Check if team uses Chat Shell (streaming mode, no polling needed) // Case-insensitive comparison since backend may return 'chat' or 'Chat' const isChatShell = selectedTeam?.agent_type?.toLowerCase() === 'chat'; @@ -497,6 +537,33 @@ export default function MessagesArea({ onContentChange, ]); + // Memoize share button to prevent infinite re-renders + const shareButton = useMemo(() => { + if (!selectedTaskDetail?.id || displayMessages.length === 0) { + return null; + } + + return ( + + ); + }, [selectedTaskDetail?.id, displayMessages.length, isSharing, handleShareTask, t]); + + // Pass share button to parent for rendering in TopNavigation + useEffect(() => { + if (onShareButtonRender) { + onShareButtonRender(shareButton); + } + }, [onShareButtonRender, shareButton]); + return (
{/* Messages Area - only shown when there are messages or loading */} @@ -637,6 +704,14 @@ export default function MessagesArea({ )}
)} + + {/* Task Share Modal */} + setShowShareModal(false)} + taskTitle={selectedTaskDetail?.title || 'Untitled Task'} + shareUrl={shareUrl} + /> ); } diff --git a/frontend/src/features/tasks/components/PublicTaskSidebar.tsx b/frontend/src/features/tasks/components/PublicTaskSidebar.tsx new file mode 100644 index 00000000..eb402cac --- /dev/null +++ b/frontend/src/features/tasks/components/PublicTaskSidebar.tsx @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import Image from 'next/image'; +import { LogIn, MessageSquare } from 'lucide-react'; +import { useTranslation } from '@/hooks/useTranslation'; + +interface PublicTaskSidebarProps { + taskTitle: string; + sharerName: string; + onLoginClick: () => void; + isLoggedIn?: boolean; +} + +/** + * Simplified sidebar for public shared task viewing + * Shows the current shared task and prompts users to login + */ +export default function PublicTaskSidebar({ + taskTitle, + sharerName, + onLoginClick, + isLoggedIn = false, +}: PublicTaskSidebarProps) { + const { t } = useTranslation('common'); + + return ( +
+ {/* Logo */} +
+
+
+ Weibo Logo + Wegent +
+
+
+ + {/* Login prompt */} +
+ +
+ + {/* Current shared task */} +
+
+
{t('shared_task.shared_task')}
+
+ +
+
+
+ +
+
+
+ {taskTitle} +
+
+ {t('shared_task.shared_by')} {sharerName} +
+
+
+
+ + {/* Information box */} +
+
+

👀 {t('shared_task.read_only_view')}

+

+ {isLoggedIn ? t('shared_task.continue_prompt') : t('shared_task.login_prompt')} +

+
+
+
+ + {/* Bottom login CTA */} +
+ +
+
+ ); +} diff --git a/frontend/src/features/tasks/components/TaskShareHandler.tsx b/frontend/src/features/tasks/components/TaskShareHandler.tsx new file mode 100644 index 00000000..ec70d217 --- /dev/null +++ b/frontend/src/features/tasks/components/TaskShareHandler.tsx @@ -0,0 +1,403 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React, { useEffect, useState, useMemo } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/hooks/use-toast'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { taskApis, TaskShareInfo } from '@/apis/tasks'; +import { teamApis } from '@/apis/team'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useUser } from '@/features/common/UserContext'; +import Modal from '@/features/common/Modal'; +import ModelSelector, { + Model, + DEFAULT_MODEL_NAME, + allBotsHavePredefinedModel, +} from './ModelSelector'; +import { Check } from 'lucide-react'; +import { UsersIcon } from '@heroicons/react/24/outline'; +import { cn } from '@/lib/utils'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import type { Team } from '@/types/api'; + +interface TaskShareHandlerProps { + onTaskCopied?: () => void; +} + +/** + * Handle task sharing URL parameter detection, copy logic, and modal display + */ +export default function TaskShareHandler({ onTaskCopied }: TaskShareHandlerProps) { + const { t } = useTranslation('common'); + const { toast } = useToast(); + const { user } = useUser(); + const searchParams = useSearchParams(); + const router = useRouter(); + + const [shareInfo, setShareInfo] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [_isLoading, setIsLoading] = useState(false); + const [isCopying, setIsCopying] = useState(false); + const [error, setError] = useState(null); + const [teams, setTeams] = useState([]); + const [selectedTeamId, setSelectedTeamId] = useState(null); + const [selectedModel, setSelectedModel] = useState(null); + const [forceOverride, setForceOverride] = useState(false); + const [isTeamSelectorOpen, setIsTeamSelectorOpen] = useState(false); + const [teamSearchValue, setTeamSearchValue] = useState(''); + + const isSelfShare = shareInfo && user && shareInfo.user_id === user.id; + + // Find the selected team with full details + const selectedTeam = useMemo(() => { + return teams.find(team => team.id === selectedTeamId) || null; + }, [teams, selectedTeamId]); + + // Check if model selection is required + const isModelSelectionRequired = useMemo(() => { + // Skip check if team is not selected, or if team type is 'dify' (external API) + if (!selectedTeam || selectedTeam.agent_type === 'dify') return false; + // If team's bots have predefined models, "Default" option is available, no need to force selection + const hasDefaultOption = allBotsHavePredefinedModel(selectedTeam); + if (hasDefaultOption) return false; + // Model selection is required when no model is selected + return !selectedModel; + }, [selectedTeam, selectedModel]); + + const cleanupUrlParams = React.useCallback(() => { + const url = new URL(window.location.href); + url.searchParams.delete('taskShare'); + router.replace(url.pathname + url.search); + }, [router]); + + useEffect(() => { + const taskShareToken = searchParams.get('taskShare'); + + if (!taskShareToken) { + return; + } + + const fetchShareInfoAndTeams = async () => { + setIsLoading(true); + try { + // Fetch share info and teams in parallel + const [info, teamsResponse] = await Promise.all([ + taskApis.getTaskShareInfo(taskShareToken), + teamApis.getTeams({ page: 1, limit: 100 }), + ]); + + setShareInfo(info); + setTeams(teamsResponse.items); + + // Auto-select first team + if (teamsResponse.items.length > 0) { + setSelectedTeamId(teamsResponse.items[0].id); + } + + setIsModalOpen(true); + } catch (err) { + console.error('Failed to fetch task share info:', err); + toast({ + variant: 'destructive', + title: t('shared_task.handler_load_failed'), + description: (err as Error)?.message || t('messages.unknown_error'), + }); + cleanupUrlParams(); + } finally { + setIsLoading(false); + } + }; + + fetchShareInfoAndTeams(); + }, [searchParams, toast, t, cleanupUrlParams]); + + const handleConfirmCopy = async () => { + if (!shareInfo) return; + + if (isSelfShare) { + handleSelfShare(); + return; + } + + if (!selectedTeamId) { + toast({ + variant: 'destructive', + title: t('shared_task.handler_select_team'), + }); + return; + } + + // Validate model selection if required + if (isModelSelectionRequired) { + toast({ + variant: 'destructive', + title: t('task_submit.model_required'), + }); + return; + } + + setIsCopying(true); + setError(null); + try { + const shareToken = searchParams.get('taskShare'); + if (!shareToken) { + throw new Error('Share token not found'); + } + + // Determine model_id based on selection + let modelId: string | undefined = undefined; + if (selectedModel && selectedModel.name !== DEFAULT_MODEL_NAME) { + modelId = selectedModel.name; + } + + const response = await taskApis.joinSharedTask({ + share_token: shareToken, + team_id: selectedTeamId, + model_id: modelId, + force_override_bot_model: forceOverride, + }); + + toast({ + title: t('shared_task.handler_copy_success'), + description: `"${shareInfo.task_title}" ${t('shared_task.handler_copy_success_desc')}`, + }); + + // Refresh task list in parent component + if (onTaskCopied) { + onTaskCopied(); + } + + handleCloseModal(); + + // Navigate to the copied task in chat page + router.push(`/chat?taskId=${response.task_id}`); + } catch (err) { + console.error('Failed to copy shared task:', err); + const errorMessage = (err as Error)?.message || 'Failed to copy task'; + toast({ + variant: 'destructive', + title: errorMessage, + }); + setError(errorMessage); + } finally { + setIsCopying(false); + } + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + setShareInfo(null); + setError(null); + cleanupUrlParams(); + }; + + const handleSelfShare = () => { + toast({ + title: t('shared_task.handler_self_task_title'), + description: t('shared_task.handler_self_task_desc'), + }); + handleCloseModal(); + }; + + if (!shareInfo || !isModalOpen) return null; + + return ( + +
+ {error && ( + + {error} + + )} + {isSelfShare ? ( + + + {shareInfo.task_title} + {t('shared_task.handler_is_your_own_task')} + + + ) : ( + <> +
+

+ {shareInfo.user_name}{' '} + {t('shared_task.handler_shared_by')} + + {' '} + {shareInfo.task_title} + {' '} + {t('shared_task.handler_with_you')} +

+
+ + + + {t('shared_task.handler_copy_description')} + {shareInfo.task_title} + {t('shared_task.handler_copy_description_suffix')} + + + + {/* Team Selection */} +
+ + + + + + + + + + + {teams.length === 0 ? ( +
+ {t('shared_task.handler_no_teams')} +
+ ) : ( + <> + + {t('branches.no_match')} + + + {teams.map(team => ( + { + setSelectedTeamId(team.id); + setIsTeamSelectorOpen(false); + }} + className={cn( + 'flex items-center gap-2 px-3 py-2 text-sm cursor-pointer', + 'hover:bg-hover', + selectedTeamId === team.id && 'bg-primary/5' + )} + > + + + {team.name} + + ))} + + + )} +
+
+
+
+ {teams.length === 0 && ( +

+ {t('shared_task.handler_create_team_hint')} +

+ )} +
+ + {/* Model Selection */} + {selectedTeam && selectedTeam.agent_type !== 'dify' && ( + + )} + + )} +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/features/tasks/components/TaskShareModal.tsx b/frontend/src/features/tasks/components/TaskShareModal.tsx new file mode 100644 index 00000000..52e65c17 --- /dev/null +++ b/frontend/src/features/tasks/components/TaskShareModal.tsx @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React from 'react'; +import Modal from '@/features/common/Modal'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/hooks/use-toast'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { DocumentDuplicateIcon } from '@heroicons/react/24/outline'; +import { useTranslation } from '@/hooks/useTranslation'; + +interface TaskShareModalProps { + visible: boolean; + onClose: () => void; + taskTitle: string; + shareUrl: string; +} + +export default function TaskShareModal({ + visible, + onClose, + taskTitle, + shareUrl, +}: TaskShareModalProps) { + const { t } = useTranslation('common'); + const { toast } = useToast(); + + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(shareUrl); + toast({ + title: t('shared_task.link_copied'), + description: t('shared_task.link_copied_desc'), + }); + onClose(); + } catch { + // Fallback to traditional method if clipboard API is not available + const textArea = document.createElement('textarea'); + textArea.value = shareUrl; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + toast({ + title: t('shared_task.link_copied'), + description: t('shared_task.link_copied_desc'), + }); + onClose(); + } + }; + + return ( + +
+
+ {/* Success message */} +
+

+ {t('shared_task.share_success_message_prefix')} + {taskTitle} + {t('shared_task.share_success_message_suffix')} +

+
+ + {/* Instructions */} +
+ + {t('shared_task.share_link_info')} + +
+ + {t('shared_task.share_continue_info')} + +
+
+ + {/* Bottom button area */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/en/chat.json b/frontend/src/i18n/locales/en/chat.json index bdebc876..9fec1621 100644 --- a/frontend/src/i18n/locales/en/chat.json +++ b/frontend/src/i18n/locales/en/chat.json @@ -38,6 +38,14 @@ "bot": "Bot", "application_parameters": "Application Parameters" }, + "shared_task": { + "share_task": "Share", + "sharing": "Sharing...", + "no_task_selected": "No task selected", + "no_task_selected_desc": "Please select a task to share", + "share_failed": "Failed to share task", + "share_failed_desc": "Please try again" + }, "settings": { "model": "Model", "temperature": "Temperature", diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index f398c9dd..026490c6 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -600,5 +600,56 @@ "required": "Required", "optional": "Optional", "copy_command": "Copy command" + }, + "shared_task": { + "continue_chat": "Continue Chat", + "login_to_continue": "Login to Continue", + "read_only_view": "Read-only view", + "login_prompt": "Login to copy this task to your workspace and continue the conversation.", + "continue_prompt": "Click the button above to copy this task to your workspace and continue the conversation.", + "want_to_continue": "Want to continue this conversation?", + "copy_and_chat": "Copy this task to your workspace and keep chatting", + "shared_task": "Shared Task", + "shared_by": "Shared by", + "read_only_notice": "This is a read-only shared conversation. To copy it to your tasks and continue the conversation, please login", + "error_invalid_link": "Invalid share link", + "error_invalid_link_desc": "The share link format is invalid. Please check the link and try again.", + "error_task_deleted": "Task No Longer Available", + "error_task_deleted_desc": "This shared task has been deleted by the owner and is no longer available.", + "error_load_failed": "Failed to Load", + "error_load_failed_desc": "Unable to load the shared task. Please try again later.", + "go_home": "Go to Chat", + "share_success_title": "Share Task Successfully", + "share_success_message": "Your task {taskTitle} has been shared successfully!", + "share_success_message_prefix": "Your task", + "share_success_message_suffix": "has been shared successfully!", + "share_link_info": "Anyone with this link can view the conversation history of this task.", + "share_continue_info": "When others open the link, they can copy this task to their own task list and continue the conversation.", + "copy_link": "Copy Link", + "link_copied": "Share link copied!", + "link_copied_desc": "You can now share this link with others", + "share_task": "Share Task", + "sharing": "Sharing...", + "no_task_selected": "No task selected", + "no_task_selected_desc": "Please select a task to share", + "share_failed": "Failed to share task", + "share_failed_desc": "Please try again", + "handler_load_failed": "Failed to load shared task information", + "handler_select_team": "Please select a team", + "handler_copy_success": "Task copied successfully!", + "handler_copy_success_desc": "has been added to your task list", + "handler_self_task_title": "This is your own task", + "handler_self_task_desc": "You cannot copy your own shared task", + "handler_modal_title": "Shared Task", + "handler_is_your_own_task": "is your own task. You cannot copy it.", + "handler_shared_by": "shared the task", + "handler_with_you": "with you", + "handler_copy_description": "Copying this task will add", + "handler_copy_description_suffix": "and all its conversation history to your task list. You can then continue the conversation.", + "handler_select_team_label": "Select Team", + "handler_no_teams": "No teams available", + "handler_create_team_hint": "You need to create at least one team to copy this task.", + "handler_copying": "Copying...", + "handler_copy_to_tasks": "Continue chat" } } diff --git a/frontend/src/i18n/locales/zh-CN/chat.json b/frontend/src/i18n/locales/zh-CN/chat.json index 37c7d10f..01c9f79e 100644 --- a/frontend/src/i18n/locales/zh-CN/chat.json +++ b/frontend/src/i18n/locales/zh-CN/chat.json @@ -38,6 +38,14 @@ "bot": "机器人", "application_parameters": "应用参数" }, + "shared_task": { + "share_task": "分享", + "sharing": "分享中...", + "no_task_selected": "未选择任务", + "no_task_selected_desc": "请选择一个任务进行分享", + "share_failed": "分享任务失败", + "share_failed_desc": "请重试" + }, "settings": { "model": "模型", "temperature": "温度", diff --git a/frontend/src/i18n/locales/zh-CN/common.json b/frontend/src/i18n/locales/zh-CN/common.json index be01a6c0..f0b38e1c 100644 --- a/frontend/src/i18n/locales/zh-CN/common.json +++ b/frontend/src/i18n/locales/zh-CN/common.json @@ -601,5 +601,56 @@ "required": "必需", "optional": "可选", "copy_command": "复制命令" + }, + "shared_task": { + "continue_chat": "继续聊天", + "login_to_continue": "登录以继续", + "read_only_view": "只读视图", + "login_prompt": "登录以将此任务复制到您的工作区并继续对话。", + "continue_prompt": "点击上方按钮将此任务复制到您的工作区并继续对话。", + "want_to_continue": "想要继续这个对话吗?", + "copy_and_chat": "将此任务复制到您的工作区并继续聊天", + "shared_task": "分享的任务", + "shared_by": "分享者", + "read_only_notice": "这是一个只读的分享对话。要将其复制到您的任务列表并继续对话,请先登录", + "error_invalid_link": "无效的分享链接", + "error_invalid_link_desc": "分享链接格式无效,请检查链接后重试。", + "error_task_deleted": "任务已不可用", + "error_task_deleted_desc": "此分享任务已被所有者删除,无法访问。", + "error_load_failed": "加载失败", + "error_load_failed_desc": "无法加载分享任务,请稍后重试。", + "go_home": "返回聊天页", + "share_success_title": "任务分享成功", + "share_success_message": "您的任务 {taskTitle} 已成功分享!", + "share_success_message_prefix": "您的任务", + "share_success_message_suffix": "已成功分享!", + "share_link_info": "任何拥有此链接的人都可以查看此任务的对话历史。", + "share_continue_info": "其他人打开链接后,可以将此任务复制到他们的任务列表并继续对话。", + "copy_link": "复制链接", + "link_copied": "分享链接已复制!", + "link_copied_desc": "您现在可以将此链接分享给他人", + "share_task": "分享任务", + "sharing": "分享中...", + "no_task_selected": "未选择任务", + "no_task_selected_desc": "请选择一个任务进行分享", + "share_failed": "分享任务失败", + "share_failed_desc": "请重试", + "handler_load_failed": "获取分享任务信息失败", + "handler_select_team": "请选择一个团队", + "handler_copy_success": "任务复制成功!", + "handler_copy_success_desc": "已添加到您的任务列表", + "handler_self_task_title": "这是您自己的任务", + "handler_self_task_desc": "您不能复制自己分享的任务", + "handler_modal_title": "分享的任务", + "handler_is_your_own_task": "是您自己的任务,无法复制。", + "handler_shared_by": "分享了任务", + "handler_with_you": "给您", + "handler_copy_description": "复制此任务将把", + "handler_copy_description_suffix": "及其所有对话历史添加到您的任务列表。您可以继续对话。", + "handler_select_team_label": "选择团队", + "handler_no_teams": "没有可用的团队", + "handler_create_team_hint": "您需要至少创建一个团队才能复制此任务。", + "handler_copying": "复制中...", + "handler_copy_to_tasks": "继续聊天" } }