Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 6 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ The threshold is overridable via `sessionStorage.setItem('agent-chat-compaction-

## 7. Embeddable Chat Widget Architecture

The chat UI is designed to be **embedded as an iframe** in any data-fair application. `lib-vuetify` provides ready-made container components.
The chat UI is designed to be **embedded as an iframe** in any data-fair application. `lib-vuetify` provides ready-made container components: a floating `DfAgentChatDrawer`, a popover `DfAgentChatMenu`, and a flat in-page `DfAgentChatBlock` (always visible, for embedding a custom chat directly in a portal page).

```mermaid
graph TB
Expand Down Expand Up @@ -286,13 +286,16 @@ graph TB
**Communication channels:**
- **BroadcastChannel** — Tool discovery (MCP), session lifecycle (`agent-start-session`, `agent-chat-ready`, ping/pong)
- **postMessage (d-frame)** — Status updates (`agent-status`), unread indicators, tools-changed notifications
- **sessionStorage init-config** — One-shot host→iframe handoff of `systemPrompt` and `title` (keyed by a `?initConfig=<key>` URL param). Replaces URL query params so prompts can be arbitrarily long and stay out of logs/history. See [Embedding Guide §9](./embedding-guide.md#9-initial-configuration-systemprompt--title).

**Singleton composables** (`useAgentChatDrawer`, `useAgentChatMenu`) ensure a single instance across the host app. Drawer state persists to `localStorage`.
**Singleton composables** (`useAgentChatDrawer`, `useAgentChatMenu`, `useAgentChatBlock`) ensure a single instance across the host app. Drawer/menu open state persists to `localStorage`; the block is always open and persists nothing.

**Key files:**
- `lib-vuetify/DfAgentChatDrawer.vue` — Floating drawer with iframe
- `lib-vuetify/DfAgentChatBlock.vue` — Flat in-page chat (always visible)
- `lib-vuetify/DfAgentChatToggle.vue` — FAB with status indicator
- `lib-vuetify/useAgentChatBase.ts` — Shared BroadcastChannel listener, status tracking
- `lib-vuetify/useAgentChatBase.ts` — Shared BroadcastChannel listener, status tracking, iframe URL resolution
- `lib-vue/agent-init-config.ts` — sessionStorage handoff for systemPrompt/title

---

Expand Down
67 changes: 59 additions & 8 deletions docs/embedding-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,28 +41,48 @@ An alternative that renders the chat as a popover menu (400x500px default):

The menu includes a built-in activator button (FAB with status indicator) and a close button. It auto-focuses the iframe on open.

### Block — `DfAgentChatBlock`

Renders the chat iframe **flat inside the page** (no drawer, no popover, no FAB) — it fills its container and is always visible. This is the variant to use for embedding a custom chat directly in a portal page rather than as a floating overlay:

```vue
<template>
<v-card style="height: 600px;">
<DfAgentChatBlock
account-type="organization"
account-id="my-org"
chat-title="Data Assistant"
system-prompt="You help users explore datasets."
/>
</v-card>
</template>
```

Because a block is always open there is no open/close state, no `localStorage` persistence, and no toggle/unread badge — give the parent element an explicit height since the iframe stretches to fill it.

### Props

Both components accept:
All three components (drawer, menu, block) accept:

| Prop | Type | Description |
|------|------|-------------|
| `accountType` | `string` | `'user'` or `'organization'` |
| `accountId` | `string` | Account ID for the chat session |
| `src` | `string` | Custom iframe URL (overrides account-based URL resolution) |
| `chatTitle` | `string` | Title displayed in the chat header |
| `systemPrompt` | `string` | System prompt passed to the LLM |
| `systemPrompt` | `string` | System prompt passed to the LLM (no length limit — see [Initial Configuration](#9-initial-configuration-systemprompt--title)) |
| `initConfigKey` | `string` | Override the per-variant init-config key. Only needed when mounting several instances of the same variant in one tab (defaults to `'drawer'` / `'menu'` / `'block'`) |

Drawer-specific: `drawerProps` (pass-through to `VNavigationDrawer`).
Menu-specific: `btnProps`, `menuProps`, `cardProps` (pass-through to Vuetify components).

**Key files:** `lib-vuetify/DfAgentChatDrawer.vue`, `lib-vuetify/DfAgentChatMenu.vue`, `lib-vuetify/DfAgentChatToggle.vue`
**Key files:** `lib-vuetify/DfAgentChatDrawer.vue`, `lib-vuetify/DfAgentChatMenu.vue`, `lib-vuetify/DfAgentChatBlock.vue`, `lib-vuetify/DfAgentChatToggle.vue`

---

## 2. Singleton State

Both drawer and menu use **singleton composables** — calling them from multiple components returns the same shared state:
Drawer, menu and block each use a **singleton composable** — calling one from multiple components returns the same shared state:

```typescript
import { useAgentChatDrawer } from '@data-fair/lib-vuetify-agents'
Expand All @@ -71,7 +91,7 @@ const state = useAgentChatDrawer()
// state.drawerOpen, state.agentStatus, state.hasUnread, state.fabIcon, state.fabColor
```

Open/close state is persisted to `localStorage` (`df-agent-chat-open` / `df-agent-menu-open`), so the drawer remembers its position across page navigations.
Open/close state is persisted to `localStorage` (`df-agent-chat-open` / `df-agent-menu-open`), so the drawer remembers its position across page navigations. The block has no open/close state (it is always visible) and so persists nothing.

---

Expand Down Expand Up @@ -223,9 +243,37 @@ bc.postMessage({ channel: channelId, type: 'agent-chat-ping' })

The iframe URL is resolved as follows:
1. If `src` prop is provided → use it directly
2. Otherwise → `{origin}/agents/{accountType}/{accountId}/chat?title=...&systemPrompt=...`
2. Otherwise → `{origin}/agents/{accountType}/{accountId}/chat`

The drawer/menu/block components then append a single `?initConfig=<key>` query parameter (see next section). The `chatTitle` and `systemPrompt` are **no longer encoded in the URL** — they are passed through same-origin sessionStorage instead.

> **Legacy / direct-URL embeds:** the chat page still reads `?title=` and `?systemPrompt=` query params directly. This path is kept for backward compatibility (e.g. linking straight to the chat URL), but it is subject to URL length limits — prefer the init-config mechanism for anything but a short prompt.

---

## 9. Initial Configuration (systemPrompt & title)

The `systemPrompt` and `chatTitle` props are handed to the iframe through **same-origin sessionStorage**, not the URL. This is a one-shot "set then get" handoff: the host writes the config before the iframe loads, and the iframe reads it once on mount. It is **not** a reactive channel — changing the prop after the iframe is running does not update the live agent.

**Why not the URL?** The iframe URL passes through nginx, whose default request-line buffer (~8 KB) returns HTTP 414 above it. After percent-encoding (accented text expands ~3×), the safe ceiling for a URL-borne system prompt is only ~2 KB — too small for real custom-agent prompts. URL params also leak into nginx logs, browser history, and `Referer` headers. sessionStorage has no practical size limit and no such exposure, so **custom local chats (e.g. embedded in a portal) can carry an arbitrarily long system prompt.**

How it works:
1. Each component writes `{ prompt, title }` to sessionStorage under a per-variant key via `setAgentInitConfig(key, config)`.
2. The component appends `?initConfig=<key>` to the iframe URL.
3. The chat iframe (`AgentChat.vue`) reads its key from the URL and loads the config via `getAgentInitConfig(key)`, which takes precedence over its props.

The key defaults to the variant name (`'drawer'` / `'menu'` / `'block'`), so one of each can coexist in a tab without clobbering. Mounting several of the **same** variant in one tab requires distinct `initConfigKey` props.

You can also use the helpers directly when building a custom embed:

```typescript
import { setAgentInitConfig } from '@data-fair/lib-vue-agents'

setAgentInitConfig('my-chat', { prompt: 'You are a portal assistant…', title: 'Portal Help' })
// then load the iframe with `…/chat?initConfig=my-chat`
```

Query parameters are URL-encoded. The URL can be customized for different deployments.
**Key file:** `lib-vue/agent-init-config.ts`

---

Expand All @@ -235,11 +283,14 @@ Query parameters are URL-encoded. The URL can be customized for different deploy
|------|------|
| `lib-vuetify/DfAgentChatDrawer.vue` | Floating drawer with iframe |
| `lib-vuetify/DfAgentChatMenu.vue` | Popover menu with iframe |
| `lib-vuetify/DfAgentChatBlock.vue` | Flat in-page chat (always visible) |
| `lib-vuetify/DfAgentChatToggle.vue` | FAB button with status indicator |
| `lib-vuetify/useAgentChatBase.ts` | Shared state factory: status, unread, BroadcastChannel |
| `lib-vuetify/useAgentChatBase.ts` | Shared state factory: status, unread, BroadcastChannel, URL resolution |
| `lib-vuetify/useAgentChatDrawer.ts` | Drawer singleton composable |
| `lib-vuetify/useAgentChatMenu.ts` | Menu singleton composable |
| `lib-vuetify/useAgentChatBlock.ts` | Block singleton composable |
| `lib-vuetify/types.ts` | Message type definitions |
| `lib-vue/agent-init-config.ts` | sessionStorage handoff for systemPrompt/title |
| `lib-vue/use-frame-server.ts` | Expose tools via MCP over BroadcastChannel |
| `lib-vue/use-agent-tools.ts` | Register tools via WebMCP polyfill |
| `lib-vue/use-agent-sub-agent.ts` | Declare sub-agents |
33 changes: 33 additions & 0 deletions lib-vue/agent-init-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Initial configuration handed from the host page to the chat iframe.
*
* This is one-shot "set then get" same-origin state: the host writes it once
* before the iframe loads, the iframe reads it once on mount. It is NOT a
* reactive channel — later changes are not propagated to a running agent.
*
* Extend this object as more initial settings are needed.
*/
export interface AgentInitConfig {
/** System prompt prepended to the agent's context. */
prompt?: string
/** Title shown in the chat header. */
title?: string
}

const INIT_CONFIG_PREFIX = 'df-agent-init-config:'

/** Store the agent's initial configuration under the given key (same-origin sessionStorage). */
export function setAgentInitConfig (key: string, config: AgentInitConfig): void {
sessionStorage.setItem(INIT_CONFIG_PREFIX + key, JSON.stringify(config))
}

/** Read the initial configuration written by the host for the given key, if any. */
export function getAgentInitConfig (key: string): AgentInitConfig | undefined {
const raw = sessionStorage.getItem(INIT_CONFIG_PREFIX + key)
if (!raw) return undefined
try {
return JSON.parse(raw) as AgentInitConfig
} catch {
return undefined
}
}
2 changes: 2 additions & 0 deletions lib-vue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ export type { FrameServerTransportOptions } from './frame-server-transport.js'
export { useAgentTool } from './use-agent-tools.js'
export { useFrameServer } from './use-frame-server.js'
export { getTabChannelId } from './get-tab-channel-id.js'
export { setAgentInitConfig, getAgentInitConfig } from './agent-init-config.js'
export type { AgentInitConfig } from './agent-init-config.js'
export { useAgentSubAgent } from './use-agent-sub-agent.js'
export type { SubAgentOptions } from './use-agent-sub-agent.js'
35 changes: 35 additions & 0 deletions lib-vuetify/DfAgentChatBlock.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<template>
<d-frame
:src="resolvedSrc"
resize="no"
style="height: 100%; width: 100%;"
@message="state.onDFrameMessage"
/>
</template>

<script lang="ts" setup>
import { computed } from 'vue'
import('@data-fair/frame/lib/d-frame.js')
import { setAgentInitConfig } from '@data-fair/lib-vue-agents'
import { useAgentChatBlock } from './useAgentChatBlock.js'
import { resolveAgentChatUrl } from './useAgentChatBase.js'

const props = defineProps<{
accountType?: string
accountId?: string
src?: string
chatTitle?: string
systemPrompt?: string
initConfigKey?: string
}>()

const state = useAgentChatBlock()

// Stable per-variant key (overridable via prop) so a page can host one of each
// variant without clobbering, while keeping sessionStorage bounded — only pass a
// custom key when mounting several blocks in the same tab.
const initConfigKey = props.initConfigKey ?? 'block'
setAgentInitConfig(initConfigKey, { prompt: props.systemPrompt, title: props.chatTitle })

const resolvedSrc = computed(() => resolveAgentChatUrl(props, initConfigKey))
</script>
10 changes: 9 additions & 1 deletion lib-vuetify/DfAgentChatDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { computed } from 'vue'
import { VNavigationDrawer } from 'vuetify/components/VNavigationDrawer'
import('@data-fair/frame/lib/d-frame.js')
import { setAgentInitConfig } from '@data-fair/lib-vue-agents'
import { useAgentChatDrawer } from './useAgentChatDrawer.js'
import { resolveAgentChatUrl } from './useAgentChatBase.js'

Expand All @@ -33,12 +34,19 @@ const props = withDefaults(defineProps<{
src?: string
chatTitle?: string
systemPrompt?: string
initConfigKey?: string
drawerProps?: DrawerProps
}>(), {
drawerProps: () => ({}) as DrawerProps
})

const state = useAgentChatDrawer()

const resolvedSrc = computed(() => resolveAgentChatUrl(props))
// Stable per-variant key (overridable via prop) so a page can host one of each
// variant without clobbering, while keeping sessionStorage bounded — only pass a
// custom key when mounting several drawers in the same tab.
const initConfigKey = props.initConfigKey ?? 'drawer'
setAgentInitConfig(initConfigKey, { prompt: props.systemPrompt, title: props.chatTitle })

const resolvedSrc = computed(() => resolveAgentChatUrl(props, initConfigKey))
</script>
10 changes: 9 additions & 1 deletion lib-vuetify/DfAgentChatMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { VBtn } from 'vuetify/components/VBtn'
import { VBadge } from 'vuetify/components/VBadge'
import { VIcon } from 'vuetify/components/VIcon'
import('@data-fair/frame/lib/d-frame.js')
import { setAgentInitConfig } from '@data-fair/lib-vue-agents'
import { useAgentChatMenu } from './useAgentChatMenu.js'
import { resolveAgentChatUrl } from './useAgentChatBase.js'

Expand All @@ -82,6 +83,7 @@ const props = withDefaults(defineProps<{
chatTitle?: string
systemPrompt?: string
title?: string
initConfigKey?: string
btnProps?: BtnProps
menuProps?: MenuProps
cardProps?: VCard['$props']
Expand All @@ -103,7 +105,13 @@ watch(() => state.menuOpen.value, async (open) => {
}
})

const resolvedSrc = computed(() => resolveAgentChatUrl(props))
// Stable per-variant key (overridable via prop) so a page can host one of each
// variant without clobbering, while keeping sessionStorage bounded — only pass a
// custom key when mounting several menus in the same tab.
const initConfigKey = props.initConfigKey ?? 'menu'
setAgentInitConfig(initConfigKey, { prompt: props.systemPrompt, title: props.chatTitle })

const resolvedSrc = computed(() => resolveAgentChatUrl(props, initConfigKey))

const windowWidth = ref(window.innerWidth)
const windowHeight = ref(window.innerHeight)
Expand Down
3 changes: 3 additions & 0 deletions lib-vuetify/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
export { default as DfAgentChatToggle } from './DfAgentChatToggle.vue'
export { default as DfAgentChatDrawer } from './DfAgentChatDrawer.vue'
export { default as DfAgentChatMenu } from './DfAgentChatMenu.vue'
export { default as DfAgentChatBlock } from './DfAgentChatBlock.vue'
export { default as DfAgentChatAction } from './DfAgentChatAction.vue'
export { useAgentChatDrawer } from './useAgentChatDrawer.js'
export type { AgentChatDrawerState } from './useAgentChatDrawer.js'
export { useAgentChatMenu } from './useAgentChatMenu.js'
export type { AgentChatMenuState } from './useAgentChatMenu.js'
export { useAgentChatBlock } from './useAgentChatBlock.js'
export type { AgentChatBlockState } from './useAgentChatBlock.js'
export type { AgentStatus, AgentStatusMessage, AgentToolsChangedMessage, AgentUnreadMessage, AgentNavigateMessage, AgentChatMessage, AgentActionStartSession, AgentActionSessionCleared, AgentChatReady, AgentChatPing, AgentChatPong, AgentActionMessage } from './types.js'
34 changes: 19 additions & 15 deletions lib-vuetify/useAgentChatBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Debug from './debug.js'

const debug = Debug('df-agents:agent-chat')

export function createAgentChatBase (isOpen: Ref<boolean>, storageKey: string) {
export function createAgentChatBase (isOpen: Ref<boolean>, storageKey?: string) {
const router = useRouter()
const agentStatus = ref<AgentStatus>('idle')
const hasUnread = ref(false)
Expand All @@ -23,7 +23,7 @@ export function createAgentChatBase (isOpen: Ref<boolean>, storageKey: string) {
if (data.type === 'agent-start-session') {
debug('received agent-start-session, opening')
isOpen.value = true
localStorage.setItem(storageKey, '1')
if (storageKey) localStorage.setItem(storageKey, '1')
hasUnread.value = false
} else if (data.type === 'agent-chat-ping') {
debug('received agent-chat-ping, sending pong')
Expand Down Expand Up @@ -55,7 +55,7 @@ export function createAgentChatBase (isOpen: Ref<boolean>, storageKey: string) {
// Clear unread state and persist open state whenever isOpen changes
// (whether via toggle(), v-model, or BroadcastChannel)
watch(isOpen, (open) => {
localStorage.setItem(storageKey, open ? '1' : '0')
if (storageKey) localStorage.setItem(storageKey, open ? '1' : '0')
if (open) {
hasUnread.value = false
}
Expand Down Expand Up @@ -99,21 +99,25 @@ export function createAgentChatBase (isOpen: Ref<boolean>, storageKey: string) {
}
}

/**
* Build the chat iframe URL. When an init-config key is provided it is appended as
* the `initConfig` query param so the iframe knows which stored config to read —
* this is what lets several chats coexist in one tab without clobbering each other.
*/
export function resolveAgentChatUrl (props: {
src?: string
accountType?: string
accountId?: string
chatTitle?: string
systemPrompt?: string
}): string {
if (props.src) return props.src
if (props.accountType && props.accountId) {
const base = `${window.location.origin}/agents/${props.accountType}/${props.accountId}/chat`
const params = new URLSearchParams()
if (props.chatTitle) params.set('title', props.chatTitle)
if (props.systemPrompt) params.set('systemPrompt', props.systemPrompt)
const qs = params.toString()
return qs ? `${base}?${qs}` : base
}, initConfigKey?: string): string {
let url: string
if (props.src) url = props.src
else if (props.accountType && props.accountId) url = `${window.location.origin}/agents/${props.accountType}/${props.accountId}/chat`
else return ''

if (initConfigKey) {
const parsed = new URL(url, window.location.origin)
parsed.searchParams.set('initConfig', initConfigKey)
url = parsed.toString()
}
return ''
return url
}
26 changes: 26 additions & 0 deletions lib-vuetify/useAgentChatBlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ref } from 'vue'
import { createAgentChatBase } from './useAgentChatBase.js'

let singleton: AgentChatBlockState | null = null

function createAgentChatBlock () {
// A block is rendered flat in a page and always visible, so there is no
// open/close state to persist (hence no storage key) — isOpen stays true.
const blockOpen = ref(true)
const base = createAgentChatBase(blockOpen)

return {
agentStatus: base.agentStatus,
onDFrameMessage: base.onDFrameMessage
}
}

export function useAgentChatBlock () {
if (typeof window === 'undefined') {
throw new Error('useAgentChatBlock cannot be used in SSR')
}
if (!singleton) singleton = createAgentChatBlock()
return singleton
}

export type AgentChatBlockState = ReturnType<typeof createAgentChatBlock>
Loading
Loading