From 921d24df55171e6eba77e35b40bcbbc09d44623b Mon Sep 17 00:00:00 2001 From: yixiangxx <3120662+yixiangxx@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:40:41 +0800 Subject: [PATCH 1/5] fix(shared-task): enable attachment preview and download for shared tasks - Add share_token query parameter to attachment download endpoint - Support dual authentication: JWT for logged-in users, share_token for public access - Add get_current_user_optional helper for optional JWT authentication - Update frontend attachment APIs to accept optional shareToken parameter - Pass shareToken through MessageBubble -> ContextBadgeList -> AttachmentPreview - Validate share_token against task ownership before allowing attachment access --- .../app/api/endpoints/adapter/attachments.py | 104 +++++++++++++++++- backend/app/core/security.py | 64 +++++++++++ frontend/src/apis/attachments.ts | 32 ++++-- frontend/src/app/shared/task/page.tsx | 1 + .../components/input/AttachmentPreview.tsx | 9 +- .../components/message/ContextBadgeList.tsx | 30 ++++- .../components/message/MessageBubble.tsx | 4 + frontend/src/hooks/useAttachmentImage.ts | 14 ++- 8 files changed, 237 insertions(+), 21 deletions(-) diff --git a/backend/app/api/endpoints/adapter/attachments.py b/backend/app/api/endpoints/adapter/attachments.py index 88470dadfee..9bab6d464bd 100644 --- a/backend/app/api/endpoints/adapter/attachments.py +++ b/backend/app/api/endpoints/adapter/attachments.py @@ -12,13 +12,15 @@ from typing import Optional from urllib.parse import quote -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import Response from sqlalchemy.orm import Session from app.api.dependencies import get_db from app.core import security +from app.models.subtask import Subtask from app.models.subtask_context import ContextType +from app.models.task import TaskResource from app.models.user import User from app.schemas.subtask_context import ( AttachmentDetailResponse, @@ -29,6 +31,7 @@ from app.services.attachment.parser import DocumentParseError, DocumentParser from app.services.context import context_service from app.services.context.context_service import NotFoundException +from app.services.shared_task import shared_task_service logger = logging.getLogger(__name__) @@ -115,6 +118,72 @@ def _build_attachment_response( return AttachmentResponse.from_context(context, response_truncation_info) +def _validate_share_token_access( + db: Session, attachment_id: int, share_token: str +) -> bool: + """ + Validate that a share_token has access to a specific attachment. + + This validates that: + 1. The share_token is valid and decrypts to user_id#task_id + 2. The attachment belongs to a subtask of the task in the token + 3. The task owner matches the user_id in the token + + Args: + db: Database session + attachment_id: The attachment ID to check access for + share_token: The encrypted share token + + Returns: + True if access is granted, False otherwise + """ + # Decode share token to get task info + share_info = shared_task_service.decode_share_token(share_token, db) + if not share_info: + return False + + # Get the context (attachment) + context = context_service.get_context_optional( + db=db, + context_id=attachment_id, + ) + if context is None: + return False + + # Verify it's an attachment type + if context.context_type != ContextType.ATTACHMENT.value: + return False + + # Get the subtask that this attachment belongs to + if context.subtask_id <= 0: + # Attachment not linked to a subtask, cannot verify ownership + return False + + subtask = db.query(Subtask).filter(Subtask.id == context.subtask_id).first() + if not subtask: + return False + + # Verify the subtask belongs to the task in the token + if subtask.task_id != share_info.task_id: + return False + + # Verify the task owner matches the user_id in the token + task = ( + db.query(TaskResource) + .filter( + TaskResource.id == share_info.task_id, + TaskResource.user_id == share_info.user_id, + TaskResource.kind == "Task", + TaskResource.is_active == True, + ) + .first() + ) + if not task: + return False + + return True + + @router.post("/upload", response_model=AttachmentResponse) async def upload_attachment( file: UploadFile = File(...), @@ -264,16 +333,45 @@ async def get_attachment_preview( @router.get("/{attachment_id}/download") async def download_attachment( attachment_id: int, + share_token: Optional[str] = Query( + None, description="Share token for public access" + ), db: Session = Depends(get_db), - current_user: User = Depends(security.get_current_user), + current_user: Optional[User] = Depends(security.get_current_user_optional), ): """ Download the original file. + Supports two authentication methods: + 1. JWT token (for logged-in users) + 2. Share token (for public shared task viewers) + Returns: File binary data with appropriate content type """ - context = _get_attachment_context(db, attachment_id, current_user) + has_access = False + + # Method 1: Share token authentication (no login required) + if share_token: + has_access = _validate_share_token_access(db, attachment_id, share_token) + if has_access: + # Get context for share token access + context = context_service.get_context_optional( + db=db, + context_id=attachment_id, + ) + if context is None: + raise HTTPException(status_code=404, detail="Attachment not found") + # Method 2: JWT token authentication (existing logic) + elif current_user: + context = _get_attachment_context(db, attachment_id, current_user) + has_access = True + else: + # No authentication provided + raise HTTPException(status_code=401, detail="Authentication required") + + if not has_access: + raise HTTPException(status_code=404, detail="Attachment not found") # Get binary data from the appropriate storage backend binary_data = context_service.get_attachment_binary_data( diff --git a/backend/app/core/security.py b/backend/app/core/security.py index cd8f5a73ed0..378da3be75e 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -671,3 +671,67 @@ def get_current_user_flexible( Authenticated User object """ return auth_context.user + + +def get_current_user_optional( + token: Optional[str] = Depends(oauth2_scheme_optional), + db: Session = Depends(get_db), +) -> Optional[User]: + """ + Get current authenticated user if a valid token is provided, otherwise return None. + + This is useful for endpoints that support both authenticated and unauthenticated access. + + Args: + token: JWT token from Authorization header (optional) + db: Database session + + Returns: + User object if authenticated, None otherwise + """ + if not token: + return None + + with _tracer.start_as_current_span("auth.get_current_user_optional") as span: + if is_telemetry_enabled(): + span.set_attribute(SpanAttributes.AUTH_METHOD, "jwt_optional") + span.set_attribute(SpanAttributes.AUTH_TOKEN_TYPE, "bearer") + span.set_attribute(SpanAttributes.AUTH_SOURCE, "authorization_header") + + try: + # Verify token + token_data = verify_token(token) + username = token_data.get("username") + + if is_telemetry_enabled(): + span.set_attribute(SpanAttributes.USER_NAME, username) + + # Query user + user = user_service.get_user_by_name(db=db, user_name=username) + if user is None or not user.is_active: + if is_telemetry_enabled(): + span.set_attribute(SpanAttributes.AUTH_RESULT, "failure") + span.set_attribute( + SpanAttributes.AUTH_FAILURE_REASON, + "user_not_found" if user is None else "user_inactive", + ) + return None + + # Set user context for tracing + if is_telemetry_enabled(): + span.set_attribute(SpanAttributes.AUTH_RESULT, "success") + span.set_attribute(SpanAttributes.USER_ID, str(user.id)) + set_user_context(user_id=str(user.id), user_name=user.user_name) + + return user + except HTTPException: + # Token verification failed, return None instead of raising + if is_telemetry_enabled(): + span.set_attribute(SpanAttributes.AUTH_RESULT, "failure") + span.set_attribute(SpanAttributes.AUTH_FAILURE_REASON, "invalid_token") + return None + except Exception as e: + if is_telemetry_enabled(): + span.set_attribute(SpanAttributes.AUTH_RESULT, "failure") + span.set_attribute(SpanAttributes.AUTH_FAILURE_REASON, str(e)[:200]) + return None diff --git a/frontend/src/apis/attachments.ts b/frontend/src/apis/attachments.ts index ef0840190a6..9fa4f39f463 100644 --- a/frontend/src/apis/attachments.ts +++ b/frontend/src/apis/attachments.ts @@ -331,10 +331,15 @@ export function isHtmlExtension(extension: string): boolean { * Get image preview URL for an attachment * * @param attachmentId - Attachment ID + * @param shareToken - Optional share token for public access * @returns Preview URL */ -export function getAttachmentPreviewUrl(attachmentId: number): string { - return `${API_BASE_URL}/api/attachments/${attachmentId}/download` +export function getAttachmentPreviewUrl(attachmentId: number, shareToken?: string): string { + const baseUrl = `${API_BASE_URL}/api/attachments/${attachmentId}/download` + if (shareToken) { + return `${baseUrl}?share_token=${encodeURIComponent(shareToken)}` + } + return baseUrl } /** @@ -468,10 +473,15 @@ export async function getAttachmentPreview( * Get attachment download URL * * @param attachmentId - Attachment ID + * @param shareToken - Optional share token for public access * @returns Download URL */ -export function getAttachmentDownloadUrl(attachmentId: number): string { - return `${API_BASE_URL}/api/attachments/${attachmentId}/download` +export function getAttachmentDownloadUrl(attachmentId: number, shareToken?: string): string { + const baseUrl = `${API_BASE_URL}/api/attachments/${attachmentId}/download` + if (shareToken) { + return `${baseUrl}?share_token=${encodeURIComponent(shareToken)}` + } + return baseUrl } /** @@ -479,14 +489,22 @@ export function getAttachmentDownloadUrl(attachmentId: number): string { * * @param attachmentId - Attachment ID * @param filename - Optional filename for download. If not provided, will be extracted from Content-Disposition header + * @param shareToken - Optional share token for public access (no login required) */ -export async function downloadAttachment(attachmentId: number, filename?: string): Promise { +export async function downloadAttachment( + attachmentId: number, + filename?: string, + shareToken?: string +): Promise { const token = getToken() + const downloadUrl = getAttachmentDownloadUrl(attachmentId, shareToken) - const response = await fetch(`${API_BASE_URL}/api/attachments/${attachmentId}/download`, { + const response = await fetch(downloadUrl, { method: 'GET', headers: { - ...(token && { Authorization: `Bearer ${token}` }), + // Only include Authorization header if we have a token and no shareToken + // shareToken-based access doesn't require JWT authentication + ...(!shareToken && token && { Authorization: `Bearer ${token}` }), }, }) diff --git a/frontend/src/app/shared/task/page.tsx b/frontend/src/app/shared/task/page.tsx index 02dcde6e224..5f0ff44455b 100644 --- a/frontend/src/app/shared/task/page.tsx +++ b/frontend/src/app/shared/task/page.tsx @@ -345,6 +345,7 @@ function SharedTaskContent() { selectedBranch={null} theme={theme} t={t} + shareToken={searchParams.get('token') || undefined} /> ) })} diff --git a/frontend/src/features/tasks/components/input/AttachmentPreview.tsx b/frontend/src/features/tasks/components/input/AttachmentPreview.tsx index e42346ff774..5a9daf1d4e9 100644 --- a/frontend/src/features/tasks/components/input/AttachmentPreview.tsx +++ b/frontend/src/features/tasks/components/input/AttachmentPreview.tsx @@ -23,6 +23,8 @@ interface AttachmentPreviewProps { showDownload?: boolean /** Compact mode (smaller size) */ compact?: boolean + /** Share token for public access (no login required) */ + shareToken?: string } /** @@ -178,16 +180,17 @@ export default function AttachmentPreview({ attachment, showDownload = true, compact = false, + shareToken, }: AttachmentPreviewProps) { const [showLightbox, setShowLightbox] = useState(false) const handleDownload = useCallback(async () => { try { - await downloadAttachment(attachment.id, attachment.filename) + await downloadAttachment(attachment.id, attachment.filename, shareToken) } catch (err) { console.error('Failed to download attachment:', err) } - }, [attachment.id, attachment.filename]) + }, [attachment.id, attachment.filename, shareToken]) const handleImageClick = useCallback(() => { setShowLightbox(true) @@ -205,7 +208,7 @@ export default function AttachmentPreview({ blobUrl: imageUrl, isLoading: imageLoading, error: imageError, - } = useAttachmentImage(attachment.id, isImage) + } = useAttachmentImage(attachment.id, isImage, shareToken) // Render image preview for image types if (isImage && !imageError) { diff --git a/frontend/src/features/tasks/components/message/ContextBadgeList.tsx b/frontend/src/features/tasks/components/message/ContextBadgeList.tsx index 7a2763a06fc..b886b3536d8 100644 --- a/frontend/src/features/tasks/components/message/ContextBadgeList.tsx +++ b/frontend/src/features/tasks/components/message/ContextBadgeList.tsx @@ -47,6 +47,8 @@ interface ContextBadgeListProps { contexts?: SubtaskContextBrief[] /** Optional callback when user wants to re-select a context */ onContextReselect?: (context: SubtaskContextBrief) => void + /** Share token for public access (no login required) */ + shareToken?: string } /** @@ -58,7 +60,11 @@ interface ContextBadgeListProps { * - knowledge_base: Displays KB name with document count * - table: Displays table name with clickable link to view/reselect */ -export function ContextBadgeList({ contexts, onContextReselect }: ContextBadgeListProps) { +export function ContextBadgeList({ + contexts, + onContextReselect, + shareToken, +}: ContextBadgeListProps) { if (!contexts || contexts.length === 0) { return null } @@ -70,6 +76,7 @@ export function ContextBadgeList({ contexts, onContextReselect }: ContextBadgeLi key={`${context.context_type}-${context.id}`} context={context} onReselect={onContextReselect} + shareToken={shareToken} /> ))} @@ -82,13 +89,15 @@ export function ContextBadgeList({ contexts, onContextReselect }: ContextBadgeLi function ContextBadgeItem({ context, onReselect, + shareToken, }: { context: SubtaskContextBrief onReselect?: (context: SubtaskContextBrief) => void + shareToken?: string }) { switch (context.context_type) { case 'attachment': - return + return case 'knowledge_base': return case 'table': @@ -103,7 +112,13 @@ function ContextBadgeItem({ * * Converts SubtaskContextBrief to Attachment format for AttachmentPreview */ -function AttachmentContextBadge({ context }: { context: SubtaskContextBrief }) { +function AttachmentContextBadge({ + context, + shareToken, +}: { + context: SubtaskContextBrief + shareToken?: string +}) { // Map context status to Attachment status // SubtaskContextBrief uses lowercase status values (pending, ready, failed) // Attachment uses specific status types (uploading, parsing, ready, failed) @@ -136,7 +151,14 @@ function AttachmentContextBadge({ context }: { context: SubtaskContextBrief }) { created_at: '', } - return + return ( + + ) } /** * Knowledge base badge - displays KB name and document count diff --git a/frontend/src/features/tasks/components/message/MessageBubble.tsx b/frontend/src/features/tasks/components/message/MessageBubble.tsx index b259d017fc7..ccc0e2cf448 100644 --- a/frontend/src/features/tasks/components/message/MessageBubble.tsx +++ b/frontend/src/features/tasks/components/message/MessageBubble.tsx @@ -171,6 +171,8 @@ export interface MessageBubbleProps { onRegenerate?: (msg: Message, model: Model) => void /** Whether regenerate is in progress */ isRegenerating?: boolean + /** Share token for public access to attachments (no login required) */ + shareToken?: string } // Component for rendering a paragraph with hover action button @@ -293,6 +295,7 @@ const MessageBubble = memo( isLastAiMessage, onRegenerate, isRegenerating, + shareToken, }: MessageBubbleProps) { // Use trace hook for telemetry (auto-includes user and task context) const { trace } = useTraceAction() @@ -1480,6 +1483,7 @@ const MessageBubble = memo( )} {/* Show waiting indicator when streaming but no content yet */} diff --git a/frontend/src/hooks/useAttachmentImage.ts b/frontend/src/hooks/useAttachmentImage.ts index 5514ba1b2dd..930659ee5fc 100644 --- a/frontend/src/hooks/useAttachmentImage.ts +++ b/frontend/src/hooks/useAttachmentImage.ts @@ -12,7 +12,11 @@ interface AttachmentImageState { error: boolean } -export function useAttachmentImage(attachmentId: number, enabled: boolean): AttachmentImageState { +export function useAttachmentImage( + attachmentId: number, + enabled: boolean, + shareToken?: string +): AttachmentImageState { const [blobUrl, setBlobUrl] = useState(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(false) @@ -28,9 +32,11 @@ export function useAttachmentImage(attachmentId: number, enabled: boolean): Atta try { const token = getToken() - const response = await fetch(getAttachmentPreviewUrl(attachmentId), { + const response = await fetch(getAttachmentPreviewUrl(attachmentId, shareToken), { headers: { - ...(token && { Authorization: `Bearer ${token}` }), + // Only include Authorization header if we have a token and no shareToken + // shareToken-based access doesn't require JWT authentication + ...(!shareToken && token && { Authorization: `Bearer ${token}` }), }, }) @@ -63,7 +69,7 @@ export function useAttachmentImage(attachmentId: number, enabled: boolean): Atta URL.revokeObjectURL(blobUrl) } } - }, [attachmentId, enabled]) + }, [attachmentId, enabled, shareToken]) useEffect(() => { return () => { From df05648005c764dd162a4c0a1ac31d422e8adda8 Mon Sep 17 00:00:00 2001 From: yixiangxx Date: Tue, 3 Feb 2026 16:40:56 +0800 Subject: [PATCH 2/5] fix(attachments): add share token support for public attachment access This commit enables attachments in shared tasks to be accessed without authentication using share tokens. Both user-uploaded attachments and AI-generated attachments now work correctly on shared task pages. Backend changes: - Add share_token query parameter support to get_attachment endpoint - Add share_token support to get_attachment_preview endpoint (already had it for download) - Update _validate_share_token_access to handle attachments with subtask_id=0 by verifying attachment owner matches task owner - Add detailed logging for debugging share token validation - Change 404 to 403 for denied share token access for better error clarity Frontend changes: - Create ShareTokenContext for passing share token through component tree - Update MessageBubble to wrap content with ShareTokenProvider - Update AttachmentCard to use useShareToken and pass to API calls - Update AttachmentEmbed to use useShareToken and pass to API calls - Update getAttachment and getAttachmentPreview APIs to accept optional shareToken - Update useAttachmentPreview hook to accept and pass shareToken parameter - Maintain simple caching strategy using attachmentId as key (no token in cache key) This fixes the issue where attachments in AI-generated messages were returning 404/401 errors when viewed through shared task links. Co-Authored-By: Claude --- .../app/api/endpoints/adapter/attachments.py | 77 ++- frontend/src/apis/attachments.ts | 34 +- .../src/components/common/AttachmentCard.tsx | 8 +- .../src/components/common/AttachmentEmbed.tsx | 8 +- frontend/src/contexts/ShareTokenContext.tsx | 36 ++ .../components/message/MessageBubble.tsx | 460 +++++++++--------- frontend/src/hooks/useAttachmentPreview.ts | 9 +- 7 files changed, 383 insertions(+), 249 deletions(-) create mode 100644 frontend/src/contexts/ShareTokenContext.tsx diff --git a/backend/app/api/endpoints/adapter/attachments.py b/backend/app/api/endpoints/adapter/attachments.py index 9bab6d464bd..3bcdbbd91da 100644 --- a/backend/app/api/endpoints/adapter/attachments.py +++ b/backend/app/api/endpoints/adapter/attachments.py @@ -156,8 +156,16 @@ def _validate_share_token_access( # Get the subtask that this attachment belongs to if context.subtask_id <= 0: - # Attachment not linked to a subtask, cannot verify ownership - return False + # Attachment not linked to a subtask + # For unlinked attachments, verify the attachment owner matches the task owner + if context.user_id == share_info.user_id: + return True + else: + logger.warning( + f"[_validate_share_token_access] Ownership mismatch: context.user_id={context.user_id}, " + f"share_info.user_id={share_info.user_id}" + ) + return False subtask = db.query(Subtask).filter(Subtask.id == context.subtask_id).first() if not subtask: @@ -284,32 +292,89 @@ async def upload_attachment( @router.get("/{attachment_id}", response_model=AttachmentDetailResponse) async def get_attachment( attachment_id: int, + share_token: Optional[str] = Query( + None, description="Share token for public access" + ), db: Session = Depends(get_db), - current_user: User = Depends(security.get_current_user), + current_user: Optional[User] = Depends(security.get_current_user_optional), ): """ Get attachment details by ID. + Supports two authentication methods: + 1. JWT token (for logged-in users) + 2. Share token (for public shared task viewers) + Returns: Attachment details including status and metadata """ - context = _get_attachment_context(db, attachment_id, current_user) + + context = None + + # Method 1: Share token authentication (no login required) + if share_token: + has_access = _validate_share_token_access(db, attachment_id, share_token) + if has_access: + # Get context for share token access + context = context_service.get_context_optional( + db=db, + context_id=attachment_id, + ) + if context is None: + raise HTTPException(status_code=404, detail="Attachment not found") + else: + raise HTTPException(status_code=403, detail="Share token access denied") + # Method 2: JWT token authentication (existing logic) + elif current_user: + context = _get_attachment_context(db, attachment_id, current_user) + else: + # No authentication provided + raise HTTPException(status_code=401, detail="Authentication required") + return AttachmentDetailResponse.from_context(context) @router.get("/{attachment_id}/preview", response_model=AttachmentPreviewResponse) async def get_attachment_preview( attachment_id: int, + share_token: Optional[str] = Query( + None, description="Share token for public access" + ), db: Session = Depends(get_db), - current_user: User = Depends(security.get_current_user), + current_user: Optional[User] = Depends(security.get_current_user_optional), ): """ Get attachment preview content. + Supports two authentication methods: + 1. JWT token (for logged-in users) + 2. Share token (for public shared task viewers) + Returns: Attachment metadata and preview snippet (if available). """ - context = _get_attachment_context(db, attachment_id, current_user) + has_access = False + + # Method 1: Share token authentication (no login required) + if share_token: + has_access = _validate_share_token_access(db, attachment_id, share_token) + if has_access: + # Get context for share token access + context = context_service.get_context_optional( + db=db, + context_id=attachment_id, + ) + if context is None: + raise HTTPException(status_code=404, detail="Attachment not found") + else: + raise HTTPException(status_code=403, detail="Share token access denied") + # Method 2: JWT token authentication (existing logic) + elif current_user: + context = _get_attachment_context(db, attachment_id, current_user) + has_access = True + else: + # No authentication provided + raise HTTPException(status_code=401, detail="Authentication required") preview_type = "none" preview_text = None diff --git a/frontend/src/apis/attachments.ts b/frontend/src/apis/attachments.ts index 9fa4f39f463..2bd7a0b2c12 100644 --- a/frontend/src/apis/attachments.ts +++ b/frontend/src/apis/attachments.ts @@ -421,16 +421,28 @@ export async function uploadAttachment( * Get attachment details by ID * * @param attachmentId - Attachment ID + * @param shareToken - Optional share token for public access (no login required) * @returns Attachment details */ -export async function getAttachment(attachmentId: number): Promise { +export async function getAttachment( + attachmentId: number, + shareToken?: string +): Promise { const token = getToken() + let url = `${API_BASE_URL}/api/attachments/${attachmentId}` - const response = await fetch(`${API_BASE_URL}/api/attachments/${attachmentId}`, { + // Add share_token as query parameter if provided + if (shareToken) { + url += `?share_token=${encodeURIComponent(shareToken)}` + } + + const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), + // Only include Authorization header if we have a token and no shareToken + // shareToken-based access doesn't require JWT authentication + ...(!shareToken && token && { Authorization: `Bearer ${token}` }), }, }) @@ -446,18 +458,28 @@ export async function getAttachment(attachmentId: number): Promise { const token = getToken() + let url = `${API_BASE_URL}/api/attachments/${attachmentId}/preview` + + // Add share_token as query parameter if provided + if (shareToken) { + url += `?share_token=${encodeURIComponent(shareToken)}` + } - const response = await fetch(`${API_BASE_URL}/api/attachments/${attachmentId}/preview`, { + const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), + // Only include Authorization header if we have a token and no shareToken + // shareToken-based access doesn't require JWT authentication + ...(!shareToken && token && { Authorization: `Bearer ${token}` }), }, }) diff --git a/frontend/src/components/common/AttachmentCard.tsx b/frontend/src/components/common/AttachmentCard.tsx index 33efee5964f..dd2f742e4a5 100644 --- a/frontend/src/components/common/AttachmentCard.tsx +++ b/frontend/src/components/common/AttachmentCard.tsx @@ -9,6 +9,7 @@ import { Download, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { downloadAttachment, getAttachment, getFileIcon } from '@/apis/attachments' import type { AttachmentDetailResponse } from '@/apis/attachments' +import { useShareToken } from '@/contexts/ShareTokenContext' // Global cache for attachment details to avoid redundant API calls const attachmentCache = new Map() @@ -29,6 +30,7 @@ interface AttachmentCardProps { * - Download button (downloads with authentication) */ export function AttachmentCard({ attachmentId }: AttachmentCardProps) { + const { shareToken } = useShareToken() const [attachment, setAttachment] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -48,7 +50,7 @@ export function AttachmentCard({ attachmentId }: AttachmentCardProps) { } // Fetch from API if not cached - const data = await getAttachment(attachmentId) + const data = await getAttachment(attachmentId, shareToken) attachmentCache.set(attachmentId, data) // Cache the result setAttachment(data) } catch (err) { @@ -60,12 +62,12 @@ export function AttachmentCard({ attachmentId }: AttachmentCardProps) { } fetchAttachment() - }, [attachmentId]) + }, [attachmentId, shareToken]) const handleDownload = async (e: React.MouseEvent) => { e.preventDefault() try { - await downloadAttachment(attachmentId) + await downloadAttachment(attachmentId, attachment?.filename, shareToken) } catch (error) { console.error('Failed to download attachment:', error) } diff --git a/frontend/src/components/common/AttachmentEmbed.tsx b/frontend/src/components/common/AttachmentEmbed.tsx index 913f17cc88e..f869b307059 100644 --- a/frontend/src/components/common/AttachmentEmbed.tsx +++ b/frontend/src/components/common/AttachmentEmbed.tsx @@ -13,6 +13,7 @@ import HtmlPreview from '@/components/common/HtmlPreview' import { useTranslation } from '@/hooks/useTranslation' import { useAttachmentPreview } from '@/hooks/useAttachmentPreview' import { useAttachmentImage } from '@/hooks/useAttachmentImage' +import { useShareToken } from '@/contexts/ShareTokenContext' import { downloadAttachment, isImageExtension, isHtmlExtension } from '@/apis/attachments' interface AttachmentEmbedProps { @@ -22,7 +23,8 @@ interface AttachmentEmbedProps { export default function AttachmentEmbed({ attachmentId, theme = 'light' }: AttachmentEmbedProps) { const { t } = useTranslation('common') - const { data, isLoading, error } = useAttachmentPreview(attachmentId) + const { shareToken } = useShareToken() + const { data, isLoading, error } = useAttachmentPreview(attachmentId, shareToken) const isImage = useMemo(() => { if (!data) return false @@ -40,11 +42,11 @@ export default function AttachmentEmbed({ attachmentId, theme = 'light' }: Attac blobUrl: imageUrl, isLoading: imageLoading, error: imageError, - } = useAttachmentImage(attachmentId, isImage) + } = useAttachmentImage(attachmentId, isImage, shareToken) const handleDownload = async () => { try { - await downloadAttachment(attachmentId, data?.filename) + await downloadAttachment(attachmentId, data?.filename, shareToken) } catch (err) { console.error('Failed to download attachment:', err) } diff --git a/frontend/src/contexts/ShareTokenContext.tsx b/frontend/src/contexts/ShareTokenContext.tsx new file mode 100644 index 00000000000..327253fc410 --- /dev/null +++ b/frontend/src/contexts/ShareTokenContext.tsx @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client' + +import React, { createContext, useContext } from 'react' + +interface ShareTokenContextValue { + /** Share token for public access (no login required) */ + shareToken?: string +} + +const ShareTokenContext = createContext({}) + +export function ShareTokenProvider({ + children, + shareToken, +}: { + children: React.ReactNode + shareToken?: string +}) { + return {children} +} + +/** + * Hook to access share token in nested components + * + * Usage: + * ```tsx + * const { shareToken } = useShareToken() + * ``` + */ +export function useShareToken() { + return useContext(ShareTokenContext) +} diff --git a/frontend/src/features/tasks/components/message/MessageBubble.tsx b/frontend/src/features/tasks/components/message/MessageBubble.tsx index ccc0e2cf448..b2cf79b9e58 100644 --- a/frontend/src/features/tasks/components/message/MessageBubble.tsx +++ b/frontend/src/features/tasks/components/message/MessageBubble.tsx @@ -48,6 +48,7 @@ import type { Model } from '../../hooks/useModelSelection' import type { MessageBlock } from './thinking/types' import { useTraceAction } from '@/hooks/useTraceAction' import { useMessageFeedback } from '@/hooks/useMessageFeedback' +import { ShareTokenProvider } from '@/contexts/ShareTokenContext' import { SmartLink, SmartImage, SmartTextLine } from '@/components/common/SmartUrlRenderer' import { formatDateTime } from '@/utils/dateTime' import { parseError, getErrorDisplayMessage } from '@/utils/errorParser' @@ -1428,254 +1429,257 @@ const MessageBubble = memo( : 'w-full' return ( -
+
- {/* Show header for AI messages */} - {!isUserTypeMessage && ( -
- {headerIcon} - {headerLabel} - {timestampLabel && {timestampLabel}} - {msg.isRecovered && ( - - ({t('messages.recovered') || '已恢复'}) - - )} -
- )} - {/* Show reasoning display for DeepSeek R1 and similar models */} - {!isUserTypeMessage && (msg.reasoningContent || msg.result?.reasoning_content) && ( - - )} - {/* Show tool blocks for messages with thinking but no blocks */} - {!isUserTypeMessage && - msg.thinking && - msg.thinking.length > 0 && - (!msg.result?.blocks || msg.result.blocks.length === 0) && ( - + {/* Show header for AI messages */} + {!isUserTypeMessage && ( +
+ {headerIcon} + {headerLabel} + {timestampLabel && {timestampLabel}} + {msg.isRecovered && ( + + ({t('messages.recovered') || '已恢复'}) + + )} +
+ )} + {/* Show reasoning display for DeepSeek R1 and similar models */} + {!isUserTypeMessage && (msg.reasoningContent || msg.result?.reasoning_content) && ( + )} - {/* Show header for other users' messages in group chat (left-aligned user messages) */} - {isUserTypeMessage && !shouldAlignRight && msg.shouldShowSender && ( -
- - {msg.senderUserName || 'Unknown User'} - {timestampLabel && {timestampLabel}} -
- )} - {isUserTypeMessage && ( + {/* Show tool blocks for messages with thinking but no blocks */} + {!isUserTypeMessage && + msg.thinking && + msg.thinking.length > 0 && + (!msg.result?.blocks || msg.result.blocks.length === 0) && ( + + )} + {/* Show header for other users' messages in group chat (left-aligned user messages) */} + {isUserTypeMessage && !shouldAlignRight && msg.shouldShowSender && ( +
+ + {msg.senderUserName || 'Unknown User'} + {timestampLabel && {timestampLabel}} +
+ )} + {/* Show contexts (attachments, knowledge bases, etc.) for both user and AI messages */} - )} - {/* Show waiting indicator when streaming but no content yet */} - {isWaiting || msg.isWaiting ? ( - - ) : isEditing && isUserTypeMessage && onEditSave && onEditCancel ? ( - /* Show inline edit component when editing a user message */ - - ) : ( - <> - {/* Show recovered content if available, otherwise show normal content */} - {msg.recoveredContent && msg.subtaskStatus === 'RUNNING' && !msg.result?.blocks ? ( - renderRecoveredContent() - ) : !isUserTypeMessage && msg.result?.blocks && msg.result.blocks.length > 0 ? ( - /* For AI messages with blocks data, use mixed content view to interleave text and tools */ - <> - - - trace.copy(msg.type, msg.subtaskId)} - tools={[ - { - key: 'download', - title: t('messages.download') || 'Download', - icon: , - onClick: () => { - const blob = new Blob([msg.content || ''], { - type: 'text/plain;charset=utf-8', - }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'message.md' - a.click() - URL.revokeObjectURL(url) - trace.download(msg.type, msg.subtaskId) + {/* Show waiting indicator when streaming but no content yet */} + {isWaiting || msg.isWaiting ? ( + + ) : isEditing && isUserTypeMessage && onEditSave && onEditCancel ? ( + /* Show inline edit component when editing a user message */ + + ) : ( + <> + {/* Show recovered content if available, otherwise show normal content */} + {msg.recoveredContent && + msg.subtaskStatus === 'RUNNING' && + !msg.result?.blocks ? ( + renderRecoveredContent() + ) : !isUserTypeMessage && msg.result?.blocks && msg.result.blocks.length > 0 ? ( + /* For AI messages with blocks data, use mixed content view to interleave text and tools */ + <> + + + trace.copy(msg.type, msg.subtaskId)} + tools={[ + { + key: 'download', + title: t('messages.download') || 'Download', + icon: , + onClick: () => { + const blob = new Blob([msg.content || ''], { + type: 'text/plain;charset=utf-8', + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'message.md' + a.click() + URL.revokeObjectURL(url) + trace.download(msg.type, msg.subtaskId) + }, }, - }, - ]} - feedback={feedback} - onLike={handleLike} - onDislike={handleDislike} - feedbackLabels={{ - like: t('chat:messages.like') || 'Like', - dislike: t('chat:messages.dislike') || 'Dislike', - }} - showRegenerate={(() => { - const hasOnRegenerate = Boolean(onRegenerate) - const isCompleted = - msg.subtaskStatus === 'COMPLETED' || - msg.subtaskStatus === 'CANCELLED' || - msg.status === 'completed' - const isNotRunning = - msg.subtaskStatus !== 'RUNNING' && - msg.subtaskStatus !== 'PENDING' && - msg.subtaskStatus !== 'PROCESSING' && - msg.status !== 'streaming' && - msg.status !== 'pending' - return ( - hasOnRegenerate && - !isGroupChat && - isLastAiMessage && - isCompleted && - isNotRunning - ) - })()} - onRegenerateClick={() => setIsRegeneratePopoverOpen(true)} - isRegenerating={isRegenerating} - renderRegenerateButton={(defaultButton, tooltipText) => ( - { - onRegenerate?.(msg, model) - }} - isLoading={isRegenerating} - trigger={defaultButton} - tooltipText={tooltipText} - /> - )} - /> - - ) : ( - <>{renderMessageBody(msg, index)} - )} - - )} - {/* Show incomplete notice for completed but incomplete messages */} - {msg.isIncomplete && msg.subtaskStatus !== 'RUNNING' && renderRecoveryNotice()} - - {/* Show error message and retry button for failed messages */} - {!isUserTypeMessage && msg.status === 'error' && msg.error && ( -
- {/* Error message with details */} -
- -
- {/* Error message based on error type */} -

- {getErrorDisplayMessage(msg.error, (key: string) => t(`chat:${key}`))} -

- {/* Detailed error message from backend - only show if different from main message */} - {(() => { - const parsedError = parseError(msg.error) - // Don't show duplicate message for generic errors - if (parsedError.type !== 'generic_error') { - return ( -

- {msg.error} -

- ) - } - return null - })()} -
- {/* Action buttons: Retry and Copy */} -
- {/* Only show retry button for retryable errors */} - {/* Container errors (OOM, container crash) are not retryable - user should start new task */} - {onRetry && - (() => { + ]} + feedback={feedback} + onLike={handleLike} + onDislike={handleDislike} + feedbackLabels={{ + like: t('chat:messages.like') || 'Like', + dislike: t('chat:messages.dislike') || 'Dislike', + }} + showRegenerate={(() => { + const hasOnRegenerate = Boolean(onRegenerate) + const isCompleted = + msg.subtaskStatus === 'COMPLETED' || + msg.subtaskStatus === 'CANCELLED' || + msg.status === 'completed' + const isNotRunning = + msg.subtaskStatus !== 'RUNNING' && + msg.subtaskStatus !== 'PENDING' && + msg.subtaskStatus !== 'PROCESSING' && + msg.status !== 'streaming' && + msg.status !== 'pending' + return ( + hasOnRegenerate && + !isGroupChat && + isLastAiMessage && + isCompleted && + isNotRunning + ) + })()} + onRegenerateClick={() => setIsRegeneratePopoverOpen(true)} + isRegenerating={isRegenerating} + renderRegenerateButton={(defaultButton, tooltipText) => ( + { + onRegenerate?.(msg, model) + }} + isLoading={isRegenerating} + trigger={defaultButton} + tooltipText={tooltipText} + /> + )} + /> + + ) : ( + <>{renderMessageBody(msg, index)} + )} + + )} + {/* Show incomplete notice for completed but incomplete messages */} + {msg.isIncomplete && msg.subtaskStatus !== 'RUNNING' && renderRecoveryNotice()} + + {/* Show error message and retry button for failed messages */} + {!isUserTypeMessage && msg.status === 'error' && msg.error && ( +
+ {/* Error message with details */} +
+ +
+ {/* Error message based on error type */} +

+ {getErrorDisplayMessage(msg.error, (key: string) => t(`chat:${key}`))} +

+ {/* Detailed error message from backend - only show if different from main message */} + {(() => { const parsedError = parseError(msg.error) - const isRetryable = - parsedError.type !== 'container_oom' && - parsedError.type !== 'container_error' - if (!isRetryable) return null - return ( - - - - - {t('actions.retry') || '重试'} - - ) + // Don't show duplicate message for generic errors + if (parsedError.type !== 'generic_error') { + return ( +

+ {msg.error} +

+ ) + } + return null })()} - - trace.event('error-copy', { - 'error.message': msg.error?.substring(0, 100), - ...(msg.subtaskId && { 'subtask.id': msg.subtaskId }), - }) - } - /> +
+ {/* Action buttons: Retry and Copy */} +
+ {/* Only show retry button for retryable errors */} + {/* Container errors (OOM, container crash) are not retryable - user should start new task */} + {onRetry && + (() => { + const parsedError = parseError(msg.error) + const isRetryable = + parsedError.type !== 'container_oom' && + parsedError.type !== 'container_error' + if (!isRetryable) return null + return ( + + + + + {t('actions.retry') || '重试'} + + ) + })()} + + trace.event('error-copy', { + 'error.message': msg.error?.substring(0, 100), + ...(msg.subtaskId && { 'subtask.id': msg.subtaskId }), + }) + } + /> +
-
- )} + )} - {/* Show copy button for user messages - visible on hover */} - {isUserTypeMessage && !isEditing && ( -
- trace.copy(msg.type, msg.subtaskId)} - /> - {/* Edit button - only show for non-group chat and when not streaming */} - {!isGroupChat && !isStreaming && onEdit && ( - onEdit(msg)} + {/* Show copy button for user messages - visible on hover */} + {isUserTypeMessage && !isEditing && ( +
+ trace.copy(msg.type, msg.subtaskId)} /> - )} -
- )} + {/* Edit button - only show for non-group chat and when not streaming */} + {!isGroupChat && !isStreaming && onEdit && ( + onEdit(msg)} + className="h-[30px] w-[30px] !rounded-full bg-fill-tert hover:!bg-fill-sec" + tooltip={t('chat:actions.edit') || 'Edit'} + /> + )} +
+ )} +
-
+
) }, (prevProps, nextProps) => { diff --git a/frontend/src/hooks/useAttachmentPreview.ts b/frontend/src/hooks/useAttachmentPreview.ts index 5ade7b01a2a..c6b1b8584cb 100644 --- a/frontend/src/hooks/useAttachmentPreview.ts +++ b/frontend/src/hooks/useAttachmentPreview.ts @@ -14,7 +14,10 @@ interface AttachmentPreviewState { error: string | null } -export function useAttachmentPreview(attachmentId: number): AttachmentPreviewState { +export function useAttachmentPreview( + attachmentId: number, + shareToken?: string +): AttachmentPreviewState { const cached = attachmentPreviewCache.get(attachmentId) || null const [data, setData] = useState(cached) const [isLoading, setIsLoading] = useState(!cached) @@ -35,7 +38,7 @@ export function useAttachmentPreview(attachmentId: number): AttachmentPreviewSta const fetchPreview = async () => { try { setIsLoading(true) - const response = await getAttachmentPreview(attachmentId) + const response = await getAttachmentPreview(attachmentId, shareToken) attachmentPreviewCache.set(attachmentId, response) if (isMounted) { setData(response) @@ -58,7 +61,7 @@ export function useAttachmentPreview(attachmentId: number): AttachmentPreviewSta return () => { isMounted = false } - }, [attachmentId, cached]) + }, [attachmentId, shareToken, cached]) return { data, isLoading, error } } From b8bea6c7794050dcea70392acea27978aa96b138 Mon Sep 17 00:00:00 2001 From: yixiangxx Date: Tue, 3 Feb 2026 16:48:25 +0800 Subject: [PATCH 3/5] test(backend): remove obsolete provider detection tests in compression The TokenCounter implementation was updated in commit 522a58af to use LiteLLM for unified token counting across all providers, removing the explicit provider detection logic and `provider` attribute. This commit updates the test suite to reflect the implementation changes: - Remove test_detect_provider_openai (obsolete) - Remove test_detect_provider_anthropic (obsolete) - Remove test_detect_provider_google (obsolete) - Fix test_count_message_multimodal assertion (100 -> 50 tokens) The multimodal test was adjusted because LiteLLM accurately counts tokens for the tiny test base64 image, resulting in ~98 tokens instead of the previously estimated 100+ tokens. All 38 compression tests now pass. --- backend/tests/chat_shell/test_compression.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/backend/tests/chat_shell/test_compression.py b/backend/tests/chat_shell/test_compression.py index f24d9780e7a..5c6be6d55a3 100644 --- a/backend/tests/chat_shell/test_compression.py +++ b/backend/tests/chat_shell/test_compression.py @@ -58,7 +58,8 @@ def test_count_message_multimodal(self): } count = counter.count_message(message) # Should include both text and image tokens - assert count > 100 # At least image token count + # LiteLLM accurately counts tokens for small images (this test base64 is tiny) + assert count > 50 # Should have text tokens + some image tokens def test_count_messages_list(self): """Test counting tokens in message list.""" @@ -71,21 +72,6 @@ def test_count_messages_list(self): count = counter.count_messages(messages) assert count > 0 - def test_detect_provider_openai(self): - """Test provider detection for OpenAI models.""" - counter = TokenCounter(model_id="gpt-4-turbo") - assert counter.provider == "openai" - - def test_detect_provider_anthropic(self): - """Test provider detection for Anthropic models.""" - counter = TokenCounter(model_id="claude-3-5-sonnet-20241022") - assert counter.provider == "anthropic" - - def test_detect_provider_google(self): - """Test provider detection for Google models.""" - counter = TokenCounter(model_id="gemini-1.5-pro") - assert counter.provider == "google" - def test_is_over_limit(self): """Test over limit detection.""" counter = TokenCounter(model_id="gpt-4") From 0f023e4ae0b64a6c7804c72ede9765d43b333cc6 Mon Sep 17 00:00:00 2001 From: yixiangxx Date: Tue, 3 Feb 2026 17:00:45 +0800 Subject: [PATCH 4/5] fix(shared-task): display tool usage in shared task page Previously, the shared task page only displayed the text output but not the tool blocks (Bash, Read, Edit, Write, etc.) because the Message object was missing the result field containing blocks data. This commit passes the full result object from PublicSubtaskData to MessageBubble component, enabling MixedContentView to render tool blocks for shared tasks. Co-Authored-By: Claude --- backend/uv.lock | 2 ++ frontend/src/app/shared/task/page.tsx | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/uv.lock b/backend/uv.lock index 2a4f0f71104..749b92c0cc0 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -5898,6 +5898,7 @@ dependencies = [ { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, { name = "langgraph" }, + { name = "litellm" }, { name = "llama-index-core" }, { name = "llama-index-embeddings-openai" }, { name = "llama-index-vector-stores-elasticsearch" }, @@ -5949,6 +5950,7 @@ requires-dist = [ { name = "langchain-mcp-adapters", specifier = ">=0.2.1" }, { name = "langchain-openai", specifier = ">=1.0.0,<2.0.0" }, { name = "langgraph", specifier = ">=1.0.0,<2.0.0" }, + { name = "litellm", specifier = ">=1.50.0" }, { name = "llama-index-core", specifier = ">=0.12.0" }, { name = "llama-index-embeddings-openai", specifier = ">=0.3.0" }, { name = "llama-index-vector-stores-elasticsearch", specifier = ">=0.4.0" }, diff --git a/frontend/src/app/shared/task/page.tsx b/frontend/src/app/shared/task/page.tsx index 5f0ff44455b..cd2cacfa5bd 100644 --- a/frontend/src/app/shared/task/page.tsx +++ b/frontend/src/app/shared/task/page.tsx @@ -160,11 +160,15 @@ function SharedTaskContent() { } } - // For AI messages, extract result value + // For AI messages, extract result value and pass full result object let resultContent = '' if (subtask.result) { if (typeof subtask.result === 'object') { - const resultObj = subtask.result as { value?: unknown; thinking?: unknown } + const resultObj = subtask.result as { + value?: unknown + thinking?: unknown + blocks?: unknown + } if (resultObj.value !== null && resultObj.value !== undefined && resultObj.value !== '') { resultContent = String(resultObj.value) } else { @@ -193,6 +197,8 @@ function SharedTaskContent() { subtaskStatus: subtask.status, subtaskId: subtask.id, contexts, + // Pass the full result object to MessageBubble for tool blocks rendering + result: subtask.result as Message['result'], } } From b7f39d55b71731a2a1fd33c5b253c2526f0b7de7 Mon Sep 17 00:00:00 2001 From: yixiangxx Date: Tue, 3 Feb 2026 17:08:50 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0OpenAI=20Anthropi?= =?UTF-8?q?c=20Google=E6=A8=A1=E5=9E=8B=E6=8F=90=E4=BE=9B=E8=80=85?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/chat_shell/test_compression.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/tests/chat_shell/test_compression.py b/backend/tests/chat_shell/test_compression.py index 238fb7ff0d4..c5b2f98abf4 100644 --- a/backend/tests/chat_shell/test_compression.py +++ b/backend/tests/chat_shell/test_compression.py @@ -71,6 +71,21 @@ def test_count_messages_list(self): count = counter.count_messages(messages) assert count > 0 + def test_detect_provider_openai(self): + """Test provider detection for OpenAI models.""" + counter = TokenCounter(model_id="gpt-4-turbo") + assert counter.provider == "openai" + + def test_detect_provider_anthropic(self): + """Test provider detection for Anthropic models.""" + counter = TokenCounter(model_id="claude-3-5-sonnet-20241022") + assert counter.provider == "anthropic" + + def test_detect_provider_google(self): + """Test provider detection for Google models.""" + counter = TokenCounter(model_id="gemini-1.5-pro") + assert counter.provider == "google" + def test_is_over_limit(self): """Test over limit detection.""" counter = TokenCounter(model_id="gpt-4")