Skip to content

Commit 5ba8b47

Browse files
committed
feat: Added a shared task model and updated user relationships and configurations.
1 parent c66593f commit 5ba8b47

File tree

26 files changed

+2873
-26
lines changed

26 files changed

+2873
-26
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""add shared_tasks table for task sharing
2+
3+
Revision ID: 2b3c4d5e6f7g
4+
Revises: add_subtask_attachments
5+
Create Date: 2025-12-04 12:00:00.000000+08:00
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '2b3c4d5e6f7g'
16+
down_revision: Union[str, Sequence[str], None] = 'add_subtask_attachments'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Add shared_tasks table for task sharing functionality."""
23+
24+
# Create shared_tasks table
25+
op.execute("""
26+
CREATE TABLE IF NOT EXISTS shared_tasks (
27+
id INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
28+
user_id INT NOT NULL DEFAULT 0 COMMENT '当前用户ID',
29+
original_user_id INT NOT NULL DEFAULT 0 COMMENT '原始任务所有者用户ID',
30+
original_task_id INT NOT NULL DEFAULT 0 COMMENT '原始任务ID',
31+
copied_task_id INT NOT NULL DEFAULT 0 COMMENT '复制后的任务ID',
32+
is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否激活',
33+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
34+
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
35+
PRIMARY KEY (id),
36+
KEY idx_shared_tasks_id (id),
37+
KEY idx_shared_tasks_user_id (user_id),
38+
KEY idx_shared_tasks_original_user_id (original_user_id),
39+
KEY idx_shared_tasks_original_task_id (original_task_id),
40+
KEY idx_shared_tasks_copied_task_id (copied_task_id),
41+
UNIQUE KEY uniq_user_original_task (user_id, original_task_id)
42+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
43+
""")
44+
45+
46+
def downgrade() -> None:
47+
"""Remove shared_tasks table."""
48+
op.execute("DROP TABLE IF EXISTS shared_tasks")

backend/app/api/endpoints/adapter/chat.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def _should_use_direct_chat(db: Session, team: Kind, user_id: int) -> bool:
130130
return True
131131

132132

133-
def _create_task_and_subtasks(
133+
async def _create_task_and_subtasks(
134134
db: Session,
135135
user: User,
136136
team: Kind,
@@ -336,7 +336,48 @@ def _create_task_and_subtasks(
336336
db.commit()
337337
db.refresh(task)
338338
db.refresh(assistant_subtask)
339-
339+
340+
# Initialize Redis chat history from existing subtasks if needed
341+
# This is crucial for shared tasks that were copied with historical messages
342+
if existing_subtasks:
343+
from app.services.chat.session_manager import session_manager
344+
345+
# Check if history exists in Redis
346+
redis_history = await session_manager.get_chat_history(task_id)
347+
348+
# If Redis history is empty but we have subtasks, rebuild history from DB
349+
if not redis_history:
350+
logger.info(f"Initializing chat history from DB for task {task_id} with {len(existing_subtasks)} existing subtasks")
351+
history_messages = []
352+
353+
# Sort subtasks by message_id to ensure correct order
354+
sorted_subtasks = sorted(existing_subtasks, key=lambda s: s.message_id)
355+
356+
for subtask in sorted_subtasks:
357+
# Only include completed subtasks with results
358+
if subtask.status == SubtaskStatus.COMPLETED:
359+
if subtask.role == SubtaskRole.USER:
360+
# User message - use prompt field
361+
if subtask.prompt:
362+
history_messages.append({
363+
"role": "user",
364+
"content": subtask.prompt
365+
})
366+
elif subtask.role == SubtaskRole.ASSISTANT:
367+
# Assistant message - use result.value field
368+
if subtask.result and isinstance(subtask.result, dict):
369+
content = subtask.result.get("value", "")
370+
if content:
371+
history_messages.append({
372+
"role": "assistant",
373+
"content": content
374+
})
375+
376+
# Save to Redis if we found any history
377+
if history_messages:
378+
await session_manager.save_chat_history(task_id, history_messages)
379+
logger.info(f"Initialized {len(history_messages)} messages in Redis for task {task_id}")
380+
340381
return task, assistant_subtask
341382

342383

@@ -427,9 +468,9 @@ async def stream_chat(
427468
final_message = attachment_service.build_message_with_attachment(
428469
request.message, attachment
429470
)
430-
471+
431472
# Create task and subtasks (use original message for storage, final_message for LLM)
432-
task, assistant_subtask = _create_task_and_subtasks(
473+
task, assistant_subtask = await _create_task_and_subtasks(
433474
db, current_user, team, request.message, request, request.task_id
434475
)
435476

backend/app/api/endpoints/adapter/tasks.py

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
from app.core.config import settings
1515
from app.models.subtask import Subtask, SubtaskRole, SubtaskStatus
1616
from app.models.user import User
17+
from app.schemas.shared_task import (
18+
JoinSharedTaskRequest,
19+
JoinSharedTaskResponse,
20+
PublicSharedTaskResponse,
21+
TaskShareInfo,
22+
TaskShareResponse,
23+
)
1724
from app.schemas.task import (
1825
TaskCreate,
1926
TaskDetail,
@@ -23,6 +30,7 @@
2330
TaskUpdate,
2431
)
2532
from app.services.adapters.task_kinds import task_kinds_service
33+
from app.services.shared_task import shared_task_service
2634

2735
router = APIRouter()
2836
logger = logging.getLogger(__name__)
@@ -49,7 +57,7 @@ async def call_chat_shell_cancel(subtask_id: int, partial_content: str = ""):
4957
"""Background task to cancel Chat Shell streaming via session manager"""
5058
try:
5159
from app.services.chat.session_manager import session_manager
52-
60+
5361
success = await session_manager.cancel_stream(subtask_id)
5462
if success:
5563
logger.info(f"Chat Shell stream cancelled successfully for subtask {subtask_id}")
@@ -190,7 +198,7 @@ async def cancel_task(
190198
"""Cancel a running task by calling executor_manager or Chat Shell cancel"""
191199
from app.models.kind import Kind
192200
from app.schemas.kind import Task
193-
201+
194202
# Verify user owns this task
195203
task = task_kinds_service.get_task_detail(
196204
db=db, task_id=task_id, user_id=current_user.id
@@ -228,13 +236,13 @@ async def cancel_task(
228236
)
229237
.first()
230238
)
231-
239+
232240
if task_kind and task_kind.json:
233241
task_crd = Task.model_validate(task_kind.json)
234242
if task_crd.metadata.labels:
235243
source = task_crd.metadata.labels.get("source", "")
236244
is_chat_shell = source == "chat_shell"
237-
245+
238246
logger.info(f"Task {task_id} is_chat_shell={is_chat_shell}")
239247

240248
if is_chat_shell:
@@ -249,11 +257,11 @@ async def cancel_task(
249257
)
250258
.first()
251259
)
252-
260+
253261
if running_subtask:
254262
# Cancel the Chat Shell stream
255263
background_tasks.add_task(call_chat_shell_cancel, running_subtask.id)
256-
264+
257265
# Update subtask status to COMPLETED (not CANCELLED, to show partial content)
258266
from datetime import datetime
259267
running_subtask.status = SubtaskStatus.COMPLETED
@@ -262,7 +270,7 @@ async def cancel_task(
262270
running_subtask.updated_at = datetime.now()
263271
running_subtask.error_message = ""
264272
db.commit()
265-
273+
266274
# Update task status to COMPLETED (not CANCELLING, for Chat Shell)
267275
try:
268276
task_kinds_service.update_task(
@@ -276,7 +284,7 @@ async def cancel_task(
276284
)
277285
except Exception as e:
278286
logger.error(f"Failed to update Chat Shell task {task_id} status: {str(e)}")
279-
287+
280288
return {"message": "Chat stopped successfully", "status": "COMPLETED"}
281289
else:
282290
# No running subtask found, just mark task as completed
@@ -289,7 +297,7 @@ async def cancel_task(
289297
)
290298
except Exception as e:
291299
logger.error(f"Failed to update task {task_id} status: {str(e)}")
292-
300+
293301
return {"message": "No running stream to cancel", "status": "COMPLETED"}
294302
else:
295303
# For non-Chat Shell tasks, use executor_manager
@@ -314,3 +322,110 @@ async def cancel_task(
314322
background_tasks.add_task(call_executor_cancel, task_id)
315323

316324
return {"message": "Cancel request accepted", "status": "CANCELLING"}
325+
326+
327+
@router.post("/{task_id}/share", response_model=TaskShareResponse)
328+
def share_task(
329+
task_id: int,
330+
current_user: User = Depends(security.get_current_user),
331+
db: Session = Depends(get_db),
332+
):
333+
"""
334+
Generate a share link for a task.
335+
The share link allows others to view the task history and copy it to their task list.
336+
"""
337+
# Validate that the task belongs to the current user
338+
if not shared_task_service.validate_task_exists(
339+
db=db, task_id=task_id, user_id=current_user.id
340+
):
341+
raise HTTPException(
342+
status_code=404, detail="Task not found or you don't have permission"
343+
)
344+
345+
return shared_task_service.share_task(
346+
db=db, task_id=task_id, user_id=current_user.id
347+
)
348+
349+
350+
@router.get("/share/info", response_model=TaskShareInfo)
351+
def get_task_share_info(
352+
share_token: str = Query(..., description="Share token from URL"),
353+
db: Session = Depends(get_db),
354+
):
355+
"""
356+
Get task share information from share token.
357+
This endpoint doesn't require authentication, so anyone with the link can view.
358+
"""
359+
return shared_task_service.get_share_info(db=db, share_token=share_token)
360+
361+
362+
@router.get("/share/public", response_model=PublicSharedTaskResponse)
363+
def get_public_shared_task(
364+
token: str = Query(..., description="Share token from URL"),
365+
db: Session = Depends(get_db),
366+
):
367+
"""
368+
Get public shared task data for read-only viewing.
369+
This endpoint doesn't require authentication - anyone with the link can view.
370+
Only returns public data (no sensitive information like team config, bot details, etc.)
371+
"""
372+
return shared_task_service.get_public_shared_task(db=db, share_token=token)
373+
374+
375+
@router.post("/share/join", response_model=JoinSharedTaskResponse)
376+
def join_shared_task(
377+
request: JoinSharedTaskRequest,
378+
current_user: User = Depends(security.get_current_user),
379+
db: Session = Depends(get_db),
380+
):
381+
"""
382+
Copy a shared task to the current user's task list.
383+
This creates a new task with all the subtasks (messages) from the shared task.
384+
"""
385+
from app.models.kind import Kind
386+
387+
# If team_id is provided, validate it belongs to the user
388+
if request.team_id:
389+
user_team = (
390+
db.query(Kind)
391+
.filter(
392+
Kind.user_id == current_user.id,
393+
Kind.kind == "Team",
394+
Kind.id == request.team_id,
395+
Kind.is_active == True,
396+
)
397+
.first()
398+
)
399+
400+
if not user_team:
401+
raise HTTPException(
402+
status_code=400,
403+
detail="Invalid team_id or team does not belong to you",
404+
)
405+
else:
406+
# Get user's first active team if not specified
407+
user_team = (
408+
db.query(Kind)
409+
.filter(
410+
Kind.user_id == current_user.id,
411+
Kind.kind == "Team",
412+
Kind.is_active == True,
413+
)
414+
.first()
415+
)
416+
417+
if not user_team:
418+
raise HTTPException(
419+
status_code=400,
420+
detail="You need to have at least one team to copy a shared task",
421+
)
422+
423+
return shared_task_service.join_shared_task(
424+
db=db,
425+
share_token=request.share_token,
426+
user_id=current_user.id,
427+
team_id=user_team.id,
428+
model_id=request.model_id,
429+
force_override_bot_model=request.force_override_bot_model or False,
430+
)
431+

backend/app/core/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ class Settings(BaseSettings):
8484
REDIS_URL: str = "redis://127.0.0.1:6379/0"
8585

8686
# Team sharing configuration
87-
TEAM_SHARE_BASE_URL: str = "http://localhost:3000"
87+
TEAM_SHARE_BASE_URL: str = "http://localhost:3000/chat"
8888
TEAM_SHARE_QUERY_PARAM: str = "teamShare"
8989

9090
# AES encryption configuration for share tokens

backend/app/models/shared_task.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint
2+
from sqlalchemy.orm import relationship
3+
from datetime import datetime
4+
from app.db.base import Base
5+
6+
7+
class SharedTask(Base):
8+
"""
9+
Shared Task model - Records task sharing relationships
10+
11+
When a user shares a task, the task's content (including subtasks/messages)
12+
can be copied to another user's task list.
13+
"""
14+
__tablename__ = "shared_tasks"
15+
16+
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
17+
18+
# User who joined/copied the shared task
19+
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
20+
21+
# Original user who created/shared the task
22+
original_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
23+
24+
# Original task ID that was shared
25+
original_task_id = Column(Integer, ForeignKey("kinds.id", ondelete="CASCADE"), nullable=False, index=True)
26+
27+
# New task ID created for the user who joined (copied task)
28+
copied_task_id = Column(Integer, ForeignKey("kinds.id", ondelete="CASCADE"), nullable=True, index=True)
29+
30+
# Whether this share relationship is active
31+
is_active = Column(Boolean, default=True, nullable=False)
32+
33+
# Timestamps
34+
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
35+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
36+
37+
# Relationships
38+
user = relationship("User", foreign_keys=[user_id], back_populates="shared_tasks")
39+
original_user = relationship("User", foreign_keys=[original_user_id])
40+
original_task = relationship("Kind", foreign_keys=[original_task_id])
41+
copied_task = relationship("Kind", foreign_keys=[copied_task_id])
42+
43+
# Unique constraint: one user can only copy the same original task once
44+
__table_args__ = (
45+
UniqueConstraint('user_id', 'original_task_id', name='uq_user_original_task'),
46+
)
47+
48+
def __repr__(self):
49+
return f"<SharedTask(id={self.id}, user_id={self.user_id}, original_task_id={self.original_task_id})>"

backend/app/models/user.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class User(Base):
2525
created_at = Column(DateTime, default=func.now())
2626
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
2727

28+
# Relationships
29+
shared_tasks = relationship("SharedTask", foreign_keys="[SharedTask.user_id]", back_populates="user")
30+
2831
__table_args__ = (
2932
{
3033
"sqlite_autoincrement": True,

0 commit comments

Comments
 (0)