Spaces:
Sleeping
Sleeping
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Echo Mimic Agent - 数字分身工坊</title> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <style> | |
| [v-cloak] { display: none; } | |
| body { background-color: #f8fafc; color: #1e293b; } | |
| .chat-bubble { max-width: 80%; padding: 12px; border-radius: 12px; margin-bottom: 8px; } | |
| .user-bubble { background-color: #3b82f6; color: white; align-self: flex-end; margin-left: auto; } | |
| .ai-bubble { background-color: #ffffff; border: 1px solid #e2e8f0; align-self: flex-start; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app" v-cloak class="h-screen flex flex-col md:flex-row overflow-hidden"> | |
| <!-- Sidebar (Personas) --> | |
| <div class="w-full md:w-64 bg-white border-r border-gray-200 flex flex-col flex-shrink-0"> | |
| <div class="p-4 border-b border-gray-200 bg-blue-50"> | |
| <h1 class="text-xl font-bold text-blue-800 flex items-center"> | |
| <span class="text-2xl mr-2">🎭</span> Echo Mimic | |
| </h1> | |
| <p class="text-xs text-blue-600 mt-1">数字分身工坊</p> | |
| </div> | |
| <div class="flex-1 overflow-y-auto p-2"> | |
| <div v-for="p in savedPersonas" :key="p.id" | |
| @click="selectPersona(p)" | |
| class="p-3 mb-2 rounded-lg cursor-pointer hover:bg-blue-50 transition-colors border border-transparent hover:border-blue-200" | |
| :class="{'bg-blue-100 border-blue-300': currentPersona && currentPersona.id === p.id}"> | |
| <div class="font-bold text-gray-800">${ p.name }</div> | |
| <div class="text-xs text-gray-500 truncate">${ p.role }</div> | |
| </div> | |
| <div v-if="savedPersonas.length === 0" class="text-center text-gray-400 mt-10 text-sm"> | |
| 暂无分身<br>点击 "新建" 创建 | |
| </div> | |
| </div> | |
| <div class="p-4 border-t border-gray-200"> | |
| <button @click="resetToCreate" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition"> | |
| + 新建分身 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Main Content --> | |
| <div class="flex-1 flex flex-col overflow-hidden relative"> | |
| <!-- Mobile Header --> | |
| <div class="md:hidden p-4 bg-white border-b border-gray-200 flex justify-between items-center"> | |
| <span class="font-bold">Echo Mimic</span> | |
| <button @click="showMobileMenu = !showMobileMenu" class="text-gray-600">☰</button> | |
| </div> | |
| <!-- Tabs --> | |
| <div class="bg-white border-b border-gray-200 flex px-4 pt-2"> | |
| <button v-for="tab in tabs" :key="tab.id" | |
| @click="currentTab = tab.id" | |
| class="px-4 py-2 text-sm font-medium border-b-2 transition-colors mr-4" | |
| :class="currentTab === tab.id ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"> | |
| ${ tab.name } | |
| </button> | |
| </div> | |
| <!-- Tab Content --> | |
| <div class="flex-1 overflow-y-auto p-4 md:p-8 relative"> | |
| <!-- Create/Studio Mode --> | |
| <div v-if="currentTab === 'create'" class="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-8"> | |
| <div class="space-y-4"> | |
| <div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100"> | |
| <h2 class="text-lg font-bold mb-4">🔮 快速生成 (AI Generate)</h2> | |
| <textarea v-model="generationPrompt" | |
| placeholder="描述你想创建的角色,例如:'一个严厉但公正的高中数学老师,喜欢用几何比喻人生'..." | |
| class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-24 mb-3"></textarea> | |
| <button @click="generatePersona" :disabled="isGenerating" | |
| class="w-full bg-indigo-600 hover:bg-indigo-700 text-white py-2 rounded-lg flex justify-center items-center"> | |
| <span v-if="isGenerating" class="animate-spin mr-2">⚙️</span> | |
| ${ isGenerating ? '生成中...' : 'AI 自动生成' } | |
| </button> | |
| </div> | |
| <div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100"> | |
| <h2 class="text-lg font-bold mb-4">📝 详细配置</h2> | |
| <div class="space-y-3"> | |
| <div> | |
| <label class="block text-xs font-medium text-gray-500 uppercase">头像</label> | |
| <div class="flex items-center space-x-3 mt-1"> | |
| <div class="w-12 h-12 rounded-full bg-gray-200 overflow-hidden flex items-center justify-center border border-gray-300"> | |
| <img v-if="form.avatar_path" :src="form.avatar_path" class="w-full h-full object-cover"> | |
| <span v-else class="text-xl">👤</span> | |
| </div> | |
| <input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept="image/*"> | |
| <button @click="triggerUpload" class="text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 py-1 px-3 rounded transition"> | |
| 上传图片 | |
| </button> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-xs font-medium text-gray-500 uppercase">姓名</label> | |
| <input v-model="form.name" class="w-full p-2 border rounded"> | |
| </div> | |
| <div> | |
| <label class="block text-xs font-medium text-gray-500 uppercase">角色/职业</label> | |
| <input v-model="form.role" class="w-full p-2 border rounded"> | |
| </div> | |
| <div> | |
| <label class="block text-xs font-medium text-gray-500 uppercase">简介</label> | |
| <textarea v-model="form.bio" class="w-full p-2 border rounded h-20"></textarea> | |
| </div> | |
| <div> | |
| <label class="block text-xs font-medium text-gray-500 uppercase">说话风格</label> | |
| <input v-model="form.speaking_style" class="w-full p-2 border rounded"> | |
| </div> | |
| </div> | |
| <div class="mt-4 pt-4 border-t border-gray-100"> | |
| <button @click="savePersona" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 rounded-lg"> | |
| 💾 保存到库 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="space-y-4"> | |
| <div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100"> | |
| <h2 class="text-lg font-bold mb-2">📊 人格五大维度 (Big 5)</h2> | |
| <div id="radarChart" class="w-full h-64"></div> | |
| <div class="grid grid-cols-1 gap-2 mt-4"> | |
| <div v-for="(val, key) in form.traits" :key="key" class="flex items-center text-sm"> | |
| <span class="w-32 text-gray-600">${ traitLabels[key] || key }</span> | |
| <input type="range" v-model.number="form.traits[key]" min="0" max="100" class="flex-1 mx-2" @input="updateChart"> | |
| <span class="w-8 text-right font-mono">${ val }</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Chat Mode --> | |
| <div v-if="currentTab === 'chat'" class="h-full flex flex-col max-w-4xl mx-auto bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"> | |
| <div class="p-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center"> | |
| <div class="flex items-center"> | |
| <div class="w-8 h-8 rounded-full bg-gray-200 overflow-hidden mr-3 border border-gray-300"> | |
| <img v-if="form.avatar_path" :src="form.avatar_path" class="w-full h-full object-cover"> | |
| <span v-else class="text-sm flex justify-center items-center h-full">👤</span> | |
| </div> | |
| <div> | |
| <h2 class="font-bold text-lg">${ form.name || '未命名角色' }</h2> | |
| <p class="text-xs text-gray-500">${ form.role || 'Role' }</p> | |
| </div> | |
| </div> | |
| <button @click="clearChat" class="text-xs text-red-500 hover:text-red-700">清空对话</button> | |
| </div> | |
| <div class="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50" id="chatContainer"> | |
| <div v-if="chatHistory.length === 0" class="text-center text-gray-400 mt-10"> | |
| <p>👋 开始与 <b>${ form.name }</b> 对话吧!</p> | |
| </div> | |
| <div v-for="(msg, idx) in chatHistory" :key="idx" class="flex flex-col"> | |
| <div class="chat-bubble shadow-sm" :class="msg.role === 'user' ? 'user-bubble' : 'ai-bubble'"> | |
| <div class="font-bold text-xs opacity-70 mb-1 flex items-center justify-between"> | |
| <span>${ msg.role === 'user' ? '我' : form.name }</span> | |
| <span class="text-[10px] ml-2 opacity-50">${ formatTime(msg.timestamp) }</span> | |
| </div> | |
| <div class="prose prose-sm max-w-none" v-html="renderMarkdown(msg.content)"></div> | |
| </div> | |
| </div> | |
| <div v-if="isSending" class="flex items-center text-gray-500 text-sm ml-2"> | |
| <span class="animate-pulse">✍️ 正在输入...</span> | |
| </div> | |
| </div> | |
| <div class="p-4 border-t border-gray-200 bg-white"> | |
| <div class="flex space-x-2"> | |
| <input v-model="inputMessage" @keyup.enter="sendMessage" | |
| placeholder="输入消息..." | |
| class="flex-1 p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <button @click="sendMessage" :disabled="isSending || !inputMessage.trim()" | |
| class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition"> | |
| 发送 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Analytics Tab Content --> | |
| <div v-if="currentTab === 'analytics'" class="max-w-4xl mx-auto p-6 bg-white rounded-xl shadow-sm border border-gray-100"> | |
| <h2 class="text-lg font-bold mb-4">📊 数据分析</h2> | |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8"> | |
| <div class="p-4 bg-blue-50 rounded-lg text-center"> | |
| <div class="text-3xl font-bold text-blue-600">${ savedPersonas.length }</div> | |
| <div class="text-sm text-gray-600">已创建分身</div> | |
| </div> | |
| <div class="p-4 bg-green-50 rounded-lg text-center"> | |
| <div class="text-3xl font-bold text-green-600">${ chatHistory.length }</div> | |
| <div class="text-sm text-gray-600">当前对话数</div> | |
| </div> | |
| <div class="p-4 bg-purple-50 rounded-lg text-center"> | |
| <div class="text-3xl font-bold text-purple-600">Mock</div> | |
| <div class="text-sm text-gray-600">运行模式</div> | |
| </div> | |
| </div> | |
| <div class="text-center text-gray-400 py-10"> | |
| 更多高级分析功能开发中... | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, reactive, onMounted, nextTick, watch } = Vue; | |
| createApp({ | |
| delimiters: ['${', '}'], | |
| setup() { | |
| const tabs = [ | |
| { id: 'create', name: '🏗️ 创造 (Studio)' }, | |
| { id: 'chat', name: '💬 对话 (Chat)' }, | |
| { id: 'analytics', name: '📈 分析 (Analytics)' } | |
| ]; | |
| const currentTab = ref('create'); | |
| const showMobileMenu = ref(false); | |
| // Form Data | |
| const generationPrompt = ref(''); | |
| const isGenerating = ref(false); | |
| const form = reactive({ | |
| id: null, | |
| name: 'Nova', | |
| role: 'Virtual Assistant', | |
| bio: 'An intelligent digital entity designed to assist with various tasks.', | |
| speaking_style: 'Helpful and precise', | |
| avatar_path: '', | |
| traits: { | |
| Openness: 70, | |
| Conscientiousness: 80, | |
| Extraversion: 50, | |
| Agreeableness: 60, | |
| Neuroticism: 20 | |
| } | |
| }); | |
| const traitLabels = { | |
| Openness: '开放性 (Openness)', | |
| Conscientiousness: '尽责性 (Conscientiousness)', | |
| Extraversion: '外向性 (Extraversion)', | |
| Agreeableness: '宜人性 (Agreeableness)', | |
| Neuroticism: '神经质 (Neuroticism)' | |
| }; | |
| // Chat Data | |
| const inputMessage = ref(''); | |
| const chatHistory = ref([]); | |
| const isSending = ref(false); | |
| const savedPersonas = ref([]); | |
| const fileInput = ref(null); | |
| // Charts | |
| let chartInstance = null; | |
| const renderMarkdown = (text) => { | |
| return marked.parse(text); | |
| }; | |
| const formatTime = (date) => { | |
| if (!date) return new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); | |
| return new Date(date).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); | |
| }; | |
| const triggerUpload = () => { | |
| fileInput.value.click(); | |
| }; | |
| const handleFileUpload = async (event) => { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| // Simple validation | |
| if (file.size > 16 * 1024 * 1024) { | |
| alert("文件过大 (Max 16MB)"); | |
| return; | |
| } | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| try { | |
| const res = await fetch('/api/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const data = await res.json(); | |
| if (data.status === 'success') { | |
| form.avatar_path = data.path; | |
| alert("上传成功"); | |
| } else { | |
| alert(data.error || "上传失败"); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| alert("上传出错"); | |
| } | |
| }; | |
| const initChart = () => { | |
| const el = document.getElementById('radarChart'); | |
| if (!el) return; | |
| if (chartInstance) chartInstance.dispose(); | |
| chartInstance = echarts.init(el); | |
| const option = { | |
| radar: { | |
| indicator: Object.keys(form.traits).map(k => ({ name: traitLabels[k] || k, max: 100 })), | |
| splitArea: { areaStyle: { color: ['#f1f5f9', '#fff'] } } | |
| }, | |
| series: [{ | |
| type: 'radar', | |
| data: [{ | |
| value: Object.values(form.traits), | |
| name: 'Personality Profile', | |
| areaStyle: { color: 'rgba(59, 130, 246, 0.2)' }, | |
| lineStyle: { color: '#3b82f6' }, | |
| itemStyle: { color: '#3b82f6' } | |
| }] | |
| }] | |
| }; | |
| chartInstance.setOption(option); | |
| }; | |
| const updateChart = () => { | |
| if (chartInstance) { | |
| chartInstance.setOption({ | |
| series: [{ | |
| data: [{ value: Object.values(form.traits) }] | |
| }] | |
| }); | |
| } | |
| }; | |
| const generatePersona = async () => { | |
| if (!generationPrompt.value.trim()) return; | |
| isGenerating.value = true; | |
| try { | |
| const res = await fetch('/api/generate_persona', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ description: generationPrompt.value }) | |
| }); | |
| const data = await res.json(); | |
| if (data.status === 'success') { | |
| Object.assign(form, data.data); | |
| form.id = null; // New persona | |
| updateChart(); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| alert('生成失败,请重试'); | |
| } finally { | |
| isGenerating.value = false; | |
| } | |
| }; | |
| const savePersona = async () => { | |
| try { | |
| const res = await fetch('/api/save_persona', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify(form) | |
| }); | |
| const data = await res.json(); | |
| if (data.status === 'success') { | |
| alert('保存成功!'); | |
| fetchPersonas(); | |
| } | |
| } catch (e) { | |
| alert('保存失败'); | |
| } | |
| }; | |
| const fetchPersonas = async () => { | |
| const res = await fetch('/api/personas'); | |
| const data = await res.json(); | |
| if (data.status === 'success') { | |
| savedPersonas.value = data.data; | |
| } | |
| }; | |
| const selectPersona = (p) => { | |
| Object.assign(form, p); | |
| chatHistory.value = []; // Clear chat when switching | |
| currentTab.value = 'chat'; | |
| nextTick(() => updateChart()); | |
| }; | |
| const resetToCreate = () => { | |
| currentTab.value = 'create'; | |
| form.id = null; | |
| form.name = 'New Persona'; | |
| form.role = ''; | |
| form.bio = ''; | |
| form.avatar_path = ''; | |
| // Reset traits | |
| Object.keys(form.traits).forEach(k => form.traits[k] = 50); | |
| nextTick(() => updateChart()); | |
| }; | |
| const sendMessage = async () => { | |
| if (!inputMessage.value.trim() || isSending.value) return; | |
| const userMsg = { role: 'user', content: inputMessage.value }; | |
| chatHistory.value.push(userMsg); | |
| const msgToSend = inputMessage.value; | |
| inputMessage.value = ''; | |
| isSending.value = true; | |
| // Scroll to bottom | |
| nextTick(() => { | |
| const container = document.getElementById('chatContainer'); | |
| if (container) container.scrollTop = container.scrollHeight; | |
| }); | |
| try { | |
| const res = await fetch('/api/chat', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| message: msgToSend, | |
| history: chatHistory.value, | |
| persona: form | |
| }) | |
| }); | |
| const data = await res.json(); | |
| chatHistory.value.push({ role: 'assistant', content: data.response }); | |
| } catch (e) { | |
| chatHistory.value.push({ role: 'assistant', content: '⚠️ Connection Error' }); | |
| } finally { | |
| isSending.value = false; | |
| nextTick(() => { | |
| const container = document.getElementById('chatContainer'); | |
| if (container) container.scrollTop = container.scrollHeight; | |
| }); | |
| } | |
| }; | |
| const clearChat = () => { | |
| chatHistory.value = []; | |
| }; | |
| // Watch for tab changes to render chart | |
| watch(currentTab, (newTab) => { | |
| if (newTab === 'create') { | |
| nextTick(() => initChart()); | |
| } | |
| }); | |
| onMounted(() => { | |
| fetchPersonas(); | |
| initChart(); | |
| window.addEventListener('resize', () => chartInstance && chartInstance.resize()); | |
| }); | |
| return { | |
| tabs, currentTab, showMobileMenu, | |
| generationPrompt, isGenerating, generatePersona, | |
| form, traitLabels, updateChart, | |
| savePersona, savedPersonas, selectPersona, resetToCreate, | |
| inputMessage, chatHistory, isSending, sendMessage, clearChat, | |
| renderMarkdown, fileInput, triggerUpload, handleFileUpload, | |
| formatTime | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |