diff --git a/apps/control-plane-backend/alembic/env.py b/apps/control-plane-backend/alembic/env.py index 5bd706e672..7c5127234e 100644 --- a/apps/control-plane-backend/alembic/env.py +++ b/apps/control-plane-backend/alembic/env.py @@ -11,6 +11,7 @@ import control_plane_backend.models.agent_instance_models # noqa: F401 import control_plane_backend.models.prompt_models # noqa: F401 import control_plane_backend.models.purge_queue_models # noqa: F401 +import control_plane_backend.models.session_attachment_models # noqa: F401 import control_plane_backend.models.session_metadata_models # noqa: F401 from alembic import context from control_plane_backend.config.loader import load_configuration diff --git a/apps/control-plane-backend/alembic/versions/f2b3c4d5e6f7_add_session_attachments.py b/apps/control-plane-backend/alembic/versions/f2b3c4d5e6f7_add_session_attachments.py new file mode 100644 index 0000000000..855cddf42e --- /dev/null +++ b/apps/control-plane-backend/alembic/versions/f2b3c4d5e6f7_add_session_attachments.py @@ -0,0 +1,39 @@ +"""add session attachments + +Revision ID: f2b3c4d5e6f7 +Revises: be753abe25d7 +Create Date: 2026-06-11 12:30:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f2b3c4d5e6f7" # pragma: allowlist secret +down_revision = "b4c5d6e7f8a9" # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "session_attachments", + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("attachment_id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("mime", sa.String(), nullable=True), + sa.Column("size_bytes", sa.Integer(), nullable=True), + sa.Column("summary_md", sa.Text(), nullable=False), + sa.Column("document_uid", sa.String(), nullable=True), + sa.Column("storage_key", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("session_id", "attachment_id"), + ) + + +def downgrade() -> None: + op.drop_table("session_attachments") diff --git a/apps/control-plane-backend/config/configuration.yaml b/apps/control-plane-backend/config/configuration.yaml index ec63bcccd1..8a6c54c1fd 100644 --- a/apps/control-plane-backend/config/configuration.yaml +++ b/apps/control-plane-backend/config/configuration.yaml @@ -50,6 +50,7 @@ policies: platform: # Runtime pod references only. Managed agent instance enrollment is DB-backed. + knowledge_flow_base_url: http://127.0.0.1:8111/knowledge-flow/v1 runtime_catalog_sources: - runtime_id: fred-samples-agents base_url: http://127.0.0.1:8010/samples/agents/v1 diff --git a/apps/control-plane-backend/config/configuration_prod.yaml b/apps/control-plane-backend/config/configuration_prod.yaml index 93c6bf77ee..aeaa980235 100644 --- a/apps/control-plane-backend/config/configuration_prod.yaml +++ b/apps/control-plane-backend/config/configuration_prod.yaml @@ -55,6 +55,7 @@ policies: platform: # Runtime pod references only. Managed agent instance enrollment is DB-backed. + knowledge_flow_base_url: http://127.0.0.1:8111/knowledge-flow/v1 runtime_catalog_sources: - runtime_id: fred-samples-agents base_url: http://127.0.0.1:8010/samples/agents/v1 diff --git a/apps/control-plane-backend/config/schema/configuration.schema.json b/apps/control-plane-backend/config/schema/configuration.schema.json index 33490f9f7c..2be5917189 100644 --- a/apps/control-plane-backend/config/schema/configuration.schema.json +++ b/apps/control-plane-backend/config/schema/configuration.schema.json @@ -367,6 +367,12 @@ "frontend": { "$ref": "#/$defs/FrontendBootstrapConfig" }, + "knowledge_flow_base_url": { + "default": "http://127.0.0.1:8111/knowledge-flow/v1", + "description": "Server-side base URL used by control-plane when it must orchestrate Knowledge Flow attachment cleanup on behalf of the authenticated user.", + "title": "Knowledge Flow Base Url", + "type": "string" + }, "runtime_catalog_sources": { "items": { "$ref": "#/$defs/RuntimeCatalogSourceConfig" diff --git a/apps/control-plane-backend/control_plane_backend/app/context.py b/apps/control-plane-backend/control_plane_backend/app/context.py index 346c006af7..0ed304f475 100644 --- a/apps/control-plane-backend/control_plane_backend/app/context.py +++ b/apps/control-plane-backend/control_plane_backend/app/context.py @@ -36,6 +36,7 @@ ConversationPolicyCatalog, ) from control_plane_backend.scheduler.queue_store import PurgeQueueStore +from control_plane_backend.sessions.attachment_store import SessionAttachmentStore from control_plane_backend.sessions.store import SessionMetadataStore logger = logging.getLogger(__name__) @@ -55,6 +56,7 @@ def __init__(self, configuration: Configuration): self._rebac_engine: RebacEngine | None = None self._agent_instance_store: AgentInstanceStore | None = None self._session_metadata_store: SessionMetadataStore | None = None + self._session_attachment_store: SessionAttachmentStore | None = None self._prompt_store: PromptStore | None = None self._task_service: TaskService | None = None self._evaluation_store: EvaluationStore | None = None @@ -171,6 +173,13 @@ def get_session_metadata_store(self) -> SessionMetadataStore: ) return self._session_metadata_store + def get_session_attachment_store(self) -> SessionAttachmentStore: + if self._session_attachment_store is None: + self._session_attachment_store = SessionAttachmentStore( + engine=self.get_pg_async_engine() + ) + return self._session_attachment_store + def get_prompt_store(self) -> PromptStore: if self._prompt_store is None: self._prompt_store = PromptStore(engine=self.get_pg_async_engine()) diff --git a/apps/control-plane-backend/control_plane_backend/config/models.py b/apps/control-plane-backend/control_plane_backend/config/models.py index 706c1790a5..3145ca90dd 100644 --- a/apps/control-plane-backend/control_plane_backend/config/models.py +++ b/apps/control-plane-backend/control_plane_backend/config/models.py @@ -164,6 +164,13 @@ class PlatformConfig(BaseModel): """ frontend: FrontendBootstrapConfig = Field(default_factory=FrontendBootstrapConfig) + knowledge_flow_base_url: str = Field( + default="http://127.0.0.1:8111/knowledge-flow/v1", + description=( + "Server-side base URL used by control-plane when it must orchestrate " + "Knowledge Flow attachment cleanup on behalf of the authenticated user." + ), + ) runtime_catalog_sources: list[RuntimeCatalogSourceConfig] = Field( default_factory=list ) diff --git a/apps/control-plane-backend/control_plane_backend/migration/__init__.py b/apps/control-plane-backend/control_plane_backend/migration/__init__.py new file mode 100644 index 0000000000..70ee2784b7 --- /dev/null +++ b/apps/control-plane-backend/control_plane_backend/migration/__init__.py @@ -0,0 +1,4 @@ +"""Kea→Swift platform import (MIGR-05). + +See docs/swift/rfc/PLATFORM-IMPORT-RFC.md for the design. +""" diff --git a/apps/control-plane-backend/control_plane_backend/migration/agent_map.py b/apps/control-plane-backend/control_plane_backend/migration/agent_map.py new file mode 100644 index 0000000000..6c8d044b6f --- /dev/null +++ b/apps/control-plane-backend/control_plane_backend/migration/agent_map.py @@ -0,0 +1,108 @@ +"""Kea→Swift agent template mapping (MIGR-05). + +Maps a kea-exported agent to its swift template, so the import can create the +equivalent managed `agent_instance`. See docs/swift/rfc/PLATFORM-IMPORT-RFC.md §7. + +The mapping table is the single control point. Every exported agent is classified +into exactly one outcome: + +- ``MAPPED`` — the kea template has a swift equivalent; create the agent_instance. +- ``IGNORED`` — a known kea built-in sample/demo; not user data, skipped on purpose. +- ``GAP`` — no mapping yet (or no resolvable template); the equivalent must be + built in fred-agents and added here. A real cutover requires zero gaps. + +Scope is user-created agent instances; kea sample agents are intentionally not +migrated (swift provides its own catalog). +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum +from typing import Any + +logger = logging.getLogger(__name__) + + +# Kea template identity → swift template id (``{source_runtime_id}:{source_agent_id}``). +# Keys are a v2 ``definition_ref`` or a legacy v1 ``class_path``. Swift ids are +# validated at import time against the live fred-agents ``/agents/templates`` catalog. +KEA_TO_SWIFT_TEMPLATE: dict[str, str] = { + # Agents users actually create on kea: + "v2.react.basic": "fred-agents:fred.github.assistant", + "v2.production.sql_analyst": "fred-agents:fred.github.sql_expert", + # Equivalents available if a real user instance uses them: + "agentic_backend.agents.v1.production.prometheus.prometheus_expert.Spot": "fred-agents:fred.github.sentinel", + "agentic_backend.agents.v1.production.rags.rag_expert.Rico": "fred-agents:fred.github.rag_expert", + "agentic_backend.agents.v1.production.tabular.tabular_expert.Tessa": "fred-agents:fred.github.sql_expert", +} + +# Kea built-in sample/demo templates that are intentionally NOT migrated. Listed so +# preflight treats them as expected rather than as gaps requiring a new fred-agents +# template. +IGNORED_KEA_TEMPLATES: frozenset[str] = frozenset( + { + "v2.sample.bank_transfer", + "v2.deep.corpus_investigator", + "v2.production.dva_risk_validator.graph", + "v2.production.dva_risk_validator.qa", + } +) + + +class AgentMapOutcome(str, Enum): + """How an exported kea agent is treated by the import.""" + + MAPPED = "mapped" + IGNORED = "ignored" + GAP = "gap" + + +@dataclass(frozen=True) +class AgentMapResult: + """Result of classifying one exported kea agent. + + ``kea_template`` is the resolved template identity (``None`` when neither a + ``definition_ref`` nor a ``class_path`` is present). ``swift_template_id`` is set + only when ``outcome`` is ``MAPPED``. + """ + + outcome: AgentMapOutcome + kea_template: str | None + swift_template_id: str | None + + +def resolve_kea_template(payload: Mapping[str, Any]) -> str | None: + """Return the kea template identity from an exported agent ``payload_json``. + + v2 agents carry a top-level ``definition_ref``; legacy v1 agents carry a + top-level ``class_path``. ``definition_ref`` takes precedence. Returns ``None`` + when neither is present (the agent is then a GAP). + """ + definition_ref = payload.get("definition_ref") + if isinstance(definition_ref, str) and definition_ref.strip(): + return definition_ref + class_path = payload.get("class_path") + if isinstance(class_path, str) and class_path.strip(): + return class_path + return None + + +def classify_agent(payload: Mapping[str, Any]) -> AgentMapResult: + """Classify one exported kea agent ``payload_json`` into mapped / ignored / gap. + + The agent is MAPPED when its template is in ``KEA_TO_SWIFT_TEMPLATE``, IGNORED + when it is a known sample, and a GAP otherwise (including when no template can be + resolved). Each GAP is a fred-agents template to build before cutover. + """ + kea_template = resolve_kea_template(payload) + if kea_template is None: + return AgentMapResult(AgentMapOutcome.GAP, None, None) + swift_template_id = KEA_TO_SWIFT_TEMPLATE.get(kea_template) + if swift_template_id is not None: + return AgentMapResult(AgentMapOutcome.MAPPED, kea_template, swift_template_id) + if kea_template in IGNORED_KEA_TEMPLATES: + return AgentMapResult(AgentMapOutcome.IGNORED, kea_template, None) + return AgentMapResult(AgentMapOutcome.GAP, kea_template, None) diff --git a/apps/control-plane-backend/control_plane_backend/models/session_attachment_models.py b/apps/control-plane-backend/control_plane_backend/models/session_attachment_models.py new file mode 100644 index 0000000000..84597b4fd2 --- /dev/null +++ b/apps/control-plane-backend/control_plane_backend/models/session_attachment_models.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from control_plane_backend.models.base import Base, utcnow + + +class SessionAttachmentRow(Base): + """ORM model for the ``session_attachments`` table.""" + + __tablename__ = "session_attachments" + + session_id: Mapped[str] = mapped_column(String, primary_key=True) + attachment_id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False) + mime: Mapped[str | None] = mapped_column(String, nullable=True) + size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True) + summary_md: Mapped[str] = mapped_column(Text, nullable=False) + document_uid: Mapped[str | None] = mapped_column(String, nullable=True) + storage_key: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=utcnow, + onupdate=utcnow, + ) diff --git a/apps/control-plane-backend/control_plane_backend/product/api.py b/apps/control-plane-backend/control_plane_backend/product/api.py index 48a6396626..ddd0863bf9 100644 --- a/apps/control-plane-backend/control_plane_backend/product/api.py +++ b/apps/control-plane-backend/control_plane_backend/product/api.py @@ -2,7 +2,7 @@ from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, Path, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request from fastapi.responses import Response from fred_core import KeycloakUser, get_current_user, require_admin from fred_core.common import TeamId @@ -17,6 +17,7 @@ ContextPromptSummary, CreateAgentInstanceRequest, CreatePromptRequest, + CreateSessionAttachmentRequest, CreateSessionRequest, ExecutionPreparation, FrontendBootstrap, @@ -26,6 +27,7 @@ PromptPromoteRequest, PromptScoreUpdateRequest, PromptSummary, + SessionAttachmentSummary, SessionListItem, UpdateAgentInstanceRequest, UpdatePromptRequest, @@ -36,11 +38,14 @@ ExecutionPreparationError, PromptRequestError, SessionAlreadyExistsError, + SessionAttachmentRequestError, build_frontend_bootstrap, create_prompt, create_session, + create_session_attachment, delete_prompt, delete_session, + delete_session_attachment, enroll_agent_instance, get_prompt, get_runtime_binding, @@ -49,6 +54,7 @@ list_context_prompts, list_managed_agent_instances, list_prompts, + list_session_attachments, list_sessions, prepare_execution, promote_prompt, @@ -749,6 +755,105 @@ async def patch_team_session( return updated +@router.get( + "/teams/{team_id}/sessions/{session_id}/attachments", + response_model=list[SessionAttachmentSummary], + response_model_exclude_none=True, + summary="List persisted attachments for one team-scoped session.", +) +async def get_team_session_attachments( + team_id: Annotated[TeamId, Path()], + session_id: Annotated[str, Path(min_length=1)], + deps: ProductDependencies, + user: KeycloakUser = Depends(get_current_user), +) -> list[SessionAttachmentSummary]: + """ + Return the persisted conversation-level attachments for one session. + + Why this endpoint exists: + - the chat drawer needs reload-safe attachment state separate from the + transient composer chips used for the current turn + """ + + team = await get_team_by_id_from_service(user, team_id, deps.team_dependencies) + try: + return await list_session_attachments( + team_id=team.id, + session_id=session_id, + user_id=user.uid, + deps=deps, + ) + except SessionAttachmentRequestError as exc: + raise HTTPException(status_code=exc.http_status, detail=str(exc)) from exc + + +@router.post( + "/teams/{team_id}/sessions/{session_id}/attachments", + response_model=SessionAttachmentSummary, + response_model_exclude_none=True, + status_code=201, + summary="Persist one attachment summary for a team-scoped session.", +) +async def post_team_session_attachment( + team_id: Annotated[TeamId, Path()], + session_id: Annotated[str, Path(min_length=1)], + body: CreateSessionAttachmentRequest, + deps: ProductDependencies, + user: KeycloakUser = Depends(get_current_user), +) -> SessionAttachmentSummary: + """ + Persist one conversation attachment after successful upload and fast-ingest. + """ + + team = await get_team_by_id_from_service(user, team_id, deps.team_dependencies) + try: + return await create_session_attachment( + team_id=team.id, + session_id=session_id, + user_id=user.uid, + request=body, + deps=deps, + ) + except SessionAttachmentRequestError as exc: + raise HTTPException(status_code=exc.http_status, detail=str(exc)) from exc + + +@router.delete( + "/teams/{team_id}/sessions/{session_id}/attachments/{attachment_id}", + status_code=204, + response_class=Response, + summary="Delete one persisted session attachment and its Knowledge Flow artifacts.", +) +async def delete_team_session_attachment( + request: Request, + team_id: Annotated[TeamId, Path()], + session_id: Annotated[str, Path(min_length=1)], + attachment_id: Annotated[str, Path(min_length=1)], + deps: ProductDependencies, + user: KeycloakUser = Depends(get_current_user), +) -> Response: + """ + Delete one persisted attachment for future turns. + + Existing chat history is left untouched; the deletion only affects future + retrieval and future conversation context. + """ + + team = await get_team_by_id_from_service(user, team_id, deps.team_dependencies) + try: + await delete_session_attachment( + team_id=team.id, + session_id=session_id, + attachment_id=attachment_id, + user_id=user.uid, + authorization=request.headers.get("Authorization", ""), + deps=deps, + ) + except SessionAttachmentRequestError as exc: + raise HTTPException(status_code=exc.http_status, detail=str(exc)) from exc + return Response(status_code=204) + + @router.delete( "/teams/{team_id}/sessions/{session_id}", status_code=204, @@ -758,6 +863,7 @@ async def patch_team_session( async def delete_team_session( team_id: Annotated[TeamId, Path()], session_id: Annotated[str, Path(min_length=1)], + request: Request, deps: ProductDependencies, user: KeycloakUser = Depends(get_current_user), ) -> Response: @@ -768,12 +874,16 @@ async def delete_team_session( Does not touch runtime-owned message history. """ team = await get_team_by_id_from_service(user, team_id, deps.team_dependencies) - await delete_session( - team_id=team.id, - session_id=session_id, - user_id=user.uid, - deps=deps, - ) + try: + await delete_session( + team_id=team.id, + session_id=session_id, + user_id=user.uid, + authorization=request.headers.get("Authorization", ""), + deps=deps, + ) + except SessionAttachmentRequestError as exc: + raise HTTPException(status_code=exc.http_status, detail=str(exc)) from exc return Response(status_code=204) diff --git a/apps/control-plane-backend/control_plane_backend/product/dependencies.py b/apps/control-plane-backend/control_plane_backend/product/dependencies.py index a3ecab7b0e..4394fcbe6c 100644 --- a/apps/control-plane-backend/control_plane_backend/product/dependencies.py +++ b/apps/control-plane-backend/control_plane_backend/product/dependencies.py @@ -10,6 +10,7 @@ from control_plane_backend.app.dependencies import get_application_container from control_plane_backend.config.models import Configuration from control_plane_backend.prompts.store import PromptStore +from control_plane_backend.sessions.attachment_store import SessionAttachmentStore from control_plane_backend.sessions.store import SessionMetadataStore from control_plane_backend.teams.dependencies import ( TeamServiceDependencies, @@ -40,6 +41,7 @@ class ProductServiceDependencies: team_dependencies: TeamServiceDependencies get_agent_instance_store: Callable[[], AgentInstanceStore] get_session_metadata_store: Callable[[], SessionMetadataStore] + get_session_attachment_store: Callable[[], SessionAttachmentStore] get_prompt_store: Callable[[], PromptStore] @@ -66,6 +68,7 @@ def build_product_service_dependencies( team_dependencies=build_team_service_dependencies(container), get_agent_instance_store=container.get_agent_instance_store, get_session_metadata_store=container.get_session_metadata_store, + get_session_attachment_store=container.get_session_attachment_store, get_prompt_store=container.get_prompt_store, ) diff --git a/apps/control-plane-backend/control_plane_backend/product/schemas.py b/apps/control-plane-backend/control_plane_backend/product/schemas.py index 79cc66942c..5568b9161b 100644 --- a/apps/control-plane-backend/control_plane_backend/product/schemas.py +++ b/apps/control-plane-backend/control_plane_backend/product/schemas.py @@ -228,6 +228,32 @@ class SessionListItem(BaseModel): updated_at: datetime | None = None +class SessionAttachmentSummary(BaseModel): + """Persisted conversation-level attachment metadata owned by control-plane.""" + + attachment_id: str + name: str + mime: str | None = None + size_bytes: int | None = None + summary_md: str + document_uid: str | None = None + storage_key: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class CreateSessionAttachmentRequest(BaseModel): + """Payload used to persist one attachment after upload and fast-ingest.""" + + attachment_id: str + name: str + mime: str | None = None + size_bytes: int | None = None + summary_md: str + document_uid: str | None = None + storage_key: str | None = None + + class PromptSummary(BaseModel): """Small team-scoped prompt-library projection used for listings.""" diff --git a/apps/control-plane-backend/control_plane_backend/product/service.py b/apps/control-plane-backend/control_plane_backend/product/service.py index f3af21f8a7..ed2711072b 100644 --- a/apps/control-plane-backend/control_plane_backend/product/service.py +++ b/apps/control-plane-backend/control_plane_backend/product/service.py @@ -30,6 +30,7 @@ ContextPromptSummary, CreateAgentInstanceRequest, CreatePromptRequest, + CreateSessionAttachmentRequest, CreateSessionRequest, EffectiveChatOptions, ExecutionPreparation, @@ -41,6 +42,7 @@ PromptPromoteRequest, PromptScoreUpdateRequest, PromptSummary, + SessionAttachmentSummary, SessionListItem, UpdateAgentInstanceRequest, UpdatePromptRequest, @@ -50,6 +52,7 @@ PromptAlreadyExistsError, PromptRecord, ) +from control_plane_backend.sessions.attachment_store import SessionAttachmentRecord from control_plane_backend.sessions.store import ( SessionMetadataAlreadyExistsError, SessionMetadataRecord, @@ -68,6 +71,15 @@ spec.category for spec in DEFAULT_PROMPTS ) +_CHAT_OPTION_ATTACH_FILES_KEY = "chat_options.attach_files" +_CHAT_OPTION_LIBRARIES_BINDING_KEY = "chat_options.libraries_binding" +_CHAT_OPTION_BOUND_LIBRARY_IDS_KEY = "chat_options.bound_library_ids" +_CHAT_OPTION_LIBRARIES_SELECTION_KEY = "chat_options.libraries_selection" +_CHAT_OPTION_SEARCH_POLICY_ENABLED_KEY = "chat_options.search_policy_enabled" +_CHAT_OPTION_SEARCH_POLICY_KEY = "chat_options.search_policy" +_CHAT_OPTION_SEARCH_RAG_SCOPE_ENABLED_KEY = "chat_options.search_rag_scope_enabled" +_CHAT_OPTION_SEARCH_RAG_SCOPE_KEY = "chat_options.search_rag_scope" + class _RuntimeTemplatePayload: """Runtime `/agents/templates` payload consumed by control-plane aggregation.""" @@ -226,6 +238,72 @@ async def _fetch_runtime_templates(base_url: str) -> list[_RuntimeTemplatePayloa return [_RuntimeTemplatePayload.model_validate(item) for item in payload] +async def _refresh_tuning_contract_from_runtime( + tuning: ManagedAgentTuning, + *, + source_runtime_id: str, + source_agent_id: str, + deps: ProductServiceDependencies, +) -> ManagedAgentTuning: + """ + Refresh mutable tuning contracts from the current runtime template catalog. + + Why this function exists: + - MCP config fields and tunable chat options can evolve while a managed + agent instance already exists + - editing an agent should validate against the current template contract + instead of getting stuck on a stale snapshot + + How to use it: + - call before validating update payloads that touch tuning or MCP config + - on lookup failure, the function falls back to the stored snapshot so the + update path stays resilient when the runtime is temporarily unavailable + + Example: + - `base = await _refresh_tuning_contract_from_runtime(record.tuning, ...)` + """ + + source = next( + ( + item + for item in deps.configuration.platform.runtime_catalog_sources + if item.enabled and item.runtime_id == source_runtime_id + ), + None, + ) + if source is None: + return tuning + + try: + runtime_templates = await _fetch_runtime_templates(source.base_url) + except Exception as exc: + logger.warning( + "Failed to refresh runtime template contract for %s:%s: %s", + source_runtime_id, + source_agent_id, + exc, + ) + return tuning + + template = next( + ( + item + for item in runtime_templates + if item.template_agent_id == source_agent_id + ), + None, + ) + if template is None: + return tuning + + return tuning.model_copy( + update={ + "fields": template.default_tuning.fields, + "mcp_servers": template.default_tuning.mcp_servers, + } + ) + + async def _fetch_mcp_catalog(base_url: str) -> dict[str, bool] | None: """ Fetch the live MCP catalog from one runtime pod. @@ -572,9 +650,9 @@ def _resolve_effective_chat_options( - `options = _resolve_effective_chat_options(instance.tuning)` """ - _raw_bound_ids = tuning.values.get("chat_options.bound_library_ids") + _raw_bound_ids = tuning.values.get(_CHAT_OPTION_BOUND_LIBRARY_IDS_KEY) options = EffectiveChatOptions( - attach_files=_as_bool(tuning.values.get("chat_options.attach_files")), + attach_files=_as_bool(tuning.values.get(_CHAT_OPTION_ATTACH_FILES_KEY)), bound_library_ids=( [str(v) for v in _raw_bound_ids] if isinstance(_raw_bound_ids, list) @@ -589,11 +667,20 @@ def _resolve_effective_chat_options( for server in active_servers: field_defaults = {field.key: field.default for field in server.config_fields} server_values = tuning.mcp_config_values.get(server.id, {}) + binding_enabled = _as_bool( + server_values.get( + _CHAT_OPTION_LIBRARIES_BINDING_KEY, + field_defaults.get(_CHAT_OPTION_LIBRARIES_BINDING_KEY), + ) + ) - if "chat_options.libraries_selection" in field_defaults: + if ( + not binding_enabled + and _CHAT_OPTION_LIBRARIES_SELECTION_KEY in field_defaults + ): value = server_values.get( - "chat_options.libraries_selection", - field_defaults["chat_options.libraries_selection"], + _CHAT_OPTION_LIBRARIES_SELECTION_KEY, + field_defaults[_CHAT_OPTION_LIBRARIES_SELECTION_KEY], ) options.libraries_selection = options.libraries_selection or _as_bool(value) @@ -605,43 +692,54 @@ def _resolve_effective_chat_options( options.documents_selection = options.documents_selection or _as_bool(value) if ( - options.bound_library_ids is None - and "chat_options.bound_library_ids" in field_defaults + binding_enabled + and options.bound_library_ids is None + and _CHAT_OPTION_BOUND_LIBRARY_IDS_KEY in field_defaults ): value = server_values.get( - "chat_options.bound_library_ids", - field_defaults["chat_options.bound_library_ids"], + _CHAT_OPTION_BOUND_LIBRARY_IDS_KEY, + field_defaults[_CHAT_OPTION_BOUND_LIBRARY_IDS_KEY], ) if isinstance(value, list): options.bound_library_ids = [str(v) for v in value] if ( not options.search_policy_selection - and "chat_options.search_policy" in field_defaults + and _CHAT_OPTION_SEARCH_POLICY_ENABLED_KEY in field_defaults ): - value = server_values.get( - "chat_options.search_policy", - field_defaults["chat_options.search_policy"], + enabled = server_values.get( + _CHAT_OPTION_SEARCH_POLICY_ENABLED_KEY, + field_defaults[_CHAT_OPTION_SEARCH_POLICY_ENABLED_KEY], ) - if value in {"strict", "hybrid", "semantic"}: + if _as_bool(enabled): options.search_policy_selection = True - options.default_search_policy = cast( - Literal["strict", "hybrid", "semantic"], value + value = server_values.get( + _CHAT_OPTION_SEARCH_POLICY_KEY, + field_defaults.get(_CHAT_OPTION_SEARCH_POLICY_KEY), ) + if value in {"strict", "hybrid", "semantic"}: + options.default_search_policy = cast( + Literal["strict", "hybrid", "semantic"], value + ) if ( not options.rag_scope_selection - and "chat_options.search_rag_scope" in field_defaults + and _CHAT_OPTION_SEARCH_RAG_SCOPE_ENABLED_KEY in field_defaults ): - value = server_values.get( - "chat_options.search_rag_scope", - field_defaults["chat_options.search_rag_scope"], + enabled = server_values.get( + _CHAT_OPTION_SEARCH_RAG_SCOPE_ENABLED_KEY, + field_defaults[_CHAT_OPTION_SEARCH_RAG_SCOPE_ENABLED_KEY], ) - if value in {"corpus_only", "hybrid", "general_only"}: + if _as_bool(enabled): options.rag_scope_selection = True - options.default_search_rag_scope = cast( - Literal["corpus_only", "hybrid", "general_only"], value + value = server_values.get( + _CHAT_OPTION_SEARCH_RAG_SCOPE_KEY, + field_defaults.get(_CHAT_OPTION_SEARCH_RAG_SCOPE_KEY), ) + if value in {"corpus_only", "hybrid", "general_only"}: + options.default_search_rag_scope = cast( + Literal["corpus_only", "hybrid", "general_only"], value + ) return options @@ -828,6 +926,127 @@ def __init__(self, session_id: str) -> None: self.session_id = session_id +class SessionAttachmentRequestError(Exception): + """Raised when a session attachment CRUD operation cannot be completed.""" + + def __init__(self, message: str, *, http_status: int = 400) -> None: + super().__init__(message) + self.http_status = http_status + + +def _to_session_attachment_summary( + record: SessionAttachmentRecord, +) -> SessionAttachmentSummary: + return SessionAttachmentSummary( + attachment_id=record.attachment_id, + name=record.name, + mime=record.mime, + size_bytes=record.size_bytes, + summary_md=record.summary_md, + document_uid=record.document_uid, + storage_key=record.storage_key, + created_at=record.created_at, + updated_at=record.updated_at, + ) + + +async def _get_owned_session_record( + *, + deps: ProductServiceDependencies, + team_id: TeamId, + session_id: str, + user_id: str, +) -> SessionMetadataRecord: + """ + Resolve one session metadata row and enforce team + ownership checks. + + Why this function exists: + - persisted conversation attachments must stay scoped to the same + team/user session ownership model as the rest of the control-plane + - centralizing the check keeps the attachment CRUD handlers aligned + + How to use it: + - call before listing, creating, or deleting session attachments + - the helper raises `SessionAttachmentRequestError` on not-found or + ownership mismatches + """ + + record = await deps.get_session_metadata_store().get(session_id) + if record is None or record.team_id != team_id: + raise SessionAttachmentRequestError( + f"Session {session_id!r} not found for team {team_id!r}.", + http_status=404, + ) + if record.user_id is not None and record.user_id != user_id: + raise SessionAttachmentRequestError( + f"Session {session_id!r} is not owned by user {user_id!r}.", + http_status=404, + ) + return record + + +async def _delete_knowledge_flow_attachment( + *, + deps: ProductServiceDependencies, + authorization: str, + document_uid: str | None, + storage_key: str | None, + session_id: str, +) -> None: + """ + Orchestrate the Knowledge Flow cleanup path for one persisted attachment. + + Why this function exists: + - control-plane owns session attachment metadata, but Knowledge Flow owns + the vectors, metadata artifacts, and uploaded file bytes + - deleting one attachment must clean both systems in one operation + + How to use it: + - pass the caller's bearer token through `authorization` + - the helper returns silently when there is nothing to clean up + """ + + if document_uid is None and storage_key is None: + return + + if not authorization.strip(): + raise SessionAttachmentRequestError( + "Missing Authorization header for attachment cleanup.", + http_status=401, + ) + + if document_uid is None: + return + + url = ( + f"{deps.configuration.platform.knowledge_flow_base_url.rstrip('/')}" + f"/fast/delete/{document_uid}" + ) + params = {"session_id": session_id} + if storage_key is not None: + params["storage_key"] = storage_key + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.delete( + url, + params=params, + headers={"Authorization": authorization}, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = exc.response.text.strip() or str(exc) + raise SessionAttachmentRequestError( + f"Knowledge Flow cleanup failed for attachment document {document_uid!r}: {detail}", + http_status=502, + ) from exc + except httpx.RequestError as exc: + raise SessionAttachmentRequestError( + f"Knowledge Flow cleanup request failed for attachment document {document_uid!r}: {exc}", + http_status=502, + ) from exc + + async def enroll_agent_instance( *, user: KeycloakUser, @@ -967,10 +1186,11 @@ async def update_agent_instance( selection; `mcp_server_ids=[]` activates no MCP servers - `mcp_config_values=None` clears stored per-server MCP config - Policy — frozen snapshot: - - field specs (ManagedAgentFieldSpec) are frozen at enrollment time - - they are never re-merged with the current template when the instance is edited - - only known keys (present in instance.tuning.fields) are accepted + Policy — current template contract: + - update validation uses the latest field specs and MCP config_fields + exposed by the source runtime template + - stored values and selected MCP servers are preserved, but the editable + contract follows the current template catalog Example: - `result = await update_agent_instance(team_id=team_id, agent_instance_id=id, request=req, deps=deps)` @@ -987,7 +1207,12 @@ async def update_agent_instance( "mcp_server_ids", "mcp_config_values", } & tuning_fields_set: - base = record.tuning + base = await _refresh_tuning_contract_from_runtime( + record.tuning, + source_runtime_id=record.source_runtime_id, + source_agent_id=record.source_agent_id, + deps=deps, + ) if "mcp_server_ids" in tuning_fields_set: if request.mcp_server_ids is None: base = base.model_copy(update={"selected_mcp_server_ids": None}) @@ -1814,17 +2039,167 @@ async def get_session( return _record_to_item(record) +async def list_session_attachments( + *, + team_id: TeamId, + session_id: str, + user_id: str, + deps: ProductServiceDependencies, +) -> list[SessionAttachmentSummary]: + """ + List persisted conversation attachments for one owned session. + + Why this function exists: + - the chat drawer needs a reload-safe source of truth for session-level + attachments after transient composer chips have cleared + + How to use it: + - call after team authorization has already succeeded + - the function enforces that the session belongs to the given user + """ + + await _get_owned_session_record( + deps=deps, + team_id=team_id, + session_id=session_id, + user_id=user_id, + ) + records = await deps.get_session_attachment_store().list_for_session(session_id) + return [_to_session_attachment_summary(record) for record in records] + + +async def create_session_attachment( + *, + team_id: TeamId, + session_id: str, + user_id: str, + request: CreateSessionAttachmentRequest, + deps: ProductServiceDependencies, +) -> SessionAttachmentSummary: + """ + Persist one attachment summary for a conversation after fast-ingest. + + Why this function exists: + - upload/ingest is handled by Knowledge Flow, but the chat product surface + needs durable session-scoped metadata for reload, preview, and deletion + + How to use it: + - call once the frontend has both the storage upload result and the + fast-ingest response + - repeated calls with the same `attachment_id` update the stored row + """ + + await _get_owned_session_record( + deps=deps, + team_id=team_id, + session_id=session_id, + user_id=user_id, + ) + store = deps.get_session_attachment_store() + await store.save( + SessionAttachmentRecord( + session_id=session_id, + attachment_id=request.attachment_id, + name=request.name, + mime=request.mime, + size_bytes=request.size_bytes, + summary_md=request.summary_md, + document_uid=request.document_uid, + storage_key=request.storage_key, + ) + ) + saved = await store.list_for_session(session_id) + created = next( + (item for item in saved if item.attachment_id == request.attachment_id), None + ) + if created is None: + raise SessionAttachmentRequestError( + f"Failed to persist attachment {request.attachment_id!r} for session {session_id!r}.", + http_status=500, + ) + return _to_session_attachment_summary(created) + + +async def delete_session_attachment( + *, + team_id: TeamId, + session_id: str, + attachment_id: str, + user_id: str, + authorization: str, + deps: ProductServiceDependencies, +) -> bool: + """ + Delete one persisted attachment and its Knowledge Flow artifacts. + + Why this function exists: + - the drawer delete action must remove both control-plane metadata and the + underlying vectors/content owned by Knowledge Flow + + How to use it: + - call after team authorization has already succeeded + - pass the caller's `Authorization` header so cleanup can run with the + same user identity in Knowledge Flow + """ + + await _get_owned_session_record( + deps=deps, + team_id=team_id, + session_id=session_id, + user_id=user_id, + ) + store = deps.get_session_attachment_store() + records = await store.list_for_session(session_id) + record = next( + (item for item in records if item.attachment_id == attachment_id), None + ) + if record is None: + return False + + await _delete_knowledge_flow_attachment( + deps=deps, + authorization=authorization, + document_uid=record.document_uid, + storage_key=record.storage_key, + session_id=session_id, + ) + await store.delete(session_id, attachment_id) + return True + + async def delete_session( team_id: TeamId, session_id: str, user_id: str, + authorization: str, deps: ProductServiceDependencies, ) -> bool: """ - Remove one control-plane session metadata record. + Remove one control-plane session metadata record and cleanup attachments. Returns True when a row was deleted, False when the session did not exist. """ + session = await _get_owned_session_record( + deps=deps, + team_id=team_id, + session_id=session_id, + user_id=user_id, + ) + if session is None: + return False + + attachment_store = deps.get_session_attachment_store() + attachments = await attachment_store.list_for_session(session_id) + for attachment in attachments: + await _delete_knowledge_flow_attachment( + deps=deps, + authorization=authorization, + document_uid=attachment.document_uid, + storage_key=attachment.storage_key, + session_id=session_id, + ) + await attachment_store.delete_for_session(session_id) + return await deps.get_session_metadata_store().delete( session_id=session_id, team_id=team_id, diff --git a/apps/control-plane-backend/control_plane_backend/sessions/attachment_store.py b/apps/control-plane-backend/control_plane_backend/sessions/attachment_store.py new file mode 100644 index 0000000000..4f135f200b --- /dev/null +++ b/apps/control-plane-backend/control_plane_backend/sessions/attachment_store.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime, timezone + +from fred_core.sql import make_session_factory, use_session +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from control_plane_backend.models.session_attachment_models import SessionAttachmentRow + + +@dataclass +class SessionAttachmentRecord: + """In-memory projection of one persisted session attachment summary.""" + + session_id: str + attachment_id: str + name: str + summary_md: str + document_uid: str | None = None + storage_key: str | None = None + mime: str | None = None + size_bytes: int | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class BaseSessionAttachmentStore(ABC): + """Persistence contract for session-scoped attachment summaries.""" + + @abstractmethod + async def save( + self, record: SessionAttachmentRecord, session: AsyncSession | None = None + ) -> None: # pragma: no cover - interface + pass + + @abstractmethod + async def list_for_session( + self, session_id: str, session: AsyncSession | None = None + ) -> list[SessionAttachmentRecord]: # pragma: no cover - interface + pass + + @abstractmethod + async def delete( + self, session_id: str, attachment_id: str, session: AsyncSession | None = None + ) -> None: # pragma: no cover - interface + pass + + @abstractmethod + async def delete_for_session( + self, session_id: str, session: AsyncSession | None = None + ) -> None: # pragma: no cover - interface + pass + + @abstractmethod + async def count_for_sessions( + self, session_ids: list[str], session: AsyncSession | None = None + ) -> int: # pragma: no cover - interface + pass + + +class SessionAttachmentStore(BaseSessionAttachmentStore): + """ + PostgreSQL-backed storage for session attachments. + + Why this class exists: + - Swift needs the same persisted attachment summary behavior as `main` + so chat attachments survive reloads and can be managed later + - the only Swift-specific schema extension is `storage_key`, used for + storage cleanup orchestration + + How to use it: + - call `save()` after a successful upload + fast-ingest cycle + - call `list_for_session()` to hydrate the drawer/source of truth + - call `delete()` / `delete_for_session()` during explicit cleanup flows + """ + + def __init__(self, engine: AsyncEngine) -> None: + self._sessions = make_session_factory(engine) + + async def save( + self, record: SessionAttachmentRecord, session: AsyncSession | None = None + ) -> None: + now = datetime.now(timezone.utc) + row = SessionAttachmentRow( + session_id=record.session_id, + attachment_id=record.attachment_id, + name=record.name, + mime=record.mime, + size_bytes=record.size_bytes, + summary_md=record.summary_md, + document_uid=record.document_uid, + storage_key=record.storage_key, + created_at=record.created_at or now, + updated_at=record.updated_at or now, + ) + async with use_session(self._sessions, session) as s: + await s.merge(row) + + async def list_for_session( + self, session_id: str, session: AsyncSession | None = None + ) -> list[SessionAttachmentRecord]: + async with use_session(self._sessions, session) as s: + rows = ( + ( + await s.execute( + select(SessionAttachmentRow) + .where(SessionAttachmentRow.session_id == session_id) + .order_by(SessionAttachmentRow.created_at.asc()) + ) + ) + .scalars() + .all() + ) + return [ + SessionAttachmentRecord( + session_id=row.session_id, + attachment_id=row.attachment_id, + name=row.name, + mime=row.mime, + size_bytes=row.size_bytes, + summary_md=row.summary_md, + document_uid=row.document_uid, + storage_key=row.storage_key, + created_at=row.created_at, + updated_at=row.updated_at, + ) + for row in rows + ] + + async def delete( + self, session_id: str, attachment_id: str, session: AsyncSession | None = None + ) -> None: + async with use_session(self._sessions, session) as s: + await s.execute( + delete(SessionAttachmentRow).where( + SessionAttachmentRow.session_id == session_id, + SessionAttachmentRow.attachment_id == attachment_id, + ) + ) + + async def delete_for_session( + self, session_id: str, session: AsyncSession | None = None + ) -> None: + async with use_session(self._sessions, session) as s: + await s.execute( + delete(SessionAttachmentRow).where( + SessionAttachmentRow.session_id == session_id + ) + ) + + async def count_for_sessions( + self, session_ids: list[str], session: AsyncSession | None = None + ) -> int: + if not session_ids: + return 0 + async with use_session(self._sessions, session) as s: + result = await s.execute( + select(func.count()) + .select_from(SessionAttachmentRow) + .where(SessionAttachmentRow.session_id.in_(session_ids)) + ) + return result.scalar() or 0 diff --git a/apps/control-plane-backend/tests/test_agent_map.py b/apps/control-plane-backend/tests/test_agent_map.py new file mode 100644 index 0000000000..814fcaf6c9 --- /dev/null +++ b/apps/control-plane-backend/tests/test_agent_map.py @@ -0,0 +1,114 @@ +"""Tests for control_plane_backend.migration.agent_map.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from control_plane_backend.migration.agent_map import ( + IGNORED_KEA_TEMPLATES, + KEA_TO_SWIFT_TEMPLATE, + AgentMapOutcome, + classify_agent, + resolve_kea_template, +) + + +def _payload(**overrides: Any) -> dict[str, Any]: + """Build a minimal exported-agent payload_json for tests.""" + base: dict[str, Any] = {"id": "agent-1", "type": "agent", "enabled": True} + base.update(overrides) + return base + + +# --- resolve_kea_template ---------------------------------------------------- + + +def test_resolve_prefers_definition_ref_over_class_path() -> None: + payload = _payload(definition_ref="v2.react.basic", class_path="x.y.Z") + assert resolve_kea_template(payload) == "v2.react.basic" + + +def test_resolve_falls_back_to_class_path() -> None: + payload = _payload( + class_path="agentic_backend.agents.v1.production.prometheus.prometheus_expert.Spot" + ) + assert ( + resolve_kea_template(payload) + == "agentic_backend.agents.v1.production.prometheus.prometheus_expert.Spot" + ) + + +@pytest.mark.parametrize( + "payload", + [ + _payload(), + _payload(definition_ref="", class_path=""), + _payload(definition_ref=" "), + _payload(definition_ref=None, class_path=None), + ], +) +def test_resolve_returns_none_when_no_template(payload: dict[str, Any]) -> None: + assert resolve_kea_template(payload) is None + + +# --- classify_agent ---------------------------------------------------------- + + +def test_classify_maps_react_basic_to_assistant() -> None: + result = classify_agent(_payload(definition_ref="v2.react.basic")) + assert result.outcome is AgentMapOutcome.MAPPED + assert result.kea_template == "v2.react.basic" + assert result.swift_template_id == "fred-agents:fred.github.assistant" + + +def test_classify_maps_sql_analyst_to_sql_expert() -> None: + result = classify_agent(_payload(definition_ref="v2.production.sql_analyst")) + assert result.outcome is AgentMapOutcome.MAPPED + assert result.swift_template_id == "fred-agents:fred.github.sql_expert" + + +def test_classify_maps_legacy_class_path() -> None: + result = classify_agent( + _payload( + class_path="agentic_backend.agents.v1.production.prometheus.prometheus_expert.Spot" + ) + ) + assert result.outcome is AgentMapOutcome.MAPPED + assert result.swift_template_id == "fred-agents:fred.github.sentinel" + + +def test_classify_ignores_known_sample() -> None: + result = classify_agent(_payload(definition_ref="v2.sample.bank_transfer")) + assert result.outcome is AgentMapOutcome.IGNORED + assert result.kea_template == "v2.sample.bank_transfer" + assert result.swift_template_id is None + + +def test_classify_reports_unknown_template_as_gap() -> None: + result = classify_agent(_payload(definition_ref="v2.production.unknown_future")) + assert result.outcome is AgentMapOutcome.GAP + assert result.kea_template == "v2.production.unknown_future" + assert result.swift_template_id is None + + +def test_classify_reports_missing_template_as_gap() -> None: + result = classify_agent(_payload()) + assert result.outcome is AgentMapOutcome.GAP + assert result.kea_template is None + assert result.swift_template_id is None + + +# --- table invariants -------------------------------------------------------- + + +def test_mapped_and_ignored_sets_are_disjoint() -> None: + assert not (set(KEA_TO_SWIFT_TEMPLATE) & IGNORED_KEA_TEMPLATES) + + +def test_all_swift_ids_are_runtime_qualified() -> None: + # template_id must be "{source_runtime_id}:{source_agent_id}" (single colon). + for swift_id in KEA_TO_SWIFT_TEMPLATE.values(): + runtime_id, _, agent_id = swift_id.partition(":") + assert runtime_id and agent_id and swift_id.count(":") == 1 diff --git a/apps/control-plane-backend/tests/test_main.py b/apps/control-plane-backend/tests/test_main.py index 2b7ec67531..5d1b81c797 100644 --- a/apps/control-plane-backend/tests/test_main.py +++ b/apps/control-plane-backend/tests/test_main.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import Any, Optional, cast +import httpx import pytest from fred_core import RelationType, SessionSchema, TeamPermission from fred_core.common import TeamId, personal_team_id @@ -35,8 +36,15 @@ RuntimeCatalogSourceConfig, ) from control_plane_backend.main import create_app -from control_plane_backend.product.service import _RuntimeTemplatePayload +from control_plane_backend.product.dependencies import ( + build_product_service_dependencies, +) +from control_plane_backend.product.service import ( + _delete_knowledge_flow_attachment, + _RuntimeTemplatePayload, +) from control_plane_backend.prompts.store import PromptRecord +from control_plane_backend.sessions.attachment_store import SessionAttachmentRecord from control_plane_backend.sessions.store import SessionMetadataRecord from control_plane_backend.teams.schemas import ( KeycloakGroupSummary, @@ -268,6 +276,55 @@ def _patch_session_store( ) +class _FakeSessionAttachmentStore: + """In-memory stand-in for SessionAttachmentStore used in offline tests.""" + + def __init__(self, records: list[SessionAttachmentRecord] | None = None) -> None: + self._records: list[SessionAttachmentRecord] = list(records or []) + + async def save(self, record: SessionAttachmentRecord) -> None: + self._records = [ + existing + for existing in self._records + if not ( + existing.session_id == record.session_id + and existing.attachment_id == record.attachment_id + ) + ] + self._records.append(record) + + async def list_for_session(self, session_id: str) -> list[SessionAttachmentRecord]: + return [record for record in self._records if record.session_id == session_id] + + async def delete(self, session_id: str, attachment_id: str) -> None: + self._records = [ + record + for record in self._records + if not ( + record.session_id == session_id + and record.attachment_id == attachment_id + ) + ] + + async def delete_for_session(self, session_id: str) -> None: + self._records = [ + record for record in self._records if record.session_id != session_id + ] + + async def count_for_sessions(self, session_ids: list[str]) -> int: + return len([r for r in self._records if r.session_id in session_ids]) + + +def _patch_session_attachment_store( + monkeypatch: pytest.MonkeyPatch, + store: _FakeSessionAttachmentStore, +) -> None: + monkeypatch.setattr( + "control_plane_backend.app.context.ApplicationContext.get_session_attachment_store", + lambda _self: store, + ) + + class _FakePromptStore: """In-memory stand-in for PromptStore used in offline tests.""" @@ -1349,7 +1406,7 @@ async def test_post_team_session_returns_conflict_for_duplicate_session( @pytest.mark.asyncio -async def test_delete_team_session_does_not_delete_other_user_session( +async def test_delete_team_session_returns_404_for_other_user_session( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( @@ -1377,9 +1434,11 @@ async def test_delete_team_session_does_not_delete_other_user_session( "/control-plane/v1/teams/personal/sessions/session-1" ) - assert resp.status_code == 204 + assert resp.status_code == 404 assert len(store._records) == 1 assert store._records[0].session_id == "session-1" + assert store._records[0].user_id == "alice" + assert store._records[0].title == "Owned by Alice" @pytest.mark.asyncio @@ -1415,6 +1474,255 @@ async def test_delete_team_session_deletes_owned_session( assert store._records == [] +@pytest.mark.asyncio +async def test_delete_team_session_cleans_up_all_session_attachments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "control_plane_backend.product.api.get_team_by_id_from_service", + _fake_get_team_by_id, + ) + session_store = _FakeSessionMetadataStore( + [ + SessionMetadataRecord( + session_id="session-1", + team_id=TeamId("personal"), + agent_instance_id="instance-1", + user_id="admin", + title="Owned by admin", + ) + ] + ) + attachment_store = _FakeSessionAttachmentStore( + [ + SessionAttachmentRecord( + session_id="session-1", + attachment_id="attachment-1", + name="notes.md", + summary_md="# Notes", + document_uid="doc-1", + storage_key="uploads/notes.md", + ), + SessionAttachmentRecord( + session_id="session-1", + attachment_id="attachment-2", + name="diagram.png", + summary_md="![diagram](diagram.png)", + document_uid="doc-2", + storage_key="uploads/diagram.png", + ), + ] + ) + cleanup_calls: list[dict[str, str | None]] = [] + + async def _fake_cleanup(**kwargs: Any) -> None: + cleanup_calls.append( + { + "document_uid": kwargs["document_uid"], + "storage_key": kwargs["storage_key"], + "session_id": kwargs["session_id"], + } + ) + + monkeypatch.setattr( + "control_plane_backend.product.service._delete_knowledge_flow_attachment", + _fake_cleanup, + ) + _patch_session_store(monkeypatch, session_store) + _patch_session_attachment_store(monkeypatch, attachment_store) + + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.delete( + "/control-plane/v1/teams/personal/sessions/session-1", + headers={"Authorization": "Bearer test-token"}, + ) + + assert resp.status_code == 204 + assert session_store._records == [] + assert attachment_store._records == [] + assert cleanup_calls == [ + { + "document_uid": "doc-1", + "storage_key": "uploads/notes.md", + "session_id": "session-1", + }, + { + "document_uid": "doc-2", + "storage_key": "uploads/diagram.png", + "session_id": "session-1", + }, + ] + + +@pytest.mark.asyncio +async def test_session_attachment_endpoints_round_trip_for_owned_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "control_plane_backend.product.api.get_team_by_id_from_service", + _fake_get_team_by_id, + ) + session_store = _FakeSessionMetadataStore( + [ + SessionMetadataRecord( + session_id="session-1", + team_id=TeamId("personal"), + agent_instance_id="instance-1", + user_id="admin", + title="Owned by admin", + ) + ] + ) + attachment_store = _FakeSessionAttachmentStore() + _patch_session_store(monkeypatch, session_store) + _patch_session_attachment_store(monkeypatch, attachment_store) + + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + create_resp = await client.post( + "/control-plane/v1/teams/personal/sessions/session-1/attachments", + json={ + "attachment_id": "attachment-1", + "name": "notes.md", + "mime": "text/markdown", + "size_bytes": 321, + "summary_md": "# Notes", + "document_uid": "doc-1", + "storage_key": "uploads/notes.md", + }, + ) + list_resp = await client.get( + "/control-plane/v1/teams/personal/sessions/session-1/attachments" + ) + + assert create_resp.status_code == 201 + assert create_resp.json()["attachment_id"] == "attachment-1" + assert list_resp.status_code == 200 + assert [item["name"] for item in list_resp.json()] == ["notes.md"] + + +@pytest.mark.asyncio +async def test_delete_session_attachment_calls_cleanup_and_removes_row( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "control_plane_backend.product.api.get_team_by_id_from_service", + _fake_get_team_by_id, + ) + session_store = _FakeSessionMetadataStore( + [ + SessionMetadataRecord( + session_id="session-1", + team_id=TeamId("personal"), + agent_instance_id="instance-1", + user_id="admin", + title="Owned by admin", + ) + ] + ) + attachment_store = _FakeSessionAttachmentStore( + [ + SessionAttachmentRecord( + session_id="session-1", + attachment_id="attachment-1", + name="notes.md", + summary_md="# Notes", + document_uid="doc-1", + storage_key="uploads/notes.md", + ) + ] + ) + cleanup_calls: list[dict[str, str | None]] = [] + + async def _fake_cleanup(**kwargs: Any) -> None: + cleanup_calls.append( + { + "document_uid": kwargs["document_uid"], + "storage_key": kwargs["storage_key"], + "session_id": kwargs["session_id"], + } + ) + + monkeypatch.setattr( + "control_plane_backend.product.service._delete_knowledge_flow_attachment", + _fake_cleanup, + ) + _patch_session_store(monkeypatch, session_store) + _patch_session_attachment_store(monkeypatch, attachment_store) + + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.delete( + "/control-plane/v1/teams/personal/sessions/session-1/attachments/attachment-1", + headers={"Authorization": "Bearer test-token"}, + ) + + assert resp.status_code == 204 + assert attachment_store._records == [] + assert cleanup_calls == [ + { + "document_uid": "doc-1", + "storage_key": "uploads/notes.md", + "session_id": "session-1", + } + ] + + +@pytest.mark.asyncio +async def test_delete_knowledge_flow_attachment_uses_fast_delete_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + class _FakeAsyncClient: + def __init__(self, *args: Any, **kwargs: Any) -> None: + captured["timeout"] = kwargs.get("timeout") + + async def __aenter__(self) -> "_FakeAsyncClient": + return self + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + return None + + async def delete( + self, url: str, *, params: dict[str, str], headers: dict[str, str] + ) -> httpx.Response: + captured["url"] = url + captured["params"] = params + captured["headers"] = headers + request = httpx.Request("DELETE", url, params=params, headers=headers) + return httpx.Response(200, request=request) + + monkeypatch.setattr( + "control_plane_backend.product.service.httpx.AsyncClient", _FakeAsyncClient + ) + app = create_app() + container = get_application_container_from_app(app) + deps = build_product_service_dependencies(container) + + await _delete_knowledge_flow_attachment( + authorization="Bearer test-token", + document_uid="doc-1", + storage_key="uploads/notes.md", + session_id="session-1", + deps=deps, + ) + + assert captured["url"].endswith("/fast/delete/doc-1") + assert captured["params"] == { + "session_id": "session-1", + "storage_key": "uploads/notes.md", + } + assert captured["headers"] == {"Authorization": "Bearer test-token"} + + @pytest.mark.asyncio async def test_enroll_agent_instance_returns_404_for_unknown_runtime( monkeypatch: pytest.MonkeyPatch, @@ -2710,6 +3018,93 @@ async def test_patch_agent_instance_updates_tuning_field_values( assert store._records[0].tuning.values == {"persona": "new persona value"} +@pytest.mark.asyncio +async def test_patch_agent_instance_refreshes_runtime_mcp_contract_before_validating_update( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "control_plane_backend.product.api.get_team_by_id_from_service", + _fake_get_team_by_id, + ) + record = AgentInstanceRecord( + agent_instance_id="instance-mcp-refresh", + team_id=TeamId("personal"), + template_id="runtime-a:rags.sample.mcp", + source_runtime_id="runtime-a", + source_agent_id="rags.sample.mcp", + display_name="MCP", + description=None, + enabled=True, + created_by="admin", + tuning=ManagedAgentTuning( + role="MCP", + description="MCP", + mcp_servers=[ + ManagedMcpServerRef( + id="mcp-search", + display_name="Search", + config_fields=[ + ManagedAgentFieldSpec( + key="chat_options.libraries_selection", + type="boolean", + title="Libraries", + default=False, + ) + ], + ) + ], + selected_mcp_server_ids=["mcp-search"], + mcp_config_values={ + "mcp-search": {"chat_options.libraries_selection": True} + }, + ), + ) + store = _FakeAgentInstanceStore([record]) + app = create_app() + _patch_store(monkeypatch, store) + container = get_application_container_from_app(app) + container.configuration.platform.runtime_catalog_sources = [ + RuntimeCatalogSourceConfig( + runtime_id="runtime-a", + base_url="http://runtime-a/pod/v1", + enabled=True, + ) + ] + + async def _fake_fetch(_base_url: str): + return [_make_template_with_mcp_servers()] + + monkeypatch.setattr( + "control_plane_backend.product.service._fetch_runtime_templates", + _fake_fetch, + ) + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.patch( + "/control-plane/v1/teams/personal/agent-instances/instance-mcp-refresh", + json={ + "mcp_config_values": { + "mcp-search": { + "chat_options.libraries_binding": True, + "chat_options.bound_library_ids": ["lib-a", "lib-b"], + "chat_options.libraries_selection": False, + } + } + }, + ) + + assert resp.status_code == 200 + assert resp.json()["mcp_config_values"] == { + "mcp-search": { + "chat_options.libraries_binding": True, + "chat_options.bound_library_ids": ["lib-a", "lib-b"], + "chat_options.libraries_selection": False, + } + } + + @pytest.mark.asyncio async def test_patch_agent_instance_returns_404_for_unknown_instance( monkeypatch: pytest.MonkeyPatch, @@ -2757,6 +3152,12 @@ def _make_template_with_mcp_servers( id="mcp-search", display_name="Search", config_fields=[ + ManagedAgentFieldSpec( + key="chat_options.libraries_binding", + type="boolean", + title="Libraries binding", + default=False, + ), ManagedAgentFieldSpec( key="chat_options.libraries_selection", type="boolean", @@ -2769,6 +3170,19 @@ def _make_template_with_mcp_servers( title="Documents", default=documents_selection_default, ), + ManagedAgentFieldSpec( + key="chat_options.bound_library_ids", + type="array", + title="Bound libraries", + item_type="string", + default=[], + ), + ManagedAgentFieldSpec( + key="chat_options.search_policy_enabled", + type="boolean", + title="Search policy picker", + default=documents_selection_default, + ), ManagedAgentFieldSpec( key="chat_options.search_policy", type="string", @@ -2776,6 +3190,12 @@ def _make_template_with_mcp_servers( enum=["strict", "hybrid", "semantic"], default="hybrid", ), + ManagedAgentFieldSpec( + key="chat_options.search_rag_scope_enabled", + type="boolean", + title="RAG scope picker", + default=documents_selection_default, + ), ManagedAgentFieldSpec( key="chat_options.search_rag_scope", type="string", @@ -2874,7 +3294,10 @@ async def _fake_fetch(_base_url: str): "mcp_server_ids": ["mcp-search"], "mcp_config_values": { "mcp-search": { - "chat_options.libraries_selection": True, + "chat_options.libraries_binding": True, + "chat_options.bound_library_ids": ["lib-a", "lib-b"], + "chat_options.libraries_selection": False, + "chat_options.search_policy_enabled": True, "chat_options.search_policy": "semantic", } }, @@ -2884,13 +3307,19 @@ async def _fake_fetch(_base_url: str): assert resp.status_code == 201 assert resp.json()["mcp_config_values"] == { "mcp-search": { - "chat_options.libraries_selection": True, + "chat_options.libraries_binding": True, + "chat_options.bound_library_ids": ["lib-a", "lib-b"], + "chat_options.libraries_selection": False, + "chat_options.search_policy_enabled": True, "chat_options.search_policy": "semantic", } } assert store._records[0].tuning.mcp_config_values == { "mcp-search": { - "chat_options.libraries_selection": True, + "chat_options.libraries_binding": True, + "chat_options.bound_library_ids": ["lib-a", "lib-b"], + "chat_options.libraries_selection": False, + "chat_options.search_policy_enabled": True, "chat_options.search_policy": "semantic", } } @@ -3150,9 +3579,13 @@ async def test_prepare_execution_resolves_effective_chat_options_from_tuning( selected_mcp_server_ids=["mcp-search"], mcp_config_values={ "mcp-search": { - "chat_options.libraries_selection": True, + "chat_options.libraries_binding": True, + "chat_options.bound_library_ids": ["lib-a", "lib-b"], + "chat_options.libraries_selection": False, "chat_options.documents_selection": True, + "chat_options.search_policy_enabled": True, "chat_options.search_policy": "semantic", + "chat_options.search_rag_scope_enabled": True, "chat_options.search_rag_scope": "corpus_only", } }, @@ -3181,7 +3614,8 @@ async def test_prepare_execution_resolves_effective_chat_options_from_tuning( assert resp.status_code == 200 assert resp.json()["effective_chat_options"] == { "attach_files": True, - "libraries_selection": True, + "bound_library_ids": ["lib-a", "lib-b"], + "libraries_selection": False, "documents_selection": True, "search_policy_selection": True, "default_search_policy": "semantic", diff --git a/apps/control-plane-backend/tests/test_metadata_stores.py b/apps/control-plane-backend/tests/test_metadata_stores.py index 29772c04be..eba1cf15f8 100644 --- a/apps/control-plane-backend/tests/test_metadata_stores.py +++ b/apps/control-plane-backend/tests/test_metadata_stores.py @@ -18,6 +18,10 @@ PromptRecord, PromptStore, ) +from control_plane_backend.sessions.attachment_store import ( + SessionAttachmentRecord, + SessionAttachmentStore, +) from control_plane_backend.sessions.store import ( SessionMetadataRecord, SessionMetadataStore, @@ -265,6 +269,86 @@ async def test_session_metadata_store_delete_is_user_scoped( await engine.dispose() +@pytest.mark.asyncio +async def test_session_attachment_store_save_list_count_and_delete( + tmp_path: Path, +) -> None: + """ + Verify persisted session attachments round-trip through the DB store. + + Why this test exists: + - the chat drawer relies on the `main`-style session attachment store for + reload-safe metadata, summary previews, and delete operations + + How to use it: + - run with the offline `control-plane-backend` test suite + """ + + engine = await _make_sqlite_engine(tmp_path, "session-attachments.sqlite3") + + try: + store = SessionAttachmentStore(engine) + created_at = datetime(2026, 6, 11, 12, 0, tzinfo=timezone.utc) + + await store.save( + SessionAttachmentRecord( + session_id="session-1", + attachment_id="attachment-1", + name="notes.md", + mime="text/markdown", + size_bytes=321, + summary_md="# Notes", + document_uid="doc-1", + storage_key="uploads/notes.md", + created_at=created_at, + updated_at=created_at, + ) + ) + await store.save( + SessionAttachmentRecord( + session_id="session-2", + attachment_id="attachment-2", + name="slides.pptx", + mime="application/vnd.openxmlformats-officedocument.presentationml.presentation", + size_bytes=1024, + summary_md="Slides", + document_uid="doc-2", + ) + ) + # Same attachment id should merge/update, matching main-branch semantics. + await store.save( + SessionAttachmentRecord( + session_id="session-1", + attachment_id="attachment-1", + name="notes-v2.md", + mime="text/markdown", + size_bytes=654, + summary_md="# Notes v2", + document_uid="doc-1b", + storage_key="uploads/notes-v2.md", + created_at=created_at, + updated_at=created_at, + ) + ) + + session_rows = await store.list_for_session("session-1") + count = await store.count_for_sessions(["session-1", "session-2"]) + + assert len(session_rows) == 1 + assert session_rows[0].name == "notes-v2.md" + assert session_rows[0].document_uid == "doc-1b" + assert session_rows[0].storage_key == "uploads/notes-v2.md" + assert count == 2 + + await store.delete("session-1", "attachment-1") + assert await store.list_for_session("session-1") == [] + + await store.delete_for_session("session-2") + assert await store.count_for_sessions(["session-2"]) == 0 + finally: + await engine.dispose() + + @pytest.mark.asyncio async def test_session_metadata_store_update_last_activity_returns_none_for_missing_row( tmp_path: Path, diff --git a/apps/fred-agents/config/mcp_catalog.yaml b/apps/fred-agents/config/mcp_catalog.yaml index 32b7d64314..6dfd862a28 100644 --- a/apps/fred-agents/config/mcp_catalog.yaml +++ b/apps/fred-agents/config/mcp_catalog.yaml @@ -51,10 +51,21 @@ servers: title: "Document library picker" description: "Show a document library selector in the chat interface." default: true + - key: "chat_options.libraries_binding" + type: "boolean" + title: "Bind to specific libraries" + description: "Restrict this agent to a fixed set of document libraries chosen at configuration time." + default: false + - key: "chat_options.bound_library_ids" + type: "array" + item_type: "string" + title: "Bound document libraries" + description: "Restrict the chat library picker to this preselected set of document libraries." + default: [] - key: "chat_options.documents_selection" type: "boolean" title: "Document picker" - description: "Show a document selector in the chat composer." + description: "Show a document selector in the chat interface." default: true - key: "chat_options.search_policy_enabled" type: "boolean" @@ -139,10 +150,21 @@ servers: title: "Document library picker" description: "Show a document library selector in the chat interface." default: false + - key: "chat_options.libraries_binding" + type: "boolean" + title: "Bind to specific libraries" + description: "Restrict this agent to a fixed set of document libraries chosen at configuration time." + default: false + - key: "chat_options.bound_library_ids" + type: "array" + item_type: "string" + title: "Bound document libraries" + description: "Restrict the chat library picker to this preselected set of document libraries." + default: [] - key: "chat_options.documents_selection" type: "boolean" title: "Document picker" - description: "Show a document selector in the chat composer." + description: "Show a document selector in the chat interface." default: false - key: "chat_options.search_policy_enabled" type: "boolean" diff --git a/apps/fred-agents/fred_agents/general_assistant.py b/apps/fred-agents/fred_agents/general_assistant.py index 675271e6d8..c7d21a8143 100644 --- a/apps/fred-agents/fred_agents/general_assistant.py +++ b/apps/fred-agents/fred_agents/general_assistant.py @@ -156,6 +156,18 @@ class GeneralAssistantDefinition(ReActAgentDefinition): default_by_lang={"fr": _SYSTEM_PROMPT_FR}, ui=UIHints(group="Prompts", multiline=True, markdown=True, max_lines=12), ), + FieldSpec( + key="chat_options.attach_files", + type="boolean", + title="Allow file attachments", + description=( + "Persist the conversation-attachment capability so the chat composer " + "can restore the toggle state after saving and reopening the agent." + ), + required=False, + default=False, + ui=UIHints(group="Chat", hide=True), + ), ) def policy(self) -> ReActPolicy: diff --git a/apps/fred-agents/fred_agents/react_rag_mcp.py b/apps/fred-agents/fred_agents/react_rag_mcp.py index 43f50109ab..4f10a153a6 100644 --- a/apps/fred-agents/fred_agents/react_rag_mcp.py +++ b/apps/fred-agents/fred_agents/react_rag_mcp.py @@ -115,6 +115,18 @@ class ReactRagMcpDefinition(ReActAgentDefinition): required=False, ui=UIHints(group="Prompts", multiline=True, markdown=True, max_lines=12), ), + FieldSpec( + key="chat_options.attach_files", + type="boolean", + title="Allow file attachments", + description=( + "Persist the conversation-attachment capability so the chat composer " + "can restore the toggle state after saving and reopening the agent." + ), + required=False, + default=False, + ui=UIHints(group="Chat", hide=True), + ), ) def policy(self) -> ReActPolicy: diff --git a/apps/fred-agents/tests/test_smoke.py b/apps/fred-agents/tests/test_smoke.py index 216c48757e..f69711f110 100644 --- a/apps/fred-agents/tests/test_smoke.py +++ b/apps/fred-agents/tests/test_smoke.py @@ -252,6 +252,27 @@ def test_fred_agents_pod_registers_and_streams_sentinel_offline( "chat_options.libraries_selection", }.issubset(field_keys) + general_assistant_template = next( + template + for template in templates_response.json() + if template["template_agent_id"] == "fred.github.assistant" + ) + general_assistant_field_keys = { + field["key"] + for field in general_assistant_template["default_tuning"]["fields"] + } + assert "chat_options.attach_files" in general_assistant_field_keys + + react_rag_mcp_template = next( + template + for template in templates_response.json() + if template["template_agent_id"] == "fred.github.react_rag_mcp" + ) + react_rag_mcp_field_keys = { + field["key"] for field in react_rag_mcp_template["default_tuning"]["fields"] + } + assert "chat_options.attach_files" in react_rag_mcp_field_keys + stream_response = client.post( "/fred/agents/v2/agents/execute/stream", json={ diff --git a/apps/frontend/dockerfiles/Dockerfile-prod b/apps/frontend/dockerfiles/Dockerfile-prod index f4124e5c83..630a9631ad 100644 --- a/apps/frontend/dockerfiles/Dockerfile-prod +++ b/apps/frontend/dockerfiles/Dockerfile-prod @@ -33,7 +33,7 @@ RUN make build # ----------------------------------------------------------------------------- # RUNTIME # ----------------------------------------------------------------------------- -FROM mirror.gcr.io/nginxinc/nginx-unprivileged:1.27.3-alpine AS runtime +FROM mirror.gcr.io/nginxinc/nginx-unprivileged:1.31.0-alpine AS runtime # Copy web app from builder to nginx html directory COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html @@ -47,6 +47,8 @@ RUN touch /etc/nginx/conf.d/fred.conf && \ # Disable default configuration for nginx RUN mv /etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf.disabled +USER nginx + # Expose default nginx port EXPOSE 8080 diff --git a/apps/frontend/public/config.json b/apps/frontend/public/config.json index 8006fc6a04..1177ea9daa 100644 --- a/apps/frontend/public/config.json +++ b/apps/frontend/public/config.json @@ -1,7 +1,7 @@ { "frontend_basename": "/", "user_auth": { - "enabled": false, + "enabled": true, "realm_url": "http://app-keycloak:8080/realms/app", "client_id": "app" } diff --git a/apps/frontend/src/common/router.tsx b/apps/frontend/src/common/router.tsx index 21a1b6c88c..87c27a5ee8 100644 --- a/apps/frontend/src/common/router.tsx +++ b/apps/frontend/src/common/router.tsx @@ -35,6 +35,7 @@ import ReleaseNotesPage from "@components/pages/ReleaseNotesPage/ReleaseNotesPag import UserSettingsPage from "@components/pages/UserSettingsPage/UserSettingsPage.tsx"; import AdminTeamsPage from "@components/pages/admin/AdminTeamsPage/AdminTeamsPage.tsx"; import TasksPage from "@components/pages/admin/TasksPage/TasksPage.tsx"; +import MigrationPage from "@components/pages/admin/MigrationPage/MigrationPage.tsx"; import { useUserCapabilities } from "@hooks/useUserCapabilities.ts"; import { getConfig } from "./config"; @@ -125,6 +126,14 @@ export const routes: RouteObject[] = [ ), }, + { + path: "admin/migration", + element: ( + + + + ), + }, { path: "monitoring/kpis", element: ( diff --git a/apps/frontend/src/locales/en/translation.json b/apps/frontend/src/locales/en/translation.json index 04bad41201..89f9e966d8 100644 --- a/apps/frontend/src/locales/en/translation.json +++ b/apps/frontend/src/locales/en/translation.json @@ -400,6 +400,9 @@ "description": "Permanently restrict this agent to selected libraries. Users cannot change the scope in chat.", "noLibraries": "No libraries available." }, + "library_scope_picker": { + "description": "Select the document libraries this agent can search. The tree shows the files currently inside each library." + }, "chat_options_libraries_selection": { "title": "Document libraries picker", "description": "Let users select document libraries/knowledge sources for this agent.", @@ -668,9 +671,19 @@ "input": { "placeholder": "Write your message… Enter to send • Shift+Enter for newline" }, + "errors": { + "missingContext": "Missing team or agent context in URL." + }, "attachFiles": "Attach files", "addLibraries": "Select folders", "addDocuments": "Add documents", + "dropFilesHere": "Drop files here", + "conversationFiles": "Conversation files", + "toggleDebugDrawer": "Toggle debug drawer", + "composerActions": { + "openAria": "Open chat actions", + "dialogAria": "Chat actions" + }, "attachments": { "betaNotice": "File attachments are in beta. Supported formats: PDF, DOC, DOCX, PPT, PPTX, TXT, MD, CSV, TSV, RTF, HTML, JSON, JSONL, XLS, XLSX. Please provide feedback to help us improve this feature.", "supportedFormats": "Supported files: PDF, DOCX, PPTX, CSV, TXT, MD, XLSM, JSONL, MP3, WAV, OGG, FLAC, M4A, AAC, MP4, MKV, AVI, MOV, WEBM", @@ -687,7 +700,20 @@ "duplicateTitle": "Already attached", "duplicateDetail": "This file is already in the conversation.", "skippedDuplicates": "Skipped duplicates", - "someFilesAlreadyAttached": "Some files were already attached and were skipped." + "someFilesAlreadyAttached": "Some files were already attached and were skipped.", + "processingPrepare": "Preparing processing", + "processingFast": "Fast processing", + "processingDone": "Done", + "processingFailed": "Failed" + }, + "attachmentChip": { + "ariaLabel": "Attached files", + "uploading": "Uploading", + "failed": "Failed", + "processing": "Processing", + "image": "Image", + "file": "File", + "removeAria": "Remove {{name}}" }, "documents": { "drawerTitle": "Documents", @@ -744,9 +770,11 @@ "searchPolicyTitle": "Search policy", "scopeTitle": "Search scope", "librariesTitle": "Libraries", + "documentPickerTitle": "Document picker", "documentsTitle": "Documents", "boundLibrariesTitle": "Bound libraries", "noLibrariesAvailable": "No libraries available.", + "noDocumentsSelected": "No document selected", "noDocumentsAvailable": "No documents available.", "documentsNoScope": "Document selection is enabled, but no library picker or bound library is configured.", "selectLibraryFirst": "Select a library first.", @@ -761,10 +789,29 @@ "scopeCorpusAndWeb": "Corpus + web", "scopeGeneral": "General", "librariesCount": "{{count}} libraries", - "documentsCount": "{{count}} documents" + "documentsCount": "{{count}} documents", + "attachFile": "Attach file" + }, + "sessionAttachments": { + "title": "Conversation files", + "loading": "Loading files…", + "empty": "No files are attached to this conversation.", + "openPreview": "Open preview", + "previewAria": "Preview {{name}}", + "deleteAria": "Delete {{name}}", + "filePreviewTitle": "File preview", + "noSummary": "_(No summary returned by Knowledge Flow)_" + }, + "markdownPreview": { + "empty": "No markdown preview available.", + "closeAria": "Close markdown preview" }, "loadingHistory": "Loading conversation history…", "startConversationHint": "Send a message to start the conversation.", + "startConversationVariantAnalyze": "What's on the agenda today, {{username}}?", + "startConversationVariantDraft": "What's on your mind today?", + "startConversationVariantExplore": "What are we tackling today?", + "startConversationVariantSearch": "What should we dive into today, {{username}}?", "conversationAriaLabel": "Conversation" }, "settings": { @@ -1191,9 +1238,9 @@ "strict": "Strict", "hybrid": "Hybrid", "semantic": "Semantic", - "strictDescription": "Strict search prioritizes precision over coverage. It applies tighter constraints (e.g., stronger keyword/metadata matching and/or higher similarity thresholds) so only highly confident results are retrieved. Use this when you want to avoid ‘maybe relevant’ documents, or when your corpus is large and you prefer fewer, more exact sources. If nothing matches strongly, results may be sparse.", + "strictDescription": "Strict search prioritizes precision over coverage. It applies tighter constraints (e.g., stronger keyword/metadata matching and/or higher similarity thresholds) so only highly confident results are retrieved. Use this when you want to avoid ‘maybe relevant' documents, or when your corpus is large and you prefer fewer, more exact sources. If nothing matches strongly, results may be sparse.", "hybridDescription": "Hybrid search combines keyword matching (exact terms, names, IDs) with semantic similarity. It is the best option when your question contains specific terminology (component names, error codes, APIs) but also benefits from conceptual matching. Hybrid typically improves recall while keeping precision high, at the cost of slightly more computation.", - "semanticDescription": "Semantic search uses embeddings to retrieve passages that are conceptually similar to your question, even if they don’t share the same words. This usually finds the most relevant context for broad or exploratory questions, but it can sometimes include loosely related results when your query is very short or ambiguous." + "semanticDescription": "Semantic search uses embeddings to retrieve passages that are conceptually similar to your question, even if they don't share the same words. This usually finds the most relevant context for broad or exploratory questions, but it can sometimes include loosely related results when your query is very short or ambiguous." }, "sourceDetails": { "bestScore": "best {{score}}%", diff --git a/apps/frontend/src/locales/fr/translation.json b/apps/frontend/src/locales/fr/translation.json index bd3435f999..5534f51545 100644 --- a/apps/frontend/src/locales/fr/translation.json +++ b/apps/frontend/src/locales/fr/translation.json @@ -287,7 +287,7 @@ "refreshFailed": "Échec du rafraîchissement", "hideProgress": "Masquer", "globalStatusTitle": "Traitement de la bibliothèque", - "globalStatusLoading": "Chargement de l’état de traitement…", + "globalStatusLoading": "Chargement de l'état de traitement…", "globalStatusSummary": "{{processed}}/{{total}} traités • {{in_progress}} en cours • {{failed}} en échec • {{not_started}} non démarrés", "globalStatusEmpty": "Aucun document dans la base de connaissances pour le moment.", "globalStatusProcessedLabel": "Traités : {{count}}", @@ -401,6 +401,9 @@ "description": "Restreint définitivement cet agent aux librairies sélectionnées. Les utilisateurs ne peuvent pas modifier la portée dans le chat.", "noLibraries": "Aucune librairie disponible." }, + "library_scope_picker": { + "description": "Sélectionnez les bibliothèques de documents que cet agent peut interroger. L'arborescence affiche les fichiers actuellement présents dans chaque bibliothèque." + }, "chat_options_libraries_selection": { "title": "Sélecteur de bibliothèques de documents", "description": "Permettre aux utilisateurs de sélectionner des bibliothèques de documents/sources de connaissances pour cet agent.", @@ -669,9 +672,19 @@ "input": { "placeholder": "Écrivez votre message… Entrée pour envoyer • Maj+Entrée pour un saut de ligne" }, + "errors": { + "missingContext": "Contexte d'équipe ou d'agent manquant dans l'URL." + }, "attachFiles": "Joindre des fichiers", "addLibraries": "Sélectionner des dossiers", "addDocuments": "Ajouter des documents", + "dropFilesHere": "Déposez les fichiers ici", + "conversationFiles": "Fichiers de conversation", + "toggleDebugDrawer": "Afficher ou masquer le tiroir de debug", + "composerActions": { + "openAria": "Ouvrir les actions de conversation", + "dialogAria": "Actions de conversation" + }, "attachments": { "betaNotice": "Les pièces jointes sont en version bêta. Formats pris en charge : PDF, DOC, DOCX, PPT, PPTX, TXT, MD, CSV, TSV, RTF, HTML, JSON, JSONL, XLS, XLSX. Veuillez donner votre avis pour nous aider à améliorer cette fonctionnalité.", "supportedFormats": "Formats pris en charge : PDF, DOCX, PPTX, CSV, TXT, MD, XLSM, JSONL, MP3, WAV, OGG, FLAC, M4A, AAC, MP4, MKV, AVI, MOV, WEBM", @@ -683,12 +696,25 @@ "includeTooltipOff": "Les pièces jointes sont exclues de la recherche pour cette conversation.", "tooltip": { "description": "Fichiers attachés à cette conversation.\n\nLes fichiers sont traités par des algorithmes plus rapides mais moins précis. L'agent pourra utiliser ces fichiers pour répondre .\n\nNotez que cette fonctionnalité est en version bêta et peut ne pas fonctionner parfaitement pour tous les types de fichiers. Pour des corpus pérennes, envisagez d'utiliser des bibliothèques de documents.", - "disabled": "Cet agent n’utilise pas les pièces jointes." + "disabled": "Cet agent n'utilise pas les pièces jointes." }, "duplicateTitle": "Déjà ajouté", "duplicateDetail": "Ce fichier est déjà dans la conversation.", "skippedDuplicates": "Doublons ignorés", - "someFilesAlreadyAttached": "Certains fichiers étaient déjà joints et ont été ignorés." + "someFilesAlreadyAttached": "Certains fichiers étaient déjà joints et ont été ignorés.", + "processingPrepare": "Préparation du traitement", + "processingFast": "Traitement rapide", + "processingDone": "Terminé", + "processingFailed": "Échec" + }, + "attachmentChip": { + "ariaLabel": "Fichiers joints", + "uploading": "Téléversement", + "failed": "Échec", + "processing": "Traitement", + "image": "Image", + "file": "Fichier", + "removeAria": "Retirer {{name}}" }, "documents": { "drawerTitle": "Documents", @@ -745,9 +771,11 @@ "searchPolicyTitle": "Politique de recherche", "scopeTitle": "Portée de recherche", "librariesTitle": "Bibliothèques", + "documentPickerTitle": "Sélecteur de documents", "documentsTitle": "Documents", "boundLibrariesTitle": "Bibliothèques liées", "noLibrariesAvailable": "Aucune bibliothèque disponible.", + "noDocumentsSelected": "Aucun document sélectionné", "noDocumentsAvailable": "Aucun document disponible.", "documentsNoScope": "La sélection de documents est activée, mais aucun sélecteur de bibliothèque ni aucune bibliothèque liée n'est configuré.", "selectLibraryFirst": "Sélectionnez d'abord une bibliothèque.", @@ -762,10 +790,29 @@ "scopeCorpusAndWeb": "Corpus + web", "scopeGeneral": "Général", "librariesCount": "{{count}} bibliothèques", - "documentsCount": "{{count}} documents" + "documentsCount": "{{count}} documents", + "attachFile": "Joindre un fichier" + }, + "sessionAttachments": { + "title": "Fichiers de conversation", + "loading": "Chargement des fichiers…", + "empty": "Aucun fichier n'est attaché à cette conversation.", + "openPreview": "Ouvrir l'aperçu", + "previewAria": "Aperçu de {{name}}", + "deleteAria": "Supprimer {{name}}", + "filePreviewTitle": "Aperçu du fichier", + "noSummary": "_(Aucun résumé renvoyé par Knowledge Flow)_" + }, + "markdownPreview": { + "empty": "Aucun aperçu markdown disponible.", + "closeAria": "Fermer l'aperçu markdown" }, "loadingHistory": "Chargement de l'historique…", "startConversationHint": "Envoyez un message pour démarrer la conversation.", + "startConversationVariantAnalyze": "Quel est le programme aujourd'hui, {{username}} ?", + "startConversationVariantDraft": "Qu'avez-vous en tête aujourd'hui ?", + "startConversationVariantExplore": "Sur quoi travaillons-nous aujourd'hui ?", + "startConversationVariantSearch": "Dans quoi veut-on plonger aujourd'hui, {{username}} ?", "conversationAriaLabel": "Conversation" }, "settings": { @@ -779,7 +826,7 @@ "confirmDeleteAllMessage": "Cette action supprimera toutes les conversations définitivement. Voulez-vous continuer ?", "chatContext": "Contexte de conversation", "chatContextTooltip": { - "description": "Ajoute des instructions (un « profil ») à cette conversation.\n\nCe contexte est injecté dans le prompt système des agents et s’applique donc à tous les agents.\n\nChaque agent possède aussi ses propres prompts internes, configurables, plus spécifiques à ses étapes : le contexte de conversation vient en complément de ces derniers et vous permet d’adapter le comportement de l’agent à votre besoin." + "description": "Ajoute des instructions (un « profil ») à cette conversation.\n\nCe contexte est injecté dans le prompt système des agents et s'applique donc à tous les agents.\n\nChaque agent possède aussi ses propres prompts internes, configurables, plus spécifiques à ses étapes : le contexte de conversation vient en complément de ces derniers et vous permet d'adapter le comportement de l'agent à votre besoin." }, "chatContextPreviewTitle": "Aperçu du contexte de conversation" }, @@ -900,7 +947,7 @@ "teamDescription": "Gérez vos documents", "viewSelector": { "libraries": "Bibliothèques", - "librariesTooltip": "Restreignez la recherche aux bibliothèques de documents sélectionnées pour cette conversation.\n\nCe paramètre est enregistré par conversation et utilisé pour les recherches RAG lorsque l’agent sélectionné le supporte.", + "librariesTooltip": "Restreignez la recherche aux bibliothèques de documents sélectionnées pour cette conversation.\n\nCe paramètre est enregistré par conversation et utilisé pour les recherches RAG lorsque l'agent sélectionné le supporte.", "librariesUnsupported": "Cet agent ne supporte pas le filtrage par bibliothèque.", "templates": "Modèles", "prompts": "Prompts", @@ -1194,8 +1241,8 @@ "hybrid": "Hybride", "semantic": "Sémantique", "strictDescription": "La recherche stricte privilégie la précision plutôt que la couverture. Elle applique des contraintes plus fortes (par exemple des correspondances lexicales ou de métadonnées plus exigeantes et/ou des seuils de similarité plus élevés), afin de ne retenir que les documents les plus clairement pertinents. Ce mode est adapté lorsque vous souhaitez éviter les résultats “approximatifs”, notamment sur de grands corpus, quitte à obtenir moins de résultats.", - "hybridDescription": "La recherche hybride combine la recherche par mots-clés (termes exacts, noms, identifiants) et la similarité sémantique. Elle est recommandée lorsque votre question contient des éléments précis (API, composants, codes d’erreur) tout en nécessitant une compréhension du contexte. Ce mode offre généralement un bon équilibre entre rappel et précision, au prix d’un traitement légèrement plus coûteux.", - "semanticDescription": "La recherche sémantique utilise des embeddings pour retrouver des documents ou passages dont le sens est proche de votre question, même s’ils n’emploient pas les mêmes mots. Elle est particulièrement efficace pour les questions ouvertes ou exploratoires, mais peut parfois inclure des résultats plus larges ou indirects lorsque la requête est courte ou ambiguë" + "hybridDescription": "La recherche hybride combine la recherche par mots-clés (termes exacts, noms, identifiants) et la similarité sémantique. Elle est recommandée lorsque votre question contient des éléments précis (API, composants, codes d'erreur) tout en nécessitant une compréhension du contexte. Ce mode offre généralement un bon équilibre entre rappel et précision, au prix d'un traitement légèrement plus coûteux.", + "semanticDescription": "La recherche sémantique utilise des embeddings pour retrouver des documents ou passages dont le sens est proche de votre question, même s'ils n'emploient pas les mêmes mots. Elle est particulièrement efficace pour les questions ouvertes ou exploratoires, mais peut parfois inclure des résultats plus larges ou indirects lorsque la requête est courte ou ambiguë" }, "sourceDetails": { "bestScore": "meilleur {{score}}%", @@ -1240,7 +1287,7 @@ "node": "Nœud", "model": "Modèle", "tokensTotal": "Jetons utilisés", - "tokensIn": "Depuis l’utilisateur (invite+contexte)", + "tokensIn": "Depuis l'utilisateur (invite+contexte)", "tokensOut": "Depuis le modèle (réponse)", "latency": "Latence", "search": "Recherche", @@ -1249,7 +1296,7 @@ "sectionLibrariesPlural": "BIBLIOTHÈQUES", "sectionchatContextSingular": "CONTEXTE DE CONVERSATION", "sectionchatContextsPlural": "CONTEXTES DE CONVERSATION", - "disclaimer": "Le contenu généré par l’IA peut être incorrect." + "disclaimer": "Le contenu généré par l'IA peut être incorrect." }, "agentSelector": { "sendTo": "Envoyer à" @@ -1367,8 +1414,8 @@ }, "teamAppsPage": { "headerSubtitle": "Apps de l'équipe", - "headerInfoTooltip": "Seuls les propriétaires de l’équipe peuvent gérer ces applications, qui sont accessibles uniquement à ses membres.", - "noAppDescription": "Les propriétaires de l’équipe n’ont configuré aucune application pour ce groupe pour le moment.", + "headerInfoTooltip": "Seuls les propriétaires de l'équipe peuvent gérer ces applications, qui sont accessibles uniquement à ses membres.", + "noAppDescription": "Les propriétaires de l'équipe n'ont configuré aucune application pour ce groupe pour le moment.", "noAppTitle": "Aucune app" }, "confirmationDialog": { @@ -1439,7 +1486,7 @@ }, "conversationProfile": { "title": "Profil de conversation", - "placeholder": "Indiquez ici qui vous êtes, ce que vous faites et comment vos {{agentsNicknamePlural}} doivent s’adresser à vous." + "placeholder": "Indiquez ici qui vous êtes, ce que vous faites et comment vos {{agentsNicknamePlural}} doivent s'adresser à vous." }, "accessGcu": "Accéder aux CGU", "accessGdpr": "Que fait {{siteTitle}} {{siteSubtitle}} de mes données ?", @@ -1482,7 +1529,7 @@ "privateTeam": "Équipe privée", "teamPrompt": { "label": "Contexte de l'équipe (prompt)", - "placeholder": "Décrivez ici les enjeux et les objectifs de votre équipe. Ces éléments seront pris en compte par vos {{agentsNicknamePlural}} d’équipe." + "placeholder": "Décrivez ici les enjeux et les objectifs de votre équipe. Ces éléments seront pris en compte par vos {{agentsNicknamePlural}} d'équipe." } } }, @@ -1502,7 +1549,7 @@ "titleCreate": "Créer un nouveau {{agentsNicknameSingular}}", "titleEdit": "Editer {{agent}}", "templateSection": "Modèle", - "noTemplates": "Aucun modèle d’agent disponible — démarrez un pod runtime.", + "noTemplates": "Aucun modèle d'agent disponible — démarrez un pod runtime.", "templateUnavailable": "Modèle indisponible — les définitions de champs peuvent être incomplètes.", "tunableFields": "Paramètres", "mcpTools": "Outils", @@ -1551,7 +1598,7 @@ } }, "gcu": { - "title": "Conditions Générales d’Utilisation (CGU)", + "title": "Conditions Générales d'Utilisation (CGU)", "lockInformation": "Lire les CGU avant de pouvoir les accepter", "validate": "Accepter les CGU", "backToApp": "Retourner à l'application" diff --git a/apps/frontend/src/rework/components/pages/ManagedChatPage/ConversationThread/ConversationThread.tsx b/apps/frontend/src/rework/components/pages/ManagedChatPage/ConversationThread/ConversationThread.tsx index b41da0718f..04c1481e74 100644 --- a/apps/frontend/src/rework/components/pages/ManagedChatPage/ConversationThread/ConversationThread.tsx +++ b/apps/frontend/src/rework/components/pages/ManagedChatPage/ConversationThread/ConversationThread.tsx @@ -15,7 +15,7 @@ // Page-local composition of organisms for ManagedChatPage. // Lives under pages/ so it may import from shared/organisms freely. -import type { RefObject } from "react"; +import type { ReactNode, RefObject } from "react"; import type { AwaitingHumanEvent } from "../../../../../slices/agentic/agenticOpenApi"; import type { ThreadMessage } from "@rework/types/thread"; import { HitlPrompt } from "@shared/molecules/HitlPrompt/HitlPrompt.tsx"; @@ -28,6 +28,7 @@ interface ConversationThreadProps { pendingHitl: AwaitingHumanEvent | null; isLoading: boolean; isStreaming: boolean; + emptyState?: ReactNode; scrollContainerRef: RefObject; onHitlAnswer: (answer: string | boolean, freeText?: string) => void; } @@ -37,6 +38,7 @@ export function ConversationThread({ pendingHitl, isLoading, isStreaming, + emptyState, scrollContainerRef, onHitlAnswer, }: ConversationThreadProps) { @@ -46,6 +48,7 @@ export function ConversationThread({ * { + width: 100%; +} + /* ── Floating input bar — sits above the scroll container, never in flow ── */ .inputOverlay { position: absolute; @@ -56,6 +144,22 @@ z-index: 1; } +.welcomeBlock { + display: flex; + justify-content: center; + margin: 0 auto var(--spacing-xl); + width: 100%; + text-align: center; +} + +.welcomeTitle { + margin: 0; + color: var(--on-surface); + font: var(--font-headline-small); + line-height: 1.1; + white-space: nowrap; +} + /* ── Error state ───────────────────────────────────────────────────────── */ .error { padding: var(--spacing-m); diff --git a/apps/frontend/src/rework/components/pages/ManagedChatPage/ManagedChatPage.tsx b/apps/frontend/src/rework/components/pages/ManagedChatPage/ManagedChatPage.tsx index 398a534c45..88954fc1aa 100644 --- a/apps/frontend/src/rework/components/pages/ManagedChatPage/ManagedChatPage.tsx +++ b/apps/frontend/src/rework/components/pages/ManagedChatPage/ManagedChatPage.tsx @@ -12,28 +12,65 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useRef, useState } from "react"; +import { DragEvent, useRef, useState } from "react"; import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { ConversationThread } from "./ConversationThread/ConversationThread"; -import { ComposerSettingsControls } from "@shared/organisms/ComposerSettingsControls/ComposerSettingsControls"; import { RichInputField } from "@shared/molecules/RichInputField/RichInputField"; import { SessionTitleEditor } from "@shared/molecules/SessionTitleEditor/SessionTitleEditor"; import { DebugRawDrawer } from "@shared/molecules/DebugRawDrawer/DebugRawDrawer"; +import { AttachmentChips } from "@shared/molecules/AttachmentChips/AttachmentChips"; +import { SessionAttachmentsDrawer } from "@shared/molecules/SessionAttachmentsDrawer/SessionAttachmentsDrawer"; +import { ComposerActionsMenu } from "@shared/molecules/ComposerActionsMenu/ComposerActionsMenu"; +import { SearchConfig } from "@shared/molecules/SearchConfig/SearchConfig"; import IconButton from "@shared/atoms/IconButton/IconButton"; import { useManagedChat } from "./useManagedChat"; import { useFrontendBootstrap } from "../../../../hooks/useFrontendBootstrap"; import { useGetTeamQuery } from "../../../../slices/controlPlane/controlPlaneApiEnhancements"; +import { KeyCloakService } from "../../../../security/KeycloakService"; import styles from "./ManagedChatPage.module.css"; +const WELCOME_VARIANT_KEYS = [ + "chatbot.startConversationVariantAnalyze", + "chatbot.startConversationVariantDraft", + "chatbot.startConversationVariantExplore", + "chatbot.startConversationVariantSearch", +] as const; + +function pickWelcomeVariant(previous: number | null): number { + const next = Math.floor(Math.random() * WELCOME_VARIANT_KEYS.length); + if (previous == null || WELCOME_VARIANT_KEYS.length < 2 || next !== previous) { + return next; + } + return (next + 1) % WELCOME_VARIANT_KEYS.length; +} + +function ManagedChatWelcome() { + const { t } = useTranslation(); + const firstName = KeyCloakService.GetUserGivenName(); + const [variantIndex] = useState(() => pickWelcomeVariant(null)); + const welcomeName = firstName ?? t("chatbot.welcomeFallback"); + + return ( +
+

{t(WELCOME_VARIANT_KEYS[variantIndex], { username: welcomeName })}

+
+ ); +} + export default function ManagedChatPage() { + const { t } = useTranslation(); const { teamId, agentInstanceId } = useParams<{ teamId: string; agentInstanceId: string }>(); if (!teamId || !agentInstanceId) { - return
Missing team or agent context in URL.
; + return
{t("chatbot.errors.missingContext")}
; } const scrollContainerRef = useRef(null); + const fileInputRef = useRef(null); const [debugOpen, setDebugOpen] = useState(false); + const [attachmentsDrawerOpen, setAttachmentsDrawerOpen] = useState(false); + const [dragActive, setDragActive] = useState(false); const { activeTeam } = useFrontendBootstrap(); const isPersonalTeam = teamId === activeTeam?.id; @@ -43,16 +80,110 @@ export default function ManagedChatPage() { isPersonalTeam || (Array.isArray(team?.permissions) && team.permissions.includes("can_administer_owners")); const chat = useManagedChat({ teamId, agentInstanceId }); + const isInitialState = + chat.threadMessages.length === 0 && !chat.waitResponse && !chat.isLoadingHistory && chat.pendingHitl == null; - const opts = chat.effectiveChatOptions; - const hasComposerControls = + const opts = chat.agentChatOptions ?? chat.effectiveChatOptions; + const attachmentsCount = chat.persistedAttachments.length; + const allowChatAttachments = opts?.attach_files === true; + const hasSearchConfigOptions = + allowChatAttachments || opts?.libraries_selection === true || opts?.documents_selection === true || opts?.search_policy_selection === true || opts?.rag_scope_selection === true; + const handleFilesSelected = (files: FileList | null) => { + if (!allowChatAttachments) return; + const selected = Array.from(files ?? []); + if (selected.length > 0) chat.handleAddAttachments(selected, "picker"); + }; + + const handleDragOver = (event: DragEvent) => { + if (!allowChatAttachments) return; + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + setDragActive(true); + }; + + const handleDrop = (event: DragEvent) => { + if (!allowChatAttachments) return; + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + setDragActive(false); + const files = Array.from(event.dataTransfer.files); + if (files.length > 0) chat.handleAddAttachments(files, "drop"); + }; + + const composer = ( + 0 ? ( + + ) : undefined + } + leftSlot={ + hasSearchConfigOptions ? ( + + {({ closeMenu }) => ( + fileInputRef.current?.click()} + onRequestClose={closeMenu} + selectedLibraryIds={chat.selectedLibraryIds} + onSelectedLibraryIdsChange={chat.setSelectedLibraryIds} + selectedDocumentUids={chat.selectedDocumentUids} + onSelectedDocumentUidsChange={chat.setSelectedDocumentUids} + searchPolicy={chat.searchPolicy} + onSearchPolicyChange={chat.setSearchPolicy} + ragScope={chat.ragScope} + onRagScopeChange={chat.setRagScope} + options={opts} + /> + )} + + ) : undefined + } + /> + ); + return ( -
+
{ + if (!allowChatAttachments) return; + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; + setDragActive(false); + }} + onDrop={handleDrop} + > + { + handleFilesSelected(event.currentTarget.files); + event.currentTarget.value = ""; + }} + /> + {allowChatAttachments && dragActive && ( +
+
+ + + {t("chatbot.dropFilesHere")} +
+
+ )} {/* Session title — floats top-left, zero layout impact */}
@@ -60,60 +191,58 @@ export default function ManagedChatPage() { )}
- {isAdmin && ( -
+
+ {attachmentsCount > 0 && ( + + )} + {isAdmin && ( setDebugOpen((v) => !v)} /> -
- )} + )} +
- {/* Scroll container — input bar is NOT inside here so it never affects scrollHeight */} -
- +
+ {isInitialState ? ( +
+ +
{composer}
+
+ ) : ( + + )}
- {/* Floating input bar — absolutely positioned overlay, zero layout impact on scroll */} -
- - ) : undefined - } - /> -
+ {!isInitialState &&
{composer}
} + setAttachmentsDrawerOpen(false)} + attachments={chat.persistedAttachments} + isLoading={chat.isHydratingAttachments} + onDelete={(attachmentId) => { + void chat.deletePersistedAttachment(attachmentId); + }} + /> {isAdmin && setDebugOpen(false)} messages={chat.messages} />}
); diff --git a/apps/frontend/src/rework/components/pages/ManagedChatPage/runtimeContextBuilder.ts b/apps/frontend/src/rework/components/pages/ManagedChatPage/runtimeContextBuilder.ts index dab0ace64b..31628874fe 100644 --- a/apps/frontend/src/rework/components/pages/ManagedChatPage/runtimeContextBuilder.ts +++ b/apps/frontend/src/rework/components/pages/ManagedChatPage/runtimeContextBuilder.ts @@ -19,14 +19,27 @@ export function buildComposerRuntimeContext(params: { selectedDocumentUids: string[]; searchPolicy: SearchPolicyName; ragScope: RagScope; + boundLibraryIds?: string[] | null; + attachmentsMarkdown?: string | null; }): Pick< RuntimeContext, - "selected_document_libraries_ids" | "selected_document_uids" | "search_policy" | "search_rag_scope" + | "selected_document_libraries_ids" + | "selected_document_uids" + | "search_policy" + | "search_rag_scope" + | "attachments_markdown" > { + const selectedDocumentLibrariesIds = + params.boundLibraryIds && params.boundLibraryIds.length > 0 + ? params.boundLibraryIds + : params.selectedLibraryIds.length > 0 + ? params.selectedLibraryIds + : null; return { - selected_document_libraries_ids: params.selectedLibraryIds.length > 0 ? params.selectedLibraryIds : null, + selected_document_libraries_ids: selectedDocumentLibrariesIds, selected_document_uids: params.selectedDocumentUids.length > 0 ? params.selectedDocumentUids : null, search_policy: params.searchPolicy, search_rag_scope: params.ragScope, + ...(params.attachmentsMarkdown != null ? { attachments_markdown: params.attachmentsMarkdown } : {}), }; } diff --git a/apps/frontend/src/rework/components/pages/ManagedChatPage/useChatAttachments.ts b/apps/frontend/src/rework/components/pages/ManagedChatPage/useChatAttachments.ts new file mode 100644 index 0000000000..5b84f48512 --- /dev/null +++ b/apps/frontend/src/rework/components/pages/ManagedChatPage/useChatAttachments.ts @@ -0,0 +1,361 @@ +// Copyright Thales 2026 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useCallback, useMemo, useState } from "react"; +import { useDispatch } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { v4 as uuidv4 } from "uuid"; +import { + useFastIngestKnowledgeFlowV1FastIngestPostMutation, + useUploadUserFileKnowledgeFlowV1StorageUserUploadPostMutation, +} from "../../../../slices/knowledgeFlow/knowledgeFlowOpenApi"; +import { + useDeleteTeamSessionAttachmentControlPlaneV1TeamsTeamIdSessionsSessionIdAttachmentsAttachmentIdDeleteMutation, + useGetTeamSessionAttachmentsControlPlaneV1TeamsTeamIdSessionsSessionIdAttachmentsGetQuery, + usePostTeamSessionAttachmentControlPlaneV1TeamsTeamIdSessionsSessionIdAttachmentsPostMutation, +} from "../../../../slices/controlPlane/controlPlaneOpenApi"; +import { taskEventReceived, taskRegistered } from "../../../features/tasks/taskSlice"; +import type { ChatAttachment, ChatImageContext, SessionAttachment } from "@rework/types/attachments"; + +const UPLOAD_PREFIX = "uploads"; +const MAX_INLINE_IMAGE_BYTES = 4 * 1024 * 1024; +const ALLOWED_INLINE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); + +interface UserStorageUploadResponse { + key?: string; + file_name?: string; + size?: number; +} + +interface UserStorageUploadResult extends UserStorageUploadResponse { + requestedKey: string; +} + +interface FastIngestResponse { + document_uid?: string; + summary_md?: string; +} + +interface SessionAttachmentApiPayload { + attachment_id?: string; + name?: string; + mime?: string | null; + size_bytes?: number | null; + summary_md?: string; + document_uid?: string | null; + storage_key?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +function emitLocalTaskEvent( + dispatch: ReturnType, + taskId: string, + state: "running" | "succeeded" | "failed", + target: { type: string; id: string; label: string } | null, + step: string | null, + error: string | null = null, +) { + dispatch( + taskEventReceived({ + kind: "ingestion", + task_id: taskId, + state, + seq: state === "running" ? 0 : 1, + timestamp: new Date().toISOString(), + progress: state === "succeeded" ? 100 : state === "failed" ? null : 10, + step, + error, + target, + owner: null, + detail: null, + }), + ); +} + +function safeUploadKey(file: File): string { + const cleaned = file.name.replace(/[^\w.\-]+/g, "_").replace(/^_+/, "") || "file"; + return `${UPLOAD_PREFIX}/${Date.now()}-${cleaned}`; +} + +function workspacePath(key: string): string { + return `/workspace/${key}`; +} + +function toSessionAttachment(payload: SessionAttachmentApiPayload): SessionAttachment { + return { + attachmentId: payload.attachment_id ?? "", + name: payload.name ?? "Attachment", + mime: payload.mime ?? undefined, + sizeBytes: payload.size_bytes ?? undefined, + summaryMd: payload.summary_md ?? "", + documentUid: payload.document_uid ?? undefined, + storageKey: payload.storage_key ?? undefined, + workspacePath: payload.storage_key ? workspacePath(payload.storage_key) : undefined, + createdAt: payload.created_at ?? undefined, + updatedAt: payload.updated_at ?? undefined, + }; +} + +function readImageContext(file: File): Promise { + if (!ALLOWED_INLINE_IMAGE_TYPES.has(file.type) || file.size > MAX_INLINE_IMAGE_BYTES) { + return Promise.resolve(undefined); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = typeof reader.result === "string" ? reader.result : ""; + resolve(dataUrl ? { name: file.name, mime: file.type, size: file.size, dataUrl } : undefined); + }; + reader.onerror = () => reject(new Error(`Could not read ${file.name}`)); + reader.readAsDataURL(file); + }); +} + +function buildAttachmentsMarkdown(persisted: SessionAttachment[], transient: ChatAttachment[]): string | null { + const persistedLines = persisted.flatMap((attachment) => + attachment.workspacePath ? [`- ${attachment.name}: ${attachment.workspacePath}`] : [], + ); + const inlineImageLines = transient.flatMap((attachment) => + attachment.imageContext + ? [ + `- ${attachment.name}: inline image (${attachment.imageContext.mime}, ${attachment.imageContext.size} bytes)`, + ` data: ${attachment.imageContext.dataUrl}`, + ] + : [], + ); + + const lines = ["## Attached files for this conversation", ...persistedLines, ...inlineImageLines]; + return lines.length > 1 ? lines.join("\n") : null; +} + +interface UseChatAttachmentsParams { + teamId: string; + sessionId: string | null; +} + +export function useChatAttachments({ teamId, sessionId }: UseChatAttachmentsParams) { + const dispatch = useDispatch(); + const { t } = useTranslation(); + const [attachments, setAttachments] = useState([]); + + const { data: persistedAttachmentsData = [], isFetching: isHydratingAttachments } = + useGetTeamSessionAttachmentsControlPlaneV1TeamsTeamIdSessionsSessionIdAttachmentsGetQuery( + { teamId, sessionId: sessionId ?? "" }, + { skip: !teamId || !sessionId }, + ); + + const [uploadUserFileMutation] = useUploadUserFileKnowledgeFlowV1StorageUserUploadPostMutation(); + const [fastIngestMutation] = useFastIngestKnowledgeFlowV1FastIngestPostMutation(); + const [persistAttachmentMutation] = + usePostTeamSessionAttachmentControlPlaneV1TeamsTeamIdSessionsSessionIdAttachmentsPostMutation(); + const [deletePersistedAttachmentMutation] = + useDeleteTeamSessionAttachmentControlPlaneV1TeamsTeamIdSessionsSessionIdAttachmentsAttachmentIdDeleteMutation(); + + const persistedAttachments = useMemo( + () => (persistedAttachmentsData as SessionAttachmentApiPayload[]).map(toSessionAttachment), + [persistedAttachmentsData], + ); + + const uploadUserFile = useCallback( + async (file: File): Promise => { + const key = safeUploadKey(file); + const formData = new FormData(); + formData.append("key", key); + formData.append("file", file); + const payload = (await uploadUserFileMutation({ + bodyUploadUserFileKnowledgeFlowV1StorageUserUploadPost: formData as never, + }).unwrap()) as UserStorageUploadResponse; + return { ...payload, requestedKey: key }; + }, + [uploadUserFileMutation], + ); + + const fastIngestAttachment = useCallback( + async (file: File, activeSessionId: string | null | undefined): Promise => { + if (!activeSessionId) return null; + + const formData = new FormData(); + formData.append("file", file); + formData.append("session_id", activeSessionId); + formData.append("scope", "session"); + formData.append("options_json", JSON.stringify({ include_summary: true })); + + return (await fastIngestMutation({ + bodyFastIngestKnowledgeFlowV1FastIngestPost: formData as never, + }).unwrap()) as FastIngestResponse; + }, + [fastIngestMutation], + ); + + const deletePersistedAttachment = useCallback( + async (attachmentId: string) => { + if (!teamId || !sessionId) return; + setAttachments((prev) => prev.filter((attachment) => attachment.id !== attachmentId)); + await deletePersistedAttachmentMutation({ + teamId, + sessionId, + attachmentId, + }).unwrap(); + }, + [deletePersistedAttachmentMutation, sessionId, teamId], + ); + + const addFiles = useCallback( + async (files: File[], source: "picker" | "drop", activeSessionId?: string | null) => { + const uniqueFiles = files.filter((file) => file.size > 0); + const ingestionSessionId = activeSessionId ?? sessionId; + for (const file of uniqueFiles) { + if (!ingestionSessionId) continue; + + const id = uuidv4(); + const localTaskId = `chat-attachment-${id}`; + const isImage = file.type.startsWith("image/"); + + dispatch( + taskRegistered({ + taskId: localTaskId, + kind: "ingestion", + target: { type: "attachment", id, label: file.name }, + localOnly: true, + }), + ); + emitLocalTaskEvent( + dispatch, + localTaskId, + "running", + { type: "attachment", id, label: file.name }, + source === "drop" ? t("chatbot.attachments.processingPrepare") : t("chatbot.attachments.processingFast"), + ); + setAttachments((prev) => [ + ...prev, + { + id, + name: file.name, + size: file.size, + mime: file.type, + status: "ingesting", + isImage, + taskIds: [localTaskId], + }, + ]); + + try { + const [upload, imageContext, fastIngest] = await Promise.all([ + uploadUserFile(file), + readImageContext(file), + fastIngestAttachment(file, ingestionSessionId), + ]); + const key = upload.key ?? upload.requestedKey; + + await persistAttachmentMutation({ + teamId, + sessionId: ingestionSessionId, + createSessionAttachmentRequest: { + attachment_id: id, + name: file.name, + mime: file.type || null, + size_bytes: file.size, + summary_md: fastIngest?.summary_md || t("chatbot.sessionAttachments.noSummary"), + document_uid: fastIngest?.document_uid || null, + storage_key: key, + }, + }).unwrap(); + + emitLocalTaskEvent( + dispatch, + localTaskId, + "succeeded", + { + type: fastIngest?.document_uid ? "document" : "attachment", + id: fastIngest?.document_uid ?? id, + label: file.name, + }, + t("chatbot.attachments.processingDone"), + ); + + setAttachments((prev) => + prev.map((attachment) => + attachment.id === id + ? { + ...attachment, + status: "ready", + workspacePath: workspacePath(key), + imageContext, + documentUid: fastIngest?.document_uid, + taskIds: [localTaskId], + } + : attachment, + ), + ); + } catch (err) { + emitLocalTaskEvent( + dispatch, + localTaskId, + "failed", + { type: "attachment", id, label: file.name }, + t("chatbot.attachments.processingFailed"), + err instanceof Error ? err.message : String(err), + ); + setAttachments((prev) => + prev.map((attachment) => + attachment.id === id + ? { ...attachment, status: "error", error: err instanceof Error ? err.message : String(err) } + : attachment, + ), + ); + } + } + }, + [dispatch, fastIngestAttachment, persistAttachmentMutation, sessionId, t, teamId, uploadUserFile], + ); + + const removeAttachment = useCallback( + (id: string) => { + setAttachments((prev) => prev.filter((attachment) => attachment.id !== id)); + const persisted = persistedAttachments.find((attachment) => attachment.attachmentId === id); + if (persisted) { + void deletePersistedAttachment(id); + } + }, + [deletePersistedAttachment, persistedAttachments], + ); + + const clearReadyAttachments = useCallback(() => { + setAttachments((prev) => + prev.filter((attachment) => attachment.status === "uploading" || attachment.status === "ingesting"), + ); + }, []); + + const attachmentsMarkdown = useMemo( + () => + buildAttachmentsMarkdown( + persistedAttachments, + attachments.filter((attachment) => attachment.status === "ready"), + ), + [attachments, persistedAttachments], + ); + + return { + attachments, + persistedAttachments, + isHydratingAttachments, + attachmentsMarkdown, + addFiles, + removeAttachment, + deletePersistedAttachment, + clearReadyAttachments, + }; +} diff --git a/apps/frontend/src/rework/components/pages/ManagedChatPage/useManagedChat.ts b/apps/frontend/src/rework/components/pages/ManagedChatPage/useManagedChat.ts index ac87cf130f..cb50a8c499 100644 --- a/apps/frontend/src/rework/components/pages/ManagedChatPage/useManagedChat.ts +++ b/apps/frontend/src/rework/components/pages/ManagedChatPage/useManagedChat.ts @@ -29,6 +29,7 @@ import { isTraceChannel, textOf } from "../../../../rework/utils/traceUtils"; import type { ThreadMessage } from "@rework/types/thread"; import type { TokenUsage } from "@rework/types/conversation"; import { useSessionHistory } from "./useSessionHistory"; +import { useChatAttachments } from "./useChatAttachments"; import { buildComposerRuntimeContext } from "./runtimeContextBuilder"; // ── Local view model builder ────────────────────────────────────────────────── @@ -165,6 +166,7 @@ export function useManagedChat({ teamId, agentInstanceId }: UseManagedChatParams agentChatOptionsRef.current = agentChatOptions; const composer = useComposerSettings(sessionId, agentChatOptions); + const attachments = useChatAttachments({ teamId, sessionId }); const { data: sessionData } = useGetTeamSessionControlPlaneV1TeamsTeamIdSessionsSessionIdGetQuery( { teamId, sessionId: sessionId ?? "" }, @@ -233,13 +235,38 @@ export function useManagedChat({ teamId, agentInstanceId }: UseManagedChatParams onLoaded: replaceAllMessages, }); + const ensureSessionForAttachments = useCallback((): string => { + let sid = sessionId; + if (!sid) { + sid = uuidv4(); + skipResetOnSessionBindRef.current = true; + bindSessionId(sid); + registerSession({ + teamId, + createSessionRequest: { session_id: sid, agent_instance_id: agentInstanceId, title: "New conversation" }, + }).catch(() => {}); + } + return sid; + }, [agentInstanceId, bindSessionId, registerSession, sessionId, teamId]); + + const handleAddAttachments = useCallback( + (files: File[], source: "picker" | "drop") => { + const sid = ensureSessionForAttachments(); + void attachments.addFiles(files, source, sid); + }, + [attachments.addFiles, ensureSessionForAttachments], + ); + const handleSend = useCallback(() => { const text = input.trim(); + const attachmentContext = attachments.attachmentsMarkdown; console.debug( `[useManagedChat] handleSend() — text="${text.slice(0, 40)}" waitResponse=${waitResponse} sessionId=${sessionId ?? "null"}`, ); - if (!text || waitResponse) { - console.debug(`[useManagedChat] handleSend() BLOCKED — text=${!!text} waitResponse=${waitResponse}`); + if ((!text && !attachmentContext) || waitResponse) { + console.debug( + `[useManagedChat] handleSend() BLOCKED — text=${!!text} attachments=${!!attachmentContext} waitResponse=${waitResponse}`, + ); return; } setInput(""); @@ -252,7 +279,11 @@ export function useManagedChat({ teamId, agentInstanceId }: UseManagedChatParams bindSessionId(sid); registerSession({ teamId, - createSessionRequest: { session_id: sid, agent_instance_id: agentInstanceId, title: text.slice(0, 120) }, + createSessionRequest: { + session_id: sid, + agent_instance_id: agentInstanceId, + title: text ? text.slice(0, 120) : "Attached files", + }, }).catch(() => {}); } console.debug(`[useManagedChat] handleSend() — calling send() with sid=${sid}`); @@ -264,14 +295,21 @@ export function useManagedChat({ teamId, agentInstanceId }: UseManagedChatParams selectedDocumentUids: composer.selectedDocumentUids, searchPolicy: composer.searchPolicy, ragScope: composer.ragScope, + boundLibraryIds: (effectiveChatOptions ?? agentChatOptions)?.bound_library_ids ?? null, + attachmentsMarkdown: attachmentContext, }), ); + attachments.clearReadyAttachments(); }, [ + attachments.attachmentsMarkdown, + attachments.clearReadyAttachments, input, waitResponse, sessionId, teamId, agentInstanceId, + effectiveChatOptions, + agentChatOptions, composer.selectedLibraryIds, composer.selectedDocumentUids, composer.searchPolicy, @@ -315,10 +353,17 @@ export function useManagedChat({ teamId, agentInstanceId }: UseManagedChatParams sessionId, sessionTitle, agentDisplayName, + agentChatOptions, input, setInput, pendingHitl, selectedLibraryIds: composer.selectedLibraryIds, + attachments: attachments.attachments, + persistedAttachments: attachments.persistedAttachments, + isHydratingAttachments: attachments.isHydratingAttachments, + handleAddAttachments, + removeAttachment: attachments.removeAttachment, + deletePersistedAttachment: attachments.deletePersistedAttachment, setSelectedLibraryIds: composer.setSelectedLibraryIds, selectedDocumentUids: composer.selectedDocumentUids, setSelectedDocumentUids: composer.setSelectedDocumentUids, diff --git a/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/DocumentLibraryScopePicker/DocumentLibraryScopePicker.module.css b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/DocumentLibraryScopePicker/DocumentLibraryScopePicker.module.css new file mode 100644 index 0000000000..279d80b037 --- /dev/null +++ b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/DocumentLibraryScopePicker/DocumentLibraryScopePicker.module.css @@ -0,0 +1,109 @@ +.tree { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + margin: 0; + padding: 0; + list-style: none; +} + +.node { + display: flex; + flex-direction: column; + gap: var(--spacing-2xs); +} + +.nodeRow { + display: flex; + align-items: center; + gap: var(--spacing-xs); + border: 1px solid var(--outline-muted); + border-radius: var(--radius-s); + background: var(--surface-container-lowest); + padding: var(--spacing-xs) var(--spacing-s); +} + +.expandButton { + display: inline-flex; + justify-content: center; + align-items: center; + cursor: pointer; + border: 0; + background: transparent; + padding: 0; + color: var(--on-surface-retreat); +} + +.checkbox { + margin: 0; + accent-color: var(--primary); +} + +.folderIcon, +.documentIcon { + color: var(--on-surface-retreat); + font-size: 18px; +} + +.nodeMeta { + display: flex; + flex: 1; + justify-content: space-between; + align-items: center; + gap: var(--spacing-s); + min-width: 0; +} + +.nodeLabel, +.documentName { + overflow: hidden; + color: var(--on-surface); + font: var(--font-body-medium); + text-overflow: ellipsis; + white-space: nowrap; +} + +.nodeCount { + flex-shrink: 0; + color: var(--on-surface-retreat); + font: var(--font-body-small); +} + +.nodeChildren { + display: flex; + flex-direction: column; + gap: var(--spacing-2xs); + margin-left: calc(var(--spacing-l) + var(--spacing-s)); +} + +.documentList { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0 0 0 var(--spacing-m); + list-style: none; +} + +.documentItem { + display: flex; + align-items: center; + gap: var(--spacing-xs); + min-width: 0; +} + +.documentToggle { + display: flex; + align-items: center; + gap: var(--spacing-xs); + cursor: pointer; + width: 100%; + min-width: 0; +} + +.stateText, +.loadingText { + margin: 0; + color: var(--on-surface-retreat); + font: var(--font-body-small); +} diff --git a/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/DocumentLibraryScopePicker/DocumentLibraryScopePicker.tsx b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/DocumentLibraryScopePicker/DocumentLibraryScopePicker.tsx new file mode 100644 index 0000000000..6cbbb966df --- /dev/null +++ b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/DocumentLibraryScopePicker/DocumentLibraryScopePicker.tsx @@ -0,0 +1,257 @@ +// Copyright Thales 2026 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useFrontendBootstrap } from "../../../../../../hooks/useFrontendBootstrap"; +import { buildTree, type TagNode } from "../../../../../../shared/utils/tagTree"; +import type { DocumentMetadata } from "../../../../../../slices/knowledgeFlow/knowledgeFlowOpenApi"; +import { + TagType, + useBrowseDocumentsByTagKnowledgeFlowV1DocumentsMetadataBrowsePostMutation, + useListAllTagsKnowledgeFlowV1TagsGetQuery, +} from "../../../../../../slices/knowledgeFlow/knowledgeFlowOpenApi"; +import styles from "./DocumentLibraryScopePicker.module.css"; + +interface DocumentLibraryScopePickerProps { + teamId?: string; + selectedTagIds: string[]; + onChange: (tagIds: string[]) => void; + selectedDocumentUids?: string[]; + onDocumentsChange?: (documentUids: string[]) => void; + disableLibrarySelection?: boolean; +} + +function findPrimaryTagId(node: TagNode): string | null { + return node.tagsHere?.[0]?.id ?? null; +} + +function collectTagIds(node: TagNode): string[] { + const ids = new Set(); + const walk = (current: TagNode) => { + current.tagsHere?.forEach((tag) => ids.add(tag.id)); + current.children.forEach((child) => walk(child)); + }; + walk(node); + return Array.from(ids); +} + +function collectDocumentIds(node: TagNode): string[] { + const ids = new Set(); + const walk = (current: TagNode) => { + current.tagsHere?.forEach((tag) => { + for (const itemId of tag.item_ids ?? []) { + ids.add(itemId); + } + }); + current.children.forEach((child) => walk(child)); + }; + walk(node); + return Array.from(ids); +} + +export function DocumentLibraryScopePicker({ + teamId, + selectedTagIds, + onChange, + selectedDocumentUids, + onDocumentsChange, + disableLibrarySelection = false, +}: DocumentLibraryScopePickerProps) { + const { t } = useTranslation(); + const { activeTeam } = useFrontendBootstrap(); + const isPersonalTeam = !teamId || teamId === activeTeam?.id; + const [expanded, setExpanded] = useState([]); + const [documentsByTagId, setDocumentsByTagId] = useState>({}); + const [loadingTagIds, setLoadingTagIds] = useState>({}); + + const { data: allTags = [], isLoading } = useListAllTagsKnowledgeFlowV1TagsGetQuery({ + type: "document" as TagType, + limit: 10000, + offset: 0, + ownerFilter: isPersonalTeam ? "personal" : "team", + teamId: isPersonalTeam ? undefined : teamId, + }); + const [browseDocumentsByTag] = useBrowseDocumentsByTagKnowledgeFlowV1DocumentsMetadataBrowsePostMutation(); + const documentSelectionEnabled = Array.isArray(selectedDocumentUids) && typeof onDocumentsChange === "function"; + + const tree = useMemo(() => buildTree(allTags), [allTags]); + + const setTagLoading = useCallback((tagId: string, loading: boolean) => { + setLoadingTagIds((prev) => { + const next = { ...prev }; + if (loading) next[tagId] = true; + else delete next[tagId]; + return next; + }); + }, []); + + const loadDocumentsForTag = useCallback( + async (tagId: string) => { + if (documentsByTagId[tagId] || loadingTagIds[tagId]) return; + setTagLoading(tagId, true); + try { + const response = await browseDocumentsByTag({ + browseDocumentsByTagRequest: { + tag_id: tagId, + offset: 0, + limit: 20, + }, + }).unwrap(); + setDocumentsByTagId((prev) => ({ ...prev, [tagId]: response.documents ?? [] })); + } finally { + setTagLoading(tagId, false); + } + }, + [browseDocumentsByTag, documentsByTagId, loadingTagIds, setTagLoading], + ); + + useEffect(() => { + expanded.forEach((path) => { + const node = path + .split("/") + .filter(Boolean) + .reduce((current, segment) => current?.children.get(segment) ?? null, tree); + const tagId = node ? findPrimaryTagId(node) : null; + if (tagId) void loadDocumentsForTag(tagId); + }); + }, [expanded, loadDocumentsForTag, tree]); + + const toggleExpand = (path: string) => { + setExpanded((prev) => (prev.includes(path) ? prev.filter((item) => item !== path) : [...prev, path])); + }; + + const toggleNodeSelection = (node: TagNode) => { + if (disableLibrarySelection) return; + const tagIds = collectTagIds(node); + const allSelected = tagIds.every((id) => selectedTagIds.includes(id)); + if (allSelected) { + onChange(selectedTagIds.filter((id) => !tagIds.includes(id))); + return; + } + onChange(Array.from(new Set([...selectedTagIds, ...tagIds]))); + }; + + const toggleDocumentSelection = (documentUid: string, checked: boolean) => { + if (!documentSelectionEnabled || !selectedDocumentUids || !onDocumentsChange) return; + if (checked) { + onDocumentsChange(Array.from(new Set([...selectedDocumentUids, documentUid]))); + return; + } + onDocumentsChange(selectedDocumentUids.filter((uid) => uid !== documentUid)); + }; + + const renderNode = (node: TagNode): ReactNode[] => + Array.from(node.children.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((child) => { + const childTagIds = collectTagIds(child); + const childDocumentIds = collectDocumentIds(child); + const selectedCount = childTagIds.filter((id) => selectedTagIds.includes(id)).length; + const isChecked = childTagIds.length > 0 && selectedCount === childTagIds.length; + const hasSelectedDocument = + (selectedDocumentUids?.some((documentUid) => childDocumentIds.includes(documentUid)) ?? false) && !isChecked; + const isIndeterminate = (selectedCount > 0 && selectedCount < childTagIds.length) || hasSelectedDocument; + const isExpanded = expanded.includes(child.full); + const tagId = findPrimaryTagId(child); + const docs = tagId ? (documentsByTagId[tagId] ?? []) : []; + const isLoadingDocs = tagId ? Boolean(loadingTagIds[tagId]) : false; + + return ( +
  • +
    + + { + if (input) input.indeterminate = isIndeterminate; + }} + onChange={() => toggleNodeSelection(child)} + /> + + {isExpanded ? "folder_open" : "folder"} + +
    + {child.name} + + {child.tagsHere?.[0]?.item_ids?.length ?? docs.length} {t("rework.documents", "documents")} + +
    +
    + + {isExpanded && ( +
    + {docs.length > 0 && ( +
      + {docs.map((doc) => { + const documentUid = doc.identity.document_uid; + const checked = selectedDocumentUids?.includes(documentUid) ?? false; + return ( +
    • + {documentSelectionEnabled ? ( + + ) : ( + <> + + description + + {doc.identity.document_name} + + )} +
    • + ); + })} +
    + )} + {isLoadingDocs &&

    {t("rework.loading", "Loading...")}

    } + {renderNode(child)} +
    + )} +
  • + ); + }); + + if (isLoading) { + return
    {t("rework.loading", "Loading...")}
    ; + } + + if (tree.children.size === 0) { + return
    {t("agentTuning.fields.library_binding.noLibraries")}
    ; + } + + return
      {renderNode(tree)}
    ; +} diff --git a/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/KfVectorSearchForm/KfVectorSearchForm.module.css b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/KfVectorSearchForm/KfVectorSearchForm.module.css index 604d39804e..db6c49b36a 100644 --- a/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/KfVectorSearchForm/KfVectorSearchForm.module.css +++ b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/KfVectorSearchForm/KfVectorSearchForm.module.css @@ -90,3 +90,9 @@ color: var(--on-surface); font: var(--font-body-medium); } + +.scopePickerSection { + display: flex; + flex-direction: column; + gap: var(--spacing-s); +} diff --git a/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/KfVectorSearchForm/KfVectorSearchForm.tsx b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/KfVectorSearchForm/KfVectorSearchForm.tsx index 68cb6c0b9e..0430f5962b 100644 --- a/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/KfVectorSearchForm/KfVectorSearchForm.tsx +++ b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentCreateEditModal/KfVectorSearchForm/KfVectorSearchForm.tsx @@ -22,6 +22,7 @@ import { TagType, useListAllTagsKnowledgeFlowV1TagsGetQuery, } from "src/slices/knowledgeFlow/knowledgeFlowOpenApi"; +import { DocumentLibraryScopePicker } from "../DocumentLibraryScopePicker/DocumentLibraryScopePicker"; import { SwitchRow } from "../SwitchRow/SwitchRow.tsx"; import styles from "./KfVectorSearchForm.module.css"; @@ -103,6 +104,24 @@ export function KfVectorSearchForm({ params, onParamsChange, teamId }: ToolParam /> )} + {(bindingEnabled || params.libraries_selection) && ( +
    +
    + + {bindingEnabled + ? t("agentTuning.fields.library_binding.title") + : t("agentTuning.fields.chat_options_libraries_selection.title")} + + {t("agentTuning.fields.library_scope_picker.description")} +
    + onParamsChange({ ...params, document_library_tags_ids: tagIds })} + /> +
    + )} + onMcpConfigChange(server.id, key, val)} + onTuningChange={onTuningChange} /> ); })} diff --git a/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentFormModal/McpServerCard/McpServerCard.module.css b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentFormModal/McpServerCard/McpServerCard.module.css index b4be14a635..1dab40d692 100644 --- a/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentFormModal/McpServerCard/McpServerCard.module.css +++ b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentFormModal/McpServerCard/McpServerCard.module.css @@ -73,6 +73,17 @@ padding: var(--spacing-m); } +.booleanField { + display: flex; + flex-direction: column; + gap: var(--spacing-s); +} + +.libraryPickerBlock { + border-left: 1px solid var(--outline-muted); + padding-left: var(--spacing-m); +} + /* Enum field row: label column left, pill group right */ .fieldRow { display: flex; diff --git a/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentFormModal/McpServerCard/McpServerCard.tsx b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentFormModal/McpServerCard/McpServerCard.tsx index 549fddb0b4..c0b7181543 100644 --- a/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentFormModal/McpServerCard/McpServerCard.tsx +++ b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentFormModal/McpServerCard/McpServerCard.tsx @@ -15,21 +15,26 @@ import Switch from "@shared/atoms/Switch/Switch.tsx"; import ButtonGroup from "@shared/atoms/ButtonGroup/ButtonGroup.tsx"; import { SwitchRow } from "@components/pages/TeamAgentsPage/AgentCreateEditModal/SwitchRow/SwitchRow.tsx"; +import { DocumentLibraryScopePicker } from "@components/pages/TeamAgentsPage/AgentCreateEditModal/DocumentLibraryScopePicker/DocumentLibraryScopePicker"; import { useTranslation } from "react-i18next"; import type { ManagedAgentFieldSpec, ManagedMcpServerRef, } from "../../../../../../slices/controlPlane/controlPlaneOpenApi.ts"; +import { CHAT_OPTION_FIELD_KEYS, serverCarriesChatOptions } from "../chatOptionsConfig"; import styles from "./McpServerCard.module.css"; interface McpServerCardProps { server: ManagedMcpServerRef; + teamId?: string; checked: boolean; disabled: boolean; /** Per-server config values keyed by the field's local key (matches config_fields[].key). */ configValues: Record; + tuningValues: Record; onToggle: () => void; onConfigChange: (key: string, value: unknown) => void; + onTuningChange: (key: string, value: unknown) => void; } function resolveValue(field: ManagedAgentFieldSpec, configValues: Record): unknown { @@ -43,28 +48,38 @@ function resolveValue(field: ManagedAgentFieldSpec, configValues: Record 0; + const showAttachFilesOption = checked && serverCarriesChatOptions(configFields); const isLocked = server.locked === true; const displayLabel = server.display_name ? t(server.display_name) : server.id; + const librariesBindingEnabled = Boolean(configValues[CHAT_OPTION_FIELD_KEYS.librariesBinding]); + const selectedBoundLibraryIds = Array.isArray(configValues[CHAT_OPTION_FIELD_KEYS.boundLibraryIds]) + ? (configValues[CHAT_OPTION_FIELD_KEYS.boundLibraryIds] as string[]) + : []; + const searchPolicyEnabled = Boolean(configValues[CHAT_OPTION_FIELD_KEYS.searchPolicyEnabled]); + const ragScopeEnabled = Boolean(configValues[CHAT_OPTION_FIELD_KEYS.searchRagScopeEnabled]); const enumOptionLabels: Record> = { - "chat_options.search_policy": { - strict: t("search.strict", "Strict"), - hybrid: t("search.hybrid", "Hybrid"), - semantic: t("search.semantic", "Semantic"), + [CHAT_OPTION_FIELD_KEYS.searchPolicy]: { + strict: t("search.strict"), + hybrid: t("search.hybrid"), + semantic: t("search.semantic"), }, - "chat_options.search_rag_scope": { - corpus_only: t("chatbot.ragScope.corpusOnly", "Corpus only"), - hybrid: t("chatbot.ragScope.hybrid", "Hybrid"), - general_only: t("chatbot.ragScope.generalOnly", "General only"), + [CHAT_OPTION_FIELD_KEYS.searchRagScope]: { + corpus_only: t("chatbot.composerSettings.scopeCorpus"), + hybrid: t("chatbot.composerSettings.scopeCorpusAndWeb"), + general_only: t("chatbot.composerSettings.scopeGeneral"), }, }; @@ -76,7 +91,7 @@ export function McpServerCard({
    {displayLabel} - {isLocked && required} + {isLocked && {t("required")}} {server.require_tools && server.require_tools.length > 0 && ( {server.require_tools.join(", ")} )} @@ -85,18 +100,110 @@ export function McpServerCard({ {hasOptions && (
    + {showAttachFilesOption && ( + onTuningChange(CHAT_OPTION_FIELD_KEYS.attachFiles, value)} + /> + )} +
    + { + onConfigChange(CHAT_OPTION_FIELD_KEYS.librariesBinding, value); + if (value) { + onConfigChange(CHAT_OPTION_FIELD_KEYS.librariesSelection, false); + } else { + onConfigChange(CHAT_OPTION_FIELD_KEYS.boundLibraryIds, []); + } + }} + /> + {librariesBindingEnabled && ( +
    + onConfigChange(CHAT_OPTION_FIELD_KEYS.boundLibraryIds, tagIds)} + /> +
    + )} +
    {configFields.map((field) => { const value = resolveValue(field, configValues); + if (field.key === CHAT_OPTION_FIELD_KEYS.librariesBinding) { + return null; + } + + if (field.key === CHAT_OPTION_FIELD_KEYS.searchPolicy) { + if (!searchPolicyEnabled || !field.enum || field.enum.length === 0) return null; + const labels = enumOptionLabels[field.key] ?? {}; + const selectedIndex = Math.max(0, field.enum.indexOf(value as string)); + return ( +
    +
    + {field.title} + {field.description && {field.description}} +
    + onConfigChange(field.key, field.enum![i])} + items={field.enum.map((opt) => ({ + label: labels[opt] ?? opt.replace(/_/g, " "), + }))} + /> +
    + ); + } + + if (field.key === CHAT_OPTION_FIELD_KEYS.searchRagScope) { + if (!ragScopeEnabled || !field.enum || field.enum.length === 0) return null; + const labels = enumOptionLabels[field.key] ?? {}; + const selectedIndex = Math.max(0, field.enum.indexOf(value as string)); + return ( +
    +
    + {field.title} + {field.description && {field.description}} +
    + onConfigChange(field.key, field.enum![i])} + items={field.enum.map((opt) => ({ + label: labels[opt] ?? opt.replace(/_/g, " "), + }))} + /> +
    + ); + } + if (field.type === "boolean") { + if (field.key === CHAT_OPTION_FIELD_KEYS.librariesSelection && librariesBindingEnabled) { + return null; + } return ( - onConfigChange(field.key, v)} - /> +
    + { + onConfigChange(field.key, v); + if (field.key === CHAT_OPTION_FIELD_KEYS.librariesSelection && v) { + onConfigChange(CHAT_OPTION_FIELD_KEYS.librariesBinding, false); + onConfigChange(CHAT_OPTION_FIELD_KEYS.boundLibraryIds, []); + } + }} + /> +
    ); } diff --git a/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentFormModal/chatOptionsConfig.ts b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentFormModal/chatOptionsConfig.ts new file mode 100644 index 0000000000..1eabe8a5ea --- /dev/null +++ b/apps/frontend/src/rework/components/pages/TeamAgentsPage/AgentFormModal/chatOptionsConfig.ts @@ -0,0 +1,54 @@ +// Copyright Thales 2026 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ManagedAgentFieldSpec } from "../../../../../slices/controlPlane/controlPlaneOpenApi"; + +export const CHAT_OPTION_FIELD_KEYS = { + attachFiles: "chat_options.attach_files", + librariesBinding: "chat_options.libraries_binding", + librariesSelection: "chat_options.libraries_selection", + documentsSelection: "chat_options.documents_selection", + boundLibraryIds: "chat_options.bound_library_ids", + searchPolicyEnabled: "chat_options.search_policy_enabled", + searchPolicy: "chat_options.search_policy", + searchRagScopeEnabled: "chat_options.search_rag_scope_enabled", + searchRagScope: "chat_options.search_rag_scope", +} as const; + +export const CHAT_OPTION_CONFIG_KEYS = Object.values(CHAT_OPTION_FIELD_KEYS); + +export type ChatOptionConfigKey = (typeof CHAT_OPTION_CONFIG_KEYS)[number]; +export type ChatRagScope = "corpus_only" | "hybrid" | "general_only"; + +export function isChatOptionConfigKey(value: string): value is ChatOptionConfigKey { + return CHAT_OPTION_CONFIG_KEYS.includes(value as ChatOptionConfigKey); +} + +export function isChatOptionField(field: ManagedAgentFieldSpec): boolean { + return isChatOptionConfigKey(field.key); +} + +export function serverCarriesChatOptions(fields: ManagedAgentFieldSpec[]): boolean { + const serverScopedKeys = new Set([ + CHAT_OPTION_FIELD_KEYS.attachFiles, + CHAT_OPTION_FIELD_KEYS.librariesBinding, + CHAT_OPTION_FIELD_KEYS.librariesSelection, + CHAT_OPTION_FIELD_KEYS.documentsSelection, + CHAT_OPTION_FIELD_KEYS.searchPolicyEnabled, + CHAT_OPTION_FIELD_KEYS.searchPolicy, + CHAT_OPTION_FIELD_KEYS.searchRagScopeEnabled, + CHAT_OPTION_FIELD_KEYS.searchRagScope, + ]); + return fields.some((field) => serverScopedKeys.has(field.key)); +} diff --git a/apps/frontend/src/rework/components/pages/admin/MigrationPage/MigrationPage.module.css b/apps/frontend/src/rework/components/pages/admin/MigrationPage/MigrationPage.module.css new file mode 100644 index 0000000000..e9ee37cf4f --- /dev/null +++ b/apps/frontend/src/rework/components/pages/admin/MigrationPage/MigrationPage.module.css @@ -0,0 +1,116 @@ +/* Copyright Thales 2026 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.page { + display: flex; + flex-direction: column; + gap: var(--spacing-l); + padding: var(--spacing-xl); + max-width: 960px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-m); +} + +.title { + margin: 0; + color: var(--on-surface); + font: var(--font-title-large); +} + +.uploadCard { + display: flex; + flex-direction: column; + gap: var(--spacing-m); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-m); + background: var(--surface-container-low); + padding: var(--spacing-l); +} + +.dropzone { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: var(--spacing-s); + cursor: pointer; + border: 1px dashed var(--outline); + border-radius: var(--radius-s); + background: none; + padding: var(--spacing-xl); + color: var(--on-surface-retreat); + font: var(--font-body-medium); + text-align: center; +} + +.dropzone[data-active="true"] { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, transparent); +} + +.dropIcon { + opacity: 0.7; + font-size: 1.6rem; +} + +.actions { + display: flex; + justify-content: flex-end; +} + +.errorText { + color: var(--error); + font: var(--font-body-small); +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-s); + margin-top: var(--spacing-xl); + color: var(--on-surface-retreat); + font: var(--font-body-medium); +} + +.emptyIcon { + opacity: 0.4; + font-size: 2rem; +} + +.section { + display: flex; + flex-direction: column; + gap: var(--spacing-s); +} + +.sectionTitle { + margin: 0; + color: var(--on-surface-retreat); + font: var(--font-label-medium); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.grid { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-s); +} diff --git a/apps/frontend/src/rework/components/pages/admin/MigrationPage/MigrationPage.tsx b/apps/frontend/src/rework/components/pages/admin/MigrationPage/MigrationPage.tsx new file mode 100644 index 0000000000..0c5c0434e0 --- /dev/null +++ b/apps/frontend/src/rework/components/pages/admin/MigrationPage/MigrationPage.tsx @@ -0,0 +1,137 @@ +// Copyright Thales 2026 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useDropzone } from "react-dropzone"; +import Button from "@shared/atoms/Button/Button.tsx"; +import TextInput from "@shared/atoms/TextInput/TextInput.tsx"; +import { TaskCard } from "@shared/molecules/TaskCard/TaskCard"; +import { selectVisibleTasks, taskRegistered } from "../../../../features/tasks/taskSlice"; +import { launchPlatformImport } from "../../../../features/migration/launchPlatformImport"; +import styles from "./MigrationPage.module.css"; + +export default function MigrationPage() { + const dispatch = useDispatch(); + const tasks = useSelector(selectVisibleTasks); + const [file, setFile] = useState(null); + const [label, setLabel] = useState(""); + const [isLaunching, setIsLaunching] = useState(false); + const [error, setError] = useState(null); + + const migrationTasks = useMemo(() => tasks.filter((t) => t.kind === "migration"), [tasks]); + const activeTasks = migrationTasks.filter( + (t) => t.state === "running" || t.state === "pending" || t.state === "cancelling", + ); + const terminalTasks = migrationTasks.filter( + (t) => t.state === "succeeded" || t.state === "failed" || t.state === "cancelled", + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + noKeyboard: true, + multiple: false, + accept: [".zip", "application/zip", "application/x-zip-compressed"], + onDrop: (accepted) => { + if (accepted.length > 0) { + setFile(accepted[0]); + setError(null); + } + }, + }); + + const handleLaunch = async () => { + if (!file || isLaunching) return; + setIsLaunching(true); + setError(null); + try { + const { taskId, importId } = await launchPlatformImport(file, label); + dispatch( + taskRegistered({ + taskId, + kind: "migration", + target: { type: "platform", id: importId, label: file.name }, + }), + ); + setFile(null); + setLabel(""); + } catch (err) { + setError((err as Error).message); + } finally { + setIsLaunching(false); + } + }; + + return ( +
    +
    +

    Migration de plateforme

    +
    + +
    +
    + + 📦 + {file ? file.name : "Glissez un export kea .zip, ou cliquez pour le sélectionner"} +
    + + setLabel(e.target.value)} + placeholder="ex. castle-prod" + disabled={isLaunching} + /> + + {error && {error}} + +
    + +
    +
    + + {migrationTasks.length === 0 ? ( +
    + + Aucune migration en cours +
    + ) : ( + <> + {activeTasks.length > 0 && ( +
    +

    En cours

    +
    + {activeTasks.map((t) => ( + + ))} +
    +
    + )} + + {terminalTasks.length > 0 && ( +
    +

    Terminées

    +
    + {terminalTasks.map((t) => ( + + ))} +
    +
    + )} + + )} +
    + ); +} diff --git a/apps/frontend/src/rework/components/shared/layouts/Sidebar/AdminNavbar/AdminNavbar.tsx b/apps/frontend/src/rework/components/shared/layouts/Sidebar/AdminNavbar/AdminNavbar.tsx index b4c8e9bf6d..633c9c14f1 100644 --- a/apps/frontend/src/rework/components/shared/layouts/Sidebar/AdminNavbar/AdminNavbar.tsx +++ b/apps/frontend/src/rework/components/shared/layouts/Sidebar/AdminNavbar/AdminNavbar.tsx @@ -37,6 +37,12 @@ export default function AdminNavbar() { linkProps: { to: "/admin/tasks" }, badge: activeTaskCount > 0 ? activeTaskCount : undefined, }, + { + type: "link", + label: "Migration", + icon: { category: "outlined", type: "sync_alt", filled: false }, + linkProps: { to: "/admin/migration" }, + }, ]; return ( diff --git a/apps/frontend/src/rework/components/shared/molecules/AttachmentChips/AttachmentChips.module.css b/apps/frontend/src/rework/components/shared/molecules/AttachmentChips/AttachmentChips.module.css new file mode 100644 index 0000000000..0841147626 --- /dev/null +++ b/apps/frontend/src/rework/components/shared/molecules/AttachmentChips/AttachmentChips.module.css @@ -0,0 +1,86 @@ +.chips { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: var(--spacing-2xs); + padding-bottom: 2px; + width: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} + +.chip { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: var(--spacing-2xs); + border: 1px solid var(--outline-retreat); + border-radius: var(--radius-s); + background: var(--surface-container-low); + padding: var(--spacing-2xs) var(--spacing-xs); + max-width: 18rem; + color: var(--on-surface); +} + +.chip[data-status="error"] { + border-color: var(--error); + background: var(--error-container); + color: var(--on-error-container); +} + +.icon { + display: inline-flex; + flex-shrink: 0; + color: var(--on-surface-retreat); +} + +.chip[data-status="error"] .icon { + color: var(--on-error-container); +} + +.text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.name { + overflow: hidden; + font: var(--font-label-medium); + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta { + color: var(--on-surface-retreat); + font: var(--font-body-small); +} + +.chip[data-status="error"] .meta { + color: var(--on-error-container); +} + +.remove { + display: inline-flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + cursor: pointer; + border: none; + border-radius: var(--radius-xs); + background: transparent; + padding: 0; + width: 1.25rem; + height: 1.25rem; + color: currentColor; +} + +.remove:hover { + background: var(--state-on-surface-hover); +} + +.remove:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} diff --git a/apps/frontend/src/rework/components/shared/molecules/AttachmentChips/AttachmentChips.tsx b/apps/frontend/src/rework/components/shared/molecules/AttachmentChips/AttachmentChips.tsx new file mode 100644 index 0000000000..7ed36a58a8 --- /dev/null +++ b/apps/frontend/src/rework/components/shared/molecules/AttachmentChips/AttachmentChips.tsx @@ -0,0 +1,90 @@ +// Copyright Thales 2026 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; +import Icon from "@shared/atoms/Icon/Icon"; +import { TaskIndicator } from "@shared/molecules/TaskIndicator/TaskIndicator"; +import { TERMINAL_STATES, type TaskState } from "../../../../features/tasks/taskTypes"; +import type { ChatAttachment } from "@rework/types/attachments"; +import styles from "./AttachmentChips.module.css"; + +interface AttachmentChipsProps { + attachments: ChatAttachment[]; + onRemove: (id: string) => void; +} + +interface TasksRootState { + tasks: { + byId: Record< + string, + { + taskId: string; + state: TaskState; + } + >; + }; +} + +function fileLabel(attachment: ChatAttachment, t: (key: string, options?: Record) => string): string { + if (attachment.status === "uploading") return t("chatbot.attachmentChip.uploading"); + if (attachment.status === "error") return t("chatbot.attachmentChip.failed"); + if (attachment.status === "ingesting") return t("chatbot.attachmentChip.processing"); + return attachment.isImage ? t("chatbot.attachmentChip.image") : t("chatbot.attachmentChip.file"); +} + +function AttachmentTaskStatus({ taskIds }: { taskIds: string[] }) { + const displayTaskId = useSelector((state: TasksRootState) => { + const tasks = taskIds.map((taskId) => state.tasks.byId[taskId]).filter(Boolean); + const activeTask = tasks.find((task) => !TERMINAL_STATES.has(task.state)); + const lastTask = tasks.length > 0 ? tasks[tasks.length - 1] : null; + return activeTask?.taskId ?? lastTask?.taskId ?? null; + }); + + if (!displayTaskId) return null; + return ; +} + +export function AttachmentChips({ attachments, onRemove }: AttachmentChipsProps) { + const { t } = useTranslation(); + + if (attachments.length === 0) return null; + + return ( +
    + {attachments.map((attachment) => ( + + + + + + + {attachment.name} + + {attachment.taskIds.length === 0 && {fileLabel(attachment, t)}} + + + + + ))} +
    + ); +} diff --git a/apps/frontend/src/rework/components/shared/molecules/ComposerActionsMenu/ComposerActionsMenu.module.css b/apps/frontend/src/rework/components/shared/molecules/ComposerActionsMenu/ComposerActionsMenu.module.css new file mode 100644 index 0000000000..d2a65046a9 --- /dev/null +++ b/apps/frontend/src/rework/components/shared/molecules/ComposerActionsMenu/ComposerActionsMenu.module.css @@ -0,0 +1,14 @@ +.container { + position: relative; + flex-shrink: 0; +} + +.menu { + position: absolute; + bottom: calc(100% + var(--spacing-xs)); + left: 0; +} + +.menuBody { + display: flex; +} diff --git a/apps/frontend/src/rework/components/shared/molecules/ComposerActionsMenu/ComposerActionsMenu.tsx b/apps/frontend/src/rework/components/shared/molecules/ComposerActionsMenu/ComposerActionsMenu.tsx new file mode 100644 index 0000000000..037a8041df --- /dev/null +++ b/apps/frontend/src/rework/components/shared/molecules/ComposerActionsMenu/ComposerActionsMenu.tsx @@ -0,0 +1,75 @@ +// Copyright Thales 2026 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactNode, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import IconButton from "@shared/atoms/IconButton/IconButton"; +import styles from "./ComposerActionsMenu.module.css"; + +interface ComposerActionsMenuProps { + disabled?: boolean; + children?: ReactNode | ((controls: { closeMenu: () => void }) => ReactNode); +} + +export function ComposerActionsMenu({ disabled = false, children }: ComposerActionsMenuProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const closeMenu = () => setOpen(false); + + useEffect(() => { + if (disabled && open) { + setOpen(false); + } + }, [disabled, open]); + + useEffect(() => { + if (!open) return; + const handleMouseDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", handleMouseDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleMouseDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + return ( +
    + setOpen((value) => !value)} + /> + {open && ( +
    + {children ? ( +
    {typeof children === "function" ? children({ closeMenu }) : children}
    + ) : null} +
    + )} +
    + ); +} diff --git a/apps/frontend/src/rework/components/shared/molecules/InlineDrawer/InlineDrawer.module.css b/apps/frontend/src/rework/components/shared/molecules/InlineDrawer/InlineDrawer.module.css index aa2b84dd8a..39703d84ba 100644 --- a/apps/frontend/src/rework/components/shared/molecules/InlineDrawer/InlineDrawer.module.css +++ b/apps/frontend/src/rework/components/shared/molecules/InlineDrawer/InlineDrawer.module.css @@ -1,3 +1,20 @@ +.backdrop { + position: fixed; + visibility: hidden; + opacity: 0; + z-index: 9; + transition: + opacity 250ms ease-out, + visibility 250ms; + inset: 0; + background: var(--scrim); +} + +.backdrop[data-open="true"] { + visibility: visible; + opacity: 1; +} + .drawer { display: flex; position: fixed; diff --git a/apps/frontend/src/rework/components/shared/molecules/InlineDrawer/InlineDrawer.tsx b/apps/frontend/src/rework/components/shared/molecules/InlineDrawer/InlineDrawer.tsx index 7c2c78e125..6256e4ad21 100644 --- a/apps/frontend/src/rework/components/shared/molecules/InlineDrawer/InlineDrawer.tsx +++ b/apps/frontend/src/rework/components/shared/molecules/InlineDrawer/InlineDrawer.tsx @@ -43,27 +43,30 @@ export function InlineDrawer({ }, [open, onClose]); return ( - + <> +
    + + ); } diff --git a/apps/frontend/src/rework/components/shared/molecules/MarkdownPreviewModal/MarkdownPreviewModal.module.css b/apps/frontend/src/rework/components/shared/molecules/MarkdownPreviewModal/MarkdownPreviewModal.module.css new file mode 100644 index 0000000000..bea4a3a8b8 --- /dev/null +++ b/apps/frontend/src/rework/components/shared/molecules/MarkdownPreviewModal/MarkdownPreviewModal.module.css @@ -0,0 +1,77 @@ +.overlay { + display: flex; + flex: 1; + justify-content: center; + width: 100%; +} + +.shell { + display: flex; + flex: 1; + flex-direction: column; + margin: 0 auto; + padding: var(--spacing-l) var(--spacing-l) var(--spacing-xl); + width: min(1100px, 100%); + min-height: 0; + color: var(--on-surface); +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-m); + padding-bottom: var(--spacing-m); +} + +.headerText { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--spacing-2xs); + min-width: 0; +} + +.title { + margin: 0; + color: var(--on-surface); + font: var(--font-title-large); +} + +.subtitle { + margin: 0; + color: var(--on-surface-retreat); + font: var(--font-body-medium); +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-s); + padding-bottom: var(--spacing-m); +} + +.body { + flex: 1; + border: 1px solid var(--outline-retreat); + border-radius: var(--radius-m); + background: var(--surface-container-low); + padding: var(--spacing-l); + min-height: 0; + overflow-y: auto; +} + +.empty { + color: var(--on-surface-retreat); + font: var(--font-body-medium); +} + +@media (max-width: 768px) { + .shell { + padding: var(--spacing-m) var(--spacing-m) var(--spacing-l); + } + + .body { + padding: var(--spacing-m); + } +} diff --git a/apps/frontend/src/rework/components/shared/molecules/MarkdownPreviewModal/MarkdownPreviewModal.tsx b/apps/frontend/src/rework/components/shared/molecules/MarkdownPreviewModal/MarkdownPreviewModal.tsx new file mode 100644 index 0000000000..a7ba4cc4e7 --- /dev/null +++ b/apps/frontend/src/rework/components/shared/molecules/MarkdownPreviewModal/MarkdownPreviewModal.tsx @@ -0,0 +1,74 @@ +// Copyright Thales 2026 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import IconButton from "@shared/atoms/IconButton/IconButton"; +import { FullPageModal } from "../FullPageModal/FullPageModal"; +import { MarkdownRenderer } from "../MarkdownRenderer/MarkdownRenderer"; +import styles from "./MarkdownPreviewModal.module.css"; + +interface MarkdownPreviewModalProps { + open: boolean; + onClose: () => void; + title: string; + subtitle?: string | null; + markdown?: string | null; + emptyLabel?: string; + meta?: ReactNode; +} + +export function MarkdownPreviewModal({ + open, + onClose, + title, + subtitle, + markdown, + emptyLabel, + meta, +}: MarkdownPreviewModalProps) { + const { t } = useTranslation(); + const resolvedEmptyLabel = emptyLabel ?? t("chatbot.markdownPreview.empty"); + + return ( + + + + ); +} diff --git a/apps/frontend/src/rework/components/shared/molecules/RichInputField/RichInputField.module.css b/apps/frontend/src/rework/components/shared/molecules/RichInputField/RichInputField.module.css index ed11a59588..85381f57eb 100644 --- a/apps/frontend/src/rework/components/shared/molecules/RichInputField/RichInputField.module.css +++ b/apps/frontend/src/rework/components/shared/molecules/RichInputField/RichInputField.module.css @@ -44,8 +44,23 @@ background: var(--state-surface-main-disabled); } +.aboveTextSlot { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2xs); + margin-bottom: var(--spacing-2xs); +} + +.inlineRow { + display: flex; + align-items: center; + gap: var(--spacing-xs); + min-height: 36px; +} + .textarea { display: block; + flex: 1; outline: none; border: none; background: transparent; @@ -81,6 +96,12 @@ gap: var(--spacing-2xs); } +.commandSlot { + display: flex; + flex-shrink: 0; + align-items: center; +} + .rightSlot { display: flex; flex-shrink: 0; diff --git a/apps/frontend/src/rework/components/shared/molecules/RichInputField/RichInputField.tsx b/apps/frontend/src/rework/components/shared/molecules/RichInputField/RichInputField.tsx index 9c1186e1b7..a5a4d6e1aa 100644 --- a/apps/frontend/src/rework/components/shared/molecules/RichInputField/RichInputField.tsx +++ b/apps/frontend/src/rework/components/shared/molecules/RichInputField/RichInputField.tsx @@ -28,12 +28,18 @@ interface RichInputFieldProps { onInterrupt?: () => void; disabled?: boolean; placeholder?: string; + /** Rendered above the textarea — typically attachment chips that should stay close to the cursor. */ + aboveTextSlot?: ReactNode; /** Rendered in the bottom-left area — context pickers, scope selectors, attachment chips. */ topSlot?: ReactNode; + /** Rendered next to the textarea controls — one compact command such as attach-file. */ + leftSlot?: ReactNode; /** Rendered to the right of the textarea — replaces the default send/stop buttons. */ rightSlot?: ReactNode; /** When true, shows send/stop buttons based on state (ignored if rightSlot is provided). */ showSendButton?: boolean; + /** Renders the command slot inline with the text cursor for compact composer layouts. */ + compactLayout?: boolean; maxHeight?: number; } @@ -44,9 +50,12 @@ export function RichInputField({ onInterrupt, disabled = false, placeholder, + aboveTextSlot, topSlot, + leftSlot, rightSlot, showSendButton = false, + compactLayout = false, maxHeight = 200, }: RichInputFieldProps) { const { t } = useTranslation(); @@ -92,57 +101,70 @@ export function RichInputField({ const hasText = value.trim().length > 0; const showStop = showSendButton && disabled && !!onInterrupt; const showSend = showSendButton && !disabled && hasText; - const showBottomRow = !!(topSlot || rightSlot || showStop || showSend); + const showBottomRow = !!(topSlot || leftSlot || rightSlot || showStop || showSend); + const actionSlot = rightSlot ? ( + rightSlot + ) : showStop ? ( + + ) : showSend ? ( + + ) : null; return (
    -