diff --git a/packages/genui/a2ui-playground/src/components/ConversationListPanel.tsx b/packages/genui/a2ui-playground/src/components/ConversationListPanel.tsx index 18376ad7d3..074b6679b1 100644 --- a/packages/genui/a2ui-playground/src/components/ConversationListPanel.tsx +++ b/packages/genui/a2ui-playground/src/components/ConversationListPanel.tsx @@ -150,7 +150,7 @@ export function ConversationListPanel(props: ConversationListPanelProps) { iconOnly iconBefore={Share2} disabled={disabled || editing} - title='Copy share link' + title='Copy conversation link' aria-label='Share conversation' onClick={() => onShare(conversation.id)} /> diff --git a/packages/genui/a2ui-playground/src/hooks/useConversation.ts b/packages/genui/a2ui-playground/src/hooks/useConversation.ts index 3bc663ce17..fea189c708 100644 --- a/packages/genui/a2ui-playground/src/hooks/useConversation.ts +++ b/packages/genui/a2ui-playground/src/hooks/useConversation.ts @@ -8,12 +8,15 @@ import { createConversationMeta, deleteConversation, getActiveConversationId, + importConversation, listConversations, loadConversation, + previewTextFromSharedMessages, renameConversation, saveConversationMessages, setActiveConversationId, } from '../storage/conversationRepo.js'; +import type { SharedConversationDoc } from '../storage/sharedConversation.js'; import type { ConversationMeta, DataModelSnapshot, @@ -64,6 +67,7 @@ export interface UseConversationReturn { isPersistent: boolean; switchTo: (id: string) => Promise; createNew: () => Promise; + importShared: (doc: SharedConversationDoc) => Promise; remove: (id: string) => Promise; rename: (id: string, title: string) => Promise; recordTurn: (input: RecordTurnInput) => Promise; @@ -450,6 +454,56 @@ export function useConversation(): UseConversationReturn { return meta.id; }, [syncHotState]); + const importShared = useCallback( + async (doc: SharedConversationDoc): Promise => { + // In-memory fallback (IndexedDB unavailable): build hot state directly. + if (!persistentRef.current) { + const meta: ConversationMeta = { + ...createConversationMeta(doc.title || 'Shared conversation'), + messageCount: doc.messages.length, + previewText: previewTextFromSharedMessages(doc.messages), + }; + const hotState: ConversationHotState = { + messages: doc.messages.map((message) => ({ + role: message.role, + content: message.content, + previewPayloadUrls: message.previewPayloadUrls, + previewMetrics: clonePreviewPerformanceMetrics( + message.previewMetrics, + ), + })), + dataModel: cloneDataModel(doc.snapshot?.dataModel ?? {}), + surfaceIds: new Set(doc.snapshot?.surfaceIds ?? []), + previewMessages: [], + previewPayloadUrls: doc.snapshot?.previewPayloadUrls ?? null, + }; + conversationHotStateMapRef.current.set( + meta.id, + cloneHotState(hotState), + ); + setConversations((prev) => [meta, ...prev]); + activationTokenRef.current = Symbol(meta.id); + activeIdRef.current = meta.id; + setActiveId(meta.id); + syncHotState( + hotState.messages, + hotState.dataModel, + hotState.surfaceIds, + hotState.previewMessages, + hotState.previewPayloadUrls, + ); + return meta.id; + } + + const meta = await importConversation(doc); + await setActiveConversationId(meta.id); + await refreshConversations(); + await activateRecord(meta.id); + return meta.id; + }, + [activateRecord, refreshConversations, syncHotState], + ); + const remove = useCallback( async (id: string) => { if (persistentRef.current) { @@ -676,6 +730,7 @@ export function useConversation(): UseConversationReturn { isPersistent, switchTo, createNew, + importShared, remove, rename, recordTurn, diff --git a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx index f18398feca..0a05de9945 100644 --- a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx @@ -26,12 +26,22 @@ import { useConversation } from '../hooks/useConversation.js'; import type { ModelChatMessage } from '../hooks/useConversation.js'; import { useResizablePanels } from '../hooks/useResizablePanels.js'; import { loadConversation } from '../storage/conversationRepo.js'; +import { + isSharedConversationDoc, + serializeConversation, +} from '../storage/sharedConversation.js'; import type { PreviewPerformanceMetrics } from '../storage/types.js'; import { copyToClipboard } from '../utils/clipboard.js'; import { DEFAULT_A2UI_DEMO_URL } from '../utils/demoUrl.js'; import type { Protocol } from '../utils/protocol.js'; -import { isDevHost, publishA2UIPayload } from '../utils/publishPayload.js'; +import { isDevHost } from '../utils/publishPayload.js'; import { buildRenderUrl } from '../utils/renderUrl.js'; +import { + buildConversationShareUrl, + clearImportConversationParam, + publishConversation, + readImportConversationParam, +} from '../utils/shareConversation.js'; interface ChatMessage { id?: string; @@ -1042,6 +1052,7 @@ export function AIChatPage( buildConversationContext, conversations, createNew, + importShared, isPersistent, isReady, messages: persistedMessages, @@ -1088,6 +1099,7 @@ export function AIChatPage( const abortRef = useRef(null); const actionAbortRef = useRef(null); const hydratedActiveIdRef = useRef(null); + const importHandledRef = useRef(false); const latestPreviewMessagesRef = useRef([]); const generatedCharacterCountRef = useRef(0); const latestPreviewPayloadUrlsRef = useRef(null); @@ -1517,6 +1529,38 @@ export function AIChatPage( updatePreviewPayloadUrls, ]); + // Import-on-open: when the page is loaded with a shared-conversation link, + // fetch the document once, rehydrate it into a new local conversation, then + // strip the param so a reload does not re-import. + useEffect(() => { + if (!isReady || importHandledRef.current) return; + const importUrl = readImportConversationParam(); + if (!importUrl) { + importHandledRef.current = true; + return; + } + importHandledRef.current = true; + void (async () => { + try { + const response = await window.fetch(importUrl); + if (!response.ok) { + throw new Error( + `Failed to load shared conversation: ${response.status}`, + ); + } + const doc = (await response.json()) as unknown; + if (!isSharedConversationDoc(doc)) { + throw new Error('Invalid shared conversation document'); + } + await importShared(doc); + } catch (err) { + console.warn('[a2ui] Failed to import shared conversation', err); + } finally { + clearImportConversationParam(); + } + })(); + }, [isReady, importShared]); + useEffect(() => { return () => { abortRef.current?.abort(); @@ -2102,77 +2146,27 @@ export function AIChatPage( const shareConversation = useCallback( async (id: string) => { try { - let urls: PreviewPayloadUrls | null = null; - let fallbackMessages: unknown[] | null = null; - - // Active conversation: the freshest URLs/messages live in refs. - if (id === activeId) { - urls = latestPreviewPayloadUrlsRef.current; - fallbackMessages = latestPreviewMessagesRef.current; - } - - // Otherwise (or if the active one hasn't published yet) read the - // durable Supabase URLs persisted on the conversation snapshot. - if (!urls?.messagesUrl) { - const record = await loadConversation(id); - const snapshot = record?.snapshot; - const fromSnapshot = snapshot?.previewPayloadUrls; - let fromMessage: PreviewPayloadUrls | undefined; - if (record) { - for (let i = record.messages.length - 1; i >= 0; i--) { - const message = record.messages[i]; - if (message?.previewPayloadUrls?.messagesUrl) { - fromMessage = message.previewPayloadUrls; - break; - } - } - } - urls = (fromSnapshot?.messagesUrl ? fromSnapshot : fromMessage) - ?? urls; - if ( - !urls?.messagesUrl - && (!fallbackMessages || fallbackMessages.length === 0) - ) { - const previewMessages = snapshot?.previewMessages; - if (Array.isArray(previewMessages) && previewMessages.length > 0) { - fallbackMessages = previewMessages; - } else if (record && record.messages.length > 0) { - // Older snapshots: rebuild A2UI preview from assistant history. - const rebuilt = buildPreviewMessagesFromHistory(record.messages); - if (rebuilt.length > 0) fallbackMessages = rebuilt; - } - } - } - - // No durable URL yet: publish the raw A2UI messages to Supabase. - if ( - !urls?.messagesUrl && fallbackMessages && fallbackMessages.length > 0 - ) { - urls = await publishA2UIPayload(fallbackMessages); - } - - if (!urls?.messagesUrl) { + // Share the whole conversation: serialize the persisted record, + // upload it, and build a link that imports it into the recipient's + // playground (transcript + data model), not just the final UI. + const record = await loadConversation(id); + if (!record || record.messages.length === 0) { showCopyToast(false); return; } - - const link = buildRenderUrl( - { - protocol, - demoUrl: DEFAULT_A2UI_DEMO_URL, - messages: [], - messagesUrl: urls.messagesUrl, - actionMocksUrl: urls.actionMocksUrl, - theme, - }, + const doc = serializeConversation(record, protocol.name); + const conversationUrl = await publishConversation(doc); + const link = buildConversationShareUrl( + conversationUrl, baseUrl, + protocol.name, ); showCopyToast(await copyToClipboard(link)); } catch { showCopyToast(false); } }, - [activeId, baseUrl, protocol, showCopyToast, theme], + [baseUrl, protocol, showCopyToast], ); const handleShareConversation = useCallback((id: string) => { diff --git a/packages/genui/a2ui-playground/src/storage/conversationRepo.ts b/packages/genui/a2ui-playground/src/storage/conversationRepo.ts index 774ff47ac1..8c7d1117b7 100644 --- a/packages/genui/a2ui-playground/src/storage/conversationRepo.ts +++ b/packages/genui/a2ui-playground/src/storage/conversationRepo.ts @@ -2,6 +2,7 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. import { getDB } from './db.js'; +import type { SharedConversationDoc } from './sharedConversation.js'; import type { ConversationMeta, DataModelSnapshot, @@ -131,6 +132,55 @@ export async function saveConversationMessages( await tx.done; } +export function previewTextFromSharedMessages( + messages: SharedConversationDoc['messages'], +): string { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message?.role === 'user') { + const compact = message.content.replace(/\s+/gu, ' ').trim(); + return compact.length > 80 ? `${compact.slice(0, 80)}...` : compact; + } + } + return ''; +} + +/** + * Write a shared conversation document into a brand-new local conversation + * (fresh id, re-sequenced messages) and return its meta. The snapshot's + * `previewMessages` is left empty on purpose — the chat page rebuilds the + * preview from the assistant message history when the conversation activates. + */ +export async function importConversation( + doc: SharedConversationDoc, +): Promise { + const now = Date.now(); + const meta: ConversationMeta = { + ...createConversationMeta(doc.title || 'Shared conversation'), + messageCount: doc.messages.length, + previewText: previewTextFromSharedMessages(doc.messages), + }; + const messages: PersistedMessage[] = doc.messages.map((message, index) => ({ + conversationId: meta.id, + seq: index, + role: message.role, + content: message.content, + previewPayloadUrls: message.previewPayloadUrls, + previewMetrics: message.previewMetrics, + createdAt: now + index, + })); + const snapshot: DataModelSnapshot = { + conversationId: meta.id, + dataModel: doc.snapshot?.dataModel ?? {}, + surfaceIds: doc.snapshot?.surfaceIds ?? [], + previewMessages: [], + previewPayloadUrls: doc.snapshot?.previewPayloadUrls, + updatedAt: now, + }; + await saveConversationMessages(meta, messages, snapshot); + return meta; +} + export async function renameConversation( id: string, title: string, diff --git a/packages/genui/a2ui-playground/src/storage/sharedConversation.ts b/packages/genui/a2ui-playground/src/storage/sharedConversation.ts new file mode 100644 index 0000000000..d4c5a4ca1b --- /dev/null +++ b/packages/genui/a2ui-playground/src/storage/sharedConversation.ts @@ -0,0 +1,113 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { ConversationRecord } from './conversationRepo.js'; +import type { PreviewPayloadUrls, PreviewPerformanceMetrics } from './types.js'; + +export const SHARED_CONVERSATION_KIND = 'a2ui-conversation'; + +/** + * A single turn in a shared conversation. Structurally a `ModelChatMessage`, + * redeclared here so the storage layer does not import from `hooks/`. + */ +export interface SharedConversationMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + previewPayloadUrls?: PreviewPayloadUrls; + previewMetrics?: PreviewPerformanceMetrics; +} + +export interface SharedConversationSnapshot { + dataModel: Record; + surfaceIds: string[]; + previewPayloadUrls?: PreviewPayloadUrls; +} + +/** + * Self-contained, versioned snapshot of an entire conversation. Uploaded to + * durable storage so a share link can rehydrate the conversation (transcript + + * data model) into the recipient's playground. Deliberately omits + * `snapshot.previewMessages`: it is a client-side render cache that is rebuilt + * from the assistant message history on import, and dropping it keeps the + * uploaded document well under the server's body-size limit. + */ +export interface SharedConversationDoc { + v: 1; + kind: typeof SHARED_CONVERSATION_KIND; + protocol?: string; + title: string; + createdAt: number; + updatedAt: number; + messages: SharedConversationMessage[]; + snapshot: SharedConversationSnapshot | null; +} + +export function serializeConversation( + record: ConversationRecord, + protocol?: string, +): SharedConversationDoc { + const messages: SharedConversationMessage[] = record.messages.map( + (message) => { + const next: SharedConversationMessage = { + role: message.role, + content: message.content, + }; + if (message.previewPayloadUrls) { + next.previewPayloadUrls = message.previewPayloadUrls; + } + if (message.previewMetrics) { + next.previewMetrics = message.previewMetrics; + } + return next; + }, + ); + + let snapshot: SharedConversationSnapshot | null = null; + if (record.snapshot) { + snapshot = { + dataModel: record.snapshot.dataModel ?? {}, + surfaceIds: record.snapshot.surfaceIds ?? [], + }; + if (record.snapshot.previewPayloadUrls) { + snapshot.previewPayloadUrls = record.snapshot.previewPayloadUrls; + } + } + + return { + v: 1, + kind: SHARED_CONVERSATION_KIND, + protocol, + title: record.meta.title, + createdAt: record.meta.createdAt, + updatedAt: record.meta.updatedAt, + messages, + snapshot, + }; +} + +function isSharedConversationMessage( + value: unknown, +): value is SharedConversationMessage { + if (!value || typeof value !== 'object') return false; + const message = value as Partial; + return ( + (message.role === 'user' + || message.role === 'assistant' + || message.role === 'system') + && typeof message.content === 'string' + ); +} + +export function isSharedConversationDoc( + value: unknown, +): value is SharedConversationDoc { + if (!value || typeof value !== 'object') return false; + const doc = value as Partial; + return ( + doc.kind === SHARED_CONVERSATION_KIND + && doc.v === 1 + && Array.isArray(doc.messages) + && doc.messages.every((message) => isSharedConversationMessage(message)) + ); +} diff --git a/packages/genui/a2ui-playground/src/utils/shareConversation.ts b/packages/genui/a2ui-playground/src/utils/shareConversation.ts new file mode 100644 index 0000000000..31f2b53cfa --- /dev/null +++ b/packages/genui/a2ui-playground/src/utils/shareConversation.ts @@ -0,0 +1,57 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { publishA2UIPayload } from './publishPayload.js'; +import type { SharedConversationDoc } from '../storage/sharedConversation.js'; + +/** Query param that carries the URL of a shared conversation document. */ +export const IMPORT_CONVERSATION_PARAM = 'importConv'; + +/** + * Upload a serialized conversation to the GenUI server (Supabase Storage) and + * return the durable public URL of the stored document. Reuses the generic + * A2UI payload upload path — the server stores the JSON body verbatim, so the + * conversation document is persisted as-is. + */ +export async function publishConversation( + doc: SharedConversationDoc, +): Promise { + const { messagesUrl } = await publishA2UIPayload(doc); + return messagesUrl; +} + +/** + * Build a link that, when opened, imports the conversation into the recipient's + * playground. Pins the hash route to `#//create` so the playground + * lands on the chat tab where the import handler runs. + */ +export function buildConversationShareUrl( + conversationUrl: string, + baseUrl: string, + protocolName: string, +): string { + const url = new URL(baseUrl); + url.search = ''; + url.searchParams.set(IMPORT_CONVERSATION_PARAM, conversationUrl); + url.hash = `#/${protocolName}/create`; + return url.toString(); +} + +export function readImportConversationParam(): string | null { + return new URLSearchParams(window.location.search).get( + IMPORT_CONVERSATION_PARAM, + ); +} + +/** Remove the import param from the URL so a reload does not re-import. */ +export function clearImportConversationParam(): void { + if (!readImportConversationParam()) return; + const url = new URL(window.location.href); + url.searchParams.delete(IMPORT_CONVERSATION_PARAM); + window.history.replaceState( + null, + '', + `${url.pathname}${url.search}${url.hash}`, + ); +}