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

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

No files matched your search

@@ -0,0 +1,208 @@
<!--
Copyright 2024-2026 ChatterMate
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 ChatterMate
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 ChatterMate
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>
+66
View File
@@ -0,0 +1,66 @@
/*
Copyright 2024-2026 ChatterMate
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 }
}
+272
View File
@@ -0,0 +1,272 @@
/*
Copyright 2024-2026 ChatterMate
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 ChatterMate
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
}
}
+534
View File
@@ -0,0 +1,534 @@
/*
Copyright 2024-2026 ChatterMate
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 ChatterMate
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 →
// `--cm-*` vars set on `.chat-container`). These computeds return `var(--cm-*)`
// 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(--cm-card)',
color: 'var(--cm-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(--cm-agent-bg)',
color: 'var(--cm-text)'
}))
const userBubbleStyles = computed(() => ({
backgroundColor: 'var(--cm-accent)',
color: 'var(--cm-on-accent)'
}))
const messageNameStyles = computed(() => ({
color: 'var(--cm-text)'
}))
const headerBorderStyles = computed(() => ({
borderBottom: '1px solid var(--cm-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
}
}
+78
View File
@@ -0,0 +1,78 @@
/*
Copyright 2024-2026 ChatterMate
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 (chattermate.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.chattermateId` + 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.chattermateBaseUrl` 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/chattermate')
.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.chattermateId = widget_id;
window.chattermateBaseUrl = '${apiUrl}';
localStorage.setItem('ctid', token);
const script = document.createElement('script');
script.src = '${loaderOrigin}/webclient/chattermate.min.js';
document.head.appendChild(script);
})
.catch(e => console.error('[ChatterMate] Initialization failed:', e));
})();
<\/script>`
}
return `<script>
window.chattermateId = '${widgetId}';
window.chattermateBaseUrl = '${apiUrl}';
<\/script>
<script src="${loaderOrigin}/webclient/chattermate.min.js"><\/script>`
}
+645
View File
@@ -0,0 +1,645 @@
<!--
Copyright 2024-2026 ChatterMate
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 --cm-* 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://chattermate.chat" target="_blank" rel="noopener noreferrer">由 ChatterMate 提供支持</a>
</div>
</div>
</template>
<style scoped>
/* Every colour is a --cm-* 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(--cm-card);
color: var(--cm-text);
font-family: var(--cm-body-font, inherit);
}
.askai__bar {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 14px 14px 16px;
border-bottom: 1px solid var(--cm-border);
flex-shrink: 0;
}
.askai__bar-icon {
color: var(--cm-accent);
flex-shrink: 0;
}
.askai__input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--cm-text);
font-size: 16px;
font-family: inherit;
padding: 2px 0;
}
.askai__input::placeholder { color: var(--cm-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(--cm-border);
background: transparent;
border-radius: 6px;
cursor: pointer;
color: var(--cm-muted);
font-family: inherit;
flex-shrink: 0;
transition: color 0.15s ease, border-color 0.15s ease;
}
.askai__new:hover:not(:disabled) {
color: var(--cm-text);
border-color: var(--cm-accent);
}
.askai__new:disabled { opacity: 0.5; cursor: default; }
.askai__new--armed {
color: var(--cm-text);
border-color: var(--cm-accent);
}
.askai__close {
border: 1px solid var(--cm-border);
background: transparent;
border-radius: 6px;
padding: 3px 7px;
cursor: pointer;
color: var(--cm-muted);
flex-shrink: 0;
transition: color 0.15s ease, border-color 0.15s ease;
}
.askai__close:hover {
color: var(--cm-text);
border-color: var(--cm-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(--cm-muted);
}
.askai__label {
display: block;
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--cm-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(--cm-border);
border-radius: 10px;
background: var(--cm-agent-bg);
color: var(--cm-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(--cm-accent);
transform: translateX(2px);
}
.askai__suggestion:disabled { opacity: 0.55; cursor: default; }
.askai__suggestion svg { color: var(--cm-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(--cm-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(--cm-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(--cm-accent); }
.askai__answer :deep(code) {
background: var(--cm-agent-bg);
padding: 1px 5px;
border-radius: 4px;
font-size: 0.9em;
}
.askai__answer :deep(pre) {
background: var(--cm-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(--cm-border);
color: var(--cm-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(--cm-muted);
font-size: 13px;
}
.askai__thinking-text { margin-left: 4px; }
.askai__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--cm-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(--cm-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(--cm-border);
font-size: 11.5px;
text-align: center;
color: var(--cm-muted);
flex-shrink: 0;
}
.askai__brand {
color: inherit;
text-decoration: none;
opacity: 0.8;
}
.askai__brand:hover { text-decoration: underline; }
@media (max-width: 768px) {
.askai__body { padding: 16px 16px 20px; }
/* 16px keeps iOS from zooming the page when the input takes focus. */
.askai__input { font-size: 16px; }
}
</style>
+132
View File
@@ -0,0 +1,132 @@
<!--
Copyright 2024-2026 ChatterMate
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(--cm-hairline, rgba(0, 0, 0, 0.07));
background: var(--cm-agent-bg, rgba(0, 0, 0, 0.03));
font-family: var(--cm-body-font, inherit);
}
.new-chat-confirm__question {
margin: 0;
font-size: 12.5px;
line-height: 1.35;
color: var(--cm-text, inherit);
}
.new-chat-confirm__actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.new-chat-confirm__button {
padding: 5px 11px;
border-radius: var(--cm-field-radius, 8px);
border: 1px solid var(--cm-border, rgba(0, 0, 0, 0.12));
background: transparent;
color: var(--cm-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(--cm-accent, #f34611);
color: var(--cm-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
File diff suppressed because it is too large. Load diff
+100
View File
@@ -0,0 +1,100 @@
/*
* Copyright 2024-2026 ChatterMate
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* The one way the widget talks to the page it is embedded in.
*
* These messages include the visitor's conversation token, so they are addressed to a
* specific origin rather than broadcast with "*": if the embedding page is ever
* replaced by another site's document, the browser drops the message instead of
* handing that site a live token.
*
* The widget runs in a srcdoc iframe, which inherits the embedder's origin, so the
* host origin is normally readable. When it genuinely cannot be determined we fall
* back to "*" - a widget that cannot talk to its loader is broken, and that is worse
* than the broadcast we have always done.
*/
const ANY_ORIGIN = '*'
/** Origins a browser reports for an opaque (sandboxed, data:, srcdoc) document. */
const OPAQUE = new Set(['null', 'about:blank', ''])
let resolved: string | null = null
const usable = (origin: string | undefined | null): string | null =>
origin && !OPAQUE.has(origin) ? origin : null
const originOf = (url: string | undefined | null): string | null => {
if (!url) return null
try {
return usable(new URL(url).origin)
} catch {
return null
}
}
/**
* Origin of the page hosting the widget, or "*" when it cannot be established.
* Resolved once: an embedding page navigating between routes keeps its origin.
*/
export function hostOrigin(): string {
if (resolved) return resolved
// Not framed at all - the dashboard preview renders the widget inline.
if (window.parent === window) {
resolved = usable(window.location.origin) || ANY_ORIGIN
return resolved
}
// Same-origin parent: the srcdoc frame the embed loader creates. Reading its
// location throws for a cross-origin embed, which the catch below handles.
try {
const parentOrigin = usable(window.parent.location.origin)
if (parentOrigin) {
resolved = parentOrigin
return resolved
}
} catch {
/* cross-origin parent - fall through */
}
// Cross-origin embed: the browser still names our ancestors (not in Firefox), and
// the referrer is the embedding document.
try {
const ancestors = window.location.ancestorOrigins
const nearest = ancestors && ancestors.length ? usable(ancestors[0]) : null
if (nearest) {
resolved = nearest
return resolved
}
} catch {
/* not supported - fall through */
}
resolved = originOf(document.referrer) || ANY_ORIGIN
return resolved
}
/** Send a message to the embedding page, addressed to its origin. */
export function postToHost(message: Record<string, unknown>): void {
window.parent.postMessage(message, hostOrigin())
}
/** Test seam: forget the cached origin. */
export function resetHostOrigin(): void {
resolved = null
}
+38
View File
@@ -0,0 +1,38 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Agent-answer markdown → safe HTML, shared by every widget surface that renders a
* reply (the chat panel and the Ask AI palette) so they can't drift apart on either
* formatting or sanitisation.
*
* Output always goes through `sanitizeHtml`, which strips iframes/scripts/handlers —
* answers contain model output and knowledge-base content, so it is never trusted.
*/
import { marked } from 'marked'
import { sanitizeHtml } from '../utils/sanitize'
marked.setOptions({
renderer: new marked.Renderer(),
gfm: true,
breaks: true,
})
// Links open in a new tab (the widget lives in an iframe, so a same-tab navigation
// would replace the chat rather than the page). sanitizeHtml already forces
// target="_blank" + rel on every surviving anchor, so nothing is needed here.
export const renderMarkdown = (text: string): string => sanitizeHtml(marked(text || '') as string)
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2024-2026 ChatterMate
*
* 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
+64
View File
@@ -0,0 +1,64 @@
/*
Copyright 2024-2026 ChatterMate
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.chattermate.chat/api/v1';
},
get WS_URL() {
const config = getRuntimeConfig();
return config.WS_URL || import.meta.env.VITE_WS_URL || 'wss://api.chattermate.chat';
}
}
/**
* Widget-side counterpart of resolveUploadUrl (src/config/api.ts). Same rules,
* but resolved against widgetEnv.API_URL so the widget keeps its own fallback.
*/
export function resolveWidgetUploadUrl(stored?: string | null): string {
return buildUploadUrl(stored, widgetEnv.API_URL);
}
+243
View File
@@ -0,0 +1,243 @@
/**
* SHARED chat-surface theme — the single place the widget's themed appearance is
* defined. Imported by BOTH the real widget (`WidgetBuilder.vue`) and the admin
* preview (`AgentChatPreviewPanel.vue`) so they can never drift.
*
* Every rule is scoped under `.chat-container.cm-surface` (both surfaces add the
* `cm-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 `--cm-*`
* 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.cm-surface {
font-family: var(--cm-body-font);
color: var(--cm-text);
}
.chat-container.cm-surface .chat-panel {
background: var(--cm-card);
color: var(--cm-text);
}
/* ===== Header ===== */
.chat-container.cm-surface .chat-header {
padding: 14px 16px;
border-bottom: 1px solid var(--cm-hairline);
background: transparent;
}
.chat-container.cm-surface .header-info h3 {
color: var(--cm-text);
font-family: var(--cm-body-font);
font-size: 14.5px;
font-weight: 600;
line-height: 1.15;
}
/* --cm-presence is the accent when it is readable on the card, otherwise the
contrast-derived muted colour (see themeCssVars). */
.chat-container.cm-surface .cm-presence,
.chat-container.cm-surface .status-text {
color: var(--cm-presence, var(--cm-accent));
font-size: 11.5px;
}
.chat-container.cm-surface .status-indicator.online {
background: var(--cm-presence, var(--cm-accent));
box-shadow: none;
}
/* ===== Avatars — circle by default, rounded-square on mono (Terminal); no ring ===== */
.chat-container.cm-surface .header-orb,
.chat-container.cm-surface .header-avatar {
width: 34px;
height: 34px;
border-radius: var(--cm-avatar-radius);
border: none;
box-shadow: none;
object-fit: cover;
flex-shrink: 0;
}
.chat-container.cm-surface .cm-msg-avatar,
.chat-container.cm-surface .cm-msg-avatar-orb,
.chat-container.cm-surface .cm-msg-avatar-img,
.chat-container.cm-surface .cm-welcome-orb,
.chat-container.cm-surface .cm-welcome-avatar {
width: 28px;
height: 28px;
border-radius: var(--cm-avatar-radius);
border: none;
box-shadow: none;
flex-shrink: 0;
display: block;
}
.chat-container.cm-surface .cm-msg-avatar-img,
.chat-container.cm-surface .cm-welcome-avatar { object-fit: cover; }
/* ===== Messages ===== */
.chat-container.cm-surface .message {
max-width: 100%;
gap: 9px;
align-items: flex-start;
}
.chat-container.cm-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.cm-surface .message-bubble p { margin: 0; }
.chat-container.cm-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.cm-surface .message-bubble a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
font-weight: 600;
cursor: pointer;
}
.chat-container.cm-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.cm-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.cm-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.cm-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.cm-surface .message-bubble table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 0.5em 0;
}
.chat-container.cm-surface .message-bubble th,
.chat-container.cm-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.cm-surface .agent-message .message-bubble,
.chat-container.cm-surface .assistant-message .message-bubble {
background: var(--cm-agent-bg);
color: var(--cm-text);
border: 1px solid var(--cm-hairline);
border-radius: var(--cm-bubble-tail) var(--cm-bubble) var(--cm-bubble) var(--cm-bubble);
}
/* User bubble: accent fill, contrast text, tail bottom-right. */
.chat-container.cm-surface .user-message .message-bubble {
background: var(--cm-accent);
color: var(--cm-on-accent);
border: none;
border-radius: var(--cm-bubble) var(--cm-bubble) var(--cm-bubble-tail) var(--cm-bubble);
}
/* ===== Quick-action pills ===== */
.chat-container.cm-surface .cm-quick-action {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--cm-accent) 66%, transparent);
background: color-mix(in srgb, var(--cm-accent) 18%, transparent);
color: var(--cm-text);
font-family: var(--cm-body-font);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.chat-container.cm-surface .cm-quick-action:hover:not(:disabled) {
background: var(--cm-accent);
color: var(--cm-on-accent);
border-color: var(--cm-accent);
}
.chat-container.cm-surface.theme-terminal .cm-quick-action { border-radius: 6px; }
/* ===== Input bar + send (transparent bar — card shows through, no grey band) ===== */
.chat-container.cm-surface .chat-input {
background: transparent;
border-top: 1px solid var(--cm-hairline);
}
.chat-container.cm-surface .message-input input,
.chat-container.cm-surface .email-input input {
border-radius: var(--cm-field-radius);
border: 1px solid color-mix(in srgb, var(--cm-accent) 30%, var(--cm-hairline));
background: color-mix(in srgb, var(--cm-text) 6%, transparent);
color: var(--cm-text);
font-family: var(--cm-body-font);
font-size: 14px;
}
.chat-container.cm-surface .message-input input::placeholder,
.chat-container.cm-surface .email-input input::placeholder {
color: var(--cm-muted);
opacity: 1;
}
.chat-container.cm-surface .message-input input:focus,
.chat-container.cm-surface .email-input input:focus {
border-color: var(--cm-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--cm-accent) 22%, transparent);
}
.chat-container.cm-surface .send-button {
width: 42px;
min-width: 42px;
height: 42px;
flex-shrink: 0;
border: none;
border-radius: var(--cm-field-radius);
background: var(--cm-accent);
color: var(--cm-on-accent);
cursor: pointer;
}
/* ===== Powered-by footer (transparent — card shows, no grey band) ===== */
.chat-container.cm-surface .powered-by,
.chat-container.cm-surface .powered-by-welcome {
background: transparent;
border-top: none;
color: var(--cm-muted);
font-family: var(--cm-body-font);
}
.chat-container.cm-surface .cm-powered-prefix { color: var(--cm-muted); opacity: 1; }
.chat-container.cm-surface .cm-brand { color: var(--cm-text); font-weight: 700; }
/* Mono (Terminal): monospace across the whole surface. */
.chat-container.cm-surface.theme-terminal,
.chat-container.cm-surface.theme-terminal .chat-panel,
.chat-container.cm-surface.theme-terminal .header-info h3,
.chat-container.cm-surface.theme-terminal .message-bubble,
.chat-container.cm-surface.theme-terminal .cm-quick-action,
.chat-container.cm-surface.theme-terminal .message-input input,
.chat-container.cm-surface.theme-terminal .powered-by {
font-family: var(--cm-body-font);
}
/* Glass: frosted panel. */
.chat-container.cm-surface.theme-glass .chat-panel {
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
+206
View File
@@ -0,0 +1,206 @@
/*
Copyright 2024-2026 ChatterMate
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 `--cm-*` 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 `--cm-*` 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 {
'--cm-card': card,
'--cm-text': text,
'--cm-muted': muted,
'--cm-agent-bg': agentBg,
'--cm-accent': accent,
'--cm-on-accent': onAccent(accent),
'--cm-presence': presence,
'--cm-border': t.border,
'--cm-glow': t.glow,
'--cm-radius': `${t.radius}px`,
'--cm-bubble': `${t.bubble}px`,
'--cm-bubble-tail': `${bubbleTail(t.bubble)}px`,
'--cm-field-radius': t.mono ? '7px' : '12px',
'--cm-avatar-radius': t.mono ? '28%' : '50%',
'--cm-hairline': t.light ? 'rgba(0,0,0,0.07)' : 'rgba(255,255,255,0.08)',
'--cm-body-font': bodyFont,
}
}
File diff suppressed because it is too large. Load diff
+76
View File
@@ -0,0 +1,76 @@
/*
Copyright 2024-2026 ChatterMate
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')