feat(widget): Widget 配置、loader 与右下角悬浮入口

生成可嵌入站点的 loader 与悬浮按钮,支持展开收起和移动端;附独立宿主测试页。
This commit is contained in:
WangShen007 committed 2026-09-25 20:50:00 +08:00
1 parent 556b650c7a
commit 0cc6299d1c
57 files changed
+32086

No files matched your search

+645
View File
@@ -0,0 +1,645 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!--
The Ask AI surface used by the ASK_ANYTHING and AURORA chat styles: a command
palette, not a chat bubble. One input pinned at the top, suggested questions while
it's empty, and answers streaming underneath with their sources — the shape people
know from every "Ask AI" search box.
Presentational only: all state and socket work stays in WidgetBuilder, which passes
it in and handles the emitted intents. Colours come from the --rp-* theme tokens
(widget-theme.ts), so both styles and any custom accent are handled for free.
-->
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { renderMarkdown } from './markdown'
import { postToHost } from './host-bridge'
import NewChatConfirm from './NewChatConfirm.vue'
import { NEW_CHAT_LABEL } from './new-chat'
import type { Message } from '../types/chat'
const props = defineProps<{
messages: Message[]
draft: string
agentName: string
suggestions: string[]
welcomeTitle?: string
welcomeSubtitle?: string
placeholder: string
/** Whether a message may be sent: false while a reply is in flight. */
inputEnabled: boolean
/** Whether the field accepts typing. Stays true while a reply is in flight —
* disabling the input blurs it, which cost the visitor their cursor after
* every reply (#316). Defaults to inputEnabled for callers that don't care. */
typingEnabled?: boolean
loading: boolean
showCitations: boolean
disclaimer?: string
/** Whether the embedder is actually showing the widget — gates autofocus. */
active: boolean
/** False when the site disabled the ⌘K chord. */
hotkey: boolean
citationLabel: (source: any) => string
citationTooltip: (source: any) => string
/** Typewriter reveal, keyed by index into `messages` (see useTypewriter). */
displayText: (index: number, fullText: string) => string
isStreaming: (index: number) => boolean
/** Opt-in "New chat" control (customization.allow_new_chat). */
canStartNewChat: boolean
startingNewChat: boolean
/** Armed = the confirmation is showing (ending a chat can't be undone). */
newChatArmed: boolean
/** Set when the last attempt to start a new chat failed. */
newChatError?: string
}>()
const emit = defineEmits<{
(e: 'update:draft', value: string): void
(e: 'send'): void
(e: 'ask', question: string): void
(e: 'close'): void
(e: 'newChat'): void
(e: 'confirmNewChat'): void
(e: 'cancelNewChat'): void
}>()
const inputEl = ref<HTMLInputElement | null>(null)
const bodyEl = ref<HTMLElement | null>(null)
const contentEl = ref<HTMLElement | null>(null)
// Questions, answers and system notes. Interactive kinds (forms, ratings, product
// cards) never reach here: WidgetBuilder falls the whole conversation back to the
// chat panel rather than let this surface drop them.
// The original index travels with each turn: useTypewriter keys its reveal state by
// position in the unfiltered `messages` array.
const RENDERABLE = ['user', 'bot', 'agent', 'system']
const turns = computed(() =>
props.messages
.map((message, index) => ({ message, index }))
.filter(({ message }) => RENDERABLE.includes(message.message_type))
)
const hasConversation = computed(() => turns.value.length > 0)
const onInput = (event: Event) => {
emit('update:draft', (event.target as HTMLInputElement).value)
}
const submit = () => {
// The typing gate, not the sending one: a message typed during a reply has to
// reach the parent, which decides whether to send it now or hold it (#316).
if (!(props.typingEnabled ?? props.inputEnabled) || !props.draft.trim()) return
emit('send')
}
const askSuggestion = (question: string) => {
if (!props.inputEnabled) return
emit('ask', question)
}
// Esc closes from inside the palette. The loader's own handler lives on the host page
// and can't see keys pressed in this iframe, so without this the shortcut would die
// the moment the input takes focus — which is immediately.
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform || '')
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
// Same chord that opened it closes it. Platform-specific on purpose: Ctrl+K on
// macOS is kill-to-end-of-line inside a text field, and this listener sits on a
// surface whose input is focused.
const chord = isMac ? (event.metaKey && !event.ctrlKey) : (event.ctrlKey && !event.metaKey)
if (props.hotkey && chord && !event.altKey && (event.key === 'k' || event.key === 'K')) {
event.preventDefault()
emit('close')
}
}
const focusInput = () => {
nextTick(() => inputEl.value?.focus())
}
// An iframe cannot size its embedder, so the palette measures its own content and
// asks the loader for that height — that's what makes the box hug a short answer
// instead of sitting at a fixed height with dead space under it. The loader clamps.
let lastReportedHeight = 0
const reportHeight = () => {
if (!contentEl.value) return
const root = contentEl.value.closest('.askai') as HTMLElement | null
const body = bodyEl.value
if (!root || !body) return
// Fixed chrome (ask bar + footer), then the content's natural height plus the
// scroll area's own padding — offsetHeight of the body is the CLAMPED height, so
// it can't be used for the content itself.
const chrome = root.offsetHeight - body.offsetHeight
const style = getComputedStyle(body)
const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)
const height = Math.ceil(chrome + padding + contentEl.value.getBoundingClientRect().height)
// Ignore sub-pixel churn, which would otherwise ping-pong with the resize.
if (Math.abs(height - lastReportedHeight) < 3) return
lastReportedHeight = height
postToHost({ type: 'WIDGET_RESIZE', height })
}
let contentObserver: ResizeObserver | null = null
// Keep the tail in view as an answer types itself out. Measures REVEALED length, so
// the scroll follows the typewriter rather than jumping once on arrival.
const revealedLength = computed(() =>
turns.value.reduce((n, { message, index }) => n + props.displayText(index, message.message || '').length, 0)
)
watch(
() => [turns.value.length, revealedLength.value, props.loading] as const,
() => nextTick(() => {
if (bodyEl.value) bodyEl.value.scrollTop = bodyEl.value.scrollHeight
})
)
// The confirmation bar is chrome outside the measured content, so the ResizeObserver
// never sees it come or go - the palette would be reported at the wrong height and
// clip it.
watch(() => props.newChatArmed, () => nextTick(() => reportHeight()))
// Focus only once the embedder actually shows the widget: the iframe is created
// eagerly at page load, so focusing on mount would steal it from the host page.
watch(() => props.active, (visible) => {
if (visible) focusInput()
})
onMounted(() => {
if (props.active) focusInput()
window.addEventListener('keydown', onKeydown)
if (contentEl.value && typeof ResizeObserver !== 'undefined') {
contentObserver = new ResizeObserver(() => reportHeight())
contentObserver.observe(contentEl.value)
}
reportHeight()
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
contentObserver?.disconnect()
contentObserver = null
})
</script>
<template>
<div class="askai">
<!-- Ask bar: stays at the top, follow-ups are typed here too. -->
<div class="askai__bar">
<svg class="askai__bar-icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3l1.9 4.9L19 9.8l-4.9 1.9L12 17l-1.9-5.3L5 9.8l5.1-1.9L12 3z" />
</svg>
<input
ref="inputEl"
type="text"
class="askai__input"
:value="draft"
:placeholder="placeholder"
:disabled="!(typingEnabled ?? inputEnabled)"
:aria-label="placeholder"
autocomplete="off"
spellcheck="false"
@input="onInput"
@keydown.enter.prevent="submit"
>
<button
v-if="canStartNewChat"
type="button"
class="askai__new"
:class="{ 'askai__new--armed': newChatArmed }"
:disabled="startingNewChat"
:title="NEW_CHAT_LABEL"
:aria-label="NEW_CHAT_LABEL"
:aria-expanded="newChatArmed"
@click="emit('newChat')"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</button>
<button type="button" class="askai__close" aria-label="关闭" title="关闭(Esc)" @click="emit('close')">
<span class="askai__kbd">Esc</span>
</button>
</div>
<NewChatConfirm
v-if="newChatArmed && canStartNewChat"
:busy="startingNewChat"
:error="newChatError"
@confirm="emit('confirmNewChat')"
@cancel="emit('cancelNewChat')"
/>
<div ref="bodyEl" class="askai__body">
<!-- flow-root so child margins don't collapse out of this box: its measured
height is what the palette asks the loader to size itself to. -->
<div ref="contentEl" class="askai__content">
<!-- Empty state. A blank panel is the one thing an answer surface must
never show: give people something to click. -->
<template v-if="!hasConversation">
<div class="askai__intro">
<h2 class="askai__title">{{ welcomeTitle || `向 ${agentName} 提问` }}</h2>
<p v-if="welcomeSubtitle" class="askai__subtitle">{{ welcomeSubtitle }}</p>
</div>
<!-- Hidden once the visitor starts typing: picking a suggestion
replaces the draft, so offering both would discard their text. -->
<div v-if="suggestions.length && !draft.trim()" class="askai__suggestions">
<p class="askai__label">推荐提问</p>
<button
v-for="question in suggestions"
:key="question"
type="button"
class="askai__suggestion"
:disabled="!inputEnabled"
@click="askSuggestion(question)"
>
<span>{{ question }}</span>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</button>
</div>
</template>
<!-- Answers. Questions read as headings, answers as prose — a document,
not a back-and-forth of bubbles. -->
<template v-else>
<!-- Announced only once the reveal finishes: a live region on text that
mutates per character makes screen readers re-read the whole answer. -->
<div
v-for="{ message, index } in turns"
:key="index"
class="askai__turn"
:aria-live="isStreaming(index) ? 'off' : 'polite'"
>
<p v-if="message.message_type === 'user'" class="askai__question">{{ message.message }}</p>
<p v-else-if="message.message_type === 'system'" class="askai__system">{{ message.message }}</p>
<template v-else>
<!-- Revealed progressively while streaming, so an answer types
itself out here exactly as it does in the chat panel. -->
<div
class="askai__answer"
:class="{ 'askai__answer--streaming': isStreaming(index) }"
v-html="renderMarkdown(isStreaming(index) ? displayText(index, message.message || '') : (message.message || ''))"
></div>
<div v-if="showCitations && !isStreaming(index) && message.sources && message.sources.length" class="askai__sources">
<span class="askai__label">参考来源</span>
<span
v-for="(source, sourceIndex) in message.sources"
:key="sourceIndex"
class="askai__source"
:title="citationTooltip(source)"
>{{ citationLabel(source) }}</span>
</div>
</template>
</div>
<div v-if="loading" class="askai__thinking" role="status" aria-live="polite">
<span class="askai__dot"></span><span class="askai__dot"></span><span class="askai__dot"></span>
<span class="askai__thinking-text">{{ showCitations ? '正在检索知识库' : '思考中' }}</span>
</div>
</template>
</div>
</div>
<div class="askai__foot">
<span v-if="disclaimer">{{ disclaimer }}</span>
<a class="askai__brand" href="https://replypier.site" target="_blank" rel="noopener noreferrer">由 ReplyPier 提供支持</a>
</div>
</div>
</template>
<style scoped>
/* Every colour is a --rp-* theme token, so light (Ask Anything) and dark (Aurora)
both work, and a custom accent_color flows through untouched. */
.askai {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--rp-card);
color: var(--rp-text);
font-family: var(--rp-body-font, inherit);
}
.askai__bar {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 14px 14px 16px;
border-bottom: 1px solid var(--rp-border);
flex-shrink: 0;
}
.askai__bar-icon {
color: var(--rp-accent);
flex-shrink: 0;
}
.askai__input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--rp-text);
font-size: 16px;
font-family: inherit;
padding: 2px 0;
}
.askai__input::placeholder { color: var(--rp-muted); }
.askai__input:disabled { opacity: 0.6; }
/* Icon-only, same weight as the Esc chip beside it — the bar is for asking, not a
place for two competing labelled buttons. */
.askai__new {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
border: 1px solid var(--rp-border);
background: transparent;
border-radius: 6px;
cursor: pointer;
color: var(--rp-muted);
font-family: inherit;
flex-shrink: 0;
transition: color 0.15s ease, border-color 0.15s ease;
}
.askai__new:hover:not(:disabled) {
color: var(--rp-text);
border-color: var(--rp-accent);
}
.askai__new:disabled { opacity: 0.5; cursor: default; }
.askai__new--armed {
color: var(--rp-text);
border-color: var(--rp-accent);
}
.askai__close {
border: 1px solid var(--rp-border);
background: transparent;
border-radius: 6px;
padding: 3px 7px;
cursor: pointer;
color: var(--rp-muted);
flex-shrink: 0;
transition: color 0.15s ease, border-color 0.15s ease;
}
.askai__close:hover {
color: var(--rp-text);
border-color: var(--rp-accent);
}
.askai__kbd {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
font-family: inherit;
}
.askai__body {
flex: 1;
min-height: 0;
overflow-y: auto;
/* Reserve the scrollbar gutter: without it, the bar appearing mid-answer narrows
the text, re-wraps it taller, and the auto-grow measurement oscillates. */
scrollbar-gutter: stable;
padding: 18px 20px 22px;
}
.askai__content { display: flow-root; }
.askai__intro { margin-bottom: 18px; }
.askai__title {
margin: 0 0 4px;
font-size: 19px;
font-weight: 600;
letter-spacing: -0.01em;
}
.askai__subtitle {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--rp-muted);
}
.askai__label {
display: block;
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--rp-muted);
}
.askai__suggestions { display: flex; flex-direction: column; gap: 6px; }
.askai__suggestion {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
text-align: left;
padding: 11px 13px;
border: 1px solid var(--rp-border);
border-radius: 10px;
background: var(--rp-agent-bg);
color: var(--rp-text);
font-size: 14px;
font-family: inherit;
cursor: pointer;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.askai__suggestion:hover:not(:disabled) {
border-color: var(--rp-accent);
transform: translateX(2px);
}
.askai__suggestion:disabled { opacity: 0.55; cursor: default; }
.askai__suggestion svg { color: var(--rp-muted); flex-shrink: 0; }
.askai__turn + .askai__turn { margin-top: 20px; }
.askai__question {
margin: 0 0 10px;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
letter-spacing: -0.01em;
}
.askai__answer {
font-size: 14.5px;
line-height: 1.65;
color: var(--rp-text);
overflow-wrap: break-word;
}
/* Blinking caret rides the end of the last rendered block while text reveals. */
.askai__answer--streaming :deep(> :last-child)::after {
content: '';
display: inline-block;
width: 6px;
height: 1em;
margin-left: 2px;
vertical-align: -0.15em;
background: var(--rp-accent, currentColor);
animation: askai-blink 1s steps(1) infinite;
}
@keyframes askai-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.askai__answer :deep(p) { margin: 0 0 10px; }
.askai__answer :deep(p:last-child) { margin-bottom: 0; }
.askai__answer :deep(ul),
.askai__answer :deep(ol) { margin: 0 0 10px; padding-left: 20px; }
.askai__answer :deep(li) { margin-bottom: 4px; }
.askai__answer :deep(a) { color: var(--rp-accent); }
.askai__answer :deep(code) {
background: var(--rp-agent-bg);
padding: 1px 5px;
border-radius: 4px;
font-size: 0.9em;
}
.askai__answer :deep(pre) {
background: var(--rp-agent-bg);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
}
.askai__answer :deep(pre code) { background: none; padding: 0; }
/* Wide tables scroll inside the answer instead of forcing a horizontal scrollbar on
the whole body (which the height measurement doesn't account for). */
.askai__answer :deep(table) { display: block; max-width: 100%; overflow-x: auto; }
.askai__sources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 12px;
}
.askai__sources .askai__label { margin: 0 2px 0 0; }
.askai__source {
font-size: 12px;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid var(--rp-border);
color: var(--rp-muted);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.askai__thinking {
display: flex;
align-items: center;
gap: 5px;
margin-top: 16px;
color: var(--rp-muted);
font-size: 13px;
}
.askai__thinking-text { margin-left: 4px; }
.askai__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--rp-accent);
animation: askai-pulse 1.4s ease-in-out infinite;
}
.askai__dot:nth-child(2) { animation-delay: 0.18s; }
.askai__dot:nth-child(3) { animation-delay: 0.36s; }
@keyframes askai-pulse {
0%, 100% { opacity: 0.35; transform: translateY(0); }
50% { opacity: 1; transform: translateY(-2px); }
}
@media (prefers-reduced-motion: reduce) {
.askai__dot { animation: none; }
.askai__suggestion:hover:not(:disabled) { transform: none; }
.askai__answer--streaming :deep(> :last-child)::after { display: none; }
}
.askai__system {
margin: 0;
font-size: 13px;
font-style: italic;
color: var(--rp-muted);
}
.askai__foot {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 4px 10px;
margin: 0;
padding: 10px 16px 12px;
border-top: 1px solid var(--rp-border);
font-size: 11.5px;
text-align: center;
color: var(--rp-muted);
flex-shrink: 0;
}
.askai__brand {
color: inherit;
text-decoration: none;
opacity: 0.8;
}
.askai__brand:hover { text-decoration: underline; }
@media (max-width: 768px) {
.askai__body { padding: 16px 16px 20px; }
/* 16px keeps iOS from zooming the page when the input takes focus. */
.askai__input { font-size: 16px; }
}
</style>
+132
View File
@@ -0,0 +1,132 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
/**
* Asks before a new chat ends the current one.
*
* Ending a chat cannot be undone, and the control used to say so only in a tooltip
* and a hint that lapsed after a few seconds - easy to miss, and easy to trigger by
* accident. This states the consequence inside the widget and waits for an answer.
* Shared by the chat header and the Ask AI bar so both ask the same question.
*/
import {
NEW_CHAT_CANCEL_ACTION,
NEW_CHAT_CONFIRM_ACTION,
NEW_CHAT_CONFIRM_QUESTION,
} from './new-chat'
defineProps<{
/** Shown instead of the question when the last attempt failed. */
error?: string
/** Disables the actions while the close is in flight. */
busy?: boolean
}>()
const emit = defineEmits<{
(e: 'confirm'): void
(e: 'cancel'): void
}>()
</script>
<template>
<div class="new-chat-confirm" role="alertdialog" aria-live="polite" :aria-label="NEW_CHAT_CONFIRM_QUESTION">
<p class="new-chat-confirm__question">{{ error || NEW_CHAT_CONFIRM_QUESTION }}</p>
<div class="new-chat-confirm__actions">
<button type="button" class="new-chat-confirm__button" :disabled="busy" @click="emit('cancel')">
{{ NEW_CHAT_CANCEL_ACTION }}
</button>
<button type="button" class="new-chat-confirm__button new-chat-confirm__button--primary" :disabled="busy"
@click="emit('confirm')">
{{ NEW_CHAT_CONFIRM_ACTION }}
</button>
</div>
</div>
</template>
<style scoped>
.new-chat-confirm {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 10px 14px;
border-bottom: 1px solid var(--rp-hairline, rgba(0, 0, 0, 0.07));
background: var(--rp-agent-bg, rgba(0, 0, 0, 0.03));
font-family: var(--rp-body-font, inherit);
}
.new-chat-confirm__question {
margin: 0;
font-size: 12.5px;
line-height: 1.35;
color: var(--rp-text, inherit);
}
.new-chat-confirm__actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.new-chat-confirm__button {
padding: 5px 11px;
border-radius: var(--rp-field-radius, 8px);
border: 1px solid var(--rp-border, rgba(0, 0, 0, 0.12));
background: transparent;
color: var(--rp-text, inherit);
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s ease;
}
.new-chat-confirm__button--primary {
border-color: transparent;
background: var(--rp-accent, #f34611);
color: var(--rp-on-accent, #ffffff);
}
.new-chat-confirm__button:disabled {
opacity: 0.5;
cursor: default;
}
.new-chat-confirm__button:not(:disabled):hover {
opacity: 0.85;
}
/* Ending a chat cannot be undone, and on a phone this is a thumb hitting glass:
give both answers a real touch target rather than a 27px pill. */
@media (pointer: coarse) {
.new-chat-confirm {
padding: 10px 12px;
}
.new-chat-confirm__button {
min-height: 40px;
padding: 8px 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.new-chat-confirm__button {
transition: none;
}
}
</style>
File diff suppressed because it is too large. Load diff
+100
View File
@@ -0,0 +1,100 @@
/*
* Copyright 2024-2026 ReplyPier
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* The one way the widget talks to the page it is embedded in.
*
* These messages include the visitor's conversation token, so they are addressed to a
* specific origin rather than broadcast with "*": if the embedding page is ever
* replaced by another site's document, the browser drops the message instead of
* handing that site a live token.
*
* The widget runs in a srcdoc iframe, which inherits the embedder's origin, so the
* host origin is normally readable. When it genuinely cannot be determined we fall
* back to "*" - a widget that cannot talk to its loader is broken, and that is worse
* than the broadcast we have always done.
*/
const ANY_ORIGIN = '*'
/** Origins a browser reports for an opaque (sandboxed, data:, srcdoc) document. */
const OPAQUE = new Set(['null', 'about:blank', ''])
let resolved: string | null = null
const usable = (origin: string | undefined | null): string | null =>
origin && !OPAQUE.has(origin) ? origin : null
const originOf = (url: string | undefined | null): string | null => {
if (!url) return null
try {
return usable(new URL(url).origin)
} catch {
return null
}
}
/**
* Origin of the page hosting the widget, or "*" when it cannot be established.
* Resolved once: an embedding page navigating between routes keeps its origin.
*/
export function hostOrigin(): string {
if (resolved) return resolved
// Not framed at all - the dashboard preview renders the widget inline.
if (window.parent === window) {
resolved = usable(window.location.origin) || ANY_ORIGIN
return resolved
}
// Same-origin parent: the srcdoc frame the embed loader creates. Reading its
// location throws for a cross-origin embed, which the catch below handles.
try {
const parentOrigin = usable(window.parent.location.origin)
if (parentOrigin) {
resolved = parentOrigin
return resolved
}
} catch {
/* cross-origin parent - fall through */
}
// Cross-origin embed: the browser still names our ancestors (not in Firefox), and
// the referrer is the embedding document.
try {
const ancestors = window.location.ancestorOrigins
const nearest = ancestors && ancestors.length ? usable(ancestors[0]) : null
if (nearest) {
resolved = nearest
return resolved
}
} catch {
/* not supported - fall through */
}
resolved = originOf(document.referrer) || ANY_ORIGIN
return resolved
}
/** Send a message to the embedding page, addressed to its origin. */
export function postToHost(message: Record<string, unknown>): void {
window.parent.postMessage(message, hostOrigin())
}
/** Test seam: forget the cached origin. */
export function resetHostOrigin(): void {
resolved = null
}
+38
View File
@@ -0,0 +1,38 @@
/*
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Agent-answer markdown → safe HTML, shared by every widget surface that renders a
* reply (the chat panel and the Ask AI palette) so they can't drift apart on either
* formatting or sanitisation.
*
* Output always goes through `sanitizeHtml`, which strips iframes/scripts/handlers —
* answers contain model output and knowledge-base content, so it is never trusted.
*/
import { marked } from 'marked'
import { sanitizeHtml } from '../utils/sanitize'
marked.setOptions({
renderer: new marked.Renderer(),
gfm: true,
breaks: true,
})
// Links open in a new tab (the widget lives in an iframe, so a same-tab navigation
// would replace the chat rather than the page). sanitizeHtml already forces
// target="_blank" + rel on every surviving anchor, so nothing is needed here.
export const renderMarkdown = (text: string): string => sanitizeHtml(marked(text || '') as string)
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2024-2026 ReplyPier
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Copy for the "New chat" control. Starting a new chat ends the current one for
* good, so both surfaces (chat header and the Ask AI bar) ask the same question in
* the same words, from here.
*/
export const NEW_CHAT_LABEL = '开始新会话'
export const NEW_CHAT_CONFIRM_QUESTION = '开始新会话?当前会话将结束。'
export const NEW_CHAT_CONFIRM_ACTION = '开始新会话'
export const NEW_CHAT_CANCEL_ACTION = '取消'
export const NEW_CHAT_FAILED_TEXT = "无法开始新会话,请重试。"
/** How long the confirmation stays up before it gives up on being answered. */
export const NEW_CHAT_CONFIRM_TIMEOUT_MS = 15000
File diff suppressed because it is too large. Load diff
+64
View File
@@ -0,0 +1,64 @@
/*
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// @ts-nocheck
import { buildUploadUrl } from '../utils/avatars';
export interface InitialData {
widgetId: string;
agentName: string;
customization: Record<string, any>;
customerId: string;
customer: Record<string, any>;
initialToken?: string;
}
// @ts-ignore
declare global {
// @ts-ignore
interface Window {
// @ts-ignore
__INITIAL_DATA__: InitialData;
}
}
// Helper function to get runtime configuration
function getRuntimeConfig() {
// @ts-ignore - APP_CONFIG might not be available at build time
return typeof window !== 'undefined' && window.APP_CONFIG ? window.APP_CONFIG : {};
}
export const widgetEnv = {
get API_URL() {
const config = getRuntimeConfig();
// Default to the production API so a plain `build:widget` (no VITE_API_URL,
// no runtime APP_CONFIG) ships a working widget instead of localhost. Local
// dev overrides via VITE_API_URL; self-hosters via window.APP_CONFIG.
return config.API_URL || import.meta.env.VITE_API_URL || 'https://api.replypier.site/api/v1';
},
get WS_URL() {
const config = getRuntimeConfig();
return config.WS_URL || import.meta.env.VITE_WS_URL || 'wss://api.replypier.site';
}
}
/**
* Widget-side counterpart of resolveUploadUrl (src/config/api.ts). Same rules,
* but resolved against widgetEnv.API_URL so the widget keeps its own fallback.
*/
export function resolveWidgetUploadUrl(stored?: string | null): string {
return buildUploadUrl(stored, widgetEnv.API_URL);
}
+243
View File
@@ -0,0 +1,243 @@
/**
* SHARED chat-surface theme — the single place the widget's themed appearance is
* defined. Imported by BOTH the real widget (`WidgetBuilder.vue`) and the admin
* preview (`AgentChatPreviewPanel.vue`) so they can never drift.
*
* Every rule is scoped under `.chat-container.rp-surface` (both surfaces add the
* `rp-surface` marker class to their panel root). The extra class both contains the
* global import to the widget and gives these rules enough specificity to win over
* each component's own scoped `[data-v-*]` CSS. Every value comes from the `--rp-*`
* custom properties emitted by `themeCssVars()` in `widget-theme.ts` — no hard-coded
* per-theme values live here.
*
* Components keep only their own NON-themed, structural CSS (layout, animations,
* socket/streaming bits, admin-mock chrome) in their scoped <style> blocks.
*/
/* ===== Panel ===== */
.chat-container.rp-surface {
font-family: var(--rp-body-font);
color: var(--rp-text);
}
.chat-container.rp-surface .chat-panel {
background: var(--rp-card);
color: var(--rp-text);
}
/* ===== Header ===== */
.chat-container.rp-surface .chat-header {
padding: 14px 16px;
border-bottom: 1px solid var(--rp-hairline);
background: transparent;
}
.chat-container.rp-surface .header-info h3 {
color: var(--rp-text);
font-family: var(--rp-body-font);
font-size: 14.5px;
font-weight: 600;
line-height: 1.15;
}
/* --rp-presence is the accent when it is readable on the card, otherwise the
contrast-derived muted colour (see themeCssVars). */
.chat-container.rp-surface .rp-presence,
.chat-container.rp-surface .status-text {
color: var(--rp-presence, var(--rp-accent));
font-size: 11.5px;
}
.chat-container.rp-surface .status-indicator.online {
background: var(--rp-presence, var(--rp-accent));
box-shadow: none;
}
/* ===== Avatars — circle by default, rounded-square on mono (Terminal); no ring ===== */
.chat-container.rp-surface .header-orb,
.chat-container.rp-surface .header-avatar {
width: 34px;
height: 34px;
border-radius: var(--rp-avatar-radius);
border: none;
box-shadow: none;
object-fit: cover;
flex-shrink: 0;
}
.chat-container.rp-surface .rp-msg-avatar,
.chat-container.rp-surface .rp-msg-avatar-orb,
.chat-container.rp-surface .rp-msg-avatar-img,
.chat-container.rp-surface .rp-welcome-orb,
.chat-container.rp-surface .rp-welcome-avatar {
width: 28px;
height: 28px;
border-radius: var(--rp-avatar-radius);
border: none;
box-shadow: none;
flex-shrink: 0;
display: block;
}
.chat-container.rp-surface .rp-msg-avatar-img,
.chat-container.rp-surface .rp-welcome-avatar { object-fit: cover; }
/* ===== Messages ===== */
.chat-container.rp-surface .message {
max-width: 100%;
gap: 9px;
align-items: flex-start;
}
.chat-container.rp-surface .message-bubble {
padding: 10px 14px;
font-size: 14px;
line-height: 1.5;
box-shadow: none;
max-width: 84%;
}
/* Collapse markdown paragraph margins so a one-line reply is a compact bubble. */
.chat-container.rp-surface .message-bubble p { margin: 0; }
.chat-container.rp-surface .message-bubble p + p { margin-top: 0.5em; }
/* Markdown links inside messages: clearly a link and clickable (v-html content, so
this must be a global rule — scoped styles don't reach it). Inherits the bubble
colour so it's readable on both the agent surface and the accent user bubble. */
.chat-container.rp-surface .message-bubble a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
font-weight: 600;
cursor: pointer;
}
.chat-container.rp-surface .message-bubble a:hover { opacity: 0.82; }
/* Markdown code & wide content (v-html, so global rules): scroll inside the
bubble instead of painting past it and forcing the chat to scroll sideways.
Backgrounds derive from currentColor so they read on light and dark bubbles. */
.chat-container.rp-surface .message-bubble pre {
max-width: 100%;
overflow-x: auto;
margin: 0.5em 0;
padding: var(--space-sm, 12px);
border-radius: 8px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.chat-container.rp-surface .message-bubble pre code {
/* Code keeps its formatting; the <pre> scrollbar handles long lines. */
white-space: pre;
overflow-wrap: normal;
background: none;
padding: 0;
}
.chat-container.rp-surface .message-bubble code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
background: color-mix(in srgb, currentColor 8%, transparent);
padding: 1px 5px;
border-radius: 6px;
}
.chat-container.rp-surface .message-bubble table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 0.5em 0;
}
.chat-container.rp-surface .message-bubble th,
.chat-container.rp-surface .message-bubble td {
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
padding: 4px 8px;
}
/* Agent/bot bubble: agent surface + hairline, tail top-left. */
.chat-container.rp-surface .agent-message .message-bubble,
.chat-container.rp-surface .assistant-message .message-bubble {
background: var(--rp-agent-bg);
color: var(--rp-text);
border: 1px solid var(--rp-hairline);
border-radius: var(--rp-bubble-tail) var(--rp-bubble) var(--rp-bubble) var(--rp-bubble);
}
/* User bubble: accent fill, contrast text, tail bottom-right. */
.chat-container.rp-surface .user-message .message-bubble {
background: var(--rp-accent);
color: var(--rp-on-accent);
border: none;
border-radius: var(--rp-bubble) var(--rp-bubble) var(--rp-bubble-tail) var(--rp-bubble);
}
/* ===== Quick-action pills ===== */
.chat-container.rp-surface .rp-quick-action {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--rp-accent) 66%, transparent);
background: color-mix(in srgb, var(--rp-accent) 18%, transparent);
color: var(--rp-text);
font-family: var(--rp-body-font);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.chat-container.rp-surface .rp-quick-action:hover:not(:disabled) {
background: var(--rp-accent);
color: var(--rp-on-accent);
border-color: var(--rp-accent);
}
.chat-container.rp-surface.theme-terminal .rp-quick-action { border-radius: 6px; }
/* ===== Input bar + send (transparent bar — card shows through, no grey band) ===== */
.chat-container.rp-surface .chat-input {
background: transparent;
border-top: 1px solid var(--rp-hairline);
}
.chat-container.rp-surface .message-input input,
.chat-container.rp-surface .email-input input {
border-radius: var(--rp-field-radius);
border: 1px solid color-mix(in srgb, var(--rp-accent) 30%, var(--rp-hairline));
background: color-mix(in srgb, var(--rp-text) 6%, transparent);
color: var(--rp-text);
font-family: var(--rp-body-font);
font-size: 14px;
}
.chat-container.rp-surface .message-input input::placeholder,
.chat-container.rp-surface .email-input input::placeholder {
color: var(--rp-muted);
opacity: 1;
}
.chat-container.rp-surface .message-input input:focus,
.chat-container.rp-surface .email-input input:focus {
border-color: var(--rp-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--rp-accent) 22%, transparent);
}
.chat-container.rp-surface .send-button {
width: 42px;
min-width: 42px;
height: 42px;
flex-shrink: 0;
border: none;
border-radius: var(--rp-field-radius);
background: var(--rp-accent);
color: var(--rp-on-accent);
cursor: pointer;
}
/* ===== Powered-by footer (transparent — card shows, no grey band) ===== */
.chat-container.rp-surface .powered-by,
.chat-container.rp-surface .powered-by-welcome {
background: transparent;
border-top: none;
color: var(--rp-muted);
font-family: var(--rp-body-font);
}
.chat-container.rp-surface .rp-powered-prefix { color: var(--rp-muted); opacity: 1; }
.chat-container.rp-surface .rp-brand { color: var(--rp-text); font-weight: 700; }
/* Mono (Terminal): monospace across the whole surface. */
.chat-container.rp-surface.theme-terminal,
.chat-container.rp-surface.theme-terminal .chat-panel,
.chat-container.rp-surface.theme-terminal .header-info h3,
.chat-container.rp-surface.theme-terminal .message-bubble,
.chat-container.rp-surface.theme-terminal .rp-quick-action,
.chat-container.rp-surface.theme-terminal .message-input input,
.chat-container.rp-surface.theme-terminal .powered-by {
font-family: var(--rp-body-font);
}
/* Glass: frosted panel. */
.chat-container.rp-surface.theme-glass .chat-panel {
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
+206
View File
@@ -0,0 +1,206 @@
/*
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Single source of truth for the widget's per-`chat_style` design tokens — colours
* (panel / text / muted / agent-bubble / accent) AND structure (radius, glow,
* border, bubble radius, mono flag). Every visual value the comp defines lives here.
*
* `themeCssVars()` flattens these into `--rp-*` custom properties that BOTH the real
* widget (`WidgetBuilder.vue`) and the admin preview (`AgentChatPreviewPanel.vue`)
* apply on `.chat-container`, and the shared `widget-surface.css` consumes. A user's
* explicit `accent_color` / `chat_background_color` / `font_family` overrides the
* theme default; otherwise the comp values win.
*/
import { isColorDark, adjustColorBrightness } from '../types/widget'
export interface ThemeTokens {
/** Light surface (affects overlay/contrast + hairline choices). */
light: boolean
/** Monospace-forward theme (Terminal). */
mono: boolean
/** Panel corner radius, px. */
radius: number
/** Message-bubble corner radius, px (the speech "tail" corner is derived from this). */
bubble: number
/** Ambient glow colour used in the panel shadow. */
glow: string
/** Panel border colour. */
border: string
/** Panel/card background (solid or gradient). */
card: string
/** Primary text colour. */
text: string
/** Muted/secondary text colour. */
muted: string
/** Agent message-bubble background. */
agentBg: string
/** Default accent (user `accent_color` overrides). */
accent: string
}
// Token sets from the design comp (Glass / Aurora / Terminal / Calm Mint /
// Playful / Sunrise). Each chat_style maps 1:1 to a comp theme. The light themes'
// muted values are darkened from the comp to reach WCAG AA (4.5:1) on white.
const GLASS: ThemeTokens = {
light: false, mono: false, radius: 22, bubble: 16,
glow: 'rgba(157,140,255,.26)', border: 'rgba(157,140,255,.32)',
card: 'linear-gradient(180deg,rgba(28,26,40,.94),rgba(15,14,22,.97))',
text: '#ECEAFA', muted: '#9C97BE', agentBg: 'rgba(255,255,255,.06)', accent: '#9D8CFF',
}
const AURORA: ThemeTokens = {
light: false, mono: false, radius: 26, bubble: 18,
glow: 'rgba(157,140,255,.32)', border: 'rgba(157,140,255,.40)',
card: 'linear-gradient(180deg,#16131F,#0A0910)',
text: '#F2F3F8', muted: '#A7A0CC', agentBg: 'rgba(255,255,255,.05)', accent: '#9D8CFF',
}
const TERMINAL: ThemeTokens = {
light: false, mono: true, radius: 8, bubble: 4,
glow: 'rgba(201,242,78,.20)', border: 'rgba(201,242,78,.30)',
card: '#070907',
text: '#D7F7C8', muted: '#7F9B57', agentBg: 'rgba(201,242,78,.045)', accent: '#C9F24E',
}
const CALM_MINT: ThemeTokens = {
light: false, mono: false, radius: 18, bubble: 14,
glow: 'rgba(95,227,214,.22)', border: 'rgba(95,227,214,.30)',
card: 'linear-gradient(180deg,#0E1A1A,#0A1414)',
text: '#DDF7F3', muted: '#6FAFA8', agentBg: 'rgba(255,255,255,.05)', accent: '#5FE3D6',
}
const PLAYFUL: ThemeTokens = {
light: true, mono: false, radius: 28, bubble: 20,
glow: 'rgba(255,138,115,.30)', border: 'rgba(0,0,0,.07)',
card: '#FFFFFF',
text: '#2A2730', muted: '#736C7C', agentBg: '#F4F1F6', accent: '#FF8A73',
}
const SUNRISE: ThemeTokens = {
light: true, mono: false, radius: 24, bubble: 16,
glow: 'rgba(255,138,115,.22)', border: 'rgba(0,0,0,.08)',
card: '#FFFFFF',
text: '#2A2A33', muted: '#6E6E7B', agentBg: '#F3F3F6', accent: '#FF8A73',
}
// chat_style → token-set mapping. CHATBOT (legacy) and ASK_ANYTHING reuse Sunrise.
const THEME_TOKENS: Record<string, ThemeTokens> = {
GLASS,
AURORA,
TERMINAL,
CALM_MINT,
PLAYFUL,
SUNRISE,
CHATBOT: SUNRISE,
ASK_ANYTHING: SUNRISE,
}
const MONO_FONT = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
const BODY_FONT = "'Instrument Sans', system-ui, -apple-system, 'Segoe UI', sans-serif"
/** Speech-bubble "tail" corner radius, derived from the bubble radius (comp formula). */
function bubbleTail(bubble: number): number {
return Math.max(4, Math.round(bubble * 0.3))
}
/**
* Contrast colour for text/icons sitting ON the accent (user bubble, send button).
* Matches the comp `on()`: relative luminance > 0.62 → near-black, else white.
*/
export function onAccent(hex: string): string {
const c = (hex || '').replace('#', '')
if (c.length < 6) return '#0B0C10'
const r = parseInt(c.slice(0, 2), 16)
const g = parseInt(c.slice(2, 4), 16)
const b = parseInt(c.slice(4, 6), 16)
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return lum > 0.62 ? '#0B0C10' : '#FFFFFF'
}
/** Resolve structural + colour tokens for a chat style (defaults to the light Sunrise set). */
export function getThemeTokens(chatStyle?: string | null): ThemeTokens {
return THEME_TOKENS[chatStyle || ''] || SUNRISE
}
/** Minimal shape of the customization fields that can override theme defaults. */
export interface ThemeOverrides {
chat_background_color?: string | null
chat_text_color?: string | null
accent_color?: string | null
font_family?: string | null
}
// Legacy DB default for chat_text_color (dark). Treated as "unset" so existing
// agents fall back to the per-theme text colour instead of near-black on dark themes.
const LEGACY_TEXT_DEFAULT = '#212529'
/**
* Flatten tokens (merged with any user overrides) into the `--rp-*` custom
* properties consumed by `widget-surface.css`. This is the ONLY place per-theme
* values are turned into CSS — both widget and preview call it.
*/
export function themeCssVars(chatStyle?: string | null, overrides?: ThemeOverrides): Record<string, string> {
const t = getThemeTokens(chatStyle)
// A user-set background is a hex colour from the picker; when present we derive
// text/muted/agent-bubble from it (so custom colours stay legible), otherwise we
// use the comp's exact per-theme colours.
const customBg = overrides?.chat_background_color || ''
const hasCustomBg = /^#[0-9a-fA-F]{6}$/.test(customBg)
const card = customBg || t.card
// Text colour precedence: explicit user text colour > derived from a custom
// background > the theme's design default.
const customText = overrides?.chat_text_color || ''
const hasCustomText = /^#[0-9a-fA-F]{6}$/.test(customText) && customText.toLowerCase() !== LEGACY_TEXT_DEFAULT
const text = hasCustomText
? customText
: (hasCustomBg ? (isColorDark(customBg) ? '#FFFFFF' : '#111111') : t.text)
const muted = hasCustomBg ? (isColorDark(customBg) ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.62)') : t.muted
const agentBg = hasCustomBg ? adjustColorBrightness(customBg, 20) : t.agentBg
const accent = overrides?.accent_color || t.accent
// Presence line ("Online · replies instantly"): the comp colours it with the
// accent, but a light accent on a light card (e.g. lime on the white Sunrise
// header) — or a dark accent on a dark card — is unreadable. Fall back to the
// contrast-derived muted colour in that case. Same luminance rule as onAccent().
const cardIsLight = hasCustomBg ? !isColorDark(customBg) : t.light
const accentIsLight = onAccent(accent) === '#0B0C10'
const presence = cardIsLight === accentIsLight ? muted : accent
// font_family may be a bare name ("Space Grotesk") or a full stack
// ("Instrument Sans, sans-serif"); append it raw so the comma-separated stack
// stays valid (quoting the whole stack would make the first token invalid).
const bodyFont = t.mono
? MONO_FONT
: (overrides?.font_family ? `${overrides.font_family}, ${BODY_FONT}` : BODY_FONT)
return {
'--rp-card': card,
'--rp-text': text,
'--rp-muted': muted,
'--rp-agent-bg': agentBg,
'--rp-accent': accent,
'--rp-on-accent': onAccent(accent),
'--rp-presence': presence,
'--rp-border': t.border,
'--rp-glow': t.glow,
'--rp-radius': `${t.radius}px`,
'--rp-bubble': `${t.bubble}px`,
'--rp-bubble-tail': `${bubbleTail(t.bubble)}px`,
'--rp-field-radius': t.mono ? '7px' : '12px',
'--rp-avatar-radius': t.mono ? '28%' : '50%',
'--rp-hairline': t.light ? 'rgba(0,0,0,0.07)' : 'rgba(255,255,255,0.08)',
'--rp-body-font': bodyFont,
}
}
File diff suppressed because it is too large. Load diff
+76
View File
@@ -0,0 +1,76 @@
/*
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { createApp } from 'vue'
import WidgetBuilder from './WidgetBuilder.vue'
import './widget.css'
// Ensure the environment is defined
// @ts-ignore
if (!window.process) {
// @ts-ignore
window.process = { env: { NODE_ENV: 'production' } }
}
// Extract widget ID and token from window or URL
// @ts-ignore
const initialData = window.__INITIAL_DATA__
const url = new URL(window.location.href)
const isPreviewMode = url.searchParams.get('preview') === 'true'
// Helper to handle URL params that might be the string "undefined"
const getURLParam = (name: string): string | undefined => {
const value = url.searchParams.get(name)
// Return undefined if value is null, empty, or the literal string "undefined"
if (!value || value === 'undefined' || value.trim() === '') {
return undefined
}
return value
}
// Get widget ID - prefer URL param in preview mode
const widgetId = isPreviewMode
? getURLParam('widget_id') || (initialData?.widgetId) || undefined
: (initialData?.widgetId) || undefined
// Get token - prefer initial data, fallback to URL param in preview mode
// @ts-ignore
const token = isPreviewMode
? (initialData?.initialToken) || getURLParam('token') || undefined
: (initialData?.initialToken) || undefined
/**
* SECURITY: Token Handling
*
* The widget supports two authentication modes based on agent's require_token_auth setting:
*
* 1. require_token_auth=true: Token MUST be provided from portal backend via /api/v1/generate-token
* 2. require_token_auth=false: Backend auto-generates tokens for anonymous users
*
* Token is a JWT that cryptographically binds:
* - widget_id (the specific widget instance)
* - customer_id (the authenticated user)
* - expiration time (TTL)
*
* Token signature is verified on the backend before any WebSocket connection.
*/
const app = createApp(WidgetBuilder, {
widgetId: widgetId,
token: token || undefined,
initialAuthError: null // Let backend determine if auth is required
})
app.mount('#app')