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

feat: complete project setup with upload feature and localization

Browse files
Files changed (7) hide show
  1. .gitattributes +2 -0
  2. Dockerfile +18 -0
  3. README.md +55 -0
  4. app.py +251 -0
  5. instance/support_pilot.db +0 -0
  6. requirements.txt +5 -0
  7. templates/index.html +425 -0
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.db binary
2
+ instance/* binary
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 instance directory and set permissions
11
+ RUN mkdir -p instance && \
12
+ chmod -R 777 instance
13
+
14
+ # Expose port 7860 for Hugging Face Spaces
15
+ EXPOSE 7860
16
+
17
+ # Run with Gunicorn
18
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Support Pilot Agent
3
+ emoji: 🎧
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ short_description: 智能客服领航员 - AI 辅助分析与训练系统
9
+ ---
10
+
11
+ # Support Pilot Agent (智能客服领航员)
12
+
13
+ **Support Pilot Agent** 是一个专为客服团队设计的 AI 辅助系统,旨在提升服务效率和质量。
14
+
15
+ ## 核心功能
16
+
17
+ 1. **工单智能分析 (Analysis)**:
18
+ - 自动识别客户意图 (Intent) 和情绪 (Sentiment)。
19
+ - 生成专业的建议回复 (Suggested Reply)。
20
+ - 提取关键标签 (Keywords)。
21
+ - 基于 SiliconFlow (Qwen) 大模型。
22
+
23
+ 2. **模拟演练室 (Training Dojo)**:
24
+ - AI 扮演不同类型的客户(如:愤怒的客户、咨询的客户)。
25
+ - 客服人员可以进行实战演练,AI 实时评分。
26
+ - 帮助新员工快速上手。
27
+
28
+ 3. **仪表盘 (Dashboard)**:
29
+ - 实时可视化数据分析。
30
+ - 情绪分布图与意图统计。
31
+
32
+ 4. **知识库 (Knowledge Base)**:
33
+ - 沉淀优秀话术与解决方案。
34
+ - 资产积累。
35
+
36
+ ## 技术栈
37
+
38
+ - **Backend**: Python Flask, SQLite
39
+ - **Frontend**: Vue.js 3, Tailwind CSS, ECharts
40
+ - **AI**: SiliconFlow API (Qwen/Qwen2.5-7B-Instruct)
41
+ - **Deploy**: Docker (Hugging Face Spaces compatible)
42
+
43
+ ## 快速开始
44
+
45
+ 1. 克隆项目
46
+ 2. 安装依赖: `pip install -r requirements.txt`
47
+ 3. 运行: `python app.py`
48
+ 4. 访问: `http://localhost:7860`
49
+
50
+ ## 商业价值
51
+
52
+ 本项目属于 **B2B SaaS / 生产力工具** 范畴,能够帮助企业:
53
+ - 降低客服培训成本。
54
+ - 提高工单处理速度。
55
+ - 积累企业服务资产(话术、案例)。
app.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import sqlite3
4
+ import datetime
5
+ import requests
6
+ import random
7
+ from flask import Flask, render_template, request, jsonify, g
8
+ from flask_cors import CORS
9
+
10
+ app = Flask(__name__, instance_relative_config=True)
11
+ CORS(app)
12
+
13
+ # Configuration
14
+ app.config['SECRET_KEY'] = 'support-pilot-secret-key'
15
+ app.config['DATABASE'] = os.path.join(app.instance_path, 'support_pilot.db')
16
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB Max Upload
17
+ SILICONFLOW_API_KEY = "sk-vimuseiptfbomzegyuvmebjzooncsqbyjtlddrfodzcdskgi"
18
+ SILICONFLOW_API_URL = "https://api.siliconflow.cn/v1/chat/completions"
19
+
20
+ # Ensure instance folder exists
21
+ try:
22
+ os.makedirs(app.instance_path)
23
+ except OSError:
24
+ pass
25
+
26
+ # --- Database Helpers ---
27
+ def get_db():
28
+ if 'db' not in g:
29
+ g.db = sqlite3.connect(app.config['DATABASE'])
30
+ g.db.row_factory = sqlite3.Row
31
+ return g.db
32
+
33
+ @app.teardown_appcontext
34
+ def close_db(error):
35
+ if hasattr(g, 'db'):
36
+ g.db.close()
37
+
38
+ def init_db():
39
+ db = get_db()
40
+ # Tickets/Logs Table
41
+ db.execute('''
42
+ CREATE TABLE IF NOT EXISTS tickets (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ content TEXT NOT NULL,
45
+ source TEXT DEFAULT 'manual',
46
+ sentiment TEXT,
47
+ intent TEXT,
48
+ priority TEXT,
49
+ status TEXT DEFAULT 'new',
50
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
51
+ )
52
+ ''')
53
+ # Knowledge Base / Solutions
54
+ db.execute('''
55
+ CREATE TABLE IF NOT EXISTS solutions (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ title TEXT NOT NULL,
58
+ content TEXT NOT NULL,
59
+ tags TEXT,
60
+ usage_count INTEGER DEFAULT 0,
61
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
62
+ )
63
+ ''')
64
+ # Seed Data
65
+ cursor = db.execute("SELECT count(*) FROM solutions")
66
+ if cursor.fetchone()[0] == 0:
67
+ db.execute("INSERT INTO solutions (title, content, tags) VALUES (?, ?, ?)",
68
+ ("退款流程", "用户申请退款需提供订单号,若在7天内且无使用痕迹,可直接办理。", "refund,policy"))
69
+ db.execute("INSERT INTO solutions (title, content, tags) VALUES (?, ?, ?)",
70
+ ("账号无法登录", "请引导用户检查大小写,或使用'忘记密码'功能重置。", "login,account"))
71
+
72
+ # Seed Tickets
73
+ cursor = db.execute("SELECT count(*) FROM tickets")
74
+ if cursor.fetchone()[0] == 0:
75
+ sample_tickets = [
76
+ ("我的包裹丢了!你们怎么搞的?", "Negative", "Logistics", "High"),
77
+ ("这个产品怎么使用?说明书看不懂。", "Neutral", "Inquiry", "Medium"),
78
+ ("感谢你们的帮助,问题解决了。", "Positive", "Feedback", "Low")
79
+ ]
80
+ for t in sample_tickets:
81
+ db.execute("INSERT INTO tickets (content, sentiment, intent, priority, status) VALUES (?, ?, ?, ?, ?)",
82
+ (t[0], t[1], t[2], t[3], 'closed'))
83
+
84
+ db.commit()
85
+
86
+ # --- AI Integration ---
87
+ def call_ai_model(system_prompt, user_input, model="Qwen/Qwen2.5-7B-Instruct"):
88
+ headers = {
89
+ "Authorization": f"Bearer {SILICONFLOW_API_KEY}",
90
+ "Content-Type": "application/json"
91
+ }
92
+ payload = {
93
+ "model": model,
94
+ "messages": [
95
+ {"role": "system", "content": system_prompt},
96
+ {"role": "user", "content": user_input}
97
+ ],
98
+ "temperature": 0.7,
99
+ "response_format": {"type": "json_object"}
100
+ }
101
+
102
+ try:
103
+ response = requests.post(SILICONFLOW_API_URL, json=payload, headers=headers, timeout=10)
104
+ response.raise_for_status()
105
+ data = response.json()
106
+ content = data['choices'][0]['message']['content']
107
+ # Try to parse JSON
108
+ try:
109
+ return json.loads(content)
110
+ except json.JSONDecodeError:
111
+ # Fallback if model didn't return pure JSON
112
+ return {"raw_content": content, "error": "json_parse_error"}
113
+ except Exception as e:
114
+ print(f"AI API Error: {e}")
115
+ # Mock Fallback
116
+ return None
117
+
118
+ # --- Routes ---
119
+ @app.route('/')
120
+ def index():
121
+ return render_template('index.html')
122
+
123
+ @app.route('/api/init', methods=['POST'])
124
+ def init_data():
125
+ with app.app_context():
126
+ init_db()
127
+ return jsonify({"status": "initialized"})
128
+
129
+ @app.route('/api/analyze', methods=['POST'])
130
+ def analyze_ticket():
131
+ content = request.json.get('content', '')
132
+ if not content:
133
+ return jsonify({"error": "No content"}), 400
134
+
135
+ system_prompt = """
136
+ You are a Customer Support AI Analyst. Analyze the following customer message.
137
+ Return a JSON object with:
138
+ - "sentiment": "Positive", "Neutral", "Negative" or "Urgent"
139
+ - "intent": The user's main goal (e.g., Refund, Tech Support, Inquiry)
140
+ - "priority": "Low", "Medium", "High", "Critical"
141
+ - "suggested_reply": A polite, professional, and helpful draft response (in Chinese).
142
+ - "keywords": A list of 3-5 keywords.
143
+ """
144
+
145
+ ai_result = call_ai_model(system_prompt, content)
146
+
147
+ # Fallback if AI fails
148
+ if not ai_result or 'error' in ai_result:
149
+ ai_result = {
150
+ "sentiment": "Neutral (Mock)",
151
+ "intent": "Unknown",
152
+ "priority": "Medium",
153
+ "suggested_reply": "感谢您的反馈,人工客服稍后会介入处理。",
154
+ "keywords": ["fallback"]
155
+ }
156
+
157
+ # Save to DB
158
+ db = get_db()
159
+ db.execute('INSERT INTO tickets (content, sentiment, intent, priority, status) VALUES (?, ?, ?, ?, ?)',
160
+ (content, ai_result.get('sentiment'), ai_result.get('intent'), ai_result.get('priority'), 'analyzed'))
161
+ db.commit()
162
+
163
+ return jsonify(ai_result)
164
+
165
+ @app.route('/api/upload', methods=['POST'])
166
+ def upload_file():
167
+ if 'file' not in request.files:
168
+ return jsonify({"error": "No file part"}), 400
169
+ file = request.files['file']
170
+ if file.filename == '':
171
+ return jsonify({"error": "No selected file"}), 400
172
+
173
+ try:
174
+ # Read file content
175
+ content = file.read()
176
+ # Try to decode as UTF-8
177
+ try:
178
+ text_content = content.decode('utf-8')
179
+ return jsonify({"content": text_content, "filename": file.filename})
180
+ except UnicodeDecodeError:
181
+ return jsonify({"error": "Binary or non-UTF-8 file detected. Please upload a text file."}), 400
182
+ except Exception as e:
183
+ return jsonify({"error": str(e)}), 500
184
+
185
+ @app.route('/api/stats', methods=['GET'])
186
+ def get_stats():
187
+ db = get_db()
188
+ tickets = db.execute('SELECT * FROM tickets ORDER BY created_at DESC LIMIT 50').fetchall()
189
+ solutions = db.execute('SELECT * FROM solutions').fetchall()
190
+
191
+ # Calculate stats
192
+ sentiment_counts = {}
193
+ intent_counts = {}
194
+
195
+ for t in tickets:
196
+ s = t['sentiment'] or 'Unknown'
197
+ i = t['intent'] or 'Other'
198
+ sentiment_counts[s] = sentiment_counts.get(s, 0) + 1
199
+ intent_counts[i] = intent_counts.get(i, 0) + 1
200
+
201
+ return jsonify({
202
+ "total_tickets": len(tickets),
203
+ "total_solutions": len(solutions),
204
+ "sentiment_dist": [{"name": k, "value": v} for k, v in sentiment_counts.items()],
205
+ "intent_dist": [{"name": k, "value": v} for k, v in intent_counts.items()],
206
+ "recent_tickets": [dict(t) for t in tickets[:5]]
207
+ })
208
+
209
+ @app.route('/api/simulate', methods=['POST'])
210
+ def simulate_chat():
211
+ # Roleplay: User acts as Agent, AI acts as Customer
212
+ history = request.json.get('history', []) # List of {role, content}
213
+ scenario = request.json.get('scenario', 'refund_dispute')
214
+
215
+ system_prompt = f"""
216
+ You are roleplaying as a CUSTOMER in a support chat.
217
+ Scenario: {scenario}.
218
+ Your goal is to test the support agent. Be realistic.
219
+ If the agent helps you, thank them. If they are rude or unhelpful, get angry.
220
+ Return JSON: {{"reply": "Your response as customer", "satisfaction_score": 1-10 (integer)}}
221
+ """
222
+
223
+ last_msg = history[-1]['content'] if history else "Start"
224
+
225
+ ai_result = call_ai_model(system_prompt, last_msg)
226
+
227
+ if not ai_result:
228
+ ai_result = {
229
+ "reply": "Wait, I need to check (Simulated Delay)...",
230
+ "satisfaction_score": 5
231
+ }
232
+
233
+ return jsonify(ai_result)
234
+
235
+ @app.route('/api/solutions', methods=['GET', 'POST'])
236
+ def manage_solutions():
237
+ db = get_db()
238
+ if request.method == 'POST':
239
+ data = request.json
240
+ db.execute('INSERT INTO solutions (title, content, tags) VALUES (?, ?, ?)',
241
+ (data['title'], data['content'], data['tags']))
242
+ db.commit()
243
+ return jsonify({"status": "success"})
244
+ else:
245
+ rows = db.execute('SELECT * FROM solutions ORDER BY usage_count DESC').fetchall()
246
+ return jsonify([dict(r) for r in rows])
247
+
248
+ if __name__ == '__main__':
249
+ with app.app_context():
250
+ init_db()
251
+ app.run(host='0.0.0.0', port=7860, debug=True)
instance/support_pilot.db ADDED
Binary file (16.4 kB). View file
 
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ Flask==3.0.0
2
+ flask-cors==4.0.0
3
+ requests==2.31.0
4
+ gunicorn==21.2.0
5
+ python-dotenv==1.0.0
templates/index.html ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Support Pilot - 智能客服领航员</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
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
11
+ <style>
12
+ body { background-color: #f3f4f6; color: #1f2937; font-family: 'Inter', sans-serif; }
13
+ .card { background: white; border-radius: 0.5rem; padding: 1.5rem; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); }
14
+ .btn-primary { background-color: #2563eb; color: white; padding: 0.5rem 1rem; border-radius: 0.375rem; transition: background-color 0.2s; }
15
+ .btn-primary:hover { background-color: #1d4ed8; }
16
+ .tab-active { border-bottom: 2px solid #2563eb; color: #2563eb; font-weight: 600; }
17
+ .tab-inactive { color: #6b7280; }
18
+ .tab-inactive:hover { color: #374151; }
19
+ /* Loader */
20
+ .loader { border: 3px solid #f3f3f3; border-radius: 50%; border-top: 3px solid #3498db; width: 20px; height: 20px; -webkit-animation: spin 1s linear infinite; animation: spin 1s linear infinite; display: inline-block; }
21
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
22
+ </style>
23
+ </head>
24
+ <body>
25
+ <div id="app" class="min-h-screen flex flex-col">
26
+ <!-- Header -->
27
+ <header class="bg-white shadow-sm z-10">
28
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
29
+ <div class="flex justify-between h-16">
30
+ <div class="flex items-center">
31
+ <i class="fa-solid fa-headset text-blue-600 text-2xl mr-3"></i>
32
+ <h1 class="text-xl font-bold text-gray-900">Support Pilot <span class="text-xs text-gray-500 font-normal ml-2">v1.0</span></h1>
33
+ </div>
34
+ <div class="flex items-center space-x-4">
35
+ <nav class="flex space-x-4">
36
+ <button @click="currentTab = 'dashboard'" :class="['px-3 py-2 text-sm font-medium', currentTab === 'dashboard' ? 'tab-active' : 'tab-inactive']">仪表盘</button>
37
+ <button @click="currentTab = 'analysis'" :class="['px-3 py-2 text-sm font-medium', currentTab === 'analysis' ? 'tab-active' : 'tab-inactive']">工单分析</button>
38
+ <button @click="currentTab = 'training'" :class="['px-3 py-2 text-sm font-medium', currentTab === 'training' ? 'tab-active' : 'tab-inactive']">模拟演练</button>
39
+ <button @click="currentTab = 'knowledge'" :class="['px-3 py-2 text-sm font-medium', currentTab === 'knowledge' ? 'tab-active' : 'tab-inactive']">知识库</button>
40
+ </nav>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </header>
45
+
46
+ <!-- Main Content -->
47
+ <main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
48
+
49
+ <!-- Dashboard View -->
50
+ <div v-if="currentTab === 'dashboard'" class="space-y-6">
51
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
52
+ <div class="card flex items-center justify-between">
53
+ <div>
54
+ <p class="text-sm font-medium text-gray-500">今日工单</p>
55
+ <p class="text-2xl font-bold text-gray-900">${ stats.total_tickets || 0 }</p>
56
+ </div>
57
+ <div class="p-3 bg-blue-100 rounded-full text-blue-600"><i class="fa-solid fa-ticket"></i></div>
58
+ </div>
59
+ <div class="card flex items-center justify-between">
60
+ <div>
61
+ <p class="text-sm font-medium text-gray-500">知识库条目</p>
62
+ <p class="text-2xl font-bold text-gray-900">${ stats.total_solutions || 0 }</p>
63
+ </div>
64
+ <div class="p-3 bg-green-100 rounded-full text-green-600"><i class="fa-solid fa-book"></i></div>
65
+ </div>
66
+ <div class="card flex items-center justify-between">
67
+ <div>
68
+ <p class="text-sm font-medium text-gray-500">平均满意度</p>
69
+ <p class="text-2xl font-bold text-gray-900">8.9</p>
70
+ </div>
71
+ <div class="p-3 bg-yellow-100 rounded-full text-yellow-600"><i class="fa-solid fa-star"></i></div>
72
+ </div>
73
+ </div>
74
+
75
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
76
+ <div class="card">
77
+ <h3 class="text-lg font-medium text-gray-900 mb-4">情绪分布</h3>
78
+ <div id="sentimentChart" style="height: 300px;"></div>
79
+ </div>
80
+ <div class="card">
81
+ <h3 class="text-lg font-medium text-gray-900 mb-4">意图分类</h3>
82
+ <div id="intentChart" style="height: 300px;"></div>
83
+ </div>
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Analysis View -->
88
+ <div v-if="currentTab === 'analysis'" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
89
+ <div class="card">
90
+ <h3 class="text-lg font-medium text-gray-900 mb-4">工单输入</h3>
91
+ <div class="space-y-4">
92
+ <div>
93
+ <label class="block text-sm font-medium text-gray-700 mb-1">客户消息 / 日志内容</label>
94
+ <textarea v-model="analysisInput" rows="8" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-3 border" placeholder="在此粘贴客户投诉或咨询内容..."></textarea>
95
+ </div>
96
+ <div class="flex space-x-3">
97
+ <button @click="analyzeTicket" :disabled="isAnalyzing" class="btn-primary w-full flex justify-center items-center">
98
+ <span v-if="isAnalyzing" class="loader mr-2"></span>
99
+ ${ isAnalyzing ? 'AI 分析中...' : '开始分析' }
100
+ </button>
101
+ <button @click="triggerUpload" class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"><i class="fa-solid fa-upload mr-1"></i> 上传日志</button>
102
+ <input type="file" ref="fileInput" @change="handleFileUpload" class="hidden">
103
+ <button @click="fillDemoData" class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50">示例数据</button>
104
+ </div>
105
+ </div>
106
+ </div>
107
+
108
+ <div class="card bg-gray-50 border border-gray-200">
109
+ <h3 class="text-lg font-medium text-gray-900 mb-4">AI 分析结果</h3>
110
+ <div v-if="analysisResult" class="space-y-4">
111
+ <div class="flex space-x-4">
112
+ <div class="flex-1">
113
+ <span class="text-xs font-semibold text-gray-500 uppercase">情绪</span>
114
+ <div class="mt-1 flex items-center">
115
+ <span :class="getSentimentColor(analysisResult.sentiment)" class="px-2 py-1 rounded text-sm font-bold">
116
+ ${ analysisResult.sentiment }
117
+ </span>
118
+ </div>
119
+ </div>
120
+ <div class="flex-1">
121
+ <span class="text-xs font-semibold text-gray-500 uppercase">意图</span>
122
+ <p class="mt-1 text-sm font-medium text-gray-900">${ analysisResult.intent }</p>
123
+ </div>
124
+ <div class="flex-1">
125
+ <span class="text-xs font-semibold text-gray-500 uppercase">优先级</span>
126
+ <p class="mt-1 text-sm font-medium text-red-600">${ analysisResult.priority }</p>
127
+ </div>
128
+ </div>
129
+
130
+ <div>
131
+ <span class="text-xs font-semibold text-gray-500 uppercase">建议回复</span>
132
+ <div class="mt-2 p-3 bg-white border border-blue-100 rounded-md shadow-sm">
133
+ <p class="text-sm text-gray-700 whitespace-pre-wrap">${ analysisResult.suggested_reply }</p>
134
+ <div class="mt-2 flex justify-end">
135
+ <button class="text-xs text-blue-600 hover:text-blue-800" @click="copyToClipboard(analysisResult.suggested_reply)">复制回复</button>
136
+ </div>
137
+ </div>
138
+ </div>
139
+
140
+ <div>
141
+ <span class="text-xs font-semibold text-gray-500 uppercase">关键词</span>
142
+ <div class="mt-2 flex flex-wrap gap-2">
143
+ <span v-for="tag in analysisResult.keywords" :key="tag" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
144
+ ${ tag }
145
+ </span>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ <div v-else class="h-64 flex items-center justify-center text-gray-400">
150
+ <div class="text-center">
151
+ <i class="fa-solid fa-robot text-4xl mb-2"></i>
152
+ <p>等待分析...</p>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+
158
+ <!-- Training View -->
159
+ <div v-if="currentTab === 'training'" class="card h-[600px] flex flex-col">
160
+ <div class="flex justify-between items-center border-b pb-4 mb-4">
161
+ <h3 class="text-lg font-medium text-gray-900">模拟演练室</h3>
162
+ <div class="flex items-center space-x-2">
163
+ <select v-model="selectedScenario" class="text-sm border-gray-300 rounded-md shadow-sm">
164
+ <option value="refund_dispute">场景:退款纠纷</option>
165
+ <option value="angry_customer">场景:愤怒客户</option>
166
+ <option value="product_inquiry">场景:产品咨询</option>
167
+ </select>
168
+ <button @click="resetSimulation" class="text-sm text-red-600 hover:text-red-800">重置</button>
169
+ </div>
170
+ </div>
171
+
172
+ <div class="flex-1 overflow-y-auto space-y-4 p-4 bg-gray-50 rounded-md mb-4" id="chatContainer">
173
+ <div v-for="(msg, index) in chatHistory" :key="index" :class="['flex', msg.role === 'user' ? 'justify-end' : 'justify-start']">
174
+ <div :class="['max-w-[70%] rounded-lg px-4 py-2', msg.role === 'user' ? 'bg-blue-600 text-white' : 'bg-white border text-gray-800']">
175
+ <p class="text-sm">${ msg.content }</p>
176
+ <p v-if="msg.role === 'system'" class="text-xs text-gray-400 mt-1">AI 客户 (满意度: ${ msg.satisfaction || '-' })</p>
177
+ </div>
178
+ </div>
179
+ </div>
180
+
181
+ <div class="flex space-x-2">
182
+ <input v-model="chatInput" @keyup.enter="sendChatMessage" type="text" class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border" placeholder="输入您的回复...">
183
+ <button @click="sendChatMessage" class="btn-primary">发送</button>
184
+ </div>
185
+ </div>
186
+
187
+ <!-- Knowledge Base -->
188
+ <div v-if="currentTab === 'knowledge'" class="space-y-6">
189
+ <div class="flex justify-between items-center">
190
+ <h3 class="text-lg font-medium text-gray-900">常用话术与解决方案</h3>
191
+ <button class="btn-primary text-sm"><i class="fa-solid fa-plus mr-1"></i> 新增条目</button>
192
+ </div>
193
+ <div class="bg-white shadow overflow-hidden sm:rounded-md">
194
+ <ul class="divide-y divide-gray-200">
195
+ <li v-for="sol in solutions" :key="sol.id">
196
+ <div class="px-4 py-4 sm:px-6 hover:bg-gray-50">
197
+ <div class="flex items-center justify-between">
198
+ <p class="text-sm font-medium text-blue-600 truncate">${ sol.title }</p>
199
+ <div class="ml-2 flex-shrink-0 flex">
200
+ <p class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
201
+ 使用次数: ${ sol.usage_count }
202
+ </p>
203
+ </div>
204
+ </div>
205
+ <div class="mt-2 sm:flex sm:justify-between">
206
+ <div class="sm:flex">
207
+ <p class="flex items-center text-sm text-gray-500">
208
+ ${ sol.content }
209
+ </p>
210
+ </div>
211
+ </div>
212
+ </div>
213
+ </li>
214
+ </ul>
215
+ </div>
216
+ </div>
217
+
218
+ </main>
219
+ </div>
220
+
221
+ <script>
222
+ const { createApp, ref, onMounted, nextTick } = Vue;
223
+
224
+ createApp({
225
+ delimiters: ['${', '}'],
226
+ setup() {
227
+ const currentTab = ref('analysis');
228
+ const analysisInput = ref('');
229
+ const analysisResult = ref(null);
230
+ const isAnalyzing = ref(false);
231
+ const stats = ref({});
232
+ const fileInput = ref(null);
233
+
234
+ // Chat Sim
235
+ const chatInput = ref('');
236
+ const chatHistory = ref([{ role: 'system', content: '您好,我买的商品到现在还没发货,怎么回事?' }]);
237
+ const selectedScenario = ref('refund_dispute');
238
+
239
+ // KB
240
+ const solutions = ref([]);
241
+
242
+ // API Helper
243
+ const fetchStats = async () => {
244
+ try {
245
+ const res = await fetch('/api/stats');
246
+ const data = await res.json();
247
+ stats.value = data;
248
+ renderCharts(data);
249
+ } catch (e) { console.error(e); }
250
+ };
251
+
252
+ const fetchSolutions = async () => {
253
+ try {
254
+ const res = await fetch('/api/solutions');
255
+ solutions.value = await res.json();
256
+ } catch (e) { console.error(e); }
257
+ };
258
+
259
+ // Analysis
260
+ const analyzeTicket = async () => {
261
+ if (!analysisInput.value) return;
262
+ isAnalyzing.value = true;
263
+ try {
264
+ const res = await fetch('/api/analyze', {
265
+ method: 'POST',
266
+ headers: {'Content-Type': 'application/json'},
267
+ body: JSON.stringify({ content: analysisInput.value })
268
+ });
269
+ analysisResult.value = await res.json();
270
+ fetchStats(); // Refresh stats
271
+ } catch (e) {
272
+ alert('分析失败,请重试');
273
+ } finally {
274
+ isAnalyzing.value = false;
275
+ }
276
+ };
277
+
278
+ const fillDemoData = () => {
279
+ analysisInput.value = "我真的很生气!上周买的吸尘器(订单号:12345678)到现在还没发货。你们承诺是24小时发货的。如果是缺货请告诉我,不要让我干等。我要投诉你们的服务态度!";
280
+ };
281
+
282
+ const triggerUpload = () => {
283
+ fileInput.value.click();
284
+ };
285
+
286
+ const handleFileUpload = async (event) => {
287
+ const file = event.target.files[0];
288
+ if (!file) return;
289
+
290
+ const formData = new FormData();
291
+ formData.append('file', file);
292
+
293
+ try {
294
+ const res = await fetch('/api/upload', {
295
+ method: 'POST',
296
+ body: formData
297
+ });
298
+ const data = await res.json();
299
+
300
+ if (res.ok) {
301
+ analysisInput.value = data.content;
302
+ } else {
303
+ alert('上传失败: ' + data.error);
304
+ }
305
+ } catch (e) {
306
+ alert('上传出错');
307
+ console.error(e);
308
+ }
309
+ // Reset input
310
+ event.target.value = '';
311
+ };
312
+
313
+ const getSentimentColor = (s) => {
314
+ if (s.includes('Positive')) return 'bg-green-100 text-green-800';
315
+ if (s.includes('Negative') || s.includes('Urgent')) return 'bg-red-100 text-red-800';
316
+ return 'bg-gray-100 text-gray-800';
317
+ };
318
+
319
+ // Chat Sim
320
+ const sendChatMessage = async () => {
321
+ if (!chatInput.value) return;
322
+ // User msg
323
+ chatHistory.value.push({ role: 'user', content: chatInput.value });
324
+ const userMsg = chatInput.value;
325
+ chatInput.value = '';
326
+
327
+ // Scroll
328
+ await nextTick();
329
+ const container = document.getElementById('chatContainer');
330
+ if (container) container.scrollTop = container.scrollHeight;
331
+
332
+ // AI Reply
333
+ try {
334
+ const res = await fetch('/api/simulate', {
335
+ method: 'POST',
336
+ headers: {'Content-Type': 'application/json'},
337
+ body: JSON.stringify({
338
+ history: chatHistory.value,
339
+ scenario: selectedScenario.value
340
+ })
341
+ });
342
+ const data = await res.json();
343
+ chatHistory.value.push({
344
+ role: 'system',
345
+ content: data.reply,
346
+ satisfaction: data.satisfaction_score
347
+ });
348
+ await nextTick();
349
+ container.scrollTop = container.scrollHeight;
350
+ } catch (e) {
351
+ chatHistory.value.push({ role: 'system', content: 'Simulation Error.' });
352
+ }
353
+ };
354
+
355
+ const resetSimulation = () => {
356
+ chatHistory.value = [{ role: 'system', content: '您好,这里是客服中心,请问有什么可以帮您?(模拟新场景)' }];
357
+ };
358
+
359
+ // Charts
360
+ const renderCharts = (data) => {
361
+ if (currentTab.value !== 'dashboard') return;
362
+
363
+ nextTick(() => {
364
+ const sChart = echarts.init(document.getElementById('sentimentChart'));
365
+ sChart.setOption({
366
+ tooltip: { trigger: 'item' },
367
+ legend: { top: '5%', left: 'center' },
368
+ series: [{
369
+ name: 'Sentiment',
370
+ type: 'pie',
371
+ radius: ['40%', '70%'],
372
+ data: data.sentiment_dist
373
+ }]
374
+ });
375
+
376
+ const iChart = echarts.init(document.getElementById('intentChart'));
377
+ iChart.setOption({
378
+ tooltip: { trigger: 'item' },
379
+ xAxis: { type: 'category', data: data.intent_dist.map(i => i.name) },
380
+ yAxis: { type: 'value' },
381
+ series: [{
382
+ data: data.intent_dist.map(i => i.value),
383
+ type: 'bar',
384
+ itemStyle: { color: '#3b82f6' }
385
+ }]
386
+ });
387
+ });
388
+ };
389
+
390
+ // Watch tab change to render charts
391
+ Vue.watch(currentTab, (newTab) => {
392
+ if (newTab === 'dashboard') {
393
+ fetchStats();
394
+ }
395
+ });
396
+
397
+ onMounted(() => {
398
+ fetchStats();
399
+ fetchSolutions();
400
+ });
401
+
402
+ return {
403
+ currentTab,
404
+ analysisInput,
405
+ analysisResult,
406
+ isAnalyzing,
407
+ stats,
408
+ analyzeTicket,
409
+ fillDemoData,
410
+ triggerUpload,
411
+ handleFileUpload,
412
+ fileInput,
413
+ getSentimentColor,
414
+ chatInput,
415
+ chatHistory,
416
+ selectedScenario,
417
+ sendChatMessage,
418
+ resetSimulation,
419
+ solutions
420
+ };
421
+ }
422
+ }).mount('#app');
423
+ </script>
424
+ </body>
425
+ </html>