ChatGXY Persistence: How Chats Are Stored, Loaded, and Managed
1. Overview / Architecture Summary
Galaxy has two distinct chat UX surfaces that persist conversations:
- ChatGXY (
/chatgxy) — full-page standalone chat with any agent type. General-purpose Q&A, tool recommendations, error analysis, etc. - PageChatPanel — embedded split-pane chat in the page editor. Scoped to a specific page, talks to the
page_assistantagent, and can propose structured edits (diffs).
Both surfaces share the same backend persistence model (ChatExchange + ChatExchangeMessage), the same API endpoint (POST /api/chat), and reuse frontend components (ChatInput, ChatMessageCell, ActionCard).
Data Flow Summary
User types query
|
v
Frontend: POST /api/chat (body: {query, exchange_id?, page_id?})
|
v
Backend: ChatAPI.query()
|--- loads conversation_history from DB if exchange_id present
|--- calls AgentService.route_and_execute()
|--- saves exchange: create new or add_message to existing
|
v
Response: ChatResponse {response, agent_response, exchange_id}
|
v
Frontend: stores exchange_id in ref (+ localStorage for pages)
|--- pushes ChatMessage into messages array
|--- renders via ChatMessageCell
Key Architectural Decisions
- JSON-in-Text storage: Conversation data stored as JSON string in a
TEXTcolumn. No separate columns for query/response/agent_type. - One exchange = one conversation thread: An exchange has many messages (turns). Each message stores one query+response pair as JSON.
- No streaming: Request-response only; no WebSocket or SSE for chat (unlike the unmerged PR #21706).
- No polling: UI does not poll for updates. Messages only appear after explicit user interaction.
- Soft delete for pages:
Page.deleted = Truedoes not cascade to chat exchanges. Frontend cleans up localStorage on page delete; DB rows for the exchange persist (orphaned but harmless).
2. Database Models
Tables
chat_exchange
| Column | Type | Constraints | Description |
|---|---|---|---|
id | INTEGER | PK | Auto-incrementing primary key |
user_id | INTEGER | FK -> galaxy_user.id, indexed, NOT NULL | Owner of this exchange |
job_id | INTEGER | FK -> job.id, indexed, nullable | For job-based (GalaxyWizard) exchanges |
page_id | INTEGER | FK -> page.id, indexed, nullable | For page-scoped (PageChatPanel) exchanges |
Source: lib/galaxy/model/__init__.py lines 3251-3274
Relationships:
user->User(back_populateschat_exchanges)messages->list[ChatExchangeMessage](back_populateschat_exchange)page->Page(no back-population on Page side)
Exchange scoping: Exactly one of job_id, page_id, or neither is set:
job_idset: GalaxyWizard error analysis exchangepage_idset: PageChatPanel conversation- Neither set: General ChatGXY conversation
chat_exchange_message
| Column | Type | Constraints | Description |
|---|---|---|---|
id | INTEGER | PK | Auto-incrementing primary key |
chat_exchange_id | INTEGER | FK -> chat_exchange.id, indexed | Parent exchange |
create_time | DATETIME | default=now | Timestamp of message creation |
message | TEXT | JSON-encoded conversation data (see below) | |
feedback | INTEGER | nullable | 0=negative, 1=positive, NULL=no feedback |
Source: lib/galaxy/model/__init__.py lines 3276-3288
Message JSON Structure
Each ChatExchangeMessage.message contains a JSON string with this shape:
{
"query": "User's question text",
"response": "Agent's response text",
"agent_type": "router",
"agent_response": {
"content": "...",
"agent_type": "router",
"confidence": "high",
"suggestions": [...],
"metadata": { "model": "...", "total_tokens": 123, ... },
"reasoning": "..."
}
}
The agent_response field is the full serialized AgentResponse Pydantic model (may be null for legacy/error messages). One message = one complete turn (query + response).
Migrations
-
cbc46035eba0(2023-06-05): Original migration creatingchat_exchangeandchat_exchange_messagetables. Pre-dates the agent framework.- File:
lib/galaxy/model/migrations/alembic/versions_gxy/cbc46035eba0_chat_exchange_storage.py
- File:
-
b75f0f4dbcd4(2025-01-06): Addspage_idcolumn tochat_exchange(plus page model additions:history_id,source_invocation_id,edit_sourceonpage_revision).- File:
lib/galaxy/model/migrations/alembic/versions_gxy/b75f0f4dbcd4_add_page_history_columns.py - Adds FK, index, and constraint for
chat_exchange.page_id -> page.id
- File:
3. API Layer
All chat endpoints are in lib/galaxy/webapps/galaxy/api/chat.py, class ChatAPI. All marked unstable=True.
Endpoints
| Method | Path | Purpose | Used By |
|---|---|---|---|
POST | /api/chat | Main chat endpoint (send query, get response) | ChatGXY, PageChatPanel |
GET | /api/chat/history | List user’s general (non-job, non-page) chat exchanges | ChatGXY history sidebar |
GET | /api/chat/page/{page_id}/history | List page-scoped chat exchanges | PageChatPanel on open |
DELETE | /api/chat/history | Clear all non-job chat exchanges | ChatGXY “Clear History” button |
PUT | /api/chat/{job_id}/feedback | Job-based feedback (GalaxyWizard) | GalaxyWizard |
PUT | /api/chat/exchange/{exchange_id}/feedback | Exchange-based feedback | ChatGXY, PageChatPanel |
GET | /api/chat/exchange/{exchange_id}/messages | Get all messages for an exchange | Loading full conversation on history select |
POST /api/chat — Request
Query params: agent_type (default "auto"), optional job_id (legacy)
Body (ChatPayload):
class ChatPayload(Model):
query: str
context: Optional[str] = ""
exchange_id: Optional[int] = None # Continue existing conversation
page_id: Optional[DecodedDatabaseIdField] = None # Scope to page
regenerate: Optional[bool] = None # Force fresh (job-based only)
Source: lib/galaxy/schema/schema.py lines 3878-3903
POST /api/chat — Response
class ChatResponse(BaseModel):
response: str
error_code: Optional[int]
error_message: Optional[str]
agent_response: Optional[AgentResponse] = None
exchange_id: Optional[int] = None
processing_time: Optional[float] = None
Source: lib/galaxy/schema/schema.py lines 3906-3935
POST /api/chat — Backend Flow (lines 96-282)
- Extract query from body payload or query param
- If
job_id: check for cached response (skip ifregenerate) - Extract
exchange_idandpage_idfrom payload - If
page_id: load page object, attachhistory_idand exportedpage_contentto context - If
exchange_id: load conversation history from DB asconversation_historyin context dict - Call
AgentService.route_and_execute()(or legacy fallback) - Save to DB:
job_idset ->ChatManager.create(trans, job_id, response)exchange_idset ->ChatManager.add_message(trans, exchange_id, json_data)(append to existing)page_idset (no exchange_id) ->ChatManager.create_page_chat(trans, page_id, ...)- Neither ->
ChatManager.create_general_chat(trans, query, ...)
- Return
ChatResponsewithexchange_idfor conversation continuity
GET /api/chat/history — Response Shape
Returns list[dict] where each dict:
{
"id": 42,
"query": "first message query",
"response": "first message response",
"agent_type": "router",
"agent_response": { ... },
"timestamp": "2026-03-05T10:00:00",
"feedback": null,
"message_count": 3
}
Note: only returns data from the first message of each exchange (for history list display). message_count indicates total turns.
GET /api/chat/exchange/{exchange_id}/messages — Response Shape
Returns list[dict] — flattened list of all messages across all turns:
[
{ "role": "user", "content": "...", "timestamp": "..." },
{ "role": "assistant", "content": "...", "agent_type": "router", "agent_response": {...}, "timestamp": "...", "feedback": null },
{ "role": "user", "content": "...", "timestamp": "..." },
{ "role": "assistant", "content": "...", "agent_type": "router", "agent_response": {...}, "timestamp": "...", "feedback": null }
]
Each stored ChatExchangeMessage (one per turn) is split into two entries (user + assistant).
4. Business Logic (ChatManager)
File: lib/galaxy/managers/chat.py
Key Methods
| Method | Signature | Description |
|---|---|---|
create | (trans, job_id, message) -> ChatExchange | Create job-based exchange with raw message string |
create_general_chat | (trans, query, response_data, agent_type) -> ChatExchange | Create general ChatGXY exchange (no job, no page) |
create_page_chat | (trans, page_id, query, response_data, agent_type) -> ChatExchange | Create page-scoped exchange |
add_message | (trans, exchange_id, message) -> ChatExchangeMessage | Append a turn to existing exchange (validates ownership) |
get | (trans, job_id) -> ChatExchange? | Lookup by job_id |
get_exchange_by_id | (trans, exchange_id) -> ChatExchange? | Lookup by exchange_id |
get_chat_history | (trans, exchange_id, format_for_pydantic_ai) -> list | Get messages for an exchange. Can return raw dicts or pydantic-ai ModelMessage objects |
get_user_chat_history | (trans, limit, include_job_chats, include_page_chats) -> list[ChatExchange] | List user’s exchanges with filtering |
get_page_chat_history | (trans, page_id, limit) -> list[ChatExchange] | List exchanges for a specific page |
set_feedback_for_exchange | (trans, exchange_id, feedback) -> ChatExchange | Set feedback (0/1) on first message |
set_feedback_for_job | (trans, job_id, feedback) -> ChatExchange | Set feedback by job lookup |
Important Implementation Details
- All queries scoped by
user_id— users can only see their own exchanges get_user_chat_historydefaults to excluding both job chats and page chats (include_job_chats=False, include_page_chats=False)get_chat_historywithformat_for_pydantic_ai=Trueconverts stored messages intoModelRequest/ModelResponseobjects for agent continuation- Feedback is stored on
ChatExchangeMessage.feedback(the first message’s feedback field). Comment in code: “There is only one message in an exchange currently” — this is outdated since multi-turn was added, but feedback still targets message[0].
5. Frontend State Management
ChatGXY (Standalone Page)
File: client/src/components/ChatGXY.vue
All state is component-local refs — no Pinia store:
| Ref | Type | Purpose |
|---|---|---|
messages | ref<ChatMessage[]> | All displayed messages |
currentChatId | ref<number | null> | Current exchange ID (sent as exchange_id in POST) |
chatHistory | ref<ChatHistoryItem[]> | History sidebar items |
busy | ref<boolean> | Loading spinner state |
selectedAgentType | ref<string> | Dropdown selection (default "auto") |
showHistory | ref<boolean> | History sidebar visibility |
loadingHistory | ref<boolean> | History list loading state |
hasLoadedInitialChat | ref<boolean> | Prevents welcome message if latest chat loaded |
No localStorage usage in ChatGXY standalone. The currentChatId is ephemeral — lost on page refresh (but the latest chat is reloaded from API on mount).
PageChatPanel (Page Editor)
File: client/src/components/PageEditor/PageChatPanel.vue
Component-local refs plus store-backed persistence:
| Ref | Type | Persistence | Purpose |
|---|---|---|---|
messages | ref<ChatMessage[]> | Ephemeral | Displayed messages |
currentChatId | ref<number | null> | Via store -> localStorage | Current exchange ID |
dismissedProposals | ref<Set<string>> | Via store -> localStorage | IDs of proposals user dismissed |
busy | ref<boolean> | Ephemeral | Loading state |
pageEditorStore (Pinia)
File: client/src/stores/pageEditorStore.ts
Three useUserLocalStorage entries persist chat state across sessions:
// Per-page chat exchange ID -- survives panel close/reopen
const currentChatExchangeIds = useUserLocalStorage<Record<string, number | null>>(
"history-page-chat-exchange", {}
);
// Per-page dismissed proposal message IDs
const dismissedChatProposals = useUserLocalStorage<Record<string, string[]>>(
"history-page-dismissed-proposals", {}
);
// Also: per-history "current page" ID (not chat-specific but relevant)
const currentPageIds = useUserLocalStorage<Record<string, string>>(
"history-page-current", {}
);
localStorage keys (all user-scoped via hashed user ID prefix):
history-page-chat-exchange— MapspageId -> exchangeId | nullhistory-page-dismissed-proposals— MapspageId -> messageId[]history-page-current— MapshistoryId -> pageId
Store Methods for Chat
| Method | Signature | Purpose |
|---|---|---|
getCurrentChatExchangeId | (pageId) -> number | null | Read cached exchange ID |
setCurrentChatExchangeId | (pageId, exchangeId) | Write exchange ID to localStorage |
clearCurrentChatExchangeId | (pageId) | Remove exchange ID entry |
getDismissedProposals | (pageId) -> string[] | Read dismissed message IDs |
addDismissedProposal | (pageId, messageId) | Add to dismissed set |
clearDismissedProposals | (pageId) | Remove all dismissed for page |
toggleChatPanel | () | Toggle showChatPanel (mutually exclusive with revisions) |
ChatMessage Type
File: client/src/components/ChatGXY/chatTypes.ts
interface ChatMessage {
id: string; // Client-generated (generateId() or hist-* prefix)
role: "user" | "assistant";
content: string;
timestamp: Date;
agentType?: string; // e.g. "router", "page_assistant"
confidence?: string;
feedback?: "up" | "down" | null;
agentResponse?: AgentResponse;
suggestions?: ActionSuggestion[];
isSystemMessage?: boolean; // Welcome/system messages don't show feedback
}
useUserLocalStorage
File: client/src/composables/userLocalStorage.ts
Wrapper around browser localStorage that:
- Hashes the current user ID
- Prefixes all keys with the hash
- Returns a reactive
Ref<T>that syncs to localStorage
This ensures per-user isolation of stored chat exchange IDs and dismissed proposals.
6. Chat Panel UI Flow
ChatGXY Standalone: Open -> Load -> Display -> Interact
-
Mount (
onMounted):- Calls
loadLatestChat():GET /api/chat/history?limit=1- If data exists, calls
loadPreviousChat(latestChat)(which fetches full conversation) - Sets
hasLoadedInitialChat = true
- If no chat loaded, pushes a welcome system message
- Calls
-
User sends query:
- Push user
ChatMessagetomessagesarray POST /api/chatwith{query, exchange_id: currentChatId}- On success: store
data.exchange_idascurrentChatId - Push assistant
ChatMessagewith agentResponse/suggestions - Scroll to bottom
- Push user
-
Multi-turn: subsequent queries include
exchange_id, so backend appends viaadd_message()and loads conversation history for agent context. -
History sidebar:
- Toggle shows 280px sidebar
- Lazy-loads via
GET /api/chat/history?limit=50 - Click item ->
loadPreviousChat(item):GET /api/chat/exchange/{id}/messages- Rebuilds full
messagesarray from all turns - Falls back to single-message if full conversation fetch fails
-
New Chat: Clears messages, resets
currentChatId = null, shows new welcome message. -
Clear History:
DELETE /api/chat/history-> clears sidebar, starts new chat if active.
PageChatPanel: Open -> Load -> Display -> Interact
-
Mount (
onMounted):- Calls
loadPageChat(): a. Checkstore.getCurrentChatExchangeId(pageId)(localStorage cache) b. If found,loadConversation(storedExchangeId):GET /api/chat/exchange/{id}/messages- Populate
messages, setcurrentChatId, restore dismissed proposals c. If no localStorage hit, fall back toGET /api/chat/page/{page_id}/history?limit=1 - If data exists,
loadConversation(latest.id)
- If still no messages, show welcome message
- Calls
-
User sends query:
POST /api/chatwith{query, exchange_id: currentChatId, page_id: pageId}- On success: store
exchange_idin both local ref AND store (setCurrentChatExchangeId) - Push assistant message (may contain edit proposals in
agentResponse.metadata)
-
Edit proposals (unique to PageChatPanel):
- Each assistant message is checked for
agentResponse.metadata.edit_mode - If
full_replacement: showsProposalDiffViewwith unified diff - If
section_patch: showsSectionPatchViewwith per-section checkboxes - Staleness detection:
isProposalStale(msg)comparesmetadata.original_content_hash(DJB2 hash of page content at proposal time) to current content hash - Accept: applies content to store (
store.updateContent()), saves page withedit_source="agent", adds to dismissed set - Reject: adds message ID to dismissed set (localStorage-persisted)
- Already-applied proposals auto-hide (content equality check)
- Each assistant message is checked for
-
New Conversation: Clears messages, resets
currentChatId, clears dismissed proposals, persists all to localStorage via store.
7. Exchange Lifecycle
Create
First message in a new conversation:
- Frontend sends
POST /api/chatwithexchange_id: null - Backend creates new
ChatExchange(viacreate_general_chatorcreate_page_chat) - First
ChatExchangeMessageattached with JSON payload exchange.idreturned in response- Frontend stores
exchange_idincurrentChatIdref (+ localStorage for pages)
Continue
Subsequent messages:
- Frontend sends
POST /api/chatwithexchange_id: <existing_id> - Backend loads conversation history from DB for agent context (
get_chat_history) - Agent processes query with full conversation history
- Backend appends new
ChatExchangeMessageviaadd_message() - Same
exchange_idreturned
Load (History)
ChatGXY history sidebar:
GET /api/chat/historyreturns first-message summaries of all general exchanges- User clicks item ->
GET /api/chat/exchange/{id}/messagesloads all turns - Messages reconstructed into
ChatMessage[]with proper roles, types, feedback states
PageChatPanel on open:
- Check localStorage for cached
exchange_idfor this page - If found, load directly via
GET /api/chat/exchange/{id}/messages - If not, fall back to
GET /api/chat/page/{page_id}/history?limit=1for most recent
Cleanup
Page deletion (deleteCurrentPage() in pageEditorStore):
- Calls
DELETE /api/pages/{id}(soft delete: setspage.deleted = True) - Frontend:
clearCurrentPageId(historyId),clearCurrentChatExchangeId(pageId),clearDismissedProposals(pageId) - Backend: No cascade to
ChatExchange. DB rows persist withpage_idpointing to a soft-deleted page.
Chat history clear (DELETE /api/chat/history):
- Loads all non-job exchanges for user (up to 1000)
- Deletes all
ChatExchangeMessagerows, thenChatExchangerows - Hard delete (not soft)
- Note: only clears general (non-page, non-job) exchanges
No automatic cleanup for:
- Orphaned page chat exchanges (page soft-deleted, exchanges remain)
- Old exchanges with no recent activity
- There is no TTL, no cron job, no garbage collection
Feedback
Feedback targets the exchange (not individual messages):
- Frontend:
PUT /api/chat/exchange/{exchange_id}/feedbackwith body0or1 - Backend: sets
chat_exchange.messages[0].feedback(first message only) - This is a design limitation — multi-turn exchanges only record feedback on the first message
8. Multi-Exchange Support (History Selection, Switching)
ChatGXY
-
History sidebar (280px, toggled by History button):
- Lists all general (non-job, non-page) exchanges, most recent first
- Each item shows: truncated query, agent icon, elapsed time
- Click loads full conversation and sets
currentChatId - “Clear History” deletes all general exchanges after confirmation
- “New” button resets to fresh conversation
-
State on switch:
messagesarray completely replacedcurrentChatIdupdated to selected exchange- Previous unsent query text lost (no save)
-
No multi-exchange view: Only one conversation visible at a time
PageChatPanel
- No history sidebar: Only shows the current conversation for the page
- “New Chat” button: Starts fresh conversation, clears localStorage exchange ID
- Automatic resume: On panel open, loads the most recent exchange for this page (from localStorage or API)
- One active exchange per page: The
currentChatExchangeIdsmap stores one exchange ID per page ID
Cross-Page Navigation
When switching between pages in the editor:
- Store’s
clearCurrentPage()setsshowChatPanel = falseand clears the exchange ID for that page - Loading a new page does NOT automatically open the chat panel
- When user toggles chat for the new page,
onMountedin PageChatPanel triggers fresh load - The per-page localStorage mapping ensures each page remembers its own conversation
9. Edit Proposal Persistence
How Proposals Are Created
The page_assistant agent returns structured output via pydantic-ai output types:
# Full replacement
class FullReplacementEdit(BaseModel):
mode: Literal["full_replacement"]
reasoning: str
content: str # Complete new document
# Section patch
class SectionPatchEdit(BaseModel):
mode: Literal["section_patch"]
reasoning: str
target_section_heading: str
new_section_content: str
These are serialized into agentResponse.metadata in the DB:
{
"edit_mode": "full_replacement",
"content": "...",
"original_content_hash": "a1b2c3d4"
}
Staleness Detection
Both frontend and backend compute a DJB2 hash of the page content:
# Backend (page_assistant.py)
def _djb2_hash(s: str) -> str:
h = 5381
for c in s:
h = ((h * 33) + ord(c)) & 0xFFFFFFFF
return format(h, "08x")
// Frontend (PageChatPanel.vue)
function djb2Hash(s: string): string {
let h = 5381;
for (let i = 0; i < s.length; i++) {
h = (h * 33 + s.charCodeAt(i)) >>> 0;
}
return h.toString(16).padStart(8, "0");
}
If original_content_hash does not match djb2Hash(currentPageContent), the proposal is marked stale and Accept is disabled.
Dismissed Proposals Persistence
Dismissed proposals are tracked by message ID in localStorage:
- Key:
history-page-dismissed-proposals(user-scoped) - Value:
{ [pageId]: ["msg-123-abc", "hist-assistant-42-1", ...] } - Cleared on: page delete, new conversation start
- Persists across: panel close/reopen, page navigation, browser refresh
10. Key File Paths
Backend
| File | Purpose |
|---|---|
lib/galaxy/model/__init__.py:3251-3288 | ChatExchange and ChatExchangeMessage ORM models |
lib/galaxy/model/migrations/alembic/versions_gxy/cbc46035eba0_chat_exchange_storage.py | Original table migration |
lib/galaxy/model/migrations/alembic/versions_gxy/b75f0f4dbcd4_add_page_history_columns.py | page_id column migration |
lib/galaxy/managers/chat.py | ChatManager — all CRUD operations |
lib/galaxy/webapps/galaxy/api/chat.py | ChatAPI — all HTTP endpoints |
lib/galaxy/schema/schema.py:3878-3935 | ChatPayload and ChatResponse Pydantic models |
lib/galaxy/schema/agents.py | AgentResponse, ActionSuggestion, ActionType |
lib/galaxy/agents/page_assistant.py | PageAssistantAgent with structured edit output |
lib/galaxy/agents/base.py:186-190 | AgentType enum including PAGE_ASSISTANT |
lib/galaxy/agents/__init__.py | Agent registry with all registered agents |
lib/galaxy/webapps/galaxy/services/pages.py:100-109 | Page soft-delete (no chat cascade) |
Frontend
| File | Purpose |
|---|---|
client/src/components/ChatGXY.vue | Full-page chat component (standalone ChatGXY) |
client/src/components/ChatGXY/chatTypes.ts | ChatMessage interface |
client/src/components/ChatGXY/chatUtils.ts | generateId(), scrollToBottom() |
client/src/components/ChatGXY/agentTypes.ts | Agent type registry, icon/label lookup |
client/src/components/ChatGXY/ChatInput.vue | Shared text input with send button |
client/src/components/ChatGXY/ChatMessageCell.vue | Shared message cell (query/response rendering) |
client/src/components/ChatGXY/ActionCard.vue | Action suggestion buttons |
client/src/components/PageEditor/PageChatPanel.vue | Page-scoped chat panel |
client/src/components/PageEditor/PageEditorView.vue | Page editor (hosts chat panel via EditorSplitView) |
client/src/components/PageEditor/EditorSplitView.vue | Draggable split pane layout |
client/src/components/PageEditor/ProposalDiffView.vue | Full-replacement diff UI |
client/src/components/PageEditor/SectionPatchView.vue | Section-level patch UI |
client/src/stores/pageEditorStore.ts | Pinia store with chat exchange ID and dismissed proposal localStorage |
client/src/composables/agentActions.ts | AgentResponse type, ActionType enum, action dispatch logic |
client/src/composables/userLocalStorage.ts | User-scoped localStorage composable |
client/src/components/Page/constants.ts | UI strings for chat panel (labels, welcome messages) |
11. Gaps and Design Notes
Known Limitations
-
Feedback granularity: Feedback is per-exchange (stored on message[0]), not per-turn. Multi-turn conversations have one feedback slot for the entire thread.
-
No cascade on page delete: Page soft-delete does not touch
chat_exchangerows. Frontend cleans localStorage but DB rows withpage_idpointing to deleted pages persist indefinitely. -
History list shows first message only:
GET /api/chat/historyreturns the first query/response pair. Themessage_countfield hints at conversation depth but the sidebar only renders the first query text. -
No real-time updates: If the same user opens ChatGXY in two tabs, they see independent local state. No synchronization mechanism exists.
-
No exchange-level delete: Individual exchanges cannot be deleted through the API. Only “clear all” (
DELETE /api/chat/history) is supported, and it only affects general (non-job, non-page) exchanges. -
Page chat history not clearable: There is no endpoint to clear page-scoped chat history. The
DELETE /api/chat/historyendpoint explicitly excludes page chats.
Architecture Observations
- The JSON-in-Text storage pattern is pragmatic but makes querying individual messages impossible at the SQL level. Searching chat content requires loading and parsing JSON.
- The
ChatManagerhas three create methods (create,create_general_chat,create_page_chat) that are structurally similar but differ in which FK is set and how the message JSON is constructed. Potential for consolidation. - The frontend manages message IDs as client-generated strings (
msg-{timestamp}-{random}orhist-{role}-{exchangeId}-{index}). These are purely for Vue’s:keybinding and diff rendering — they have no backend counterpart.