Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a82e305
wip: rework chat attachments
simcariou Jun 9, 2026
3676665
Merge branch 'swift' into 1706-chat-04-chat-attachments-option-a-comp…
simcariou Jun 9, 2026
ba5d497
feat: Add drag n' drop in chat + xlsx, txt, image in fast ingest
simcariou Jun 9, 2026
ade8727
Merge branch 'swift' into 1706-chat-04-chat-attachments-option-a-comp…
simcariou Jun 9, 2026
e9878a3
fix: make test
simcariou Jun 10, 2026
18bb42b
Merge branch 'swift' into 1706-chat-04-chat-attachments-option-a-comp…
simcariou Jun 10, 2026
bc6cfbc
fix: remove "synthia" from pyproject.toml
simcariou Jun 10, 2026
09162de
fix: type-check
simcariou Jun 10, 2026
bae2af8
fix: kics on frontend Dockerfile
simcariou Jun 10, 2026
72418fc
fix: add transparency on drag and drop page
simcariou Jun 10, 2026
2033eed
impr: Overall design and use slices instead of hardcoded queries in f…
simcariou Jun 11, 2026
65106bf
impr: scrollable attachment list
simcariou Jun 11, 2026
62e5a62
wip: Add persisted attachments management
simcariou Jun 11, 2026
01d3543
impr: fast/delete and various fixes
simcariou Jun 12, 2026
d9a521a
impr: Deleting a conversation deletes all associated attachments
simcariou Jun 12, 2026
6e03295
impr: rework SearchConfig in chat
simcariou Jun 12, 2026
eb74424
impr: close SearchConfig when clicking away
simcariou Jun 12, 2026
bf4ae42
impr: Bind mcp chat options to managed chat
simcariou Jun 12, 2026
75cd0e4
fix: code-quality
simcariou Jun 12, 2026
401727f
Merge branch 'swift' into 1706-chat-04-chat-attachments-option-a-comp…
simcariou Jun 12, 2026
feeb6be
Merge branch 'swift' into 1706-chat-04-chat-attachments-option-a-comp…
simcariou Jun 12, 2026
3b0757b
fix: jsons schema for cp & test
simcariou Jun 12, 2026
26e5119
fix: kf json schema
simcariou Jun 12, 2026
3e205f9
fix: make test cp
simcariou Jun 12, 2026
22f9d96
fix: code-quality
simcariou Jun 12, 2026
e749865
fix: use the same library picker in the SearchConfig and in the agent…
simcariou Jun 12, 2026
fbe2019
impr: add translations and better chat ui
simcariou Jun 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/control-plane-backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
simcariou marked this conversation as resolved.
Dismissed
import control_plane_backend.models.session_metadata_models # noqa: F401
from alembic import context
from control_plane_backend.config.loader import load_configuration
Expand Down
Original file line number Diff line number Diff line change
@@ -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

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "f2b3c4d5e6f7"
down_revision = "b4c5d6e7f8a9"
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")
1 change: 1 addition & 0 deletions apps/control-plane-backend/config/configuration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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
Expand Down
1 change: 1 addition & 0 deletions apps/control-plane-backend/config/configuration_prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
MinioContentStorageConfig,
)
from control_plane_backend.prompts.store import PromptStore
from control_plane_backend.sessions.attachment_store import SessionAttachmentStore
from control_plane_backend.scheduler.policies.policy_loader import (
load_conversation_policy_catalog,
)
Expand All @@ -54,6 +55,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

Expand Down Expand Up @@ -169,6 +171,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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
124 changes: 117 additions & 7 deletions apps/control-plane-backend/control_plane_backend/product/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,6 +14,7 @@
)
from control_plane_backend.product.schemas import (
AgentTemplateSummary,
CreateSessionAttachmentRequest,
ContextPromptSummary,
CreateAgentInstanceRequest,
CreatePromptRequest,
Expand All @@ -26,6 +27,7 @@
PromptPromoteRequest,
PromptScoreUpdateRequest,
PromptSummary,
SessionAttachmentSummary,
SessionListItem,
UpdateAgentInstanceRequest,
UpdatePromptRequest,
Expand All @@ -36,9 +38,12 @@
ExecutionPreparationError,
PromptRequestError,
SessionAlreadyExistsError,
SessionAttachmentRequestError,
build_frontend_bootstrap,
create_session_attachment,
create_prompt,
create_session,
delete_session_attachment,
delete_prompt,
delete_session,
enroll_agent_instance,
Expand All @@ -49,6 +54,7 @@
list_context_prompts,
list_managed_agent_instances,
list_prompts,
list_session_attachments,
list_sessions,
prepare_execution,
promote_prompt,
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]


Expand All @@ -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,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,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."""

Expand Down
Loading