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,208 @@
|
||||
<!--
|
||||
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">
|
||||
import { ref } from 'vue'
|
||||
import { ClipboardIcon, CheckIcon } from '@heroicons/vue/24/outline'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const props = defineProps<{
|
||||
apiKey: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.apiKey)
|
||||
copied.value = true
|
||||
toast.success('已复制!', {
|
||||
description: 'API 密钥已复制到剪贴板',
|
||||
duration: 2000
|
||||
})
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
toast.error('复制失败', {
|
||||
description: '请手动复制',
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal @close="emit('close')">
|
||||
<template #title>API 密钥已创建</template>
|
||||
<template #content>
|
||||
<div class="api-key-modal">
|
||||
<div class="warning-banner">
|
||||
<strong>⚠️ 请立即保存此密钥!</strong>
|
||||
<p>这是唯一一次显示该 API 密钥,之后将无法再次查看。</p>
|
||||
</div>
|
||||
|
||||
<div class="key-container">
|
||||
<label>API 密钥:</label>
|
||||
<div class="key-display">
|
||||
<code class="api-key">{{ apiKey }}</code>
|
||||
<button
|
||||
class="copy-button"
|
||||
@click="copyToClipboard"
|
||||
:title="copied ? '已复制!' : '复制到剪贴板'"
|
||||
>
|
||||
<CheckIcon v-if="copied" class="icon success" />
|
||||
<ClipboardIcon v-else class="icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instructions">
|
||||
<h4>后续步骤:</h4>
|
||||
<ol>
|
||||
<li>复制该 API 密钥并妥善保管</li>
|
||||
<li>用它来验证组件 Token 生成请求</li>
|
||||
<li>将其放入 Authorization 请求头:<code>Bearer {{ apiKey.substring(0, 20) }}...</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" @click="emit('close')">
|
||||
我已保存密钥
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-key-modal {
|
||||
padding: var(--space-md);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.warning-banner strong {
|
||||
display: block;
|
||||
color: var(--warning-color);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.warning-banner p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.key-container {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.key-container label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.api-key {
|
||||
flex: 1;
|
||||
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
||||
font-size: var(--text-sm);
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
padding: var(--space-xs);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-normal);
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.icon.success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: var(--info-bg);
|
||||
border: 1px solid var(--info-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.instructions h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
.instructions ol {
|
||||
margin: 0;
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
.instructions li {
|
||||
margin-bottom: var(--space-xs);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.instructions code {
|
||||
background: var(--code-bg);
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<!--
|
||||
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">
|
||||
import { ref } from 'vue'
|
||||
import type { WidgetApp, WidgetAppCreate } from '@/types/widget-app'
|
||||
|
||||
const props = defineProps<{
|
||||
app?: WidgetApp | null
|
||||
}>()
|
||||
|
||||
// Always emit WidgetAppCreate since the form requires name
|
||||
// The parent component uses this for both create and update operations
|
||||
const emit = defineEmits<{
|
||||
submit: [data: WidgetAppCreate]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const name = ref(props.app?.name || '')
|
||||
const description = ref(props.app?.description || '')
|
||||
const error = ref('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
error.value = ''
|
||||
|
||||
if (!name.value.trim()) {
|
||||
error.value = '请输入名称'
|
||||
return
|
||||
}
|
||||
|
||||
if (name.value.length > 100) {
|
||||
error.value = '名称不能超过 100 个字符'
|
||||
return
|
||||
}
|
||||
|
||||
if (description.value && description.value.length > 500) {
|
||||
error.value = '描述不能超过 500 个字符'
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim() || undefined
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="widget-app-form">
|
||||
<div class="form-group">
|
||||
<label for="name">名称 <span class="required">*</span></label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
placeholder="例如:市场官网"
|
||||
maxlength="100"
|
||||
required
|
||||
/>
|
||||
<span class="hint">便于识别的网站组件名称</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="description"
|
||||
placeholder="该组件将部署在哪里?"
|
||||
maxlength="500"
|
||||
rows="3"
|
||||
/>
|
||||
<span class="hint">可选:说明该组件的用途</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ app ? '更新组件' : '创建组件' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-app-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 9px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 13px 15px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group textarea::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--error-bg);
|
||||
color: var(--error-color);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 22px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), filter var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,859 @@
|
||||
<!--
|
||||
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">
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useWidgetApps } from '@/composables/useWidgetApps'
|
||||
import { PlusIcon } from '@heroicons/vue/24/outline'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import WidgetAppForm from './WidgetAppForm.vue'
|
||||
import ApiKeyModal from './ApiKeyModal.vue'
|
||||
import { permissionChecks } from '@/utils/permissions'
|
||||
import { useSubscriptionStorage } from '@/utils/storage'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
|
||||
const subscriptionStorage = useSubscriptionStorage()
|
||||
const { hasEnterpriseModule } = useEnterpriseFeatures()
|
||||
|
||||
const {
|
||||
apps,
|
||||
loading,
|
||||
error,
|
||||
showCreateModal,
|
||||
showEditModal,
|
||||
showDeleteModal,
|
||||
showApiKeyModal,
|
||||
selectedApp,
|
||||
newApiKey,
|
||||
showInactive,
|
||||
hasAnyApps,
|
||||
hasInactiveApps,
|
||||
fetchApps,
|
||||
handleCreateApp,
|
||||
handleEditApp,
|
||||
handleUpdateApp,
|
||||
handleDeleteApp,
|
||||
confirmDeleteApp,
|
||||
handleRegenerateKey,
|
||||
closeApiKeyModal
|
||||
} = useWidgetApps()
|
||||
|
||||
const canManage = computed(() => permissionChecks.canManageOrganization())
|
||||
|
||||
// Subscription and widget apps feature checking
|
||||
const currentSubscription = computed(() => subscriptionStorage.getCurrentSubscription())
|
||||
const isSubscriptionActive = computed(() => subscriptionStorage.isSubscriptionActive())
|
||||
|
||||
// Check if widget apps is locked (only if enterprise module exists)
|
||||
const isWidgetAppsLocked = computed(() => {
|
||||
// Only lock if enterprise module exists
|
||||
if (!hasEnterpriseModule) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!currentSubscription.value || !isSubscriptionActive.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if api_access feature exists in subscription (required for widget apps)
|
||||
const hasApiAccessFeature = subscriptionStorage.hasFeature('api_access')
|
||||
if (!hasApiAccessFeature) {
|
||||
return true // Lock widget apps if feature doesn't exist
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Upgrade modal functions
|
||||
const handleUpgrade = () => {
|
||||
// Only redirect to subscription page if enterprise module exists
|
||||
if (hasEnterpriseModule) {
|
||||
window.location.href = '/settings/subscription'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (canManage.value && !isWidgetAppsLocked.value) {
|
||||
await fetchApps()
|
||||
}
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!canManage" class="no-permission">
|
||||
<p>你没有管理网站组件的权限。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="widget-app-list">
|
||||
<!-- Widget Apps Locked Overlay -->
|
||||
<div v-if="isWidgetAppsLocked" class="widget-apps-locked-overlay">
|
||||
<div class="locked-content">
|
||||
<div class="locked-header">
|
||||
<div class="locked-icon-wrapper">
|
||||
<div class="locked-icon-bg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="locked-icon">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h2>网站组件</h2>
|
||||
<div class="locked-badge">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="badge-icon">
|
||||
<path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 00-5.25 5.25v3a3 3 0 00-3 3v6.75a3 3 0 003 3h10.5a3 3 0 003-3v-6.75a3 3 0 00-3-3v-3c0-2.9-2.35-5.25-5.25-5.25zm3.75 8.25v-3a3.75 3.75 0 10-7.5 0v3h7.5z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>付费功能</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="locked-description">
|
||||
解锁网站组件,即可创建安全的 API 密钥,用于在多个平台与应用中验证聊天组件。
|
||||
</p>
|
||||
|
||||
<div class="locked-features">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-content">
|
||||
<span class="feature-title">安全 API 密钥</span>
|
||||
<span class="feature-desc">为每个集成生成独立密钥</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-content">
|
||||
<span class="feature-title">访问控制</span>
|
||||
<span class="feature-desc">随时管理与吊销访问权限</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
|
||||
<path d="M21 12a9 9 0 0 1-9 9m9-9a9 9 0 0 0-9-9m9 9H3m9 9a9 9 0 0 1-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-content">
|
||||
<span class="feature-title">多平台</span>
|
||||
<span class="feature-desc">在多个站点部署组件</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="upgrade-btn" @click="handleUpgrade">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="upgrade-icon">
|
||||
<path fill-rule="evenodd" d="M12.516 2.17a.75.75 0 00-1.032 0 11.209 11.209 0 01-7.877 3.08.75.75 0 00-.722.515A12.74 12.74 0 002.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 00.374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 00-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08zm3.094 8.016a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
升级到 Pro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Normal content when not locked -->
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<header class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1>网站组件</h1>
|
||||
<p class="subtitle">
|
||||
生成安全的 API 密钥,用于验证你的聊天组件。
|
||||
<span v-if="hasAnyApps" class="subtitle-count">{{ apps.length }} 个组件</span>
|
||||
</p>
|
||||
</div>
|
||||
<button v-if="hasAnyApps" class="btn btn-primary" @click="showCreateModal = true">
|
||||
<PlusIcon class="icon" />
|
||||
创建组件
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Include inactive toggle (only show when there are inactive apps) -->
|
||||
<div v-if="hasInactiveApps" class="filters">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showInactive"
|
||||
/>
|
||||
显示已停用组件
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>正在加载网站组件…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<p>{{ error }}</p>
|
||||
<button class="btn btn-secondary" @click="fetchApps">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (only when no apps at all) -->
|
||||
<div v-else-if="!hasAnyApps" class="empty-state">
|
||||
<div class="empty-content">
|
||||
<div class="empty-icon-bg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" class="empty-icon">
|
||||
<rect x="4" y="4" width="7" height="7" rx="2"/>
|
||||
<rect x="13" y="4" width="7" height="7" rx="2"/>
|
||||
<rect x="4" y="13" width="7" height="7" rx="2"/>
|
||||
<rect x="13" y="13" width="7" height="7" rx="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>暂无网站组件</h2>
|
||||
<p class="empty-description">
|
||||
网站组件可用于生成安全的 API 密钥,以验证你的聊天组件。
|
||||
创建第一个组件即可开始。
|
||||
</p>
|
||||
<button class="btn btn-primary create-btn" @click="showCreateModal = true">
|
||||
<PlusIcon class="icon" />
|
||||
创建第一个组件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else-if="apps.length > 0" class="table-container">
|
||||
<div class="table-head">
|
||||
<span class="col-name">名称</span>
|
||||
<span class="col-desc">描述</span>
|
||||
<span class="col-created">创建时间</span>
|
||||
<span class="col-updated">更新时间</span>
|
||||
<span class="col-actions">操作</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="app in apps"
|
||||
:key="app.id"
|
||||
:class="['table-row', { inactive: !app.is_active }]"
|
||||
>
|
||||
<div class="cell-name">
|
||||
<div class="app-name">{{ app.name }}</div>
|
||||
<span :class="['status-badge', app.is_active ? 'active' : 'inactive']">
|
||||
<span class="status-dot"></span>
|
||||
{{ app.is_active ? '启用中' : '已停用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="cell-desc">{{ app.description || '—' }}</div>
|
||||
<div class="cell-date">{{ formatDate(app.created_at) }}</div>
|
||||
<div class="cell-date">{{ formatDate(app.updated_at) }}</div>
|
||||
<div class="cell-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
title="编辑"
|
||||
@click="handleEditApp(app)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
title="重新生成密钥"
|
||||
@click="handleRegenerateKey(app)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn action-btn-danger"
|
||||
title="吊销"
|
||||
@click="handleDeleteApp(app)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M6 6l1 14a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-14"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All apps filtered state (all inactive and showInactive is false) -->
|
||||
<div v-else-if="hasAnyApps && apps.length === 0" class="filtered-state">
|
||||
<p>所有网站组件当前均已停用。</p>
|
||||
<button class="btn btn-secondary" @click="showInactive = true">
|
||||
显示已停用组件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<Modal v-if="showCreateModal" @close="showCreateModal = false">
|
||||
<template #title>创建网站组件</template>
|
||||
<template #content>
|
||||
<WidgetAppForm
|
||||
@submit="handleCreateApp"
|
||||
@cancel="showCreateModal = false"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<Modal v-if="showEditModal && selectedApp" @close="showEditModal = false">
|
||||
<template #title>编辑网站组件</template>
|
||||
<template #content>
|
||||
<WidgetAppForm
|
||||
:app="selectedApp"
|
||||
@submit="handleUpdateApp"
|
||||
@cancel="showEditModal = false"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal v-if="showDeleteModal && selectedApp" @close="showDeleteModal = false">
|
||||
<template #title>删除网站组件</template>
|
||||
<template #content>
|
||||
<div class="confirm-delete">
|
||||
<p>确定要删除 <strong>{{ selectedApp.name }}</strong> 吗?</p>
|
||||
<p class="warning">此操作将立即使该 API 密钥失效。</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-danger" @click="confirmDeleteApp">
|
||||
删除
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- API Key Display Modal -->
|
||||
<ApiKeyModal
|
||||
v-if="showApiKeyModal && newApiKey"
|
||||
:apiKey="newApiKey"
|
||||
@close="closeApiKeyModal"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-app-list {
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle-count {
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-header .btn-primary {
|
||||
padding: 12px 20px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: filter var(--transition-fast, 0.15s);
|
||||
}
|
||||
|
||||
.page-header .btn-primary:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.page-header .btn-primary .icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 18px;
|
||||
padding: 18px 0 6px;
|
||||
}
|
||||
|
||||
.table-head,
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 1.3fr) minmax(160px, 1.6fr) 130px 130px 130px;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-head {
|
||||
padding: 0 22px 14px;
|
||||
}
|
||||
|
||||
.table-head span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.table-head .col-actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
padding: 15px 22px;
|
||||
border-top: 1px solid var(--o06);
|
||||
}
|
||||
|
||||
.table-row.inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cell-name {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-desc {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-date {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
color: var(--c-online);
|
||||
}
|
||||
|
||||
.status-badge.active .status-dot {
|
||||
background: var(--c-teal);
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-badge.inactive .status-dot {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background var(--transition-fast, 0.15s), color var(--transition-fast, 0.15s);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--o10);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.action-btn-danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--coral-border);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.action-btn-danger:hover {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.confirm-delete {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.confirm-delete .warning {
|
||||
color: var(--error-color);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.no-permission,
|
||||
.filtered-state {
|
||||
text-align: center;
|
||||
padding: var(--space-2xl);
|
||||
}
|
||||
|
||||
.filtered-state {
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.filtered-state p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto var(--space-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Empty State Styles */
|
||||
.empty-state {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 20px;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
max-width: 440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-icon-bg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 16px;
|
||||
background: var(--accent-bg-12);
|
||||
border: 1px solid var(--accent-border);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.empty-content h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
margin: 0 auto 26px;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
padding: 13px 24px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter var(--transition-fast, 0.15s);
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.create-btn .icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
/* Widget Apps Locked Overlay Styles */
|
||||
.widget-apps-locked-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
background: var(--background-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(201, 242, 78, 0.04) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(59, 130, 246, 0.05) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
padding: var(--space-xl);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-header {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-icon-wrapper {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-icon-bg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, var(--accent-solid), var(--primary-dark));
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-header h2 {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .badge-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-description {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .locked-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--background-color);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--info-bg);
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--info-color);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .feature-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .upgrade-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .upgrade-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.widget-apps-locked-overlay .upgrade-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
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 type { Ref } from 'vue'
|
||||
import type { QueueItem } from '@/types/knowledge'
|
||||
|
||||
const normalizeSource = (source: string) => source.trim().toLowerCase().replace(/\/+$/, '')
|
||||
const sameSource = (a: string, b: string) => normalizeSource(a) === normalizeSource(b)
|
||||
|
||||
/** The newest row for each source: a retry must not be judged by the old failure.
|
||||
* The queue API lists newest first. */
|
||||
export const newestPerSource = (items: QueueItem[]) => {
|
||||
const seen = new Set<string>()
|
||||
return items.filter((item) => {
|
||||
const key = normalizeSource(item.source)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any just-sent source has already failed - a domain that doesn't
|
||||
* exist is refused the moment it is added. One look, no waiting: every other
|
||||
* failure only shows once the crawl has run (often minutes), so holding the
|
||||
* wizard for it would delay every site that reads fine. Later failures are
|
||||
* reported on the Test step.
|
||||
*/
|
||||
export function useCrawlCheck(fetchQueueItems: () => Promise<unknown>, queueItems: Ref<QueueItem[]>) {
|
||||
let cancelled = false
|
||||
|
||||
/** The first of `sources` that has already failed, or null. */
|
||||
const firstFailure = async (sources: string[]): Promise<QueueItem | null> => {
|
||||
cancelled = false
|
||||
try {
|
||||
await fetchQueueItems()
|
||||
} catch (err) {
|
||||
// Not knowing is not a failure: move on, the Test step will tell.
|
||||
console.error('Failed to check the knowledge queue:', err)
|
||||
return null
|
||||
}
|
||||
if (cancelled) return null
|
||||
const mine = newestPerSource(queueItems.value).filter((item) => sources.some((s) => sameSource(s, item.source)))
|
||||
return mine.find((item) => item.status === 'failed') ?? null
|
||||
}
|
||||
|
||||
/** Ignore the answer (the user went Back, or the step unmounted). */
|
||||
const cancel = () => {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
return { firstFailure, cancel }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
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 { ref, computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { widgetAppService } from '@/services/widget-app'
|
||||
import type { WidgetApp, WidgetAppCreate, WidgetAppUpdate } from '@/types/widget-app'
|
||||
|
||||
export function useWidgetApps() {
|
||||
const allApps = ref<WidgetApp[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Modal states
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const showApiKeyModal = ref(false)
|
||||
|
||||
// Selected app state
|
||||
const selectedApp = ref<WidgetApp | null>(null)
|
||||
const newApiKey = ref<string | null>(null) // For showing key after create/regenerate
|
||||
|
||||
// Include inactive toggle
|
||||
const showInactive = ref(false)
|
||||
|
||||
// Computed: Check if there are any apps at all
|
||||
const hasAnyApps = computed(() => allApps.value.length > 0)
|
||||
|
||||
// Computed: Check if there are any inactive apps
|
||||
const hasInactiveApps = computed(() => allApps.value.some(app => !app.is_active))
|
||||
|
||||
// Computed: Filter apps based on showInactive toggle
|
||||
const apps = computed(() => {
|
||||
if (showInactive.value) {
|
||||
return allApps.value
|
||||
}
|
||||
return allApps.value.filter(app => app.is_active)
|
||||
})
|
||||
|
||||
// Fetch all apps (always include inactive to know if filter should show)
|
||||
const fetchApps = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const response = await widgetAppService.listApps(true) // Always fetch all
|
||||
allApps.value = response.apps
|
||||
} catch (err: any) {
|
||||
error.value = '加载组件应用失败'
|
||||
console.error('Error loading widget apps:', err)
|
||||
toast.error('出错了', {
|
||||
description: '加载组件应用失败',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Create app
|
||||
const handleCreateApp = async (appData: WidgetAppCreate) => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const result = await widgetAppService.createApp(appData)
|
||||
|
||||
// Add new app to list
|
||||
allApps.value.unshift({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
organization_id: result.organization_id,
|
||||
created_by: result.created_by,
|
||||
is_active: result.is_active,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at
|
||||
})
|
||||
|
||||
// Store API key for display
|
||||
newApiKey.value = result.api_key
|
||||
|
||||
// Close create modal, open API key modal
|
||||
showCreateModal.value = false
|
||||
showApiKeyModal.value = true
|
||||
|
||||
toast.success('成功', {
|
||||
description: '组件应用已创建。请立即保存 API Key!',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = '创建组件应用失败'
|
||||
toast.error('出错了', {
|
||||
description: err.response?.data?.detail || '创建组件应用失败',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Edit app
|
||||
const handleEditApp = (app: WidgetApp) => {
|
||||
selectedApp.value = app
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
// Update app (accepts WidgetAppCreate since form always provides required fields)
|
||||
const handleUpdateApp = async (appData: WidgetAppCreate) => {
|
||||
if (!selectedApp.value) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const updatedApp = await widgetAppService.updateApp(selectedApp.value.id, appData)
|
||||
|
||||
// Update in list
|
||||
const index = allApps.value.findIndex(a => a.id === updatedApp.id)
|
||||
if (index !== -1) {
|
||||
allApps.value[index] = updatedApp
|
||||
}
|
||||
|
||||
showEditModal.value = false
|
||||
selectedApp.value = null
|
||||
|
||||
toast.success('成功', {
|
||||
description: '组件应用已更新',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = '更新组件应用失败'
|
||||
toast.error('出错了', {
|
||||
description: err.response?.data?.detail || '更新组件应用失败',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Delete app
|
||||
const handleDeleteApp = (app: WidgetApp) => {
|
||||
selectedApp.value = app
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
const confirmDeleteApp = async () => {
|
||||
if (!selectedApp.value) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
await widgetAppService.deleteApp(selectedApp.value.id)
|
||||
|
||||
// Remove from list
|
||||
allApps.value = allApps.value.filter(a => a.id !== selectedApp.value?.id)
|
||||
|
||||
showDeleteModal.value = false
|
||||
selectedApp.value = null
|
||||
|
||||
toast.success('成功', {
|
||||
description: '组件应用已删除',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = '删除组件应用失败'
|
||||
toast.error('出错了', {
|
||||
description: err.response?.data?.detail || '删除组件应用失败',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate API key
|
||||
const handleRegenerateKey = async (app: WidgetApp) => {
|
||||
if (!confirm('确定吗?这会立即使当前 API Key 失效。')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const result = await widgetAppService.regenerateApiKey(app.id)
|
||||
|
||||
// Update app in list
|
||||
const index = allApps.value.findIndex(a => a.id === result.id)
|
||||
if (index !== -1) {
|
||||
allApps.value[index] = {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
organization_id: result.organization_id,
|
||||
created_by: result.created_by,
|
||||
is_active: result.is_active,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
// Store new API key for display
|
||||
newApiKey.value = result.api_key
|
||||
showApiKeyModal.value = true
|
||||
|
||||
toast.success('成功', {
|
||||
description: 'API Key 已重新生成。请立即保存新的 Key!',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = '重新生成 API Key 失败'
|
||||
toast.error('出错了', {
|
||||
description: err.response?.data?.detail || '重新生成 API Key 失败',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Close API key modal
|
||||
const closeApiKeyModal = () => {
|
||||
showApiKeyModal.value = false
|
||||
newApiKey.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
apps,
|
||||
loading,
|
||||
error,
|
||||
showCreateModal,
|
||||
showEditModal,
|
||||
showDeleteModal,
|
||||
showApiKeyModal,
|
||||
selectedApp,
|
||||
newApiKey,
|
||||
showInactive,
|
||||
hasAnyApps,
|
||||
hasInactiveApps,
|
||||
fetchApps,
|
||||
handleCreateApp,
|
||||
handleEditApp,
|
||||
handleUpdateApp,
|
||||
handleDeleteApp,
|
||||
confirmDeleteApp,
|
||||
handleRegenerateKey,
|
||||
closeApiKeyModal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
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 { ref } from 'vue'
|
||||
import type { AgentCustomization } from '../types/agent'
|
||||
import WebFont from 'webfontloader'
|
||||
import { postToHost } from '../webclient/host-bridge'
|
||||
|
||||
// Design fonts the redesigned widget always needs (display / body / mono), with
|
||||
// the weights it actually uses. The user's custom font_family is layered on top.
|
||||
const DESIGN_FONT_FAMILIES = [
|
||||
'Space Grotesk:400,500,600,700',
|
||||
'Instrument Sans:400,500,600',
|
||||
'JetBrains Mono:400,500,600',
|
||||
]
|
||||
|
||||
// Load the design fonts plus any custom body font, and apply the custom font to
|
||||
// the chat container once available.
|
||||
const loadFonts = (customFontFamily?: string) => {
|
||||
const families = [...DESIGN_FONT_FAMILIES]
|
||||
|
||||
const primary = customFontFamily?.split(',')[0].trim().replace(/['"]/g, '') || ''
|
||||
const alreadyLoaded = DESIGN_FONT_FAMILIES.some(
|
||||
f => f.toLowerCase().startsWith(primary.toLowerCase())
|
||||
)
|
||||
if (primary && !alreadyLoaded) {
|
||||
families.push(primary)
|
||||
}
|
||||
|
||||
WebFont.load({
|
||||
google: { families },
|
||||
active: () => {
|
||||
if (!customFontFamily) return
|
||||
const chatContainer = document.querySelector('.chat-container') as HTMLElement
|
||||
if (chatContainer) {
|
||||
chatContainer.style.fontFamily = customFontFamily.includes(',')
|
||||
? customFontFamily
|
||||
: `"${customFontFamily}", system-ui, sans-serif`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useWidgetCustomization() {
|
||||
const customization = ref<Partial<AgentCustomization>>({})
|
||||
const agentName = ref('')
|
||||
|
||||
const applyCustomization = (newCustomization: Partial<AgentCustomization>) => {
|
||||
customization.value = newCustomization
|
||||
|
||||
|
||||
if (newCustomization.photo_url) {
|
||||
customization.value.photo_url = newCustomization.photo_url
|
||||
}
|
||||
|
||||
// Always load the design fonts; layer the custom font_family on top.
|
||||
loadFonts(newCustomization.font_family)
|
||||
|
||||
// Send customization update to parent
|
||||
postToHost({
|
||||
type: 'CUSTOMIZATION_UPDATE',
|
||||
data: {
|
||||
chat_bubble_color: newCustomization.chat_bubble_color || '#C9F24E',
|
||||
chat_style: newCustomization.chat_style,
|
||||
chat_initiation_messages: newCustomization.chat_initiation_messages || [],
|
||||
// Dashboard "Widget placement" defaults — the embed loader merges these
|
||||
// under any options the installing developer set.
|
||||
widget_display: newCustomization.customization_metadata?.widget_display
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize from window.__INITIAL_DATA__
|
||||
const initializeFromData = () => {
|
||||
const initialData = window.__INITIAL_DATA__
|
||||
if (initialData) {
|
||||
applyCustomization(initialData.customization || {})
|
||||
agentName.value = initialData.agentName || ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customization,
|
||||
agentName,
|
||||
applyCustomization,
|
||||
initializeFromData
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
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 { ref, type Ref } from 'vue'
|
||||
import { widgetEnv, resolveWidgetUploadUrl } from '../webclient/widget-env'
|
||||
import { isAbsoluteUrl } from '../utils/avatars'
|
||||
|
||||
// Allowed file types configuration (matching backend)
|
||||
const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'])
|
||||
const ALLOWED_DOCUMENT_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
])
|
||||
const ALLOWED_FILE_TYPES = new Set([...ALLOWED_IMAGE_TYPES, ...ALLOWED_DOCUMENT_TYPES])
|
||||
|
||||
// Extension to MIME type mapping
|
||||
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.pdf': 'application/pdf',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.txt': 'text/plain',
|
||||
'.csv': 'text/csv',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
}
|
||||
|
||||
// Magic byte signatures for client-side validation
|
||||
const MAGIC_BYTES: Record<string, { bytes: number[]; offset: number }[]> = {
|
||||
'image/jpeg': [
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xe0], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xe1], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xe2], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xdb], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xee], offset: 0 }
|
||||
],
|
||||
'image/png': [{ bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], offset: 0 }],
|
||||
'image/gif': [
|
||||
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], offset: 0 }, // GIF87a
|
||||
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], offset: 0 } // GIF89a
|
||||
],
|
||||
'image/webp': [{ bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }], // RIFF
|
||||
'application/pdf': [{ bytes: [0x25, 0x50, 0x44, 0x46, 0x2d], offset: 0 }] // %PDF-
|
||||
}
|
||||
|
||||
// Validate file magic bytes
|
||||
async function validateMagicBytes(file: File): Promise<{ valid: boolean; error?: string }> {
|
||||
const mimeType = file.type
|
||||
|
||||
// Text files don't have magic bytes
|
||||
if (mimeType === 'text/plain' || mimeType === 'text/csv') {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Office files (ZIP-based) start with PK
|
||||
if (mimeType.includes('openxmlformats') || mimeType === 'application/msword') {
|
||||
const buffer = await file.slice(0, 4).arrayBuffer()
|
||||
const bytes = new Uint8Array(buffer)
|
||||
// PK signature for ZIP-based or OLE for old Office
|
||||
if ((bytes[0] === 0x50 && bytes[1] === 0x4b) ||
|
||||
(bytes[0] === 0xd0 && bytes[1] === 0xcf)) {
|
||||
return { valid: true }
|
||||
}
|
||||
return { valid: false, error: 'File content does not match Office document format' }
|
||||
}
|
||||
|
||||
const signatures = MAGIC_BYTES[mimeType]
|
||||
if (!signatures) {
|
||||
// Unknown type, will be validated server-side
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Read first 16 bytes
|
||||
const buffer = await file.slice(0, 16).arrayBuffer()
|
||||
const fileBytes = new Uint8Array(buffer)
|
||||
|
||||
// WebP special check
|
||||
if (mimeType === 'image/webp') {
|
||||
if (fileBytes[0] === 0x52 && fileBytes[1] === 0x49 &&
|
||||
fileBytes[2] === 0x46 && fileBytes[3] === 0x46 &&
|
||||
fileBytes[8] === 0x57 && fileBytes[9] === 0x45 &&
|
||||
fileBytes[10] === 0x42 && fileBytes[11] === 0x50) {
|
||||
return { valid: true }
|
||||
}
|
||||
return { valid: false, error: 'File content does not match WebP format' }
|
||||
}
|
||||
|
||||
// Check signatures
|
||||
for (const sig of signatures) {
|
||||
let match = true
|
||||
for (let i = 0; i < sig.bytes.length; i++) {
|
||||
if (fileBytes[sig.offset + i] !== sig.bytes[i]) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (match) return { valid: true }
|
||||
}
|
||||
|
||||
return { valid: false, error: `File content does not match ${mimeType} format` }
|
||||
}
|
||||
|
||||
// Validate file extension matches MIME type
|
||||
function validateExtension(filename: string, mimeType: string): { valid: boolean; error?: string } {
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'))
|
||||
|
||||
if (!ext || ext === '.') {
|
||||
return { valid: false, error: 'File must have an extension' }
|
||||
}
|
||||
|
||||
if (!(ext in EXTENSION_TO_MIME)) {
|
||||
const allowed = Object.keys(EXTENSION_TO_MIME).map(e => e.toUpperCase().replace('.', '')).join(', ')
|
||||
return { valid: false, error: `File type not allowed. Allowed: ${allowed}` }
|
||||
}
|
||||
|
||||
const expectedMime = EXTENSION_TO_MIME[ext]
|
||||
|
||||
// Handle jpg/jpeg equivalence
|
||||
if ((mimeType === 'image/jpeg' || mimeType === 'image/jpg') &&
|
||||
(expectedMime === 'image/jpeg' || expectedMime === 'image/jpg')) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
if (expectedMime !== mimeType) {
|
||||
return { valid: false, error: `File extension ${ext} does not match content type` }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export function useWidgetFiles(token: Ref<string | null>, fileInputRef: Ref<HTMLInputElement | null>) {
|
||||
// File handling state
|
||||
const uploadedAttachments = ref<Array<{
|
||||
content: string
|
||||
filename: string
|
||||
type: string
|
||||
size: number
|
||||
url: string
|
||||
file_url: string
|
||||
}>>([])
|
||||
|
||||
const previewModal = ref(false)
|
||||
const previewFile = ref<{
|
||||
url: string
|
||||
filename: string
|
||||
type: string
|
||||
file_url?: string
|
||||
size?: number
|
||||
} | null>(null)
|
||||
|
||||
// Format file size for display
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Check if file is an image
|
||||
const isImageAttachment = (contentType: string): boolean => {
|
||||
return contentType.startsWith('image/')
|
||||
}
|
||||
|
||||
// Generate download URL for attachments
|
||||
const getDownloadUrl = (fileUrl: string | undefined | null): string => {
|
||||
if (!fileUrl) return ''
|
||||
|
||||
// Blob previews and absolute S3 URLs pass through; local paths already
|
||||
// carry the /api/v1 prefix, so they resolve against the API origin.
|
||||
return resolveWidgetUploadUrl(fileUrl)
|
||||
}
|
||||
|
||||
// Generate preview URL for file display (similar to FileUpload.vue)
|
||||
const getPreviewUrl = (file: {url: string, file_url?: string}): string => {
|
||||
const urlToUse = file.file_url || file.url
|
||||
if (!urlToUse) return ''
|
||||
|
||||
return resolveWidgetUploadUrl(urlToUse)
|
||||
}
|
||||
|
||||
// Handle file selection from input
|
||||
const handleFileSelect = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files.length > 0) {
|
||||
await uploadFiles(Array.from(target.files))
|
||||
// Reset the input value to allow selecting the same file again
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag and drop
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
const files = event.dataTransfer?.files
|
||||
if (files && files.length > 0) {
|
||||
await uploadFiles(Array.from(files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// Handle paste events
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
const files: File[] = []
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
files.push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
await uploadFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
// Compress image if it's too large
|
||||
const compressImage = async (file: File, maxSizeKB: number = 500): Promise<{blob: Blob, base64: string}> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
let width = img.width
|
||||
let height = img.height
|
||||
|
||||
// Calculate new dimensions (max 1920px width/height while maintaining aspect ratio)
|
||||
const maxDimension = 1920
|
||||
if (width > maxDimension || height > maxDimension) {
|
||||
if (width > height) {
|
||||
height = (height / width) * maxDimension
|
||||
width = maxDimension
|
||||
} else {
|
||||
width = (width / height) * maxDimension
|
||||
height = maxDimension
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get canvas context'))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Start with quality 0.9 and reduce if needed
|
||||
let quality = 0.9
|
||||
const tryCompress = () => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to compress image'))
|
||||
return
|
||||
}
|
||||
|
||||
const sizeKB = blob.size / 1024
|
||||
|
||||
// If still too large and quality can be reduced, try again
|
||||
if (sizeKB > maxSizeKB && quality > 0.3) {
|
||||
quality -= 0.1
|
||||
tryCompress()
|
||||
} else {
|
||||
// Convert blob to base64
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(',')[1]
|
||||
resolve({ blob, base64 })
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
}
|
||||
}, file.type === 'image/png' ? 'image/png' : 'image/jpeg', quality)
|
||||
}
|
||||
|
||||
tryCompress()
|
||||
}
|
||||
img.onerror = () => reject(new Error('Failed to load image'))
|
||||
img.src = e.target?.result as string
|
||||
}
|
||||
reader.onerror = () => reject(new Error('Failed to read file'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
// Upload files (convert to base64 and store locally)
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
const MAX_FILES = 3 // Maximum 3 files per message
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB for images
|
||||
const MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 // 10MB for documents
|
||||
const TARGET_SIZE_KB = 500 // Target 500KB after compression
|
||||
|
||||
// Check if adding these files would exceed the limit
|
||||
if (uploadedAttachments.value.length >= MAX_FILES) {
|
||||
alert(`每条消息最多 ${MAX_FILES} 个文件`)
|
||||
return
|
||||
}
|
||||
|
||||
const remainingSlots = MAX_FILES - uploadedAttachments.value.length
|
||||
const filesToUpload = files.slice(0, remainingSlots)
|
||||
|
||||
if (files.length > remainingSlots) {
|
||||
alert(`最多还能上传 ${remainingSlots} 个文件。每条消息最多 ${MAX_FILES} 个文件。`)
|
||||
}
|
||||
|
||||
for (const file of filesToUpload) {
|
||||
try {
|
||||
// Check if file with same name already exists
|
||||
const isDuplicate = uploadedAttachments.value.some(att => att.filename === file.name)
|
||||
if (isDuplicate) {
|
||||
console.warn(`File ${file.name} is already selected`)
|
||||
alert(`文件“${file.name}”已选择`)
|
||||
continue
|
||||
}
|
||||
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const maxSize = isImage ? MAX_FILE_SIZE : MAX_DOCUMENT_SIZE
|
||||
|
||||
// Validate file size before upload
|
||||
if (file.size > maxSize) {
|
||||
const maxSizeMB = maxSize / (1024 * 1024)
|
||||
console.error(`File ${file.name} is too large. Maximum size is ${maxSizeMB}MB`)
|
||||
alert(`文件“${file.name}”过大,${isImage ? '图片' : '文档'}最大为 ${maxSizeMB}MB`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isImage) {
|
||||
// Compress image before upload
|
||||
try {
|
||||
const { blob, base64 } = await compressImage(file, TARGET_SIZE_KB)
|
||||
const compressedSize = blob.size
|
||||
|
||||
console.log(`Compressed ${file.name}: ${(file.size / 1024).toFixed(2)}KB → ${(compressedSize / 1024).toFixed(2)}KB`)
|
||||
|
||||
uploadedAttachments.value.push({
|
||||
content: base64,
|
||||
filename: file.name,
|
||||
type: file.type,
|
||||
size: compressedSize,
|
||||
url: URL.createObjectURL(blob),
|
||||
file_url: URL.createObjectURL(blob)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Image compression failed, uploading original:', error)
|
||||
// Fallback to original file if compression fails
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const base64Content = e.target?.result as string
|
||||
const base64Data = base64Content.split(',')[1]
|
||||
|
||||
uploadedAttachments.value.push({
|
||||
content: base64Data,
|
||||
filename: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
url: URL.createObjectURL(file),
|
||||
file_url: URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
} else {
|
||||
// For non-images, read as-is
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const base64Content = e.target?.result as string
|
||||
const base64Data = base64Content.split(',')[1]
|
||||
|
||||
uploadedAttachments.value.push({
|
||||
content: base64Data,
|
||||
filename: file.name,
|
||||
type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
url: '',
|
||||
file_url: ''
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File upload error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove attachment and call delete API
|
||||
const removeAttachment = async (index: number) => {
|
||||
const file = uploadedAttachments.value[index]
|
||||
if (!file) return
|
||||
|
||||
// Call delete API to remove file from storage
|
||||
try {
|
||||
// Extract the file path from the URL
|
||||
let filePath = file.url
|
||||
|
||||
// Remove /uploads/ prefix if present
|
||||
if (filePath.startsWith('/uploads/')) {
|
||||
filePath = filePath.substring(9)
|
||||
} else if (filePath.startsWith('/')) {
|
||||
filePath = filePath.substring(1)
|
||||
}
|
||||
|
||||
// For absolute S3/CDN URLs, reduce to the object key (the URL path).
|
||||
// Parsing the URL avoids matching a host substring, which is unreliable
|
||||
// and flagged by static analysis (js/incomplete-url-substring-sanitization).
|
||||
if (isAbsoluteUrl(filePath)) {
|
||||
try {
|
||||
filePath = new URL(filePath).pathname.replace(/^\/+/, '')
|
||||
} catch {
|
||||
// Not a parseable URL — leave the path as-is.
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (token.value) {
|
||||
headers['Authorization'] = `Bearer ${token.value}`
|
||||
}
|
||||
|
||||
// API_URL already ends in /api/v1 — do not repeat it here.
|
||||
const response = await fetch(`${widgetEnv.API_URL}/files/upload/${filePath}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
console.error('Failed to delete file:', errorData.detail)
|
||||
// Optionally, show an error message to the user
|
||||
} else {
|
||||
console.log('File deleted successfully from backend.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error calling delete API:', error)
|
||||
}
|
||||
|
||||
// Revoke blob URLs to free memory
|
||||
if (file.url && file.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(file.url)
|
||||
}
|
||||
if (file.file_url && file.file_url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(file.file_url)
|
||||
}
|
||||
|
||||
uploadedAttachments.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// Open file preview modal
|
||||
const openPreview = (file: {
|
||||
url: string
|
||||
filename: string
|
||||
type: string
|
||||
file_url?: string
|
||||
size?: number
|
||||
}) => {
|
||||
previewFile.value = file
|
||||
previewModal.value = true
|
||||
}
|
||||
|
||||
// Close preview modal
|
||||
const closePreview = () => {
|
||||
previewModal.value = false
|
||||
// Don't clear previewFile immediately to allow smooth transition
|
||||
setTimeout(() => {
|
||||
previewFile.value = null
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Open file picker
|
||||
const openFilePicker = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
// Check if file type is image (utility function)
|
||||
const isImage = (type: string): boolean => {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
|
||||
return {
|
||||
uploadedAttachments,
|
||||
previewModal,
|
||||
previewFile,
|
||||
formatFileSize,
|
||||
isImageAttachment,
|
||||
getDownloadUrl,
|
||||
getPreviewUrl,
|
||||
handleFileSelect,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handlePaste,
|
||||
uploadFiles,
|
||||
removeAttachment,
|
||||
openPreview,
|
||||
closePreview,
|
||||
openFilePicker,
|
||||
isImage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
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 { computed } from 'vue'
|
||||
import type { AgentCustomization } from '../types/widget'
|
||||
import { isColorDark } from '../types/widget'
|
||||
import { resolveWidgetUploadUrl } from '../webclient/widget-env'
|
||||
|
||||
// Themed colours now come from the shared design tokens (widget-theme.ts →
|
||||
// `--rp-*` vars set on `.chat-container`). These computeds return `var(--rp-*)`
|
||||
// references so the widget's existing inline styles resolve to the token values,
|
||||
// while user overrides are folded into the vars by `themeCssVars()`.
|
||||
export function useWidgetStyles(customization: { value: AgentCustomization }) {
|
||||
const chatStyles = computed(() => ({
|
||||
backgroundColor: 'var(--rp-card)',
|
||||
color: 'var(--rp-text)'
|
||||
}))
|
||||
|
||||
// Launcher icon still follows the dedicated chat_bubble_color.
|
||||
const chatIconStyles = computed(() => ({
|
||||
backgroundColor: customization.value.chat_bubble_color || '#C9F24E',
|
||||
color: isColorDark(customization.value.chat_bubble_color || '#C9F24E') ? '#FFFFFF' : '#000000'
|
||||
}))
|
||||
|
||||
const agentBubbleStyles = computed(() => ({
|
||||
backgroundColor: 'var(--rp-agent-bg)',
|
||||
color: 'var(--rp-text)'
|
||||
}))
|
||||
|
||||
const userBubbleStyles = computed(() => ({
|
||||
backgroundColor: 'var(--rp-accent)',
|
||||
color: 'var(--rp-on-accent)'
|
||||
}))
|
||||
|
||||
const messageNameStyles = computed(() => ({
|
||||
color: 'var(--rp-text)'
|
||||
}))
|
||||
|
||||
const headerBorderStyles = computed(() => ({
|
||||
borderBottom: '1px solid var(--rp-hairline)'
|
||||
}))
|
||||
|
||||
// Signed S3/CDN URLs pass through; local paths are resolved against the
|
||||
// runtime API origin (photo_url already carries the /api/v1 prefix).
|
||||
const photoUrl = computed(() => resolveWidgetUploadUrl(customization.value.photo_url))
|
||||
|
||||
const shadowStyle = computed(() => {
|
||||
const bgColor = customization.value.chat_background_color || '#ffffff'
|
||||
return {
|
||||
boxShadow: `0 8px 5px ${isColorDark(bgColor) ? 'rgba(0, 0, 0, 0.24)' : 'rgba(0, 0, 0, 0.12)'}`
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
chatStyles,
|
||||
chatIconStyles,
|
||||
agentBubbleStyles,
|
||||
userBubbleStyles,
|
||||
messageNameStyles,
|
||||
headerBorderStyles,
|
||||
photoUrl,
|
||||
shadowStyle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
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 { getApiUrl } from '@/config/api'
|
||||
|
||||
// The loader (replypier.min.js) is served by THIS frontend, so resolve its
|
||||
// origin at snippet-generation time from where the dashboard is running — no
|
||||
// build-time VITE_WIDGET_URL bake, so a self-hosted install just works.
|
||||
function getLoaderOrigin(): string {
|
||||
return typeof window !== 'undefined' ? window.location.origin : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the embeddable <script> snippet for a widget.
|
||||
* - Simple (anonymous) variant: drops `window.replypierId` + the loader script.
|
||||
* - Token-auth variant: fetches a short-lived token from the host's backend
|
||||
* before loading the widget (used when the agent requires token auth).
|
||||
* Shared by the AI Agents list and the onboarding Launch step. Its exact text
|
||||
* is pinned in __tests__/fixtures/widget-snippet.txt, which other code that
|
||||
* builds the snippet tests against too.
|
||||
*
|
||||
* `window.replypierBaseUrl` is baked into the snippet from the runtime API URL
|
||||
* so the embedded widget (running on the customer's own site, where APP_CONFIG is
|
||||
* absent) talks to THIS install's backend instead of the vendor cloud.
|
||||
*/
|
||||
export function buildWidgetEmbed(widgetId: string, requireTokenAuth?: boolean): string {
|
||||
const loaderOrigin = getLoaderOrigin()
|
||||
const apiUrl = getApiUrl()
|
||||
if (requireTokenAuth) {
|
||||
return `<!-- Get token from your backend: POST /api/v1/generate-token with API key -->
|
||||
<!-- Security Note: Widget ID and token are cryptographically bound in the JWT. -->
|
||||
<script>
|
||||
(function() {
|
||||
fetch('/api/replypier')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
let token, widget_id;
|
||||
if (d.data && d.data.token && d.data.widget_id) {
|
||||
token = d.data.token;
|
||||
widget_id = d.data.widget_id;
|
||||
} else if (d.token && d.token.data && d.token.data.data) {
|
||||
token = d.token.data.data.token;
|
||||
widget_id = d.token.data.data.widget_id;
|
||||
} else if (d.token && d.widget_id) {
|
||||
token = d.token;
|
||||
widget_id = d.widget_id;
|
||||
}
|
||||
if (!token || !widget_id) throw new Error('Failed to extract token or widget_id');
|
||||
window.replypierId = widget_id;
|
||||
window.replypierBaseUrl = '${apiUrl}';
|
||||
localStorage.setItem('rpid', token);
|
||||
const script = document.createElement('script');
|
||||
script.src = '${loaderOrigin}/webclient/replypier.min.js';
|
||||
document.head.appendChild(script);
|
||||
})
|
||||
.catch(e => console.error('[ReplyPier] Initialization failed:', e));
|
||||
})();
|
||||
<\/script>`
|
||||
}
|
||||
return `<script>
|
||||
window.replypierId = '${widgetId}';
|
||||
window.replypierBaseUrl = '${apiUrl}';
|
||||
<\/script>
|
||||
<script src="${loaderOrigin}/webclient/replypier.min.js"><\/script>`
|
||||
}
|
||||
@@ -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