diff --git a/backend/app/api/endpoints/adapter/attachments.py b/backend/app/api/endpoints/adapter/attachments.py index 88470dadfee..3bcdbbd91da 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,80 @@ 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 + # 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: + 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(...), @@ -215,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 @@ -264,16 +398,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..2bd7a0b2c12 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 } /** @@ -416,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}` }), }, }) @@ -441,18 +458,28 @@ export async function getAttachment(attachmentId: number): Promise { const token = getToken() + let url = `${API_BASE_URL}/api/attachments/${attachmentId}/preview` - const response = await fetch(`${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(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}` }), }, }) @@ -468,10 +495,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 +511,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..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'], } } @@ -345,6 +351,7 @@ function SharedTaskContent() { selectedBranch={null} theme={theme} t={t} + shareToken={searchParams.get('token') || undefined} /> ) })} 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/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..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' @@ -171,6 +172,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 +296,7 @@ const MessageBubble = memo( isLastAiMessage, onRegenerate, isRegenerating, + shareToken, }: MessageBubbleProps) { // Use trace hook for telemetry (auto-includes user and task context) const { trace } = useTraceAction() @@ -1425,253 +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/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 () => { 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 } }