diff --git a/backend/contracts/agent.py b/backend/contracts/agent.py index e9353eb154..8f9437d4c0 100644 --- a/backend/contracts/agent.py +++ b/backend/contracts/agent.py @@ -20,7 +20,7 @@ from contracts.clarification import ClarificationResponse from contracts.constants.constants import WORKBENCH_OFFICIAL_SKILL_NAMES -from contracts.conversation import ConversationTarget, HistoryItem +from contracts.conversation import ConversationTarget, HistoryItem, MessageAnnotationItem from contracts.knowledge import ConversationKnowledgeScopeRequest, WorkbenchMode from contracts.skill import SkillResolution, SkillZipEntry from contracts.tool import ToolParamsRequest @@ -194,6 +194,10 @@ class AgentRequest(BaseModel): _message_source: str = PrivateAttr(default="CHAT") query: str clarification_response: Optional[ClarificationResponse] = None + # Reply annotations ("add to chat" quotes) attached to this turn's user + # message; persisted as a structured unit and merged into the model-facing + # text at request time. + annotations: Optional[List[MessageAnnotationItem]] = None conversation_id: Optional[int] = None history: Optional[List[HistoryItem]] = None # Complete list of attachment information diff --git a/backend/contracts/conversation.py b/backend/contracts/conversation.py index 367ae0f6ad..e1b03ca9fc 100644 --- a/backend/contracts/conversation.py +++ b/backend/contracts/conversation.py @@ -1,6 +1,6 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -"""Internal message provenance and opaque activity pagination cursors.""" +"""Conversation messages, reply annotations and opaque activity pagination cursors.""" import base64 import hashlib @@ -14,6 +14,126 @@ from contracts.clarification import ClarificationResponse +# Unit type used for the per-message annotation payload persisted on user +# messages through conversation_message_unit_t. +MESSAGE_ANNOTATIONS_UNIT_TYPE = "message_annotations" + +# Defensive caps mirroring the client-side limits; the backend truncates +# rather than rejects so oversized payloads never break a send. +ANNOTATION_QUOTED_TEXT_MAX_CHARS = 1500 +ANNOTATION_COMMENT_MAX_CHARS = 500 +ANNOTATION_SOURCE_EXCERPT_MAX_CHARS = 120 +ANNOTATION_MAX_COUNT = 10 + + +class MessageAnnotationItem(BaseModel): + """One quoted excerpt attached to an outgoing user message. + + Length caps are enforced by ``normalize_message_annotations`` (truncating + rather than rejecting) so oversized client payloads degrade gracefully + instead of failing the whole send. Legacy neighboring-context fields are + ignored when reading requests and persisted units. + """ + + quoted_text: str + comment: Optional[str] = None + source_excerpt: Optional[str] = None + + +def normalize_message_annotations( + raw: Any, +) -> Optional[List[MessageAnnotationItem]]: + """Validate raw annotation payloads, applying defensive caps. + + Accepts a list of annotation dicts (or models) as delivered by the + request body or read back from a persisted unit. Entries without usable + quoted text are dropped, over-length fields are truncated, and the list + is capped to ``ANNOTATION_MAX_COUNT``. Returns ``None`` when nothing + usable remains so callers can skip persistence and merging entirely. + """ + if not isinstance(raw, list): + return None + + annotations: List[MessageAnnotationItem] = [] + for item in raw: + try: + annotation = ( + item + if isinstance(item, MessageAnnotationItem) + else MessageAnnotationItem.model_validate(item) + ) + except (ValueError, TypeError): + continue + quoted = annotation.quoted_text.strip() + if not quoted: + continue + comment = (annotation.comment or "").strip() or None + excerpt = (annotation.source_excerpt or "").strip() or None + annotations.append( + MessageAnnotationItem( + quoted_text=quoted[:ANNOTATION_QUOTED_TEXT_MAX_CHARS], + comment=comment[:ANNOTATION_COMMENT_MAX_CHARS] if comment else None, + source_excerpt=( + excerpt[:ANNOTATION_SOURCE_EXCERPT_MAX_CHARS] + if excerpt + else None + ), + ) + ) + if len(annotations) >= ANNOTATION_MAX_COUNT: + break + + return annotations or None + + +def parse_message_annotation_unit(unit_content: Optional[str]) -> Optional[List[MessageAnnotationItem]]: + """Parse a persisted ``message_annotations`` unit payload. + + Returns the normalized annotations, or ``None`` for missing/invalid + payloads so history readers can skip the field entirely. + """ + if not unit_content: + return None + try: + payload = json.loads(unit_content) + except (ValueError, TypeError): + return None + return normalize_message_annotations(payload) + + +def _annotation_block(index: int, annotation: MessageAnnotationItem) -> str: + # The reply intro is a UI label, not reliable model-facing context. + lines = [f"{index}. Quote:"] + for line in annotation.quoted_text.split("\n"): + lines.append(f"> {line}" if line else ">") + if annotation.comment: + lines.append(f"Comment: {annotation.comment}") + return "\n".join(lines) + + +def format_annotations_for_llm( + text: str, annotations: Optional[List[MessageAnnotationItem]] +) -> str: + """Merge annotation blocks into the model-facing user text. + + Each annotation becomes a numbered quote block (blockquote excerpt plus + an optional comment line); the blocks are joined with blank lines and + separated from the user's own text by a horizontal rule so the model + receives both the quoted reply excerpts and the follow-up question in + one message. With no annotations the text is returned unchanged. + """ + if not annotations: + return text + + blocks = [ + _annotation_block(index, annotation) + for index, annotation in enumerate(annotations, start=1) + ] + quote_block = "\n\n".join(blocks) + body = text or "" + return f"{quote_block}\n\n---\n\n{body}" if body else quote_block + + class MessageSource(str, Enum): CHAT = "CHAT" AUTOMATION = "AUTOMATION" @@ -72,6 +192,8 @@ class MessageRequest(BaseModel): role: str message: List[MessageUnit] clarification_response: Optional[ClarificationResponse] = None + # Reply annotations ("add to chat" quotes) attached to this user message + annotations: Optional[List[MessageAnnotationItem]] = None # Complete list of attachment information minio_files: Optional[List[Dict[str, Any]]] = None @@ -100,6 +222,15 @@ class RenameRequest(BaseModel): only_if_default_title: bool = False +class BranchConversationRequest(BaseModel): + conversation_id: int + # 1-based Q&A turn (inclusive) that marks the branch point + turn_index: int + # Persisted answer ID, or the runtime's 1-based answer version before reload. + source_message_id: Optional[StrictInt] = Field(default=None, gt=0) + answer_index: Optional[StrictInt] = Field(default=None, gt=0) + + class BatchDeleteConversationRequest(BaseModel): conversation_ids: List[int] diff --git a/backend/management/api/conversation/conversation_management.py b/backend/management/api/conversation/conversation_management.py index 89e7463b93..8a27c63196 100644 --- a/backend/management/api/conversation/conversation_management.py +++ b/backend/management/api/conversation/conversation_management.py @@ -1,5 +1,5 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -from contracts.conversation import ConversationModelPreferencePatch, ConversationModelSelectionPatch, ConversationTarget, BatchDeleteConversationRequest, ConversationRequest, ConversationResponse, GenerateTitleRequest, MessageIdRequest, OpinionRequest, RenameRequest +from contracts.conversation import ConversationModelPreferencePatch, ConversationModelSelectionPatch, ConversationTarget, BranchConversationRequest, BatchDeleteConversationRequest, ConversationRequest, ConversationResponse, GenerateTitleRequest, MessageIdRequest, OpinionRequest, RenameRequest from contracts.knowledge import ConversationKnowledgeScopeUpdateRequest from contracts.agent import WorkbenchConfigUpdateRequest from contracts.errors.exceptions import ModelOutletConflictError, ModelOutletValidationError, AppException, ConversationNotFoundError, ValidationError, TokenExpiredError, AgentDraftEditError, WorkbenchConfigVersionConflict, WorkbenchError @@ -12,6 +12,7 @@ from management.db.conversation.repository import get_conversation_list_page from management.services.conversation.service import ( + branch_conversation_service, create_new_conversation, delete_conversation_service, delete_conversations_batch_service, @@ -279,6 +280,55 @@ async def rename_conversation_endpoint(request: RenameRequest, authorization: Op raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) +@router.post("/branch", response_model=ConversationResponse) +async def branch_conversation_endpoint( + request: BranchConversationRequest, authorization: Optional[str] = Header(None) +): + """ + Branch a conversation into a new one up to and including the given turn + + Args: + request: BranchConversationRequest object containing: + - conversation_id: Source conversation ID + - turn_index: 1-based Q&A turn (inclusive) marking the branch point + authorization: Authorization header + + Returns: + ConversationResponse object containing: + - conversation_id: New conversation ID + - conversation_title: New conversation title (suffixed with the branch marker) + - branched_message_count: Number of copied messages + """ + try: + user_id, tenant_id = get_current_user_id(authorization) + target = { + key: value + for key, value in request.model_dump(include={"source_message_id", "answer_index"}).items() + if value is not None + } + branched = branch_conversation_service( + request.conversation_id, request.turn_index, user_id, **target + ) + return ConversationResponse(code=0, message="success", data=branched) + except ValueError as exc: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) from exc + except ConversationNotFoundError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) from exc + except TokenExpiredError as e: + logging.warning("Session expired") + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except AppException: + raise + except HTTPException: + raise + except Exception as e: + logging.exception(f"Failed to branch conversation: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to branch conversation", + ) from e + + @router.delete("/{conversation_id}", response_model=ConversationResponse) async def delete_conversation_endpoint(conversation_id: int, authorization: Optional[str] = Header(None)): """ diff --git a/backend/management/db/conversation/branch_titles.py b/backend/management/db/conversation/branch_titles.py new file mode 100644 index 0000000000..e1340438ad --- /dev/null +++ b/backend/management/db/conversation/branch_titles.py @@ -0,0 +1,39 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Numbered display titles for newly branched conversations.""" + +import re +from typing import Iterable, Optional + + +CONVERSATION_TITLE_MAX_LENGTH = 100 +LEGACY_BRANCH_SUFFIX = "(分支)" +NUMBERED_TITLE = re.compile(r"(.*?)\s*\(([0-9]+)\)") + + +def build_branch_title( + source_title: Optional[str], existing_titles: Iterable[Optional[str]] = () +) -> str: + """Pick an unused numbered title while preserving the column length.""" + base = (source_title or "").strip() + if base.endswith(LEGACY_BRANCH_SUFFIX): + base = base[:-len(LEGACY_BRANCH_SUFFIX)].rstrip() + number = 2 + match = NUMBERED_TITLE.fullmatch(base) + if match: + base = match.group(1).rstrip() + number = max(2, int(match.group(2)) + 1) + occupied = set(existing_titles) + while True: + suffix = f" ({number})" + # An arbitrary user title can contain a very long numeric suffix. + # Treat that case as ordinary source text rather than exceeding the cap. + if len(suffix) > CONVERSATION_TITLE_MAX_LENGTH: + base = (source_title or "").strip() + number = 2 + continue + keep = CONVERSATION_TITLE_MAX_LENGTH - len(suffix) + candidate = f"{base[:keep].rstrip()}{suffix}".strip() + if candidate not in occupied: + return candidate + number += 1 diff --git a/backend/management/db/conversation/repository.py b/backend/management/db/conversation/repository.py index 53c6b214a0..770c035dac 100644 --- a/backend/management/db/conversation/repository.py +++ b/backend/management/db/conversation/repository.py @@ -22,9 +22,13 @@ ) from contracts.conversation import ( ConversationHistory, + MESSAGE_ANNOTATIONS_UNIT_TYPE, MessageSource, decode_activity_cursor, encode_activity_cursor, + format_annotations_for_llm, + normalize_message_annotations, + parse_message_annotation_unit, ) from contracts.errors.codes import ErrorCode from contracts.errors.exceptions import ( @@ -37,6 +41,7 @@ ) from contracts.roles import has_any_role, roles_from_record from management.db.client import db_client, get_db_session +from management.db.conversation.branch_titles import build_branch_title as _build_branch_title from management.db.conversation.classification import ( executor_system_key, is_workbench_history, @@ -364,6 +369,9 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[ response = _resolve_clarification_response( session, conversation_id, user_id, message_role, message_data['clarification_response'], ) + annotations = None + if message_data.get('annotations') is not None: + annotations = normalize_message_annotations(message_data['annotations']) minio_files = message_data.get('minio_files') # Convert minio_files to JSON string for storage @@ -393,6 +401,22 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[ if user_id: unit_data = add_creation_tracking(unit_data, user_id) session.execute(insert(ConversationMessageUnit).values(**unit_data)) + if annotations is not None and message_role == MESSAGE_ROLE["USER"]: + # Reply annotations ride as a structured unit so the stored + # message_content stays clean; the unit drives history rendering + # and the model-facing merge for later turns. + annotation_unit = { + 'conversation_id': conversation_id, 'message_id': message_id, + 'unit_index': 0, 'unit_type': MESSAGE_ANNOTATIONS_UNIT_TYPE, + 'unit_content': json.dumps( + [item.model_dump(mode="json") for item in annotations], + ensure_ascii=False, + ), + 'unit_status': 'completed', 'delete_flag': 'N', + } + if user_id: + annotation_unit = add_creation_tracking(annotation_unit, user_id) + session.execute(insert(ConversationMessageUnit).values(**annotation_unit)) if message_role == MESSAGE_ROLE["USER"] or ( message_role == MESSAGE_ROLE["ASSISTANT"] and message_data["content"] and status != "streaming" ): @@ -496,6 +520,338 @@ def create_message_units(message_units: List[Dict[str, Any]], message_id: int, c return unit_ids +def _select_branch_turn_rows( + rows: Sequence[ConversationMessage], + turn_index: int, + source_message_id: Optional[int] = None, + answer_index: Optional[int] = None, +) -> List[ConversationMessage]: + """Select one answer per turn, ending at the explicitly selected version. + + Keep regenerated assistant rows until version selection. Collapsing them + by message_index first would lose older answers still visible in the UI. + Runtime answer ordinals follow the same message_index/message_id ordering + as history loading; a persisted ID takes precedence when available. + """ + latest_users = { + int(row.message_index): row + for row in rows + if row.message_role == MESSAGE_ROLE["USER"] + } + turns: List[List[ConversationMessage]] = [] + preamble: List[ConversationMessage] = [] + for row in rows: + if row.message_role == MESSAGE_ROLE["USER"]: + if row is not latest_users[int(row.message_index)]: + continue + turns.append([row]) + elif turns: + turns[-1].append(row) + else: + preamble.append(row) + if turn_index < 1 or turn_index > len(turns): + raise ValueError(f"turn_index {turn_index} exceeds the {len(turns)} turns in the conversation") + + branch_rows = list(preamble) + for index, turn in enumerate(turns[:turn_index], start=1): + answers = [row for row in turn if row.message_role == MESSAGE_ROLE["ASSISTANT"]] + selected = answers[-1] if answers else None + if index == turn_index: + if source_message_id is not None: + selected = next( + (row for row in answers if int(row.message_id) == source_message_id), None + ) + if selected is None: + raise ValueError("source_message_id does not identify an answer in the selected turn") + elif answer_index is not None: + if not 1 <= answer_index <= len(answers): + raise ValueError("answer_index does not identify an answer in the selected turn") + selected = answers[answer_index - 1] + for row in turn: + if row.message_role != MESSAGE_ROLE["ASSISTANT"] or row is selected: + branch_rows.append(row) + if row is selected: + break + return branch_rows + + +def branch_conversation( + conversation_id: int, + turn_index: int, + user_id: Optional[str] = None, + source_message_id: Optional[int] = None, + answer_index: Optional[int] = None, +) -> Optional[Dict[str, Any]]: + """ + Branch a conversation into a new one up to and including the given turn. + + Copies conversation-level configuration plus the active messages, units and + search/image sources of every turn up to and including ``turn_index`` + (1-based over active user rows). MinIO file references inside + ``minio_files`` are shared, not copied; the original conversation and its + rows stay untouched. + + Args: + conversation_id: Source conversation ID (integer) + turn_index: 1-based Q&A turn (inclusive) marking the branch point + user_id: User that must own the source conversation + source_message_id: Exact persisted answer to retain in the final turn + answer_index: 1-based answer version for messages with runtime-only IDs + + Returns: + Optional[Dict[str, Any]]: New conversation data, or None when the + source conversation does not exist or is not accessible + """ + conversation_id = int(conversation_id) + turn_index = int(turn_index) + if turn_index < 1: + raise ValueError("turn_index must be a 1-based turn number") + + with get_db_session() as session: + # Take the user-quota advisory lock before any row lock so the lock + # ordering matches create_conversation (advisory -> row). + _enforce_user_conversation_limit(session, user_id) + + conversation = session.scalars( + select(ConversationRecord) + .where( + ConversationRecord.conversation_id == conversation_id, + ConversationRecord.delete_flag == "N", + *([ConversationRecord.created_by == user_id] if user_id else []), + ) + .with_for_update() + ).first() + if conversation is None: + return None + + message_rows = session.scalars( + select(ConversationMessage) + .where( + ConversationMessage.conversation_id == conversation_id, + ConversationMessage.delete_flag == "N", + ) + .order_by(asc(ConversationMessage.message_index), asc(ConversationMessage.message_id)) + .with_for_update() + ).all() + + branch_rows = _select_branch_turn_rows( + message_rows, turn_index, source_message_id=source_message_id, answer_index=answer_index + ) + + # The existing user advisory lock serializes numbering for branches + # created by the same owner. Other users and deleted rows do not count. + existing_titles = session.scalars( + select(ConversationRecord.conversation_title).where( + ConversationRecord.created_by == (user_id or conversation.created_by), + ConversationRecord.delete_flag == "N", + ) + ).all() + conversation_data = { + "conversation_title": _build_branch_title( + conversation.conversation_title, existing_titles + ), + "delete_flag": "N", + "agent_id": conversation.agent_id, + "chat_mode": conversation.chat_mode, + "runtime_metadata": deepcopy(conversation.runtime_metadata) or {}, + "runtime_metadata_version": 1, + } + # SQLAlchemy serializes Python None bound to a JSONB column as the JSON + # value null (not SQL NULL), and agent-chat history filters on + # "workbench_config IS NULL": binding an absent declaration would hide + # the branched conversation from every agent-chat list. Copy nullable + # JSONB values only when the source actually declares them, mirroring + # create_conversation. + if conversation.knowledge_scope is not None: + conversation_data["knowledge_scope"] = deepcopy( + conversation.knowledge_scope + ) + if conversation.workbench_config is not None: + conversation_data["workbench_config"] = deepcopy( + conversation.workbench_config + ) + conversation_data["workbench_config_version"] = 1 + if user_id: + conversation_data = add_creation_tracking(conversation_data, user_id) + + new_conversation = session.execute( + insert(ConversationRecord) + .values(**conversation_data) + .returning( + ConversationRecord.conversation_id, + ConversationRecord.conversation_title, + ConversationRecord.agent_id, + ConversationRecord.chat_mode, + ConversationRecord.knowledge_scope, + ConversationRecord.runtime_metadata, + ConversationRecord.runtime_metadata_version, + _optional_conversation_column("workbench_config", None), + _optional_conversation_column("workbench_config_version", 0), + (func.extract("epoch", ConversationRecord.create_time) * 1000).label("create_time"), + (func.extract("epoch", ConversationRecord.update_time) * 1000).label("update_time"), + ) + ).fetchone() + + new_conversation_id = int(new_conversation.conversation_id) + branch_message_ids = [int(row.message_id) for row in branch_rows] + + units_by_message: Dict[int, List[ConversationMessageUnit]] = {} + searches_by_message: Dict[int, List[ConversationSourceSearch]] = {} + images_by_message: Dict[int, List[ConversationSourceImage]] = {} + if branch_message_ids: + for unit in session.scalars( + select(ConversationMessageUnit) + .where( + ConversationMessageUnit.message_id.in_(branch_message_ids), + ConversationMessageUnit.delete_flag == "N", + ) + .order_by(asc(ConversationMessageUnit.message_id), asc(ConversationMessageUnit.unit_index)) + ).all(): + units_by_message.setdefault(int(unit.message_id), []).append(unit) + + for search in session.scalars( + select(ConversationSourceSearch).where( + ConversationSourceSearch.message_id.in_(branch_message_ids), + ConversationSourceSearch.delete_flag == "N", + ) + ).all(): + searches_by_message.setdefault(int(search.message_id), []).append(search) + + for image in session.scalars( + select(ConversationSourceImage).where( + ConversationSourceImage.message_id.in_(branch_message_ids), + ConversationSourceImage.delete_flag == "N", + ) + ).all(): + images_by_message.setdefault(int(image.message_id), []).append(image) + + copied_unit_ids = { + int(unit.unit_id) + for units in units_by_message.values() + for unit in units + } + + message_id_map: Dict[int, int] = {} + for row in branch_rows: + # opinion_flag is intentionally not copied: user feedback stays + # attached to the original conversation's messages. + message_data = { + "conversation_id": new_conversation_id, + "message_index": row.message_index, + "message_role": row.message_role, + "message_source": row.message_source or MessageSource.CHAT.value, + "message_content": row.message_content, + "minio_files": row.minio_files, + "status": row.status or "completed", + "delete_flag": "N", + } + if user_id: + message_data = add_creation_tracking(message_data, user_id) + message_id_map[int(row.message_id)] = int( + session.execute( + insert(ConversationMessage) + .values(**message_data) + .returning(ConversationMessage.message_id) + ).scalar_one() + ) + + unit_id_map: Dict[int, int] = {} + for old_message_id, units in units_by_message.items(): + for unit in units: + unit_data = { + "message_id": message_id_map[old_message_id], + "conversation_id": new_conversation_id, + "unit_index": unit.unit_index, + "unit_type": unit.unit_type, + "unit_content": unit.unit_content, + "unit_status": unit.unit_status or "completed", + "tool_call_id": unit.tool_call_id, + "invocation_id": unit.invocation_id, + "delete_flag": "N", + } + if user_id: + unit_data = add_creation_tracking(unit_data, user_id) + unit_id_map[int(unit.unit_id)] = int( + session.execute( + insert(ConversationMessageUnit) + .values(**unit_data) + .returning(ConversationMessageUnit.unit_id) + ).scalar_one() + ) + + for old_message_id, searches in searches_by_message.items(): + for search in searches: + search_data = { + "message_id": message_id_map[old_message_id], + "conversation_id": new_conversation_id, + "unit_id": ( + unit_id_map.get(int(search.unit_id)) + if search.unit_id is not None and int(search.unit_id) in copied_unit_ids + else None + ), + "source_type": search.source_type, + "source_title": search.source_title, + "source_location": search.source_location, + "source_content": search.source_content, + "score_overall": search.score_overall, + "score_accuracy": search.score_accuracy, + "score_semantic": search.score_semantic, + "retrieval_highlight_terms": search.retrieval_highlight_terms, + "aidp_metadata": search.aidp_metadata, + "aidp_source_reference": search.aidp_source_reference, + "published_date": search.published_date, + "cite_index": search.cite_index, + "search_type": search.search_type, + "tool_sign": search.tool_sign, + "delete_flag": "N", + } + if user_id: + search_data = add_creation_tracking(search_data, user_id) + session.execute(insert(ConversationSourceSearch).values(**search_data)) + + for old_message_id, images in images_by_message.items(): + for image in images: + image_data = { + "message_id": message_id_map[old_message_id], + "conversation_id": new_conversation_id, + "unit_id": ( + unit_id_map.get(int(image.unit_id)) + if image.unit_id is not None and int(image.unit_id) in copied_unit_ids + else None + ), + "image_url": image.image_url, + "cite_index": image.cite_index, + "search_type": image.search_type, + "delete_flag": "N", + } + if user_id: + image_data = add_creation_tracking(image_data, user_id) + session.execute(insert(ConversationSourceImage).values(**image_data)) + + return { + "conversation_id": new_conversation_id, + "conversation_title": new_conversation.conversation_title, + "agent_id": new_conversation.agent_id, + "chat_mode": new_conversation.chat_mode or "execution", + "knowledge_scope": new_conversation.knowledge_scope, + "runtime_metadata": new_conversation.runtime_metadata or {}, + "runtime_metadata_version": new_conversation.runtime_metadata_version or 0, + "workbench_config": ( + new_conversation.workbench_config + if isinstance(getattr(new_conversation, "workbench_config", None), dict) + else None + ), + "workbench_config_version": ( + new_conversation.workbench_config_version + if isinstance(getattr(new_conversation, "workbench_config_version", None), int) + else 0 + ), + "create_time": int(new_conversation.create_time), + "update_time": int(new_conversation.update_time), + "branched_message_count": len(branch_rows), + } + + def persist_assistant_run_batch( message_id: int, conversation_id: int, @@ -2646,9 +3002,15 @@ def _stopped_history_text(message, session, conversation_id: int) -> str: return _STOPPED_CONTEXT_NOTE + ("\n" + body if body else "") -def _history_turn(user, assistant_text: str, assistant_id=None) -> Dict[str, Any]: +def _history_turn(user, assistant_text: str, assistant_id=None, + annotations=None) -> Dict[str, Any]: + # Annotations merge into the model-facing user text only; the stored + # message_content stays clean. + user_message = user.message_content or "" + if annotations: + user_message = format_annotations_for_llm(user_message, annotations) return { - "user_message": user.message_content or "", + "user_message": user_message, "assistant_final_answer": assistant_text, "attachments": user.minio_files, "user_message_id": user.message_id, @@ -2758,12 +3120,45 @@ def get_historical_context( .order_by(asc(ConversationMessage.message_index)) ).all() + # Fetch reply-annotation units for the historical user messages so the + # model keeps seeing quoted excerpts on later turns while the stored + # message_content stays clean. + user_message_ids = [ + message.message_id + for message in messages + if message.message_role == "user" + ] + annotations_by_message: Dict[int, Any] = {} + if user_message_ids: + annotation_rows = session.execute( + select( + ConversationMessageUnit.message_id, + ConversationMessageUnit.unit_content, + ).where( + ConversationMessageUnit.conversation_id == conversation_id, + ConversationMessageUnit.message_id.in_(user_message_ids), + ConversationMessageUnit.unit_type == MESSAGE_ANNOTATIONS_UNIT_TYPE, + ConversationMessageUnit.unit_status == "completed", + ConversationMessageUnit.delete_flag == "N", + ) + ).all() + for row in annotation_rows: + items = parse_message_annotation_unit(row.unit_content) + if items is not None: + annotations_by_message[int(row.message_id)] = items + turns: List[Dict[str, Any]] = [] pending_user = None for message in messages: if message.message_role == "user": if pending_user is not None: - turns.append(_history_turn(pending_user, _UNANSWERED_CONTEXT_NOTE)) + turns.append( + _history_turn( + pending_user, + _UNANSWERED_CONTEXT_NOTE, + annotations=annotations_by_message.get(pending_user.message_id), + ) + ) pending_user = message elif pending_user is not None: answer = ( @@ -2771,10 +3166,23 @@ def get_historical_context( if message.status == "stopped" else message.message_content or "" ) - turns.append(_history_turn(pending_user, answer, message.message_id)) + turns.append( + _history_turn( + pending_user, + answer, + message.message_id, + annotations=annotations_by_message.get(pending_user.message_id), + ) + ) pending_user = None if pending_user is not None: - turns.append(_history_turn(pending_user, _UNANSWERED_CONTEXT_NOTE)) + turns.append( + _history_turn( + pending_user, + _UNANSWERED_CONTEXT_NOTE, + annotations=annotations_by_message.get(pending_user.message_id), + ) + ) summary_result = None if summary_record and summary_payload: diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index 7636831aad..94ee5cc404 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -25,6 +25,10 @@ from contracts.constants.constants import MESSAGE_ROLE from contracts.errors.exceptions import MemoryPreparationException from contracts.agent import AgentRequest +from contracts.conversation import ( + format_annotations_for_llm, + normalize_message_annotations, +) from management.services.agent.context import ( build_agent_run_context, build_authorized_context_input, @@ -136,7 +140,13 @@ async def prepare_agent_run( create_run_kwargs = { "agent_id": agent_request.agent_id, "minio_files": agent_request.minio_files, - "query": agent_request.query, + # Merge reply annotations into the model-facing task text only; the + # persisted message (save_conversation_user reads agent_request.query) + # keeps the clean user text. + "query": format_annotations_for_llm( + agent_request.query, + normalize_message_annotations(getattr(agent_request, "annotations", None)), + ), "history": agent_request.history, "tenant_id": tenant_id, "user_id": user_id, diff --git a/backend/management/services/conversation/service.py b/backend/management/services/conversation/service.py index 7025c873ed..d9fee031ce 100644 --- a/backend/management/services/conversation/service.py +++ b/backend/management/services/conversation/service.py @@ -18,10 +18,16 @@ from contracts.constants.constants import MODEL_CONFIG_MAPPING from contracts.constants.constants import MESSAGE_ROLE, DEFAULT_EN_TITLE, DEFAULT_ZH_TITLE from contracts.agent import AgentRequest -from contracts.conversation import MessageRequest, MessageUnit +from contracts.conversation import ( + MESSAGE_ANNOTATIONS_UNIT_TYPE, + MessageRequest, + MessageUnit, + normalize_message_annotations, + parse_message_annotation_unit, +) from contracts.errors.exceptions import AppException, ConversationNotFoundError, ValidationError from contracts.constants.constants import CHAT_MODE_VALUES -from management.db.conversation.repository import create_conversation, create_conversation_message, create_message_unit, create_source_image, create_source_search, delete_conversation, delete_conversations_batch, get_conversation, get_conversation_history, get_historical_context, get_latest_assistant_message_id, get_latest_user_message_id, get_message_id_by_index, get_source_images_by_conversation, get_source_images_by_message, get_source_searches_by_conversation, get_source_searches_by_message, persist_assistant_run_batch as persist_assistant_run_batch_db, rename_conversation, save_history_summary, update_conversation_agent_id, update_conversation_chat_mode, update_conversation_knowledge_scope, replace_conversation_workbench_config, replace_conversation_workbench_and_metadata, update_conversation_message_content, update_conversation_message_status, update_message_minio_files, update_message_opinion, update_message_unit_content, update_message_unit_status +from management.db.conversation.repository import branch_conversation, create_conversation, create_conversation_message, create_message_unit, create_source_image, create_source_search, delete_conversation, delete_conversations_batch, get_conversation, get_conversation_history, get_historical_context, get_latest_assistant_message_id, get_latest_user_message_id, get_message_id_by_index, get_source_images_by_conversation, get_source_images_by_message, get_source_searches_by_conversation, get_source_searches_by_message, persist_assistant_run_batch as persist_assistant_run_batch_db, rename_conversation, save_history_summary, update_conversation_agent_id, update_conversation_chat_mode, update_conversation_knowledge_scope, replace_conversation_workbench_config, replace_conversation_workbench_and_metadata, update_conversation_message_content, update_conversation_message_status, update_message_minio_files, update_message_opinion, update_message_unit_content, update_message_unit_status # Public read facade consumed by Agent subscriptions; these are intentional re-exports. from management.db.conversation.repository import ( # noqa: F401 get_last_unit_for_message, @@ -91,6 +97,8 @@ def save_message(request: MessageRequest, user_id: str, tenant_id: str, } if message_data.get('clarification_response') is not None: message_data_copy['clarification_response'] = message_data['clarification_response'] + if message_data.get('annotations') is not None: + message_data_copy['annotations'] = message_data['annotations'] return create_conversation_message( message_data_copy, user_id, status=status, message_source=message_source, ) @@ -263,6 +271,10 @@ def save_conversation_user(request: AgentRequest, user_id: str, tenant_id: str) # run_agent_stream for the LLM call only. raw_query = strip_current_time_prefix(request.query) + # Normalize annotations (defensive caps) before persistence; None skips + # the structured unit entirely. + annotations = normalize_message_annotations(getattr(request, "annotations", None)) + conversation_req = MessageRequest( conversation_id=request.conversation_id, message_idx=user_role_count * 2, @@ -271,6 +283,7 @@ def save_conversation_user(request: AgentRequest, user_id: str, tenant_id: str) minio_files=request.minio_files, **({'clarification_response': request.clarification_response} if getattr(request, 'clarification_response', None) is not None else {}), + **({'annotations': annotations} if annotations is not None else {}), ) save_message( conversation_req, user_id=user_id, tenant_id=tenant_id, @@ -430,6 +443,52 @@ def create_new_conversation( raise Exception(str(e)) +def branch_conversation_service( + conversation_id: int, + turn_index: int, + user_id: str, + source_message_id: Optional[int] = None, + answer_index: Optional[int] = None, +) -> Dict[str, Any]: + """ + Branch a conversation into a new one up to and including the given turn + + Args: + conversation_id: Source conversation ID + turn_index: 1-based Q&A turn (inclusive) marking the branch point + user_id: User ID that must own the source conversation + source_message_id: Exact persisted answer in the requested turn + answer_index: 1-based runtime answer version when no persisted ID is available + + Returns: + Dict[str, Any]: New conversation data + + Raises: + ConversationNotFoundError: When the source conversation does not exist + """ + if turn_index < 1: + raise ValueError("turn_index must be a 1-based turn number") + try: + target = {} + if source_message_id is not None: + target["source_message_id"] = source_message_id + if answer_index is not None: + target["answer_index"] = answer_index + conversation_data = branch_conversation( + conversation_id, turn_index, user_id=user_id, **target + ) + if conversation_data is None: + raise ConversationNotFoundError( + "Conversation does not exist or has been deleted" + ) + return conversation_data + except (AppException, ConversationNotFoundError, ValueError): + raise + except Exception as e: + logging.error(f"Failed to branch conversation: {str(e)}") + raise Exception(str(e)) + + def update_conversation_workbench_config_service( conversation_id: int, config: Dict[str, Any], @@ -883,6 +942,12 @@ def get_conversation_history_service(conversation_id: int, user_id: str) -> List message_item['clarification_response'] = response except (ValueError, TypeError): logger.warning("Skipping invalid clarification response message_id=%s", message_id) + elif unit.get('unit_type') == MESSAGE_ANNOTATIONS_UNIT_TYPE: + annotations = parse_message_annotation_unit(unit.get('unit_content')) + if annotations is not None: + message_item['annotations'] = [ + item.model_dump(mode="json") for item in annotations + ] # Add minio_files field (if any) if 'minio_files' in msg and msg['minio_files']: diff --git a/frontend/app/[locale]/newchat/adapter/attachment-adapter.ts b/frontend/app/[locale]/newchat/adapter/attachment-adapter.ts index fa2cd6c371..bf66f4c54f 100644 --- a/frontend/app/[locale]/newchat/adapter/attachment-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/attachment-adapter.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + "use client"; import type { @@ -39,6 +41,65 @@ interface UploadedFileMeta { size: number; } +/** + * Marker for files whose bytes already live in storage (agent-generated + * artifacts added back into the composer). `send()` reuses the stashed + * metadata instead of uploading the same object a second time. + */ +export type ExistingUploadMeta = UploadedFileMeta; + +const EXISTING_UPLOAD_MARKER = "__nexentExistingUpload"; + +type FileWithUploadMarker = File & { + [EXISTING_UPLOAD_MARKER]?: ExistingUploadMeta; +}; + +/** Stashes known storage metadata on a file so `send()` can skip the upload. */ +export const markFileWithExistingUpload = ( + file: File, + meta: ExistingUploadMeta +): File => { + (file as FileWithUploadMarker)[EXISTING_UPLOAD_MARKER] = meta; + return file; +}; + +const readExistingUploadMeta = ( + file: File | undefined +): ExistingUploadMeta | undefined => + (file as FileWithUploadMarker | undefined)?.[EXISTING_UPLOAD_MARKER]; + +const buildCompleteAttachment = ( + attachment: PendingAttachment, + meta: UploadedFileMeta +): CompleteAttachment => { + // Prefer the presigned URL (works for time-limited access); fall back to url. + const serverUrl = meta.presigned_url || meta.url; + const isImage = attachment.type === "image"; + + // Build the content part so the message attachment carries a usable + // reference for both UI rendering and downstream consumption. + const part: ThreadUserMessagePart = isImage + ? { type: "image", image: serverUrl } + : { + type: "file", + filename: attachment.name, + data: serverUrl, + mimeType: attachment.contentType || "application/octet-stream", + }; + + const completeAttachment: CompleteAttachment = { + id: attachment.id, + status: { type: "complete" }, + type: attachment.type as "image" | "document" | "file", + name: attachment.name, + contentType: attachment.contentType, + content: [part], + // Carry upload metadata alongside the typed fields. + ...(meta as object), + }; + return completeAttachment; +}; + const createPendingAttachment = async ({ file, }: { @@ -68,12 +129,24 @@ export const compositeAttachmentAdapter: AttachmentAdapter = { }, async send(attachment: PendingAttachment): Promise { + // Agent-generated artifacts re-attached from the artifacts panel carry + // their storage metadata; reuse the existing object instead of uploading + // the same bytes a second time. + const existingMeta = readExistingUploadMeta(attachment.file); + if (existingMeta) { + log.log( + "[AttachmentAdapter] Reusing stored object for attachment:", + existingMeta.object_name + ); + return buildCompleteAttachment(attachment, existingMeta); + } + const file = attachment.file; log.log( "[AttachmentAdapter] Uploading file to MinIO:", file.name, "type:", - attachment.type, + attachment.type ); try { @@ -88,38 +161,18 @@ export const compositeAttachmentAdapter: AttachmentAdapter = { throw new Error(errorMessage); } - // Prefer the presigned URL (works for time-limited access); fall back to url. - const serverUrl = item.presigned_url || item.url; - const isImage = attachment.type === "image"; - - // Build the content part so the message attachment carries a usable - // reference for both UI rendering and downstream consumption. - const part: ThreadUserMessagePart = isImage - ? { type: "image", image: serverUrl } - : { type: "file", filename: file.name, data: serverUrl, mimeType: file.type || "application/octet-stream" }; - // Stash upload metadata on the attachment for the ChatModelAdapter to // forward as `minio_files`. Cast through unknown because the public // BaseAttachment type does not declare these fields. const meta: UploadedFileMeta = { object_name: item.object_name, - url: serverUrl, + url: item.presigned_url || item.url, presigned_url: item.presigned_url, type: file.type || attachment.type, size: item.file_size ?? file.size, }; - const completeAttachment: CompleteAttachment = { - id: attachment.id, - status: { type: "complete" }, - type: attachment.type as "image" | "document" | "file", - name: attachment.name, - contentType: attachment.contentType, - content: [part], - // Carry upload metadata alongside the typed fields. - ...(meta as object), - }; - return completeAttachment; + return buildCompleteAttachment(attachment, meta); } catch (error) { log.error("[AttachmentAdapter] Upload error:", error); // Surface the error to the assistant-ui runtime so the user sees it. @@ -168,7 +221,8 @@ export const createNewChatAttachmentAdapter = (): AttachmentAdapter => { }, async send(attachment: PendingAttachment): Promise { - const completeAttachment = await compositeAttachmentAdapter.send(attachment); + const completeAttachment = + await compositeAttachmentAdapter.send(attachment); releaseAttachmentSlot(); return completeAttachment; }, diff --git a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx index 6892d1c8ce..1ef67cbd24 100644 --- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx +++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx @@ -46,6 +46,7 @@ import { collapseRefreshUserMessages } from "./history-branching"; import log from "@/lib/logger"; import { createAssistantStream } from "assistant-stream"; import type { AttachmentType } from "../utils/attachment-type"; +import { fromMessageAnnotationWireItems } from "../utils/message-annotations"; import { attachExecutionLogsToTool, collapseSubAgentParts, @@ -1416,6 +1417,9 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { : {}), ...(historicalNl2a ? { nl2a: historicalNl2a } : {}), ...(stepTokenCounts.length > 0 ? { stepTokenCounts } : {}), + ...(msg.role === "user" && msg.annotations?.length + ? { annotations: fromMessageAnnotationWireItems(msg.annotations) } + : {}), ...(msg.role === "user" && typeof msg.message_index === "number" ? { historicalMessageIndex: msg.message_index } : {}), diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index e6042a6799..37815cef7e 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -45,6 +45,7 @@ import type { HumanInteractionEvent, } from "@/types/clarification"; import { extractMinioFiles } from "../utils/history-attachments"; +import { fromMessageAnnotationWireItems } from "../utils/message-annotations"; // Backend SSE chunk format interface ImageMetadata { @@ -310,6 +311,9 @@ interface NexentRunConfig { onRunId?: (runId: string) => void; onRunStarted?: () => void; onRunRejected?: (error: unknown, message?: ThreadMessage) => void; + // Structured reply annotations for the outgoing message; set when the + // composer keeps the library-managed send (in-flight attachment uploads). + messageAnnotations?: unknown; resume?: boolean; agentId?: number | string; agentVersionNo?: number; @@ -1459,7 +1463,26 @@ export const remoteChatModelAdapter: ChatModelAdapter = { ? JSON.stringify(structuredNl2AgentInput) : visibleQuery; - if (!isResume && !query) { + // Reply annotations: prefer the structured metadata on the outgoing user + // message (manual append path and retries); fall back to the runConfig + // channel used when in-flight uploads keep the library submission flow + // in charge of the append. Resolved before the empty-query guard because + // an annotation-only send (quoted reply, no typed text) carries an empty + // query; the backend merges the quotes into the model-facing text. + const messageAnnotations = fromMessageAnnotationWireItems( + lastUserIndex >= 0 + ? ( + messages[lastUserIndex].metadata?.custom as + | { annotations?: unknown } + | undefined + )?.annotations + : undefined + ); + const annotations = messageAnnotations.length + ? messageAnnotations + : fromMessageAnnotationWireItems(custom?.messageAnnotations); + + if (!isResume && !query && !annotations.length) { log.warn("[ChatModelAdapter] No user query found in messages"); return; } @@ -1537,6 +1560,11 @@ export const remoteChatModelAdapter: ChatModelAdapter = { if (!isResume && !isNl2Agent && clarificationResponse) { requestBody.clarification_response = clarificationResponse; } + // Reply annotations travel as a structured request field; an + // annotation-only send keeps its empty query (see the guard above). + if (!isResume && !isNl2Agent && annotations.length) { + requestBody.annotations = annotations; + } const numericServerThreadId = Number(serverThreadId); const hasServerConversationId = Number.isInteger(numericServerThreadId) && numericServerThreadId > 0; @@ -1653,6 +1681,9 @@ export const remoteChatModelAdapter: ChatModelAdapter = { query: String(requestBody.query || ""), clarification_response: requestBody.clarification_response as ClarificationResponse | undefined, + annotations: requestBody.annotations as + | import("@/types/conversation").ApiMessageAnnotation[] + | undefined, history: (requestBody.history || []) as Array<{ role: string; content: string; diff --git a/frontend/app/[locale]/newchat/assistant-ui/annotation-chip.tsx b/frontend/app/[locale]/newchat/assistant-ui/annotation-chip.tsx new file mode 100644 index 0000000000..4490878c6f --- /dev/null +++ b/frontend/app/[locale]/newchat/assistant-ui/annotation-chip.tsx @@ -0,0 +1,294 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useCallback, useEffect, useRef, useState, type FC } from "react"; +import { useTranslation } from "react-i18next"; +import { + CheckIcon, + MessageSquareQuote, + PencilIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { + useAnnotationQuotePreview, + type MessageAnnotation, +} from "../utils/message-annotations"; + +/** Hover grace period before the popover closes. */ +const POPOVER_CLOSE_DELAY_MS = 150; + +/** + * Composer chip summarizing the pending message annotations. + * + * Renders nothing without annotations. Hovering (or focusing) the chip opens + * the annotation list where each entry can be edited or deleted; the trailing + * button clears every annotation at once. + */ +export const ComposerAnnotationChip: FC<{ + /** Pending annotations in creation order. */ + annotations: readonly MessageAnnotation[]; + /** Disables edit/delete/clear actions while a run is active. */ + disabled?: boolean; + /** Saves an edited comment. */ + onEditComment: (id: string, comment: string) => void; + /** Deletes a single annotation. */ + onRemove: (id: string) => void; + /** Deletes every pending annotation. */ + onClear: () => void; +}> = ({ annotations, disabled, onEditComment, onRemove, onClear }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(""); + const closeTimerRef = useRef(undefined); + + useEffect(() => () => window.clearTimeout(closeTimerRef.current), []); + + const openNow = useCallback(() => { + window.clearTimeout(closeTimerRef.current); + setOpen(true); + }, []); + + const scheduleClose = useCallback(() => { + // Keep the popover open while a comment is being edited so moving the + // pointer towards the input cannot discard the draft. + if (editingId) return; + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = window.setTimeout( + () => setOpen(false), + POPOVER_CLOSE_DELAY_MS + ); + }, [editingId]); + + const startEdit = useCallback((annotation: MessageAnnotation) => { + setEditingId(annotation.id); + setEditValue(annotation.comment ?? ""); + }, []); + + const commitEdit = useCallback(() => { + if (editingId) onEditComment(editingId, editValue.trim()); + setEditingId(null); + }, [editValue, editingId, onEditComment]); + + const cancelEdit = useCallback(() => setEditingId(null), []); + + if (annotations.length === 0) return null; + + return ( + { + setOpen(next); + if (!next) setEditingId(null); + }} + > + + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openNow(); + } + }} + className="inline-flex h-7 max-w-72 cursor-pointer items-center gap-1.5 rounded-md border border-[#e5e5e5] bg-[#f7f7f7] px-2 text-xs text-[#191919] dark:border-[#404040] dark:bg-[#2a2a2a] dark:text-[#e5e5e5]" + > + + + {t("chat.annotation.chipLabel", { count: annotations.length })} + + + + + +

+ {t("chat.annotation.listTitle")} +

+
    + {annotations.map((annotation, index) => ( + + ))} +
+
+
+ ); +}; + +const AnnotationListItem: FC<{ + index: number; + annotation: MessageAnnotation; + editing: boolean; + editValue: string; + disabled?: boolean; + onEditValueChange: (value: string) => void; + onEditStart: (annotation: MessageAnnotation) => void; + onEditCommit: () => void; + onEditCancel: () => void; + onRemove: (id: string) => void; +}> = ({ + index, + annotation, + editing, + editValue, + disabled, + onEditValueChange, + onEditStart, + onEditCommit, + onEditCancel, + onRemove, +}) => { + const { t } = useTranslation(); + const quotePreview = useAnnotationQuotePreview(annotation); + const editInputRef = useRef(null); + + useEffect(() => { + if (editing) editInputRef.current?.focus(); + }, [editing]); + + if (editing) { + return ( +
  • + + {index}. + + onEditValueChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onEditCommit(); + } else if (event.key === "Escape") { + event.preventDefault(); + onEditCancel(); + } + }} + className="h-7 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" + /> + + +
  • + ); + } + + return ( +
  • + + {index}. + +
    +

    + + {t("chat.annotation.selectedTextLabel")} + {t("chat.annotation.colon")} + + {quotePreview} +

    + {annotation.comment ? ( +

    + {t("chat.annotation.commentLabel")} + {t("chat.annotation.colon")} + {annotation.comment} +

    + ) : null} +
    + + + + +
  • + ); +}; diff --git a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx index c898cf7915..e53f66068e 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx @@ -2,7 +2,6 @@ "use client"; - import { useMemo, useState, @@ -38,6 +37,8 @@ import { ComposerPrimitive, useAui, useAuiState, + type CompleteAttachment, + type ThreadRuntime, } from "@assistant-ui/react"; import { LexicalComposerInput, @@ -79,6 +80,11 @@ import { skillDirectiveIconMap, } from "../ui/skill-directives"; import { ordinarySendError, protectOrdinarySend } from "../utils/ordinary-send"; +import { ComposerAnnotationChip } from "./annotation-chip"; +import { + toMessageAnnotationWireItems, + type MessageAnnotationsControl, +} from "../utils/message-annotations"; import { RuntimeMetadataEditor } from "@/components/chat/RuntimeMetadataEditor"; import { getConversationResourceLimitMessage } from "@/const/errorMessageI18n"; @@ -132,6 +138,23 @@ const KnowledgeBookIcon: FC<{ className?: string }> = ({ className }) => ( export type ChatMode = "planning" | "execution"; +/** + * Drops the annotation payload from the composer runConfig. + * + * The fallback send path (in-flight attachment uploads) parks the structured + * annotations on the composer runConfig because the library owns the append + * there. Once the run settles the payload must be cleared again so it cannot + * ride a later programmatic send that bypasses `prepareSend` (edit resends, + * clarification cards). + */ +function clearComposerMessageAnnotations(thread: ThreadRuntime) { + const config = thread.composer.getState().runConfig; + thread.composer.setRunConfig({ + ...config, + custom: { ...config.custom, messageAnnotations: undefined }, + }); +} + export interface ComposerProps { conversationId?: number; models: readonly ModelOption[]; @@ -161,6 +184,8 @@ export interface ComposerProps { workbenchResources?: import("@/features/workbench/types").WorkbenchResourceControls; onRemoveWorkbenchSkill?: (skillId: number) => void; onOpenWorkbenchSkillPicker?: () => void; + /** Pending reply annotations attached to the next outgoing message. */ + annotations?: MessageAnnotationsControl; } // Simple tooltip wrapper @@ -305,6 +330,7 @@ export const Composer: FC = ({ workbenchResources, onRemoveWorkbenchSkill, onOpenWorkbenchSkillPicker, + annotations, }) => { const conversationResources = !workbenchResources && @@ -314,15 +340,27 @@ export const Composer: FC = ({ const aui = useAui(); const runtime = aui.threads().__internal_getAssistantRuntime?.(); const [sendError, setSendError] = useState(""); - const prepareSend = () => { + const prepareSend = (event?: { preventDefault: () => void }) => { if (!runtime || runtime.thread.getState().isRunning) return; const threadId = runtime.threads.getState().mainThreadId; const thread = runtime.threads.getById(threadId); - const config = thread.composer.getState().runConfig; + const composerState = thread.composer.getState(); + const config = composerState.runConfig; setSendError(""); + const pendingAnnotations = annotations?.annotations ?? []; const feedback = protectOrdinarySend(thread, { restoreDraft: true, + // Annotations attach to exactly one outgoing message: consume them + // once the run accepts them. A rejected send keeps them (together + // with the restored draft) so the user can retry. + onStarted: pendingAnnotations.length + ? () => { + annotations?.consumeAnnotations(); + clearComposerMessageAnnotations(thread); + } + : undefined, onRejected: (error) => { + clearComposerMessageAnnotations(thread); if (runtime.threads.getState().mainThreadId === threadId) { setSendError( ordinarySendError(error, i18n.language, (value) => @@ -332,14 +370,77 @@ export const Composer: FC = ({ } }, }); - thread.composer.setRunConfig({ - ...config, - custom: { - ...config.custom, - ...feedback, - modelPreferenceScope: modelParameterScope, + if (!pendingAnnotations.length) { + // Ordinary send: keep the library-managed composer submit untouched. + // The explicit undefined drops any annotation payload left on the + // composer runConfig by a previous fallback send. + thread.composer.setRunConfig({ + ...config, + custom: { + ...config.custom, + ...feedback, + modelPreferenceScope: modelParameterScope, + messageAnnotations: undefined, + }, + }); + return; + } + + // Reply annotations travel as a structured field alongside the clean + // composer text; the backend persists them as a unit and merges them + // into the model-facing text at request time. + const annotationItems = toMessageAnnotationWireItems(pendingAnnotations); + const attachmentsReady = composerState.attachments.every( + (attachment) => attachment.status.type === "complete" + ); + if (!attachmentsReady) { + // In-flight uploads must go through the library submission flow, + // which waits for uploads before appending; ride the annotations via + // the runConfig channel there (the optimistic bubble shows no cards + // until reload). + thread.composer.setRunConfig({ + ...config, + custom: { + ...config.custom, + ...feedback, + modelPreferenceScope: modelParameterScope, + messageAnnotations: annotationItems, + }, + }); + return; + } + + // Take over the append so the optimistic message carries the structured + // annotation metadata (rendered as cards on the user bubble). Preventing + // the default keeps the library from appending a second message; the + // composer draft is cleared manually to match the library send behavior. + event?.preventDefault(); + if (composerState.dictation) { + thread.composer.stopDictation(); + } + const completeAttachments = composerState.attachments.filter( + (attachment): attachment is CompleteAttachment => + attachment.status.type === "complete" + ); + thread.append({ + role: "user", + content: composerState.text + ? [{ type: "text", text: composerState.text }] + : [], + attachments: completeAttachments, + metadata: { custom: { annotations: annotationItems } }, + runConfig: { + ...config, + custom: { + ...config.custom, + ...feedback, + modelPreferenceScope: modelParameterScope, + messageAnnotations: annotationItems, + }, }, }); + thread.composer.setText(""); + void thread.composer.clearAttachments(); }; const [skillPickerOpen, setSkillPickerOpen] = useState(false); const [conversationSkills, setConversationSkills] = useState< @@ -610,7 +711,7 @@ export const Composer: FC = ({ className="flex w-full flex-col gap-2.5 outline-none" onSubmit={(event) => { if (isRunning) event.preventDefault(); - else prepareSend(); + else prepareSend(event); }} onKeyDownCapture={(event) => { if (isRunning && event.key === "Enter" && !event.shiftKey) @@ -624,6 +725,15 @@ export const Composer: FC = ({ }} > {hasAttachments && } + + annotations?.updateAnnotationComment(id, comment) + } + onRemove={(id) => annotations?.removeAnnotation(id)} + onClear={() => annotations?.clearAnnotations()} + /> {conversationResources && (selectedSkillNames.length > 0 || (canSelectKnowledge && @@ -822,6 +932,7 @@ export const Composer: FC = ({ 0} /> @@ -937,9 +1048,10 @@ const WorkbenchPlanningMenu: FC<{ // Trigger can use `asChild` against the Button. `AuiIf` toggles between the // two branches declaratively based on `thread.isRunning`. const ComposerSendOrCancel: FC<{ - onSend: () => void; + onSend: (event?: { preventDefault: () => void }) => void; disabled?: boolean; -}> = ({ onSend, disabled }) => { + hasAnnotations?: boolean; +}> = ({ onSend, disabled, hasAnnotations = false }) => { const { t } = useTranslation(); const hasText = useAuiState((state) => state.composer.text.trim().length > 0); @@ -976,7 +1088,7 @@ const ComposerSendOrCancel: FC<{ + ) : ( + <> + + setPhase((current) => + current.kind === "composing" + ? { ...current, comment: event.target.value } + : current + ) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitComposing(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelComposing(); + } + }} + aria-label={t("chat.annotation.commentPlaceholder")} + placeholder={t("chat.annotation.commentPlaceholder")} + className="h-8 min-w-0 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground" + /> + + + + )} + , + document.body + ); +}; diff --git a/frontend/app/[locale]/newchat/assistant-ui/message-annotation-cards.tsx b/frontend/app/[locale]/newchat/assistant-ui/message-annotation-cards.tsx new file mode 100644 index 0000000000..399a578ef4 --- /dev/null +++ b/frontend/app/[locale]/newchat/assistant-ui/message-annotation-cards.tsx @@ -0,0 +1,118 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { memo, useMemo, useState, type ComponentProps, type FC } from "react"; +import { useTranslation } from "react-i18next"; +import { useAuiState } from "@assistant-ui/react"; +import { ChevronDown, MessageSquareQuote } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + fromMessageAnnotationWireItems, + type MessageAnnotationWireItem, +} from "../utils/message-annotations"; + +/** + * Compact annotation badge rendered above a user message bubble. + * + * Collapsed it shows a "N annotations" pill (Codex-style); expanding it + * reveals one card per attached reply annotation: the quoted excerpt, an + * optional comment line and the source reply excerpt. Data comes from the + * message's structured `metadata.custom.annotations` (set by the optimistic + * append on send and restored from the persisted history unit on reload), so + * the badge survives page refreshes just like the model-facing context does. + */ +export const MessageAnnotationCards: FC< + { + /** Overrides the message metadata (used by unit tests). */ + annotations?: readonly unknown[]; + } & ComponentProps<"div"> +> = memo(function MessageAnnotationCards({ + annotations: annotationsProp, + className, + ...props +}) { + const [expanded, setExpanded] = useState(false); + const { t } = useTranslation(); + const rawAnnotations = useAuiState((state) => + state.message?.role === "user" + ? ( + state.message.metadata as + { custom?: { annotations?: unknown[] } } | undefined + )?.custom?.annotations + : undefined, + ); + const items = useMemo( + () => fromMessageAnnotationWireItems(annotationsProp ?? rawAnnotations), + [annotationsProp, rawAnnotations], + ); + if (items.length === 0) return null; + + return ( +
    + + {expanded ? ( +
    + {items.map((item) => ( + + ))} +
    + ) : null} +
    + ); +}); + +const AnnotationCard: FC<{ item: MessageAnnotationWireItem }> = ({ item }) => { + const { t } = useTranslation(); + return ( +
    + +
    +

    {item.quoted_text}

    + {item.comment ? ( +

    + {t("chat.annotation.commentLabel")} + {t("chat.annotation.colon")} + {item.comment} +

    + ) : null} + {item.source_excerpt ? ( +

    + {t("chat.annotation.cardSourceLabel", { + excerpt: item.source_excerpt, + })} +

    + ) : null} +
    +
    + ); +}; diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 116027cff6..53e9746542 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -61,6 +61,38 @@ import { } from "@assistant-ui/react"; import { Sources } from "../ui/sources"; import { SourcesPanel } from "../ui/sources-panel"; +import { + ConversationArtifactsToolbarEntry, + ConversationArtifactsPanel, +} from "../ui/artifacts-panel"; +import { ConversationArtifactsEntryProvider } from "../ui/conversation-artifacts-context"; +import { QuestionDirectoryRail } from "../ui/question-directory-rail"; +import { useAutoArtifactPreview } from "../components/use-auto-artifact-preview"; +import { ArtifactDocumentPanel } from "../components/artifact-document-panel"; +import { markFileWithExistingUpload } from "../adapter/attachment-adapter"; +import { + fetchArtifactFile, + useConversationArtifacts, + type ArtifactAttachmentLike, + type ConversationArtifact, +} from "../utils/conversation-artifacts"; +import { MessageAnnotationBubble } from "./message-annotation-bubble"; +import { MessageAnnotationCards } from "./message-annotation-cards"; +import { ViewportScrollRescue } from "./viewport-scroll-rescue"; +import { + createMessageAnnotation, + MESSAGE_ANNOTATION_COMMENT_MAX, + MESSAGE_ANNOTATION_MAX_COUNT, + type MessageAnnotation, + type MessageAnnotationDraft, + type MessageAnnotationsControl, +} from "../utils/message-annotations"; +import { + scrollThreadToMessage, + useActiveQuestionId, + useQuestionDirectory, + type QuestionDirectoryEntry, +} from "../utils/question-directory"; import { SourcesPanelProvider, useSourcesPanel, @@ -85,6 +117,7 @@ import { XIcon, } from "lucide-react"; import { message } from "antd"; +import log from "@/lib/logger"; import type { Agent, PublishedAgent } from "@/types/agentConfig"; import { getConversationResourceLimitMessage } from "@/const/errorMessageI18n"; import { getAgentIcon } from "@/lib/chat/agentIconUtils"; @@ -181,6 +214,10 @@ import { useConversationShare } from "./use-conversation-share"; import { ConversationShareContext } from "./conversation-share-context"; import { ConversationShareFooter } from "./conversation-share-footer"; import { ConversationShareMenuItem } from "./conversation-share-menu-item"; +import { ConversationBranchContext } from "./conversation-branch-context"; +import { ConversationBranchMenuItem } from "./conversation-branch-menu-item"; +import { computeBranchTurnIndex } from "../utils/conversation-branch"; +import { conversationService } from "@/services/conversationService"; import type { ConversationKnowledgeScope, KnowledgeCapabilities, @@ -328,6 +365,15 @@ export const Thread: FC = ({ }; const messages = useAuiState((s) => s.thread.messages); + // Conversation-wide agent-generated files feed the trailing "view all + // artifacts" entry on each assistant message and the side panel it opens. + // Computed here so the entry and the panel share one aggregation pass over + // the thread messages. + const conversationArtifacts = useConversationArtifacts(messages); + // Conversation-wide user questions feed the question directory entry at the + // top of the thread. Same single-pass pattern as the artifacts aggregation. + const questionDirectory = useQuestionDirectory(messages); + const [artifactsOpen, setArtifactsOpen] = useState(false); const currentThreadTitle = useAuiState((s) => { const currentThread = s.threads.threadItems.find( (item) => item.id === s.threads.mainThreadId @@ -365,6 +411,145 @@ export const Thread: FC = ({ readOnly, }); + const artifactPreview = useAutoArtifactPreview({ + scopeKey: threadId, + artifacts: conversationArtifacts, + isRunning, + isLoading, + enabled: !readOnly && !isShareMode && !configurationLayout && !debugLayout, + }); + const openArtifactsPanel = useCallback(() => { + artifactPreview.close(); + setArtifactsOpen(true); + }, [artifactPreview.close]); + + const aui = useAui(); + const [isBranching, setIsBranching] = useState(false); + const branchDisabled = + !conversationId || readOnly || isRunning || isLoading || isBranching; + + // Branch the conversation into a new chat that keeps every turn up to and + // including the one the selected assistant message answers, then switch to + // the branched thread. The original conversation stays untouched. + const branchFromMessage = useCallback( + (assistantMessageId: string, answerIndex: number) => { + if (!conversationId || branchDisabled) return; + const turnIndex = computeBranchTurnIndex(messages, assistantMessageId); + if (turnIndex === null) { + message.error(t("chat.thread.branchConversationFailed")); + return; + } + setIsBranching(true); + conversationService + .branch(conversationId, turnIndex, assistantMessageId, answerIndex) + .then(async (data) => { + const newConversationId = Number(data?.conversation_id); + if (!Number.isInteger(newConversationId) || newConversationId <= 0) { + throw new Error("Invalid branch response"); + } + await aui.threads.switchToThread(String(newConversationId)); + await aui.threads.reload(); + message.success(t("chat.thread.branchConversationSuccess")); + }) + .catch((error) => { + log.error("[Thread] Failed to branch conversation:", error); + message.error(t("chat.thread.branchConversationFailed")); + }) + .finally(() => setIsBranching(false)); + }, + [aui, branchDisabled, conversationId, messages, t] + ); + + const branchContextValue = useMemo( + () => ({ disabled: branchDisabled, branchFromMessage }), + [branchDisabled, branchFromMessage] + ); + + // Reply annotations ("add to chat") attach to the next outgoing message. + // State lives at the Thread level so the selection bubble, the composer + // chip and the send flow share one source of truth. The source excerpt is + // captured when the annotation is created so it keeps referencing the reply + // content the user saw when selecting. + const [messageAnnotations, setMessageAnnotations] = useState< + MessageAnnotation[] + >([]); + // Pending annotations belong to the active thread: switching threads resets + // them during render (the same keyed-state pattern the composer uses for + // the knowledge modal) so they never leak across conversations. + const [annotationThreadId, setAnnotationThreadId] = useState(threadId); + if (annotationThreadId !== threadId) { + setAnnotationThreadId(threadId); + setMessageAnnotations([]); + } + + const addMessageAnnotation = useCallback( + (draft: MessageAnnotationDraft) => { + setMessageAnnotations((current) => { + // Cap matches the backend limit; the excess annotation would be + // silently dropped by the request-time normalization anyway. + if (current.length >= MESSAGE_ANNOTATION_MAX_COUNT) { + message.warning( + t("chat.annotation.maxCountReached", { + maxCount: MESSAGE_ANNOTATION_MAX_COUNT, + }) + ); + return current; + } + return [ + ...current, + createMessageAnnotation(draft, messages, crypto.randomUUID()), + ]; + }); + }, + // `message` is a module-level antd import, not a reactive value. + [messages, t] + ); + const updateMessageAnnotationComment = useCallback( + (id: string, comment: string) => { + setMessageAnnotations((current) => + current.map((annotation) => + annotation.id === id + ? { + ...annotation, + comment: + comment.trim().slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) || + undefined, + } + : annotation + ) + ); + }, + [] + ); + const removeMessageAnnotation = useCallback((id: string) => { + setMessageAnnotations((current) => + current.filter((annotation) => annotation.id !== id) + ); + }, []); + const clearMessageAnnotations = useCallback(() => { + setMessageAnnotations([]); + }, []); + + const messageAnnotationsControl = useMemo( + () => ({ + annotations: messageAnnotations, + addAnnotation: addMessageAnnotation, + updateAnnotationComment: updateMessageAnnotationComment, + removeAnnotation: removeMessageAnnotation, + clearAnnotations: clearMessageAnnotations, + // Consumed annotations attach to exactly one outgoing message; the + // composer invokes this once the run has accepted them. + consumeAnnotations: clearMessageAnnotations, + }), + [ + messageAnnotations, + addMessageAnnotation, + updateMessageAnnotationComment, + removeMessageAnnotation, + clearMessageAnnotations, + ] + ); + // Sources panel state lives at the Thread level so the right-hand panel and // each `group-source` button share a single source of truth. The selection // carries the snapshot of sources/images for the group that opened it, @@ -379,120 +564,134 @@ export const Thread: FC = ({ - - void enterShareMode()} - onLeaveShareMode={leaveShareMode} - onToggleShareAll={toggleShareAll} - onToggleShareMessage={toggleShareMessage} - onCreateShare={createShare} - selection={selection} - onPanelClose={close} - /> - !open && setManualShareUrl(null)} - > - - - - {t("chatInterface.shareLinkReady", "分享链接已生成")} - - - {t( - "chatInterface.shareCreatedCopyFailed", - "分享链接已创建,但当前环境无法自动复制" - )} - - - event.currentTarget.select()} - className="w-full rounded-md border bg-muted/30 px-3 py-2 text-sm" + + + + void enterShareMode()} + onLeaveShareMode={leaveShareMode} + onToggleShareAll={toggleShareAll} + onToggleShareMessage={toggleShareMessage} + onCreateShare={createShare} + selection={selection} + onPanelClose={close} + conversationArtifacts={conversationArtifacts} + artifactPreview={artifactPreview} + artifactsOpen={artifactsOpen} + onArtifactsOpenChange={setArtifactsOpen} + questionDirectory={questionDirectory} + messageAnnotations={messageAnnotationsControl} /> - - - - - - - + + !open && setManualShareUrl(null)} + > + + + + {t("chatInterface.shareLinkReady", "分享链接已生成")} + + + {t( + "chatInterface.shareCreatedCopyFailed", + "分享链接已创建,但当前环境无法自动复制" + )} + + + event.currentTarget.select()} + className="w-full rounded-md border bg-muted/30 px-3 py-2 text-sm" + /> + + + + + + + + ); }; @@ -536,6 +735,12 @@ interface ThreadViewProps { onCreateShare: () => void; selection: SourcesPanelSelection | null; onPanelClose: () => void; + conversationArtifacts: ConversationArtifact[]; + artifactPreview: ReturnType; + artifactsOpen: boolean; + onArtifactsOpenChange: (open: boolean) => void; + questionDirectory: QuestionDirectoryEntry[]; + messageAnnotations: MessageAnnotationsControl; variant: "default" | "embedded"; skillFiles?: readonly SkillFileContent[]; onSkillFileSelect?: (path: string) => void; @@ -592,6 +797,12 @@ const ThreadView: FC = ({ onCreateShare, selection, onPanelClose, + conversationArtifacts, + artifactPreview, + artifactsOpen, + onArtifactsOpenChange, + questionDirectory, + messageAnnotations, variant, skillFiles, onSkillFileSelect, @@ -615,12 +826,80 @@ const ThreadView: FC = ({ const workbenchLanding = Boolean( workbenchPresentation && !hasMessages && !isShareMode ); + // Reply annotations only make sense where the composer is live. They stay + // disabled for read-only threads, share mode, skill-file chats and the + // preview layouts, which either block sending or render no real replies. + const annotationsEnabled = Boolean( + showComposer && + !isShareMode && + !readOnly && + !skillFiles && + !configurationLayout && + !debugLayout + ); const aui = useAui(); const suggestions = deriveWelcomeSuggestions( welcomeSuggestions, agent.example_questions ); + // "Add to chat": re-attach an agent-generated artifact into the composer so + // the next outgoing message references the already-stored object instead of + // uploading its bytes a second time. + const handleArtifactToComposer = useCallback( + async (attachment: ArtifactAttachmentLike) => { + const name = attachment.name || "Generated file"; + try { + const file = await fetchArtifactFile(attachment); + const objectName = attachment.object_name ?? ""; + // Artifacts with a known MinIO object carry their storage metadata so + // the attachment adapter skips the redundant upload; the rest fall + // back to the regular upload path. + const enriched = objectName + ? markFileWithExistingUpload(file, { + object_name: objectName, + url: + attachment.preview_url ?? + attachment.presigned_url ?? + attachment.url ?? + "", + presigned_url: attachment.presigned_url, + type: attachment.contentType ?? file.type, + size: attachment.size ?? file.size, + }) + : file; + const runtime = aui.threads().__internal_getAssistantRuntime?.(); + const threadId = runtime?.threads.getState().mainThreadId; + const thread = threadId + ? runtime?.threads.getById(threadId) + : undefined; + if (!thread) { + log.error("[Artifacts] Composer runtime unavailable"); + message.error(t("chat.artifacts.addToChatFailed")); + return; + } + await thread.composer.addAttachment(enriched); + message.success(t("chat.artifacts.addedToChat", { name })); + } catch (error) { + log.error("Failed to add conversation artifact to composer:", error); + message.error(t("chat.artifacts.addToChatFailed")); + } + }, + [aui, t] + ); + + // "Question directory": jump the viewport to the message with the clicked + // question. Navigation is safe in read-only and while a run is streaming. + const handleJumpToQuestion = useCallback((messageId: string) => { + if (!scrollThreadToMessage(messageId)) { + log.warn("[QuestionDirectory] Jump target not found:", messageId); + } + }, []); + + // Question the viewport is currently scrolled to; highlights the matching + // directory row so long conversations show "where am I" at a glance. + const activeQuestionId = useActiveQuestionId(questionDirectory); + return ( = ({ >
    0 && "pl-8", newChatDesign && "relative [container-type:size]", workbenchLanding && "overflow-y-auto pb-[10vh] [container-type:size]" )} @@ -704,6 +984,13 @@ const ThreadView: FC = ({ )} + + + = ({ "relative mt-[max(24px,calc(50cqh-220px))] flex-none max-w-[890px] overflow-visible px-4 pb-0 pt-6 sm:px-8" )} > + {/* Re-implements the auto-scroll triggers that the legacy runtime + fails to deliver (run start / history load / streaming follow). */} + {workbenchLanding && (
    = ({ disabled={readOnly} modelSelectionDisabled={modelSelectionDisabled} disabledReason={readOnlyReason} + annotations={annotationsEnabled ? messageAnnotations : undefined} workbenchPresentation={workbenchPresentation} workbenchResources={workbenchResources} onRemoveWorkbenchSkill={onRemoveWorkbenchSkill} @@ -842,12 +1133,46 @@ const ThreadView: FC = ({ messageId={selection?.messageId} sources={selection?.sources ?? []} images={selection?.images ?? []} - open={selection !== null} + open={selection !== null && !artifactPreview.selected} selectedCitationKey={selection?.selectedCitationKey} citationContext={selection?.citationContext} selectionToken={selection ?? undefined} onClose={onPanelClose} /> + + onArtifactsOpenChange(false)} + onAddToComposer={ + showComposer && !isShareMode && !readOnly + ? handleArtifactToComposer + : undefined + } + /> + + {/* The document surface occupies a sibling column without covering desktop chat. */} + {artifactPreview.selected && ( + { + artifactPreview.close(); + onArtifactsOpenChange(true); + }} + /> + )} + + {/* Mounted only while quotable so the bubble state resets whenever the + thread turns read-only, enters share mode, or switches to a preview + layout. */} + {annotationsEnabled ? ( + + ) : null} ); }; @@ -860,6 +1185,16 @@ export const ReadOnlyConversation: FC<{ const [selection, setSelection] = useState( null ); + const messages = useAuiState((s) => s.thread.messages); + const questionDirectory = useQuestionDirectory(messages); + // "Where am I" tracking for the read-only share view as well; the scroll + // listener is cheap and keeps the directory consistent across surfaces. + const activeQuestionId = useActiveQuestionId(questionDirectory); + const handleJumpToQuestion = useCallback((messageId: string) => { + if (!scrollThreadToMessage(messageId)) { + log.warn("[QuestionDirectory] Jump target not found:", messageId); + } + }, []); const open = useCallback( (payload: SourcesPanelSelection) => setSelection(payload), [] @@ -887,13 +1222,25 @@ export const ReadOnlyConversation: FC<{ newChatStyles.font )} > -
    -
    -

    {title}

    -

    - {t("chatInterface.shareReadOnly", "分享对话仅可查看")} -

    +
    0 && "pl-8" + )} + > +
    +
    +

    {title}

    +

    + {t("chatInterface.shareReadOnly", "分享对话仅可查看")} +

    +
    + @@ -1677,6 +2024,7 @@ const AssistantActionBar: FC = () => { className="aui-action-bar-more-content bg-popover/95 text-popover-foreground data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:animate-out data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-xl border p-1.5 shadow-lg backdrop-blur-sm" > + @@ -1709,6 +2057,9 @@ const UserMessage: FC<{
    + {/* Reply annotations ("add to chat") render as a compact badge + above the message text; data rides the structured metadata. */} + {(quote) => } diff --git a/frontend/app/[locale]/newchat/assistant-ui/viewport-scroll-rescue.tsx b/frontend/app/[locale]/newchat/assistant-ui/viewport-scroll-rescue.tsx new file mode 100644 index 0000000000..a62bd1fe78 --- /dev/null +++ b/frontend/app/[locale]/newchat/assistant-ui/viewport-scroll-rescue.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useLayoutEffect, useRef, type FC } from "react"; +import { useAuiState, useThreadViewportStore } from "@assistant-ui/react"; + +// Avoids the useLayoutEffect SSR warning when this tree renders on the +// server; mirrors the library's own useIsomorphicLayoutEffect pattern. +const useIsomorphicLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + +/** + * Restores chat auto-scroll for the thread viewport. + * + * assistant-ui 0.15.x drives viewport auto-scroll from runtime events + * ("thread.runStart", "threads.selectionChanged") and resets its internal + * follow flag only through those events. Under the legacy remote thread list + * runtime the events never reach the viewport controller, so once the user + * scrolls away (for example to reach the "branch" action on an earlier + * message) the viewport stops following the conversation entirely: sent + * messages and streamed replies stay below the fold. + * + * The triggers are rebuilt from plain state subscriptions, which work under + * every runtime: + * 1. A run starts -> scroll to the bottom once (mirrors + * scrollToBottomOnRunStart) so the new reply becomes visible. + * 2. History finishes loading -> land at the bottom of the restored + * conversation (mirrors scrollToBottomOnThreadSwitch + initialize). + * 3. While a run streams and the user is at the bottom -> keep following on + * every message update. The store's isAtBottom is driven by real scroll + * events, so scrolling away stops the follow and scrolling back resumes. + */ +export const ViewportScrollRescue: FC = () => { + const viewport = useThreadViewportStore(); + const isRunning = useAuiState((s) => s.thread.isRunning); + const isLoading = useAuiState((s) => s.thread.isLoading); + const hasMessages = useAuiState((s) => s.thread.messages.length > 0); + // `messages` identity changes on every streamed token, which provides the + // follow cadence while content grows. + const messages = useAuiState((s) => s.thread.messages); + + // The bottom is "sticky" from the moment a run starts: the first streamed + // updates can arrive before the run-start scroll event has refreshed the + // store's isAtBottom. + const followRef = useRef(true); + + useEffect(() => { + // The user leaving the bottom cancels the sticky follow. + return viewport.subscribe((state, prev) => { + if (prev.isAtBottom && !state.isAtBottom) followRef.current = false; + }); + }, [viewport]); + + // 1. Run start: jump to the bottom so the reply is visible. + const prevRunningRef = useRef(false); + useIsomorphicLayoutEffect(() => { + const wasRunning = prevRunningRef.current; + prevRunningRef.current = isRunning; + if (!isRunning || wasRunning) return; + followRef.current = true; + viewport.getState().scrollToBottom({ behavior: "instant" }); + }, [isRunning, viewport]); + + // 2. History load: land at the bottom of the restored conversation. + const prevLoadingRef = useRef(false); + useIsomorphicLayoutEffect(() => { + const wasLoading = prevLoadingRef.current; + prevLoadingRef.current = isLoading; + if (!wasLoading || isLoading || !hasMessages) return; + viewport.getState().scrollToBottom({ behavior: "instant" }); + }, [isLoading, hasMessages, viewport]); + + // 3. Streaming follow: track each message update while the viewport stays + // at the bottom. The messages-identity guard keeps this effect inert when + // only the running flag changed (the run-start scroll already fired), so a + // run start scrolls exactly once. + const prevMessagesRef = useRef(messages); + useIsomorphicLayoutEffect(() => { + const prevMessages = prevMessagesRef.current; + prevMessagesRef.current = messages; + if (!isRunning) return; + if (prevMessages === messages) return; + if (!followRef.current && !viewport.getState().isAtBottom) return; + viewport.getState().scrollToBottom({ behavior: "instant" }); + }, [messages, isRunning, viewport]); + + return null; +}; diff --git a/frontend/app/[locale]/newchat/components/artifact-document-panel.tsx b/frontend/app/[locale]/newchat/components/artifact-document-panel.tsx new file mode 100644 index 0000000000..dd37da7771 --- /dev/null +++ b/frontend/app/[locale]/newchat/components/artifact-document-panel.tsx @@ -0,0 +1,115 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useTranslation } from "react-i18next"; +import { Download, FolderOutput, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { FilePreviewDrawer } from "@/components/common/filePreviewDrawer"; +import { API_ENDPOINTS } from "@/services/api"; +import { storageService } from "@/services/storageService"; +import { getFileExtension } from "@/lib/officePreview"; +import { triggerUrlDownload } from "../ui/attachment"; +import type { + ArtifactAttachmentLike, + ConversationArtifact, +} from "../utils/conversation-artifacts"; +import { artifactPreviewIdentity } from "./use-auto-artifact-preview"; + +/** Original OOXML bytes use the existing browser renderer, never the PDF renderer. */ +export function artifactDocumentSource(attachment: ArtifactAttachmentLike) { + const name = attachment.name || "Generated file"; + const extension = getFileExtension(name); + const rawStream = ["docx", "xlsx", "pptx"].includes(extension); + const part = attachment.content?.[0] as { data?: string } | undefined; + const direct = + attachment.download_url || + attachment.presigned_url || + attachment.url || + part?.data || + attachment.preview_url || + ""; + const stream = attachment.object_name + ? API_ENDPOINTS.storage.file(attachment.object_name, "stream", name) + : direct; + const previewUrl = rawStream + ? stream + : attachment.object_name + ? storageService.getPreviewUrl(attachment.object_name, name) + : attachment.preview_url || direct; + return { previewUrl, downloadUrl: stream, rawStream }; +} + +interface ArtifactDocumentPanelProps { + artifact: ConversationArtifact; + onClose: () => void; + onShowArtifacts: () => void; +} + +export function ArtifactDocumentPanel({ + artifact, + onClose, + onShowArtifacts, +}: ArtifactDocumentPanelProps) { + const { t } = useTranslation(); + const attachment = artifact.attachment as ArtifactAttachmentLike; + const source = artifactDocumentSource(attachment); + const name = attachment.name || "Generated file"; + return ( + + ); +} diff --git a/frontend/app/[locale]/newchat/components/clarification-message-card.tsx b/frontend/app/[locale]/newchat/components/clarification-message-card.tsx index eeb3f4439d..51a0a909e2 100644 --- a/frontend/app/[locale]/newchat/components/clarification-message-card.tsx +++ b/frontend/app/[locale]/newchat/components/clarification-message-card.tsx @@ -111,7 +111,16 @@ export function ClarificationMessageCard({ }, }, }, - runConfig: { ...config, custom: { ...config.custom, ...feedback } }, + runConfig: { + ...config, + custom: { + ...config.custom, + ...feedback, + // Clarification answers are their own message: reply annotations + // stay pending on the composer and must not ride this run. + messageAnnotations: undefined, + }, + }, }); }); }; diff --git a/frontend/app/[locale]/newchat/components/use-auto-artifact-preview.ts b/frontend/app/[locale]/newchat/components/use-auto-artifact-preview.ts new file mode 100644 index 0000000000..a552d82673 --- /dev/null +++ b/frontend/app/[locale]/newchat/components/use-auto-artifact-preview.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + ArtifactAttachmentLike, + ConversationArtifact, +} from "../utils/conversation-artifacts"; + +const DOCUMENT_EXTENSIONS = new Set([ + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", + "pdf", + "txt", + "md", + "markdown", + "csv", + "tsv", + "html", + "htm", +]); +const OFFICE_EXTENSIONS = new Set([ + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", +]); + +export function artifactPreviewIdentity( + artifact: ConversationArtifact +): string { + return ( + (artifact.attachment as ArtifactAttachmentLike).object_name || artifact.key + ); +} + +function documentPriority(artifact: ConversationArtifact): number { + const attachment = artifact.attachment as ArtifactAttachmentLike; + const extension = attachment.name?.split(".").pop()?.toLowerCase() ?? ""; + const part = attachment.content?.[0] as { data?: string } | undefined; + if ( + !DOCUMENT_EXTENSIONS.has(extension) || + !( + attachment.object_name || + attachment.preview_url || + attachment.download_url || + attachment.presigned_url || + attachment.url || + part?.data + ) + ) + return 0; + return OFFICE_EXTENSIONS.has(extension) ? 3 : extension === "pdf" ? 2 : 1; +} + +interface AutoArtifactPreviewOptions { + scopeKey: string; + artifacts: readonly ConversationArtifact[]; + isRunning: boolean; + isLoading: boolean; + enabled: boolean; +} + +/** Observe live output publication without treating restored history as a new run. */ +export function useAutoArtifactPreview({ + scopeKey, + artifacts, + isRunning, + isLoading, + enabled, +}: AutoArtifactPreviewOptions) { + const [selection, setSelection] = useState<{ + scope: string; + identity: string; + } | null>(null); + const tracking = useRef({ + scope: scopeKey, + ready: false, + running: false, + observedRun: false, + dismissed: false, + priority: 0, + seen: new Set(), + pending: null as ConversationArtifact | null, + }); + + useEffect(() => { + if (tracking.current.scope !== scopeKey || isLoading) { + tracking.current = { + scope: scopeKey, + ready: false, + running: false, + observedRun: false, + dismissed: false, + priority: 0, + seen: new Set(), + pending: null, + }; + // Loading or switching the external runtime invalidates the old selection. + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelection(null); + } + const state = tracking.current; + if (isLoading) return; + if (!state.ready) { + state.ready = true; + state.seen = new Set(artifacts.map(artifactPreviewIdentity)); + } + if (isRunning && !state.running) { + state.observedRun = true; + state.dismissed = false; + state.pending = null; + state.priority = 0; + } + state.running = isRunning; + for (const artifact of artifacts) { + const identity = artifactPreviewIdentity(artifact); + if (state.seen.has(identity)) continue; + if (documentPriority(artifact) === 0) continue; + state.seen.add(identity); + if ( + enabled && + state.observedRun && + documentPriority(artifact) > 0 && + documentPriority(artifact) >= state.priority + ) { + state.pending = artifact; + state.priority = documentPriority(artifact); + } + } + if (enabled && !isRunning && !state.dismissed && state.pending) { + const identity = artifactPreviewIdentity(state.pending); + state.pending = null; + // Live run completion is an external runtime event, not derived render state. + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelection({ scope: scopeKey, identity }); + } + }, [scopeKey, artifacts, isRunning, isLoading, enabled]); + + const open = useCallback( + (artifact: ConversationArtifact) => { + setSelection({ + scope: scopeKey, + identity: artifactPreviewIdentity(artifact), + }); + }, + [scopeKey] + ); + const close = useCallback(() => { + tracking.current.dismissed = true; + tracking.current.pending = null; + setSelection(null); + }, []); + + return { + selected: + !isLoading && selection?.scope === scopeKey + ? (artifacts.find( + (artifact) => + artifactPreviewIdentity(artifact) === selection.identity + ) ?? null) + : null, + open, + close, + }; +} diff --git a/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx b/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx new file mode 100644 index 0000000000..bfb1b2c9e0 --- /dev/null +++ b/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx @@ -0,0 +1,337 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useRef, useState, type FC } from "react"; +import { + DownloadIcon, + EyeIcon, + FolderOutputIcon, + ImageIcon, + MessageSquarePlusIcon, + XIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { TooltipIconButton } from "./tooltip-icon-button"; +import { FilePreviewDrawer } from "@/components/common/filePreviewDrawer"; +import { storageService } from "@/services/storageService"; +import { cn } from "@/lib/utils"; +import log from "@/lib/logger"; +import { useTranslation } from "react-i18next"; +import { getFileIcon, triggerUrlDownload } from "./attachment"; +import { useConversationArtifactsEntry } from "./conversation-artifacts-context"; +import type { + ConversationArtifact, + ArtifactAttachmentLike, +} from "../utils/conversation-artifacts"; + +export interface ConversationArtifactsPanelProps { + /** Whether the panel is currently mounted. */ + open: boolean; + /** All agent-generated files collected across the conversation. */ + artifacts: ConversationArtifact[]; + onClose: () => void; + /** + * Adds an artifact back into the composer as an attachment ("add to chat"). + * When omitted the per-entry action is hidden, e.g. in read-only or shared + * conversations where there is no composer to add to. + */ + onAddToComposer?: ( + attachment: ArtifactAttachmentLike + ) => Promise | void; + className?: string; + /** Route previews into the conversation's document surface when provided. */ + onPreview?: (artifact: ConversationArtifact) => void; +} + +/** Formats an artifact creation time without importing a date library. */ +const formatGeneratedAt = (value: Date | undefined): string => { + if (!value) return ""; + return new Intl.DateTimeFormat(undefined, { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(value); +}; + +/** + * Side panel listing every file the agent generated during the conversation. + * Mirrors the SourcesPanel layout (right-hand column, header + scrollable + * list) so the two panels read as one family. Each entry supports preview + * through the shared {@link FilePreviewDrawer}, download, and re-attaching + * the file to the composer when {@link ConversationArtifactsPanelProps.onAddToComposer} + * is provided. + */ +export const ConversationArtifactsPanel: FC< + ConversationArtifactsPanelProps +> = ({ open, artifacts, onClose, onAddToComposer, className, onPreview }) => { + const { t } = useTranslation(); + const [previewing, setPreviewing] = useState( + null + ); + // Serialize additions across entries. The ref also blocks clicks before React flushes state. + const [addingKey, setAddingKey] = useState(null); + const addingRef = useRef(false); + + if (!open) return null; + + const handleAddToComposer = async ( + key: string, + attachment: ArtifactAttachmentLike + ) => { + if (!onAddToComposer || addingRef.current) return; + addingRef.current = true; + setAddingKey(key); + try { + await onAddToComposer(attachment); + } finally { + addingRef.current = false; + setAddingKey(null); + } + }; + + const handleDownload = async (attachment: ArtifactAttachmentLike) => { + const name = attachment.name || "Generated file"; + try { + if (attachment.download_url) { + triggerUrlDownload(attachment.download_url, name); + } else if (attachment.object_name) { + await storageService.downloadFile(attachment.object_name, name); + } else { + const fallbackUrl = + attachment.preview_url || + attachment.presigned_url || + attachment.url || + readAttachmentDataUrl(attachment); + if (fallbackUrl) triggerUrlDownload(fallbackUrl, name); + } + } catch (error) { + log.error("Failed to download conversation artifact:", error); + } + }; + + return ( + + ); +}; + +/** Local size formatter matching the chat attachment cards. */ +function formatArtifactSize(size: number): string { + if (size < 1024) return `${size} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = size / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + const precision = value >= 10 ? 0 : 1; + return `${value.toFixed(precision)} ${units[unitIndex]}`; +} + +/** Image detection aligned with the chat attachment cards. */ +function isImageAttachment(name: string, contentType?: string): boolean { + const extension = name.split(".").pop()?.toLowerCase() || ""; + return ( + contentType?.startsWith("image/") === true || + ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico"].includes( + extension + ) + ); +} + +/** Reads the raw URL stashed in a file attachment content part. */ +function readAttachmentDataUrl( + attachment: ArtifactAttachmentLike +): string | undefined { + const part = attachment.content?.[0] as { data?: string } | undefined; + return part?.data; +} + +export interface ConversationArtifactsButtonProps { + /** Number of artifacts accumulated in the conversation. */ + count: number; + onClick: () => void; + className?: string; +} + +/** + * Compact pill entry that opens the {@link ConversationArtifactsPanel}. + * Rendered once in the thread toolbar while the conversation has generated files. + */ +export const ConversationArtifactsButton: FC< + ConversationArtifactsButtonProps +> = ({ count, onClick, className }) => { + const { t } = useTranslation(); + if (count <= 0) return null; + return ( +
    + +
    + ); +}; + +export interface ConversationArtifactsToolbarEntryProps { + className?: string; +} + +/** + * Persistent top-right entry outside the message viewport. Its toolbar only + * takes up space after the current conversation has generated files. Reads + * the conversation total rather than the latest message's attachments, so + * later text-only turns do not hide it. + */ +export const ConversationArtifactsToolbarEntry: FC< + ConversationArtifactsToolbarEntryProps +> = ({ className }) => { + const entry = useConversationArtifactsEntry(); + if (!entry || entry.count <= 0) return null; + return ( +
    + +
    + ); +}; diff --git a/frontend/app/[locale]/newchat/ui/attachment.tsx b/frontend/app/[locale]/newchat/ui/attachment.tsx index 7d2d58bb46..e65087c756 100644 --- a/frontend/app/[locale]/newchat/ui/attachment.tsx +++ b/frontend/app/[locale]/newchat/ui/attachment.tsx @@ -53,7 +53,10 @@ const useFileSrc = (file: File | undefined) => { return src; }; -const getFileIcon = (filename: string | undefined, contentType?: string) => { +export const getFileIcon = ( + filename: string | undefined, + contentType?: string +) => { const ext = filename?.split(".").pop()?.toLowerCase() || ""; const mimeType = contentType?.toLowerCase() || ""; @@ -159,7 +162,7 @@ interface AttachmentViewModel { download_url?: string; } -const triggerUrlDownload = (url: string, filename: string) => { +export const triggerUrlDownload = (url: string, filename: string) => { const link = document.createElement("a"); link.href = url; link.download = filename; diff --git a/frontend/app/[locale]/newchat/ui/conversation-artifacts-context.tsx b/frontend/app/[locale]/newchat/ui/conversation-artifacts-context.tsx new file mode 100644 index 0000000000..50e2857040 --- /dev/null +++ b/frontend/app/[locale]/newchat/ui/conversation-artifacts-context.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { + createContext, + useContext, + useMemo, + type FC, + type ReactNode, +} from "react"; + +/** + * Conversation-wide artifacts state exposed to the thread toolbar. + * + * The thread root aggregates every agent-generated file and owns the side + * panel's open state. The toolbar reads this context to render one persistent + * entry outside the message viewport without duplicating panel state. + */ +export interface ConversationArtifactsEntryValue { + /** Total agent-generated files collected across the conversation. */ + count: number; + /** Opens the conversation artifacts panel. */ + open: () => void; +} + +const ConversationArtifactsEntryContext = + createContext(null); + +export interface ConversationArtifactsEntryProviderProps extends ConversationArtifactsEntryValue { + children: ReactNode; +} + +export const ConversationArtifactsEntryProvider: FC< + ConversationArtifactsEntryProviderProps +> = ({ count, open, children }) => { + // The thread root re-renders on every streamed token; a memoized value + // keeps toolbar consumers from re-rendering unless the count changed. + const value = useMemo(() => ({ count, open }), [count, open]); + return ( + + {children} + + ); +}; + +/** + * Returns the conversation artifacts entry state, or null when no provider + * is mounted (e.g. message surfaces outside the main thread). + */ +export function useConversationArtifactsEntry() { + return useContext(ConversationArtifactsEntryContext); +} diff --git a/frontend/app/[locale]/newchat/ui/question-directory-rail.tsx b/frontend/app/[locale]/newchat/ui/question-directory-rail.tsx new file mode 100644 index 0000000000..f43df0410a --- /dev/null +++ b/frontend/app/[locale]/newchat/ui/question-directory-rail.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useRef, type FC } from "react"; +import { Tooltip as TooltipPrimitive } from "radix-ui"; +import { useTranslation } from "react-i18next"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { QuestionDirectoryEntry } from "../utils/question-directory"; + +export interface QuestionDirectoryRailProps { + questions: QuestionDirectoryEntry[]; + onJump: (messageId: string) => void; + activeMessageId?: string | null; + className?: string; +} + +/** Compact, persistent navigation in the conversation's reserved left gutter. */ +export const QuestionDirectoryRail: FC = ({ + questions, + onJump, + activeMessageId, + className, +}) => { + const { t } = useTranslation(); + const listRef = useRef(null); + + useEffect(() => { + const list = listRef.current; + const current = list?.querySelector( + '[aria-current="location"]' + ); + if (!list || !current) return; + // Scroll only the rail, never the surrounding chat or page. + const top = current.offsetTop; + if (top < list.scrollTop) list.scrollTop = top; + else if (top + current.offsetHeight > list.scrollTop + list.clientHeight) { + list.scrollTop = top + current.offsetHeight - list.clientHeight; + } + }, [activeMessageId, questions.length]); + + if (!questions.length) return null; + + return ( + + ); +}; diff --git a/frontend/app/[locale]/newchat/utils/conversation-artifacts.ts b/frontend/app/[locale]/newchat/utils/conversation-artifacts.ts new file mode 100644 index 0000000000..d160ba54f2 --- /dev/null +++ b/frontend/app/[locale]/newchat/utils/conversation-artifacts.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useMemo } from "react"; +import type { CompleteAttachment } from "@assistant-ui/react"; +import { API_ENDPOINTS } from "@/services/api"; +import { skillFileUploadsRegistry } from "../adapter/remote-chat-model-adapter"; + +/** + * Loose shape of an assistant-ui thread message as consumed by the artifacts + * aggregation. Only the fields actually read are declared, mirroring the + * relaxed typing used by `thread.tsx` for restored content parts. + */ +export interface ArtifactMessageLike { + id: string; + role?: string; + createdAt?: Date | string; + content?: readonly { + type?: string; + /** Marker used by the history adapter for restored artifact messages. */ + isSkillFiles?: boolean; + skillFileAttachments?: CompleteAttachment[]; + }[]; +} + +/** + * One agent-generated file in a conversation, flattened from the per-message + * attachment payloads for the conversation-level artifacts panel. + */ +export interface ConversationArtifact { + /** Stable identity combining the source message and attachment id. */ + key: string; + /** assistant-ui message id the file belongs to. */ + messageId: string; + /** Message creation time, used to show when the file was generated. */ + createdAt?: Date; + attachment: CompleteAttachment; +} + +/** Attachment fields the artifacts panel reads; keeps rendering loosely typed. */ +export type ArtifactAttachmentLike = CompleteAttachment & { + name?: string; + contentType?: string; + size?: number; + object_name?: string; + preview_url?: string; + download_url?: string; + url?: string; + presigned_url?: string; +}; + +/** + * Collects every agent-generated file across a conversation's messages. + * + * Assistant-generated files arrive through two paths that must both be + * covered: + * + * - Streaming: the chat adapter appends a marker text part carrying + * `skillFileAttachments` on the assistant message itself. + * - History restore: the thread history adapter stores the attachments in + * `skillFileUploadsRegistry` keyed by message id and leaves only an + * `isSkillFiles` marker part on the message. + * + * The per-part payload wins when present so live updates are picked up + * immediately; the registry is the fallback for restored messages. + */ +export function collectConversationArtifacts( + messages: readonly ArtifactMessageLike[] +): ConversationArtifact[] { + const artifacts: ConversationArtifact[] = []; + for (const message of messages) { + if (message.role !== "assistant") continue; + + const attachments = readMessageArtifacts(message); + const createdAt = message.createdAt + ? new Date(message.createdAt) + : undefined; + + attachments.forEach((attachment, index) => { + artifacts.push({ + // Attachment ids restart per streaming chunk, so the position within + // the message keeps the key unique when several chunks contribute. + key: `${message.id}:${attachment.id}:${index}`, + messageId: message.id, + createdAt: Number.isNaN(createdAt?.getTime()) ? undefined : createdAt, + attachment, + }); + }); + } + return artifacts; +} + +function readMessageArtifacts( + message: ArtifactMessageLike +): CompleteAttachment[] { + const parts = message.content ?? []; + // Streaming path: the last marker part carries the cumulative attachment + // list for the message, so later parts supersede earlier ones. + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index]; + if (part?.type === "text" && part.skillFileAttachments?.length) { + return part.skillFileAttachments; + } + } + // History restore path: attachments live in the module-level registry. + return skillFileUploadsRegistry.get(message.id) ?? []; +} + +/** + * Memoized wrapper for component consumption. `messages` identity changes on + * every streamed token, which keeps the artifact list live during generation. + */ +export function useConversationArtifacts( + messages: readonly ArtifactMessageLike[] +): ConversationArtifact[] { + return useMemo(() => collectConversationArtifacts(messages), [messages]); +} + +/** Raw URL stashed on a file attachment content part, if any. */ +const readAttachmentPartUrl = ( + attachment: ArtifactAttachmentLike +): string | undefined => { + const part = attachment.content?.[0] as { data?: string } | undefined; + return part?.data; +}; + +/** + * Downloads an artifact's bytes so it can be attached to the composer. + * + * Prefers the backend stream endpoint for the artifact's `object_name` (same + * origin, no CORS concerns) and falls back to the attachment's own URLs when + * no object name is available. + */ +export async function fetchArtifactFile( + attachment: ArtifactAttachmentLike +): Promise { + const name = attachment.name || "Generated file"; + const contentType = attachment.contentType || "application/octet-stream"; + const sources: string[] = []; + if (attachment.object_name) { + sources.push( + API_ENDPOINTS.storage.file(attachment.object_name, "stream", name) + ); + } + for (const url of [ + attachment.download_url, + attachment.preview_url, + attachment.presigned_url, + attachment.url, + readAttachmentPartUrl(attachment), + ]) { + if (url) sources.push(url); + } + if (sources.length === 0) { + throw new Error(`No downloadable source for artifact: ${name}`); + } + + let lastError: unknown; + for (const url of sources) { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Fetch failed with status ${response.status}`); + } + const blob = await response.blob(); + return new File([blob], name, { type: contentType }); + } catch (error) { + lastError = error; + } + } + throw lastError instanceof Error + ? lastError + : new Error(`Failed to fetch artifact: ${name}`); +} diff --git a/frontend/app/[locale]/newchat/utils/conversation-branch.ts b/frontend/app/[locale]/newchat/utils/conversation-branch.ts new file mode 100644 index 0000000000..ec93084383 --- /dev/null +++ b/frontend/app/[locale]/newchat/utils/conversation-branch.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +export type BranchMessageLike = { + id: string; + role: string; +}; + +/** + * Derive the 1-based turn number that an assistant message answers. + * + * A turn is one user message plus its answer(s); the turn number therefore + * equals the count of user messages from the thread start up to and including + * the turn of the selected assistant message. The backend resolves the same + * turn over its active (deduplicated) user rows. The selected answer ID/version + * is sent separately because a turn can contain regenerated answers. Returns + * null when the message is missing or no user message precedes it. + */ +export function computeBranchTurnIndex( + messages: readonly BranchMessageLike[], + assistantMessageId: string +): number | null { + const position = messages.findIndex((m) => m.id === assistantMessageId); + if (position < 0) return null; + let turnIndex = 0; + for (let index = 0; index <= position; index++) { + if (messages[index].role === "user") turnIndex++; + } + return turnIndex >= 1 ? turnIndex : null; +} diff --git a/frontend/app/[locale]/newchat/utils/message-annotations.ts b/frontend/app/[locale]/newchat/utils/message-annotations.ts new file mode 100644 index 0000000000..5e2b97c8ec --- /dev/null +++ b/frontend/app/[locale]/newchat/utils/message-annotations.ts @@ -0,0 +1,314 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useMemo } from "react"; + +/** DOM hooks used to scope a quotable selection to one assistant message. */ +const ASSISTANT_MESSAGE_SELECTOR = "[data-role='assistant'][data-message-id]"; +const MESSAGE_CONTENT_SELECTOR = "[data-slot='aui_assistant-message-content']"; +// Reasoning, tool calls, execution-code summaries and subagent cards render +// outside the quotable reply text. +const NON_QUOTABLE_SELECTOR = + "[data-slot='aui_chain-of-thought'], .aui-tool-group-root"; + +/** Default excerpt length for the annotation source label. */ +const SOURCE_EXCERPT_MAX_LENGTH = 40; + +/** Default truncation length for quoted text shown inside the chip list. */ +const LIST_QUOTE_MAX_LENGTH = 80; + +/** Defensive caps mirrored by the backend (see backend/contracts/conversation.py). */ +export const MESSAGE_ANNOTATION_QUOTED_TEXT_MAX = 1500; +export const MESSAGE_ANNOTATION_COMMENT_MAX = 500; +export const MESSAGE_ANNOTATION_MAX_COUNT = 10; + +/** + * Loose shape of an assistant-ui thread message as consumed by the + * annotation helpers. Only the fields actually read are declared, mirroring + * the relaxed typing used by `question-directory.ts`. + */ +export interface AnnotationMessageLike { + id: string; + role?: string; + content?: readonly { + type?: string; + text?: string; + }[]; +} + +/** A pending selection inside one assistant message, resolved from the DOM. */ +export interface AnnotationSelectionTarget { + /** assistant-ui message id of the message the selection belongs to. */ + messageId: string; + /** Normalized selected text (CRLF collapsed to LF). */ + text: string; + /** Cloned DOM range used to anchor the floating bubble. */ + range: Range; +} + +/** Draft annotation captured from a completed selection. */ +export interface MessageAnnotationDraft { + /** The selected text quoted verbatim from the assistant reply. */ + quotedText: string; + /** Optional user comment attached to the quote. */ + comment?: string; + /** assistant-ui message id of the message the quote came from. */ + sourceMessageId: string; +} + +/** A stored annotation attached to the next outgoing message. */ +export interface MessageAnnotation extends MessageAnnotationDraft { + /** Runtime identity used for list keys and edit/delete actions. */ + id: string; + /** Display-only source label; model input uses only the selected quote and comment. */ + sourceExcerpt: string; +} + +/** Aggregate handle passed from the Thread down to the composer and bubble. */ +export interface MessageAnnotationsControl { + /** Pending annotations in creation order. */ + annotations: readonly MessageAnnotation[]; + /** Stores a new annotation captured from a selection. */ + addAnnotation: (draft: MessageAnnotationDraft) => void; + /** Saves an edited comment. */ + updateAnnotationComment: (id: string, comment: string) => void; + /** Deletes a single annotation. */ + removeAnnotation: (id: string) => void; + /** Deletes every pending annotation. */ + clearAnnotations: () => void; + /** Consumes the annotations once the outgoing run has accepted them. */ + consumeAnnotations: () => void; +} + +/** + * Wire shape of one persisted reply annotation (snake_case, matching + * `backend/contracts/conversation.py::MessageAnnotationItem`). + */ +export interface MessageAnnotationWireItem { + quoted_text: string; + comment?: string | null; + source_excerpt?: string | null; +} + +/** + * Collapses whitespace and truncates to a single-line excerpt. + * + * @param value Raw text to summarize. + * @param maxLength Maximum character count including the ellipsis. + * @returns The truncated single-line summary. + */ +export function truncateAnnotationText( + value: string, + maxLength: number +): string { + const line = value.replace(/\s+/g, " ").trim(); + if (!line) return ""; + if (line.length <= maxLength) return line; + return `${line.slice(0, maxLength - 1)}…`; +} + +/** + * Resolves the live selection into a quotable target. + * + * A selection is quotable when both endpoints sit inside the content area of + * the same assistant message and outside reasoning, tool, execution-code and + * subagent containers. Selections crossing messages, user messages and + * collapsed selections are rejected. + * + * @param selection The live window selection. + * @returns The target, or null when the selection is not quotable. + */ +export function resolveAnnotationSelection( + selection: Selection | null +): AnnotationSelectionTarget | null { + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return null; + } + const text = selection.toString(); + if (!text || !text.trim()) return null; + const anchorElement = elementFromNode(selection.anchorNode); + const focusElement = elementFromNode(selection.focusNode); + if (!anchorElement || !focusElement) return null; + + const messageElement = anchorElement.closest(ASSISTANT_MESSAGE_SELECTOR); + if (!messageElement) return null; + // Both endpoints must belong to the same assistant message; this also + // rejects selections that start in chrome outside the message list. + if ( + messageElement !== focusElement.closest(ASSISTANT_MESSAGE_SELECTOR) || + anchorElement.closest(MESSAGE_CONTENT_SELECTOR) !== + focusElement.closest(MESSAGE_CONTENT_SELECTOR) + ) { + return null; + } + if ( + anchorElement.closest(NON_QUOTABLE_SELECTOR) || + focusElement.closest(NON_QUOTABLE_SELECTOR) + ) { + return null; + } + + const messageId = messageElement.getAttribute("data-message-id") ?? ""; + if (!messageId) return null; + + const content = anchorElement.closest(MESSAGE_CONTENT_SELECTOR); + const range = selection.getRangeAt(0).cloneRange(); + if ( + !content || + !content.contains(range.startContainer) || + !content.contains(range.endContainer) + ) { + return null; + } + // Endpoints alone cannot detect a selection spanning a tool/reasoning card. + if ( + Array.from(content.querySelectorAll(NON_QUOTABLE_SELECTOR)).some((node) => + range.intersectsNode(node) + ) + ) { + return null; + } + + return { + messageId, + text: text.replace(/\r\n/g, "\n"), + range, + }; +} + +function elementFromNode(node: Node | null): HTMLElement | null { + if (!node) return null; + if (node.nodeType === Node.TEXT_NODE) return node.parentElement; + return node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null; +} + +/** + * Extracts a short excerpt of the assistant message an annotation quotes. + * + * @param messages Thread messages in conversation order. + * @param messageId assistant-ui message id of the source message. + * @param maxLength Maximum excerpt length including the ellipsis. + * @returns The truncated first line of the reply, or "" for unknown sources. + */ +export function getAnnotationSourceExcerpt( + messages: readonly AnnotationMessageLike[], + messageId: string, + maxLength = SOURCE_EXCERPT_MAX_LENGTH +): string { + const message = messages.find((item) => item.id === messageId); + if (!message || message.role !== "assistant") return ""; + const text = (message.content ?? []) + .map((part) => (part.type === "text" ? (part.text ?? "") : "")) + .join("") + .trim(); + return truncateAnnotationText(text, maxLength); +} + +/** + * Builds a stored annotation from a draft. + * + * The comment is trimmed; a whitespace-only comment is dropped. The source + * excerpt is captured at creation time so the annotation keeps referencing + * the reply content the user saw when selecting. + * + * @param draft Draft captured from the selection bubble. + * @param messages Thread messages used to resolve the source excerpt. + * @param id Runtime identity for the stored annotation. + * @returns The fully populated annotation. + */ +export function createMessageAnnotation( + draft: MessageAnnotationDraft, + messages: readonly AnnotationMessageLike[], + id: string +): MessageAnnotation { + return { + id, + sourceMessageId: draft.sourceMessageId, + // Defensive caps keep the structured payload inside the backend limits. + quotedText: draft.quotedText.slice(0, MESSAGE_ANNOTATION_QUOTED_TEXT_MAX), + comment: + draft.comment?.trim().slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) || + undefined, + sourceExcerpt: getAnnotationSourceExcerpt(messages, draft.sourceMessageId), + }; +} + +/** + * Maps pending annotations onto the structured wire payload sent with the + * outgoing request (the backend persists it and merges it into the + * model-facing text at request time). + * + * @param annotations Pending annotations in creation order. + * @returns The snake_case wire items (length-capped by the caller). + */ +export function toMessageAnnotationWireItems( + annotations: readonly MessageAnnotation[] +): MessageAnnotationWireItem[] { + return annotations + .slice(0, MESSAGE_ANNOTATION_MAX_COUNT) + .map((annotation) => ({ + quoted_text: annotation.quotedText.slice( + 0, + MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + ), + comment: + annotation.comment?.slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) ?? null, + source_excerpt: annotation.sourceExcerpt || null, + })); +} + +/** + * Normalizes annotation payloads read back from persisted messages. + * + * Invalid entries are dropped and the defensive caps are re-applied so the + * user bubble renders sane cards even for legacy or oversized payloads. + * + * @param raw Value of the message's `annotations` field. + * @returns Validated wire items; empty when nothing usable remains. + */ +export function fromMessageAnnotationWireItems( + raw: unknown +): MessageAnnotationWireItem[] { + if (!Array.isArray(raw)) return []; + const items: MessageAnnotationWireItem[] = []; + for (const entry of raw) { + if (typeof entry !== "object" || entry === null) continue; + const record = entry as Record; + const quotedText = + typeof record.quoted_text === "string" ? record.quoted_text.trim() : ""; + if (!quotedText) continue; + const comment = + typeof record.comment === "string" ? record.comment.trim() : ""; + const sourceExcerpt = + typeof record.source_excerpt === "string" + ? record.source_excerpt.trim() + : ""; + items.push({ + quoted_text: quotedText.slice(0, MESSAGE_ANNOTATION_QUOTED_TEXT_MAX), + comment: comment + ? comment.slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) + : null, + source_excerpt: sourceExcerpt + ? sourceExcerpt.slice(0, MESSAGE_ANNOTATION_QUOTED_TEXT_MAX) + : null, + }); + if (items.length >= MESSAGE_ANNOTATION_MAX_COUNT) break; + } + return items; +} + +/** + * Memoized helper for the chip list: shortens quoted text for display. + * + * @param annotation The annotation whose quote should be summarized. + * @returns A single-line truncated quote. + */ +export function useAnnotationQuotePreview( + annotation: MessageAnnotation +): string { + return useMemo( + () => truncateAnnotationText(annotation.quotedText, LIST_QUOTE_MAX_LENGTH), + [annotation.quotedText] + ); +} diff --git a/frontend/app/[locale]/newchat/utils/question-directory.ts b/frontend/app/[locale]/newchat/utils/question-directory.ts new file mode 100644 index 0000000000..75213c9e1a --- /dev/null +++ b/frontend/app/[locale]/newchat/utils/question-directory.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +/** Truncation length for the question summary shown in the directory list. */ +const QUESTION_SUMMARY_MAX_LENGTH = 60; + +/** Truncation length for the assistant reply excerpt shown in the preview. */ +const REPLY_EXCERPT_MAX_CHARS = 400; + +/** Viewport fraction used as the "current question" midline. */ +const ACTIVE_QUESTION_MIDLINE = 0.5; + +/** + * Loose shape of an assistant-ui thread message as consumed by the question + * directory. Only the fields actually read are declared, mirroring the relaxed + * typing used by `conversation-artifacts.ts` for restored content parts. + */ +export interface QuestionMessageLike { + id: string; + role?: string; + content?: readonly { + type?: string; + text?: string; + }[]; + attachments?: readonly { name?: string }[]; +} + +/** One user question turn, flattened for the conversation directory. */ +export interface QuestionDirectoryEntry { + /** Stable identity (the user message id; also the jump target). */ + key: string; + /** assistant-ui message id used to locate the DOM node for scrolling. */ + messageId: string; + /** 1-based position of the question within the conversation. */ + index: number; + /** Short single-line label for the list. */ + summary: string; + /** Full question text (or attachment name) for the preview card. */ + fullText: string; + /** Excerpt of the assistant reply that answered the question ("" if none yet). */ + replyText: string; + /** Whether the turn was derived from text or from an attachment-only message. */ + kind: "text" | "attachment"; +} + +const truncateSummary = (value: string): string => { + const line = value.split("\n")[0]?.trim() ?? ""; + if (!line) return ""; + if (line.length <= QUESTION_SUMMARY_MAX_LENGTH) return line; + return `${line.slice(0, QUESTION_SUMMARY_MAX_LENGTH - 1)}…`; +}; + +const truncateReply = (value: string): string => { + const trimmed = value.trim(); + if (trimmed.length <= REPLY_EXCERPT_MAX_CHARS) return trimmed; + return `${trimmed.slice(0, REPLY_EXCERPT_MAX_CHARS - 1)}…`; +}; + +/** Joins the text parts of a message (assistant replies only). */ +const messageText = (message: QuestionMessageLike): string => + (message.content ?? []) + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => (part.type === "text" ? (part.text ?? "") : "")) + .join("\n") + .trim(); + +/** + * Collects one directory entry per user message, in conversation order. + * + * Text questions use the first non-empty text part (first line, truncated) as + * the summary; messages that only carried attachments fall back to the first + * attachment's name. Messages with neither are skipped. Each entry also + * carries an excerpt of the first assistant reply that follows it so the + * directory can preview the answer together with the question. + */ +export function collectQuestionDirectory( + messages: readonly QuestionMessageLike[], +): QuestionDirectoryEntry[] { + const entries: QuestionDirectoryEntry[] = []; + // Index of the newest question that has not received an assistant reply yet; + // assistant messages without text (tool calls, reasoning summaries) do not + // consume it so the first textual reply wins. + let pendingIndex = -1; + for (const message of messages) { + if (message.role === "user") { + const fullText = + message.content + ?.find( + (part) => + part?.type === "text" && + typeof part.text === "string" && + part.text, + ) + ?.text?.trim() ?? ""; + + let kind: QuestionDirectoryEntry["kind"]; + let source: string; + if (fullText) { + kind = "text"; + source = fullText; + } else { + const attachmentName = message.attachments?.find((a) => a?.name)?.name; + if (!attachmentName) continue; + kind = "attachment"; + source = attachmentName; + } + + const summary = truncateSummary(source); + if (!summary) continue; + + entries.push({ + key: message.id, + messageId: message.id, + index: entries.length + 1, + summary, + fullText: source, + replyText: "", + kind, + }); + pendingIndex = entries.length - 1; + continue; + } + + if (message.role === "assistant" && pendingIndex >= 0) { + const pending = entries[pendingIndex]; + const reply = messageText(message); + if (pending && reply) { + pending.replyText = truncateReply(reply); + pendingIndex = -1; + } + } + } + return entries; +} + +/** + * Memoized wrapper for component consumption. `messages` identity changes on + * every streamed token, which keeps the directory live during generation. + */ +export function useQuestionDirectory( + messages: readonly QuestionMessageLike[], +): QuestionDirectoryEntry[] { + return useMemo(() => collectQuestionDirectory(messages), [messages]); +} + +/** + * Locates the rendered message element for an assistant-ui message id. + * + * assistant-ui renders `data-message-id` on every message root and exposes no + * public message-lookup API, so the target is located through the DOM. Message + * ids are runtime-generated, but quote/backslash are escaped anyway so the + * attribute selector stays valid for any id shape. + */ +export function findMessageElement(messageId: string): HTMLElement | null { + const escaped = messageId.replace(/["\\]/g, "\\$&"); + return document.querySelector(`[data-message-id="${escaped}"]`); +} + +/** + * Scrolls the thread viewport to the message with the given id. + * + * The target is located through the DOM and scrolled natively without adding + * a visual highlight to the message. + */ +export function scrollThreadToMessage(messageId: string): boolean { + const element = findMessageElement(messageId); + if (!element) return false; + + // Breathing room between the landed message and the viewport top edge. + element.style.scrollMarginTop = "16px"; + element.scrollIntoView({ behavior: "smooth", block: "start" }); + + return true; +} + +/** + * Tracks the question the thread viewport is currently scrolled to. + * + * The active question is the last one whose message top sits above the + * viewport midline (classic active-section logic), recomputed rAF-throttled on + * every scroll and once whenever the tracked question set changes. Scroll + * events do not bubble, so the listener attaches to `window` in the capture + * phase to also catch inner scroll containers. + */ +export function useActiveQuestionId( + questions: readonly QuestionDirectoryEntry[], +): string | null { + const [activeId, setActiveId] = useState(null); + // Stable dependency: entry identities change on every streamed token, but + // the tracked id list only changes when questions are added or removed. + const questionIdsKey = questions + .map((question) => question.messageId) + .join("\n"); + + useEffect(() => { + const ids = questionIdsKey ? questionIdsKey.split("\n") : []; + let frame = 0; + const compute = () => { + frame = 0; + const midline = window.innerHeight * ACTIVE_QUESTION_MIDLINE; + let current: string | null = null; + for (const id of ids) { + const element = findMessageElement(id); + // Unmounted messages (still loading, filtered runs) are skipped; the + // first rendered message below the midline ends the scan. + if (!element) continue; + if (element.getBoundingClientRect().top <= midline) { + current = id; + } else { + break; + } + } + setActiveId(current); + }; + + const scheduleCompute = () => { + if (frame) return; + frame = window.requestAnimationFrame(compute); + }; + + // The initial computation rides the same rAF path as scroll-driven ones; + // an empty question set resolves to null on the next frame. + scheduleCompute(); + window.addEventListener("scroll", scheduleCompute, true); + return () => { + window.removeEventListener("scroll", scheduleCompute, true); + if (frame) window.cancelAnimationFrame(frame); + }; + }, [questionIdsKey]); + + return activeId; +} diff --git a/frontend/components/common/filePreviewDrawer.tsx b/frontend/components/common/filePreviewDrawer.tsx index 72853058ec..6bab917cd1 100644 --- a/frontend/components/common/filePreviewDrawer.tsx +++ b/frontend/components/common/filePreviewDrawer.tsx @@ -1426,6 +1426,18 @@ export function FilePreviewDrawer(props: Readonly) { } }; + // Inline hosts own their header, download action and responsive panel layout. + if (props.presentation === "inline") { + return open ? ( +
    + {renderContent()} +
    + ) : null; + } + // Full-screen presentation: the document owns the viewport and the back arrow // returns to whatever opened it. It is portalled into `document.body` because // ancestor transforms (the page transition animates the content) turn such an diff --git a/frontend/components/common/rawStreamPreviewViewer.module.css b/frontend/components/common/rawStreamPreviewViewer.module.css new file mode 100644 index 0000000000..00da112649 --- /dev/null +++ b/frontend/components/common/rawStreamPreviewViewer.module.css @@ -0,0 +1,14 @@ +/* Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. */ + +.docxViewport { + background: var(--color-bg-3); +} + +.docxPages :global(.docx-wrapper) { + background: transparent; + padding: 0; +} + +.docxPages :global(.docx-wrapper) > :global(section.docx) { + box-shadow: none; +} diff --git a/frontend/components/common/rawStreamPreviewViewer.tsx b/frontend/components/common/rawStreamPreviewViewer.tsx index 96b1d07663..04272ded95 100644 --- a/frontend/components/common/rawStreamPreviewViewer.tsx +++ b/frontend/components/common/rawStreamPreviewViewer.tsx @@ -15,6 +15,7 @@ import { type PreviewAccessReason, } from "@/lib/filePreviewUtils"; import { + applyDocxPageDefaults, neutralizeUnsafeLinks, officeHtmlToText, percentageOf, @@ -30,6 +31,7 @@ import { type SheetGrid, } from "@/lib/spreadsheetGrid"; import log from "@/lib/logger"; +import styles from "./rawStreamPreviewViewer.module.css"; const PdfViewer = dynamic( () => @@ -348,7 +350,8 @@ export function RawStreamPreviewViewer( body, docxStyleRef.current ?? undefined, { - inWrapper: false, + // Default paper styling is scoped to the renderer's page wrapper. + inWrapper: true, breakPages: true, ignoreLastRenderedPageBreak: true, renderHeaders: true, @@ -365,6 +368,7 @@ export function RawStreamPreviewViewer( // with no scheme filter, so the anchors it produced are disarmed before // a reader can click them. neutralizeUnsafeLinks(body); + applyDocxPageDefaults(body); } else if (kind === "xlsx") { const excelModule = await import("exceljs"); const ExcelJS = (excelModule.default ?? @@ -431,9 +435,12 @@ export function RawStreamPreviewViewer( : renderMessage(t("filePreview.previewFailed"))}
    )} -
    +
    -
    +
    ); diff --git a/frontend/lib/officePreview.ts b/frontend/lib/officePreview.ts index 51d558fb25..02af6ea174 100644 --- a/frontend/lib/officePreview.ts +++ b/frontend/lib/officePreview.ts @@ -13,6 +13,24 @@ /** Office formats that are a zip archive of XML parts and can be parsed client-side. */ export type OfficeFormat = "docx" | "xlsx" | "pptx"; +/** Supply readable paper geometry when a generated DOCX omits section settings. */ +export function applyDocxPageDefaults(container: HTMLElement): void { + for (const page of container.querySelectorAll( + ".docx-wrapper > section.docx" + )) { + if (!page.style.width) page.style.width = "min(210mm, 100%)"; + if (!page.style.height && !page.style.minHeight) { + page.style.minHeight = "297mm"; + } + for (const side of ["top", "right", "bottom", "left"]) { + const property = `padding-${side}`; + if (!page.style.getPropertyValue(property)) { + page.style.setProperty(property, "25.4mm"); + } + } + } +} + /** * Renderer that handles the bytes a document stream returned. * diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 8eea726623..f30c8ba49b 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -402,6 +402,9 @@ "chat.thread.refresh": "Refresh", "chat.thread.more": "More", "chat.thread.exportMarkdown": "Export as Markdown", + "chat.thread.branchToNewChat": "Branch to new chat", + "chat.thread.branchConversationSuccess": "Branched to a new chat", + "chat.thread.branchConversationFailed": "Failed to branch conversation", "chat.thread.edit": "Edit", "chat.thread.newChat": "New Chat", "chat.threadList.loading": "Loading threads", @@ -473,6 +476,43 @@ "chat.attachments.preview": "Preview {{name}}", "chat.attachments.remove": "Remove {{name}}", "chat.attachments.download": "Download", + "chat.artifacts.viewAll": "View all artifacts ({{count}})", + "chat.artifacts.title": "Conversation artifacts", + "chat.artifacts.total": "{{count}} files in total", + "chat.artifacts.empty": "No files generated in this conversation yet", + "chat.artifacts.preview": "Preview", + "chat.artifacts.download": "Download", + "chat.artifacts.addToChat": "Add to chat", + "chat.artifacts.addedToChat": "{{name}} added to the chat box", + "chat.artifacts.addToChatFailed": "Failed to add to the chat box. Please try again", + "chat.artifacts.close": "Close", + "chat.questions.viewAll": "Questions ({{count}})", + "chat.questions.title": "Question directory", + "chat.questions.total": "{{count}} questions in total", + "chat.questions.jump": "Jump to question {{index}}", + "chat.questions.search": "Search questions…", + "chat.questions.noMatches": "No matching questions", + "chat.questions.current": "Current", + "chat.questions.noReply": "No reply yet", + "chat.annotation.add": "Add to chat", + "chat.annotation.commentPlaceholder": "Add an optional comment...", + "chat.annotation.editPlaceholder": "Edit comment...", + "chat.annotation.confirm": "Add to chat", + "chat.annotation.cancel": "Cancel annotation", + "chat.annotation.chipLabel_one": "{{count}} annotation", + "chat.annotation.chipLabel_other": "{{count}} annotations", + "chat.annotation.listTitle": "Annotations", + "chat.annotation.selectedTextLabel": "Selected text", + "chat.annotation.commentLabel": "Comment", + "chat.annotation.editComment": "Edit comment", + "chat.annotation.deleteAnnotation": "Delete annotation", + "chat.annotation.clearAnnotations": "Clear all annotations", + "chat.annotation.saveComment": "Save comment", + "chat.annotation.cancelEdit": "Cancel editing", + "chat.annotation.colon": ": ", + "chat.annotation.cardSourceLabel": "From reply \"{{excerpt}}\"", + "chat.annotation.maxCountReached": "Up to {{maxCount}} annotations can be attached", + "chat.artifacts.generatedAt": "Generated {{time}}", "chat.tokenUsage.used": "used", "chat.tokenUsage.turn": "this turn", "chat.tokenUsage.details": "Token usage details", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 870cac1c5c..ffc5f01fe9 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -400,6 +400,9 @@ "chat.thread.refresh": "刷新", "chat.thread.more": "更多", "chat.thread.exportMarkdown": "导出为 Markdown", + "chat.thread.branchToNewChat": "分支到新聊天", + "chat.thread.branchConversationSuccess": "已分支到新聊天", + "chat.thread.branchConversationFailed": "分支会话失败", "chat.thread.edit": "编辑", "chat.thread.newChat": "新对话", "chat.threadList.loading": "正在加载对话", @@ -471,6 +474,42 @@ "chat.attachments.preview": "预览 {{name}}", "chat.attachments.remove": "移除 {{name}}", "chat.attachments.download": "下载", + "chat.artifacts.viewAll": "查看所有产物 ({{count}})", + "chat.artifacts.title": "对话产物", + "chat.artifacts.total": "共 {{count}} 个文件", + "chat.artifacts.empty": "本次对话尚未生成文件", + "chat.artifacts.preview": "预览", + "chat.artifacts.download": "下载", + "chat.artifacts.addToChat": "添加到对话框", + "chat.artifacts.addedToChat": "已将 {{name}} 添加到对话框", + "chat.artifacts.addToChatFailed": "添加到对话框失败,请重试", + "chat.artifacts.close": "关闭", + "chat.questions.viewAll": "问题目录 ({{count}})", + "chat.questions.title": "问题目录", + "chat.questions.total": "共 {{count}} 个问题", + "chat.questions.jump": "跳转到问题 {{index}}", + "chat.questions.search": "搜索问题…", + "chat.questions.noMatches": "没有匹配的问题", + "chat.questions.current": "当前", + "chat.questions.noReply": "暂无回复", + "chat.annotation.add": "添加到对话", + "chat.annotation.commentPlaceholder": "添加可选批注…", + "chat.annotation.editPlaceholder": "编辑批注…", + "chat.annotation.confirm": "添加到对话", + "chat.annotation.cancel": "取消批注", + "chat.annotation.chipLabel": "{{count}} 条批注", + "chat.annotation.listTitle": "批注", + "chat.annotation.selectedTextLabel": "选中内容", + "chat.annotation.commentLabel": "批注", + "chat.annotation.editComment": "编辑批注", + "chat.annotation.deleteAnnotation": "删除批注", + "chat.annotation.clearAnnotations": "清空全部批注", + "chat.annotation.saveComment": "保存批注", + "chat.annotation.cancelEdit": "取消编辑", + "chat.annotation.colon": ":", + "chat.annotation.cardSourceLabel": "来自回复「{{excerpt}}」", + "chat.annotation.maxCountReached": "最多添加 {{maxCount}} 条批注", + "chat.artifacts.generatedAt": "生成于 {{time}}", "chat.tokenUsage.used": "已使用", "chat.tokenUsage.turn": "本轮", "chat.tokenUsage.details": "Token 使用详情", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 64c40158ec..56a98feeab 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -75,6 +75,7 @@ export const API_ENDPOINTS = { create: `${API_BASE_URL}/conversation/create`, save: `${API_BASE_URL}/conversation/save`, rename: `${API_BASE_URL}/conversation/rename`, + branch: `${API_BASE_URL}/conversation/branch`, detail: (id: number) => `${API_BASE_URL}/conversation/${id}`, target: (id: number) => `${API_BASE_URL}/conversation/${id}/target`, knowledgeScope: (id: number) => diff --git a/frontend/services/conversationService.ts b/frontend/services/conversationService.ts index e96d56452d..3a824e3c72 100644 --- a/frontend/services/conversationService.ts +++ b/frontend/services/conversationService.ts @@ -6,6 +6,7 @@ import { API_ENDPOINTS, ApiError, fetchWithErrorHandling } from "./api"; import { chatConfig } from "@/const/chatConfig"; import type { + ApiMessageAnnotation, ConversationListResponse, ConversationListPage, ConversationListParams, @@ -345,6 +346,41 @@ export const conversationService = { throw new ApiError(data.code, data.message); }, + // Branch a conversation into a new one up to and including the given turn + // (1-based). Returns the new conversation payload. + async branch( + conversationId: number, + turnIndex: number, + assistantMessageId?: string, + answerIndex?: number + ) { + // Restored messages carry database IDs; live messages carry runtime UUIDs. + const sourceMessageId = + assistantMessageId && /^[1-9]\d*$/.test(assistantMessageId) + ? Number(assistantMessageId) + : undefined; + const response = await fetch(API_ENDPOINTS.conversation.branch, { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + conversation_id: conversationId, + turn_index: turnIndex, + source_message_id: Number.isSafeInteger(sourceMessageId) + ? sourceMessageId + : undefined, + answer_index: answerIndex, + }), + }); + + const data = await response.json(); + + if (data.code === 0) { + return data.data; + } + + throw new ApiError(data.code, data.message); + }, + // Get conversation details async getDetail( conversationId: number, @@ -1186,6 +1222,7 @@ export const conversationService = { params: { query: string; clarification_response?: ClarificationResponse; + annotations?: ApiMessageAnnotation[]; conversation_id?: number; history: Array<{ role: string; content: string }>; files?: File[]; @@ -1245,6 +1282,13 @@ export const conversationService = { ) { requestParams.clarification_response = params.clarification_response; } + if ( + !params.is_resume && + !params.runtime_mode && + params.annotations?.length + ) { + requestParams.annotations = params.annotations; + } if (params.runtime_mode === "nl2skill") { requestParams.draft_snapshot = params.draft_snapshot; requestParams.language = params.language; diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index e723cd3e57..04971c6e27 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -61,6 +61,8 @@ @layer base { :root { + --palette-gray-0: #ffffff; + --color-bg-3: var(--palette-gray-0); --navigation-surface: lab(97.0939% -0.108689 -0.749958); --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; diff --git a/frontend/tests/component/artifactDocumentPanel.test.tsx b/frontend/tests/component/artifactDocumentPanel.test.tsx new file mode 100644 index 0000000000..662d3d5591 --- /dev/null +++ b/frontend/tests/component/artifactDocumentPanel.test.tsx @@ -0,0 +1,125 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { + ArtifactDocumentPanel, + artifactDocumentSource, +} from "@/app/newchat/components/artifact-document-panel"; +import type { + ArtifactAttachmentLike, + ConversationArtifact, +} from "@/app/newchat/utils/conversation-artifacts"; + +const state = vi.hoisted(() => ({ props: vi.fn(), download: vi.fn() })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/common/filePreviewDrawer", () => ({ + FilePreviewDrawer: (props: unknown) => { + state.props(props); + return
    ; + }, +})); +vi.mock("@/services/api", () => ({ + API_ENDPOINTS: { + storage: { + file: (object: string, mode: string, name: string) => + `/file/${object}?mode=${mode}&name=${name}`, + }, + }, +})); +vi.mock("@/services/storageService", () => ({ + storageService: { + getPreviewUrl: (object: string, name: string) => + `/preview/${object}?name=${name}`, + }, +})); +vi.mock("@/app/newchat/ui/attachment", () => ({ + triggerUrlDownload: state.download, +})); + +const attachment = ( + name: string, + extra: Partial = {} +): ArtifactAttachmentLike => ({ + id: "file", + name, + type: "file", + status: { type: "complete" }, + content: [], + object_name: "workspace/report", + preview_url: "https://old/raw.docx", + ...extra, +}); + +test.each(["docx", "xlsx", "pptx"])( + "%s uses original bytes and the stream renderer instead of PDF", + (extension) => { + const result = artifactDocumentSource(attachment(`report.${extension}`)); + expect(result.rawStream).toBe(true); + expect(result.previewUrl).toContain("mode=stream"); + expect(result.previewUrl).not.toContain("https://old"); + expect(result.downloadUrl).toBe(result.previewUrl); + } +); + +test.each(["doc", "xls", "ppt", "pdf", "txt", "md"])( + "%s keeps the existing remote preview path", + (extension) => { + const result = artifactDocumentSource(attachment(`report.${extension}`)); + expect(result.rawStream).toBe(false); + expect(result.previewUrl).toContain("/preview/workspace/report"); + expect(result.downloadUrl).toContain("mode=stream"); + } +); + +test("falls back to a real source URL if object identity is unavailable", () => { + const result = artifactDocumentSource( + attachment("report.docx", { + object_name: "", + download_url: "https://files/original.docx", + }) + ); + expect(result.previewUrl).toBe("https://files/original.docx"); + expect(result.rawStream).toBe(true); +}); + +test("renders an inline document with close, download and return-to-artifacts controls", () => { + const onClose = vi.fn(); + const onShowArtifacts = vi.fn(); + const artifact = { + key: "a:file", + messageId: "a", + attachment: attachment("report.docx"), + } satisfies ConversationArtifact; + render( + + ); + expect( + screen.getByRole("complementary", { name: "report.docx" }) + ).toBeVisible(); + expect(state.props).toHaveBeenLastCalledWith( + expect.objectContaining({ + open: true, + presentation: "inline", + rawStream: true, + fileName: "report.docx", + }) + ); + fireEvent.click( + screen.getByRole("button", { name: "chat.artifacts.download" }) + ); + expect(state.download).toHaveBeenCalledWith( + expect.stringContaining("mode=stream"), + "report.docx" + ); + fireEvent.click(screen.getByRole("button", { name: "chat.artifacts.title" })); + expect(onShowArtifacts).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("button", { name: "chat.artifacts.close" })); + expect(onClose).toHaveBeenCalledOnce(); +}); diff --git a/frontend/tests/component/autoArtifactPreview.test.tsx b/frontend/tests/component/autoArtifactPreview.test.tsx new file mode 100644 index 0000000000..4bf9267cba --- /dev/null +++ b/frontend/tests/component/autoArtifactPreview.test.tsx @@ -0,0 +1,181 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { act, renderHook } from "@testing-library/react"; +import { expect, test } from "vitest"; +import { useAutoArtifactPreview } from "@/app/newchat/components/use-auto-artifact-preview"; +import type { + ArtifactAttachmentLike, + ConversationArtifact, +} from "@/app/newchat/utils/conversation-artifacts"; + +const file = ( + name: string, + key = name, + objectName = `workspace/${name}` +): ConversationArtifact => { + const attachment: ArtifactAttachmentLike = { + id: key, + name, + type: "file", + status: { type: "complete" }, + content: [], + object_name: objectName, + }; + return { key, messageId: "assistant", attachment }; +}; +const initial = { + scopeKey: "chat-1", + artifacts: [] as ConversationArtifact[], + isRunning: false, + isLoading: false, + enabled: true, +}; + +test("opens a new document only after the live run finishes", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + const doc = file("report.docx"); + rerender({ ...initial, isRunning: true, artifacts: [doc] }); + expect(result.current.selected).toBeNull(); + rerender({ ...initial, artifacts: [doc] }); + expect(result.current.selected).toEqual(doc); +}); + +test("history loading and conversation switching never auto-open old documents", () => { + const old = file("old.docx"); + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: { ...initial, isLoading: true }, + }); + rerender({ ...initial, artifacts: [old] }); + expect(result.current.selected).toBeNull(); + act(() => result.current.open(old)); + expect(result.current.selected).toEqual(old); + rerender({ + ...initial, + scopeKey: "chat-2", + artifacts: [file("another.pdf")], + }); + expect(result.current.selected).toBeNull(); + rerender({ ...initial, artifacts: [old] }); + expect(result.current.selected).toBeNull(); +}); + +test("prefers Office originals over companion PDF checks and intermediate scripts", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + const doc = file("report.docx"); + rerender({ + ...initial, + isRunning: true, + artifacts: [doc, file("report.pdf"), file("build.py")], + }); + rerender({ + ...initial, + artifacts: [doc, file("report.pdf"), file("build.py")], + }); + expect(result.current.selected).toEqual(doc); +}); + +test("closing suppresses late files and metadata updates until another run", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + const doc = file("report.docx"); + rerender({ ...initial, isRunning: true, artifacts: [doc] }); + rerender({ ...initial, artifacts: [doc] }); + act(() => result.current.close()); + const renamed = file("report.docx", "restored-id"); + rerender({ ...initial, artifacts: [renamed, file("late.pdf")] }); + expect(result.current.selected).toBeNull(); + act(() => result.current.open(renamed)); + expect(result.current.selected).toEqual(renamed); + act(() => result.current.close()); + rerender({ ...initial, isRunning: true, artifacts: [renamed] }); + const next = file("new.docx"); + rerender({ ...initial, artifacts: [renamed, next] }); + expect(result.current.selected).toEqual(next); +}); + +test("supports late artifact publication after completion without repeating on token updates", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + rerender(initial); + const doc = file("published.pdf"); + rerender({ ...initial, artifacts: [doc] }); + expect(result.current.selected).toEqual(doc); + act(() => result.current.close()); + rerender({ ...initial, artifacts: [{ ...doc }] }); + expect(result.current.selected).toBeNull(); +}); + +test("a late companion PDF does not replace the Office original", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + const doc = file("report.docx"); + rerender({ ...initial, isRunning: true, artifacts: [doc] }); + rerender({ ...initial, artifacts: [doc] }); + rerender({ ...initial, artifacts: [doc, file("report.pdf")] }); + expect(result.current.selected).toEqual(doc); +}); + +test.each(["build.js", "build.py", "archive.zip", "image.png"])( + "does not auto-open intermediate or unsupported %s", + (name) => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + rerender({ ...initial, artifacts: [file(name)] }); + expect(result.current.selected).toBeNull(); + } +); + +test("does not auto-open missing sources or read-only views", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: { ...initial, enabled: false }, + }); + rerender({ ...initial, enabled: false, isRunning: true }); + rerender({ ...initial, enabled: false, artifacts: [file("report.docx")] }); + expect(result.current.selected).toBeNull(); + rerender({ ...initial, isRunning: true }); + rerender({ ...initial, artifacts: [file("missing.docx", "missing", "")] }); + expect(result.current.selected).toBeNull(); +}); + +test("reloading the same conversation resets generation observation", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + rerender({ ...initial, isLoading: true }); + rerender({ ...initial, artifacts: [file("history.docx")] }); + expect(result.current.selected).toBeNull(); +}); + +test("waits for a real preview source when a file marker arrives before upload", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + const pending = file("report.docx", "pending", ""); + rerender({ ...initial, isRunning: true, artifacts: [pending] }); + rerender({ ...initial, artifacts: [pending] }); + expect(result.current.selected).toBeNull(); + const uploadedAttachment: ArtifactAttachmentLike = { + ...pending.attachment, + preview_url: "https://files/report.docx", + }; + const uploaded: ConversationArtifact = { + ...pending, + attachment: uploadedAttachment, + }; + rerender({ ...initial, artifacts: [uploaded] }); + expect(result.current.selected).toEqual(uploaded); +}); diff --git a/frontend/tests/component/conversationArtifacts.test.tsx b/frontend/tests/component/conversationArtifacts.test.tsx new file mode 100644 index 0000000000..1ab762c7f8 --- /dev/null +++ b/frontend/tests/component/conversationArtifacts.test.tsx @@ -0,0 +1,769 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { + act, + fireEvent, + render, + screen, + cleanup, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { + collectConversationArtifacts, + fetchArtifactFile, + type ArtifactMessageLike, +} from "@/app/newchat/utils/conversation-artifacts"; +import { + ConversationArtifactsButton, + ConversationArtifactsToolbarEntry, + ConversationArtifactsPanel, +} from "@/app/newchat/ui/artifacts-panel"; +import { ConversationArtifactsEntryProvider } from "@/app/newchat/ui/conversation-artifacts-context"; +import { + compositeAttachmentAdapter, + markFileWithExistingUpload, +} from "@/app/newchat/adapter/attachment-adapter"; +import { skillFileUploadsRegistry } from "@/app/newchat/adapter/remote-chat-model-adapter"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import type { CompleteAttachment } from "@assistant-ui/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (params && "count" in params) return `${key}:${params.count}`; + if (params && "time" in params) return `${key}:${params.time}`; + return key; + }, + }), +})); + +// The aggregation util imports the artifact registry from the streaming +// adapter; mocking the module keeps the heavyweight adapter out of the test. +vi.mock("@/app/newchat/adapter/remote-chat-model-adapter", () => ({ + searchSourcesRegistry: new Map(), + searchImagesRegistry: new Map(), + conversationSourcesRegistry: new Map(), + skillFileUploadsRegistry: new Map(), + stepTokenCounts: [], +})); + +const previewDrawerMock = vi.hoisted(() => ({ + open: vi.fn(), +})); +vi.mock("@/components/common/filePreviewDrawer", () => ({ + FilePreviewDrawer: (props: { + open: boolean; + fileName: string; + objectName: string; + onClose: () => void; + }) => { + previewDrawerMock.open(props); + return props.open ? ( +
    {props.fileName}
    + ) : null; + }, +})); + +const storageServiceMock = vi.hoisted(() => ({ + downloadFile: vi.fn(), + getPreviewUrl: vi.fn(() => "blob:preview"), + uploadFiles: vi.fn(), +})); +vi.mock("@/services/storageService", () => ({ + storageService: storageServiceMock, + getLocalFilePreviewUrl: vi.fn(), + extractObjectNameFromUrl: vi.fn(), + fetchImageBlob: vi.fn(), + isLocalStorageObjectUrl: vi.fn(() => false), +})); + +vi.mock("@/lib/logger", () => ({ + default: { error: vi.fn(), warn: vi.fn(), log: vi.fn() }, +})); + +const makeAttachment = ( + id: string, + name: string, + overrides: Record = {} +): CompleteAttachment => + ({ + id, + status: { type: "complete" }, + type: "file", + name, + contentType: + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + object_name: `runs/${id}/${name}`, + preview_url: `blob:preview-${id}`, + download_url: `/api/storage/download/${id}`, + size: 2048, + ...overrides, + }) as unknown as CompleteAttachment; + +beforeEach(() => { + skillFileUploadsRegistry.clear(); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +test("collectConversationArtifacts reads streamed part payloads", () => { + const messages: ArtifactMessageLike[] = [ + { + id: "m1", + role: "user", + content: [{ type: "text" }], + }, + { + id: "m2", + role: "assistant", + createdAt: new Date("2026-10-31T08:10:00Z"), + content: [ + { type: "text" }, + { + type: "text", + skillFileAttachments: [ + makeAttachment("a1", "report.pptx"), + makeAttachment("a2", "data.csv"), + ], + }, + ], + }, + ]; + + const artifacts = collectConversationArtifacts(messages); + + expect(artifacts).toHaveLength(2); + expect(artifacts[0]?.attachment.name).toBe("report.pptx"); + expect(artifacts[1]?.attachment.name).toBe("data.csv"); + expect(artifacts[0]?.messageId).toBe("m2"); + expect(artifacts[0]?.key).toBe("m2:a1:0"); + expect(artifacts[0]?.createdAt).toEqual(new Date("2026-10-31T08:10:00Z")); +}); + +test("collectConversationArtifacts falls back to the history registry", () => { + skillFileUploadsRegistry.set("m-restored", [ + makeAttachment("r1", "restored.md"), + ]); + const messages: ArtifactMessageLike[] = [ + { + id: "m-restored", + role: "assistant", + content: [{ type: "text", isSkillFiles: true }], + }, + ]; + + const artifacts = collectConversationArtifacts(messages); + + expect(artifacts).toHaveLength(1); + expect(artifacts[0]?.attachment.name).toBe("restored.md"); +}); + +test("collectConversationArtifacts prefers the latest cumulative part", () => { + const messages: ArtifactMessageLike[] = [ + { + id: "m3", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("old", "old.txt")], + }, + { + type: "text", + skillFileAttachments: [ + makeAttachment("old", "old.txt"), + makeAttachment("new", "new.txt"), + ], + }, + ], + }, + ]; + + const artifacts = collectConversationArtifacts(messages); + + expect(artifacts).toHaveLength(2); + expect(artifacts.map((a) => a.attachment.name)).toEqual([ + "old.txt", + "new.txt", + ]); +}); + +test("collectConversationArtifacts ignores invalid createdAt values", () => { + const messages: ArtifactMessageLike[] = [ + { + id: "m4", + role: "assistant", + createdAt: new Date("not-a-date"), + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("a", "a.txt")], + }, + ], + }, + ]; + + const artifacts = collectConversationArtifacts(messages); + + expect(artifacts).toHaveLength(1); + expect(artifacts[0]?.createdAt).toBeUndefined(); +}); + +test("collectConversationArtifacts keeps keys unique across chunks", () => { + // Two streaming chunks restart the attachment id counter, so both files + // share the id "m9-skill-file-0" inside the cumulative part payload. + const messages: ArtifactMessageLike[] = [ + { + id: "m9", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [ + { ...makeAttachment("m9-skill-file-0", "first.txt") }, + { ...makeAttachment("m9-skill-file-0", "second.txt") }, + ], + }, + ], + }, + ]; + + const artifacts = collectConversationArtifacts(messages); + const keys = artifacts.map((artifact) => artifact.key); + + expect(artifacts).toHaveLength(2); + expect(new Set(keys).size).toBe(2); +}); + +test("collectConversationArtifacts returns empty for empty input", () => { + expect(collectConversationArtifacts([])).toEqual([]); +}); + +test("ConversationArtifactsPanel renders artifacts with actions", () => { + const artifacts = collectConversationArtifacts([ + { + id: "m5", + role: "assistant", + createdAt: new Date("2026-10-31T08:10:00Z"), + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("a1", "summary.pptx")], + }, + ], + }, + ]); + + render( + + {}} + /> + + ); + + expect(screen.getByText("summary.pptx")).toBeTruthy(); + expect(screen.getByText("chat.artifacts.total:1")).toBeTruthy(); + // Size (2.0 KB) and generated-at metadata render alongside the name. + expect(screen.getByText(/2\.0 KB/)).toBeTruthy(); +}); + +test("ConversationArtifactsPanel preview opens the shared drawer", () => { + const artifacts = collectConversationArtifacts([ + { + id: "m6", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("a1", "summary.pptx")], + }, + ], + }, + ]); + + render( + + {}} + /> + + ); + + fireEvent.click( + screen.getByRole("button", { name: "chat.artifacts.preview" }) + ); + + expect(previewDrawerMock.open).toHaveBeenCalledWith( + expect.objectContaining({ + open: true, + fileName: "summary.pptx", + objectName: "runs/a1/summary.pptx", + downloadUrl: "/api/storage/download/a1", + }) + ); + expect(screen.getByTestId("file-preview-drawer")).toBeTruthy(); +}); + +test("ConversationArtifactsPanel download uses the download url", () => { + const artifacts = collectConversationArtifacts([ + { + id: "m7", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("a1", "summary.pptx")], + }, + ], + }, + ]); + + render( + + {}} + /> + + ); + + fireEvent.click( + screen.getByRole("button", { name: "chat.artifacts.download" }) + ); + + // The anchor-based download path does not call the storage service. + expect(storageServiceMock.downloadFile).not.toHaveBeenCalled(); +}); + +test("ConversationArtifactsPanel download falls back to object storage", () => { + const artifacts = collectConversationArtifacts([ + { + id: "m8", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [ + makeAttachment("a1", "summary.pptx", { download_url: undefined }), + ], + }, + ], + }, + ]); + + render( + + {}} + /> + + ); + + fireEvent.click( + screen.getByRole("button", { name: "chat.artifacts.download" }) + ); + + expect(storageServiceMock.downloadFile).toHaveBeenCalledWith( + "runs/a1/summary.pptx", + "summary.pptx" + ); +}); + +test("ConversationArtifactsPanel hides when closed", () => { + const { container } = render( + {}} + /> + ); + + expect(container.firstChild).toBeNull(); +}); + +test("ConversationArtifactsPanel shows the empty state", () => { + render( + + {}} /> + + ); + + expect(screen.getByText("chat.artifacts.empty")).toBeTruthy(); +}); + +test("ConversationArtifactsButton hides without artifacts", () => { + const { container } = render( + {}} /> + ); + + expect(container.firstChild).toBeNull(); +}); + +test("ConversationArtifactsButton opens the panel on click", () => { + const onClick = vi.fn(); + render(); + + const button = screen.getByRole("button", { + name: "chat.artifacts.viewAll:3", + }); + fireEvent.click(button); + + expect(onClick).toHaveBeenCalledTimes(1); +}); + +test("ConversationArtifactsToolbarEntry renders nothing without a provider", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); +}); + +test("ConversationArtifactsToolbarEntry hides while the conversation has no artifacts", () => { + const { container } = render( + {}}> + + + ); + + expect(container.firstChild).toBeNull(); +}); + +test("ConversationArtifactsToolbarEntry shows the conversation total and opens the panel", () => { + const open = vi.fn(); + render( + + + + ); + + const button = screen.getByRole("button", { + name: "chat.artifacts.viewAll:2", + }); + fireEvent.click(button); + + expect(open).toHaveBeenCalledTimes(1); +}); + +test("ConversationArtifactsToolbarEntry persists after generation and resets for an empty conversation", () => { + const open = vi.fn(); + const Toolbar = ({ messages }: { messages: ArtifactMessageLike[] }) => ( + + + + ); + const generated: ArtifactMessageLike = { + id: "generated-1", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("file-1", "report.docx")], + }, + ], + }; + const laterTurns: ArtifactMessageLike[] = [ + generated, + { id: "follow-up", role: "user", content: [{ type: "text" }] }, + { id: "text-answer", role: "assistant", content: [{ type: "text" }] }, + ]; + const { container, rerender } = render(); + expect( + container.querySelector('[data-slot="conversation-artifacts-toolbar"]') + ).toBeNull(); + rerender(); + expect( + screen.getAllByRole("button", { name: "chat.artifacts.viewAll:1" }) + ).toHaveLength(1); + expect( + container.querySelector('[data-slot="conversation-artifacts-toolbar"]') + ?.className + ).toContain("justify-end"); + rerender(); + fireEvent.click( + screen.getByRole("button", { name: "chat.artifacts.viewAll:1" }) + ); + expect(open).toHaveBeenCalledTimes(1); + rerender( + + ); + expect( + screen.getAllByRole("button", { name: "chat.artifacts.viewAll:2" }) + ).toHaveLength(1); + rerender(); + expect(container.firstChild).toBeNull(); +}); + +const renderArtifactsPanel = ( + panelProps: Partial[0]> = {} +) => { + const artifacts = collectConversationArtifacts([ + { + id: "m-add", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("a1", "summary.pptx")], + }, + ], + }, + ]); + return render( + + {}} + {...panelProps} + /> + + ); +}; + +test("ConversationArtifactsPanel offers add-to-chat when a handler is provided", async () => { + const onAddToComposer = vi.fn(); + renderArtifactsPanel({ onAddToComposer }); + + const addButton = screen.getByRole("button", { + name: "chat.artifacts.addToChat", + }); + fireEvent.click(addButton); + + expect(onAddToComposer).toHaveBeenCalledTimes(1); + expect(onAddToComposer).toHaveBeenCalledWith( + expect.objectContaining({ name: "summary.pptx" }) + ); + // The in-flight guard releases once the handler settles. + await waitFor(() => expect(addButton).not.toBeDisabled()); +}); + +test("ConversationArtifactsPanel hides add-to-chat without a handler", () => { + renderArtifactsPanel(); + + expect( + screen.queryByRole("button", { name: "chat.artifacts.addToChat" }) + ).toBeNull(); +}); + +test("ConversationArtifactsPanel serializes rapid additions across different files", async () => { + let finish!: () => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const onAddToComposer = vi + .fn() + .mockReturnValueOnce(pending) + .mockResolvedValue(undefined); + const artifacts = collectConversationArtifacts([ + { + id: "multi", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [ + makeAttachment("a", "one.docx"), + makeAttachment("b", "two.docx"), + ], + }, + ], + }, + ]); + renderArtifactsPanel({ artifacts, onAddToComposer }); + const buttons = screen.getAllByRole("button", { + name: "chat.artifacts.addToChat", + }); + act(() => { + fireEvent.click(buttons[0]); + fireEvent.click(buttons[1]); + fireEvent.click(buttons[0]); + }); + expect(onAddToComposer).toHaveBeenCalledTimes(1); + expect( + buttons.every((button) => (button as HTMLButtonElement).disabled) + ).toBe(true); + await act(async () => { + finish(); + await pending; + }); + await waitFor(() => + expect( + buttons.every((button) => !(button as HTMLButtonElement).disabled) + ).toBe(true) + ); + fireEvent.click(buttons[1]); + await waitFor(() => expect(onAddToComposer).toHaveBeenCalledTimes(2)); + expect(onAddToComposer).toHaveBeenLastCalledWith( + expect.objectContaining({ name: "two.docx" }) + ); +}); + +test("ConversationArtifactsPanel ignores repeat clicks while adding", () => { + const onAddToComposer = vi.fn( + () => new Promise(() => {}) // never settles + ); + renderArtifactsPanel({ onAddToComposer }); + + const addButton = screen.getByRole("button", { + name: "chat.artifacts.addToChat", + }); + fireEvent.click(addButton); + fireEvent.click(addButton); + + expect(onAddToComposer).toHaveBeenCalledTimes(1); +}); + +test("fetchArtifactFile prefers the object storage stream endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + blob: async () => new Blob(["bytes"]), + }); + vi.stubGlobal("fetch", fetchMock); + + try { + const file = await fetchArtifactFile(makeAttachment("a1", "report.pptx")); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + "/file/download/runs/a1/report.pptx" + ); + expect(file.name).toBe("report.pptx"); + expect(file.size).toBeGreaterThan(0); + } finally { + vi.unstubAllGlobals(); + } +}); + +test("fetchArtifactFile falls back to the attachment download url", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + blob: async () => new Blob(["bytes"]), + }); + vi.stubGlobal("fetch", fetchMock); + + try { + const attachment = makeAttachment("a2", "notes.md", { + object_name: undefined, + contentType: "text/markdown", + }); + const file = await fetchArtifactFile(attachment); + + expect(fetchMock).toHaveBeenCalledWith("/api/storage/download/a2"); + expect(file.name).toBe("notes.md"); + expect(file.type).toBe("text/markdown"); + } finally { + vi.unstubAllGlobals(); + } +}); + +test("fetchArtifactFile rejects when no source is available", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + try { + const attachment = makeAttachment("a3", "lost.bin", { + object_name: undefined, + preview_url: undefined, + download_url: undefined, + }); + + await expect(fetchArtifactFile(attachment)).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } +}); + +const makePendingAttachment = (file: File, name: string) => + ({ + id: `att-${name}`, + status: { type: "running", reason: "composer-send" }, + type: "file", + name, + contentType: "application/vnd.api+json", + file, + }) as unknown as Parameters[0]; + +test("compositeAttachmentAdapter reuses stored objects for marked files", async () => { + const meta = { + object_name: "runs/a1/report.pptx", + url: "blob:preview-a1", + type: "application/vnd.api+json", + size: 2048, + }; + const file = markFileWithExistingUpload( + new File(["bytes"], "report.pptx"), + meta + ); + + const result = await compositeAttachmentAdapter.send( + makePendingAttachment(file, "report.pptx") + ); + + // The already-stored object must not be uploaded a second time. + expect(storageServiceMock.uploadFiles).not.toHaveBeenCalled(); + expect(result.status.type).toBe("complete"); + expect(result.content[0]).toMatchObject({ + type: "file", + data: "blob:preview-a1", + }); + expect(result).toMatchObject({ object_name: meta.object_name }); +}); + +test("compositeAttachmentAdapter still uploads unmarked files", async () => { + storageServiceMock.uploadFiles.mockResolvedValue({ + success_count: 1, + results: [ + { + success: true, + object_name: "attachments/report.pptx", + url: "blob:url", + presigned_url: "blob:presigned", + file_size: 2048, + }, + ], + }); + + const result = await compositeAttachmentAdapter.send( + makePendingAttachment(new File(["bytes"], "report.pptx"), "report.pptx") + ); + + expect(storageServiceMock.uploadFiles).toHaveBeenCalledTimes(1); + expect(result.status.type).toBe("complete"); + expect(result).toMatchObject({ object_name: "attachments/report.pptx" }); + expect(result.content[0]).toMatchObject({ + type: "file", + data: "blob:presigned", + }); +}); diff --git a/frontend/tests/component/conversationBranch.test.tsx b/frontend/tests/component/conversationBranch.test.tsx new file mode 100644 index 0000000000..220577ee6c --- /dev/null +++ b/frontend/tests/component/conversationBranch.test.tsx @@ -0,0 +1,148 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +import { + computeBranchTurnIndex, + type BranchMessageLike, +} from "@/app/newchat/utils/conversation-branch"; +import { ConversationBranchMenuItem } from "@/app/newchat/assistant-ui/conversation-branch-menu-item"; +import { ConversationBranchContext } from "@/app/newchat/assistant-ui/conversation-branch-context"; + +const auiMocks = vi.hoisted(() => ({ + getState: vi.fn(() => ({ id: "assistant-2", branchNumber: 2 })), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@assistant-ui/react", () => ({ + useAui: () => ({ message: { getState: auiMocks.getState } }), + ActionBarMorePrimitive: { + Item: ({ + onSelect, + disabled, + children, + }: { + onSelect?: () => void; + disabled?: boolean; + children: React.ReactNode; + }) => ( + + ), + }, +})); + +const makeMessage = (id: string, role: string): BranchMessageLike => ({ + id, + role, +}); + +const renderMenuItem = ( + contextValue: { + disabled: boolean; + branchFromMessage: ( + assistantMessageId: string, + answerIndex: number + ) => void; + } | null +) => + render( + contextValue ? ( + + + + ) : ( + + ) + ); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +test("computeBranchTurnIndex counts turns up to the selected answer", () => { + const messages = [ + makeMessage("u1", "user"), + makeMessage("a1", "assistant"), + makeMessage("u2", "user"), + makeMessage("a2", "assistant"), + makeMessage("u3", "user"), + makeMessage("a3", "assistant"), + ]; + + expect(computeBranchTurnIndex(messages, "a1")).toBe(1); + expect(computeBranchTurnIndex(messages, "a2")).toBe(2); + expect(computeBranchTurnIndex(messages, "a3")).toBe(3); +}); + +test("computeBranchTurnIndex ignores non-user rows while keeping turn order", () => { + const messages = [ + makeMessage("s1", "system"), + makeMessage("u1", "user"), + makeMessage("s2", "system"), + makeMessage("a1", "assistant"), + ]; + + expect(computeBranchTurnIndex(messages, "a1")).toBe(1); +}); + +test("computeBranchTurnIndex returns null for unknown ids or missing questions", () => { + const messages = [makeMessage("u1", "user"), makeMessage("a1", "assistant")]; + + expect(computeBranchTurnIndex(messages, "missing")).toBeNull(); + expect(computeBranchTurnIndex([], "a1")).toBeNull(); + expect( + computeBranchTurnIndex([makeMessage("a0", "assistant")], "a0") + ).toBeNull(); +}); + +test("menu item invokes the branch handler with the aui message id", () => { + const branchFromMessage = vi.fn(); + + renderMenuItem({ disabled: false, branchFromMessage }); + fireEvent.click(screen.getByTestId("branch-item")); + + expect(branchFromMessage).toHaveBeenCalledTimes(1); + expect(branchFromMessage).toHaveBeenCalledWith("assistant-2", 2); +}); + +test("menu item reads the selected answer version at click time", () => { + const branchFromMessage = vi.fn(); + renderMenuItem({ disabled: false, branchFromMessage }); + auiMocks.getState.mockReturnValueOnce({ id: "123", branchNumber: 1 }); + fireEvent.click(screen.getByTestId("branch-item")); + expect(branchFromMessage).toHaveBeenCalledWith("123", 1); +}); + +test("menu item surfaces the localized branch label", () => { + renderMenuItem({ disabled: false, branchFromMessage: vi.fn() }); + + expect(screen.getByText("chat.thread.branchToNewChat")).toBeTruthy(); +}); + +test("menu item is disabled when branching is unavailable", () => { + renderMenuItem({ disabled: true, branchFromMessage: vi.fn() }); + + const item = screen.getByTestId("branch-item") as HTMLButtonElement; + expect(item.disabled).toBe(true); +}); + +test("menu item renders nothing without the branch context", () => { + renderMenuItem(null); + + expect(screen.queryByTestId("branch-item")).toBeNull(); +}); diff --git a/frontend/tests/component/conversationBranchRequest.test.ts b/frontend/tests/component/conversationBranchRequest.test.ts new file mode 100644 index 0000000000..789a2daf6c --- /dev/null +++ b/frontend/tests/component/conversationBranchRequest.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { beforeEach, expect, test, vi } from "vitest"; +import { conversationService } from "@/services/conversationService"; + +const mocks = vi.hoisted(() => ({ fetch: vi.fn() })); +vi.mock("@/lib/auth", () => ({ + getAuthHeaders: () => ({ Authorization: "Bearer test" }), + fetchWithAuth: mocks.fetch, +})); +vi.mock("@/services/api", () => ({ + API_ENDPOINTS: { conversation: { branch: "/conversation/branch" } }, + ApiError: class extends Error {}, + fetchWithErrorHandling: vi.fn(), +})); +vi.mock("i18next", () => ({ t: (key: string) => key })); + +beforeEach(() => { + mocks.fetch.mockReset(); + mocks.fetch.mockResolvedValue({ + json: async () => ({ code: 0, data: { conversation_id: 42 } }), + }); +}); + +test("restored replies send their exact persisted ID and selected version", async () => { + await expect(conversationService.branch(7, 2, "123", 1)).resolves.toEqual({ + conversation_id: 42, + }); + expect(JSON.parse(mocks.fetch.mock.calls[0][1].body)).toEqual({ + conversation_id: 7, + turn_index: 2, + source_message_id: 123, + answer_index: 1, + }); +}); + +test.each(["runtime-uuid", "msg_123", "9007199254740993"])( + "live or unsafe ID %s uses the runtime answer version", + async (id) => { + await conversationService.branch(7, 2, id, 2); + expect(JSON.parse(mocks.fetch.mock.calls[0][1].body)).toEqual({ + conversation_id: 7, + turn_index: 2, + answer_index: 2, + }); + } +); + +test("legacy callers retain the original request body", async () => { + await conversationService.branch(7, 2); + expect(JSON.parse(mocks.fetch.mock.calls[0][1].body)).toEqual({ + conversation_id: 7, + turn_index: 2, + }); +}); + +test("failed branch requests do not return a new conversation", async () => { + mocks.fetch.mockRejectedValue(new Error("Network failed")); + await expect(conversationService.branch(7, 2, "123", 2)).rejects.toThrow( + "Network failed" + ); +}); diff --git a/frontend/tests/component/docxPageLayout.test.tsx b/frontend/tests/component/docxPageLayout.test.tsx new file mode 100644 index 0000000000..b4a7e314e7 --- /dev/null +++ b/frontend/tests/component/docxPageLayout.test.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import JSZip from "jszip"; +import { renderAsync } from "docx-preview"; +import { expect, test } from "vitest"; +import { applyDocxPageDefaults } from "@/lib/officePreview"; + +async function renderDocument(section = "") { + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + `` + ); + zip.file( + "_rels/.rels", + `` + ); + zip.file( + "word/document.xml", + `你好!这是一个简单的Word文档。${section}` + ); + const body = document.createElement("div"); + const styles = document.createElement("div"); + document.body.append(body, styles); + await renderAsync( + await zip.generateAsync({ type: "arraybuffer" }), + body, + styles, + { inWrapper: true, renderAltChunks: false } + ); + return { + body, + styles, + page: body.querySelector("section.docx")!, + }; +} + +test("gives a real generated DOCX without section metadata a full paper page and margins", async () => { + const { body, styles, page } = await renderDocument(); + try { + expect(page.style.width).toBe(""); + expect(page.style.minHeight).toBe(""); + applyDocxPageDefaults(body); + expect(page.style.width).toBe("min(210mm, 100%)"); + expect(page.style.minHeight).toBe("297mm"); + expect(page.style.paddingTop).toBe("25.4mm"); + expect(page.textContent).toContain("这是一个简单的Word文档"); + } finally { + body.remove(); + styles.remove(); + } +}); + +test("preserves explicit landscape dimensions and zero/custom margins from the document", async () => { + const { body, styles, page } = await renderDocument( + '' + ); + try { + const original = page.getAttribute("style"); + expect(page.style.width).not.toBe(""); + expect(page.style.minHeight).not.toBe(""); + applyDocxPageDefaults(body); + expect(page.getAttribute("style")).toBe(original); + } finally { + body.remove(); + styles.remove(); + } +}); + +test("fills only missing values and leaves non-document content untouched", () => { + const body = document.createElement("div"); + body.innerHTML = + '

    One

    Two

    Other
    '; + applyDocxPageDefaults(body); + const pages = body.querySelectorAll("section.docx"); + expect(pages[0].style.width).toBe("500px"); + expect(pages[0].style.height).toBe("700px"); + expect(pages[0].style.minHeight).toBe(""); + expect(pages[0].style.paddingTop).toBe("0px"); + expect(pages[0].style.paddingRight).toBe("25.4mm"); + expect(pages[1].style.minHeight).toBe("297mm"); + expect( + body.querySelector("section:not(.docx)")!.style.cssText + ).toBe("width: 300px;"); + const once = body.innerHTML; + applyDocxPageDefaults(body); + expect(body.innerHTML).toBe(once); +}); diff --git a/frontend/tests/component/docxPreviewBackground.test.tsx b/frontend/tests/component/docxPreviewBackground.test.tsx new file mode 100644 index 0000000000..e421cdbac4 --- /dev/null +++ b/frontend/tests/component/docxPreviewBackground.test.tsx @@ -0,0 +1,93 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { render, waitFor } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import JSZip from "jszip"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { RawStreamPreviewViewer } from "@/components/common/rawStreamPreviewViewer"; +import { fetchPreviewBlobWithMeta } from "@/lib/filePreviewUtils"; +import log from "@/lib/logger"; +import styles from "@/components/common/rawStreamPreviewViewer.module.css"; + +let stylesheet: HTMLStyleElement; +beforeEach(() => { + // Apply the actual module rules with their exported scope in jsdom. + const css = readFileSync( + resolve( + dirname(fileURLToPath(import.meta.url)), + "../../components/common/rawStreamPreviewViewer.module.css" + ), + "utf8" + ); + stylesheet = document.createElement("style"); + stylesheet.textContent = css + .replace(/:global\(([^)]+)\)/g, "$1") + .replaceAll(".docxPages", `.${styles.docxPages}`); + document.head.append(stylesheet); +}); +afterEach(() => stylesheet?.remove()); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/lib/logger", () => ({ + default: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); +vi.mock("@/lib/filePreviewUtils", async (original) => ({ + ...(await original()), + fetchPreviewBlobWithMeta: vi.fn(), +})); + +async function documentBytes(background?: string) { + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + `` + ); + zip.file( + "_rels/.rels", + `` + ); + zip.file( + "word/document.xml", + `${background ? `` : ""}Document paper` + ); + return zip.generateAsync({ type: "arraybuffer" }); +} + +test.each([ + [undefined, "rgb(255, 255, 255)"], + ["FFEECC", "rgb(255, 238, 204)"], +])( + "real DOCX rendering uses paper color %s instead of a transparent page", + async (background, expected) => { + const bytes = await documentBytes(background); + vi.mocked(fetchPreviewBlobWithMeta).mockResolvedValue({ + blob: { arrayBuffer: async () => bytes }, + fileName: "report.docx", + mimeType: "", + } as never); + const { container } = render( + + ); + await waitFor(() => { + expect( + vi.mocked(log.error).mock.calls.map((call) => String(call[1])) + ).toEqual([]); + const page = container.querySelector( + "section.docx" + ) as HTMLElement | null; + expect(page).not.toBeNull(); + expect(page?.textContent).toContain("Document paper"); + expect(window.getComputedStyle(page!).backgroundColor).toBe(expected); + expect(window.getComputedStyle(page!).boxShadow).toBe("none"); + const wrapper = page!.parentElement!; + expect(window.getComputedStyle(wrapper).backgroundColor).toBe( + "rgba(0, 0, 0, 0)" + ); + expect(window.getComputedStyle(wrapper).padding).toBe("0px"); + }); + } +); diff --git a/frontend/tests/component/inlineFilePreview.test.tsx b/frontend/tests/component/inlineFilePreview.test.tsx new file mode 100644 index 0000000000..79cddc6ea6 --- /dev/null +++ b/frontend/tests/component/inlineFilePreview.test.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { render, screen, waitFor } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { FilePreviewDrawer } from "@/components/common/filePreviewDrawer"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/lib/logger", () => ({ + default: { error: vi.fn(), warn: vi.fn(), log: vi.fn() }, +})); +vi.mock("@/services/storageService", () => ({ + storageService: { getPreviewUrl: () => "/preview", downloadFile: vi.fn() }, +})); +vi.mock("@/components/common/markdownRenderer", () => ({ + MarkdownRenderer: () => null, + extractMarkdownHeadings: () => [], +})); +vi.mock("@/components/common/rawStreamPreviewViewer", () => ({ + RawStreamPreviewViewer: ({ url }: { url: string }) => ( +
    {url}
    + ), +})); + +const props = { + open: true, + onClose: vi.fn(), + objectName: "workspace/report.docx", + fileName: "report.docx", + previewUrl: "/original.docx", + rawStream: true, +}; + +test("inline mode mounts preview content without a modal or drawer", async () => { + const { container } = render( + + ); + expect( + container.querySelector('[data-slot="inline-file-preview"]') + ).not.toBeNull(); + expect(await screen.findByTestId("stream-viewer")).toHaveTextContent( + "/original.docx" + ); + expect(document.querySelector(".ant-drawer")).toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); +}); + +test("closed inline mode does not leave a document visible", () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); +}); + +test("default drawer presentation stays available", async () => { + render(); + await waitFor(() => + expect(document.querySelector(".ant-drawer")).not.toBeNull() + ); + expect(await screen.findByTestId("stream-viewer")).toHaveTextContent( + "/original.docx" + ); +}); + +test("fullscreen presentation retains its existing back action", async () => { + const close = vi.fn(); + render( + + ); + expect( + await screen.findByRole("button", { name: "common.back" }) + ).toBeVisible(); + expect(await screen.findByTestId("stream-viewer")).toHaveTextContent( + "/original.docx" + ); +}); diff --git a/frontend/tests/component/messageAnnotation.test.tsx b/frontend/tests/component/messageAnnotation.test.tsx new file mode 100644 index 0000000000..5ad68b9972 --- /dev/null +++ b/frontend/tests/component/messageAnnotation.test.tsx @@ -0,0 +1,681 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { + act, + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { + createMessageAnnotation, + fromMessageAnnotationWireItems, + getAnnotationSourceExcerpt, + MESSAGE_ANNOTATION_COMMENT_MAX, + MESSAGE_ANNOTATION_MAX_COUNT, + MESSAGE_ANNOTATION_QUOTED_TEXT_MAX, + resolveAnnotationSelection, + toMessageAnnotationWireItems, + truncateAnnotationText, + type MessageAnnotation, +} from "@/app/newchat/utils/message-annotations"; +import { ComposerAnnotationChip } from "@/app/newchat/assistant-ui/annotation-chip"; +import { MessageAnnotationCards } from "@/app/newchat/assistant-ui/message-annotation-cards"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@assistant-ui/react", () => ({ + useAuiState: (selector: (state: unknown) => unknown) => + selector({ message: { role: "user" } }), +})); + +const makeAnnotation = ( + overrides: Partial = {} +): MessageAnnotation => ({ + id: "annotation-1", + quotedText: "quoted reply text", + comment: undefined, + sourceMessageId: "assistant-1", + sourceExcerpt: "reply intro", + ...overrides, +}); + +afterEach(cleanup); + +describe("toMessageAnnotationWireItems", () => { + test("drops legacy context from creation, outgoing and history mappings", () => { + const annotation = createMessageAnnotation( + { + quotedText: "this approach", + sourceMessageId: "assistant-1", + contextBefore: "x".repeat(250) + "Redis ", + contextAfter: " shares state." + "y".repeat(250), + } as Parameters[0], + [], + "snapshot" + ); + const wire = toMessageAnnotationWireItems([annotation]); + expect(annotation).not.toHaveProperty("contextBefore"); + expect(annotation).not.toHaveProperty("contextAfter"); + expect(wire[0]).toEqual({ + quoted_text: "this approach", + comment: null, + source_excerpt: null, + }); + const restored = fromMessageAnnotationWireItems([ + { ...wire[0], context_before: "old before", context_after: "old after" }, + { quoted_text: "q", context_before: 42, context_after: {} }, + ]); + expect(restored).toEqual([ + wire[0], + { quoted_text: "q", comment: null, source_excerpt: null }, + ]); + }); + test("maps camelCase annotations onto the snake_case wire shape", () => { + const items = toMessageAnnotationWireItems([ + makeAnnotation({ comment: "note", sourceExcerpt: "reply intro" }), + makeAnnotation({ + id: "a2", + quotedText: "second", + comment: undefined, + sourceExcerpt: "", + }), + ]); + + expect(items).toEqual([ + { + quoted_text: "quoted reply text", + comment: "note", + source_excerpt: "reply intro", + }, + { quoted_text: "second", comment: null, source_excerpt: null }, + ]); + }); + + test("caps the list length and truncates oversized fields", () => { + const many = Array.from( + { length: MESSAGE_ANNOTATION_MAX_COUNT + 3 }, + (_, i) => makeAnnotation({ id: `a${i}`, quotedText: `${i}` }) + ); + const items = toMessageAnnotationWireItems(many); + expect(items).toHaveLength(MESSAGE_ANNOTATION_MAX_COUNT); + + const oversized = toMessageAnnotationWireItems([ + makeAnnotation({ + quotedText: "q".repeat(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + 10), + comment: "c".repeat(MESSAGE_ANNOTATION_COMMENT_MAX + 10), + }), + ]); + expect(oversized[0].quoted_text).toHaveLength( + MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + ); + expect(oversized[0].comment).toHaveLength(MESSAGE_ANNOTATION_COMMENT_MAX); + }); +}); + +describe("fromMessageAnnotationWireItems", () => { + test("validates entries and drops unusable ones", () => { + const items = fromMessageAnnotationWireItems([ + { quoted_text: "one", comment: "note", source_excerpt: "intro" }, + { quoted_text: " " }, + { quoted_text: "" }, + { comment: "no quote" }, + "not-an-object", + ]); + + expect(items).toEqual([ + { quoted_text: "one", comment: "note", source_excerpt: "intro" }, + ]); + }); + + test("returns an empty list for non-array payloads", () => { + expect(fromMessageAnnotationWireItems(undefined)).toEqual([]); + expect(fromMessageAnnotationWireItems(null)).toEqual([]); + expect(fromMessageAnnotationWireItems({ quoted_text: "x" })).toEqual([]); + }); + + test("re-applies the defensive caps for oversized persisted payloads", () => { + const items = fromMessageAnnotationWireItems([ + { + quoted_text: "q".repeat(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + 5), + comment: null, + source_excerpt: null, + }, + ]); + expect(items[0].quoted_text).toHaveLength( + MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + ); + }); + + test("round-trips payloads produced by the outgoing mapping", () => { + const outgoing = toMessageAnnotationWireItems([ + makeAnnotation({ comment: "note" }), + ]); + expect(fromMessageAnnotationWireItems(outgoing)).toEqual(outgoing); + }); +}); + +describe("truncateAnnotationText", () => { + test("keeps short text and collapses whitespace", () => { + expect(truncateAnnotationText(" hello world ", 20)).toBe("hello world"); + }); + + test("truncates long text with an ellipsis at the limit", () => { + expect(truncateAnnotationText("abcdefghij", 4)).toBe("abc…"); + expect(truncateAnnotationText("abcdefghij", 10)).toBe("abcdefghij"); + expect(truncateAnnotationText(" ", 10)).toBe(""); + }); +}); + +describe("getAnnotationSourceExcerpt", () => { + test("joins text parts of the assistant message and skips other parts", () => { + const messages = [ + { + id: "user-1", + role: "user", + content: [{ type: "text", text: "question" }], + }, + { + id: "assistant-1", + role: "assistant", + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "first " }, + { type: "text", text: "second" }, + { type: "tool-call" }, + ], + }, + ]; + + expect(getAnnotationSourceExcerpt(messages, "assistant-1")).toBe( + "first second" + ); + }); + + test("returns an empty excerpt for user or unknown messages", () => { + const messages = [ + { + id: "user-1", + role: "user", + content: [{ type: "text", text: "question" }], + }, + ]; + + expect(getAnnotationSourceExcerpt(messages, "user-1")).toBe(""); + expect(getAnnotationSourceExcerpt(messages, "missing")).toBe(""); + }); + + test("truncates long replies to the excerpt limit", () => { + const long = "x".repeat(60); + const messages = [ + { + id: "assistant-1", + role: "assistant", + content: [{ type: "text", text: long }], + }, + ]; + + const excerpt = getAnnotationSourceExcerpt(messages, "assistant-1"); + expect(excerpt).toHaveLength(40); + expect(excerpt.endsWith("…")).toBe(true); + }); +}); + +describe("createMessageAnnotation", () => { + test("uses the given id, trims the comment and resolves the excerpt", () => { + const messages = [ + { + id: "assistant-1", + role: "assistant", + content: [{ type: "text", text: "reply intro text" }], + }, + ]; + + const annotation = createMessageAnnotation( + { + quotedText: " quoted ", + comment: " keep this ", + sourceMessageId: "assistant-1", + }, + messages, + "generated-id" + ); + + expect(annotation).toEqual({ + id: "generated-id", + quotedText: " quoted ", + comment: "keep this", + sourceMessageId: "assistant-1", + sourceExcerpt: "reply intro text", + }); + }); + + test("drops whitespace-only comments", () => { + const annotation = createMessageAnnotation( + { quotedText: "q", comment: " ", sourceMessageId: "assistant-1" }, + [], + "generated-id" + ); + + expect(annotation.comment).toBeUndefined(); + }); + + test("applies the defensive caps to oversized quotes and comments", () => { + const annotation = createMessageAnnotation( + { + quotedText: "q".repeat(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + 5), + comment: "c".repeat(MESSAGE_ANNOTATION_COMMENT_MAX + 5), + sourceMessageId: "assistant-1", + }, + [], + "generated-id" + ); + + expect(annotation.quotedText).toHaveLength( + MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + ); + expect(annotation.comment).toHaveLength(MESSAGE_ANNOTATION_COMMENT_MAX); + }); +}); + +describe("resolveAnnotationSelection", () => { + test("captures only the selected occurrence without surrounding paragraph text", () => { + const root = document.createElement("div"); + root.dataset.role = "assistant"; + root.dataset.messageId = "a1"; + root.innerHTML = `

    irrelevant intro

    local cache: same. Redis cache: same is shared.

    `; + document.body.append(root); + const paragraph = root.querySelectorAll("p")[1]; + const node = paragraph.firstChild!; + const range = document.createRange(); + const start = node.textContent!.lastIndexOf("same"); + range.setStart(node, start); + range.setEnd(node, start + 4); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + const target = resolveAnnotationSelection(selection)!; + expect(target.text).toBe("same"); + expect(target.messageId).toBe("a1"); + expect(target).not.toHaveProperty("contextBefore"); + expect(target).not.toHaveProperty("contextAfter"); + paragraph.textContent = "replacement"; + expect(target.text).toBe("same"); + selection.removeAllRanges(); + root.remove(); + }); + + test("ignores neighboring paragraphs while still rejecting selections across tools", () => { + const root = document.createElement("div"); + root.dataset.role = "assistant"; + root.dataset.messageId = "a1"; + root.innerHTML = `

    ${"b".repeat(250)}

    secret tool result

    quote

    ${"a".repeat(250)}

    `; + document.body.append(root); + const node = root.querySelectorAll("p")[1].firstChild!; + const range = document.createRange(); + range.selectNodeContents(node); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + const target = resolveAnnotationSelection(selection)!; + expect(target.text).toBe("quote"); + expect(target).not.toHaveProperty("contextBefore"); + expect(target).not.toHaveProperty("contextAfter"); + // Crossing an excluded card is invalid even with valid endpoints. + range.setStart(root.querySelectorAll("p")[0].firstChild!, 0); + range.setEnd(node, 5); + selection.removeAllRanges(); + selection.addRange(range); + expect(resolveAnnotationSelection(selection)).toBeNull(); + selection.removeAllRanges(); + root.remove(); + }); + const buildAssistantMessage = (id: string) => { + const root = document.createElement("div"); + root.setAttribute("data-role", "assistant"); + root.setAttribute("data-message-id", id); + const content = document.createElement("div"); + content.setAttribute("data-slot", "aui_assistant-message-content"); + const paragraph = document.createElement("p"); + paragraph.textContent = "quotable text here"; + content.appendChild(paragraph); + root.appendChild(content); + const chain = document.createElement("div"); + chain.setAttribute("data-slot", "aui_chain-of-thought"); + const chainText = document.createElement("span"); + chainText.textContent = "reasoning content"; + chain.appendChild(chainText); + root.appendChild(chain); + const toolGroup = document.createElement("div"); + toolGroup.className = "aui-tool-group-root"; + const toolText = document.createElement("span"); + toolText.textContent = "tool content"; + toolGroup.appendChild(toolText); + root.appendChild(toolGroup); + document.body.appendChild(root); + return { root, paragraph, chainText, toolText }; + }; + + const makeSelection = ( + anchorNode: Node, + focusNode: Node, + text: string, + isCollapsed = false + ) => + ({ + isCollapsed, + rangeCount: 1, + anchorNode, + focusNode, + toString: () => text, + getRangeAt: () => { + const range = document.createRange(); + range.setStart(anchorNode, 0); + range.setEnd(focusNode, focusNode.textContent?.length ?? 0); + return range; + }, + }) as unknown as Selection; + + test("resolves a selection inside one assistant message", () => { + const { paragraph } = buildAssistantMessage("assistant-1"); + const textNode = paragraph.firstChild as Node; + + const target = resolveAnnotationSelection( + makeSelection(textNode, textNode, "quotable text") + ); + + expect(target).not.toBeNull(); + expect(target?.messageId).toBe("assistant-1"); + expect(target?.text).toBe("quotable text"); + expect(target?.range).toBeInstanceOf(Range); + }); + + test("rejects collapsed and empty selections", () => { + const { paragraph } = buildAssistantMessage("assistant-1"); + const textNode = paragraph.firstChild as Node; + + expect( + resolveAnnotationSelection( + makeSelection(textNode, textNode, "quotable", true) + ) + ).toBeNull(); + expect( + resolveAnnotationSelection(makeSelection(textNode, textNode, " ")) + ).toBeNull(); + expect(resolveAnnotationSelection(null)).toBeNull(); + }); + + test("rejects selections inside reasoning and tool containers", () => { + const { paragraph, chainText, toolText } = + buildAssistantMessage("assistant-1"); + const contentNode = paragraph.firstChild as Node; + const chainNode = chainText.firstChild as Node; + const toolNode = toolText.firstChild as Node; + + expect( + resolveAnnotationSelection( + makeSelection(chainNode, chainNode, "reasoning content") + ) + ).toBeNull(); + expect( + resolveAnnotationSelection( + makeSelection(toolNode, toolNode, "tool content") + ) + ).toBeNull(); + // Spans from the reply content into a reasoning block. + expect( + resolveAnnotationSelection( + makeSelection(contentNode, chainNode, "crossing") + ) + ).toBeNull(); + }); + + test("rejects selections outside or across assistant messages", () => { + const { paragraph } = buildAssistantMessage("assistant-1"); + const other = buildAssistantMessage("assistant-2"); + const userMessage = document.createElement("div"); + userMessage.setAttribute("data-role", "user"); + const userText = document.createElement("p"); + userText.textContent = "user text"; + userMessage.appendChild(userText); + document.body.appendChild(userMessage); + + const contentNode = paragraph.firstChild as Node; + const otherNode = other.paragraph.firstChild as Node; + const userNode = userText.firstChild as Node; + + // Endpoints in two different messages. + expect( + resolveAnnotationSelection(makeSelection(contentNode, otherNode, "two")) + ).toBeNull(); + // User message selection. + expect( + resolveAnnotationSelection(makeSelection(userNode, userNode, "user text")) + ).toBeNull(); + }); +}); + +describe("ComposerAnnotationChip", () => { + const baseAnnotations = (): MessageAnnotation[] => [ + makeAnnotation({ + id: "a1", + quotedText: "first quote", + comment: "first note", + }), + makeAnnotation({ + id: "a2", + quotedText: "second quote", + comment: undefined, + }), + ]; + + const renderChip = (annotations: MessageAnnotation[]) => + render( + + ); + + test("renders nothing without annotations", () => { + const { container } = renderChip([]); + expect(container.firstChild).toBeNull(); + }); + + test("keeps the list open when keyboard focus enters its actions", async () => { + renderChip(baseAnnotations()); + const trigger = screen.getByRole("button", { + name: "chat.annotation.listTitle", + }); + fireEvent.focus(trigger); + const edit = screen.getAllByRole("button", { + name: "chat.annotation.editComment", + })[0]; + fireEvent.blur(trigger); + fireEvent.focus(edit); + await act(() => new Promise((resolve) => setTimeout(resolve, 220))); + expect(screen.getByRole("dialog")).toBeVisible(); + fireEvent.blur(edit); + await act(() => new Promise((resolve) => setTimeout(resolve, 220))); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + test("opens the annotation list on hover and lists every entry", () => { + renderChip(baseAnnotations()); + + const trigger = screen.getByText("chat.annotation.chipLabel"); + fireEvent.mouseEnter(trigger); + + const list = screen.getByRole("list"); + const items = within(list).getAllByRole("listitem"); + expect(items).toHaveLength(2); + expect(within(items[0]).getByText(/first quote/)).toBeTruthy(); + expect(within(items[0]).getByText(/first note/)).toBeTruthy(); + expect(within(items[1]).getByText(/second quote/)).toBeTruthy(); + }); + + test("removes a single annotation from the list", () => { + const onRemove = vi.fn(); + render( + + ); + + fireEvent.mouseEnter(screen.getByText("chat.annotation.chipLabel")); + fireEvent.click( + screen.getAllByRole("button", { + name: "chat.annotation.deleteAnnotation", + })[0] + ); + + expect(onRemove).toHaveBeenCalledWith("a1"); + }); + + test("clears all annotations from the chip", () => { + const onClear = vi.fn(); + render( + + ); + + fireEvent.click( + screen.getByRole("button", { name: "chat.annotation.clearAnnotations" }) + ); + + expect(onClear).toHaveBeenCalledTimes(1); + }); + + test("edits a comment inline and saves the trimmed value", () => { + const onEditComment = vi.fn(); + render( + + ); + + fireEvent.mouseEnter(screen.getByText("chat.annotation.chipLabel")); + fireEvent.click( + screen.getAllByRole("button", { + name: "chat.annotation.editComment", + })[0] + ); + + const input = screen.getByLabelText("chat.annotation.editPlaceholder"); + fireEvent.change(input, { target: { value: " updated note " } }); + fireEvent.click( + screen.getByRole("button", { name: "chat.annotation.saveComment" }) + ); + + expect(onEditComment).toHaveBeenCalledWith("a1", "updated note"); + }); + + test("cancels editing without saving", () => { + const onEditComment = vi.fn(); + render( + + ); + + fireEvent.mouseEnter(screen.getByText("chat.annotation.chipLabel")); + fireEvent.click( + screen.getAllByRole("button", { + name: "chat.annotation.editComment", + })[0] + ); + fireEvent.click( + screen.getByRole("button", { name: "chat.annotation.cancelEdit" }) + ); + + expect(onEditComment).not.toHaveBeenCalled(); + // The edit entry point is rendered again after cancelling. + expect( + screen.getAllByRole("button", { name: "chat.annotation.editComment" }) + .length + ).toBeGreaterThan(0); + }); +}); + +describe("MessageAnnotationCards", () => { + test("renders nothing without annotations", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + test("shows a collapsed badge first and reveals cards on click", () => { + const { container } = render( + + ); + + const badge = screen.getByRole("button", { + name: "chat.annotation.chipLabel", + }); + expect(badge.getAttribute("aria-expanded")).toBe("false"); + expect( + container.querySelectorAll("[data-slot='aui_message-annotation-card']") + ).toHaveLength(0); + + fireEvent.click(badge); + + expect(badge.getAttribute("aria-expanded")).toBe("true"); + const cards = container.querySelectorAll( + "[data-slot='aui_message-annotation-card']" + ); + expect(cards).toHaveLength(2); + expect(screen.getByText("first quote")).toBeTruthy(); + expect(screen.getByText(/first note/)).toBeTruthy(); + expect(screen.getByText(/chat.annotation.cardSourceLabel/)).toBeTruthy(); + expect(screen.getByText("second quote")).toBeTruthy(); + }); + + test("drops invalid entries instead of rendering broken cards", () => { + const { container } = render( + + ); + + fireEvent.click( + screen.getByRole("button", { name: "chat.annotation.chipLabel" }) + ); + + expect( + container.querySelectorAll("[data-slot='aui_message-annotation-card']") + ).toHaveLength(1); + expect(screen.getByText("valid")).toBeTruthy(); + }); +}); diff --git a/frontend/tests/component/questionDirectory.test.tsx b/frontend/tests/component/questionDirectory.test.tsx new file mode 100644 index 0000000000..2546cb4622 --- /dev/null +++ b/frontend/tests/component/questionDirectory.test.tsx @@ -0,0 +1,369 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { + cleanup, + fireEvent, + render, + renderHook, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +import { + collectQuestionDirectory, + scrollThreadToMessage, + useActiveQuestionId, + type QuestionMessageLike, +} from "@/app/newchat/utils/question-directory"; +import { QuestionDirectoryRail } from "@/app/newchat/ui/question-directory-rail"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (params && "count" in params) return `${key}:${params.count}`; + if (params && "index" in params) return `${key}:${params.index}`; + return key; + }, + }), +})); + +vi.mock("@/lib/logger", () => ({ + default: { error: vi.fn(), warn: vi.fn(), log: vi.fn() }, +})); + +const makeMessage = ( + id: string, + role: string, + overrides: Record = {} +): QuestionMessageLike => + ({ + id, + role, + content: [{ type: "text", text: `question ${id}` }], + ...overrides, + }) as QuestionMessageLike; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +test("QuestionDirectoryRail hides for an empty conversation", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); +}); + +test("QuestionDirectoryRail renders question ticks, marks current and jumps", () => { + const onJump = vi.fn(); + const questions = collectQuestionDirectory([ + makeMessage("u1", "user"), + makeMessage("u2", "user"), + ]); + const { container } = render( + + ); + const first = screen.getByRole("button", { name: "chat.questions.jump:1" }); + const second = screen.getByRole("button", { name: "chat.questions.jump:2" }); + expect(first.getAttribute("aria-current")).toBeNull(); + expect(second.getAttribute("aria-current")).toBe("location"); + expect( + container.querySelectorAll('[data-slot="question-rail-current-dot"]') + ).toHaveLength(1); + fireEvent.click(first); + expect(onJump).toHaveBeenCalledWith("u1"); +}); + +test("QuestionDirectoryRail previews question and reply on keyboard focus", async () => { + const questions = collectQuestionDirectory([ + makeMessage("u1", "user"), + makeMessage("a1", "assistant"), + ]); + render(); + fireEvent.focus( + screen.getByRole("button", { name: "chat.questions.jump:1" }) + ); + const preview = await screen.findByRole("tooltip"); + expect(preview.textContent).toContain("question u1"); + expect(preview.textContent).toContain("question a1"); +}); + +test("QuestionDirectoryRail switches previews when moving from Q3 to adjacent Q2", async () => { + const questions = collectQuestionDirectory([ + makeMessage("u1", "user"), + makeMessage("u2", "user"), + makeMessage("u3", "user"), + ]); + render( + + ); + const second = screen.getByRole("button", { name: "chat.questions.jump:2" }); + const third = screen.getByRole("button", { name: "chat.questions.jump:3" }); + const rect = (x: number, y: number, width: number, height: number) => + ({ + x, + y, + width, + height, + top: y, + left: x, + right: x + width, + bottom: y + height, + toJSON: () => ({}), + }) as DOMRect; + const bounds = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockImplementation(function (this: HTMLElement) { + if (this === second) return rect(0, 15, 32, 15); + if (this === third) return rect(0, 30, 32, 15); + // A tall preview beside the ticks overlaps the diagonal hover corridor. + if (this.getAttribute("role") === "tooltip") return rect(40, 0, 288, 300); + return rect(0, 0, 0, 0); + }); + try { + fireEvent.pointerMove(third, { + pointerType: "mouse", + clientX: 31, + clientY: 38, + }); + expect((await screen.findByRole("tooltip")).textContent).toContain( + "question u3" + ); + fireEvent.pointerLeave(third, { + pointerType: "mouse", + clientX: 31, + clientY: 30, + }); + fireEvent.pointerMove(second, { + pointerType: "mouse", + clientX: 31, + clientY: 25, + }); + await waitFor(() => { + const previews = screen.getAllByRole("tooltip"); + expect(previews).toHaveLength(1); + expect(previews[0].textContent).toContain("question u2"); + expect(previews[0].textContent).not.toContain("question u3"); + }); + fireEvent.pointerLeave(second, { + pointerType: "mouse", + clientX: 0, + clientY: 15, + }); + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); + } finally { + bounds.mockRestore(); + } +}); + +test("QuestionDirectoryRail has no expanded-directory entry or search popover", () => { + const questions = collectQuestionDirectory([ + makeMessage("u1", "user"), + makeMessage("u2", "user"), + ]); + render( {}} />); + expect(screen.getAllByRole("button")).toHaveLength(2); + expect( + screen.queryByRole("button", { name: "chat.questions.viewAll:2" }) + ).toBeNull(); + expect( + screen.queryByRole("textbox", { name: "chat.questions.search" }) + ).toBeNull(); +}); + +test("QuestionDirectoryRail keeps the active tick visible without scrolling chat", () => { + const questions = collectQuestionDirectory([ + makeMessage("u1", "user"), + makeMessage("u2", "user"), + ]); + const props = { questions, onJump: vi.fn() }; + const { container, rerender } = render(); + const list = container.querySelector("nav > div") as HTMLDivElement; + const current = screen.getByRole("button", { name: "chat.questions.jump:2" }); + Object.defineProperties(list, { + clientHeight: { value: 100 }, + offsetTop: { value: 80 }, + }); + Object.defineProperties(current, { + offsetTop: { value: 300 }, + offsetHeight: { value: 15 }, + }); + const scrollIntoView = vi.mocked(HTMLElement.prototype.scrollIntoView); + scrollIntoView.mockClear(); + rerender(); + expect(list.scrollTop).toBe(215); + expect(scrollIntoView).not.toHaveBeenCalled(); +}); + +test("collectQuestionDirectory extracts user questions in order", () => { + const messages: QuestionMessageLike[] = [ + makeMessage("u1", "user", { + content: [{ type: "text", text: "第一问" }], + }), + makeMessage("a1", "assistant"), + makeMessage("u2", "user", { + content: [{ type: "text", text: "第二问" }], + }), + ]; + + const questions = collectQuestionDirectory(messages); + + expect(questions).toHaveLength(2); + expect(questions[0]).toMatchObject({ + key: "u1", + messageId: "u1", + index: 1, + summary: "第一问", + kind: "text", + }); + expect(questions[1]).toMatchObject({ + key: "u2", + messageId: "u2", + index: 2, + kind: "text", + }); +}); + +test("collectQuestionDirectory uses the first line and truncates long questions", () => { + const longQuestion = `第一行超长问题${"很".repeat(80)},后面还有第二行`; + const questions = collectQuestionDirectory([ + makeMessage("u1", "user", { + content: [{ type: "text", text: `${longQuestion}\nsecond line` }], + }), + ]); + + expect(questions).toHaveLength(1); + expect(questions[0]?.summary.startsWith("第一行超长问题")).toBe(true); + expect(questions[0]?.summary.length).toBeLessThanOrEqual(60); + expect(questions[0]?.summary.endsWith("…")).toBe(true); + // The full multi-line text is preserved for the tooltip. + expect(questions[0]?.fullText).toContain("second line"); +}); + +test("collectQuestionDirectory falls back to the first attachment name", () => { + const questions = collectQuestionDirectory([ + makeMessage("u1", "user", { + content: [{ type: "file", filename: "data.csv" }], + attachments: [{ name: "data.csv" }, { name: "extra.txt" }], + }), + ]); + + expect(questions).toHaveLength(1); + expect(questions[0]).toMatchObject({ + kind: "attachment", + summary: "data.csv", + fullText: "data.csv", + }); +}); + +test("collectQuestionDirectory skips empty and non-user messages", () => { + const questions = collectQuestionDirectory([ + makeMessage("a1", "assistant"), + makeMessage("u1", "user", { content: [], attachments: [] }), + makeMessage("u2", "user", { + content: [{ type: "text", text: " " }], + }), + ]); + + expect(questions).toEqual([]); +}); + +test("collectQuestionDirectory attaches and caps the assistant reply excerpt", () => { + const longReply = "回".repeat(450); + const questions = collectQuestionDirectory([ + makeMessage("u1", "user", { content: [{ type: "text", text: "第一问" }] }), + makeMessage("a1", "assistant", { content: [{ type: "tool" }] }), + makeMessage("a2", "assistant", { + content: [{ type: "text", text: longReply }], + }), + makeMessage("u2", "user", { content: [{ type: "text", text: "第二问" }] }), + ]); + + // Text-free assistant messages (tool calls, reasoning) do not consume the + // pending reply; the first textual reply wins and is capped at the limit. + expect(questions[0]?.replyText).toHaveLength(400); + expect(questions[0]?.replyText?.endsWith("…")).toBe(true); + // A trailing question without any reply keeps an empty excerpt. + expect(questions[1]?.replyText).toBe(""); +}); + +test("scrollThreadToMessage scrolls to the target without a highlight frame", () => { + const element = document.createElement("div"); + element.setAttribute("data-message-id", "msg-42"); + const scrollIntoView = vi.fn(); + element.scrollIntoView = scrollIntoView; + document.body.appendChild(element); + + const found = scrollThreadToMessage("msg-42"); + + expect(found).toBe(true); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "smooth", + block: "start", + }); + expect(element.style.boxShadow).toBe(""); + expect(element.style.transition).toBe(""); +}); + +test("scrollThreadToMessage reports missing targets", () => { + expect(scrollThreadToMessage("missing")).toBe(false); + expect(scrollThreadToMessage("")).toBe(false); +}); + +test("useActiveQuestionId tracks the last question above the viewport midline", async () => { + const first = document.createElement("div"); + first.setAttribute("data-message-id", "u1"); + const second = document.createElement("div"); + second.setAttribute("data-message-id", "u2"); + document.body.append(first, second); + + const makeRect = (top: number) => + ({ + top, + right: 0, + bottom: 0, + left: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + + const getRectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockImplementation(function (this: HTMLElement) { + // jsdom viewport height is 768, so the midline sits at 384. + return makeRect(this === first ? 100 : 500); + }); + + const questions = collectQuestionDirectory([ + makeMessage("u1", "user", { content: [{ type: "text", text: "第一问" }] }), + makeMessage("u2", "user", { content: [{ type: "text", text: "第二问" }] }), + ]); + const { result, unmount } = renderHook(() => useActiveQuestionId(questions)); + + // Initial computation: only the first question sits above the midline. + await waitFor(() => expect(result.current).toBe("u1")); + + // Scrolling the second question above the midline switches the marker. + getRectSpy.mockImplementation(function (this: HTMLElement) { + return makeRect(this === first ? 100 : 200); + }); + window.dispatchEvent(new Event("scroll")); + await waitFor(() => expect(result.current).toBe("u2")); + + unmount(); + getRectSpy.mockRestore(); +}); diff --git a/frontend/tests/component/rawStreamPreviewViewer.test.tsx b/frontend/tests/component/rawStreamPreviewViewer.test.tsx index 1d567c2d95..a3a47c376d 100644 --- a/frontend/tests/component/rawStreamPreviewViewer.test.tsx +++ b/frontend/tests/component/rawStreamPreviewViewer.test.tsx @@ -115,8 +115,10 @@ describe("RawStreamPreviewViewer", () => { // never passes the sanitizer, so the option has to stay off. const options = vi.mocked(renderAsync).mock.calls[0]?.[3] as { renderAltChunks?: boolean; + inWrapper?: boolean; }; expect(options?.renderAltChunks).toBe(false); + expect(options?.inWrapper).toBe(true); // The link pass still runs over whatever docx-preview produced. expect(document.querySelector("a")?.hasAttribute("href")).toBe(false); }); @@ -137,6 +139,27 @@ describe("RawStreamPreviewViewer", () => { ).not.toBeNull(); }); + it("applies paper defaults to a DOCX preview with no section layout", async () => { + vi.mocked(fetchPreviewBlobWithMeta).mockResolvedValue( + servedDocument("simple_document.docx") as never + ); + vi.mocked(renderAsync).mockImplementationOnce(async (_data, body) => { + body.innerHTML = + '

    Short generated document

    '; + return {} as never; + }); + render( + + ); + expect(await screen.findByText("Short generated document")).toBeVisible(); + const page = document.querySelector("section.docx")!; + expect(page.style.minHeight).toBe("297mm"); + expect(page.style.paddingTop).toBe("25.4mm"); + }); + it("anchors a rotated pptx group around its own centre", () => { // Group centre: (100 + 400 / 2) / 960 and (50 + 200 / 2) / 540. expect( diff --git a/frontend/tests/component/viewportScrollRescue.test.tsx b/frontend/tests/component/viewportScrollRescue.test.tsx new file mode 100644 index 0000000000..26d583231d --- /dev/null +++ b/frontend/tests/component/viewportScrollRescue.test.tsx @@ -0,0 +1,174 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { ViewportScrollRescue } from "@/app/newchat/assistant-ui/viewport-scroll-rescue"; + +type AuiThreadState = { + isRunning: boolean; + isLoading: boolean; + messages: unknown[]; +}; + +type ViewportState = { + isAtBottom: boolean; + scrollToBottom: (options?: { behavior?: string }) => void; +}; + +const auiMock = vi.hoisted(() => { + const scrollToBottom = vi.fn(); + const viewportState: ViewportState = { isAtBottom: false, scrollToBottom }; + const listeners = new Set<(state: ViewportState, prev: ViewportState) => void>(); + const store = { + getState: () => viewportState, + subscribe: (listener: (state: ViewportState, prev: ViewportState) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + return { + thread: { + isRunning: false, + isLoading: false, + messages: [] as unknown[], + } as AuiThreadState, + viewportState, + scrollToBottom, + listeners, + store, + }; +}); + +vi.mock("@assistant-ui/react", () => ({ + useAuiState: (selector: (s: { thread: AuiThreadState }) => unknown) => + selector({ thread: auiMock.thread }), + useThreadViewportStore: () => auiMock.store, +})); + +const renderRescue = () => { + const utils = render(); + return { + container: utils.container, + rerender: () => utils.rerender(), + }; +}; + +/** Simulate a scroll event changing the store's isAtBottom flag. */ +const notifyViewportIsAtBottom = (next: boolean) => { + const prev: ViewportState = { ...auiMock.viewportState }; + auiMock.viewportState.isAtBottom = next; + for (const listener of auiMock.listeners) { + listener({ ...auiMock.viewportState }, prev); + } +}; + +beforeEach(() => { + auiMock.thread.isRunning = false; + auiMock.thread.isLoading = false; + auiMock.thread.messages = []; + auiMock.viewportState.isAtBottom = false; + auiMock.scrollToBottom.mockClear(); +}); + +afterEach(() => { + cleanup(); +}); + +test("renders nothing and does not scroll on initial mount", () => { + const { container } = renderRescue(); + expect(container.childElementCount).toBe(0); + expect(auiMock.scrollToBottom).not.toHaveBeenCalled(); +}); + +test("scrolls to the bottom once when a run starts", () => { + const { rerender } = renderRescue(); + + auiMock.thread.isRunning = true; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); + expect(auiMock.scrollToBottom).toHaveBeenCalledWith({ behavior: "instant" }); + + // Re-rendering without new state must not re-trigger the scroll. + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); + + // The run ending must not scroll either. + auiMock.thread.isRunning = false; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); +}); + +test("scrolls to the bottom when history finishes loading with messages", () => { + const { rerender } = renderRescue(); + + auiMock.thread.isLoading = true; + rerender(); + expect(auiMock.scrollToBottom).not.toHaveBeenCalled(); + + auiMock.thread.isLoading = false; + auiMock.thread.messages = [{ id: "history-1" }, { id: "history-2" }]; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); +}); + +test("does not scroll when history finishes loading for an empty thread", () => { + const { rerender } = renderRescue(); + + auiMock.thread.isLoading = true; + rerender(); + auiMock.thread.isLoading = false; + auiMock.thread.messages = []; + rerender(); + expect(auiMock.scrollToBottom).not.toHaveBeenCalled(); +}); + +test("follows streamed updates while the run is active", () => { + const { rerender } = renderRescue(); + + auiMock.thread.isRunning = true; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); + + auiMock.thread.messages = [{ id: "chunk-1" }]; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(2); + + auiMock.thread.messages = [{ id: "chunk-1" }, { id: "chunk-2" }]; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(3); +}); + +test("stops following after the user scrolls away and resumes at the bottom", () => { + const { rerender } = renderRescue(); + + auiMock.thread.isRunning = true; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); + + // The run-start scroll landed at the bottom; the user then scrolls away. + notifyViewportIsAtBottom(true); + notifyViewportIsAtBottom(false); + + auiMock.thread.messages = [{ id: "chunk-1" }]; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); + + // Scrolling back to the bottom resumes the follow on the next update. + notifyViewportIsAtBottom(true); + auiMock.thread.messages = [{ id: "chunk-1" }, { id: "chunk-2" }]; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(2); +}); + +test("does not follow message updates when no run is active", () => { + const { rerender } = renderRescue(); + + auiMock.thread.messages = [{ id: "edited-1" }]; + rerender(); + auiMock.thread.messages = [{ id: "edited-1" }, { id: "edited-2" }]; + rerender(); + expect(auiMock.scrollToBottom).not.toHaveBeenCalled(); +}); diff --git a/frontend/types/chat.ts b/frontend/types/chat.ts index 717786e89c..78838dadd2 100644 --- a/frontend/types/chat.ts +++ b/frontend/types/chat.ts @@ -194,8 +194,9 @@ export type FilePreviewProps = { * - `"drawer"` (default): a side drawer over the current page. * - `"fullscreen"`: a full-viewport surface with a back arrow, used where a * document is the whole task (AIDP knowledge base documents). + * - `"inline"`: content only; the host owns the surrounding panel and controls. */ - presentation?: "drawer" | "fullscreen"; + presentation?: "drawer" | "fullscreen" | "inline"; } & (RemoteFilePreviewSource | LocalFilePreviewSource); // Main chat message type diff --git a/frontend/types/conversation.ts b/frontend/types/conversation.ts index 382197016f..8f04902df0 100644 --- a/frontend/types/conversation.ts +++ b/frontend/types/conversation.ts @@ -112,8 +112,15 @@ export interface ApiMessageItem { depth?: number; } +export interface ApiMessageAnnotation { + quoted_text: string; + comment?: string | null; + source_excerpt?: string | null; +} + export interface ApiMessage { clarification_response?: unknown; + annotations?: ApiMessageAnnotation[]; status?: "pending" | "streaming" | "completed" | "failed" | "stopped"; role: "user" | "assistant"; message: ApiMessageItem[] | string; diff --git a/test-e2e/changes/bugs/DOCX-PREVIEW-PAGE-LAYOUT-20261113.md b/test-e2e/changes/bugs/DOCX-PREVIEW-PAGE-LAYOUT-20261113.md new file mode 100644 index 0000000000..607046d623 --- /dev/null +++ b/test-e2e/changes/bugs/DOCX-PREVIEW-PAGE-LAYOUT-20261113.md @@ -0,0 +1,67 @@ +# DOCX preview background and missing page layout — 2026-11-13 + +## Reproduction and scope + +The conversation artifact viewer opened a generated `simple_document.docx` +containing three paragraphs and no `w:sectPr`, page size or page margins. The +pasted XML demonstrates the missing section metadata. The shared viewer used +`bg-gray-200` around a renderer page that collapsed to its text height. This +produced a small white text strip and a large gray panel, as in the user image. + +`RawStreamPreviewViewer` owns byte retrieval, format selection, Office rendering +and unsafe-link hardening; chat artifact panes reuse it. The document is not +rewritten or uploaded by this fix. No deployment or environment data is changed. + +## Expected behavior and implementation + +- The DOCX viewport uses the shared white sliding-panel background token. + Existing transparent renderer wrapper and shadow removal remain scoped. +- Missing page width/height use an A4 fallback; missing padding uses one-inch + page margins. The fallback width fits a narrow panel. Explicit inline page + dimensions, orientation, height and zero/custom margins are preserved. +- Changes are confined to actual `.docx-wrapper > section.docx` pages. Reapplying + defaults is idempotent; adjacent content is untouched. +- PDF, spreadsheet and presentation rendering are unchanged. Existing unsafe + link neutralization and disabled HTML altChunk rendering are retained. +- Document-defined page backgrounds are not replaced; only the viewer backdrop + changes to white. + +## Evidence and verification boundaries + +Traditional UT decision: strengthen the shared viewer component test and add +targeted DOCX tests; reuse existing sanitizer and file-kind unit tests. There is +no Python/backend behavior change requiring Python UT. Existing UT/formal assets +are preserved. No additional D1 quota or formal execution PASS is claimed. + +- `frontend/tests/component/docxPageLayout.test.tsx`: real JSZip OOXML and real + docx-preview rendering prove missing metadata, landscape/custom margins, + partially specified layouts, multiple pages, idempotence and sibling isolation. +- `frontend/tests/component/rawStreamPreviewViewer.test.tsx`: proves defaults + are called after rendering; existing altChunk/link and PPTX group assertions + remain active. +- `frontend/tests/component/officePreviewSanitizer.test.tsx`: existing 19 tests. +- `frontend/tests/officePreview.test.ts`: existing six native Node tests. + +Results: 26 component tests and six native Node tests passed. Component tests ran +in an isolated temporary directory with Vitest 4.1.12, React 19.2.3 and the +repository's docx-preview 0.4.1. This is not the complete frontend dependency set. + +Real browser checks used an independent headless Edge profile and temporary HTML +produced by the real DOCX renderer and application defaults/styles. Default and +narrow views had white viewport/page backgrounds and a full paper height; +landscape dimensions and zero top margin were preserved. The default view was +also visually inspected. These fixture checks are separate from formal D4 and +do not establish the deployed application's end-to-end behavior. + +The sibling SPEC repository/UI source is unavailable; SPEC integration and formal +ownership mapping remain incomplete. Full application type-check/build and live +conversation verification remain unverified because dependencies are not +installed in the checkout and this is a code-only fix. Formal asset validation +requires the missing local openpyxl dependency; no formal acceptance completion +is claimed. Source copyright, formatting and diff whitespace checks apply. + +Copyright and `git diff --check` passed. Targeted TypeScript, module CSS and tests +passed Prettier. The global stylesheet retains pre-existing formatting warnings +in its accordion keyframes and scrollbar selector; formatting a temporary copy +confirmed that neither added token declaration caused those warnings. Those +unrelated sections were intentionally not rewritten. diff --git a/test/backend/app/test_conversation_management_app.py b/test/backend/app/test_conversation_management_app.py index 36d91b4be5..ce30be0429 100644 --- a/test/backend/app/test_conversation_management_app.py +++ b/test/backend/app/test_conversation_management_app.py @@ -10,6 +10,7 @@ from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient +from contracts.conversation import BranchConversationRequest from contracts.errors.codes import ErrorCode from contracts.errors.exceptions import AppException, ValidationError @@ -57,6 +58,7 @@ list_agent_groups_endpoint, list_agent_group_conversations_endpoint, list_agent_group_conversation_ids_endpoint, + branch_conversation_endpoint, router, ) @@ -72,6 +74,7 @@ def conversation_mocks(): patch('management.api.conversation.conversation_management.create_new_conversation') as mock_create_new_conv, \ patch('management.api.conversation.conversation_management.get_conversation_list_page') as mock_get_conv_list, \ patch('management.api.conversation.conversation_management.rename_conversation_service') as mock_rename_conv, \ + patch('management.api.conversation.conversation_management.branch_conversation_service') as mock_branch_conv, \ patch('management.api.conversation.conversation_management.logging') as mock_logging, \ patch('management.api.conversation.conversation_management.delete_conversation_service') as mock_delete_conv, \ patch('management.api.conversation.conversation_management.delete_conversations_batch_service') as mock_delete_batch, \ @@ -90,6 +93,7 @@ def conversation_mocks(): 'create_new_convo': mock_create_new_conv, 'get_conversation_list': mock_get_conv_list, 'rename_conversation': mock_rename_conv, + 'branch_conversation': mock_branch_conv, 'logging': mock_logging, 'delete_conversation': mock_delete_conv, 'delete_conversations_batch': mock_delete_batch, @@ -874,3 +878,112 @@ def test_agent_history_routes_reject_invalid_scope_and_expired_identity(conversa ) expired = client.get("/api/conversation/agent_groups") assert expired.status_code == 401 + + +@pytest.mark.asyncio +async def test_branch_conversation_success(conversation_mocks): + """Verify successful conversation branching""" + mock_auth_header = "Bearer test-token" + dummy_response = { + "conversation_id": 42, + "conversation_title": "New Conversation����֧��", + "branched_message_count": 4, + } + + conversation_mocks["get_current_user_id"].return_value = ( + "user_id", "tenant_id") + conversation_mocks["branch_conversation"].return_value = dummy_response + + request_obj = BranchConversationRequest(conversation_id=7, turn_index=2) + + result = await branch_conversation_endpoint(request_obj, authorization=mock_auth_header) + + assert result.code == 0 + assert result.data == dummy_response + conversation_mocks["branch_conversation"].assert_called_once_with( + 7, 2, "user_id") + + +@pytest.mark.asyncio +async def test_branch_conversation_invalid_turn(conversation_mocks): + """Invalid turn indexes are rejected with a client error""" + conversation_mocks["get_current_user_id"].return_value = ( + "user_id", "tenant_id") + conversation_mocks["branch_conversation"].side_effect = ValueError( + "turn_index must be a 1-based turn number") + + request_obj = BranchConversationRequest(conversation_id=7, turn_index=0) + + with pytest.raises(HTTPException) as exc_info: + await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") + + assert exc_info.value.status_code == 400 + conversation_mocks["branch_conversation"].assert_called_once() + + +@pytest.mark.asyncio +async def test_branch_conversation_not_found(conversation_mocks): + """Domain not-found errors from the service map to 404""" + from contracts.errors.exceptions import ConversationNotFoundError + + conversation_mocks["get_current_user_id"].return_value = ( + "user_id", "tenant_id") + conversation_mocks["branch_conversation"].side_effect = ConversationNotFoundError( + "Conversation does not exist or has been deleted") + + request_obj = BranchConversationRequest(conversation_id=7, turn_index=2) + + with pytest.raises(HTTPException) as exc_info: + await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_branch_conversation_expired_token(conversation_mocks): + """Expired sessions are rejected with 401""" + from contracts.errors.exceptions import TokenExpiredError + + conversation_mocks["get_current_user_id"].side_effect = TokenExpiredError( + "expired") + + request_obj = BranchConversationRequest(conversation_id=7, turn_index=2) + + with pytest.raises(HTTPException) as exc_info: + await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") + + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_branch_conversation_failure(conversation_mocks): + """Unexpected service errors return a sanitized 500 without database diagnostics.""" + conversation_mocks["get_current_user_id"].return_value = ( + "user_id", "tenant_id") + conversation_mocks["branch_conversation"].side_effect = Exception( + "Database error: SELECT * FROM private_table; connection password=secret") + + request_obj = BranchConversationRequest(conversation_id=7, turn_index=2) + + with pytest.raises(HTTPException) as exc_info: + await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") + + assert exc_info.value.status_code == 500 + assert exc_info.value.detail == "Failed to branch conversation" + assert "private_table" not in exc_info.value.detail + assert "secret" not in exc_info.value.detail + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target", [ + {"source_message_id": 123}, + {"answer_index": 2}, + {"source_message_id": 123, "answer_index": 2}, +]) +async def test_branch_conversation_forwards_selected_answer(conversation_mocks, target): + conversation_mocks["get_current_user_id"].return_value = ("user_id", "tenant_id") + conversation_mocks["branch_conversation"].return_value = {"conversation_id": 42} + request_obj = BranchConversationRequest(conversation_id=7, turn_index=2, **target) + result = await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") + assert result.data == {"conversation_id": 42} + conversation_mocks["branch_conversation"].assert_called_once_with(7, 2, "user_id", **target) diff --git a/test/backend/database/test_branch_titles.py b/test/backend/database/test_branch_titles.py new file mode 100644 index 0000000000..abe6561cbf --- /dev/null +++ b/test/backend/database/test_branch_titles.py @@ -0,0 +1,43 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Numbered branch title formatting and collision boundaries.""" + +import pytest + +from management.db.conversation.branch_titles import build_branch_title + + +@pytest.mark.parametrize(("source", "expected"), [ + ("先看一下这个代码", "先看一下这个代码 (2)"), + ("Trip planning", "Trip planning (2)"), + ("Trip planning (2)", "Trip planning (3)"), + ("Trip planning (9)", "Trip planning (10)"), + ("Trip planning(分支)", "Trip planning (2)"), + ("Trip planning (2)(分支)", "Trip planning (3)"), + (" Trip planning ", "Trip planning (2)"), + (None, "(2)"), + ("", "(2)"), +]) +def test_branch_title_formats_source(source, expected): + assert build_branch_title(source) == expected + + +def test_branch_title_skips_occupied_names_without_changing_input(): + occupied = ["Trip planning", "Trip planning (2)", "Trip planning (3)", None] + assert build_branch_title("Trip planning", occupied) == "Trip planning (4)" + assert build_branch_title("Trip planning (2)", occupied) == "Trip planning (4)" + assert occupied[1] == "Trip planning (2)" + + +def test_branch_title_caps_length_and_rechecks_when_number_gains_a_digit(): + source = "很" * 120 + occupied = [f'{source[:96]} ({number})' for number in range(2, 10)] + occupied.append(f'{source[:95]} (10)') + title = build_branch_title(source, occupied) + assert title == f'{source[:95]} (11)' + assert len(title) == 100 + + +def test_branch_title_keeps_non_numeric_parentheses_and_handles_huge_numbers(): + assert build_branch_title("Report (draft)") == "Report (draft) (2)" + assert len(build_branch_title("(" + "9" * 100 + ")")) <= 100 diff --git a/test/backend/database/test_conversation_branch_db.py b/test/backend/database/test_conversation_branch_db.py new file mode 100644 index 0000000000..055eb37ed5 --- /dev/null +++ b/test/backend/database/test_conversation_branch_db.py @@ -0,0 +1,499 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Conversation branching copies configuration and the selected turn range.""" + +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from sqlalchemy.dialects import postgresql + +from management.db.conversation import repository as db + + +NEW_CONVERSATION_ROW = SimpleNamespace( + conversation_id=42, + conversation_title="Trip planning (2)", + agent_id=5, + chat_mode="execution", + knowledge_scope={"kb": True}, + runtime_metadata={}, + runtime_metadata_version=1, + workbench_config=None, + workbench_config_version=0, + create_time=1700000000000, + update_time=1700000000000, +) + + +def _conversation_row(**overrides): + row = SimpleNamespace( + conversation_id=7, + conversation_title="Trip planning", + title_version=3, + last_activity_time=None, + agent_id=5, + chat_mode="execution", + knowledge_scope={"kb": True}, + runtime_metadata={"model": "gpt"}, + runtime_metadata_version=4, + workbench_config=None, + workbench_pinned_at=None, + workbench_config_version=0, + create_time=None, + update_time=None, + created_by="owner", + updated_by="owner", + delete_flag="N", + ) + for key, value in overrides.items(): + setattr(row, key, value) + return row + + +def _message(message_id, message_index, role, content, **overrides): + row = SimpleNamespace( + message_id=message_id, + conversation_id=7, + message_index=message_index, + message_role=role, + message_source="CHAT", + message_content=content, + minio_files=None, + opinion_flag=None, + status="completed", + ) + for key, value in overrides.items(): + setattr(row, key, value) + return row + + +def _unit(unit_id, message_id, **overrides): + row = SimpleNamespace( + unit_id=unit_id, + message_id=message_id, + conversation_id=7, + unit_index=0, + unit_type="answer", + unit_content="unit body", + unit_status="completed", + tool_call_id=None, + invocation_id=None, + ) + for key, value in overrides.items(): + setattr(row, key, value) + return row + + +def _search(search_id, message_id, unit_id=None, **overrides): + row = SimpleNamespace( + search_id=search_id, + unit_id=unit_id, + message_id=message_id, + conversation_id=7, + source_type="url", + source_title="doc", + source_location="https://example.com", + source_content="text", + score_overall=None, + score_accuracy=None, + score_semantic=None, + retrieval_highlight_terms=None, + aidp_metadata=None, + aidp_source_reference=None, + published_date=None, + cite_index=None, + search_type="web_search", + tool_sign=None, + ) + for key, value in overrides.items(): + setattr(row, key, value) + return row + + +def _image(image_id, message_id, **overrides): + row = SimpleNamespace( + image_id=image_id, + conversation_id=7, + message_id=message_id, + unit_id=None, + image_url="https://example.com/a.png", + cite_index=None, + search_type="web_search", + ) + for key, value in overrides.items(): + setattr(row, key, value) + return row + + +def _scalars_result(first=None, all_rows=None): + result = MagicMock() + result.first.return_value = first + result.all.return_value = all_rows or [] + return result + + +def _three_turn_rows(): + """Two settled turns, a regenerated answer on turn two, and a third turn.""" + return [ + _message(11, 0, "user", "Q1"), + _message(12, 1, "assistant", "A1"), + _message(13, 2, "user", "Q2"), + _message(14, 3, "assistant", "A2-old"), + _message(15, 3, "assistant", "A2-new", opinion_flag="Y"), + _message(16, 4, "user", "Q3"), + _message(17, 5, "assistant", "A3"), + ] + + +def sql(statement): + return str( + statement.compile( + dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True} + ) + ) + + +def params(statement): + return statement.compile().params + + +class _ExecuteRecorder: + """Route executed statements by table and return per-table results.""" + + def __init__(self, new_conversation_row=NEW_CONVERSATION_ROW): + self.statements = [] + self.new_conversation_row = new_conversation_row + self.next_ids = {"message": 1000, "unit": 2000} + self.results = { + "conversation_record_t": self._conversation_result, + "conversation_message_t": self._message_result, + "conversation_message_unit_t": self._unit_result, + } + + def _conversation_result(self, _statement): + result = MagicMock() + result.fetchone.return_value = self.new_conversation_row + return result + + def _message_result(self, _statement): + self.next_ids["message"] += 1 + result = MagicMock() + result.scalar_one.return_value = self.next_ids["message"] + return result + + def _unit_result(self, _statement): + self.next_ids["unit"] += 1 + result = MagicMock() + result.scalar_one.return_value = self.next_ids["unit"] + return result + + def __call__(self, statement, *_args, **_kwargs): + self.statements.append(statement) + text = str(statement.compile(dialect=postgresql.dialect())) + for table, factory in self.results.items(): + if text.startswith(f"INSERT INTO nexent.{table}"): + return factory(statement) + return MagicMock() + + +@pytest.fixture +def session(monkeypatch): + value = MagicMock() + value.scalar.return_value = 1 + + @contextmanager + def sessions(): + yield value + + monkeypatch.setattr(db, "get_db_session", sessions) + return value + + +_UNSET = object() + + +def _wire(session, rows, *, conversation=_UNSET, units=None, searches=None, images=None, existing_titles=None): + """Queue the scalars results in the order branch_conversation reads them.""" + if conversation is _UNSET: + conversation = _conversation_row() + session.scalars.side_effect = [ + _scalars_result(first=conversation), + _scalars_result(all_rows=rows), + _scalars_result(all_rows=existing_titles or []), + _scalars_result(all_rows=units or []), + _scalars_result(all_rows=searches or []), + _scalars_result(all_rows=images or []), + ] + + +def test_branch_copies_turn_range_and_remaps_children(session): + rows = _three_turn_rows() + units = [ + _unit(21, 15, unit_index=1, tool_call_id="t-1"), + ] + searches = [ + _search(31, 12), + _search(33, 15, unit_id=21), + ] + images = [ + _image(41, 15), + ] + _wire(session, rows, units=units, searches=searches, images=images) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + + result = db.branch_conversation(7, 2, user_id="owner") + + assert result["conversation_id"] == 42 + assert result["conversation_title"] == "Trip planning (2)" + assert result["branched_message_count"] == 4 + + # Child queries only fetch rows of the copied messages. + units_filter = sql(session.scalars.call_args_list[3].args[0]) + assert "message_id IN (11, 12, 13, 15)" in units_filter + searches_filter = sql(session.scalars.call_args_list[4].args[0]) + assert "message_id IN (11, 12, 13, 15)" in searches_filter + images_filter = sql(session.scalars.call_args_list[5].args[0]) + assert "message_id IN (11, 12, 13, 15)" in images_filter + + inserted = {} + for statement in recorder.statements: + text = str(statement.compile(dialect=postgresql.dialect())) + if text.startswith("INSERT INTO nexent."): + table = text.split("nexent.")[1].split(" ")[0] + inserted.setdefault(table, []).append(params(statement)) + + # Only the four active rows of turns one and two are copied. + assert [row["message_content"] for row in inserted["conversation_message_t"]] == [ + "Q1", + "A1", + "Q2", + "A2-new", + ] + # User feedback is intentionally not carried over. + assert all("opinion_flag" not in row for row in inserted["conversation_message_t"]) + assert all(row["delete_flag"] == "N" for row in inserted["conversation_message_t"]) + assert all(row["created_by"] == "owner" for row in inserted["conversation_message_t"]) + + # Conversation configuration is cloned with fresh versions. + conversation_row = inserted["conversation_record_t"][0] + assert conversation_row["conversation_title"] == "Trip planning (2)" + assert conversation_row["agent_id"] == 5 + assert conversation_row["chat_mode"] == "execution" + assert conversation_row["knowledge_scope"] == {"kb": True} + assert conversation_row["runtime_metadata"] == {"model": "gpt"} + assert conversation_row["runtime_metadata_version"] == 1 + + # Units, searches and images follow only the copied messages, with + # unit_id references remapped to the inserted unit rows. + assert [row["unit_content"] for row in inserted["conversation_message_unit_t"]] == ["unit body"] + assert len(inserted["conversation_source_search_t"]) == 2 + remapped_unit = inserted["conversation_source_search_t"][1] + assert remapped_unit["source_title"] == "doc" + assert remapped_unit["unit_id"] == 2001 + assert len(inserted["conversation_source_image_t"]) == 1 + assert inserted["conversation_source_image_t"][0]["message_id"] == 1004 + + +def _conversation_insert(recorder): + """Return the single conversation INSERT statement executed by a branch.""" + statements = [ + statement + for statement in recorder.statements + if str(statement.compile(dialect=postgresql.dialect())).startswith( + "INSERT INTO nexent.conversation_record_t" + ) + ] + assert len(statements) == 1 + return statements[0] + + +def test_branch_of_agent_chat_leaves_workbench_columns_unbound(session): + """An agent-chat branch must not bind JSONB columns with Python None. + + SQLAlchemy serializes a None bound to a JSONB column as the JSON value + null instead of SQL NULL, and agent-chat history filters on + "workbench_config IS NULL": binding the absent declaration would hide + the branched conversation from every agent-chat list as soon as it stops + being the active thread. + """ + _wire( + session, + _three_turn_rows(), + conversation=_conversation_row( + workbench_config=None, knowledge_scope=None + ), + ) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + + result = db.branch_conversation(7, 1, user_id="owner") + + assert result["workbench_config"] is None + # The RETURNING clause references workbench_config regardless, so assert + # on the VALUES binds: an unbound column falls back to SQL NULL while a + # bound None would be serialized by SQLAlchemy as the JSON value null. + inserted = params(_conversation_insert(recorder)) + assert "workbench_config" not in inserted + assert "workbench_config_version" not in inserted + assert "knowledge_scope" not in inserted + assert inserted["runtime_metadata"] == {"model": "gpt"} + assert inserted["runtime_metadata_version"] == 1 + + +def test_branch_copies_workbench_declaration_when_present(session): + """A Workbench branch keeps its declaration and restarts the version.""" + workbench = { + "schema_version": 3, + "mode": "single_agent_chat", + "agent_mounts": [{"agent_id": 5, "version_no": 3}], + } + new_row = SimpleNamespace( + conversation_id=42, + conversation_title="Trip planning (2)", + agent_id=5, + chat_mode="execution", + knowledge_scope={"kb": True}, + runtime_metadata={}, + runtime_metadata_version=1, + workbench_config=workbench, + workbench_config_version=1, + create_time=1700000000000, + update_time=1700000000000, + ) + _wire( + session, + _three_turn_rows(), + conversation=_conversation_row( + workbench_config=workbench, workbench_config_version=5 + ), + ) + recorder = _ExecuteRecorder(new_conversation_row=new_row) + session.execute.side_effect = recorder + + result = db.branch_conversation(7, 1, user_id="owner") + + assert result["workbench_config"] == workbench + inserted = params(_conversation_insert(recorder)) + assert inserted["workbench_config"] == workbench + # The copy is independent of the source row's declaration. + assert inserted["workbench_config"] is not workbench + assert inserted["workbench_config_version"] == 1 + + +def test_branch_last_turn_copies_everything(session): + _wire(session, _three_turn_rows()) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + + result = db.branch_conversation(7, 3, user_id="owner") + + assert result["branched_message_count"] == 6 + inserted_rows = [ + params(statement) + for statement in recorder.statements + if str(statement.compile(dialect=postgresql.dialect())).startswith( + "INSERT INTO nexent.conversation_message_t" + ) + ] + assert [row["message_content"] for row in inserted_rows] == [ + "Q1", + "A1", + "Q2", + "A2-new", + "Q3", + "A3", + ] + + +def test_branch_takes_user_quota_lock_before_row_locks(session): + _wire(session, _three_turn_rows()) + session.execute.side_effect = _ExecuteRecorder() + + db.branch_conversation(7, 1, user_id="owner") + + quota_lock = sql(session.execute.call_args_list[0].args[0]) + assert "pg_advisory_xact_lock" in quota_lock + conversation_lock = sql(session.scalars.call_args_list[0].args[0]) + assert "conversation_record_t" in conversation_lock and "FOR UPDATE" in conversation_lock + assert "conversation_id = 7" in conversation_lock + assert "created_by = 'owner'" in conversation_lock and "delete_flag = 'N'" in conversation_lock + + +def test_branch_returns_none_without_inserts_for_missing_conversation(session): + _wire(session, [], conversation=None) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + + assert db.branch_conversation(7, 1, user_id="owner") is None + assert all( + not str(statement.compile(dialect=postgresql.dialect())).startswith("INSERT INTO nexent.") + for statement in recorder.statements + ) + + +@pytest.mark.parametrize("turn_index", [0, -1]) +def test_branch_rejects_non_positive_turn_index(session, turn_index): + with pytest.raises(ValueError, match="1-based turn number"): + db.branch_conversation(7, turn_index, user_id="owner") + + +def test_branch_rejects_turn_index_beyond_available_turns(session): + _wire(session, _three_turn_rows()) + + with pytest.raises(ValueError, match="exceeds the 3 turns"): + db.branch_conversation(7, 4, user_id="owner") + + +def test_branch_title_never_stacks_suffixes_and_respects_length(session): + # Branching a numbered branch increments rather than stacking suffixes. + assert db._build_branch_title("Trip planning (2)") == "Trip planning (3)" + assert db._build_branch_title("Trip planning(分支)") == "Trip planning (2)" + # Over-long titles are truncated so the result fits the 100-char column. + long_title = "x" * 120 + assert len(db._build_branch_title(long_title)) == 100 + assert db._build_branch_title(long_title).endswith(" (2)") + assert db._build_branch_title(None) == "(2)" + + +def test_branch_numbers_titles_using_only_active_owner_conversations(session): + _wire(session, _three_turn_rows(), existing_titles=["Trip planning (2)", "Trip planning (3)"]) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + + db.branch_conversation(7, 1, user_id="owner") + + title_query = session.scalars.call_args_list[2].args[0].compile(dialect=postgresql.dialect()) + assert "created_by" in str(title_query) + assert "delete_flag" in str(title_query) + assert "owner" in title_query.params.values() + assert "N" in title_query.params.values() + conversation_insert = params(next( + statement for statement in recorder.statements + if str(statement.compile()).startswith("INSERT INTO nexent.conversation_record_t") + )) + assert conversation_insert["conversation_title"] == "Trip planning (4)" + + +def test_branch_without_answer_stops_at_user_row(session): + rows = [ + _message(11, 0, "user", "Q1"), + _message(12, 1, "assistant", "A1"), + _message(13, 2, "user", "Q2"), + ] + _wire(session, rows) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + + result = db.branch_conversation(7, 2, user_id="owner") + + assert result["branched_message_count"] == 3 + inserted_rows = [ + params(statement) + for statement in recorder.statements + if str(statement.compile(dialect=postgresql.dialect())).startswith( + "INSERT INTO nexent.conversation_message_t" + ) + ] + assert [row["message_content"] for row in inserted_rows] == ["Q1", "A1", "Q2"] diff --git a/test/backend/database/test_conversation_branch_selection.py b/test/backend/database/test_conversation_branch_selection.py new file mode 100644 index 0000000000..9cd339e4e2 --- /dev/null +++ b/test/backend/database/test_conversation_branch_selection.py @@ -0,0 +1,130 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Branch boundaries must retain the answer version selected by the user.""" + +from types import SimpleNamespace + +import pytest + +from contracts.conversation import BranchConversationRequest +from management.db.conversation.repository import _select_branch_turn_rows + + +def row(message_id, index, role): + return SimpleNamespace(message_id=message_id, message_index=index, message_role=role) + + +@pytest.fixture(params=[1, 2]) +def regenerated_rows(request): + return [ + row(11, 0, "user"), + row(12, 1, "assistant"), + row(13, request.param, "assistant"), + row(14, 3, "user"), + row(15, 4, "assistant"), + ] + + +@pytest.mark.parametrize("message_id", [12, 13]) +def test_exact_answer_id_selects_either_version(regenerated_rows, message_id): + selected = _select_branch_turn_rows(regenerated_rows, 1, source_message_id=message_id) + assert [item.message_id for item in selected] == [11, message_id] + + +@pytest.mark.parametrize("answer_index,message_id", [(1, 12), (2, 13)]) +def test_live_answer_ordinal_selects_either_version(regenerated_rows, answer_index, message_id): + selected = _select_branch_turn_rows(regenerated_rows, 1, answer_index=answer_index) + assert [item.message_id for item in selected] == [11, message_id] + + +def test_exact_id_takes_precedence_over_runtime_ordinal(regenerated_rows): + selected = _select_branch_turn_rows(regenerated_rows, 1, source_message_id=13, answer_index=1) + assert [item.message_id for item in selected] == [11, 13] + + +def test_legacy_request_and_earlier_turns_keep_latest_answer(regenerated_rows): + assert [item.message_id for item in _select_branch_turn_rows(regenerated_rows, 1)] == [11, 13] + assert [item.message_id for item in _select_branch_turn_rows(regenerated_rows, 2)] == [11, 13, 14, 15] + + +@pytest.mark.parametrize("target", [ + {"source_message_id": 999}, + {"source_message_id": 11}, + {"source_message_id": 15}, + {"answer_index": 3}, + {"answer_index": 0}, +]) +def test_invalid_or_other_turn_answer_is_rejected(regenerated_rows, target): + with pytest.raises(ValueError): + _select_branch_turn_rows(regenerated_rows, 1, **target) + + +@pytest.mark.parametrize("field", ["source_message_id", "answer_index"]) +@pytest.mark.parametrize("value", [0, -1, True, 1.5, "2"]) +def test_request_rejects_invalid_answer_identifiers(field, value): + with pytest.raises(ValueError): + BranchConversationRequest(conversation_id=7, turn_index=1, **{field: value}) + + +def test_refresh_duplicate_user_does_not_create_an_extra_turn(): + rows = [row(11, 0, "user"), row(12, 0, "user"), row(13, 1, "assistant"), + row(14, 2, "user"), row(15, 3, "assistant")] + selected = _select_branch_turn_rows(rows, 2, source_message_id=15) + assert [item.message_id for item in selected] == [12, 13, 14, 15] + + +def test_unanswered_boundary_cannot_borrow_the_next_turn_answer(): + rows = [row(11, 0, "user"), row(12, 1, "user"), row(13, 2, "assistant")] + assert [item.message_id for item in _select_branch_turn_rows(rows, 1)] == [11] + with pytest.raises(ValueError): + _select_branch_turn_rows(rows, 1, source_message_id=13) + + +@pytest.mark.parametrize("selected_id", [14, 15]) +def test_copy_and_child_queries_use_only_selected_answer(monkeypatch, selected_id): + from contextlib import contextmanager + from unittest.mock import MagicMock + + from management.db.conversation import repository as db + from test.backend.database.test_conversation_branch_db import ( + _ExecuteRecorder, _three_turn_rows, _wire, params, sql, + ) + + session = MagicMock() + session.scalar.return_value = 1 + + @contextmanager + def sessions(): + yield session + + monkeypatch.setattr(db, "get_db_session", sessions) + _wire(session, _three_turn_rows()) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + result = db.branch_conversation(7, 2, user_id="owner", source_message_id=selected_id) + assert result["branched_message_count"] == 4 + messages = [params(stmt) for stmt in recorder.statements + if getattr(getattr(stmt, "table", None), "name", None) == "conversation_message_t"] + assert [item["message_content"] for item in messages] == ["Q1", "A1", "Q2", "A2-old" if selected_id == 14 else "A2-new"] + for call in session.scalars.call_args_list[3:6]: + assert f"message_id IN (11, 12, 13, {selected_id})" in sql(call.args[0]) + + +def test_invalid_answer_is_rejected_before_any_insert(monkeypatch): + from contextlib import contextmanager + from unittest.mock import MagicMock + + from management.db.conversation import repository as db + from test.backend.database.test_conversation_branch_db import _three_turn_rows, _wire + + session = MagicMock() + session.scalar.return_value = 1 + + @contextmanager + def sessions(): + yield session + + monkeypatch.setattr(db, "get_db_session", sessions) + _wire(session, _three_turn_rows()) + with pytest.raises(ValueError): + db.branch_conversation(7, 2, user_id="owner", source_message_id=999) + assert not any(getattr(call.args[0], "is_insert", False) for call in session.execute.call_args_list) diff --git a/test/backend/database/test_conversation_db.py b/test/backend/database/test_conversation_db.py index 15f2d159ec..33f3f48618 100644 --- a/test/backend/database/test_conversation_db.py +++ b/test/backend/database/test_conversation_db.py @@ -3647,8 +3647,12 @@ def test_get_historical_context_returns_latest_summary_and_only_new_turns( SimpleNamespace(message_id=32, message_index=5, message_role="assistant", message_content="new answer", minio_files=None, status="completed"), ] + # Reply-annotation units for the historical user messages (none here). + annotations_result = MagicMock() + annotations_result.all.return_value = [] session.execute.side_effect = [ - current_result, candidates_result, boundary_result, messages_result] + current_result, candidates_result, boundary_result, messages_result, + annotations_result] monkeypatch.setattr("management.db.conversation.repository.get_db_session", lambda: ctx) result = get_historical_context(1, 25, "user-a", "tenant-a") diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py index 2f4bba899c..6ec1a7e77b 100644 --- a/test/backend/services/test_conversation_management_service.py +++ b/test/backend/services/test_conversation_management_service.py @@ -6,6 +6,8 @@ import types from unittest.mock import patch +import pytest + # Mock storage client factory and MinIO config before any imports that would initialize MinIO from unittest.mock import MagicMock storage_client_mock = MagicMock() @@ -218,6 +220,7 @@ def validate(self): pass call_llm_for_title, update_conversation_title, create_new_conversation, + branch_conversation_service, get_conversation_service, rename_conversation_service, delete_conversation_service, @@ -2061,5 +2064,63 @@ def test_message_database_failure_preserves_unexpected_error(): assert error.value is failure +def test_branch_conversation_service_returns_repository_data(): + import pytest + + repository_result = { + "conversation_id": 42, + "conversation_title": "Original(分支)", + "branched_message_count": 4, + } + with patch( + 'management.services.conversation.service.branch_conversation', + return_value=repository_result, + ) as mock_branch: + result = branch_conversation_service(7, 2, "user-1") + + assert result == repository_result + mock_branch.assert_called_once_with(7, 2, user_id="user-1") + + +def test_branch_conversation_service_rejects_non_positive_turn_index(): + import pytest + + with pytest.raises(ValueError, match="1-based turn number"): + branch_conversation_service(7, 0, "user-1") + + +def test_branch_conversation_service_raises_not_found_for_missing_conversation(): + import pytest + from contracts.errors.exceptions import ConversationNotFoundError + + with patch('management.services.conversation.service.branch_conversation', return_value=None): + with pytest.raises(ConversationNotFoundError): + branch_conversation_service(7, 2, "user-1") + + +def test_branch_conversation_service_wraps_repository_failure(): + import pytest + + with patch( + 'management.services.conversation.service.branch_conversation', + side_effect=RuntimeError("database unavailable"), + ): + with pytest.raises(Exception, match="database unavailable"): + branch_conversation_service(7, 2, "user-1") + + +@pytest.mark.parametrize("target", [ + {"source_message_id": 123}, + {"answer_index": 2}, + {"source_message_id": 123, "answer_index": 2}, +]) +def test_branch_conversation_service_forwards_selected_answer(target): + with patch( + "management.services.conversation.service.branch_conversation", return_value={"conversation_id": 42} + ) as repo: + assert branch_conversation_service(7, 2, "user-1", **target) == {"conversation_id": 42} + repo.assert_called_once_with(7, 2, user_id="user-1", **target) + + if __name__ == '__main__': unittest.main() diff --git a/test/backend/services/test_message_annotations.py b/test/backend/services/test_message_annotations.py new file mode 100644 index 0000000000..de4f8567f0 --- /dev/null +++ b/test/backend/services/test_message_annotations.py @@ -0,0 +1,338 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Structured reply annotations: contracts, persistence and history merging.""" + +import importlib +import json +from contextlib import contextmanager +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from contracts.conversation import ( + ANNOTATION_COMMENT_MAX_CHARS, + ANNOTATION_MAX_COUNT, + ANNOTATION_QUOTED_TEXT_MAX_CHARS, + ANNOTATION_SOURCE_EXCERPT_MAX_CHARS, + format_annotations_for_llm, + normalize_message_annotations, + parse_message_annotation_unit, +) + +ANNOTATION = { + 'quoted_text': 'quoted reply text', + 'comment': 'note', + 'source_excerpt': 'reply intro', +} + +LEGACY_ANNOTATION = dict(ANNOTATION, context_before='Redis shares the cache.', context_after='Consider expiration.') + + +def test_normalize_validates_trims_and_drops_unusable_entries(): + annotations = normalize_message_annotations([ + ANNOTATION, + {'quoted_text': ' '}, + {'quoted_text': ''}, + {'comment': 'no quote'}, + 'not-a-dict', + {'quoted_text': 'keep', 'comment': ' ', 'source_excerpt': ' '}, + ]) + + assert annotations is not None + assert [item.quoted_text for item in annotations] == [ + 'quoted reply text', 'keep', + ] + assert annotations[1].comment is None + assert annotations[1].source_excerpt is None + + +@pytest.mark.parametrize('raw', [None, 'text', {}, 42]) +def test_normalize_rejects_non_list_payloads(raw): + assert normalize_message_annotations(raw) is None + + +def test_normalize_returns_none_when_nothing_usable_remains(): + assert normalize_message_annotations([{'quoted_text': ' '}]) is None + + +def test_normalize_applies_defensive_caps(): + oversized = { + 'quoted_text': 'q' * (ANNOTATION_QUOTED_TEXT_MAX_CHARS + 10), + 'comment': 'c' * (ANNOTATION_COMMENT_MAX_CHARS + 10), + 'source_excerpt': 's' * (ANNOTATION_SOURCE_EXCERPT_MAX_CHARS + 10), + } + + truncated = normalize_message_annotations([oversized]) + + assert truncated is not None + assert len(truncated[0].quoted_text) == ANNOTATION_QUOTED_TEXT_MAX_CHARS + assert len(truncated[0].comment) == ANNOTATION_COMMENT_MAX_CHARS + assert len(truncated[0].source_excerpt) == ANNOTATION_SOURCE_EXCERPT_MAX_CHARS + + many = [dict(oversized, quoted_text=f'quote {index}') + for index in range(ANNOTATION_MAX_COUNT + 3)] + capped = normalize_message_annotations(many) + + assert capped is not None + assert len(capped) == ANNOTATION_MAX_COUNT + + +def test_format_without_annotations_returns_text_unchanged(): + assert format_annotations_for_llm('why stop?', None) == 'why stop?' + assert format_annotations_for_llm('why stop?', []) == 'why stop?' + + +def test_legacy_context_is_ignored_even_when_malformed(): + normalized = normalize_message_annotations([{ + 'quoted_text': 'selected', + 'context_before': 'x' * 250 + ' nearest', + 'context_after': 'nearest ' + 'y' * 250, + }]) + assert normalized[0].model_dump() == {'quoted_text': 'selected', 'comment': None, 'source_excerpt': None} + malformed = normalize_message_annotations([{'quoted_text': 'q', 'context_before': {}, 'context_after': 42}]) + assert malformed[0].quoted_text == 'q' + legacy = normalize_message_annotations([{'quoted_text': 'legacy', 'source_excerpt': 'intro'}]) + assert 'context_before' not in legacy[0].model_dump() + assert format_annotations_for_llm('', legacy) == '1. Quote:\n> legacy' + + +def test_format_ignores_legacy_context_and_keeps_source_label_ui_only(): + merged = format_annotations_for_llm('why?', normalize_message_annotations([LEGACY_ANNOTATION])) + assert merged == ( + '1. Quote:\n> quoted reply text\n' + 'Comment: note\n\n---\n\nwhy?' + ) + assert 'reply intro' not in merged + + +def test_format_merges_blocks_comment_and_source_into_user_text(): + annotations = normalize_message_annotations([ + {'quoted_text': 'line one\nline two', 'comment': 'skip this', + 'source_excerpt': 'reply intro'}, + ]) + + merged = format_annotations_for_llm('why stop?', annotations) + + assert merged == ('1. Quote:\n' + '> line one\n> line two\n' + 'Comment: skip this\n\n---\n\nwhy stop?') + + +def test_format_joins_multiple_blocks_and_skips_empty_fields(): + annotations = normalize_message_annotations([ + {'quoted_text': 'first', 'comment': 'note'}, + {'quoted_text': 'second'}, + ]) + + merged = format_annotations_for_llm('follow-up', annotations) + + assert merged == ('1. Quote:\n> first\nComment: note\n\n' + '2. Quote:\n> second\n\n---\n\nfollow-up') + + +def test_format_annotation_only_send_keeps_blocks_without_separator(): + annotations = normalize_message_annotations([{'quoted_text': 'only quote'}]) + + assert format_annotations_for_llm('', annotations) == '1. Quote:\n> only quote' + + +def test_parse_unit_round_trips_and_rejects_invalid_payloads(): + annotations = normalize_message_annotations([ANNOTATION]) + payload = json.dumps([item.model_dump(mode='json') for item in annotations]) + + parsed = parse_message_annotation_unit(payload) + assert parsed is not None and len(parsed) == 1 + assert parsed[0].quoted_text == 'quoted reply text' + + assert parse_message_annotation_unit(None) is None + assert parse_message_annotation_unit('{malformed') is None + assert parse_message_annotation_unit(json.dumps([{'quoted_text': ''}])) is None + + +def test_parse_legacy_units_never_restores_context_to_model_input(): + parsed = parse_message_annotation_unit(json.dumps([LEGACY_ANNOTATION])) + assert parsed[0].model_dump() == ANNOTATION + assert format_annotations_for_llm('', parsed) == '1. Quote:\n> quoted reply text\nComment: note' + + +def test_agent_request_retains_structured_annotations(): + from contracts.agent import AgentRequest + request = AgentRequest(query='why stop?', conversation_id=1, annotations=[ANNOTATION]) + + assert request.annotations[0].comment == 'note' + dumped = request.model_dump() + assert dumped['annotations'][0]['source_excerpt'] == 'reply intro' + + +def test_service_passes_annotations_to_atomic_message_save(monkeypatch): + from contracts.agent import AgentRequest + from management.services.conversation import service + + captured = [] + monkeypatch.setattr( + service, 'create_conversation_message', + lambda data, *args, **kwargs: captured.append(data) or 20, + ) + request = AgentRequest(query='why stop?', conversation_id=1, history=[], + annotations=[ANNOTATION, {'quoted_text': ' '}]) + service.save_conversation_user(request, 'owner', 'tenant') + + assert captured[0]['content'] == 'why stop?' + # The unusable second entry is dropped by normalization. + assert captured[0]['annotations'] == [ANNOTATION] + + +def test_history_returns_annotations_without_replacing_readable_text(monkeypatch): + from management.services.conversation import service + + annotations = normalize_message_annotations([ANNOTATION]) + payload = json.dumps([item.model_dump(mode='json') for item in annotations]) + record = {'message_id': 20, 'message_index': 2, 'role': 'user', + 'message_content': 'why stop?', 'status': 'completed', + 'units': [ + {'unit_type': 'message_annotations', 'unit_content': payload}, + {'unit_type': 'message_annotations', 'unit_content': '{bad'}, + ], + 'minio_files': []} + history = {'conversation_id': 1, 'create_time': 0, 'message_records': [record], + 'search_records': [], 'image_records': []} + monkeypatch.setattr(service, 'get_conversation_history', lambda *args: history) + + item = service.get_conversation_history_service(1, 'owner')[0]['message'][0] + + assert item['message'] == 'why stop?' + assert item['annotations'] == [ANNOTATION] + + +@pytest.fixture +def message_db(monkeypatch): + monkeypatch.setattr( + 'nexent.storage.storage_client_factory.create_storage_client_from_config', + lambda *_: MagicMock(), + ) + module = importlib.import_module('management.db.conversation.repository') + engine = create_engine('sqlite://', poolclass=StaticPool) + with engine.begin() as connection: + connection.exec_driver_sql("ATTACH DATABASE ':memory:' AS nexent") + connection.exec_driver_sql( + 'CREATE TABLE nexent.conversation_record_t ' + '(conversation_id INTEGER PRIMARY KEY, created_by TEXT, delete_flag TEXT)') + connection.exec_driver_sql( + 'CREATE TABLE nexent.conversation_message_t ' + '(message_id INTEGER PRIMARY KEY, conversation_id INTEGER, message_index INTEGER, ' + 'message_role TEXT, message_source TEXT, message_content TEXT, minio_files TEXT, ' + 'opinion_flag TEXT, status TEXT, delete_flag TEXT, created_by TEXT, updated_by TEXT, ' + 'create_time DATETIME, update_time DATETIME)') + connection.exec_driver_sql( + 'CREATE TABLE nexent.conversation_message_unit_t ' + '(unit_id INTEGER PRIMARY KEY, conversation_id INTEGER, message_id INTEGER, ' + 'unit_index INTEGER, unit_type TEXT, unit_content TEXT, unit_status TEXT, ' + 'delete_flag TEXT, created_by TEXT, updated_by TEXT, create_time DATETIME, ' + 'update_time DATETIME)') + connection.exec_driver_sql("INSERT INTO nexent.conversation_record_t VALUES (1, 'owner', 'N')") + + monkeypatch.setattr(module, 'get_db_session', + _session_factory(engine)) + monkeypatch.setattr(module, '_get_user_tenant', lambda _: {'tenant_id': 'tenant'}) + monkeypatch.setattr(module, '_enforce_conversation_turn_limit', lambda **kwargs: None) + monkeypatch.setattr(module, '_touch_conversation_activity', lambda *args: None) + + def insert_message(message_id, index, role, content, status='completed', deleted='N'): + with engine.begin() as connection: + connection.execute(text( + 'INSERT INTO nexent.conversation_message_t ' + '(message_id, conversation_id, message_index, message_role, ' + 'message_content, status, delete_flag) ' + 'VALUES (:id, 1, :idx, :role, :body, :status, :deleted)'), + {'id': message_id, 'idx': index, 'role': role, 'body': content, + 'status': status, 'deleted': deleted}) + + def insert_unit(unit_id, message_id, unit_type, content, status='completed', deleted='N'): + with engine.begin() as connection: + connection.execute(text( + 'INSERT INTO nexent.conversation_message_unit_t ' + '(unit_id, message_id, conversation_id, unit_index, unit_type, ' + 'unit_content, unit_status, delete_flag) ' + 'VALUES (:id, :mid, 1, :idx, :kind, :body, :status, :deleted)'), + {'id': unit_id, 'mid': message_id, 'idx': unit_id, 'kind': unit_type, + 'body': content, 'status': status, 'deleted': deleted}) + + yield module, insert_message, insert_unit, engine + engine.dispose() + + +def _session_factory(engine): + @contextmanager + def session(): + with Session(engine) as current, current.begin(): + yield current + + return session + + +def test_create_message_persists_annotations_as_structured_unit(message_db): + module, _, _, engine = message_db + + message_id = module.create_conversation_message({ + 'conversation_id': 1, 'message_idx': 0, 'role': 'user', + 'content': 'why stop?', 'annotations': [ANNOTATION], + }, 'owner') + + with engine.begin() as connection: + rows = connection.execute(text( + 'SELECT unit_type, unit_content FROM nexent.conversation_message_unit_t ' + 'WHERE message_id = :mid'), {'mid': message_id}).fetchall() + assert len(rows) == 1 + assert rows[0][0] == 'message_annotations' + assert json.loads(rows[0][1]) == [ANNOTATION] + + +def test_create_message_skips_annotation_unit_for_assistant_rows(message_db): + module, _, _, engine = message_db + + message_id = module.create_conversation_message({ + 'conversation_id': 1, 'message_idx': 1, 'role': 'assistant', + 'content': 'answer', 'annotations': [ANNOTATION], + }, 'owner') + + with engine.begin() as connection: + count = connection.execute(text( + 'SELECT COUNT(*) FROM nexent.conversation_message_unit_t ' + 'WHERE message_id = :mid'), {'mid': message_id}).scalar() + assert count == 0 + + +def test_historical_turns_merge_annotations_into_model_facing_text(message_db): + module, insert_message, insert_unit, _ = message_db + annotations = normalize_message_annotations([ANNOTATION]) + payload = json.dumps([item.model_dump(mode='json') for item in annotations]) + + insert_message(1, 0, 'user', 'why stop?') + insert_message(2, 1, 'assistant', 'because of the quota') + insert_unit(1, 1, 'message_annotations', payload) + insert_message(3, 2, 'user', 'then raise it') + + turns = module.get_historical_context(1, 3, 'owner', 'tenant')['conversation_turns'] + + assert len(turns) == 1 + assert turns[0]['user_message'] == ( + '1. Quote:\n' + '> quoted reply text\n' + 'Comment: note\n\n---\n\nwhy stop?' + ) + assert turns[0]['assistant_final_answer'] == 'because of the quota' + + +def test_historical_turns_ignore_deleted_annotation_units(message_db): + module, insert_message, insert_unit, _ = message_db + insert_message(1, 0, 'user', 'why stop?') + insert_message(2, 1, 'assistant', 'because of the quota') + insert_unit(1, 1, 'message_annotations', json.dumps([ANNOTATION]), deleted='Y') + insert_message(3, 2, 'user', 'then raise it') + + turns = module.get_historical_context(1, 3, 'owner', 'tenant')['conversation_turns'] + + assert turns[0]['user_message'] == 'why stop?'