Trae Assistant commited on
Commit
cfcca60
·
0 Parent(s):

feat: enhance echo mimic agent with file upload, analytics and better ui

Browse files
.gitattributes ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Auto-detect text files and perform LF normalization
2
+ * text=auto
3
+
4
+ # Custom for Hugging Face
5
+ *.db filter=lfs diff=lfs merge=lfs -text
6
+ *.sqlite filter=lfs diff=lfs merge=lfs -text
7
+ *.bin filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.pt filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Create a non-root user for security (optional but good practice, though HF Spaces runs as user 1000 usually)
11
+ # For simplicity, running as root in container is fine for this demo,
12
+ # but we need to ensure port 7860 is exposed.
13
+ EXPOSE 7860
14
+
15
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Echo Mimic Agent
3
+ emoji: 🎭
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ short_description: AI驱动的数字人生成与交互工坊,一键定制个性化分身。
9
+ pinned: false
10
+ ---
11
+
12
+ # Echo Mimic Agent - 数字分身工坊 (Digital Persona Studio)
13
+
14
+ **Echo Mimic Agent** 是一个专业的数字人生成与交互平台。用户可以通过简单的自然语言描述,利用 AI (SiliconFlow Qwen) 自动生成具有独特个性、职业背景和说话风格的“数字分身”,并与之进行实时对话。
15
+
16
+ ## 🌟 核心功能 (Core Features)
17
+
18
+ 1. **🔮 智能生成 (AI Generation)**:
19
+ - 输入一句话描述(如“一个愤世嫉俗的赛博朋克黑客”),AI 自动生成完整的角色档案(姓名、职业、简介、Big 5 人格参数)。
20
+ 2. **📊 人格雷达 (Personality Radar)**:
21
+ - 可视化展示“开放性、尽责性、外向性、宜人性、神经质”五大维度,支持手动微调。
22
+ 3. **💬 沉浸式对话 (Immersive Chat)**:
23
+ - 与生成的数字分身进行实时对话,AI 会严格遵守设定的人设、语风和口头禅。
24
+ - 支持 Markdown 渲染,对话内容富文本展示。
25
+ 4. **💾 资产库 (Asset Library)**:
26
+ - 保存并管理你的数字分身库,随时切换不同角色进行交互。
27
+ 5. **📱 移动端适配 (Mobile First)**:
28
+ - 响应式设计,完美支持手机端操作。
29
+
30
+ ## 🛠️ 技术栈 (Tech Stack)
31
+
32
+ - **Frontend**: Vue.js 3, Tailwind CSS, ECharts 5 (Radar Charts), Marked.js (Markdown)
33
+ - **Backend**: Python Flask, SQLite (Persistence)
34
+ - **AI Service**: SiliconFlow API (Qwen/Qwen2.5-7B-Instruct)
35
+ - **Deployment**: Docker
36
+
37
+ ## 🚀 快速开始 (Quick Start)
38
+
39
+ ### 本地运行 (Local Run)
40
+
41
+ 1. 克隆项目
42
+ ```bash
43
+ git clone https://github.com/your-username/echo-mimic-agent.git
44
+ cd echo-mimic-agent
45
+ ```
46
+
47
+ 2. 安装依赖
48
+ ```bash
49
+ pip install -r requirements.txt
50
+ ```
51
+
52
+ 3. 运行应用
53
+ ```bash
54
+ python app.py
55
+ ```
56
+ 访问 http://localhost:7860
57
+
58
+ ### Docker 运行
59
+
60
+ ```bash
61
+ docker build -t echo-mimic-agent .
62
+ docker run -p 7860:7860 echo-mimic-agent
63
+ ```
64
+
65
+ ## 📝 环境变量 (Env Vars)
66
+
67
+ 本项目内置了 SiliconFlow API Key 用于演示。如需更换,请修改 `app.py` 中的 `SF_API_KEY` 或使用环境变量。
68
+
69
+ ---
70
+ *Created by Echo Mimic Team*
__pycache__/app.cpython-314.pyc ADDED
Binary file (13.3 kB). View file
 
app.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import sqlite3
4
+ import requests
5
+ from flask import Flask, render_template, request, jsonify, send_from_directory
6
+ from werkzeug.utils import secure_filename
7
+
8
+ app = Flask(__name__)
9
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max upload
10
+ app.config['UPLOAD_FOLDER'] = os.path.join(app.instance_path, 'uploads')
11
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
12
+ app.secret_key = os.urandom(24)
13
+
14
+ # Error Handlers
15
+ @app.errorhandler(413)
16
+ def request_entity_too_large(error):
17
+ return jsonify({"error": "File too large (Max 16MB)"}), 413
18
+
19
+ @app.errorhandler(404)
20
+ def page_not_found(error):
21
+ return render_template('index.html'), 200 # SPA fallback
22
+
23
+ @app.errorhandler(500)
24
+ def internal_error(error):
25
+ return jsonify({"error": "Internal Server Error"}), 500
26
+
27
+ # Database Setup
28
+ DB_PATH = os.path.join(app.instance_path, 'echo_mimic.db')
29
+ os.makedirs(app.instance_path, exist_ok=True)
30
+
31
+ def init_db():
32
+ conn = sqlite3.connect(DB_PATH)
33
+ c = conn.cursor()
34
+ c.execute('''CREATE TABLE IF NOT EXISTS personas
35
+ (id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ name TEXT NOT NULL,
37
+ role TEXT,
38
+ bio TEXT,
39
+ traits JSON,
40
+ avatar_style TEXT,
41
+ avatar_path TEXT,
42
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
43
+
44
+ # Check if empty and seed
45
+ c.execute("SELECT count(*) FROM personas")
46
+ if c.fetchone()[0] == 0:
47
+ seed_data = [
48
+ ("Einstein (Demo)", "Physicist", "The father of relativity.", json.dumps({"Openness": 95, "Conscientiousness": 80, "Extraversion": 40, "Agreeableness": 70, "Neuroticism": 30}), "sketch", ""),
49
+ ("Sherlock (Demo)", "Detective", "High-functioning sociopath.", json.dumps({"Openness": 90, "Conscientiousness": 95, "Extraversion": 20, "Agreeableness": 10, "Neuroticism": 60}), "realistic", "")
50
+ ]
51
+ c.executemany("INSERT INTO personas (name, role, bio, traits, avatar_style, avatar_path) VALUES (?, ?, ?, ?, ?, ?)", seed_data)
52
+ conn.commit()
53
+ print("Database seeded with default personas.")
54
+
55
+ conn.commit()
56
+ conn.close()
57
+
58
+ init_db()
59
+
60
+ # SiliconFlow API Configuration
61
+ SF_API_KEY = "sk-vimuseiptfbomzegyuvmebjzooncsqbyjtlddrfodzcdskgi"
62
+ SF_API_URL = "https://api.siliconflow.cn/v1/chat/completions"
63
+ MODEL_NAME = "Qwen/Qwen2.5-7B-Instruct"
64
+
65
+ def call_silicon_flow(messages, temperature=0.7):
66
+ headers = {
67
+ "Authorization": f"Bearer {SF_API_KEY}",
68
+ "Content-Type": "application/json"
69
+ }
70
+ payload = {
71
+ "model": MODEL_NAME,
72
+ "messages": messages,
73
+ "temperature": temperature,
74
+ "max_tokens": 1024
75
+ }
76
+
77
+ try:
78
+ response = requests.post(SF_API_URL, json=payload, headers=headers, timeout=10)
79
+ response.raise_for_status()
80
+ data = response.json()
81
+ return data['choices'][0]['message']['content']
82
+ except Exception as e:
83
+ print(f"SiliconFlow Error: {e}")
84
+ return None
85
+
86
+ @app.route('/')
87
+ def index():
88
+ return render_template('index.html')
89
+
90
+ @app.route('/api/generate_persona', methods=['POST'])
91
+ def generate_persona():
92
+ data = request.json
93
+ desc = data.get('description', 'A helpful assistant')
94
+
95
+ system_prompt = """
96
+ You are an expert Character Designer.
97
+ Based on the user's description, generate a detailed persona profile in JSON format.
98
+ Return ONLY the JSON object, no markdown, no other text.
99
+
100
+ JSON Structure:
101
+ {
102
+ "name": "Name",
103
+ "role": "Job Title / Role",
104
+ "bio": "A short 2-sentence biography.",
105
+ "traits": {
106
+ "Openness": 1-100,
107
+ "Conscientiousness": 1-100,
108
+ "Extraversion": 1-100,
109
+ "Agreeableness": 1-100,
110
+ "Neuroticism": 1-100
111
+ },
112
+ "speaking_style": "Keywords describing how they talk (e.g. formal, slang, poetic)",
113
+ "catchphrases": ["phrase 1", "phrase 2"]
114
+ }
115
+ """
116
+
117
+ messages = [
118
+ {"role": "system", "content": system_prompt},
119
+ {"role": "user", "content": f"Create a persona for: {desc}"}
120
+ ]
121
+
122
+ content = call_silicon_flow(messages, temperature=0.8)
123
+
124
+ if content:
125
+ try:
126
+ # Clean up potential markdown code blocks
127
+ if "```json" in content:
128
+ content = content.split("```json")[1].split("```")[0].strip()
129
+ elif "```" in content:
130
+ content = content.split("```")[1].strip()
131
+
132
+ persona = json.loads(content)
133
+ return jsonify({"status": "success", "data": persona})
134
+ except json.JSONDecodeError:
135
+ return jsonify({"status": "error", "message": "Failed to parse AI response"}), 500
136
+ else:
137
+ # Fallback Mock Data
138
+ return jsonify({
139
+ "status": "success",
140
+ "data": {
141
+ "name": "Nova (Mock)",
142
+ "role": "AI Assistant",
143
+ "bio": "A fallback persona generated because the API call failed.",
144
+ "traits": {"Openness": 80, "Conscientiousness": 90, "Extraversion": 50, "Agreeableness": 85, "Neuroticism": 20},
145
+ "speaking_style": "Polite and direct",
146
+ "catchphrases": ["How can I help?", "Processing request."]
147
+ }
148
+ })
149
+
150
+ @app.route('/api/chat', methods=['POST'])
151
+ def chat():
152
+ data = request.json
153
+ message = data.get('message')
154
+ history = data.get('history', [])
155
+ persona = data.get('persona', {})
156
+
157
+ if not message:
158
+ return jsonify({"error": "No message provided"}), 400
159
+
160
+ # Construct System Prompt from Persona
161
+ sys_prompt = f"""
162
+ You are roleplaying as {persona.get('name', 'AI')}.
163
+ Role: {persona.get('role', 'Assistant')}
164
+ Bio: {persona.get('bio', '')}
165
+ Speaking Style: {persona.get('speaking_style', 'Normal')}
166
+
167
+ Your personality traits (1-100):
168
+ - Openness: {persona.get('traits', {}).get('Openness', 50)}
169
+ - Conscientiousness: {persona.get('traits', {}).get('Conscientiousness', 50)}
170
+ - Extraversion: {persona.get('traits', {}).get('Extraversion', 50)}
171
+ - Agreeableness: {persona.get('traits', {}).get('Agreeableness', 50)}
172
+ - Neuroticism: {persona.get('traits', {}).get('Neuroticism', 50)}
173
+
174
+ Stay in character at all times. Keep responses concise and engaging.
175
+ """
176
+
177
+ messages = [{"role": "system", "content": sys_prompt}]
178
+
179
+ # Add last 10 messages for context
180
+ for msg in history[-10:]:
181
+ messages.append({"role": msg['role'], "content": msg['content']})
182
+
183
+ messages.append({"role": "user", "content": message})
184
+
185
+ response_text = call_silicon_flow(messages)
186
+
187
+ if not response_text:
188
+ response_text = f"[{persona.get('name')} is offline - Mock Mode] I heard you say: {message}"
189
+
190
+ return jsonify({"response": response_text})
191
+
192
+ @app.route('/api/upload', methods=['POST'])
193
+ def upload_file():
194
+ if 'file' not in request.files:
195
+ return jsonify({"error": "No file part"}), 400
196
+ file = request.files['file']
197
+ if file.filename == '':
198
+ return jsonify({"error": "No selected file"}), 400
199
+
200
+ if file:
201
+ filename = secure_filename(file.filename)
202
+ # Handle non-ascii filenames
203
+ if not filename:
204
+ filename = "uploaded_file_" + os.urandom(4).hex()
205
+
206
+ file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
207
+ file.save(file_path)
208
+ return jsonify({"status": "success", "path": f"/uploads/{filename}", "filename": filename})
209
+
210
+ @app.route('/uploads/<filename>')
211
+ def uploaded_file(filename):
212
+ return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
213
+
214
+ @app.route('/api/save_persona', methods=['POST'])
215
+ def save_persona():
216
+ data = request.json
217
+ try:
218
+ conn = sqlite3.connect(DB_PATH)
219
+ c = conn.cursor()
220
+ c.execute("INSERT INTO personas (name, role, bio, traits, avatar_style, avatar_path) VALUES (?, ?, ?, ?, ?, ?)",
221
+ (data['name'], data['role'], data['bio'], json.dumps(data['traits']),
222
+ data.get('avatar_style', 'default'), data.get('avatar_path', '')))
223
+ conn.commit()
224
+ conn.close()
225
+ return jsonify({"status": "success"})
226
+ except Exception as e:
227
+ return jsonify({"status": "error", "message": str(e)}), 500
228
+
229
+ @app.route('/api/personas', methods=['GET'])
230
+ def get_personas():
231
+ conn = sqlite3.connect(DB_PATH)
232
+ conn.row_factory = sqlite3.Row
233
+ c = conn.cursor()
234
+ c.execute("SELECT * FROM personas ORDER BY created_at DESC")
235
+ rows = c.fetchall()
236
+ conn.close()
237
+
238
+ personas = []
239
+ for row in rows:
240
+ p = dict(row)
241
+ p['traits'] = json.loads(p['traits'])
242
+ personas.append(p)
243
+
244
+ return jsonify({"status": "success", "data": personas})
245
+
246
+ if __name__ == '__main__':
247
+ app.run(host='0.0.0.0', port=7860)
instance/echo_mimic.db ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fab05df471e32ca8697831460ecac8dffb8867316869a77201a96f0d30204c93
3
+ size 12288
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ flask
2
+ requests
3
+ python-dotenv
4
+ gunicorn
templates/index.html ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Echo Mimic Agent - 数字分身工坊</title>
7
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
10
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
11
+ <style>
12
+ [v-cloak] { display: none; }
13
+ body { background-color: #f8fafc; color: #1e293b; }
14
+ .chat-bubble { max-width: 80%; padding: 12px; border-radius: 12px; margin-bottom: 8px; }
15
+ .user-bubble { background-color: #3b82f6; color: white; align-self: flex-end; margin-left: auto; }
16
+ .ai-bubble { background-color: #ffffff; border: 1px solid #e2e8f0; align-self: flex-start; }
17
+ </style>
18
+ </head>
19
+ <body>
20
+ <div id="app" v-cloak class="h-screen flex flex-col md:flex-row overflow-hidden">
21
+
22
+ <!-- Sidebar (Personas) -->
23
+ <div class="w-full md:w-64 bg-white border-r border-gray-200 flex flex-col flex-shrink-0">
24
+ <div class="p-4 border-b border-gray-200 bg-blue-50">
25
+ <h1 class="text-xl font-bold text-blue-800 flex items-center">
26
+ <span class="text-2xl mr-2">🎭</span> Echo Mimic
27
+ </h1>
28
+ <p class="text-xs text-blue-600 mt-1">数字分身工坊</p>
29
+ </div>
30
+
31
+ <div class="flex-1 overflow-y-auto p-2">
32
+ <div v-for="p in savedPersonas" :key="p.id"
33
+ @click="selectPersona(p)"
34
+ class="p-3 mb-2 rounded-lg cursor-pointer hover:bg-blue-50 transition-colors border border-transparent hover:border-blue-200"
35
+ :class="{'bg-blue-100 border-blue-300': currentPersona && currentPersona.id === p.id}">
36
+ <div class="font-bold text-gray-800">${ p.name }</div>
37
+ <div class="text-xs text-gray-500 truncate">${ p.role }</div>
38
+ </div>
39
+
40
+ <div v-if="savedPersonas.length === 0" class="text-center text-gray-400 mt-10 text-sm">
41
+ 暂无分身<br>点击 "新建" 创建
42
+ </div>
43
+ </div>
44
+
45
+ <div class="p-4 border-t border-gray-200">
46
+ <button @click="resetToCreate" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition">
47
+ + 新建分身
48
+ </button>
49
+ </div>
50
+ </div>
51
+
52
+ <!-- Main Content -->
53
+ <div class="flex-1 flex flex-col overflow-hidden relative">
54
+
55
+ <!-- Mobile Header -->
56
+ <div class="md:hidden p-4 bg-white border-b border-gray-200 flex justify-between items-center">
57
+ <span class="font-bold">Echo Mimic</span>
58
+ <button @click="showMobileMenu = !showMobileMenu" class="text-gray-600">☰</button>
59
+ </div>
60
+
61
+ <!-- Tabs -->
62
+ <div class="bg-white border-b border-gray-200 flex px-4 pt-2">
63
+ <button v-for="tab in tabs" :key="tab.id"
64
+ @click="currentTab = tab.id"
65
+ class="px-4 py-2 text-sm font-medium border-b-2 transition-colors mr-4"
66
+ :class="currentTab === tab.id ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'">
67
+ ${ tab.name }
68
+ </button>
69
+ </div>
70
+
71
+ <!-- Tab Content -->
72
+ <div class="flex-1 overflow-y-auto p-4 md:p-8 relative">
73
+
74
+ <!-- Create/Studio Mode -->
75
+ <div v-if="currentTab === 'create'" class="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-8">
76
+ <div class="space-y-4">
77
+ <div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
78
+ <h2 class="text-lg font-bold mb-4">🔮 快速生成 (AI Generate)</h2>
79
+ <textarea v-model="generationPrompt"
80
+ placeholder="描述你想创建的角色,例如:'一个严厉但公正的高中数学老师,喜欢用几何比喻人生'..."
81
+ 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>
82
+ <button @click="generatePersona" :disabled="isGenerating"
83
+ class="w-full bg-indigo-600 hover:bg-indigo-700 text-white py-2 rounded-lg flex justify-center items-center">
84
+ <span v-if="isGenerating" class="animate-spin mr-2">⚙️</span>
85
+ ${ isGenerating ? '生成中...' : 'AI 自动生成' }
86
+ </button>
87
+ </div>
88
+
89
+ <div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
90
+ <h2 class="text-lg font-bold mb-4">📝 详细配置</h2>
91
+ <div class="space-y-3">
92
+ <div>
93
+ <label class="block text-xs font-medium text-gray-500 uppercase">头像</label>
94
+ <div class="flex items-center space-x-3 mt-1">
95
+ <div class="w-12 h-12 rounded-full bg-gray-200 overflow-hidden flex items-center justify-center border border-gray-300">
96
+ <img v-if="form.avatar_path" :src="form.avatar_path" class="w-full h-full object-cover">
97
+ <span v-else class="text-xl">👤</span>
98
+ </div>
99
+ <input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept="image/*">
100
+ <button @click="triggerUpload" class="text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 py-1 px-3 rounded transition">
101
+ 上传图片
102
+ </button>
103
+ </div>
104
+ </div>
105
+ <div>
106
+ <label class="block text-xs font-medium text-gray-500 uppercase">姓名</label>
107
+ <input v-model="form.name" class="w-full p-2 border rounded">
108
+ </div>
109
+ <div>
110
+ <label class="block text-xs font-medium text-gray-500 uppercase">角色/职业</label>
111
+ <input v-model="form.role" class="w-full p-2 border rounded">
112
+ </div>
113
+ <div>
114
+ <label class="block text-xs font-medium text-gray-500 uppercase">简介</label>
115
+ <textarea v-model="form.bio" class="w-full p-2 border rounded h-20"></textarea>
116
+ </div>
117
+ <div>
118
+ <label class="block text-xs font-medium text-gray-500 uppercase">说话风格</label>
119
+ <input v-model="form.speaking_style" class="w-full p-2 border rounded">
120
+ </div>
121
+ </div>
122
+ <div class="mt-4 pt-4 border-t border-gray-100">
123
+ <button @click="savePersona" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 rounded-lg">
124
+ 💾 保存到库
125
+ </button>
126
+ </div>
127
+ </div>
128
+ </div>
129
+
130
+ <div class="space-y-4">
131
+ <div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
132
+ <h2 class="text-lg font-bold mb-2">📊 人格五大维度 (Big 5)</h2>
133
+ <div id="radarChart" class="w-full h-64"></div>
134
+
135
+ <div class="grid grid-cols-1 gap-2 mt-4">
136
+ <div v-for="(val, key) in form.traits" :key="key" class="flex items-center text-sm">
137
+ <span class="w-32 text-gray-600">${ traitLabels[key] || key }</span>
138
+ <input type="range" v-model.number="form.traits[key]" min="0" max="100" class="flex-1 mx-2" @input="updateChart">
139
+ <span class="w-8 text-right font-mono">${ val }</span>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+
146
+ <!-- Chat Mode -->
147
+ <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">
148
+ <div class="p-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
149
+ <div class="flex items-center">
150
+ <div class="w-8 h-8 rounded-full bg-gray-200 overflow-hidden mr-3 border border-gray-300">
151
+ <img v-if="form.avatar_path" :src="form.avatar_path" class="w-full h-full object-cover">
152
+ <span v-else class="text-sm flex justify-center items-center h-full">👤</span>
153
+ </div>
154
+ <div>
155
+ <h2 class="font-bold text-lg">${ form.name || '未命名角色' }</h2>
156
+ <p class="text-xs text-gray-500">${ form.role || 'Role' }</p>
157
+ </div>
158
+ </div>
159
+ <button @click="clearChat" class="text-xs text-red-500 hover:text-red-700">清空对话</button>
160
+ </div>
161
+
162
+ <div class="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50" id="chatContainer">
163
+ <div v-if="chatHistory.length === 0" class="text-center text-gray-400 mt-10">
164
+ <p>👋 开始与 <b>${ form.name }</b> 对话吧!</p>
165
+ </div>
166
+ <div v-for="(msg, idx) in chatHistory" :key="idx" class="flex flex-col">
167
+ <div class="chat-bubble shadow-sm" :class="msg.role === 'user' ? 'user-bubble' : 'ai-bubble'">
168
+ <div class="font-bold text-xs opacity-70 mb-1 flex items-center justify-between">
169
+ <span>${ msg.role === 'user' ? '我' : form.name }</span>
170
+ <span class="text-[10px] ml-2 opacity-50">${ new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) }</span>
171
+ </div>
172
+ <div class="prose prose-sm max-w-none" v-html="renderMarkdown(msg.content)"></div>
173
+ </div>
174
+ </div>
175
+ <div v-if="isSending" class="flex items-center text-gray-500 text-sm ml-2">
176
+ <span class="animate-pulse">✍️ 正在输入...</span>
177
+ </div>
178
+ </div>
179
+
180
+ <div class="p-4 border-t border-gray-200 bg-white">
181
+ <div class="flex space-x-2">
182
+ <input v-model="inputMessage" @keyup.enter="sendMessage"
183
+ placeholder="输入消息..."
184
+ class="flex-1 p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
185
+ <button @click="sendMessage" :disabled="isSending || !inputMessage.trim()"
186
+ class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition">
187
+ 发送
188
+ </button>
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ <!-- Analytics Tab Content -->
194
+ <div v-if="currentTab === 'analytics'" class="max-w-4xl mx-auto p-6 bg-white rounded-xl shadow-sm border border-gray-100">
195
+ <h2 class="text-lg font-bold mb-4">📊 数据分析</h2>
196
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
197
+ <div class="p-4 bg-blue-50 rounded-lg text-center">
198
+ <div class="text-3xl font-bold text-blue-600">${ savedPersonas.length }</div>
199
+ <div class="text-sm text-gray-600">已创建分身</div>
200
+ </div>
201
+ <div class="p-4 bg-green-50 rounded-lg text-center">
202
+ <div class="text-3xl font-bold text-green-600">${ chatHistory.length }</div>
203
+ <div class="text-sm text-gray-600">当前对话数</div>
204
+ </div>
205
+ <div class="p-4 bg-purple-50 rounded-lg text-center">
206
+ <div class="text-3xl font-bold text-purple-600">Mock</div>
207
+ <div class="text-sm text-gray-600">运行模式</div>
208
+ </div>
209
+ </div>
210
+ <div class="text-center text-gray-400 py-10">
211
+ 更多高级分析功能开发中...
212
+ </div>
213
+ </div>
214
+
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ <script>
220
+ const { createApp, ref, reactive, onMounted, nextTick, watch } = Vue;
221
+
222
+ createApp({
223
+ delimiters: ['${', '}'],
224
+ setup() {
225
+ const tabs = [
226
+ { id: 'create', name: '🏗️ 创造 (Studio)' },
227
+ { id: 'chat', name: '💬 对话 (Chat)' },
228
+ { id: 'analytics', name: '📈 分析 (Analytics)' }
229
+ ];
230
+ const currentTab = ref('create');
231
+ const showMobileMenu = ref(false);
232
+
233
+ // Form Data
234
+ const generationPrompt = ref('');
235
+ const isGenerating = ref(false);
236
+ const form = reactive({
237
+ id: null,
238
+ name: 'Nova',
239
+ role: 'Virtual Assistant',
240
+ bio: 'An intelligent digital entity designed to assist with various tasks.',
241
+ speaking_style: 'Helpful and precise',
242
+ avatar_path: '',
243
+ traits: {
244
+ Openness: 70,
245
+ Conscientiousness: 80,
246
+ Extraversion: 50,
247
+ Agreeableness: 60,
248
+ Neuroticism: 20
249
+ }
250
+ });
251
+
252
+ const traitLabels = {
253
+ Openness: '开放性 (Openness)',
254
+ Conscientiousness: '尽责性 (Conscientiousness)',
255
+ Extraversion: '外向性 (Extraversion)',
256
+ Agreeableness: '宜人性 (Agreeableness)',
257
+ Neuroticism: '神经质 (Neuroticism)'
258
+ };
259
+
260
+ // Chat Data
261
+ const inputMessage = ref('');
262
+ const chatHistory = ref([]);
263
+ const isSending = ref(false);
264
+ const savedPersonas = ref([]);
265
+ const fileInput = ref(null);
266
+
267
+ // Charts
268
+ let chartInstance = null;
269
+
270
+ const renderMarkdown = (text) => {
271
+ return marked.parse(text);
272
+ };
273
+
274
+ const triggerUpload = () => {
275
+ fileInput.value.click();
276
+ };
277
+
278
+ const handleFileUpload = async (event) => {
279
+ const file = event.target.files[0];
280
+ if (!file) return;
281
+
282
+ // Simple validation
283
+ if (file.size > 16 * 1024 * 1024) {
284
+ alert("文件过大 (Max 16MB)");
285
+ return;
286
+ }
287
+
288
+ const formData = new FormData();
289
+ formData.append('file', file);
290
+
291
+ try {
292
+ const res = await fetch('/api/upload', {
293
+ method: 'POST',
294
+ body: formData
295
+ });
296
+ const data = await res.json();
297
+ if (data.status === 'success') {
298
+ form.avatar_path = data.path;
299
+ alert("上传成功");
300
+ } else {
301
+ alert(data.error || "上传失败");
302
+ }
303
+ } catch (e) {
304
+ console.error(e);
305
+ alert("上传出错");
306
+ }
307
+ };
308
+
309
+
310
+ const initChart = () => {
311
+ const el = document.getElementById('radarChart');
312
+ if (!el) return;
313
+
314
+ if (chartInstance) chartInstance.dispose();
315
+ chartInstance = echarts.init(el);
316
+
317
+ const option = {
318
+ radar: {
319
+ indicator: Object.keys(form.traits).map(k => ({ name: traitLabels[k] || k, max: 100 })),
320
+ splitArea: { areaStyle: { color: ['#f1f5f9', '#fff'] } }
321
+ },
322
+ series: [{
323
+ type: 'radar',
324
+ data: [{
325
+ value: Object.values(form.traits),
326
+ name: 'Personality Profile',
327
+ areaStyle: { color: 'rgba(59, 130, 246, 0.2)' },
328
+ lineStyle: { color: '#3b82f6' },
329
+ itemStyle: { color: '#3b82f6' }
330
+ }]
331
+ }]
332
+ };
333
+ chartInstance.setOption(option);
334
+ };
335
+
336
+ const updateChart = () => {
337
+ if (chartInstance) {
338
+ chartInstance.setOption({
339
+ series: [{
340
+ data: [{ value: Object.values(form.traits) }]
341
+ }]
342
+ });
343
+ }
344
+ };
345
+
346
+ const generatePersona = async () => {
347
+ if (!generationPrompt.value.trim()) return;
348
+ isGenerating.value = true;
349
+ try {
350
+ const res = await fetch('/api/generate_persona', {
351
+ method: 'POST',
352
+ headers: {'Content-Type': 'application/json'},
353
+ body: JSON.stringify({ description: generationPrompt.value })
354
+ });
355
+ const data = await res.json();
356
+ if (data.status === 'success') {
357
+ Object.assign(form, data.data);
358
+ form.id = null; // New persona
359
+ updateChart();
360
+ }
361
+ } catch (e) {
362
+ console.error(e);
363
+ alert('生成失败,请重试');
364
+ } finally {
365
+ isGenerating.value = false;
366
+ }
367
+ };
368
+
369
+ const savePersona = async () => {
370
+ try {
371
+ const res = await fetch('/api/save_persona', {
372
+ method: 'POST',
373
+ headers: {'Content-Type': 'application/json'},
374
+ body: JSON.stringify(form)
375
+ });
376
+ const data = await res.json();
377
+ if (data.status === 'success') {
378
+ alert('保存成功!');
379
+ fetchPersonas();
380
+ }
381
+ } catch (e) {
382
+ alert('保存失败');
383
+ }
384
+ };
385
+
386
+ const fetchPersonas = async () => {
387
+ const res = await fetch('/api/personas');
388
+ const data = await res.json();
389
+ if (data.status === 'success') {
390
+ savedPersonas.value = data.data;
391
+ }
392
+ };
393
+
394
+ const selectPersona = (p) => {
395
+ Object.assign(form, p);
396
+ chatHistory.value = []; // Clear chat when switching
397
+ currentTab.value = 'chat';
398
+ nextTick(() => updateChart());
399
+ };
400
+
401
+ const resetToCreate = () => {
402
+ currentTab.value = 'create';
403
+ form.id = null;
404
+ form.name = 'New Persona';
405
+ form.role = '';
406
+ form.bio = '';
407
+ form.avatar_path = '';
408
+ // Reset traits
409
+ Object.keys(form.traits).forEach(k => form.traits[k] = 50);
410
+ nextTick(() => updateChart());
411
+ };
412
+
413
+ const sendMessage = async () => {
414
+ if (!inputMessage.value.trim() || isSending.value) return;
415
+
416
+ const userMsg = { role: 'user', content: inputMessage.value };
417
+ chatHistory.value.push(userMsg);
418
+ const msgToSend = inputMessage.value;
419
+ inputMessage.value = '';
420
+ isSending.value = true;
421
+
422
+ // Scroll to bottom
423
+ nextTick(() => {
424
+ const container = document.getElementById('chatContainer');
425
+ if (container) container.scrollTop = container.scrollHeight;
426
+ });
427
+
428
+ try {
429
+ const res = await fetch('/api/chat', {
430
+ method: 'POST',
431
+ headers: {'Content-Type': 'application/json'},
432
+ body: JSON.stringify({
433
+ message: msgToSend,
434
+ history: chatHistory.value,
435
+ persona: form
436
+ })
437
+ });
438
+ const data = await res.json();
439
+ chatHistory.value.push({ role: 'assistant', content: data.response });
440
+ } catch (e) {
441
+ chatHistory.value.push({ role: 'assistant', content: '⚠️ Connection Error' });
442
+ } finally {
443
+ isSending.value = false;
444
+ nextTick(() => {
445
+ const container = document.getElementById('chatContainer');
446
+ if (container) container.scrollTop = container.scrollHeight;
447
+ });
448
+ }
449
+ };
450
+
451
+ const clearChat = () => {
452
+ chatHistory.value = [];
453
+ };
454
+
455
+ // Watch for tab changes to render chart
456
+ watch(currentTab, (newTab) => {
457
+ if (newTab === 'create') {
458
+ nextTick(() => initChart());
459
+ }
460
+ });
461
+
462
+ onMounted(() => {
463
+ fetchPersonas();
464
+ initChart();
465
+ window.addEventListener('resize', () => chartInstance && chartInstance.resize());
466
+ });
467
+
468
+ return {
469
+ tabs, currentTab, showMobileMenu,
470
+ generationPrompt, isGenerating, generatePersona,
471
+ form, traitLabels, updateChart,
472
+ savePersona, savedPersonas, selectPersona, resetToCreate,
473
+ inputMessage, chatHistory, isSending, sendMessage, clearChat,
474
+ renderMarkdown, fileInput, triggerUpload, handleFileUpload
475
+ };
476
+ }
477
+ }).mount('#app');
478
+ </script>
479
+ </body>
480
+ </html>