feat(widget): Widget 配置、loader 与右下角悬浮入口
生成可嵌入站点的 loader 与悬浮按钮,支持展开收起和移动端;附独立宿主测试页。
This commit is contained in:
1 parent
556b650c7a
commit
0cc6299d1c
57 files changed
+32086
No files matched your search
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
Reference in new issue
Block a user