@8wave/ai-elements 0.103.0-beta.2 → 0.103.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/web-BMnEaWK7.js +20226 -0
- package/dist/_chunks/web-BMnEaWK7.js.map +1 -0
- package/dist/ai-elements.es.js +4406 -24302
- package/dist/ai-elements.es.js.map +1 -1
- package/dist-vue/PkChatbot.js +1 -1
- package/dist-vue/PkChatbotViewChat.js +1 -1
- package/dist-vue/_chunks/{PkChatbot-C4SPneVX.js → PkChatbot-DZ6TaZHh.js} +2 -2
- package/dist-vue/_chunks/{PkChatbot-C4SPneVX.js.map → PkChatbot-DZ6TaZHh.js.map} +1 -1
- package/dist-vue/_chunks/PkChatbotViewChat-0g30_sQH.js +802 -0
- package/dist-vue/_chunks/PkChatbotViewChat-0g30_sQH.js.map +1 -0
- package/dist-vue/_chunks/{PkChatbotViewChat-BMr1DpNY.js → web-BMnEaWK7.js} +3879 -4355
- package/dist-vue/_chunks/web-BMnEaWK7.js.map +1 -0
- package/dist-vue/index.js +5 -5
- package/package.json +1 -1
- package/dist-vue/_chunks/PkChatbotViewChat-BMr1DpNY.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PkChatbotViewChat-0g30_sQH.js","names":[],"sources":["../../../../packages/composable/src/chatbot/useGreeting.ts","../../../../packages/components/src/chat/PkAuroraCanvas.vue","../../../../packages/components/src/chat/PkAuroraCanvas.vue","../../../../packages/components/src/chat/PkVoiceOrb.vue","../../../../packages/components/src/chat/PkVoiceOrb.vue","../../../../packages/components/src/chat/PkVoiceSession.vue","../../../../packages/components/src/chat/PkVoiceSession.vue","../../../../packages/components/src/chat/useVoiceSession.ts","../../../../packages/components/src/chat/PkChatbotViewChat.vue","../../../../packages/components/src/chat/PkChatbotViewChat.vue"],"sourcesContent":["import { computed, toValue } from 'vue'\nimport type { ComputedRef, MaybeRefOrGetter } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport type { AgentInterface } from 'models'\n\n/**\n * Resolves the greeting shown in the fullscreen empty state.\n *\n * v1 behavior (docs/specs/public-chat-page.md): uses the first entry of the\n * `agentInterface.greeting` pool and supports only the `{name}` placeholder.\n * Fallback chain: `greeting[0]` → `initialMessage` → default welcome message.\n * Pool rotation and `{timeGreeting}` are planned follow-ups.\n */\nexport function useGreeting(\n agentInterface: MaybeRefOrGetter<AgentInterface | undefined>,\n userName: MaybeRefOrGetter<string | undefined>,\n): ComputedRef<string> {\n const { locale, t, te } = useI18n({ useScope: 'global' })\n\n return computed(() => {\n const ui = toValue(agentInterface)\n const template =\n ui?.greeting?.[0]?.[locale.value] ||\n ui?.initialMessage?.[locale.value] ||\n (te('message.defaultAgentWelcome')\n ? t('message.defaultAgentWelcome')\n : '')\n return resolveNamePlaceholder(template, toValue(userName))\n })\n}\n\nfunction resolveNamePlaceholder(template: string, name?: string): string {\n const result = name\n ? template.replace(/\\{name\\}/g, name)\n : // No name available: drop the placeholder along with any\n // adjacent punctuation (\", {name}!\" → \"\")\n template.replace(/[,\\s]*\\{name\\}[!?]?/g, '')\n return result.trim()\n}\n","<script setup lang=\"ts\">\n import {\n nextTick,\n onBeforeUnmount,\n onMounted,\n onUnmounted,\n ref,\n useTemplateRef,\n } from 'vue'\n\n /**\n * Ambient animated \"aurora\" rendered with a raw WebGL fragment shader\n * (simplex noise blending two tints of the inherited `color`). No\n * dependencies; falls back to CSS blobs when WebGL is unavailable.\n * The accent comes from the CSS cascade: set `color` on the host.\n */\n\n const props = defineProps<{\n /** CSS selector (within the positioned parent) of the element the glow radiates from; defaults to the positioned parent box itself */\n sourceSelector?: string\n }>()\n\n const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')\n const failed = ref(false)\n\n const VERTEX_SHADER = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}`\n\n // snoise: 2D simplex noise by Ian McEwan / Stefan Gustavson (MIT)\n const FRAGMENT_SHADER = `\nprecision mediump float;\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform vec3 u_color;\nuniform vec2 u_center;\nuniform vec2 u_half;\nuniform float u_spread;\n\nvec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }\n\nfloat snoise(vec2 v) {\n const vec4 C = vec4(0.211324865405187, 0.366025403784439,\n -0.577350269189626, 0.024390243902439);\n vec2 i = floor(v + dot(v, C.yy));\n vec2 x0 = v - i + dot(i, C.xx);\n vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);\n vec4 x12 = x0.xyxy + C.xxzz;\n x12.xy -= i1;\n i = mod(i, 289.0);\n vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))\n + i.x + vec3(0.0, i1.x, 1.0));\n vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy),\n dot(x12.zw, x12.zw)), 0.0);\n m = m * m;\n m = m * m;\n vec3 x = 2.0 * fract(p * C.www) - 1.0;\n vec3 h = abs(x) - 0.5;\n vec3 ox = floor(x + 0.5);\n vec3 a0 = x - ox;\n m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);\n vec3 g;\n g.x = a0.x * x0.x + h.x * x0.y;\n g.yz = a0.yz * x12.xz + h.yz * x12.yw;\n return 130.0 * dot(m, g);\n}\n\nvoid main() {\n vec2 px = gl_FragCoord.xy;\n vec2 rel = px - u_center;\n\n // Ignition: the ellipse lights up expanding from the center on mount\n float intro = smoothstep(0.0, 1.4, u_time);\n\n // Breathing: size and brightness oscillate gently but visibly\n float breath = 1.0\n + 0.12 * sin(u_time * 0.55)\n + 0.06 * snoise(vec2(u_time * 0.1, 3.7));\n float pulse = 1.0 + 0.2 * sin(u_time * 0.55 + 1.3);\n\n // Keep it simple (Gemini-style): one huge soft elliptical glow\n // centered on the pill\n vec2 radii = vec2(u_half.x * 0.95, u_half.y * 6.5)\n * u_spread\n * breath\n * mix(0.15, 1.0, intro);\n float r = length(rel / max(radii, vec2(1.0)));\n float glow = exp(-r * r * 2.2);\n\n // Never cut hard against the canvas bounds\n vec2 m = min(px, u_resolution - px);\n glow *= smoothstep(0.0, 32.0, min(m.x, m.y));\n\n // Dither: breaks the 8-bit banding rings of the very soft gradient\n // (no CSS blur on the canvas, the noise must fully mask the bands)\n glow = max(glow + (snoise(px * 0.9) - 0.5) * 0.03, 0.0);\n\n float alpha = glow * 0.2 * pulse * intro;\n\n // Premultiplied alpha (default canvas compositing)\n gl_FragColor = vec4(u_color * alpha, alpha);\n}`\n\n let gl: WebGLRenderingContext | null = null\n let rafId = 0\n let resizeObserver: ResizeObserver | null = null\n let timeLocation: WebGLUniformLocation | null = null\n let resolutionLocation: WebGLUniformLocation | null = null\n let centerLocation: WebGLUniformLocation | null = null\n let halfLocation: WebGLUniformLocation | null = null\n let spreadLocation: WebGLUniformLocation | null = null\n const startTime = performance.now()\n\n const compile = (type: number, source: string): WebGLShader | null => {\n if (!gl) {\n return null\n }\n const shader = gl.createShader(type)\n if (!shader) {\n return null\n }\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n return null\n }\n return shader\n }\n\n const parseInheritedColor = (): [number, number, number] | null => {\n if (!canvasEl.value) {\n return null\n }\n const raw = getComputedStyle(canvasEl.value).color\n const match = raw.match(/rgba?\\(([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/)\n if (!match) {\n return null\n }\n return [\n Number(match[1]) / 255,\n Number(match[2]) / 255,\n Number(match[3]) / 255,\n ]\n }\n\n const resize = () => {\n if (!gl || !canvasEl.value) {\n return\n }\n // The glow is blurred anyway: cap the DPR to keep the GPU cost low\n const dpr = Math.min(window.devicePixelRatio || 1, 1.5)\n const { clientWidth, clientHeight } = canvasEl.value\n canvasEl.value.width = Math.max(1, Math.round(clientWidth * dpr))\n canvasEl.value.height = Math.max(1, Math.round(clientHeight * dpr))\n gl.viewport(0, 0, canvasEl.value.width, canvasEl.value.height)\n\n // The glow radiates from the source element (by selector within the\n // positioned parent, or the parent box itself): measure it into\n // shader pixel coordinates (gl origin is bottom-left)\n const host = canvasEl.value.parentElement?.offsetParent\n const source =\n (props.sourceSelector\n ? host?.querySelector(props.sourceSelector)\n : host) ?? host\n const canvasRect = canvasEl.value.getBoundingClientRect()\n const sourceRect = source?.getBoundingClientRect()\n if (sourceRect && canvasRect.width > 0) {\n const scaleX = canvasEl.value.width / canvasRect.width\n const scaleY = canvasEl.value.height / canvasRect.height\n const centerX =\n (sourceRect.left + sourceRect.width / 2 - canvasRect.left) *\n scaleX\n const centerY =\n (canvasRect.bottom - (sourceRect.top + sourceRect.height / 2)) *\n scaleY\n const halfWidth = (sourceRect.width / 2) * scaleX\n const halfHeight = (sourceRect.height / 2) * scaleY\n gl.uniform2f(centerLocation, centerX, centerY)\n gl.uniform2f(halfLocation, halfWidth, halfHeight)\n // Optional responsive boost of the glow size (e.g. mobile)\n const spread = Number.parseFloat(\n getComputedStyle(canvasEl.value).getPropertyValue(\n '--aurora-spread',\n ),\n )\n gl.uniform1f(spreadLocation, Number.isNaN(spread) ? 1 : spread)\n }\n\n // Redraw before the next paint: setting the canvas size reallocates\n // the drawing buffer, and presenting it undrawn can flash as an\n // uninitialized (white) frame. With reduced motion (no loop) this\n // also keeps the settled static frame across resizes.\n drawFrame(prefersReducedMotion() ? 10 : undefined)\n }\n\n const prefersReducedMotion = () =>\n window.matchMedia('(prefers-reduced-motion: reduce)').matches\n\n const drawFrame = (timeSeconds?: number) => {\n if (!gl || !canvasEl.value) {\n return\n }\n gl.uniform1f(\n timeLocation,\n timeSeconds ?? (performance.now() - startTime) / 1000,\n )\n gl.uniform2f(\n resolutionLocation,\n canvasEl.value.width,\n canvasEl.value.height,\n )\n gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)\n }\n\n const loop = () => {\n drawFrame()\n rafId = requestAnimationFrame(loop)\n }\n\n onMounted(() => {\n const canvas = canvasEl.value\n gl = canvas?.getContext('webgl', { alpha: true }) ?? null\n const color = parseInheritedColor()\n if (!canvas || !gl || !color) {\n failed.value = true\n return\n }\n\n const vertex = compile(gl.VERTEX_SHADER, VERTEX_SHADER)\n const fragment = compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER)\n const program = gl.createProgram()\n if (!vertex || !fragment || !program) {\n failed.value = true\n return\n }\n gl.attachShader(program, vertex)\n gl.attachShader(program, fragment)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n failed.value = true\n return\n }\n gl.useProgram(program)\n\n // Fullscreen quad\n const buffer = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),\n gl.STATIC_DRAW,\n )\n const positionLocation = gl.getAttribLocation(program, 'a_position')\n gl.enableVertexAttribArray(positionLocation)\n gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)\n\n timeLocation = gl.getUniformLocation(program, 'u_time')\n resolutionLocation = gl.getUniformLocation(program, 'u_resolution')\n centerLocation = gl.getUniformLocation(program, 'u_center')\n halfLocation = gl.getUniformLocation(program, 'u_half')\n spreadLocation = gl.getUniformLocation(program, 'u_spread')\n gl.uniform3f(gl.getUniformLocation(program, 'u_color'), ...color)\n\n resizeObserver = new ResizeObserver(resize)\n resizeObserver.observe(canvas)\n resize()\n // And again once the rest of the tree exists: the source is usually a\n // sibling rendered after this canvas, so the measure above ran while\n // the selector still matched nothing and silently fell back to the\n // parent box.\n void nextTick(resize)\n\n if (prefersReducedMotion()) {\n // Static frame: the color stays, the motion goes\n drawFrame(10)\n return\n }\n loop()\n })\n\n onBeforeUnmount(() => {\n cancelAnimationFrame(rafId)\n resizeObserver?.disconnect()\n })\n\n // Release the context (Chrome caps live WebGL contexts per page) only\n // after the compositor committed the removal: dropping the backing\n // buffer while the last frame is still on screen paints an undefined\n // (white) frame — a visible flash on the dark theme\n onUnmounted(() => {\n const context = gl\n gl = null\n setTimeout(() => {\n context?.getExtension('WEBGL_lose_context')?.loseContext()\n }, 150)\n })\n</script>\n\n<template>\n <div class=\"pk-aurora\" aria-hidden=\"true\">\n <canvas v-if=\"!failed\" ref=\"canvasEl\" class=\"pk-aurora__canvas\" />\n <div v-else class=\"pk-aurora__fallback\" />\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-aurora {\n position: absolute;\n inset: 0;\n // The accent resolved by the canvas (and the CSS fallback)\n color: var(--chat-page-accent, var(--color-brand));\n pointer-events: none;\n\n &__canvas {\n width: 100%;\n height: 100%;\n // No CSS filter on purpose: a filtered accelerated canvas gets\n // its own compositor surface, which Chrome can present\n // uninitialized (a white flash) when the layer is created or\n // torn down. The glow is smoothed in the shader instead.\n }\n\n // CSS blobs when WebGL is unavailable\n &__fallback {\n position: absolute;\n inset: 0;\n background:\n radial-gradient(\n 45% 65% at 32% 38%,\n color-mix(in srgb, currentColor 26%, transparent),\n transparent 70%\n ),\n radial-gradient(\n 40% 55% at 68% 62%,\n color-mix(in srgb, currentColor 16%, transparent),\n transparent 70%\n );\n filter: blur(48px);\n animation: pk-aurora-drift 14s var(--ease-in-out) infinite alternate;\n\n @media (prefers-reduced-motion: reduce) {\n animation: none;\n }\n }\n }\n\n @keyframes pk-aurora-drift {\n from {\n transform: rotate(-10deg) scale(1);\n }\n\n to {\n transform: rotate(10deg) scale(1.2);\n }\n }\n</style>\n","<script setup lang=\"ts\">\n import {\n nextTick,\n onBeforeUnmount,\n onMounted,\n onUnmounted,\n ref,\n useTemplateRef,\n } from 'vue'\n\n /**\n * Ambient animated \"aurora\" rendered with a raw WebGL fragment shader\n * (simplex noise blending two tints of the inherited `color`). No\n * dependencies; falls back to CSS blobs when WebGL is unavailable.\n * The accent comes from the CSS cascade: set `color` on the host.\n */\n\n const props = defineProps<{\n /** CSS selector (within the positioned parent) of the element the glow radiates from; defaults to the positioned parent box itself */\n sourceSelector?: string\n }>()\n\n const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')\n const failed = ref(false)\n\n const VERTEX_SHADER = `\nattribute vec2 a_position;\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}`\n\n // snoise: 2D simplex noise by Ian McEwan / Stefan Gustavson (MIT)\n const FRAGMENT_SHADER = `\nprecision mediump float;\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform vec3 u_color;\nuniform vec2 u_center;\nuniform vec2 u_half;\nuniform float u_spread;\n\nvec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }\n\nfloat snoise(vec2 v) {\n const vec4 C = vec4(0.211324865405187, 0.366025403784439,\n -0.577350269189626, 0.024390243902439);\n vec2 i = floor(v + dot(v, C.yy));\n vec2 x0 = v - i + dot(i, C.xx);\n vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);\n vec4 x12 = x0.xyxy + C.xxzz;\n x12.xy -= i1;\n i = mod(i, 289.0);\n vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))\n + i.x + vec3(0.0, i1.x, 1.0));\n vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy),\n dot(x12.zw, x12.zw)), 0.0);\n m = m * m;\n m = m * m;\n vec3 x = 2.0 * fract(p * C.www) - 1.0;\n vec3 h = abs(x) - 0.5;\n vec3 ox = floor(x + 0.5);\n vec3 a0 = x - ox;\n m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);\n vec3 g;\n g.x = a0.x * x0.x + h.x * x0.y;\n g.yz = a0.yz * x12.xz + h.yz * x12.yw;\n return 130.0 * dot(m, g);\n}\n\nvoid main() {\n vec2 px = gl_FragCoord.xy;\n vec2 rel = px - u_center;\n\n // Ignition: the ellipse lights up expanding from the center on mount\n float intro = smoothstep(0.0, 1.4, u_time);\n\n // Breathing: size and brightness oscillate gently but visibly\n float breath = 1.0\n + 0.12 * sin(u_time * 0.55)\n + 0.06 * snoise(vec2(u_time * 0.1, 3.7));\n float pulse = 1.0 + 0.2 * sin(u_time * 0.55 + 1.3);\n\n // Keep it simple (Gemini-style): one huge soft elliptical glow\n // centered on the pill\n vec2 radii = vec2(u_half.x * 0.95, u_half.y * 6.5)\n * u_spread\n * breath\n * mix(0.15, 1.0, intro);\n float r = length(rel / max(radii, vec2(1.0)));\n float glow = exp(-r * r * 2.2);\n\n // Never cut hard against the canvas bounds\n vec2 m = min(px, u_resolution - px);\n glow *= smoothstep(0.0, 32.0, min(m.x, m.y));\n\n // Dither: breaks the 8-bit banding rings of the very soft gradient\n // (no CSS blur on the canvas, the noise must fully mask the bands)\n glow = max(glow + (snoise(px * 0.9) - 0.5) * 0.03, 0.0);\n\n float alpha = glow * 0.2 * pulse * intro;\n\n // Premultiplied alpha (default canvas compositing)\n gl_FragColor = vec4(u_color * alpha, alpha);\n}`\n\n let gl: WebGLRenderingContext | null = null\n let rafId = 0\n let resizeObserver: ResizeObserver | null = null\n let timeLocation: WebGLUniformLocation | null = null\n let resolutionLocation: WebGLUniformLocation | null = null\n let centerLocation: WebGLUniformLocation | null = null\n let halfLocation: WebGLUniformLocation | null = null\n let spreadLocation: WebGLUniformLocation | null = null\n const startTime = performance.now()\n\n const compile = (type: number, source: string): WebGLShader | null => {\n if (!gl) {\n return null\n }\n const shader = gl.createShader(type)\n if (!shader) {\n return null\n }\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n return null\n }\n return shader\n }\n\n const parseInheritedColor = (): [number, number, number] | null => {\n if (!canvasEl.value) {\n return null\n }\n const raw = getComputedStyle(canvasEl.value).color\n const match = raw.match(/rgba?\\(([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/)\n if (!match) {\n return null\n }\n return [\n Number(match[1]) / 255,\n Number(match[2]) / 255,\n Number(match[3]) / 255,\n ]\n }\n\n const resize = () => {\n if (!gl || !canvasEl.value) {\n return\n }\n // The glow is blurred anyway: cap the DPR to keep the GPU cost low\n const dpr = Math.min(window.devicePixelRatio || 1, 1.5)\n const { clientWidth, clientHeight } = canvasEl.value\n canvasEl.value.width = Math.max(1, Math.round(clientWidth * dpr))\n canvasEl.value.height = Math.max(1, Math.round(clientHeight * dpr))\n gl.viewport(0, 0, canvasEl.value.width, canvasEl.value.height)\n\n // The glow radiates from the source element (by selector within the\n // positioned parent, or the parent box itself): measure it into\n // shader pixel coordinates (gl origin is bottom-left)\n const host = canvasEl.value.parentElement?.offsetParent\n const source =\n (props.sourceSelector\n ? host?.querySelector(props.sourceSelector)\n : host) ?? host\n const canvasRect = canvasEl.value.getBoundingClientRect()\n const sourceRect = source?.getBoundingClientRect()\n if (sourceRect && canvasRect.width > 0) {\n const scaleX = canvasEl.value.width / canvasRect.width\n const scaleY = canvasEl.value.height / canvasRect.height\n const centerX =\n (sourceRect.left + sourceRect.width / 2 - canvasRect.left) *\n scaleX\n const centerY =\n (canvasRect.bottom - (sourceRect.top + sourceRect.height / 2)) *\n scaleY\n const halfWidth = (sourceRect.width / 2) * scaleX\n const halfHeight = (sourceRect.height / 2) * scaleY\n gl.uniform2f(centerLocation, centerX, centerY)\n gl.uniform2f(halfLocation, halfWidth, halfHeight)\n // Optional responsive boost of the glow size (e.g. mobile)\n const spread = Number.parseFloat(\n getComputedStyle(canvasEl.value).getPropertyValue(\n '--aurora-spread',\n ),\n )\n gl.uniform1f(spreadLocation, Number.isNaN(spread) ? 1 : spread)\n }\n\n // Redraw before the next paint: setting the canvas size reallocates\n // the drawing buffer, and presenting it undrawn can flash as an\n // uninitialized (white) frame. With reduced motion (no loop) this\n // also keeps the settled static frame across resizes.\n drawFrame(prefersReducedMotion() ? 10 : undefined)\n }\n\n const prefersReducedMotion = () =>\n window.matchMedia('(prefers-reduced-motion: reduce)').matches\n\n const drawFrame = (timeSeconds?: number) => {\n if (!gl || !canvasEl.value) {\n return\n }\n gl.uniform1f(\n timeLocation,\n timeSeconds ?? (performance.now() - startTime) / 1000,\n )\n gl.uniform2f(\n resolutionLocation,\n canvasEl.value.width,\n canvasEl.value.height,\n )\n gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)\n }\n\n const loop = () => {\n drawFrame()\n rafId = requestAnimationFrame(loop)\n }\n\n onMounted(() => {\n const canvas = canvasEl.value\n gl = canvas?.getContext('webgl', { alpha: true }) ?? null\n const color = parseInheritedColor()\n if (!canvas || !gl || !color) {\n failed.value = true\n return\n }\n\n const vertex = compile(gl.VERTEX_SHADER, VERTEX_SHADER)\n const fragment = compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER)\n const program = gl.createProgram()\n if (!vertex || !fragment || !program) {\n failed.value = true\n return\n }\n gl.attachShader(program, vertex)\n gl.attachShader(program, fragment)\n gl.linkProgram(program)\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n failed.value = true\n return\n }\n gl.useProgram(program)\n\n // Fullscreen quad\n const buffer = gl.createBuffer()\n gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),\n gl.STATIC_DRAW,\n )\n const positionLocation = gl.getAttribLocation(program, 'a_position')\n gl.enableVertexAttribArray(positionLocation)\n gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)\n\n timeLocation = gl.getUniformLocation(program, 'u_time')\n resolutionLocation = gl.getUniformLocation(program, 'u_resolution')\n centerLocation = gl.getUniformLocation(program, 'u_center')\n halfLocation = gl.getUniformLocation(program, 'u_half')\n spreadLocation = gl.getUniformLocation(program, 'u_spread')\n gl.uniform3f(gl.getUniformLocation(program, 'u_color'), ...color)\n\n resizeObserver = new ResizeObserver(resize)\n resizeObserver.observe(canvas)\n resize()\n // And again once the rest of the tree exists: the source is usually a\n // sibling rendered after this canvas, so the measure above ran while\n // the selector still matched nothing and silently fell back to the\n // parent box.\n void nextTick(resize)\n\n if (prefersReducedMotion()) {\n // Static frame: the color stays, the motion goes\n drawFrame(10)\n return\n }\n loop()\n })\n\n onBeforeUnmount(() => {\n cancelAnimationFrame(rafId)\n resizeObserver?.disconnect()\n })\n\n // Release the context (Chrome caps live WebGL contexts per page) only\n // after the compositor committed the removal: dropping the backing\n // buffer while the last frame is still on screen paints an undefined\n // (white) frame — a visible flash on the dark theme\n onUnmounted(() => {\n const context = gl\n gl = null\n setTimeout(() => {\n context?.getExtension('WEBGL_lose_context')?.loseContext()\n }, 150)\n })\n</script>\n\n<template>\n <div class=\"pk-aurora\" aria-hidden=\"true\">\n <canvas v-if=\"!failed\" ref=\"canvasEl\" class=\"pk-aurora__canvas\" />\n <div v-else class=\"pk-aurora__fallback\" />\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-aurora {\n position: absolute;\n inset: 0;\n // The accent resolved by the canvas (and the CSS fallback)\n color: var(--chat-page-accent, var(--color-brand));\n pointer-events: none;\n\n &__canvas {\n width: 100%;\n height: 100%;\n // No CSS filter on purpose: a filtered accelerated canvas gets\n // its own compositor surface, which Chrome can present\n // uninitialized (a white flash) when the layer is created or\n // torn down. The glow is smoothed in the shader instead.\n }\n\n // CSS blobs when WebGL is unavailable\n &__fallback {\n position: absolute;\n inset: 0;\n background:\n radial-gradient(\n 45% 65% at 32% 38%,\n color-mix(in srgb, currentColor 26%, transparent),\n transparent 70%\n ),\n radial-gradient(\n 40% 55% at 68% 62%,\n color-mix(in srgb, currentColor 16%, transparent),\n transparent 70%\n );\n filter: blur(48px);\n animation: pk-aurora-drift 14s var(--ease-in-out) infinite alternate;\n\n @media (prefers-reduced-motion: reduce) {\n animation: none;\n }\n }\n }\n\n @keyframes pk-aurora-drift {\n from {\n transform: rotate(-10deg) scale(1);\n }\n\n to {\n transform: rotate(10deg) scale(1.2);\n }\n }\n</style>\n","<script lang=\"ts\" setup>\n import { nextTick, onBeforeUnmount, useTemplateRef, watch } from 'vue'\n\n /**\n * Audio-reactive bars for a live voice session: the visible proof that the\n * microphone is open and that the agent is actually speaking.\n *\n * Fed by pull, not by push: the parent hands over samplers and this component\n * calls them once per animation frame. Sixty reactive writes a second through\n * a component tree would cost more than the animation itself, and the levels\n * are only ever needed here.\n */\n const props = withDefaults(\n defineProps<{\n /** Loudness of the channel currently on air, 0 to 1. */\n sample: () => number\n /** Frequency bins of the same channel, when the transport exposes them. */\n spectrum?: () => Uint8Array | undefined\n state: 'idle' | 'connecting' | 'listening' | 'speaking' | 'error'\n bars?: number\n }>(),\n { bars: 5, spectrum: undefined },\n )\n\n const barsEl = useTemplateRef<HTMLElement>('barsEl')\n\n const REST = 0.18\n const SMOOTHING = 0.35\n\n /**\n * Current heights, deliberately NOT reactive: they change every frame and are\n * written straight to a CSS custom property. A ref here would re-render the\n * bars sixty times a second to produce the same DOM.\n */\n let heights = Array.from({ length: props.bars }, () => REST)\n\n let frame: number | undefined\n\n const prefersReducedMotion = () =>\n typeof window !== 'undefined' &&\n window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true\n\n /**\n * One target height per bar. With a spectrum, each bar owns a band, so the\n * shape follows the voice; without it, the single loudness value is spread\n * with a phase offset per bar so it still reads as speech and not as a\n * progress bar.\n */\n const readTargets = (): number[] => {\n const level = Math.min(1, Math.max(0, props.sample()))\n const bins = props.spectrum?.()\n const count = heights.length\n\n if (!bins?.length) {\n const now = Date.now() / 260\n return Array.from({ length: count }, (_unused, index) => {\n const wave = 0.65 + 0.35 * Math.sin(now + index * 0.9)\n return REST + level * 2.4 * wave\n })\n }\n\n // Speech lives in the lower half of the spectrum: using the whole range\n // would leave most bars flat.\n const usable = Math.max(1, Math.floor(bins.length / 2))\n const bandSize = Math.max(1, Math.floor(usable / count))\n return Array.from({ length: count }, (_unused, index) => {\n let total = 0\n for (let bin = 0; bin < bandSize; bin += 1) {\n total += bins[index * bandSize + bin] ?? 0\n }\n return REST + (total / bandSize / 255) * 2.2\n })\n }\n\n const render = () => {\n const nodes = barsEl.value?.children\n if (!nodes) {\n return\n }\n heights.forEach((height, index) => {\n ;(nodes[index] as HTMLElement | undefined)?.style.setProperty(\n '--pk-voice-orb-scale',\n String(height),\n )\n })\n }\n\n const paint = () => {\n const targets = readTargets()\n heights = heights.map((current, index) => {\n const target = Math.min(1, Math.max(REST, targets[index] ?? REST))\n return current + (target - current) * SMOOTHING\n })\n render()\n frame = requestAnimationFrame(paint)\n }\n\n const stopPainting = () => {\n if (frame !== undefined) {\n cancelAnimationFrame(frame)\n frame = undefined\n }\n }\n\n watch(\n () => props.state,\n async (state) => {\n const live = state === 'listening' || state === 'speaking'\n if (!live || prefersReducedMotion()) {\n stopPainting()\n heights = heights.map(() => REST * 1.6)\n // The bars may not be in the DOM yet on the first call.\n await nextTick()\n render()\n return\n }\n if (frame === undefined) {\n frame = requestAnimationFrame(paint)\n }\n },\n { immediate: true },\n )\n\n onBeforeUnmount(stopPainting)\n</script>\n\n<template>\n <div\n class=\"pk-voice-orb\"\n :class=\"`pk-voice-orb--${state}`\"\n aria-hidden=\"true\">\n <div ref=\"barsEl\" class=\"pk-voice-orb__bars\">\n <span\n v-for=\"index in bars\"\n :key=\"index\"\n class=\"pk-voice-orb__bar\" />\n </div>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-voice-orb {\n --pk-voice-orb-color: var(--color-word-3);\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n // The size is a custom property so a host can scale the orb without\n // reaching into its internals, and its default lives here in the\n // fallback rather than as a declaration on this block: declared, it\n // would sit on the element itself and shadow whatever the host sets\n // on an ancestor.\n width: var(--pk-voice-orb-size, var(--spacing-40));\n height: var(--pk-voice-orb-size, var(--spacing-40));\n border-radius: var(--rounded-full);\n background-color: color-mix(\n in srgb,\n var(--pk-voice-orb-color) 12%,\n transparent\n );\n transition: background-color var(--duration-200, 200ms) ease;\n\n &__bars {\n display: flex;\n align-items: center;\n gap: 2px;\n height: var(--pk-voice-orb-bars-height, var(--spacing-20));\n }\n\n &__bar {\n width: 3px;\n height: 100%;\n border-radius: var(--rounded-full);\n background-color: var(--pk-voice-orb-color);\n transform: scaleY(var(--pk-voice-orb-scale, 0.2));\n transform-origin: center;\n }\n\n // The user is on air: the accent colour says \"you are being heard\".\n &--listening {\n --pk-voice-orb-color: var(--color-brand);\n }\n\n &--speaking {\n --pk-voice-orb-color: var(--color-accent);\n }\n\n // A live call breathes even in silence: the bars rest flat when nobody\n // talks, so without this ring an open line is indistinguishable from a\n // dead one.\n &--listening,\n &--speaking {\n animation: pk-voice-orb-live 2s ease-out infinite;\n }\n\n &--error {\n --pk-voice-orb-color: var(--color-danger);\n }\n\n &--connecting &__bar {\n animation: pk-voice-orb-wait 1.2s ease-in-out infinite;\n }\n\n &--connecting &__bar:nth-child(2) {\n animation-delay: 0.15s;\n }\n &--connecting &__bar:nth-child(3) {\n animation-delay: 0.3s;\n }\n &--connecting &__bar:nth-child(4) {\n animation-delay: 0.45s;\n }\n &--connecting &__bar:nth-child(5) {\n animation-delay: 0.6s;\n }\n }\n\n @keyframes pk-voice-orb-wait {\n 0%,\n 100% {\n transform: scaleY(0.25);\n }\n 50% {\n transform: scaleY(0.8);\n }\n }\n\n // A sonar ping: the ring expands and fades, then the snap back to zero is\n // the start of the next ping rather than a visible jump.\n @keyframes pk-voice-orb-live {\n from {\n box-shadow: 0 0 0 0\n color-mix(in srgb, var(--pk-voice-orb-color) 35%, transparent);\n }\n to {\n box-shadow: 0 0 0 var(--spacing-8) transparent;\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .pk-voice-orb__bar {\n animation: none;\n transform: scaleY(0.45);\n }\n\n // Still say \"live\", just without moving: a steady ring instead of the ping.\n .pk-voice-orb--listening,\n .pk-voice-orb--speaking {\n animation: none;\n box-shadow: 0 0 0 3px\n color-mix(in srgb, var(--pk-voice-orb-color) 20%, transparent);\n }\n }\n</style>\n","<script lang=\"ts\" setup>\n import { nextTick, onBeforeUnmount, useTemplateRef, watch } from 'vue'\n\n /**\n * Audio-reactive bars for a live voice session: the visible proof that the\n * microphone is open and that the agent is actually speaking.\n *\n * Fed by pull, not by push: the parent hands over samplers and this component\n * calls them once per animation frame. Sixty reactive writes a second through\n * a component tree would cost more than the animation itself, and the levels\n * are only ever needed here.\n */\n const props = withDefaults(\n defineProps<{\n /** Loudness of the channel currently on air, 0 to 1. */\n sample: () => number\n /** Frequency bins of the same channel, when the transport exposes them. */\n spectrum?: () => Uint8Array | undefined\n state: 'idle' | 'connecting' | 'listening' | 'speaking' | 'error'\n bars?: number\n }>(),\n { bars: 5, spectrum: undefined },\n )\n\n const barsEl = useTemplateRef<HTMLElement>('barsEl')\n\n const REST = 0.18\n const SMOOTHING = 0.35\n\n /**\n * Current heights, deliberately NOT reactive: they change every frame and are\n * written straight to a CSS custom property. A ref here would re-render the\n * bars sixty times a second to produce the same DOM.\n */\n let heights = Array.from({ length: props.bars }, () => REST)\n\n let frame: number | undefined\n\n const prefersReducedMotion = () =>\n typeof window !== 'undefined' &&\n window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true\n\n /**\n * One target height per bar. With a spectrum, each bar owns a band, so the\n * shape follows the voice; without it, the single loudness value is spread\n * with a phase offset per bar so it still reads as speech and not as a\n * progress bar.\n */\n const readTargets = (): number[] => {\n const level = Math.min(1, Math.max(0, props.sample()))\n const bins = props.spectrum?.()\n const count = heights.length\n\n if (!bins?.length) {\n const now = Date.now() / 260\n return Array.from({ length: count }, (_unused, index) => {\n const wave = 0.65 + 0.35 * Math.sin(now + index * 0.9)\n return REST + level * 2.4 * wave\n })\n }\n\n // Speech lives in the lower half of the spectrum: using the whole range\n // would leave most bars flat.\n const usable = Math.max(1, Math.floor(bins.length / 2))\n const bandSize = Math.max(1, Math.floor(usable / count))\n return Array.from({ length: count }, (_unused, index) => {\n let total = 0\n for (let bin = 0; bin < bandSize; bin += 1) {\n total += bins[index * bandSize + bin] ?? 0\n }\n return REST + (total / bandSize / 255) * 2.2\n })\n }\n\n const render = () => {\n const nodes = barsEl.value?.children\n if (!nodes) {\n return\n }\n heights.forEach((height, index) => {\n ;(nodes[index] as HTMLElement | undefined)?.style.setProperty(\n '--pk-voice-orb-scale',\n String(height),\n )\n })\n }\n\n const paint = () => {\n const targets = readTargets()\n heights = heights.map((current, index) => {\n const target = Math.min(1, Math.max(REST, targets[index] ?? REST))\n return current + (target - current) * SMOOTHING\n })\n render()\n frame = requestAnimationFrame(paint)\n }\n\n const stopPainting = () => {\n if (frame !== undefined) {\n cancelAnimationFrame(frame)\n frame = undefined\n }\n }\n\n watch(\n () => props.state,\n async (state) => {\n const live = state === 'listening' || state === 'speaking'\n if (!live || prefersReducedMotion()) {\n stopPainting()\n heights = heights.map(() => REST * 1.6)\n // The bars may not be in the DOM yet on the first call.\n await nextTick()\n render()\n return\n }\n if (frame === undefined) {\n frame = requestAnimationFrame(paint)\n }\n },\n { immediate: true },\n )\n\n onBeforeUnmount(stopPainting)\n</script>\n\n<template>\n <div\n class=\"pk-voice-orb\"\n :class=\"`pk-voice-orb--${state}`\"\n aria-hidden=\"true\">\n <div ref=\"barsEl\" class=\"pk-voice-orb__bars\">\n <span\n v-for=\"index in bars\"\n :key=\"index\"\n class=\"pk-voice-orb__bar\" />\n </div>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-voice-orb {\n --pk-voice-orb-color: var(--color-word-3);\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n // The size is a custom property so a host can scale the orb without\n // reaching into its internals, and its default lives here in the\n // fallback rather than as a declaration on this block: declared, it\n // would sit on the element itself and shadow whatever the host sets\n // on an ancestor.\n width: var(--pk-voice-orb-size, var(--spacing-40));\n height: var(--pk-voice-orb-size, var(--spacing-40));\n border-radius: var(--rounded-full);\n background-color: color-mix(\n in srgb,\n var(--pk-voice-orb-color) 12%,\n transparent\n );\n transition: background-color var(--duration-200, 200ms) ease;\n\n &__bars {\n display: flex;\n align-items: center;\n gap: 2px;\n height: var(--pk-voice-orb-bars-height, var(--spacing-20));\n }\n\n &__bar {\n width: 3px;\n height: 100%;\n border-radius: var(--rounded-full);\n background-color: var(--pk-voice-orb-color);\n transform: scaleY(var(--pk-voice-orb-scale, 0.2));\n transform-origin: center;\n }\n\n // The user is on air: the accent colour says \"you are being heard\".\n &--listening {\n --pk-voice-orb-color: var(--color-brand);\n }\n\n &--speaking {\n --pk-voice-orb-color: var(--color-accent);\n }\n\n // A live call breathes even in silence: the bars rest flat when nobody\n // talks, so without this ring an open line is indistinguishable from a\n // dead one.\n &--listening,\n &--speaking {\n animation: pk-voice-orb-live 2s ease-out infinite;\n }\n\n &--error {\n --pk-voice-orb-color: var(--color-danger);\n }\n\n &--connecting &__bar {\n animation: pk-voice-orb-wait 1.2s ease-in-out infinite;\n }\n\n &--connecting &__bar:nth-child(2) {\n animation-delay: 0.15s;\n }\n &--connecting &__bar:nth-child(3) {\n animation-delay: 0.3s;\n }\n &--connecting &__bar:nth-child(4) {\n animation-delay: 0.45s;\n }\n &--connecting &__bar:nth-child(5) {\n animation-delay: 0.6s;\n }\n }\n\n @keyframes pk-voice-orb-wait {\n 0%,\n 100% {\n transform: scaleY(0.25);\n }\n 50% {\n transform: scaleY(0.8);\n }\n }\n\n // A sonar ping: the ring expands and fades, then the snap back to zero is\n // the start of the next ping rather than a visible jump.\n @keyframes pk-voice-orb-live {\n from {\n box-shadow: 0 0 0 0\n color-mix(in srgb, var(--pk-voice-orb-color) 35%, transparent);\n }\n to {\n box-shadow: 0 0 0 var(--spacing-8) transparent;\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .pk-voice-orb__bar {\n animation: none;\n transform: scaleY(0.45);\n }\n\n // Still say \"live\", just without moving: a steady ring instead of the ping.\n .pk-voice-orb--listening,\n .pk-voice-orb--speaking {\n animation: none;\n box-shadow: 0 0 0 3px\n color-mix(in srgb, var(--pk-voice-orb-color) 20%, transparent);\n }\n }\n</style>\n","<script lang=\"ts\" setup>\n import { computed } from 'vue'\n import { useI18n } from 'vue-i18n'\n import PkVoiceOrb from './PkVoiceOrb.vue'\n import PkChatButton from './PkChatButton.vue'\n\n /**\n * The live voice session, docked where the input pill normally is: while a\n * call is open this replaces it, so there is one place to look and one place\n * to hang up. Presentational on purpose, the session itself is owned by the\n * chat view, which is also what writes the transcript into the conversation.\n */\n const props = defineProps<{\n state: 'idle' | 'connecting' | 'listening' | 'speaking' | 'error'\n /** Loudness of whichever side is on air, sampled per animation frame. */\n sample: () => number\n spectrum?: () => Uint8Array | undefined\n remainingSeconds?: number\n muted?: boolean\n error?: string\n /**\n * The provider's own words for the failure, kept for the tooltip when\n * `error` is our localized copy for it.\n */\n rawError?: string\n /**\n * The socket is still open. The state collapses to 'error' whatever\n * the line is doing, and which actions make sense depends on the line:\n * a live failure is still billing and must offer the hang-up, a dead\n * one has nothing left to hang up.\n */\n live?: boolean\n }>()\n\n const emit = defineEmits<{\n 'toggle-mute': []\n 'hang-up': []\n retry: []\n dismiss: []\n }>()\n\n const { t: $t } = useI18n({\n useScope: 'global',\n })\n\n /** One label for the whole session, because the user reads a state, not a protocol. */\n const stateLabel = computed(() => {\n if (props.error) {\n return $t('message.voiceFailed')\n }\n if (props.state === 'connecting') {\n return $t('message.voiceConnecting')\n }\n if (props.state === 'speaking') {\n return $t('message.voiceSpeaking')\n }\n if (props.muted) {\n return $t('message.voiceMuted')\n }\n return $t('message.voiceListening')\n })\n\n const remainingLabel = computed(() => {\n if (props.remainingSeconds === undefined) {\n return undefined\n }\n const minutes = Math.floor(props.remainingSeconds / 60)\n const seconds = props.remainingSeconds % 60\n return `${minutes}:${String(seconds).padStart(2, '0')}`\n })\n\n /** Warn only when the cap is close enough to interrupt a sentence. */\n const isRunningOut = computed(\n () =>\n props.remainingSeconds !== undefined &&\n props.remainingSeconds <= 30,\n )\n\n /** The line is open: audio is flowing in one direction or the other. */\n const isLive = computed(\n () =>\n !props.error &&\n (props.state === 'listening' || props.state === 'speaking'),\n )\n\n /**\n * Colours the state line: brand while the microphone is yours, accent\n * while the agent holds it, nothing while muted, connecting or failed.\n */\n const stateTone = computed(() => {\n if (!isLive.value) {\n return undefined\n }\n if (props.state === 'speaking') {\n return 'speaking'\n }\n return props.muted ? undefined : 'listening'\n })\n</script>\n\n<template>\n <div\n class=\"pk-voice-session\"\n :class=\"{ 'pk-voice-session--failed': !!error }\">\n <!-- A visualiser of a line that is dead has nothing to visualise: the\n orb kept animating an audio level of zero, which is what made a\n failure look like a call still in progress -->\n <span v-if=\"error\" class=\"pk-voice-session__badge\" aria-hidden=\"true\">\n <VvIcon name=\"ri:error-warning-line\" />\n </span>\n <PkVoiceOrb\n v-else\n :state=\"state\"\n :sample=\"sample\"\n :spectrum=\"spectrum\" />\n\n <!-- Only the state is live: the timer ticks every second and would make\n a screen reader read the whole bar again with it -->\n <div class=\"pk-voice-session__status\" role=\"status\">\n <span\n class=\"pk-voice-session__state\"\n :class=\"stateTone && `pk-voice-session__state--${stateTone}`\">\n <!-- The dot marks the connection, the colour marks whose turn\n it is: it stays on while muted (the line is still open) and\n only the tone drops -->\n <span\n v-if=\"isLive\"\n class=\"pk-voice-session__live-dot\"\n aria-hidden=\"true\" />\n {{ stateLabel }}\n </span>\n <!-- One line, ellipsed, with the whole of it on the tooltip: the\n bar stands where the input pill stands, and a failure that grows\n it to three lines shoves the conversation up the screen. The\n provider's own words survive in the tooltip when the line shows\n our copy for them -->\n <p\n v-if=\"error\"\n class=\"pk-voice-session__detail\"\n :title=\"rawError ?? error\">\n {{ error }}\n </p>\n </div>\n\n <span\n v-if=\"remainingLabel\"\n class=\"pk-voice-session__timer\"\n :class=\"{\n 'pk-voice-session__timer--warning': isRunningOut,\n }\"\n :title=\"$t('hint.voiceRemaining')\"\n aria-hidden=\"true\">\n {{ remainingLabel }}\n </span>\n\n <!-- Same icon buttons as the input pill this bar replaces: a call must\n not look like a different piece of software from the thing it took\n over. Recovering from a failure keeps its words, an icon-only refresh\n is not a recovery path -->\n <div class=\"pk-voice-session__actions\">\n <template v-if=\"error\">\n <PkChatButton\n icon=\"ri:refresh-line\"\n :label=\"$t('action.retry')\"\n @click=\"emit('retry')\" />\n <!-- Two actions, chosen by the line rather than three at once.\n A failure does not always end the call: on a socket still open\n (and still billing) the way out is hanging up, while dismissing\n would hide a line that keeps costing. On a dead socket there is\n nothing left to hang up, and closing gives the input back -->\n <PkChatButton\n v-if=\"live\"\n icon=\"ri:stop-circle-line\"\n modifiers=\"danger\"\n :title=\"$t('action.hangUp')\"\n :aria-label=\"$t('action.hangUp')\"\n @click=\"emit('hang-up')\" />\n <PkChatButton\n v-else\n icon=\"ri:close-line\"\n :label=\"$t('action.close')\"\n @click=\"emit('dismiss')\" />\n </template>\n <template v-else>\n <PkChatButton\n :icon=\"muted ? 'ri:mic-off-line' : 'ri:mic-line'\"\n :title=\"muted ? $t('action.unmute') : $t('action.mute')\"\n :aria-label=\"\n muted ? $t('action.unmute') : $t('action.mute')\n \"\n :aria-pressed=\"muted\"\n @click=\"emit('toggle-mute')\" />\n <PkChatButton\n icon=\"ri:stop-circle-line\"\n modifiers=\"danger\"\n :title=\"$t('action.hangUp')\"\n :aria-label=\"$t('action.hangUp')\"\n @click=\"emit('hang-up')\" />\n </template>\n </div>\n </div>\n</template>\n\n<style lang=\"scss\">\n // Sits where the input sits and is built out of its box: same margins, same\n // rounding, same surface, same resting height, so answering by voice does\n // not move the dock. The error state stays inside it rather than pushing a\n // second block below, which is what made a failure look like a stack trace.\n .pk-voice-session {\n // Above the ambient glow, which is painted by an absolutely positioned\n // sibling: the input pill it replaces raises itself the same way\n position: relative;\n z-index: 1;\n // The dock is 50px tall in the widget and 60px in fullscreen. It is\n // declared, not derived: deriving it from the tallest child means the\n // box moves whenever a child changes, and this box has to be exactly\n // as tall as the input it took the place of.\n --pk-chat-dock-height: var(--spacing-50);\n // Free to be as big as it looks right, now that nothing hangs off it:\n // the height above is a floor no child reaches. It stays well inside\n // that floor, because a filled disc reads heavier than its diameter\n // and the state next to it is what should be read first.\n --pk-voice-orb-size: var(--spacing-32);\n --pk-voice-orb-bars-height: var(--spacing-18);\n // Named, because the failure tint is mixed into it and fullscreen\n // swaps it: a plain `background-color` there would outrank the\n // modifier and paint a failed call as a healthy one.\n --pk-voice-session-surface: var(--color-surface);\n display: flex;\n align-items: center;\n // One row, always. Wrapping put the actions of a failure on a line of\n // their own under a two-line reason, and the dock grew to three rows\n // and shoved the conversation up the screen (reported 2026-08-27).\n flex-wrap: nowrap;\n gap: var(--spacing-12);\n // A floor rather than a height: the failure keeps its reason on a\n // second line, and two lines still fit inside it.\n min-height: var(--pk-chat-dock-height);\n margin-inline: var(--spacing-sm);\n margin-bottom: var(--spacing-sm);\n padding-block: 0;\n padding-inline: var(--spacing-14);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--rounded-xl);\n background-color: var(--pk-voice-session-surface);\n box-shadow: var(--shadow-lg);\n\n @include media-breakpoint-up('sm', $breakpoints) {\n padding-inline: var(--spacing-10);\n }\n\n &--failed {\n background-color: color-mix(\n in srgb,\n var(--color-danger) 6%,\n var(--pk-voice-session-surface)\n );\n border-color: color-mix(\n in srgb,\n var(--color-danger) 24%,\n transparent\n );\n }\n\n // Takes the orb's place and its size, so the row keeps its rhythm: the\n // failure changes what the disc means, not where anything sits.\n &__badge {\n display: flex;\n flex-shrink: 0;\n align-items: center;\n justify-content: center;\n width: var(--pk-voice-orb-size);\n height: var(--pk-voice-orb-size);\n border-radius: var(--rounded-full);\n background-color: color-mix(\n in srgb,\n var(--color-danger) 12%,\n transparent\n );\n color: var(--color-danger);\n font-size: var(--text-18);\n }\n\n // Takes the room that is left and gives all of it back when the\n // actions need it: with a basis of its own it pushed them onto a\n // second row in the widget, which is the one place with no room to\n // spare. The label itself is a single ellipsed line, so it has nothing\n // to lose by shrinking.\n &__status {\n display: flex;\n min-width: 0;\n flex: 1 1 auto;\n flex-direction: column;\n }\n\n &__state {\n overflow: hidden;\n font-size: var(--text-14);\n color: var(--color-word-3);\n text-overflow: ellipsis;\n white-space: nowrap;\n\n // The turn colours match the orb: brand is \"you are being heard\",\n // accent is \"the agent has the floor\". Muted and connecting stay\n // in the resting grey, because grey is what makes these read.\n &--listening {\n color: var(--color-brand);\n font-weight: var(--font-medium, 500);\n }\n\n &--speaking {\n color: var(--color-accent);\n font-weight: var(--font-medium, 500);\n }\n }\n\n // Blinks as long as the socket is up, whatever the colour of the text\n // around it: a live line and an open microphone are two different\n // facts, and muting must not look like hanging up.\n &__live-dot {\n display: inline-block;\n width: var(--spacing-6);\n height: var(--spacing-6);\n margin-inline-end: var(--spacing-4);\n border-radius: var(--rounded-full);\n background-color: currentcolor;\n vertical-align: middle;\n animation: pk-voice-session-live 2s ease-in-out infinite;\n }\n\n // One line, ellipsed: the reason has to fit the dock, and the whole of\n // it is on the title attribute. Clamped to two it made the box grow by\n // a row exactly when the caller was already looking at a failure.\n &__detail {\n overflow: hidden;\n margin: 0;\n color: var(--color-word-3);\n font-size: var(--text-12);\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n // Softer than the title above it: the state line names the failure, the\n // reason explains it, and two lines of full danger read as an alarm.\n &--failed &__detail {\n color: color-mix(\n in srgb,\n var(--color-danger-darken-1) 80%,\n var(--color-word-3)\n );\n }\n\n &--failed &__state {\n color: var(--color-danger-darken-1);\n font-weight: var(--font-medium, 500);\n }\n\n &__timer {\n color: var(--color-word-3);\n font-size: var(--text-12);\n font-variant-numeric: tabular-nums;\n\n &--warning {\n color: var(--color-danger);\n }\n }\n\n // Never squeezed and never wrapped: the way out of a failure is the one\n // thing that must stay reachable, so the reason gives up its width\n // first (it is ellipsed and carries a tooltip).\n &__actions {\n display: flex;\n flex-shrink: 0;\n align-items: center;\n gap: var(--spacing-xs);\n margin-inline-start: auto;\n }\n }\n\n @keyframes pk-voice-session-live {\n 0%,\n 100% {\n opacity: 1;\n }\n 50% {\n opacity: 0.35;\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .pk-voice-session__live-dot {\n animation: none;\n }\n }\n</style>\n","<script lang=\"ts\" setup>\n import { computed } from 'vue'\n import { useI18n } from 'vue-i18n'\n import PkVoiceOrb from './PkVoiceOrb.vue'\n import PkChatButton from './PkChatButton.vue'\n\n /**\n * The live voice session, docked where the input pill normally is: while a\n * call is open this replaces it, so there is one place to look and one place\n * to hang up. Presentational on purpose, the session itself is owned by the\n * chat view, which is also what writes the transcript into the conversation.\n */\n const props = defineProps<{\n state: 'idle' | 'connecting' | 'listening' | 'speaking' | 'error'\n /** Loudness of whichever side is on air, sampled per animation frame. */\n sample: () => number\n spectrum?: () => Uint8Array | undefined\n remainingSeconds?: number\n muted?: boolean\n error?: string\n /**\n * The provider's own words for the failure, kept for the tooltip when\n * `error` is our localized copy for it.\n */\n rawError?: string\n /**\n * The socket is still open. The state collapses to 'error' whatever\n * the line is doing, and which actions make sense depends on the line:\n * a live failure is still billing and must offer the hang-up, a dead\n * one has nothing left to hang up.\n */\n live?: boolean\n }>()\n\n const emit = defineEmits<{\n 'toggle-mute': []\n 'hang-up': []\n retry: []\n dismiss: []\n }>()\n\n const { t: $t } = useI18n({\n useScope: 'global',\n })\n\n /** One label for the whole session, because the user reads a state, not a protocol. */\n const stateLabel = computed(() => {\n if (props.error) {\n return $t('message.voiceFailed')\n }\n if (props.state === 'connecting') {\n return $t('message.voiceConnecting')\n }\n if (props.state === 'speaking') {\n return $t('message.voiceSpeaking')\n }\n if (props.muted) {\n return $t('message.voiceMuted')\n }\n return $t('message.voiceListening')\n })\n\n const remainingLabel = computed(() => {\n if (props.remainingSeconds === undefined) {\n return undefined\n }\n const minutes = Math.floor(props.remainingSeconds / 60)\n const seconds = props.remainingSeconds % 60\n return `${minutes}:${String(seconds).padStart(2, '0')}`\n })\n\n /** Warn only when the cap is close enough to interrupt a sentence. */\n const isRunningOut = computed(\n () =>\n props.remainingSeconds !== undefined &&\n props.remainingSeconds <= 30,\n )\n\n /** The line is open: audio is flowing in one direction or the other. */\n const isLive = computed(\n () =>\n !props.error &&\n (props.state === 'listening' || props.state === 'speaking'),\n )\n\n /**\n * Colours the state line: brand while the microphone is yours, accent\n * while the agent holds it, nothing while muted, connecting or failed.\n */\n const stateTone = computed(() => {\n if (!isLive.value) {\n return undefined\n }\n if (props.state === 'speaking') {\n return 'speaking'\n }\n return props.muted ? undefined : 'listening'\n })\n</script>\n\n<template>\n <div\n class=\"pk-voice-session\"\n :class=\"{ 'pk-voice-session--failed': !!error }\">\n <!-- A visualiser of a line that is dead has nothing to visualise: the\n orb kept animating an audio level of zero, which is what made a\n failure look like a call still in progress -->\n <span v-if=\"error\" class=\"pk-voice-session__badge\" aria-hidden=\"true\">\n <VvIcon name=\"ri:error-warning-line\" />\n </span>\n <PkVoiceOrb\n v-else\n :state=\"state\"\n :sample=\"sample\"\n :spectrum=\"spectrum\" />\n\n <!-- Only the state is live: the timer ticks every second and would make\n a screen reader read the whole bar again with it -->\n <div class=\"pk-voice-session__status\" role=\"status\">\n <span\n class=\"pk-voice-session__state\"\n :class=\"stateTone && `pk-voice-session__state--${stateTone}`\">\n <!-- The dot marks the connection, the colour marks whose turn\n it is: it stays on while muted (the line is still open) and\n only the tone drops -->\n <span\n v-if=\"isLive\"\n class=\"pk-voice-session__live-dot\"\n aria-hidden=\"true\" />\n {{ stateLabel }}\n </span>\n <!-- One line, ellipsed, with the whole of it on the tooltip: the\n bar stands where the input pill stands, and a failure that grows\n it to three lines shoves the conversation up the screen. The\n provider's own words survive in the tooltip when the line shows\n our copy for them -->\n <p\n v-if=\"error\"\n class=\"pk-voice-session__detail\"\n :title=\"rawError ?? error\">\n {{ error }}\n </p>\n </div>\n\n <span\n v-if=\"remainingLabel\"\n class=\"pk-voice-session__timer\"\n :class=\"{\n 'pk-voice-session__timer--warning': isRunningOut,\n }\"\n :title=\"$t('hint.voiceRemaining')\"\n aria-hidden=\"true\">\n {{ remainingLabel }}\n </span>\n\n <!-- Same icon buttons as the input pill this bar replaces: a call must\n not look like a different piece of software from the thing it took\n over. Recovering from a failure keeps its words, an icon-only refresh\n is not a recovery path -->\n <div class=\"pk-voice-session__actions\">\n <template v-if=\"error\">\n <PkChatButton\n icon=\"ri:refresh-line\"\n :label=\"$t('action.retry')\"\n @click=\"emit('retry')\" />\n <!-- Two actions, chosen by the line rather than three at once.\n A failure does not always end the call: on a socket still open\n (and still billing) the way out is hanging up, while dismissing\n would hide a line that keeps costing. On a dead socket there is\n nothing left to hang up, and closing gives the input back -->\n <PkChatButton\n v-if=\"live\"\n icon=\"ri:stop-circle-line\"\n modifiers=\"danger\"\n :title=\"$t('action.hangUp')\"\n :aria-label=\"$t('action.hangUp')\"\n @click=\"emit('hang-up')\" />\n <PkChatButton\n v-else\n icon=\"ri:close-line\"\n :label=\"$t('action.close')\"\n @click=\"emit('dismiss')\" />\n </template>\n <template v-else>\n <PkChatButton\n :icon=\"muted ? 'ri:mic-off-line' : 'ri:mic-line'\"\n :title=\"muted ? $t('action.unmute') : $t('action.mute')\"\n :aria-label=\"\n muted ? $t('action.unmute') : $t('action.mute')\n \"\n :aria-pressed=\"muted\"\n @click=\"emit('toggle-mute')\" />\n <PkChatButton\n icon=\"ri:stop-circle-line\"\n modifiers=\"danger\"\n :title=\"$t('action.hangUp')\"\n :aria-label=\"$t('action.hangUp')\"\n @click=\"emit('hang-up')\" />\n </template>\n </div>\n </div>\n</template>\n\n<style lang=\"scss\">\n // Sits where the input sits and is built out of its box: same margins, same\n // rounding, same surface, same resting height, so answering by voice does\n // not move the dock. The error state stays inside it rather than pushing a\n // second block below, which is what made a failure look like a stack trace.\n .pk-voice-session {\n // Above the ambient glow, which is painted by an absolutely positioned\n // sibling: the input pill it replaces raises itself the same way\n position: relative;\n z-index: 1;\n // The dock is 50px tall in the widget and 60px in fullscreen. It is\n // declared, not derived: deriving it from the tallest child means the\n // box moves whenever a child changes, and this box has to be exactly\n // as tall as the input it took the place of.\n --pk-chat-dock-height: var(--spacing-50);\n // Free to be as big as it looks right, now that nothing hangs off it:\n // the height above is a floor no child reaches. It stays well inside\n // that floor, because a filled disc reads heavier than its diameter\n // and the state next to it is what should be read first.\n --pk-voice-orb-size: var(--spacing-32);\n --pk-voice-orb-bars-height: var(--spacing-18);\n // Named, because the failure tint is mixed into it and fullscreen\n // swaps it: a plain `background-color` there would outrank the\n // modifier and paint a failed call as a healthy one.\n --pk-voice-session-surface: var(--color-surface);\n display: flex;\n align-items: center;\n // One row, always. Wrapping put the actions of a failure on a line of\n // their own under a two-line reason, and the dock grew to three rows\n // and shoved the conversation up the screen (reported 2026-08-27).\n flex-wrap: nowrap;\n gap: var(--spacing-12);\n // A floor rather than a height: the failure keeps its reason on a\n // second line, and two lines still fit inside it.\n min-height: var(--pk-chat-dock-height);\n margin-inline: var(--spacing-sm);\n margin-bottom: var(--spacing-sm);\n padding-block: 0;\n padding-inline: var(--spacing-14);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--rounded-xl);\n background-color: var(--pk-voice-session-surface);\n box-shadow: var(--shadow-lg);\n\n @include media-breakpoint-up('sm', $breakpoints) {\n padding-inline: var(--spacing-10);\n }\n\n &--failed {\n background-color: color-mix(\n in srgb,\n var(--color-danger) 6%,\n var(--pk-voice-session-surface)\n );\n border-color: color-mix(\n in srgb,\n var(--color-danger) 24%,\n transparent\n );\n }\n\n // Takes the orb's place and its size, so the row keeps its rhythm: the\n // failure changes what the disc means, not where anything sits.\n &__badge {\n display: flex;\n flex-shrink: 0;\n align-items: center;\n justify-content: center;\n width: var(--pk-voice-orb-size);\n height: var(--pk-voice-orb-size);\n border-radius: var(--rounded-full);\n background-color: color-mix(\n in srgb,\n var(--color-danger) 12%,\n transparent\n );\n color: var(--color-danger);\n font-size: var(--text-18);\n }\n\n // Takes the room that is left and gives all of it back when the\n // actions need it: with a basis of its own it pushed them onto a\n // second row in the widget, which is the one place with no room to\n // spare. The label itself is a single ellipsed line, so it has nothing\n // to lose by shrinking.\n &__status {\n display: flex;\n min-width: 0;\n flex: 1 1 auto;\n flex-direction: column;\n }\n\n &__state {\n overflow: hidden;\n font-size: var(--text-14);\n color: var(--color-word-3);\n text-overflow: ellipsis;\n white-space: nowrap;\n\n // The turn colours match the orb: brand is \"you are being heard\",\n // accent is \"the agent has the floor\". Muted and connecting stay\n // in the resting grey, because grey is what makes these read.\n &--listening {\n color: var(--color-brand);\n font-weight: var(--font-medium, 500);\n }\n\n &--speaking {\n color: var(--color-accent);\n font-weight: var(--font-medium, 500);\n }\n }\n\n // Blinks as long as the socket is up, whatever the colour of the text\n // around it: a live line and an open microphone are two different\n // facts, and muting must not look like hanging up.\n &__live-dot {\n display: inline-block;\n width: var(--spacing-6);\n height: var(--spacing-6);\n margin-inline-end: var(--spacing-4);\n border-radius: var(--rounded-full);\n background-color: currentcolor;\n vertical-align: middle;\n animation: pk-voice-session-live 2s ease-in-out infinite;\n }\n\n // One line, ellipsed: the reason has to fit the dock, and the whole of\n // it is on the title attribute. Clamped to two it made the box grow by\n // a row exactly when the caller was already looking at a failure.\n &__detail {\n overflow: hidden;\n margin: 0;\n color: var(--color-word-3);\n font-size: var(--text-12);\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n // Softer than the title above it: the state line names the failure, the\n // reason explains it, and two lines of full danger read as an alarm.\n &--failed &__detail {\n color: color-mix(\n in srgb,\n var(--color-danger-darken-1) 80%,\n var(--color-word-3)\n );\n }\n\n &--failed &__state {\n color: var(--color-danger-darken-1);\n font-weight: var(--font-medium, 500);\n }\n\n &__timer {\n color: var(--color-word-3);\n font-size: var(--text-12);\n font-variant-numeric: tabular-nums;\n\n &--warning {\n color: var(--color-danger);\n }\n }\n\n // Never squeezed and never wrapped: the way out of a failure is the one\n // thing that must stay reachable, so the reason gives up its width\n // first (it is ellipsed and carries a tooltip).\n &__actions {\n display: flex;\n flex-shrink: 0;\n align-items: center;\n gap: var(--spacing-xs);\n margin-inline-start: auto;\n }\n }\n\n @keyframes pk-voice-session-live {\n 0%,\n 100% {\n opacity: 1;\n }\n 50% {\n opacity: 0.35;\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .pk-voice-session__live-dot {\n animation: none;\n }\n }\n</style>\n","import { computed, onScopeDispose, ref, shallowRef } from 'vue'\nimport type { Mode, Status, VoiceConversation } from '@elevenlabs/client'\nimport { logger } from 'utils'\n\n/** What the backend hands over to open a session, and nothing more. */\nexport type VoiceSessionGrant = {\n /** Opaque server-side session id, echoed back to us through the provider. */\n sessionId: string\n signedUrl: string\n /** Conversation the spoken turns are persisted into. */\n chatId: string\n maxSessionSeconds: number\n}\n\nexport type VoiceTranscriptEntry = {\n role: 'user' | 'agent'\n text: string\n}\n\n/** Which side of the conversation an audio level belongs to. */\nexport type VoiceChannel = 'input' | 'output'\n\n/**\n * A voice conversation with one of our agents.\n *\n * The provider handles microphone, turn detection and playback; the turn itself\n * is answered by our own pipeline, because the provider-side agent is configured\n * to call our OpenAI-compatible surface as a custom LLM. So there is no chat\n * logic here: this composable owns the session lifecycle, the visible state, the\n * duration cap and the audio levels the visualiser reads, and nothing else.\n *\n * `fetchGrant` and `closeGrant` are injected rather than called from here, so the\n * package stays free of any knowledge about backend URLs or authentication.\n */\nexport const useVoiceSession = (options: {\n fetchGrant: () => Promise<VoiceSessionGrant>\n /** Ends the server-side session, which is also what bills its minutes. */\n closeGrant?: (sessionId: string) => Promise<void>\n onTranscript?: (entry: VoiceTranscriptEntry) => void\n /**\n * The agent stopped speaking, so the conversation persisted server-side is\n * now ahead of what the client shows.\n */\n onTurnEnd?: () => void\n}) => {\n const conversation = shallowRef<VoiceConversation>()\n const status = ref<Status>('disconnected')\n const mode = ref<Mode>('listening')\n const error = ref<string>()\n const transcript = ref<VoiceTranscriptEntry[]>([])\n const remainingSeconds = ref<number>()\n const chatId = ref<string>()\n\n let sessionId: string | undefined\n let capTimer: ReturnType<typeof setInterval> | undefined\n /**\n * Bumped by every start and every stop. The provider's callbacks arrive\n * asynchronously and a late one from a closed session would otherwise clear\n * the state of the next: it would wipe the transcript on screen and close\n * the server-side session that is currently billing. Every callback checks\n * the generation it was registered for.\n */\n let generation = 0\n let disposed = false\n\n const clearCap = () => {\n if (capTimer) {\n clearInterval(capTimer)\n capTimer = undefined\n }\n remainingSeconds.value = undefined\n }\n\n /**\n * The provider enforces the same cap on its side (and our backend refuses a\n * turn past it), but a client that stops on its own is what keeps an idle tab\n * from burning provider minutes in the meantime.\n */\n const startCap = (maxSessionSeconds: number) => {\n // Counted from a deadline instead of decremented once per tick: a\n // background tab throttles intervals to about one a minute, and a\n // countdown would have shown minutes left on a call the provider had\n // already hung up, while our own stop never fired.\n const endsAt = Date.now() + maxSessionSeconds * 1000\n const tick = () => {\n const left = Math.max(0, Math.ceil((endsAt - Date.now()) / 1000))\n remainingSeconds.value = left\n if (left === 0) {\n void stop()\n }\n }\n tick()\n capTimer = setInterval(tick, 1000)\n }\n\n /**\n * Closes one server-side session, exactly once, and only the one it was\n * given. Reading the shared `sessionId` here was a real hazard: a `stop()`\n * still awaiting `endSession()` would pick up the id a new `start()` had\n * just stored and close the session that was live, leaving the provider\n * talking to a record we had deleted (every turn refused, minutes billed).\n */\n const releaseGrant = async (id: string | undefined) => {\n if (!id) {\n return\n }\n if (sessionId === id) {\n sessionId = undefined\n }\n await options.closeGrant?.(id).catch(() => undefined)\n }\n\n const stop = async () => {\n generation += 1\n clearCap()\n const mine = sessionId\n sessionId = undefined\n const active = conversation.value\n conversation.value = undefined\n await active?.endSession().catch(() => undefined)\n await releaseGrant(mine)\n status.value = 'disconnected'\n mode.value = 'listening'\n }\n\n const start = async () => {\n if (disposed || conversation.value || status.value === 'connecting') {\n return\n }\n error.value = undefined\n transcript.value = []\n // Both would otherwise survive into the next call: a stale 'speaking'\n // makes the first mode change of the new one look like the end of a turn\n // and fires a reload for a turn that never happened.\n mode.value = 'listening'\n chatId.value = undefined\n status.value = 'connecting'\n generation += 1\n const mine = generation\n const isCurrent = () => mine === generation && !disposed\n /**\n * The grant this attempt opened, if it got that far. The failure path\n * reads it instead of the shared `sessionId`, for the same reason\n * `releaseGrant` takes an id rather than reading one.\n */\n let ownSessionId: string | undefined\n\n try {\n // The provider SDK is loaded here rather than imported at the top:\n // it carries a WebRTC stack that weighs more than the rest of the\n // widget put together, and a page that never opens a call should\n // not pay for it. Started before the grant so it downloads while\n // the backend prepares the session, and awaited after it, so a\n // failure lands in the same catch that releases the grant. Its\n // rejection is claimed right away, because nothing awaits the\n // promise until then and the browser would report it as unhandled.\n const providerModule = import('@elevenlabs/client')\n void providerModule.catch(() => undefined)\n\n const grant = await options.fetchGrant()\n // The surface may have unmounted, or the user may have given up,\n // while the grant was in flight: closing it here is what keeps an\n // orphan session from billing minutes nobody is listening to.\n if (!isCurrent()) {\n await options\n .closeGrant?.(grant.sessionId)\n .catch(() => undefined)\n return\n }\n sessionId = grant.sessionId\n ownSessionId = grant.sessionId\n chatId.value = grant.chatId\n const { VoiceConversation } = await providerModule\n // Downloading the SDK is a wait the caller can give up on, unlike\n // the ones before it: opening the socket now would start provider\n // minutes for a call nobody is on any more.\n if (!isCurrent()) {\n await releaseGrant(grant.sessionId)\n return\n }\n const conversationStarted = await VoiceConversation.startSession({\n signedUrl: grant.signedUrl,\n // The only channel from this browser to our own callback: the\n // provider forwards it verbatim, and it is what tells the turn\n // which conversation (and which user) it belongs to. Requires\n // the extra-body override to be allowed on the provider agent,\n // which the backend sets when it provisions it.\n customLlmExtraBody: { voice_session_id: grant.sessionId },\n onStatusChange: ({ status: next }) => {\n if (!isCurrent()) {\n return\n }\n status.value = next\n },\n onModeChange: ({ mode: next }) => {\n if (!isCurrent()) {\n return\n }\n const wasSpeaking = mode.value === 'speaking'\n mode.value = next\n if (wasSpeaking && next !== 'speaking') {\n options.onTurnEnd?.()\n }\n },\n onMessage: ({ message, role }) => {\n if (!isCurrent()) {\n return\n }\n const entry = { role, text: message }\n transcript.value = [...transcript.value, entry]\n options.onTranscript?.(entry)\n },\n onError: (message) => {\n if (!isCurrent()) {\n return\n }\n error.value = message\n },\n onDisconnect: (details) => {\n if (!isCurrent()) {\n return\n }\n clearCap()\n conversation.value = undefined\n // Said here rather than trusted to arrive: the interface and\n // the reload poll both read `status`, and a disconnect that\n // left it on 'connected' showed a live call that was over.\n status.value = 'disconnected'\n mode.value = 'listening'\n if (details.reason === 'error') {\n error.value = details.message\n }\n void releaseGrant(grant.sessionId)\n options.onTurnEnd?.()\n },\n })\n\n // Closed while the socket was opening: end it rather than leave a\n // live conversation nothing holds a reference to.\n if (!isCurrent()) {\n await conversationStarted.endSession().catch(() => undefined)\n await releaseGrant(grant.sessionId)\n return\n }\n conversation.value = conversationStarted\n startCap(grant.maxSessionSeconds)\n } catch (caught) {\n // Only ever this attempt's own grant: reading the shared id here\n // closed the session a newer attempt had just opened, and the\n // clearing below took its live conversation and its countdown with\n // it. That leaves the provider talking to a record we deleted, with\n // nothing left on this side to hang up on: every turn refused,\n // minutes billed. A user who starts a call, gives up and starts\n // another is all it takes.\n await releaseGrant(ownSessionId)\n if (!isCurrent()) {\n return\n }\n conversation.value = undefined\n clearCap()\n status.value = 'disconnected'\n error.value =\n caught instanceof Error ? caught.message : String(caught)\n }\n }\n\n /** Puts the failure away so the input can come back. */\n const dismissError = () => {\n error.value = undefined\n }\n\n const setMuted = (muted: boolean) => {\n conversation.value?.setMicMuted(muted)\n }\n\n /**\n * Says something on the caller's behalf, without the microphone.\n *\n * What the caller answers on screen during a call (a form, a set of\n * options) has no way back to the agent otherwise: the turn that drew the\n * widget is long over, and nothing the browser posts reaches a conversation\n * the provider is driving. Sent this way it becomes an ordinary user turn,\n * answered out loud like any other.\n *\n * Returns false when there is no live call to say it into, so the caller\n * can fall back to the written path.\n */\n const sendUserMessage = (text: string): boolean => {\n const active = conversation.value\n if (!active || !text.trim()) {\n return false\n }\n try {\n active.sendUserMessage(text)\n return true\n } catch (caught) {\n logger.warn('The spoken answer could not be sent', caught)\n return false\n }\n }\n\n /**\n * Instantaneous loudness of one side, 0 to 1. A function rather than a ref on\n * purpose: the visualiser samples it once per animation frame, and pushing 60\n * reactive writes a second through the whole component tree would cost more\n * than the animation itself.\n */\n const readLevel = (channel: VoiceChannel): number => {\n const active = conversation.value\n if (!active) {\n return 0\n }\n try {\n return channel === 'input'\n ? active.getInputVolume()\n : active.getOutputVolume()\n } catch {\n // The analyser is gone (session closing): silence is the truth.\n return 0\n }\n }\n\n /** Frequency bins of one side, for a visualiser with actual character. */\n const readSpectrum = (channel: VoiceChannel): Uint8Array | undefined => {\n const active = conversation.value\n if (!active) {\n return undefined\n }\n try {\n return channel === 'input'\n ? active.getInputByteFrequencyData()\n : active.getOutputByteFrequencyData()\n } catch {\n return undefined\n }\n }\n\n // A session outlives a route change unless it is closed here: the provider\n // keeps billing a socket nobody is listening to. A call in progress when the\n // tab goes away gets the same treatment, and `closeGrant` is what settles\n // its minutes.\n const releaseOnPageHide = () => {\n // Order matters here and nowhere else: the page may be killed at any\n // moment, so the request that settles the minutes goes out first (it is\n // sent with `keepalive`, which cannot help a fetch that never started),\n // and only then do we ask the provider to end the socket.\n const mine = sessionId\n sessionId = undefined\n generation += 1\n clearCap()\n void releaseGrant(mine)\n const active = conversation.value\n conversation.value = undefined\n void active?.endSession().catch(() => undefined)\n status.value = 'disconnected'\n }\n if (typeof window !== 'undefined') {\n window.addEventListener('pagehide', releaseOnPageHide)\n }\n\n onScopeDispose(() => {\n disposed = true\n if (typeof window !== 'undefined') {\n window.removeEventListener('pagehide', releaseOnPageHide)\n }\n void stop()\n })\n\n return {\n status,\n mode,\n error,\n transcript,\n remainingSeconds,\n chatId,\n isActive: computed(\n () => status.value === 'connected' || status.value === 'connecting',\n ),\n isConnecting: computed(() => status.value === 'connecting'),\n isSpeaking: computed(\n () => status.value === 'connected' && mode.value === 'speaking',\n ),\n isListening: computed(\n () => status.value === 'connected' && mode.value === 'listening',\n ),\n start,\n stop,\n dismissError,\n setMuted,\n sendUserMessage,\n readLevel,\n readSpectrum,\n }\n}\n","<script setup lang=\"ts\">\n import {\n computed,\n onBeforeUnmount,\n ref,\n useTemplateRef,\n watch,\n defineAsyncComponent,\n } from 'vue'\n import { useI18n } from 'vue-i18n'\n import { useDropZone } from '@vueuse/core'\n import { storeToRefs } from 'pinia'\n import { generateId } from 'ai'\n import PkAuroraCanvas from './PkAuroraCanvas.vue'\n import PkAvatar from './PkAvatar.vue'\n import PkChatbotMessages from './PkChatbotMessages.vue'\n import PkChatbotInput from './PkChatbotInput.vue'\n import PkChatbotEmptyState from './PkChatbotEmptyState.vue'\n import PkVoiceSession from './PkVoiceSession.vue'\n import { useVoiceSession } from './useVoiceSession'\n import { formAnswerAsSpoken } from './utils'\n import {\n useChatbotStore,\n useLocalizedString,\n useGreeting,\n VOICE_LINE_ID_PREFIX,\n } from 'composables'\n import type { UIChatMessage } from 'models'\n\n const PkToolShowForm = defineAsyncComponent(\n () => import('./PkToolShowForm.vue'),\n )\n const PkToolShowContactForm = defineAsyncComponent(\n () => import('./PkToolShowContactForm.vue'),\n )\n const PkToolShowSuggestedReply = defineAsyncComponent(\n () => import('./PkToolShowSuggestedReply.vue'),\n )\n const PkToolShowSources = defineAsyncComponent(\n () => import('./PkToolShowSources.vue'),\n )\n const PkToolRequestGeolocation = defineAsyncComponent(\n () => import('./PkToolRequestGeolocation.vue'),\n )\n const PkToolRequestOAuthConnection = defineAsyncComponent(\n () => import('./PkToolRequestOAuthConnection.vue'),\n )\n const PkToolShowLocation = defineAsyncComponent(\n () => import('./PkToolShowLocation.vue'),\n )\n const PkToolShowDiagram = defineAsyncComponent(\n () => import('./PkToolShowDiagram.vue'),\n )\n\n const props = defineProps<{\n agentId: string\n /** Mirrors the PkChatbot `modifier` prop: `fullscreen` shows a centered empty state (greeting) instead of the welcome message, `panel` behaves like `widget` */\n modifier?: 'widget' | 'fullscreen' | 'panel'\n /** Display name of the current user, used for the `{name}` greeting placeholder */\n userName?: string\n }>()\n\n const emit = defineEmits<{\n 'show-info': [message: UIChatMessage]\n revise: [message: UIChatMessage]\n }>()\n\n const store = useChatbotStore(props.agentId)\n\n const {\n agentInterface,\n agentFileUpload,\n agentVoice,\n actions,\n revisedAnswers,\n messages,\n chat,\n messageFeedbacks,\n feedbackDialogMessage,\n isFeedbackSubmitting,\n isFeedbackSubmitted,\n feedbackSubmitError,\n isLeadSubmitted,\n isLoadingSubmitLead,\n submitLeadError,\n input,\n inputMessagePlaceholder,\n isConversationBlocked,\n baseUrl,\n pendingAttachments,\n isAttachmentLimitReached,\n fileLimitError,\n apiClient,\n isDark,\n } = storeToRefs(store)\n\n const {\n handleSubmit: storeHandleSubmit,\n stopGeneration,\n regenerate,\n onUpvote,\n onDownvote,\n onFeedback,\n onFeedbackSubmit,\n onLeadSubmit,\n startNewChat,\n addToolOutput,\n addToolApprovalResponse,\n handleFileSelect,\n clearFileLimitError,\n refreshMessages,\n appendMessage,\n } = store\n\n const { t: $t } = useI18n({ useScope: 'global' })\n\n const attachmentNotice = computed(() => {\n const err = fileLimitError.value\n if (!err) {\n return undefined\n }\n return err.reason === 'per-chat'\n ? $t('message.fileLimitPerChat', { max: err.max })\n : $t('message.fileLimitPerMessage', { max: err.max })\n })\n\n const dismissableNotice = useLocalizedString(\n () => agentInterface.value?.dismissableNotice,\n )\n\n // Fullscreen empty state: shown until the user sends the first message.\n // The synthetic welcome message is suppressed by the store in fullscreen\n // (`hideWelcomeMessage`), the greeting takes its place.\n const hasUserMessages = computed(() =>\n messages.value.some((message) => message.role === 'user'),\n )\n const showEmptyState = computed(\n () => props.modifier === 'fullscreen' && !hasUserMessages.value,\n )\n const greeting = useGreeting(agentInterface, () => props.userName)\n\n const chatViewEl = useTemplateRef<HTMLDivElement>('chatViewEl')\n\n const handleExpandSourceContext = async (payload: {\n documentId: string\n chunkIndex: number\n }) => {\n const result = await apiClient.value.expandSourceContext(\n props.agentId,\n payload.documentId,\n payload.chunkIndex,\n )\n return result.content\n }\n\n const handleDownloadSource = async (documentId: string) => {\n const result = await apiClient.value.downloadSourceDocument(\n props.agentId,\n documentId,\n )\n window.open(result.downloadUrl, '_blank')\n }\n\n const handleFileDrop = (files: File[] | null) => {\n if (!agentFileUpload.value?.enabled || !files) {\n return\n }\n for (const file of files) {\n handleFileSelect(file)\n }\n }\n\n const { isOverDropZone } = useDropZone(chatViewEl, {\n dataTypes: computed(\n () => agentFileUpload.value?.allowedMimeTypes ?? [],\n ),\n onDrop: handleFileDrop,\n })\n\n // #region voice\n /**\n * A spoken turn is a normal turn: the provider calls our backend, which\n * persists it into this very conversation. So the client does not answer\n * anything here, it only shows the transcript as it arrives and then reloads\n * the conversation from the server, which is the version that also carries\n * sources and tool results. When the call ends the input comes back with the\n * whole exchange above it, ready to continue in writing.\n */\n const isMuted = ref(false)\n // The session closes asynchronously, so `onTurnEnd` can still fire after\n // this view is gone: without the flag it would schedule a reload of a\n // conversation nobody is showing.\n let isUnmounted = false\n\n /**\n * True between the caller finishing a sentence and the answer starting.\n *\n * On a call nothing of the turn passes through this client, so the feed has\n * no way of its own to show that something is happening: the question sits\n * there alone until the answer is written and reloaded, which on a\n * retrieving turn is a long ten seconds of a screen that looks stuck.\n */\n const isAwaitingSpokenAnswer = ref(false)\n\n /**\n * How the conversation catches up with what the server persisted: one\n * sequential chain of reloads, the next scheduled only when the previous\n * one has answered, so slow responses never pile up. It replaces an\n * interval plus a backed-off retry ladder that could overlap each other.\n *\n * While the call is live it looks every {@link POLL_LIVE_MS}. When the\n * call is over (hang-up, provider disconnect, time cap) it keeps looking:\n * the turn that killed the call is by definition longer than the\n * provider's own timeout, and its write can land minutes later. Measured\n * live (2026-08-26, chat FYYaVLwX1UyU3CnQ): answers persisted 172 seconds\n * and 6.5 minutes after their turn started, both past the fixed 60-second\n * grace this replaces, and the screen stayed frozen until a manual reload.\n */\n const POLL_LIVE_MS = 3000\n const POLL_ENDED_BACKOFF_MS = [3000, 5000, 10_000]\n /**\n * The after-call poll never gives up before this, even with nothing on\n * screen looking missing: a write can land late and leave no trace to wait\n * for, because a turn the provider never transcribed has no spoken line of\n * its own.\n */\n const POLL_ENDED_MIN_MS = 60_000\n /**\n * And this much when the call ended badly, which is the case where a turn\n * is certainly still running server-side: the provider hangs up on a turn\n * it considers too slow, and that turn keeps going and is written when it\n * ends. A minute is not enough for it (measured 172 seconds, and once six\n * and a half minutes).\n */\n const POLL_FAILED_MIN_MS = 300_000\n /** And never survives longer than this, whatever is still missing. */\n const POLL_ENDED_MAX_MS = 600_000\n\n let pollTimer: ReturnType<typeof setTimeout> | undefined\n /** Set by the first look that finds the call over: the after-call clock. */\n let callEndedAt: number | undefined\n /**\n * Whether that call ended on a failure, read once when it ended: the\n * caller can dismiss the error from the bar, and the turn it interrupted\n * would not stop running because the message went away.\n */\n let callEndedBadly = false\n let endedBackoffStep = 0\n /**\n * A look is in flight. Guards the one thing a single timer cannot: a poke\n * (a turn that ended, an answer sent from a card) landing while the\n * previous request is still open would fire a second one beside it.\n */\n let isPolling = false\n\n /**\n * Ends the chain. Also puts the working indicator away: whatever it was\n * waiting for is either on screen or never coming, and an indicator left\n * spinning under the conversation promises an answer nobody is producing.\n */\n function stopPolling() {\n clearTimeout(pollTimer)\n pollTimer = undefined\n callEndedAt = undefined\n callEndedBadly = false\n endedBackoffStep = 0\n isAwaitingSpokenAnswer.value = false\n }\n\n /** One timer for the whole chain, so two looks can never overlap. */\n function schedulePoll(delayMs: number) {\n if (isUnmounted) {\n return\n }\n clearTimeout(pollTimer)\n pollTimer = setTimeout(() => {\n void runPoll()\n }, delayMs)\n }\n\n async function runPoll() {\n // The look that is running will schedule the next one, with fresher\n // data than this one would have asked for.\n if (isUnmounted || isPolling) {\n return\n }\n isPolling = true\n let outcome: Awaited<ReturnType<typeof refreshMessages>>\n try {\n outcome = await refreshMessages()\n } finally {\n isPolling = false\n }\n if (isUnmounted) {\n return\n }\n // Everything spoken is on screen: the wait, if any, is over.\n if (outcome === 'caught-up') {\n isAwaitingSpokenAnswer.value = false\n }\n if (voice.isActive.value) {\n callEndedAt = undefined\n callEndedBadly = false\n endedBackoffStep = 0\n schedulePoll(POLL_LIVE_MS)\n return\n }\n if (callEndedAt === undefined) {\n callEndedAt = Date.now()\n callEndedBadly = !!voice.error.value\n }\n const elapsed = Date.now() - callEndedAt\n // Only a reload that was actually applied and found nothing missing\n // ends the chain, and never before the floor: 'indeterminate' (a\n // failed request, a stale answer) keeps it alive, or a transient\n // failure would strand the last turn.\n const floorMs = callEndedBadly ? POLL_FAILED_MIN_MS : POLL_ENDED_MIN_MS\n const settled = outcome === 'caught-up' && elapsed >= floorMs\n if (settled || elapsed >= POLL_ENDED_MAX_MS) {\n stopPolling()\n return\n }\n const delay =\n POLL_ENDED_BACKOFF_MS[\n Math.min(endedBackoffStep, POLL_ENDED_BACKOFF_MS.length - 1)\n ]\n endedBackoffStep += 1\n schedulePoll(delay)\n }\n\n /** Pulls the next look closer: a turn just ended, an answer was sent. */\n const pokePoll = (delayMs = 1200) => {\n schedulePoll(delayMs)\n }\n\n const voice = useVoiceSession({\n fetchGrant: async () => {\n const grant = await apiClient.value.createVoiceSession(\n props.agentId,\n store.localChatId,\n {\n deviceContext: store.buildDeviceContext(),\n externalContext: store.externalContext,\n },\n )\n // The backend decides which conversation the call writes into, and\n // it may not be the one asked for: a stale id (a deleted chat, or\n // one left over from another agent) opens a new conversation rather\n // than failing the call. Follow it, or the transcript would land\n // somewhere this view is not showing.\n if (grant.chatId !== store.localChatId) {\n store.localChatId = grant.chatId\n }\n return grant\n },\n closeGrant: (sessionId) =>\n apiClient.value.closeVoiceSession(props.agentId, sessionId),\n onTranscript: (entry) => {\n // What the AGENT says never becomes a message here, only the\n // written turn does. The transcript is text and nothing else, so\n // showing it meant a plain paragraph appearing and then being\n // swapped for the real answer with its sources and its widgets a\n // second later, and any part of it the server never writes (a\n // filler, a sentence the provider reworded) hanging around as the\n // last message until a manual reload (reported 2026-08-27). The\n // wait is shown by `isAwaitingSpokenAnswer` instead, and the\n // answer arrives once, whole.\n if (entry.role !== 'user') {\n // It is still the end of the wait: the agent has the floor.\n isAwaitingSpokenAnswer.value = false\n return\n }\n // The caller stopped talking: from here until the answer lands,\n // the turn is ours to show as working.\n isAwaitingSpokenAnswer.value = true\n appendMessage({\n // Marked as its own id space so a reload of the persisted\n // conversation replaces this rather than duplicating it, and\n // keeps it while the server does not have it yet.\n id: `${VOICE_LINE_ID_PREFIX}user-${generateId()}`,\n role: 'user',\n parts: [{ type: 'text', text: entry.text }],\n })\n },\n // The persisted answer arrives a moment after the provider finished\n // speaking it, so the reload waits for the write to land. The wait\n // indicator is NOT cleared here: a turn can end with nothing spoken\n // (the provider cut it), and the poll clearing it on caught-up is the\n // only signal that tells that apart from an answer on its way.\n onTurnEnd: () => {\n pokePoll()\n },\n })\n\n // A failure takes the stage: the error bar says what happened, and a\n // working indicator under it would promise an answer that may never come.\n watch(\n () => voice.error.value,\n (failure) => {\n if (failure) {\n isAwaitingSpokenAnswer.value = false\n }\n },\n )\n\n const isVoiceEnabled = computed(() => agentVoice.value?.enabled === true)\n\n /** What the session bar and the visualiser render. */\n const voiceState = computed(() => {\n if (voice.error.value) {\n return 'error' as const\n }\n if (voice.isConnecting.value) {\n return 'connecting' as const\n }\n if (voice.isSpeaking.value) {\n return 'speaking' as const\n }\n return voice.isActive.value ? ('listening' as const) : ('idle' as const)\n })\n\n /**\n * What the failure says on screen.\n *\n * Never the provider's own words: those are English prose written for\n * whoever wired the integration (\"custom_llm_error: LLM Cascade Error:\n * Brain returned no response\"), and passing the unrecognized ones through\n * put exactly that in front of a caller (production, 2026-08-27). The two\n * families we can tell apart get copy that says what to do about them,\n * everything else gets the honest generic one, and the raw text stays in\n * the tooltip for whoever is debugging.\n */\n const voiceErrorText = computed(() => {\n const raw = voice.error.value\n if (!raw) {\n return undefined\n }\n if (/took too long/i.test(raw)) {\n return $t('message.voiceTookTooLong')\n }\n // Their word for \"the turn produced nothing I could speak\", whatever\n // broke underneath: a cascade error, an empty completion, a refused\n // model. Trying again is the one thing that helps.\n if (/cascade|no response|custom_llm_error/i.test(raw)) {\n return $t('message.voiceNoAnswer')\n }\n return $t('message.voiceInterrupted')\n })\n\n /** Whoever is on air feeds the bars: the user, then the agent answering. */\n const voiceChannel = computed<'input' | 'output'>(() =>\n voice.isSpeaking.value ? 'output' : 'input',\n )\n const sampleVoiceLevel = () => voice.readLevel(voiceChannel.value)\n const sampleVoiceSpectrum = () => voice.readSpectrum(voiceChannel.value)\n\n const startVoice = async () => {\n isMuted.value = false\n await voice.start()\n if (voice.isActive.value) {\n // A fresh call restarts the clock of the chain: whatever the\n // previous one still owed, this one is live again.\n stopPolling()\n schedulePoll(POLL_LIVE_MS)\n }\n }\n\n const stopVoice = async () => {\n isAwaitingSpokenAnswer.value = false\n await voice.stop()\n if (isUnmounted) {\n return\n }\n // First look right away; the chain deliberately survives the call\n // (see POLL_ENDED_MIN_MS) and covers the write that lands later.\n pokePoll(0)\n }\n\n /** Leaves the failure behind and gives the written input back. */\n const dismissVoiceError = () => {\n voice.dismissError()\n }\n\n /** Same button, one step: forget the failure and open a new call. */\n const retryVoice = async () => {\n voice.dismissError()\n // The provider can report a failure on a socket that is still open,\n // and `start()` refuses while one exists: without this stop the retry\n // button only dismissed the error and never opened a new call.\n await voice.stop()\n await startVoice()\n }\n\n /**\n * Tool calls this client answered while the call was up, by id.\n *\n * On a call the answer does not go back to the tool call (it goes to the\n * agent as speech), so nothing in the persisted part ever changes to say it\n * was given: without this the same widget stays answerable forever and the\n * caller can send the same thing three times.\n */\n const readToolCallId = (part: unknown) =>\n (part as { toolCallId?: string })?.toolCallId ?? ''\n\n const answeredOnCall = ref(new Set<string>())\n const isAnsweredOnCall = (part: unknown) =>\n answeredOnCall.value.has(readToolCallId(part))\n\n /**\n * The verdicts of the confirms answered ON A CALL, by toolCallId. A spoken\n * verdict never resolves the tool part (the answer travels as a user\n * message), so without this the card kept its buttons and every extra tap\n * said \"Confermo\" again (three times in a row, measured 2026-08-07).\n * Recorded only when the send went through: `answerOnCall` leaves a failed\n * send unanswered on purpose, so the caller can tap again.\n */\n const confirmVerdictsOnCall = ref(new Map<string, boolean>())\n\n /**\n * Something the caller answered on screen during a call.\n *\n * In writing an answer resumes the suspended tool call, which is what makes\n * the agent continue. On a call there is no suspended call to resume: the\n * turn that drew the widget closed it on its own (a call cannot wait for a\n * screen), so the answer is said on the caller's behalf and comes back\n * spoken, in the same conversation.\n *\n * Returns true when it was said, and then the written path must NOT run:\n * `addToolOutput` would resume the turn in writing and the caller would get\n * two answers, one of them silent.\n */\n const answerOnCall = (part: unknown, spoken: string): boolean => {\n if (!voice.isActive.value) {\n return false\n }\n if (voice.sendUserMessage(spoken)) {\n answeredOnCall.value = new Set(answeredOnCall.value).add(\n readToolCallId(part),\n )\n pokePoll()\n return true\n }\n // The call is up and the answer did not get through. Saying it in\n // writing here would answer a question nobody asked in writing, so the\n // widget stays as it is and the caller can try again.\n return true\n }\n\n /** A form or a set of options, answered on screen. */\n const onFormAnswer = (\n tool: 'showForm' | 'showMultipleChoice',\n part: unknown,\n answer: string | { answers: { id: string; value: unknown }[] },\n ) => {\n if (answerOnCall(part, formAnswerAsSpoken(part, answer))) {\n return\n }\n addToolOutput({\n tool,\n toolCallId: readToolCallId(part),\n output: answer,\n })\n }\n\n /** A confirmation card, answered on screen. */\n const onConfirmRespond = (payload: {\n toolCallId: string\n confirmed: boolean\n }) => {\n const spoken = payload.confirmed\n ? $t('message.voiceConfirmed')\n : $t('message.voiceCancelled')\n if (answerOnCall({ toolCallId: payload.toolCallId }, spoken)) {\n if (answeredOnCall.value.has(payload.toolCallId)) {\n confirmVerdictsOnCall.value = new Map(\n confirmVerdictsOnCall.value,\n ).set(payload.toolCallId, payload.confirmed)\n }\n return\n }\n addToolOutput({\n tool: 'requestConfirm',\n toolCallId: payload.toolCallId,\n output: payload.confirmed ? 'confirmed' : 'cancelled',\n })\n }\n\n /** A suggested reply: tapping it is saying it. */\n const onSuggestedReply = (part: unknown, reply: string) => {\n if (answerOnCall(part, reply)) {\n return\n }\n input.value = reply\n storeHandleSubmit()\n }\n\n /** The position the caller shared from the browser. */\n const onGeolocation = (\n part: unknown,\n result: {\n latitude?: number\n longitude?: number\n displayName?: string\n error?: string\n },\n ) => {\n const spoken = result.error\n ? $t('message.voiceLocationRefused')\n : $t('message.voiceLocationShared', {\n place:\n result.displayName ??\n `${result.latitude}, ${result.longitude}`,\n })\n if (answerOnCall(part, spoken)) {\n return\n }\n addToolOutput({\n tool: 'requestGeolocation',\n toolCallId: readToolCallId(part),\n output: result,\n })\n }\n\n /** An integration the caller connected from the card on screen. */\n const onOAuthConnected = (part: unknown, mcpServerId: string) => {\n if (answerOnCall(part, $t('message.voiceConnectionDone'))) {\n return\n }\n addToolOutput({\n tool: 'requestOAuthConnection',\n toolCallId: readToolCallId(part),\n output: { connected: true, mcpServerId },\n })\n }\n\n const toggleVoiceMute = () => {\n isMuted.value = !isMuted.value\n voice.setMuted(isMuted.value)\n }\n\n onBeforeUnmount(() => {\n isUnmounted = true\n stopPolling()\n })\n // #endregion voice\n</script>\n\n<template>\n <div\n ref=\"chatViewEl\"\n class=\"pk-chatbot-view-chat\"\n :class=\"{\n 'pk-chatbot-view-chat--dragover':\n isOverDropZone && agentFileUpload?.enabled,\n 'pk-chatbot-view-chat--empty': showEmptyState,\n }\">\n <!-- #region empty state (fullscreen) / messages — crossfade out-in -->\n <Transition name=\"pk-chatbot-view-chat-fade\" mode=\"out-in\">\n <PkChatbotEmptyState\n v-if=\"showEmptyState\"\n :greeting=\"greeting\"\n :logo=\"agentInterface?.logo\"\n :name=\"store.name\" />\n <PkChatbotMessages\n v-else\n class=\"flex flex-col flex-1 min-h-0 p-md overflow-y-auto\"\n interactive\n :messages=\"messages\"\n :confirm-verdicts-on-call=\"confirmVerdictsOnCall\"\n :status=\"chat.status\"\n :error=\"chat.error\"\n :main-color=\"agentInterface?.mainColor\"\n :text-color=\"agentInterface?.textColor\"\n :revised-answers=\"revisedAnswers\"\n :actions=\"actions\"\n :message-feedbacks=\"messageFeedbacks\"\n :feedback-message-id=\"feedbackDialogMessage?.id\"\n :feedback-loading=\"isFeedbackSubmitting\"\n :feedback-submitted=\"isFeedbackSubmitted\"\n :feedback-error=\"feedbackSubmitError\"\n :show-extended-steps=\"agentInterface?.showExtendedSteps\"\n :awaiting-spoken-answer=\"isAwaitingSpokenAnswer\"\n :is-dark=\"isDark\"\n :show-scroll-to-bottom=\"modifier === 'fullscreen'\"\n @feedback-submit=\"onFeedbackSubmit($event)\"\n @feedback-close=\"feedbackDialogMessage = undefined\"\n @regenerate=\"regenerate\"\n @auto-retry=\"regenerate\"\n @reset-chat=\"startNewChat\"\n @show-info=\"emit('show-info', $event)\"\n @revise=\"emit('revise', $event)\"\n @upvote=\"onUpvote\"\n @downvote=\"onDownvote\"\n @feedback=\"onFeedback\"\n @approval-respond=\"addToolApprovalResponse\"\n @confirm-respond=\"onConfirmRespond\">\n <template #tool-showContactForm=\"{ part }\">\n <PkToolShowContactForm\n :part\n :readonly=\"!baseUrl\"\n :submitted=\"isLeadSubmitted\"\n :loading=\"isLoadingSubmitLead\"\n :error=\"submitLeadError\"\n :privacy-policy-notice=\"\n agentInterface?.privacyPolicyNotice\n \"\n @submit=\"onLeadSubmit\" />\n </template>\n <template #tool-showSuggestedReply=\"{ part }\">\n <PkToolShowSuggestedReply\n :part\n @select=\"onSuggestedReply(part, $event)\" />\n </template>\n <template #tool-showSources=\"{ part }\">\n <PkToolShowSources\n :part\n :on-expand-context=\"handleExpandSourceContext\"\n :on-download=\"handleDownloadSource\" />\n </template>\n <template #tool-showForm=\"{ part }\">\n <transition mode=\"out-in\">\n <PkToolShowForm\n :part\n :answered=\"isAnsweredOnCall(part)\"\n @select=\"onFormAnswer('showForm', part, $event)\" />\n </transition>\n </template>\n <template #tool-showMultipleChoice=\"{ part }\">\n <transition mode=\"out-in\">\n <PkToolShowForm\n :part\n allow-custom-answer\n :answered=\"isAnsweredOnCall(part)\"\n @select=\"\n onFormAnswer('showMultipleChoice', part, $event)\n \" />\n </transition>\n </template>\n <template #tool-requestOAuthConnection=\"{ part }\">\n <PkToolRequestOAuthConnection\n :part\n :resolve-connection=\"\n (serverName: string) =>\n apiClient.getOAuthAuthorizeUrl(\n props.agentId,\n serverName,\n )\n \"\n :check-connection=\"\n (serverName: string) =>\n apiClient.getOAuthConnectionStatus(\n props.agentId,\n serverName,\n )\n \"\n @connected=\"onOAuthConnected(part, $event)\" />\n </template>\n <template #tool-requestGeolocation=\"{ part }\">\n <PkToolRequestGeolocation\n :part\n :call-live=\"voice.isActive.value\"\n :reverse-geocode=\"\n (lat: number, lon: number) =>\n apiClient.reverseGeocode(lat, lon)\n \"\n @result=\"onGeolocation(part, $event)\" />\n </template>\n <template #tool-showLocation=\"{ part }\">\n <PkToolShowLocation\n :part\n :is-dark\n :main-color=\"agentInterface?.mainColor\"\n :forward-geocode=\"\n (query: string, lang?: string) =>\n apiClient.forwardGeocode(query, lang)\n \" />\n </template>\n <template #tool-showDiagram=\"{ part }\">\n <PkToolShowDiagram :part :is-dark />\n </template>\n </PkChatbotMessages>\n </Transition>\n <!-- #endregion -->\n\n <!-- #region input -->\n <div class=\"pk-chatbot-view-chat__input-stage\">\n <!-- Animated accent glow radiating from the input pill -->\n <Transition name=\"pk-chatbot-view-chat-fade\">\n <PkAuroraCanvas\n v-if=\"showEmptyState\"\n class=\"pk-chatbot-view-chat__aurora\"\n source-selector=\".pk-chatbot-input__form\" />\n </Transition>\n <div\n v-if=\"isConversationBlocked\"\n class=\"p-md border-t border-surface-3 text-center text-12 text-danger-darken-2 bg-surface-danger\">\n {{ $t('message.chatErrorConversationBlocked') }}\n </div>\n <!-- A live call takes the place of the input: one thing on air, one\n way to hang up, and the conversation stays above it -->\n <PkVoiceSession\n v-else-if=\"voiceState !== 'idle'\"\n :state=\"voiceState\"\n :sample=\"sampleVoiceLevel\"\n :spectrum=\"sampleVoiceSpectrum\"\n :remaining-seconds=\"voice.remainingSeconds.value\"\n :muted=\"isMuted\"\n :error=\"voiceErrorText\"\n :raw-error=\"voice.error.value\"\n :live=\"voice.isActive.value\"\n class=\"pk-chatbot-view-chat__voice\"\n @toggle-mute=\"toggleVoiceMute\"\n @hang-up=\"stopVoice\"\n @retry=\"retryVoice\"\n @dismiss=\"dismissVoiceError\" />\n <PkChatbotInput\n v-else\n v-model=\"input\"\n v-model:pending-attachments=\"pendingAttachments\"\n :placeholder=\"inputMessagePlaceholder\"\n :dismissable-notice=\"\n dismissableNotice && chat.messages.length <= 1\n ? dismissableNotice\n : undefined\n \"\n :status=\"chat.status\"\n :max-message-length=\"agentInterface?.maxMessageLength\"\n :file-upload=\"agentFileUpload\"\n :attachment-limit-reached=\"isAttachmentLimitReached\"\n :attachment-notice=\"attachmentNotice\"\n :voice-enabled=\"isVoiceEnabled\"\n @stop-generation=\"stopGeneration\"\n @submit=\"storeHandleSubmit\"\n @file-select=\"handleFileSelect\"\n @dismiss-attachment-notice=\"clearFileLimitError\"\n @voice-start=\"startVoice\">\n <!-- Fullscreen has no header: the agent identity lives in the\n input pill -->\n <template v-if=\"modifier === 'fullscreen'\" #prepend>\n <span class=\"pk-chatbot-view-chat__agent-chip\">\n <PkAvatar\n modifiers=\"surface\"\n class=\"pk-chatbot-view-chat__agent-chip-avatar\"\n :img-src=\"agentInterface?.logo\"\n :name=\"store.name\" />\n <strong\n v-if=\"store.name\"\n class=\"pk-chatbot-view-chat__agent-chip-name\">\n {{ store.name }}\n </strong>\n </span>\n </template>\n </PkChatbotInput>\n </div>\n <!-- #endregion -->\n <Transition>\n <div\n v-if=\"isOverDropZone && agentFileUpload?.enabled\"\n class=\"pk-chatbot-view-chat__drop-overlay\">\n <VvIcon\n name=\"ri:upload-cloud-2-line\"\n class=\"pk-chatbot-view-chat__drop-overlay-icon\" />\n <span>{{ $t('action.dropFile') }}</span>\n </div>\n </Transition>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-chatbot-view-chat {\n position: relative;\n display: flex;\n flex-direction: column;\n flex: 1;\n min-height: 0;\n\n // Empty state (fullscreen): center the greeting + input block\n // vertically in the available space\n &--empty {\n justify-content: center;\n gap: var(--spacing-md);\n\n // On phones the input docks to the bottom edge instead\n // (Gemini-like), with the greeting centered in the space above\n @include media-breakpoint-down('xs', $breakpoints) {\n justify-content: flex-start;\n\n .pk-chatbot-empty-state {\n flex: 1;\n justify-content: center;\n }\n }\n }\n\n // Anchors the glow to the input pill\n &__input-stage {\n position: relative;\n }\n\n // Radiates outwards from the pill, biased upwards (the pill itself\n // paints above, being later in the DOM)\n &__input-stage &__aurora {\n inset: calc(-1 * var(--spacing-224)) calc(-1 * var(--spacing-96))\n calc(-1 * var(--spacing-160));\n\n // With the input docked at the bottom the glow becomes a wide\n // dome filling the lower part of the screen (Gemini-like)\n @include media-breakpoint-down('xs', $breakpoints) {\n --aurora-spread: 1.6;\n\n // Tall enough that the glow tail never meets the canvas\n // edge fade (it would draw a visible horizontal band)\n inset: calc(-1 * var(--spacing-384) - var(--spacing-128))\n calc(-1 * var(--spacing-96)) calc(-1 * var(--spacing-160));\n }\n }\n\n // Agent identity inside the input pill (fullscreen has no header)\n &__agent-chip {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-6);\n flex-shrink: 0;\n padding: var(--spacing-4) var(--spacing-10) var(--spacing-4)\n var(--spacing-4);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--rounded-full);\n background-color: var(--color-surface);\n\n // Don't crowd the textarea on small screens\n @include media-breakpoint-down('md', $breakpoints) {\n display: none;\n }\n }\n\n &__agent-chip-avatar {\n width: var(--spacing-24);\n height: var(--spacing-24);\n font-size: var(--text-10);\n border-radius: var(--rounded-full);\n }\n\n &__agent-chip-name {\n font-size: var(--text-12);\n font-weight: var(--font-semibold);\n color: var(--color-word-2);\n white-space: nowrap;\n }\n\n // Crossfade empty state ↔ messages: exit faster than enter so the\n // switch never feels like a snap\n &-fade-enter-active {\n transition: opacity 250ms var(--ease-out);\n }\n\n &-fade-leave-active {\n transition: opacity 150ms var(--ease-out);\n }\n\n &-fade-enter-from,\n &-fade-leave-to {\n opacity: 0;\n }\n\n @media (prefers-reduced-motion: reduce) {\n &-fade-enter-active,\n &-fade-leave-active {\n transition: none;\n }\n }\n\n &__drop-overlay {\n position: absolute;\n inset: var(--spacing-sm) var(--spacing-sm) var(--spacing-sm)\n var(--spacing-sm);\n z-index: 1;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: var(--spacing-xs);\n background-color: color-mix(\n in srgb,\n var(--color-surface) 85%,\n transparent\n );\n border-radius: var(--rounded-xl);\n border: var(--spacing-2) dashed var(--color-surface-5);\n pointer-events: none;\n color: var(--color-word-3);\n\n &-icon {\n font-size: var(--spacing-32);\n }\n }\n }\n</style>\n","<script setup lang=\"ts\">\n import {\n computed,\n onBeforeUnmount,\n ref,\n useTemplateRef,\n watch,\n defineAsyncComponent,\n } from 'vue'\n import { useI18n } from 'vue-i18n'\n import { useDropZone } from '@vueuse/core'\n import { storeToRefs } from 'pinia'\n import { generateId } from 'ai'\n import PkAuroraCanvas from './PkAuroraCanvas.vue'\n import PkAvatar from './PkAvatar.vue'\n import PkChatbotMessages from './PkChatbotMessages.vue'\n import PkChatbotInput from './PkChatbotInput.vue'\n import PkChatbotEmptyState from './PkChatbotEmptyState.vue'\n import PkVoiceSession from './PkVoiceSession.vue'\n import { useVoiceSession } from './useVoiceSession'\n import { formAnswerAsSpoken } from './utils'\n import {\n useChatbotStore,\n useLocalizedString,\n useGreeting,\n VOICE_LINE_ID_PREFIX,\n } from 'composables'\n import type { UIChatMessage } from 'models'\n\n const PkToolShowForm = defineAsyncComponent(\n () => import('./PkToolShowForm.vue'),\n )\n const PkToolShowContactForm = defineAsyncComponent(\n () => import('./PkToolShowContactForm.vue'),\n )\n const PkToolShowSuggestedReply = defineAsyncComponent(\n () => import('./PkToolShowSuggestedReply.vue'),\n )\n const PkToolShowSources = defineAsyncComponent(\n () => import('./PkToolShowSources.vue'),\n )\n const PkToolRequestGeolocation = defineAsyncComponent(\n () => import('./PkToolRequestGeolocation.vue'),\n )\n const PkToolRequestOAuthConnection = defineAsyncComponent(\n () => import('./PkToolRequestOAuthConnection.vue'),\n )\n const PkToolShowLocation = defineAsyncComponent(\n () => import('./PkToolShowLocation.vue'),\n )\n const PkToolShowDiagram = defineAsyncComponent(\n () => import('./PkToolShowDiagram.vue'),\n )\n\n const props = defineProps<{\n agentId: string\n /** Mirrors the PkChatbot `modifier` prop: `fullscreen` shows a centered empty state (greeting) instead of the welcome message, `panel` behaves like `widget` */\n modifier?: 'widget' | 'fullscreen' | 'panel'\n /** Display name of the current user, used for the `{name}` greeting placeholder */\n userName?: string\n }>()\n\n const emit = defineEmits<{\n 'show-info': [message: UIChatMessage]\n revise: [message: UIChatMessage]\n }>()\n\n const store = useChatbotStore(props.agentId)\n\n const {\n agentInterface,\n agentFileUpload,\n agentVoice,\n actions,\n revisedAnswers,\n messages,\n chat,\n messageFeedbacks,\n feedbackDialogMessage,\n isFeedbackSubmitting,\n isFeedbackSubmitted,\n feedbackSubmitError,\n isLeadSubmitted,\n isLoadingSubmitLead,\n submitLeadError,\n input,\n inputMessagePlaceholder,\n isConversationBlocked,\n baseUrl,\n pendingAttachments,\n isAttachmentLimitReached,\n fileLimitError,\n apiClient,\n isDark,\n } = storeToRefs(store)\n\n const {\n handleSubmit: storeHandleSubmit,\n stopGeneration,\n regenerate,\n onUpvote,\n onDownvote,\n onFeedback,\n onFeedbackSubmit,\n onLeadSubmit,\n startNewChat,\n addToolOutput,\n addToolApprovalResponse,\n handleFileSelect,\n clearFileLimitError,\n refreshMessages,\n appendMessage,\n } = store\n\n const { t: $t } = useI18n({ useScope: 'global' })\n\n const attachmentNotice = computed(() => {\n const err = fileLimitError.value\n if (!err) {\n return undefined\n }\n return err.reason === 'per-chat'\n ? $t('message.fileLimitPerChat', { max: err.max })\n : $t('message.fileLimitPerMessage', { max: err.max })\n })\n\n const dismissableNotice = useLocalizedString(\n () => agentInterface.value?.dismissableNotice,\n )\n\n // Fullscreen empty state: shown until the user sends the first message.\n // The synthetic welcome message is suppressed by the store in fullscreen\n // (`hideWelcomeMessage`), the greeting takes its place.\n const hasUserMessages = computed(() =>\n messages.value.some((message) => message.role === 'user'),\n )\n const showEmptyState = computed(\n () => props.modifier === 'fullscreen' && !hasUserMessages.value,\n )\n const greeting = useGreeting(agentInterface, () => props.userName)\n\n const chatViewEl = useTemplateRef<HTMLDivElement>('chatViewEl')\n\n const handleExpandSourceContext = async (payload: {\n documentId: string\n chunkIndex: number\n }) => {\n const result = await apiClient.value.expandSourceContext(\n props.agentId,\n payload.documentId,\n payload.chunkIndex,\n )\n return result.content\n }\n\n const handleDownloadSource = async (documentId: string) => {\n const result = await apiClient.value.downloadSourceDocument(\n props.agentId,\n documentId,\n )\n window.open(result.downloadUrl, '_blank')\n }\n\n const handleFileDrop = (files: File[] | null) => {\n if (!agentFileUpload.value?.enabled || !files) {\n return\n }\n for (const file of files) {\n handleFileSelect(file)\n }\n }\n\n const { isOverDropZone } = useDropZone(chatViewEl, {\n dataTypes: computed(\n () => agentFileUpload.value?.allowedMimeTypes ?? [],\n ),\n onDrop: handleFileDrop,\n })\n\n // #region voice\n /**\n * A spoken turn is a normal turn: the provider calls our backend, which\n * persists it into this very conversation. So the client does not answer\n * anything here, it only shows the transcript as it arrives and then reloads\n * the conversation from the server, which is the version that also carries\n * sources and tool results. When the call ends the input comes back with the\n * whole exchange above it, ready to continue in writing.\n */\n const isMuted = ref(false)\n // The session closes asynchronously, so `onTurnEnd` can still fire after\n // this view is gone: without the flag it would schedule a reload of a\n // conversation nobody is showing.\n let isUnmounted = false\n\n /**\n * True between the caller finishing a sentence and the answer starting.\n *\n * On a call nothing of the turn passes through this client, so the feed has\n * no way of its own to show that something is happening: the question sits\n * there alone until the answer is written and reloaded, which on a\n * retrieving turn is a long ten seconds of a screen that looks stuck.\n */\n const isAwaitingSpokenAnswer = ref(false)\n\n /**\n * How the conversation catches up with what the server persisted: one\n * sequential chain of reloads, the next scheduled only when the previous\n * one has answered, so slow responses never pile up. It replaces an\n * interval plus a backed-off retry ladder that could overlap each other.\n *\n * While the call is live it looks every {@link POLL_LIVE_MS}. When the\n * call is over (hang-up, provider disconnect, time cap) it keeps looking:\n * the turn that killed the call is by definition longer than the\n * provider's own timeout, and its write can land minutes later. Measured\n * live (2026-08-26, chat FYYaVLwX1UyU3CnQ): answers persisted 172 seconds\n * and 6.5 minutes after their turn started, both past the fixed 60-second\n * grace this replaces, and the screen stayed frozen until a manual reload.\n */\n const POLL_LIVE_MS = 3000\n const POLL_ENDED_BACKOFF_MS = [3000, 5000, 10_000]\n /**\n * The after-call poll never gives up before this, even with nothing on\n * screen looking missing: a write can land late and leave no trace to wait\n * for, because a turn the provider never transcribed has no spoken line of\n * its own.\n */\n const POLL_ENDED_MIN_MS = 60_000\n /**\n * And this much when the call ended badly, which is the case where a turn\n * is certainly still running server-side: the provider hangs up on a turn\n * it considers too slow, and that turn keeps going and is written when it\n * ends. A minute is not enough for it (measured 172 seconds, and once six\n * and a half minutes).\n */\n const POLL_FAILED_MIN_MS = 300_000\n /** And never survives longer than this, whatever is still missing. */\n const POLL_ENDED_MAX_MS = 600_000\n\n let pollTimer: ReturnType<typeof setTimeout> | undefined\n /** Set by the first look that finds the call over: the after-call clock. */\n let callEndedAt: number | undefined\n /**\n * Whether that call ended on a failure, read once when it ended: the\n * caller can dismiss the error from the bar, and the turn it interrupted\n * would not stop running because the message went away.\n */\n let callEndedBadly = false\n let endedBackoffStep = 0\n /**\n * A look is in flight. Guards the one thing a single timer cannot: a poke\n * (a turn that ended, an answer sent from a card) landing while the\n * previous request is still open would fire a second one beside it.\n */\n let isPolling = false\n\n /**\n * Ends the chain. Also puts the working indicator away: whatever it was\n * waiting for is either on screen or never coming, and an indicator left\n * spinning under the conversation promises an answer nobody is producing.\n */\n function stopPolling() {\n clearTimeout(pollTimer)\n pollTimer = undefined\n callEndedAt = undefined\n callEndedBadly = false\n endedBackoffStep = 0\n isAwaitingSpokenAnswer.value = false\n }\n\n /** One timer for the whole chain, so two looks can never overlap. */\n function schedulePoll(delayMs: number) {\n if (isUnmounted) {\n return\n }\n clearTimeout(pollTimer)\n pollTimer = setTimeout(() => {\n void runPoll()\n }, delayMs)\n }\n\n async function runPoll() {\n // The look that is running will schedule the next one, with fresher\n // data than this one would have asked for.\n if (isUnmounted || isPolling) {\n return\n }\n isPolling = true\n let outcome: Awaited<ReturnType<typeof refreshMessages>>\n try {\n outcome = await refreshMessages()\n } finally {\n isPolling = false\n }\n if (isUnmounted) {\n return\n }\n // Everything spoken is on screen: the wait, if any, is over.\n if (outcome === 'caught-up') {\n isAwaitingSpokenAnswer.value = false\n }\n if (voice.isActive.value) {\n callEndedAt = undefined\n callEndedBadly = false\n endedBackoffStep = 0\n schedulePoll(POLL_LIVE_MS)\n return\n }\n if (callEndedAt === undefined) {\n callEndedAt = Date.now()\n callEndedBadly = !!voice.error.value\n }\n const elapsed = Date.now() - callEndedAt\n // Only a reload that was actually applied and found nothing missing\n // ends the chain, and never before the floor: 'indeterminate' (a\n // failed request, a stale answer) keeps it alive, or a transient\n // failure would strand the last turn.\n const floorMs = callEndedBadly ? POLL_FAILED_MIN_MS : POLL_ENDED_MIN_MS\n const settled = outcome === 'caught-up' && elapsed >= floorMs\n if (settled || elapsed >= POLL_ENDED_MAX_MS) {\n stopPolling()\n return\n }\n const delay =\n POLL_ENDED_BACKOFF_MS[\n Math.min(endedBackoffStep, POLL_ENDED_BACKOFF_MS.length - 1)\n ]\n endedBackoffStep += 1\n schedulePoll(delay)\n }\n\n /** Pulls the next look closer: a turn just ended, an answer was sent. */\n const pokePoll = (delayMs = 1200) => {\n schedulePoll(delayMs)\n }\n\n const voice = useVoiceSession({\n fetchGrant: async () => {\n const grant = await apiClient.value.createVoiceSession(\n props.agentId,\n store.localChatId,\n {\n deviceContext: store.buildDeviceContext(),\n externalContext: store.externalContext,\n },\n )\n // The backend decides which conversation the call writes into, and\n // it may not be the one asked for: a stale id (a deleted chat, or\n // one left over from another agent) opens a new conversation rather\n // than failing the call. Follow it, or the transcript would land\n // somewhere this view is not showing.\n if (grant.chatId !== store.localChatId) {\n store.localChatId = grant.chatId\n }\n return grant\n },\n closeGrant: (sessionId) =>\n apiClient.value.closeVoiceSession(props.agentId, sessionId),\n onTranscript: (entry) => {\n // What the AGENT says never becomes a message here, only the\n // written turn does. The transcript is text and nothing else, so\n // showing it meant a plain paragraph appearing and then being\n // swapped for the real answer with its sources and its widgets a\n // second later, and any part of it the server never writes (a\n // filler, a sentence the provider reworded) hanging around as the\n // last message until a manual reload (reported 2026-08-27). The\n // wait is shown by `isAwaitingSpokenAnswer` instead, and the\n // answer arrives once, whole.\n if (entry.role !== 'user') {\n // It is still the end of the wait: the agent has the floor.\n isAwaitingSpokenAnswer.value = false\n return\n }\n // The caller stopped talking: from here until the answer lands,\n // the turn is ours to show as working.\n isAwaitingSpokenAnswer.value = true\n appendMessage({\n // Marked as its own id space so a reload of the persisted\n // conversation replaces this rather than duplicating it, and\n // keeps it while the server does not have it yet.\n id: `${VOICE_LINE_ID_PREFIX}user-${generateId()}`,\n role: 'user',\n parts: [{ type: 'text', text: entry.text }],\n })\n },\n // The persisted answer arrives a moment after the provider finished\n // speaking it, so the reload waits for the write to land. The wait\n // indicator is NOT cleared here: a turn can end with nothing spoken\n // (the provider cut it), and the poll clearing it on caught-up is the\n // only signal that tells that apart from an answer on its way.\n onTurnEnd: () => {\n pokePoll()\n },\n })\n\n // A failure takes the stage: the error bar says what happened, and a\n // working indicator under it would promise an answer that may never come.\n watch(\n () => voice.error.value,\n (failure) => {\n if (failure) {\n isAwaitingSpokenAnswer.value = false\n }\n },\n )\n\n const isVoiceEnabled = computed(() => agentVoice.value?.enabled === true)\n\n /** What the session bar and the visualiser render. */\n const voiceState = computed(() => {\n if (voice.error.value) {\n return 'error' as const\n }\n if (voice.isConnecting.value) {\n return 'connecting' as const\n }\n if (voice.isSpeaking.value) {\n return 'speaking' as const\n }\n return voice.isActive.value ? ('listening' as const) : ('idle' as const)\n })\n\n /**\n * What the failure says on screen.\n *\n * Never the provider's own words: those are English prose written for\n * whoever wired the integration (\"custom_llm_error: LLM Cascade Error:\n * Brain returned no response\"), and passing the unrecognized ones through\n * put exactly that in front of a caller (production, 2026-08-27). The two\n * families we can tell apart get copy that says what to do about them,\n * everything else gets the honest generic one, and the raw text stays in\n * the tooltip for whoever is debugging.\n */\n const voiceErrorText = computed(() => {\n const raw = voice.error.value\n if (!raw) {\n return undefined\n }\n if (/took too long/i.test(raw)) {\n return $t('message.voiceTookTooLong')\n }\n // Their word for \"the turn produced nothing I could speak\", whatever\n // broke underneath: a cascade error, an empty completion, a refused\n // model. Trying again is the one thing that helps.\n if (/cascade|no response|custom_llm_error/i.test(raw)) {\n return $t('message.voiceNoAnswer')\n }\n return $t('message.voiceInterrupted')\n })\n\n /** Whoever is on air feeds the bars: the user, then the agent answering. */\n const voiceChannel = computed<'input' | 'output'>(() =>\n voice.isSpeaking.value ? 'output' : 'input',\n )\n const sampleVoiceLevel = () => voice.readLevel(voiceChannel.value)\n const sampleVoiceSpectrum = () => voice.readSpectrum(voiceChannel.value)\n\n const startVoice = async () => {\n isMuted.value = false\n await voice.start()\n if (voice.isActive.value) {\n // A fresh call restarts the clock of the chain: whatever the\n // previous one still owed, this one is live again.\n stopPolling()\n schedulePoll(POLL_LIVE_MS)\n }\n }\n\n const stopVoice = async () => {\n isAwaitingSpokenAnswer.value = false\n await voice.stop()\n if (isUnmounted) {\n return\n }\n // First look right away; the chain deliberately survives the call\n // (see POLL_ENDED_MIN_MS) and covers the write that lands later.\n pokePoll(0)\n }\n\n /** Leaves the failure behind and gives the written input back. */\n const dismissVoiceError = () => {\n voice.dismissError()\n }\n\n /** Same button, one step: forget the failure and open a new call. */\n const retryVoice = async () => {\n voice.dismissError()\n // The provider can report a failure on a socket that is still open,\n // and `start()` refuses while one exists: without this stop the retry\n // button only dismissed the error and never opened a new call.\n await voice.stop()\n await startVoice()\n }\n\n /**\n * Tool calls this client answered while the call was up, by id.\n *\n * On a call the answer does not go back to the tool call (it goes to the\n * agent as speech), so nothing in the persisted part ever changes to say it\n * was given: without this the same widget stays answerable forever and the\n * caller can send the same thing three times.\n */\n const readToolCallId = (part: unknown) =>\n (part as { toolCallId?: string })?.toolCallId ?? ''\n\n const answeredOnCall = ref(new Set<string>())\n const isAnsweredOnCall = (part: unknown) =>\n answeredOnCall.value.has(readToolCallId(part))\n\n /**\n * The verdicts of the confirms answered ON A CALL, by toolCallId. A spoken\n * verdict never resolves the tool part (the answer travels as a user\n * message), so without this the card kept its buttons and every extra tap\n * said \"Confermo\" again (three times in a row, measured 2026-08-07).\n * Recorded only when the send went through: `answerOnCall` leaves a failed\n * send unanswered on purpose, so the caller can tap again.\n */\n const confirmVerdictsOnCall = ref(new Map<string, boolean>())\n\n /**\n * Something the caller answered on screen during a call.\n *\n * In writing an answer resumes the suspended tool call, which is what makes\n * the agent continue. On a call there is no suspended call to resume: the\n * turn that drew the widget closed it on its own (a call cannot wait for a\n * screen), so the answer is said on the caller's behalf and comes back\n * spoken, in the same conversation.\n *\n * Returns true when it was said, and then the written path must NOT run:\n * `addToolOutput` would resume the turn in writing and the caller would get\n * two answers, one of them silent.\n */\n const answerOnCall = (part: unknown, spoken: string): boolean => {\n if (!voice.isActive.value) {\n return false\n }\n if (voice.sendUserMessage(spoken)) {\n answeredOnCall.value = new Set(answeredOnCall.value).add(\n readToolCallId(part),\n )\n pokePoll()\n return true\n }\n // The call is up and the answer did not get through. Saying it in\n // writing here would answer a question nobody asked in writing, so the\n // widget stays as it is and the caller can try again.\n return true\n }\n\n /** A form or a set of options, answered on screen. */\n const onFormAnswer = (\n tool: 'showForm' | 'showMultipleChoice',\n part: unknown,\n answer: string | { answers: { id: string; value: unknown }[] },\n ) => {\n if (answerOnCall(part, formAnswerAsSpoken(part, answer))) {\n return\n }\n addToolOutput({\n tool,\n toolCallId: readToolCallId(part),\n output: answer,\n })\n }\n\n /** A confirmation card, answered on screen. */\n const onConfirmRespond = (payload: {\n toolCallId: string\n confirmed: boolean\n }) => {\n const spoken = payload.confirmed\n ? $t('message.voiceConfirmed')\n : $t('message.voiceCancelled')\n if (answerOnCall({ toolCallId: payload.toolCallId }, spoken)) {\n if (answeredOnCall.value.has(payload.toolCallId)) {\n confirmVerdictsOnCall.value = new Map(\n confirmVerdictsOnCall.value,\n ).set(payload.toolCallId, payload.confirmed)\n }\n return\n }\n addToolOutput({\n tool: 'requestConfirm',\n toolCallId: payload.toolCallId,\n output: payload.confirmed ? 'confirmed' : 'cancelled',\n })\n }\n\n /** A suggested reply: tapping it is saying it. */\n const onSuggestedReply = (part: unknown, reply: string) => {\n if (answerOnCall(part, reply)) {\n return\n }\n input.value = reply\n storeHandleSubmit()\n }\n\n /** The position the caller shared from the browser. */\n const onGeolocation = (\n part: unknown,\n result: {\n latitude?: number\n longitude?: number\n displayName?: string\n error?: string\n },\n ) => {\n const spoken = result.error\n ? $t('message.voiceLocationRefused')\n : $t('message.voiceLocationShared', {\n place:\n result.displayName ??\n `${result.latitude}, ${result.longitude}`,\n })\n if (answerOnCall(part, spoken)) {\n return\n }\n addToolOutput({\n tool: 'requestGeolocation',\n toolCallId: readToolCallId(part),\n output: result,\n })\n }\n\n /** An integration the caller connected from the card on screen. */\n const onOAuthConnected = (part: unknown, mcpServerId: string) => {\n if (answerOnCall(part, $t('message.voiceConnectionDone'))) {\n return\n }\n addToolOutput({\n tool: 'requestOAuthConnection',\n toolCallId: readToolCallId(part),\n output: { connected: true, mcpServerId },\n })\n }\n\n const toggleVoiceMute = () => {\n isMuted.value = !isMuted.value\n voice.setMuted(isMuted.value)\n }\n\n onBeforeUnmount(() => {\n isUnmounted = true\n stopPolling()\n })\n // #endregion voice\n</script>\n\n<template>\n <div\n ref=\"chatViewEl\"\n class=\"pk-chatbot-view-chat\"\n :class=\"{\n 'pk-chatbot-view-chat--dragover':\n isOverDropZone && agentFileUpload?.enabled,\n 'pk-chatbot-view-chat--empty': showEmptyState,\n }\">\n <!-- #region empty state (fullscreen) / messages — crossfade out-in -->\n <Transition name=\"pk-chatbot-view-chat-fade\" mode=\"out-in\">\n <PkChatbotEmptyState\n v-if=\"showEmptyState\"\n :greeting=\"greeting\"\n :logo=\"agentInterface?.logo\"\n :name=\"store.name\" />\n <PkChatbotMessages\n v-else\n class=\"flex flex-col flex-1 min-h-0 p-md overflow-y-auto\"\n interactive\n :messages=\"messages\"\n :confirm-verdicts-on-call=\"confirmVerdictsOnCall\"\n :status=\"chat.status\"\n :error=\"chat.error\"\n :main-color=\"agentInterface?.mainColor\"\n :text-color=\"agentInterface?.textColor\"\n :revised-answers=\"revisedAnswers\"\n :actions=\"actions\"\n :message-feedbacks=\"messageFeedbacks\"\n :feedback-message-id=\"feedbackDialogMessage?.id\"\n :feedback-loading=\"isFeedbackSubmitting\"\n :feedback-submitted=\"isFeedbackSubmitted\"\n :feedback-error=\"feedbackSubmitError\"\n :show-extended-steps=\"agentInterface?.showExtendedSteps\"\n :awaiting-spoken-answer=\"isAwaitingSpokenAnswer\"\n :is-dark=\"isDark\"\n :show-scroll-to-bottom=\"modifier === 'fullscreen'\"\n @feedback-submit=\"onFeedbackSubmit($event)\"\n @feedback-close=\"feedbackDialogMessage = undefined\"\n @regenerate=\"regenerate\"\n @auto-retry=\"regenerate\"\n @reset-chat=\"startNewChat\"\n @show-info=\"emit('show-info', $event)\"\n @revise=\"emit('revise', $event)\"\n @upvote=\"onUpvote\"\n @downvote=\"onDownvote\"\n @feedback=\"onFeedback\"\n @approval-respond=\"addToolApprovalResponse\"\n @confirm-respond=\"onConfirmRespond\">\n <template #tool-showContactForm=\"{ part }\">\n <PkToolShowContactForm\n :part\n :readonly=\"!baseUrl\"\n :submitted=\"isLeadSubmitted\"\n :loading=\"isLoadingSubmitLead\"\n :error=\"submitLeadError\"\n :privacy-policy-notice=\"\n agentInterface?.privacyPolicyNotice\n \"\n @submit=\"onLeadSubmit\" />\n </template>\n <template #tool-showSuggestedReply=\"{ part }\">\n <PkToolShowSuggestedReply\n :part\n @select=\"onSuggestedReply(part, $event)\" />\n </template>\n <template #tool-showSources=\"{ part }\">\n <PkToolShowSources\n :part\n :on-expand-context=\"handleExpandSourceContext\"\n :on-download=\"handleDownloadSource\" />\n </template>\n <template #tool-showForm=\"{ part }\">\n <transition mode=\"out-in\">\n <PkToolShowForm\n :part\n :answered=\"isAnsweredOnCall(part)\"\n @select=\"onFormAnswer('showForm', part, $event)\" />\n </transition>\n </template>\n <template #tool-showMultipleChoice=\"{ part }\">\n <transition mode=\"out-in\">\n <PkToolShowForm\n :part\n allow-custom-answer\n :answered=\"isAnsweredOnCall(part)\"\n @select=\"\n onFormAnswer('showMultipleChoice', part, $event)\n \" />\n </transition>\n </template>\n <template #tool-requestOAuthConnection=\"{ part }\">\n <PkToolRequestOAuthConnection\n :part\n :resolve-connection=\"\n (serverName: string) =>\n apiClient.getOAuthAuthorizeUrl(\n props.agentId,\n serverName,\n )\n \"\n :check-connection=\"\n (serverName: string) =>\n apiClient.getOAuthConnectionStatus(\n props.agentId,\n serverName,\n )\n \"\n @connected=\"onOAuthConnected(part, $event)\" />\n </template>\n <template #tool-requestGeolocation=\"{ part }\">\n <PkToolRequestGeolocation\n :part\n :call-live=\"voice.isActive.value\"\n :reverse-geocode=\"\n (lat: number, lon: number) =>\n apiClient.reverseGeocode(lat, lon)\n \"\n @result=\"onGeolocation(part, $event)\" />\n </template>\n <template #tool-showLocation=\"{ part }\">\n <PkToolShowLocation\n :part\n :is-dark\n :main-color=\"agentInterface?.mainColor\"\n :forward-geocode=\"\n (query: string, lang?: string) =>\n apiClient.forwardGeocode(query, lang)\n \" />\n </template>\n <template #tool-showDiagram=\"{ part }\">\n <PkToolShowDiagram :part :is-dark />\n </template>\n </PkChatbotMessages>\n </Transition>\n <!-- #endregion -->\n\n <!-- #region input -->\n <div class=\"pk-chatbot-view-chat__input-stage\">\n <!-- Animated accent glow radiating from the input pill -->\n <Transition name=\"pk-chatbot-view-chat-fade\">\n <PkAuroraCanvas\n v-if=\"showEmptyState\"\n class=\"pk-chatbot-view-chat__aurora\"\n source-selector=\".pk-chatbot-input__form\" />\n </Transition>\n <div\n v-if=\"isConversationBlocked\"\n class=\"p-md border-t border-surface-3 text-center text-12 text-danger-darken-2 bg-surface-danger\">\n {{ $t('message.chatErrorConversationBlocked') }}\n </div>\n <!-- A live call takes the place of the input: one thing on air, one\n way to hang up, and the conversation stays above it -->\n <PkVoiceSession\n v-else-if=\"voiceState !== 'idle'\"\n :state=\"voiceState\"\n :sample=\"sampleVoiceLevel\"\n :spectrum=\"sampleVoiceSpectrum\"\n :remaining-seconds=\"voice.remainingSeconds.value\"\n :muted=\"isMuted\"\n :error=\"voiceErrorText\"\n :raw-error=\"voice.error.value\"\n :live=\"voice.isActive.value\"\n class=\"pk-chatbot-view-chat__voice\"\n @toggle-mute=\"toggleVoiceMute\"\n @hang-up=\"stopVoice\"\n @retry=\"retryVoice\"\n @dismiss=\"dismissVoiceError\" />\n <PkChatbotInput\n v-else\n v-model=\"input\"\n v-model:pending-attachments=\"pendingAttachments\"\n :placeholder=\"inputMessagePlaceholder\"\n :dismissable-notice=\"\n dismissableNotice && chat.messages.length <= 1\n ? dismissableNotice\n : undefined\n \"\n :status=\"chat.status\"\n :max-message-length=\"agentInterface?.maxMessageLength\"\n :file-upload=\"agentFileUpload\"\n :attachment-limit-reached=\"isAttachmentLimitReached\"\n :attachment-notice=\"attachmentNotice\"\n :voice-enabled=\"isVoiceEnabled\"\n @stop-generation=\"stopGeneration\"\n @submit=\"storeHandleSubmit\"\n @file-select=\"handleFileSelect\"\n @dismiss-attachment-notice=\"clearFileLimitError\"\n @voice-start=\"startVoice\">\n <!-- Fullscreen has no header: the agent identity lives in the\n input pill -->\n <template v-if=\"modifier === 'fullscreen'\" #prepend>\n <span class=\"pk-chatbot-view-chat__agent-chip\">\n <PkAvatar\n modifiers=\"surface\"\n class=\"pk-chatbot-view-chat__agent-chip-avatar\"\n :img-src=\"agentInterface?.logo\"\n :name=\"store.name\" />\n <strong\n v-if=\"store.name\"\n class=\"pk-chatbot-view-chat__agent-chip-name\">\n {{ store.name }}\n </strong>\n </span>\n </template>\n </PkChatbotInput>\n </div>\n <!-- #endregion -->\n <Transition>\n <div\n v-if=\"isOverDropZone && agentFileUpload?.enabled\"\n class=\"pk-chatbot-view-chat__drop-overlay\">\n <VvIcon\n name=\"ri:upload-cloud-2-line\"\n class=\"pk-chatbot-view-chat__drop-overlay-icon\" />\n <span>{{ $t('action.dropFile') }}</span>\n </div>\n </Transition>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-chatbot-view-chat {\n position: relative;\n display: flex;\n flex-direction: column;\n flex: 1;\n min-height: 0;\n\n // Empty state (fullscreen): center the greeting + input block\n // vertically in the available space\n &--empty {\n justify-content: center;\n gap: var(--spacing-md);\n\n // On phones the input docks to the bottom edge instead\n // (Gemini-like), with the greeting centered in the space above\n @include media-breakpoint-down('xs', $breakpoints) {\n justify-content: flex-start;\n\n .pk-chatbot-empty-state {\n flex: 1;\n justify-content: center;\n }\n }\n }\n\n // Anchors the glow to the input pill\n &__input-stage {\n position: relative;\n }\n\n // Radiates outwards from the pill, biased upwards (the pill itself\n // paints above, being later in the DOM)\n &__input-stage &__aurora {\n inset: calc(-1 * var(--spacing-224)) calc(-1 * var(--spacing-96))\n calc(-1 * var(--spacing-160));\n\n // With the input docked at the bottom the glow becomes a wide\n // dome filling the lower part of the screen (Gemini-like)\n @include media-breakpoint-down('xs', $breakpoints) {\n --aurora-spread: 1.6;\n\n // Tall enough that the glow tail never meets the canvas\n // edge fade (it would draw a visible horizontal band)\n inset: calc(-1 * var(--spacing-384) - var(--spacing-128))\n calc(-1 * var(--spacing-96)) calc(-1 * var(--spacing-160));\n }\n }\n\n // Agent identity inside the input pill (fullscreen has no header)\n &__agent-chip {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-6);\n flex-shrink: 0;\n padding: var(--spacing-4) var(--spacing-10) var(--spacing-4)\n var(--spacing-4);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--rounded-full);\n background-color: var(--color-surface);\n\n // Don't crowd the textarea on small screens\n @include media-breakpoint-down('md', $breakpoints) {\n display: none;\n }\n }\n\n &__agent-chip-avatar {\n width: var(--spacing-24);\n height: var(--spacing-24);\n font-size: var(--text-10);\n border-radius: var(--rounded-full);\n }\n\n &__agent-chip-name {\n font-size: var(--text-12);\n font-weight: var(--font-semibold);\n color: var(--color-word-2);\n white-space: nowrap;\n }\n\n // Crossfade empty state ↔ messages: exit faster than enter so the\n // switch never feels like a snap\n &-fade-enter-active {\n transition: opacity 250ms var(--ease-out);\n }\n\n &-fade-leave-active {\n transition: opacity 150ms var(--ease-out);\n }\n\n &-fade-enter-from,\n &-fade-leave-to {\n opacity: 0;\n }\n\n @media (prefers-reduced-motion: reduce) {\n &-fade-enter-active,\n &-fade-leave-active {\n transition: none;\n }\n }\n\n &__drop-overlay {\n position: absolute;\n inset: var(--spacing-sm) var(--spacing-sm) var(--spacing-sm)\n var(--spacing-sm);\n z-index: 1;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: var(--spacing-xs);\n background-color: color-mix(\n in srgb,\n var(--color-surface) 85%,\n transparent\n );\n border-radius: var(--rounded-xl);\n border: var(--spacing-2) dashed var(--color-surface-5);\n pointer-events: none;\n color: var(--color-word-3);\n\n &-icon {\n font-size: var(--spacing-32);\n }\n }\n }\n</style>\n"],"mappings":"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"}
|