Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f9dc6f2
feat(frontend): add conversation artifacts panel for agent-generated …
Oct 31, 2026
71de5f7
feat(chat): add "add to chat" action for conversation artifacts
Nov 2, 2026
942433c
feat(chat): add question directory with viewport jump
Nov 2, 2026
c214353
feat(chat): add keyword search to the question directory
Nov 2, 2026
030dd21
feat(chat): branch a conversation to a new chat from an answer
Nov 2, 2026
e238376
feat(chat): quote selected reply text into the next message
Nov 2, 2026
7218efd
feat(chat): send reply annotations as structured fields
Nov 2, 2026
3aa0ad1
feat(chat): rename the selection bubble action to add annotation
Nov 3, 2026
3bd2186
fix(chat): send annotation-only turns past the adapter query guard
Nov 3, 2026
60dc03e
feat(chat): collapse reply annotation cards into an expandable badge
Nov 3, 2026
3aae5d5
feat(chat): add hover previews and current position to the question d…
Nov 3, 2026
b80a214
fix(chat): keep branched conversations visible without workbench config
Nov 5, 2026
bb810b9
fix(chat): clear question directory preview on jump and list scroll
Nov 5, 2026
ad1fff8
feat(chat): add viewport scroll rescue component
Nov 5, 2026
a36529a
feat(chat): capture nearby reply context for add-to-chat quotes
Nov 5, 2026
51c227c
feat(chat): move artifacts entry to assistant messages and wire scrol…
Nov 5, 2026
94c35e7
Merge develop and finalize conversation UI improvements
Nov 6, 2026
3d27151
feat(chat): add question bookmarks and automatic document previews
Nov 9, 2026
28315e6
chore: remove chat change records and verification reports
Nov 9, 2026
e731abd
revert(chat): remove question bookmarks and database migration
Nov 9, 2026
e5ad484
chore: remove remaining conversation change documentation
Nov 9, 2026
a2c8b50
fix(tests): construct complete artifact attachments for type checking
Nov 9, 2026
ecaba75
fix(contracts): keep reply annotations in conversation business contract
Nov 9, 2026
80f726a
fix(chat): address branch errors, keyboard focus and artifact concurr…
Nov 9, 2026
a2783a2
fix: preserve selected answer when branching conversations
Nov 9, 2026
660d203
fix: remove highlight frame when navigating chat history
Nov 10, 2026
71916cc
fix(preview): use white DOCX backdrop and default page layout
Nov 13, 2026
24d5854
merge: sync develop and preserve conversation features
Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion backend/contracts/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
133 changes: 132 additions & 1 deletion backend/contracts/conversation.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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]

Expand Down
52 changes: 51 additions & 1 deletion backend/management/api/conversation/conversation_management.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)):
"""
Expand Down
39 changes: 39 additions & 0 deletions backend/management/db/conversation/branch_titles.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading