feat(assistant): add conversations list, infinite history scroll, and archived tab
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
import os
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
# Force isolated sqlite databases for test module before dependencies import.
|
# Force isolated sqlite databases for test module before dependencies import.
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_api.db")
|
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_api.db")
|
||||||
@@ -391,4 +392,144 @@ def test_llm_validation_missing_dashboard_returns_needs_clarification():
|
|||||||
|
|
||||||
|
|
||||||
# [/DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function]
|
# [/DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_list_conversations_groups_by_conversation_and_marks_archived:Function]
|
||||||
|
# @PURPOSE: Conversations endpoint must group messages and compute archived marker by inactivity threshold.
|
||||||
|
# @PRE: Fake DB contains two conversations with different update timestamps.
|
||||||
|
# @POST: Response includes both conversations with archived flag set for stale one.
|
||||||
|
def test_list_conversations_groups_by_conversation_and_marks_archived():
|
||||||
|
_clear_assistant_state()
|
||||||
|
db = _FakeDb()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
AssistantMessageRecord(
|
||||||
|
id="m-1",
|
||||||
|
user_id="u-admin",
|
||||||
|
conversation_id="conv-active",
|
||||||
|
role="user",
|
||||||
|
text="active chat",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
AssistantMessageRecord(
|
||||||
|
id="m-2",
|
||||||
|
user_id="u-admin",
|
||||||
|
conversation_id="conv-old",
|
||||||
|
role="user",
|
||||||
|
text="old chat",
|
||||||
|
created_at=now - timedelta(days=assistant_module.ASSISTANT_ARCHIVE_AFTER_DAYS + 2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _run_async(
|
||||||
|
assistant_module.list_conversations(
|
||||||
|
page=1,
|
||||||
|
page_size=20,
|
||||||
|
include_archived=True,
|
||||||
|
search=None,
|
||||||
|
current_user=_admin_user(),
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["total"] == 2
|
||||||
|
by_id = {item["conversation_id"]: item for item in result["items"]}
|
||||||
|
assert by_id["conv-active"]["archived"] is False
|
||||||
|
assert by_id["conv-old"]["archived"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_list_conversations_groups_by_conversation_and_marks_archived:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_history_from_latest_returns_recent_page_first:Function]
|
||||||
|
# @PURPOSE: History endpoint from_latest mode must return newest page while preserving chronological order in chunk.
|
||||||
|
# @PRE: Conversation has more messages than single page size.
|
||||||
|
# @POST: First page returns latest messages and has_next indicates older pages exist.
|
||||||
|
def test_history_from_latest_returns_recent_page_first():
|
||||||
|
_clear_assistant_state()
|
||||||
|
db = _FakeDb()
|
||||||
|
base_time = datetime.utcnow() - timedelta(minutes=10)
|
||||||
|
conv_id = "conv-paginated"
|
||||||
|
for i in range(4, -1, -1):
|
||||||
|
db.add(
|
||||||
|
AssistantMessageRecord(
|
||||||
|
id=f"msg-{i}",
|
||||||
|
user_id="u-admin",
|
||||||
|
conversation_id=conv_id,
|
||||||
|
role="user" if i % 2 == 0 else "assistant",
|
||||||
|
text=f"message-{i}",
|
||||||
|
created_at=base_time + timedelta(minutes=i),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _run_async(
|
||||||
|
assistant_module.get_history(
|
||||||
|
page=1,
|
||||||
|
page_size=2,
|
||||||
|
conversation_id=conv_id,
|
||||||
|
from_latest=True,
|
||||||
|
current_user=_admin_user(),
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["from_latest"] is True
|
||||||
|
assert result["has_next"] is True
|
||||||
|
# Chunk is chronological while representing latest page.
|
||||||
|
assert [item["text"] for item in result["items"]] == ["message-3", "message-4"]
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_history_from_latest_returns_recent_page_first:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:test_list_conversations_archived_only_filters_active:Function]
|
||||||
|
# @PURPOSE: archived_only mode must return only archived conversations.
|
||||||
|
# @PRE: Dataset includes one active and one archived conversation.
|
||||||
|
# @POST: Only archived conversation remains in response payload.
|
||||||
|
def test_list_conversations_archived_only_filters_active():
|
||||||
|
_clear_assistant_state()
|
||||||
|
db = _FakeDb()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
db.add(
|
||||||
|
AssistantMessageRecord(
|
||||||
|
id="m-active",
|
||||||
|
user_id="u-admin",
|
||||||
|
conversation_id="conv-active-2",
|
||||||
|
role="user",
|
||||||
|
text="active",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
AssistantMessageRecord(
|
||||||
|
id="m-archived",
|
||||||
|
user_id="u-admin",
|
||||||
|
conversation_id="conv-archived-2",
|
||||||
|
role="user",
|
||||||
|
text="archived",
|
||||||
|
created_at=now - timedelta(days=assistant_module.ASSISTANT_ARCHIVE_AFTER_DAYS + 3),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _run_async(
|
||||||
|
assistant_module.list_conversations(
|
||||||
|
page=1,
|
||||||
|
page_size=20,
|
||||||
|
include_archived=True,
|
||||||
|
archived_only=True,
|
||||||
|
search=None,
|
||||||
|
current_user=_admin_user(),
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["total"] == 1
|
||||||
|
assert result["items"][0]["conversation_id"] == "conv-archived-2"
|
||||||
|
assert result["items"][0]["archived"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# [/DEF:test_list_conversations_archived_only_filters_active:Function]
|
||||||
# [/DEF:backend.src.api.routes.__tests__.test_assistant_api:Module]
|
# [/DEF:backend.src.api.routes.__tests__.test_assistant_api:Module]
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ CONVERSATIONS: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
|
|||||||
USER_ACTIVE_CONVERSATION: Dict[str, str] = {}
|
USER_ACTIVE_CONVERSATION: Dict[str, str] = {}
|
||||||
CONFIRMATIONS: Dict[str, ConfirmationRecord] = {}
|
CONFIRMATIONS: Dict[str, ConfirmationRecord] = {}
|
||||||
ASSISTANT_AUDIT: Dict[str, List[Dict[str, Any]]] = {}
|
ASSISTANT_AUDIT: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
ASSISTANT_ARCHIVE_AFTER_DAYS = 14
|
||||||
|
ASSISTANT_MESSAGE_TTL_DAYS = 90
|
||||||
|
|
||||||
INTENT_PERMISSION_CHECKS: Dict[str, List[Tuple[str, str]]] = {
|
INTENT_PERMISSION_CHECKS: Dict[str, List[Tuple[str, str]]] = {
|
||||||
"get_task_status": [("tasks", "READ")],
|
"get_task_status": [("tasks", "READ")],
|
||||||
@@ -334,6 +336,68 @@ def _resolve_or_create_conversation(user_id: str, conversation_id: Optional[str]
|
|||||||
# [/DEF:_resolve_or_create_conversation:Function]
|
# [/DEF:_resolve_or_create_conversation:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:_cleanup_history_ttl:Function]
|
||||||
|
# @PURPOSE: Enforce assistant message retention window by deleting expired rows and in-memory records.
|
||||||
|
# @PRE: db session is available and user_id references current actor scope.
|
||||||
|
# @POST: Messages older than ASSISTANT_MESSAGE_TTL_DAYS are removed from persistence and memory mirrors.
|
||||||
|
def _cleanup_history_ttl(db: Session, user_id: str):
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=ASSISTANT_MESSAGE_TTL_DAYS)
|
||||||
|
try:
|
||||||
|
query = db.query(AssistantMessageRecord).filter(
|
||||||
|
AssistantMessageRecord.user_id == user_id,
|
||||||
|
AssistantMessageRecord.created_at < cutoff,
|
||||||
|
)
|
||||||
|
if hasattr(query, "delete"):
|
||||||
|
query.delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
logger.warning(f"[assistant.history][ttl_cleanup_failed] user={user_id} error={exc}")
|
||||||
|
|
||||||
|
stale_keys: List[Tuple[str, str]] = []
|
||||||
|
for key, items in CONVERSATIONS.items():
|
||||||
|
if key[0] != user_id:
|
||||||
|
continue
|
||||||
|
kept = []
|
||||||
|
for item in items:
|
||||||
|
created_at = item.get("created_at")
|
||||||
|
if isinstance(created_at, datetime) and created_at < cutoff:
|
||||||
|
continue
|
||||||
|
kept.append(item)
|
||||||
|
if kept:
|
||||||
|
CONVERSATIONS[key] = kept
|
||||||
|
else:
|
||||||
|
stale_keys.append(key)
|
||||||
|
for key in stale_keys:
|
||||||
|
CONVERSATIONS.pop(key, None)
|
||||||
|
# [/DEF:_cleanup_history_ttl:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:_is_conversation_archived:Function]
|
||||||
|
# @PURPOSE: Determine archived state for a conversation based on last update timestamp.
|
||||||
|
# @PRE: updated_at can be null for empty conversations.
|
||||||
|
# @POST: Returns True when conversation inactivity exceeds archive threshold.
|
||||||
|
def _is_conversation_archived(updated_at: Optional[datetime]) -> bool:
|
||||||
|
if not updated_at:
|
||||||
|
return False
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=ASSISTANT_ARCHIVE_AFTER_DAYS)
|
||||||
|
return updated_at < cutoff
|
||||||
|
# [/DEF:_is_conversation_archived:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:_coerce_query_bool:Function]
|
||||||
|
# @PURPOSE: Normalize bool-like query values for compatibility in direct handler invocations/tests.
|
||||||
|
# @PRE: value may be bool, string, or FastAPI Query metadata object.
|
||||||
|
# @POST: Returns deterministic boolean flag.
|
||||||
|
def _coerce_query_bool(value: Any) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
return False
|
||||||
|
# [/DEF:_coerce_query_bool:Function]
|
||||||
|
|
||||||
|
|
||||||
# [DEF:_extract_id:Function]
|
# [DEF:_extract_id:Function]
|
||||||
# @PURPOSE: Extract first regex match group from text by ordered pattern list.
|
# @PURPOSE: Extract first regex match group from text by ordered pattern list.
|
||||||
# @PRE: patterns contain at least one capture group.
|
# @PRE: patterns contain at least one capture group.
|
||||||
@@ -1385,6 +1449,93 @@ async def cancel_operation(
|
|||||||
# [/DEF:cancel_operation:Function]
|
# [/DEF:cancel_operation:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:list_conversations:Function]
|
||||||
|
# @PURPOSE: Return paginated conversation list for current user with archived flag and last message preview.
|
||||||
|
# @PRE: Authenticated user context and valid pagination params.
|
||||||
|
# @POST: Conversations are grouped by conversation_id sorted by latest activity descending.
|
||||||
|
# @RETURN: Dict with items, paging metadata, and archive segmentation counts.
|
||||||
|
@router.get("/conversations")
|
||||||
|
async def list_conversations(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
include_archived: bool = Query(False),
|
||||||
|
archived_only: bool = Query(False),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
with belief_scope("assistant.conversations"):
|
||||||
|
user_id = current_user.id
|
||||||
|
include_archived = _coerce_query_bool(include_archived)
|
||||||
|
archived_only = _coerce_query_bool(archived_only)
|
||||||
|
_cleanup_history_ttl(db, user_id)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(AssistantMessageRecord)
|
||||||
|
.filter(AssistantMessageRecord.user_id == user_id)
|
||||||
|
.order_by(desc(AssistantMessageRecord.created_at))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
summary: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
conv_id = row.conversation_id
|
||||||
|
if not conv_id:
|
||||||
|
continue
|
||||||
|
created_at = row.created_at or datetime.utcnow()
|
||||||
|
if conv_id not in summary:
|
||||||
|
summary[conv_id] = {
|
||||||
|
"conversation_id": conv_id,
|
||||||
|
"title": "",
|
||||||
|
"updated_at": created_at,
|
||||||
|
"last_message": row.text,
|
||||||
|
"last_role": row.role,
|
||||||
|
"last_state": row.state,
|
||||||
|
"last_task_id": row.task_id,
|
||||||
|
"message_count": 0,
|
||||||
|
}
|
||||||
|
item = summary[conv_id]
|
||||||
|
item["message_count"] += 1
|
||||||
|
if row.role == "user" and row.text and not item["title"]:
|
||||||
|
item["title"] = row.text.strip()[:80]
|
||||||
|
|
||||||
|
items = []
|
||||||
|
search_term = search.lower().strip() if search else ""
|
||||||
|
archived_total = sum(1 for c in summary.values() if _is_conversation_archived(c.get("updated_at")))
|
||||||
|
active_total = len(summary) - archived_total
|
||||||
|
for conv in summary.values():
|
||||||
|
conv["archived"] = _is_conversation_archived(conv.get("updated_at"))
|
||||||
|
if not conv.get("title"):
|
||||||
|
conv["title"] = f"Conversation {conv['conversation_id'][:8]}"
|
||||||
|
if search_term:
|
||||||
|
haystack = f"{conv.get('title', '')} {conv.get('last_message', '')}".lower()
|
||||||
|
if search_term not in haystack:
|
||||||
|
continue
|
||||||
|
if archived_only and not conv["archived"]:
|
||||||
|
continue
|
||||||
|
if not archived_only and not include_archived and conv["archived"]:
|
||||||
|
continue
|
||||||
|
updated = conv.get("updated_at")
|
||||||
|
conv["updated_at"] = updated.isoformat() if isinstance(updated, datetime) else None
|
||||||
|
items.append(conv)
|
||||||
|
|
||||||
|
items.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
|
||||||
|
total = len(items)
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
page_items = items[start : start + page_size]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": page_items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"has_next": start + page_size < total,
|
||||||
|
"active_total": active_total,
|
||||||
|
"archived_total": archived_total,
|
||||||
|
}
|
||||||
|
# [/DEF:list_conversations:Function]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/history")
|
@router.get("/history")
|
||||||
# [DEF:get_history:Function]
|
# [DEF:get_history:Function]
|
||||||
# @PURPOSE: Retrieve paginated assistant conversation history for current user.
|
# @PURPOSE: Retrieve paginated assistant conversation history for current user.
|
||||||
@@ -1395,24 +1546,41 @@ async def get_history(
|
|||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
conversation_id: Optional[str] = Query(None),
|
conversation_id: Optional[str] = Query(None),
|
||||||
|
from_latest: bool = Query(False),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
with belief_scope("assistant.history"):
|
with belief_scope("assistant.history"):
|
||||||
user_id = current_user.id
|
user_id = current_user.id
|
||||||
|
_cleanup_history_ttl(db, user_id)
|
||||||
conv_id = _resolve_or_create_conversation(user_id, conversation_id, db)
|
conv_id = _resolve_or_create_conversation(user_id, conversation_id, db)
|
||||||
|
|
||||||
query = (
|
base_query = (
|
||||||
db.query(AssistantMessageRecord)
|
db.query(AssistantMessageRecord)
|
||||||
.filter(
|
.filter(
|
||||||
AssistantMessageRecord.user_id == user_id,
|
AssistantMessageRecord.user_id == user_id,
|
||||||
AssistantMessageRecord.conversation_id == conv_id,
|
AssistantMessageRecord.conversation_id == conv_id,
|
||||||
)
|
)
|
||||||
.order_by(AssistantMessageRecord.created_at.asc())
|
|
||||||
)
|
)
|
||||||
total = query.count()
|
total = base_query.count()
|
||||||
start = (page - 1) * page_size
|
start = (page - 1) * page_size
|
||||||
rows = query.offset(start).limit(page_size).all()
|
if from_latest:
|
||||||
|
rows = (
|
||||||
|
base_query
|
||||||
|
.order_by(desc(AssistantMessageRecord.created_at))
|
||||||
|
.offset(start)
|
||||||
|
.limit(page_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
rows = list(reversed(rows))
|
||||||
|
else:
|
||||||
|
rows = (
|
||||||
|
base_query
|
||||||
|
.order_by(AssistantMessageRecord.created_at.asc())
|
||||||
|
.offset(start)
|
||||||
|
.limit(page_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
persistent_items = [
|
persistent_items = [
|
||||||
{
|
{
|
||||||
@@ -1437,6 +1605,7 @@ async def get_history(
|
|||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
"has_next": start + page_size < total,
|
"has_next": start + page_size < total,
|
||||||
|
"from_latest": from_latest,
|
||||||
"conversation_id": conv_id,
|
"conversation_id": conv_id,
|
||||||
}
|
}
|
||||||
# [/DEF:get_history:Function]
|
# [/DEF:get_history:Function]
|
||||||
|
|||||||
@@ -39,12 +39,40 @@ export function cancelAssistantOperation(confirmationId) {
|
|||||||
// @PURPOSE: Retrieve paginated assistant conversation history.
|
// @PURPOSE: Retrieve paginated assistant conversation history.
|
||||||
// @PRE: page/pageSize are positive integers.
|
// @PRE: page/pageSize are positive integers.
|
||||||
// @POST: Returns a paginated payload with history items.
|
// @POST: Returns a paginated payload with history items.
|
||||||
export function getAssistantHistory(page = 1, pageSize = 20, conversationId = null) {
|
export function getAssistantHistory(page = 1, pageSize = 20, conversationId = null, fromLatest = false) {
|
||||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
params.append('conversation_id', conversationId);
|
params.append('conversation_id', conversationId);
|
||||||
}
|
}
|
||||||
|
if (fromLatest) {
|
||||||
|
params.append('from_latest', 'true');
|
||||||
|
}
|
||||||
return requestApi(`/assistant/history?${params.toString()}`, 'GET');
|
return requestApi(`/assistant/history?${params.toString()}`, 'GET');
|
||||||
}
|
}
|
||||||
// [/DEF:getAssistantHistory:Function]
|
// [/DEF:getAssistantHistory:Function]
|
||||||
|
|
||||||
|
// [DEF:getAssistantConversations:Function]
|
||||||
|
// @PURPOSE: Retrieve paginated conversation list for assistant sidebar/history switcher.
|
||||||
|
// @PRE: page/pageSize are positive integers.
|
||||||
|
// @POST: Returns paginated conversation summaries.
|
||||||
|
export function getAssistantConversations(
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
includeArchived = false,
|
||||||
|
search = '',
|
||||||
|
archivedOnly = false,
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||||
|
if (includeArchived) {
|
||||||
|
params.append('include_archived', 'true');
|
||||||
|
}
|
||||||
|
if (archivedOnly) {
|
||||||
|
params.append('archived_only', 'true');
|
||||||
|
}
|
||||||
|
if (search?.trim()) {
|
||||||
|
params.append('search', search.trim());
|
||||||
|
}
|
||||||
|
return requestApi(`/assistant/conversations?${params.toString()}`, 'GET');
|
||||||
|
}
|
||||||
|
// [/DEF:getAssistantConversations:Function]
|
||||||
// [/DEF:frontend.src.lib.api.assistant:Module]
|
// [/DEF:frontend.src.lib.api.assistant:Module]
|
||||||
|
|||||||
@@ -38,12 +38,27 @@
|
|||||||
confirmAssistantOperation,
|
confirmAssistantOperation,
|
||||||
cancelAssistantOperation,
|
cancelAssistantOperation,
|
||||||
getAssistantHistory,
|
getAssistantHistory,
|
||||||
|
getAssistantConversations,
|
||||||
} from '$lib/api/assistant.js';
|
} from '$lib/api/assistant.js';
|
||||||
|
|
||||||
|
const HISTORY_PAGE_SIZE = 30;
|
||||||
|
const CONVERSATIONS_PAGE_SIZE = 20;
|
||||||
|
|
||||||
let input = '';
|
let input = '';
|
||||||
let loading = false;
|
let loading = false;
|
||||||
let loadingHistory = false;
|
let loadingHistory = false;
|
||||||
|
let loadingMoreHistory = false;
|
||||||
|
let loadingConversations = false;
|
||||||
let messages = [];
|
let messages = [];
|
||||||
|
let conversations = [];
|
||||||
|
let conversationFilter = 'active';
|
||||||
|
let activeConversationsTotal = 0;
|
||||||
|
let archivedConversationsTotal = 0;
|
||||||
|
let historyPage = 1;
|
||||||
|
let historyHasNext = false;
|
||||||
|
let conversationsPage = 1;
|
||||||
|
let conversationsHasNext = false;
|
||||||
|
let historyViewport = null;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|
||||||
$: isOpen = $assistantChatStore?.isOpen || false;
|
$: isOpen = $assistantChatStore?.isOpen || false;
|
||||||
@@ -60,11 +75,13 @@
|
|||||||
if (loadingHistory || !isOpen) return;
|
if (loadingHistory || !isOpen) return;
|
||||||
loadingHistory = true;
|
loadingHistory = true;
|
||||||
try {
|
try {
|
||||||
const history = await getAssistantHistory(1, 50, conversationId);
|
const history = await getAssistantHistory(1, HISTORY_PAGE_SIZE, conversationId, true);
|
||||||
messages = (history.items || []).map((msg) => ({
|
messages = (history.items || []).map((msg) => ({
|
||||||
...msg,
|
...msg,
|
||||||
actions: msg.actions || msg.metadata?.actions || [],
|
actions: msg.actions || msg.metadata?.actions || [],
|
||||||
}));
|
}));
|
||||||
|
historyPage = 1;
|
||||||
|
historyHasNext = Boolean(history.has_next);
|
||||||
if (history.conversation_id && history.conversation_id !== conversationId) {
|
if (history.conversation_id && history.conversation_id !== conversationId) {
|
||||||
setAssistantConversationId(history.conversation_id);
|
setAssistantConversationId(history.conversation_id);
|
||||||
}
|
}
|
||||||
@@ -78,10 +95,82 @@
|
|||||||
}
|
}
|
||||||
// [/DEF:loadHistory:Function]
|
// [/DEF:loadHistory:Function]
|
||||||
|
|
||||||
|
// [DEF:loadConversations:Function]
|
||||||
|
/**
|
||||||
|
* @PURPOSE: Load paginated conversation summaries for quick switching UI.
|
||||||
|
* @PRE: Panel is open and request not already running.
|
||||||
|
* @POST: conversations list refreshed or appended based on page.
|
||||||
|
*/
|
||||||
|
async function loadConversations(reset = false) {
|
||||||
|
if (loadingConversations || !isOpen) return;
|
||||||
|
loadingConversations = true;
|
||||||
|
try {
|
||||||
|
const page = reset ? 1 : conversationsPage + 1;
|
||||||
|
const includeArchived = conversationFilter === 'archived';
|
||||||
|
const archivedOnly = conversationFilter === 'archived';
|
||||||
|
const response = await getAssistantConversations(
|
||||||
|
page,
|
||||||
|
CONVERSATIONS_PAGE_SIZE,
|
||||||
|
includeArchived,
|
||||||
|
'',
|
||||||
|
archivedOnly,
|
||||||
|
);
|
||||||
|
const rows = response.items || [];
|
||||||
|
conversations = reset ? rows : [...conversations, ...rows];
|
||||||
|
conversationsPage = page;
|
||||||
|
conversationsHasNext = Boolean(response.has_next);
|
||||||
|
activeConversationsTotal = response.active_total ?? activeConversationsTotal;
|
||||||
|
archivedConversationsTotal = response.archived_total ?? archivedConversationsTotal;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AssistantChatPanel][Coherence:Failed] Failed to load conversations', err);
|
||||||
|
} finally {
|
||||||
|
loadingConversations = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [/DEF:loadConversations:Function]
|
||||||
|
|
||||||
|
// [DEF:loadOlderMessages:Function]
|
||||||
|
/**
|
||||||
|
* @PURPOSE: Lazy-load older messages for active conversation when user scrolls to top.
|
||||||
|
* @PRE: History has next page and active conversation is known.
|
||||||
|
* @POST: Older messages are prepended while preserving order.
|
||||||
|
*/
|
||||||
|
async function loadOlderMessages() {
|
||||||
|
if (loadingMoreHistory || loadingHistory || !historyHasNext || !conversationId) return;
|
||||||
|
loadingMoreHistory = true;
|
||||||
|
try {
|
||||||
|
const nextPage = historyPage + 1;
|
||||||
|
const history = await getAssistantHistory(nextPage, HISTORY_PAGE_SIZE, conversationId, true);
|
||||||
|
const chunk = (history.items || []).map((msg) => ({
|
||||||
|
...msg,
|
||||||
|
actions: msg.actions || msg.metadata?.actions || [],
|
||||||
|
}));
|
||||||
|
const existingIds = new Set(messages.map((m) => m.message_id));
|
||||||
|
const uniqueChunk = chunk.filter((m) => !existingIds.has(m.message_id));
|
||||||
|
messages = [...uniqueChunk, ...messages];
|
||||||
|
historyPage = nextPage;
|
||||||
|
historyHasNext = Boolean(history.has_next);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AssistantChatPanel][Coherence:Failed] Failed to load older messages', err);
|
||||||
|
} finally {
|
||||||
|
loadingMoreHistory = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [/DEF:loadOlderMessages:Function]
|
||||||
|
|
||||||
$: if (isOpen && !initialized) {
|
$: if (isOpen && !initialized) {
|
||||||
|
loadConversations(true);
|
||||||
loadHistory();
|
loadHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: if (isOpen && initialized && conversationId) {
|
||||||
|
// Re-load only when user switched to another conversation.
|
||||||
|
const currentFirstConversationId = messages.length ? messages[0].conversation_id : conversationId;
|
||||||
|
if (currentFirstConversationId !== conversationId) {
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// [DEF:appendLocalUserMessage:Function]
|
// [DEF:appendLocalUserMessage:Function]
|
||||||
/**
|
/**
|
||||||
* @PURPOSE: Add optimistic local user message before backend response.
|
* @PURPOSE: Add optimistic local user message before backend response.
|
||||||
@@ -124,6 +213,29 @@
|
|||||||
}
|
}
|
||||||
// [/DEF:appendAssistantResponse:Function]
|
// [/DEF:appendAssistantResponse:Function]
|
||||||
|
|
||||||
|
function buildConversationTitle(conversation) {
|
||||||
|
if (conversation?.title?.trim()) return conversation.title.trim();
|
||||||
|
if (!conversation?.conversation_id) return 'Conversation';
|
||||||
|
return `Conversation ${conversation.conversation_id.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConversationFilter(filter) {
|
||||||
|
if (filter !== 'active' && filter !== 'archived') return;
|
||||||
|
if (conversationFilter === filter) return;
|
||||||
|
conversationFilter = filter;
|
||||||
|
conversations = [];
|
||||||
|
conversationsPage = 1;
|
||||||
|
conversationsHasNext = false;
|
||||||
|
loadConversations(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConversationTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const dt = new Date(iso);
|
||||||
|
if (Number.isNaN(dt.getTime())) return '';
|
||||||
|
return dt.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
// [DEF:handleSend:Function]
|
// [DEF:handleSend:Function]
|
||||||
/**
|
/**
|
||||||
* @PURPOSE: Submit user command to assistant orchestration API.
|
* @PURPOSE: Submit user command to assistant orchestration API.
|
||||||
@@ -150,6 +262,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
appendAssistantResponse(response);
|
appendAssistantResponse(response);
|
||||||
|
await loadConversations(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
appendAssistantResponse({
|
appendAssistantResponse({
|
||||||
response_id: `error-${Date.now()}`,
|
response_id: `error-${Date.now()}`,
|
||||||
@@ -164,6 +277,40 @@
|
|||||||
}
|
}
|
||||||
// [/DEF:handleSend:Function]
|
// [/DEF:handleSend:Function]
|
||||||
|
|
||||||
|
// [DEF:selectConversation:Function]
|
||||||
|
/**
|
||||||
|
* @PURPOSE: Switch active chat context to selected conversation item.
|
||||||
|
* @PRE: conversation carries valid conversation_id.
|
||||||
|
* @POST: conversationId updated and history reloaded.
|
||||||
|
*/
|
||||||
|
async function selectConversation(conversation) {
|
||||||
|
if (!conversation?.conversation_id) return;
|
||||||
|
if (conversation.conversation_id === conversationId) return;
|
||||||
|
setAssistantConversationId(conversation.conversation_id);
|
||||||
|
initialized = false;
|
||||||
|
await loadHistory();
|
||||||
|
}
|
||||||
|
// [/DEF:selectConversation:Function]
|
||||||
|
|
||||||
|
// [DEF:startNewConversation:Function]
|
||||||
|
/**
|
||||||
|
* @PURPOSE: Create local empty chat context that will be persisted on first message.
|
||||||
|
* @PRE: Panel is open.
|
||||||
|
* @POST: Messages reset and new conversation id bound.
|
||||||
|
*/
|
||||||
|
function startNewConversation() {
|
||||||
|
const newId =
|
||||||
|
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `conv-${Date.now()}`;
|
||||||
|
setAssistantConversationId(newId);
|
||||||
|
messages = [];
|
||||||
|
historyPage = 1;
|
||||||
|
historyHasNext = false;
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
// [/DEF:startNewConversation:Function]
|
||||||
|
|
||||||
// [DEF:handleAction:Function]
|
// [DEF:handleAction:Function]
|
||||||
/**
|
/**
|
||||||
* @PURPOSE: Execute assistant action button behavior (open task/reports, confirm, cancel).
|
* @PURPOSE: Execute assistant action button behavior (open task/reports, confirm, cancel).
|
||||||
@@ -235,6 +382,21 @@
|
|||||||
}
|
}
|
||||||
// [/DEF:stateClass:Function]
|
// [/DEF:stateClass:Function]
|
||||||
|
|
||||||
|
// [DEF:handleHistoryScroll:Function]
|
||||||
|
/**
|
||||||
|
* @PURPOSE: Trigger lazy history fetch when user scroll reaches top boundary.
|
||||||
|
* @PRE: Scroll event emitted by history viewport container.
|
||||||
|
* @POST: loadOlderMessages called when boundary and more pages available.
|
||||||
|
*/
|
||||||
|
function handleHistoryScroll(event) {
|
||||||
|
const el = event.currentTarget;
|
||||||
|
if (!el || typeof el.scrollTop !== 'number') return;
|
||||||
|
if (el.scrollTop <= 16) {
|
||||||
|
loadOlderMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [/DEF:handleHistoryScroll:Function]
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
initialized = false;
|
initialized = false;
|
||||||
});
|
});
|
||||||
@@ -259,7 +421,59 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex h-[calc(100%-56px)] flex-col">
|
<div class="flex h-[calc(100%-56px)] flex-col">
|
||||||
<div class="flex-1 space-y-3 overflow-y-auto p-4">
|
<div class="border-b border-slate-200 px-3 py-2">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">Conversations</span>
|
||||||
|
<button
|
||||||
|
class="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-700 transition hover:bg-slate-100"
|
||||||
|
on:click={startNewConversation}
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
class="rounded-md border px-2 py-1 text-[11px] font-medium transition {conversationFilter === 'active' ? 'border-sky-300 bg-sky-50 text-sky-900' : 'border-slate-300 bg-white text-slate-700 hover:bg-slate-100'}"
|
||||||
|
on:click={() => setConversationFilter('active')}
|
||||||
|
>
|
||||||
|
Active ({activeConversationsTotal})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-md border px-2 py-1 text-[11px] font-medium transition {conversationFilter === 'archived' ? 'border-sky-300 bg-sky-50 text-sky-900' : 'border-slate-300 bg-white text-slate-700 hover:bg-slate-100'}"
|
||||||
|
on:click={() => setConversationFilter('archived')}
|
||||||
|
>
|
||||||
|
Archived ({archivedConversationsTotal})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{#each conversations as convo (convo.conversation_id)}
|
||||||
|
<button
|
||||||
|
class="min-w-[140px] max-w-[220px] rounded-lg border px-2.5 py-1.5 text-left text-xs transition {convo.conversation_id === conversationId ? 'border-sky-300 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'}"
|
||||||
|
on:click={() => selectConversation(convo)}
|
||||||
|
title={formatConversationTime(convo.updated_at)}
|
||||||
|
>
|
||||||
|
<div class="truncate font-semibold">{buildConversationTitle(convo)}</div>
|
||||||
|
<div class="truncate text-[10px] text-slate-500">{convo.last_message || ''}</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if loadingConversations}
|
||||||
|
<div class="rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs text-slate-500">...</div>
|
||||||
|
{/if}
|
||||||
|
{#if conversationsHasNext}
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-100"
|
||||||
|
on:click={() => loadConversations(false)}
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 space-y-3 overflow-y-auto p-4" bind:this={historyViewport} on:scroll={handleHistoryScroll}>
|
||||||
|
{#if loadingMoreHistory}
|
||||||
|
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center text-xs text-slate-500">Loading older messages...</div>
|
||||||
|
{/if}
|
||||||
{#if loadingHistory}
|
{#if loadingHistory}
|
||||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">{$t.assistant?.loading_history || 'Loading history...'}</div>
|
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">{$t.assistant?.loading_history || 'Loading history...'}</div>
|
||||||
{:else if messages.length === 0}
|
{:else if messages.length === 0}
|
||||||
|
|||||||
Reference in New Issue
Block a user