3v324v23 commited on
Commit
740aa76
·
1 Parent(s): 4dbefc0

开发智能客服 agent

Browse files
Files changed (6) hide show
  1. .gitignore +1 -0
  2. README.md +15 -1
  3. app.py +334 -2
  4. data/conversations/.gitkeep +1 -0
  5. data/faq.json +25 -0
  6. templates/index.html +295 -1
.gitignore CHANGED
@@ -5,3 +5,4 @@ uploads/
5
  .DS_Store
6
  .env
7
  venv/
 
 
5
  .DS_Store
6
  .env
7
  venv/
8
+ data/conversations/*.json
README.md CHANGED
@@ -33,6 +33,12 @@ Support Intel Pro 是一个面向现代化客户服务团队的智能工单分
33
  - 监控坐席(L1/L2/Tech Lead)的实时负载。
34
  - 自动将新工单分配给负载最低的在线坐席。
35
 
 
 
 
 
 
 
36
  ## 技术栈 (Tech Stack)
37
  - **Backend**: Flask (Python)
38
  - **Frontend**: Vue 3 + Tailwind CSS
@@ -48,9 +54,17 @@ Support Intel Pro 是一个面向现代化客户服务团队的智能工单分
48
  pip install -r requirements.txt
49
 
50
  # 启动应用
51
- python app.py
52
  ```
53
 
 
 
 
 
 
 
 
 
54
  ### Docker 运行
55
  ```bash
56
  docker build -t support-intel-pro .
 
33
  - 监控坐席(L1/L2/Tech Lead)的实时负载。
34
  - 自动将新工单分配给负载最低的在线坐席。
35
 
36
+ 5. **智能客服 Agent(闭环对话)**:
37
+ - Web 聊天界面:新建会话、历史会话列表、快捷意图
38
+ - 对话调用硅基流 OpenAI 兼容接口(默认模型:Qwen2.5-7B-Instruct)
39
+ - 会话落盘(JSON)+ 导出 + 评分反馈
40
+ - 本地知识库注入(`data/faq.json` 关键词匹配)
41
+
42
  ## 技术栈 (Tech Stack)
43
  - **Backend**: Flask (Python)
44
  - **Frontend**: Vue 3 + Tailwind CSS
 
54
  pip install -r requirements.txt
55
 
56
  # 启动应用
57
+ python3 app.py
58
  ```
59
 
60
+ ### 环境变量(用于智能客服 Agent)
61
+
62
+ 运行前按需设置:
63
+
64
+ - `SILICONFLOW_API_KEY`:必填
65
+ - `SILICONFLOW_BASE_URL`:默认 `https://api.siliconflow.cn/v1`
66
+ - `SILICONFLOW_MODEL`:默认 `Qwen/Qwen2.5-7B-Instruct`
67
+
68
  ### Docker 运行
69
  ```bash
70
  docker build -t support-intel-pro .
app.py CHANGED
@@ -2,18 +2,36 @@ import os
2
  import random
3
  import time
4
  import json
 
 
 
 
 
 
 
 
 
5
  from datetime import datetime, timedelta
6
- from flask import Flask, render_template, jsonify, request
7
  from faker import Faker
8
 
9
  app = Flask(__name__)
10
  fake = Faker('zh_CN')
11
 
12
  # --- Configuration ---
13
- PORT = 7860
14
  UPLOAD_FOLDER = 'uploads'
15
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
16
 
 
 
 
 
 
 
 
 
 
17
  # --- In-Memory Data Store ---
18
  tickets = []
19
  agents = [
@@ -30,6 +48,158 @@ KEYWORDS_URGENT = ["投诉", "退款", "崩溃", "无法登录", "数据丢失",
30
  KEYWORDS_BILLING = ["发票", "扣款", "续费", "价格", "支付", "账单"]
31
  KEYWORDS_TECH = ["API", "Bug", "连接", "延迟", "配置", "代码", "服务器", "数据库"]
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  def calculate_sentiment(text):
34
  """
35
  Simulate sentiment analysis.
@@ -209,6 +379,168 @@ def resolve_ticket():
209
 
210
  return jsonify({"success": False}), 404
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  @app.errorhandler(404)
213
  def page_not_found(e):
214
  return render_template('index.html'), 404 # SPA fallback mostly
 
2
  import random
3
  import time
4
  import json
5
+ import uuid
6
+ import re
7
+ import urllib.request
8
+ import urllib.error
9
+ import ssl
10
+ try:
11
+ import certifi
12
+ except Exception:
13
+ certifi = None
14
  from datetime import datetime, timedelta
15
+ from flask import Flask, render_template, jsonify, request, Response
16
  from faker import Faker
17
 
18
  app = Flask(__name__)
19
  fake = Faker('zh_CN')
20
 
21
  # --- Configuration ---
22
+ PORT = int(os.environ.get('PORT', '7860'))
23
  UPLOAD_FOLDER = 'uploads'
24
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
25
 
26
+ DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
27
+ CONV_DIR = os.path.join(DATA_DIR, 'conversations')
28
+ FAQ_PATH = os.path.join(DATA_DIR, 'faq.json')
29
+ os.makedirs(CONV_DIR, exist_ok=True)
30
+
31
+ SILICONFLOW_API_KEY = os.environ.get('SILICONFLOW_API_KEY', '').strip()
32
+ SILICONFLOW_BASE_URL = os.environ.get('SILICONFLOW_BASE_URL', 'https://api.siliconflow.cn/v1').strip().rstrip('/')
33
+ SILICONFLOW_MODEL = os.environ.get('SILICONFLOW_MODEL', 'Qwen/Qwen2.5-7B-Instruct').strip()
34
+
35
  # --- In-Memory Data Store ---
36
  tickets = []
37
  agents = [
 
48
  KEYWORDS_BILLING = ["发票", "扣款", "续费", "价格", "支付", "账单"]
49
  KEYWORDS_TECH = ["API", "Bug", "连接", "延迟", "配置", "代码", "服务器", "数据库"]
50
 
51
+ def now_iso():
52
+ return datetime.utcnow().replace(microsecond=0).isoformat() + 'Z'
53
+
54
+ def normalize_text(s):
55
+ return re.sub(r'\s+', ' ', str(s or '')).strip()
56
+
57
+ def safe_title_from_text(s):
58
+ t = normalize_text(s)
59
+ if not t:
60
+ return '新会话'
61
+ return (t[:24] + '…') if len(t) > 24 else t
62
+
63
+ def read_json_if_exists(p):
64
+ try:
65
+ with open(p, 'r', encoding='utf-8') as f:
66
+ return json.load(f)
67
+ except FileNotFoundError:
68
+ return None
69
+
70
+ def atomic_write_json(p, data):
71
+ os.makedirs(os.path.dirname(p), exist_ok=True)
72
+ tmp = os.path.join(os.path.dirname(p), f'.tmp-{uuid.uuid4().hex}.json')
73
+ with open(tmp, 'w', encoding='utf-8') as f:
74
+ json.dump(data, f, ensure_ascii=False, indent=2)
75
+ os.replace(tmp, p)
76
+
77
+ def conversation_path(conversation_id):
78
+ safe = re.sub(r'[^a-zA-Z0-9_-]', '', str(conversation_id or ''))
79
+ return os.path.join(CONV_DIR, f'{safe}.json')
80
+
81
+ def load_faq():
82
+ data = read_json_if_exists(FAQ_PATH)
83
+ if not data or not isinstance(data, dict):
84
+ return {'items': []}
85
+ items = data.get('items')
86
+ return {'items': items if isinstance(items, list) else []}
87
+
88
+ FAQ = load_faq()
89
+
90
+ def tokenize_for_match(s):
91
+ t = normalize_text(s).lower()
92
+ if not t:
93
+ return []
94
+ raw = [x for x in re.split(r'[^a-z0-9\u4e00-\u9fa5]+', t) if x]
95
+ uniq = []
96
+ seen = set()
97
+ for x in raw:
98
+ if len(x) < 2:
99
+ continue
100
+ if x in seen:
101
+ continue
102
+ seen.add(x)
103
+ uniq.append(x)
104
+ if len(uniq) >= 30:
105
+ break
106
+ return uniq
107
+
108
+ def score_faq_item(item, tokens):
109
+ q = str(item.get('q') or '')
110
+ a = str(item.get('a') or '')
111
+ tags = item.get('tags') if isinstance(item.get('tags'), list) else []
112
+ hay = f'{q} {a} {" ".join([str(x) for x in tags])}'.lower()
113
+ score = 0
114
+ for tok in tokens:
115
+ if tok in hay:
116
+ score += 3 if len(tok) >= 4 else 1
117
+ return score
118
+
119
+ def pick_kb_snippets(user_text):
120
+ tokens = tokenize_for_match(user_text)
121
+ if not tokens:
122
+ return []
123
+ scored = []
124
+ for it in FAQ.get('items', []):
125
+ if not isinstance(it, dict):
126
+ continue
127
+ s = score_faq_item(it, tokens)
128
+ if s > 0:
129
+ scored.append((s, it))
130
+ scored.sort(key=lambda x: x[0], reverse=True)
131
+ out = []
132
+ for _, it in scored[:3]:
133
+ out.append(f"Q: {normalize_text(it.get('q'))}\nA: {normalize_text(it.get('a'))}")
134
+ return out
135
+
136
+ def build_system_prompt(kb_snippets):
137
+ kb = '\n\n'.join(kb_snippets or [])
138
+ kb_block = f'\n\n【公司知识库(可引用)】\n{kb}' if kb else ''
139
+ parts = [
140
+ '你是一个中文智能客服 Agent,代表「Support Intel Pro 演示公司」与用户对话。',
141
+ '目标:快速定位问题、给出可执行的解决方案,并在必要时提出澄清问题。',
142
+ '约束:',
143
+ '1) 不要编造政策/价格/承诺;知识库没有覆盖时,明确说明“需要进一步确认/转接人工/让用户提供信息”。',
144
+ '2) 优先输出步骤化的解决方案(用 1、2、3…)。',
145
+ '3) 涉及账号/订单/隐私信息时,引导用户只提供必要字段(如订单号后 4 位)。',
146
+ '4) 若用户提及“退款/投诉/发票/物流/售后”,主动收集关键字段并给下一步动作。',
147
+ kb_block,
148
+ ]
149
+ return '\n'.join([p for p in parts if p])
150
+
151
+ def trim_messages_for_context(messages, max_turns):
152
+ out = []
153
+ turns = 0
154
+ for m in reversed(messages or []):
155
+ role = m.get('role')
156
+ content = m.get('content')
157
+ if role not in ['user', 'assistant'] or not isinstance(content, str):
158
+ continue
159
+ out.insert(0, {'role': role, 'content': content})
160
+ if role == 'user':
161
+ turns += 1
162
+ if turns >= max_turns:
163
+ break
164
+ return out
165
+
166
+ def call_siliconflow_chat(api_key, base_url, model, messages, temperature, max_tokens):
167
+ body = json.dumps({
168
+ 'model': model,
169
+ 'messages': messages,
170
+ 'temperature': temperature,
171
+ 'max_tokens': max_tokens,
172
+ 'stream': False,
173
+ }).encode('utf-8')
174
+ req = urllib.request.Request(
175
+ url=f'{base_url}/chat/completions',
176
+ data=body,
177
+ headers={
178
+ 'Content-Type': 'application/json',
179
+ 'Authorization': f'Bearer {api_key}',
180
+ },
181
+ method='POST',
182
+ )
183
+ ctx = ssl.create_default_context(cafile=certifi.where()) if certifi else ssl.create_default_context()
184
+ try:
185
+ with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
186
+ raw = resp.read().decode('utf-8')
187
+ data = json.loads(raw)
188
+ content = (((data.get('choices') or [{}])[0].get('message') or {}).get('content') or '').strip()
189
+ usage = data.get('usage')
190
+ if not content:
191
+ raise RuntimeError('上游返回为空')
192
+ return content, usage
193
+ except urllib.error.HTTPError as e:
194
+ try:
195
+ detail = e.read().decode('utf-8')
196
+ except Exception:
197
+ detail = str(e)
198
+ brief = (detail[:500] + '…') if len(detail) > 500 else detail
199
+ raise RuntimeError(f'上游接口错误:HTTP {e.code};{brief}')
200
+ except urllib.error.URLError as e:
201
+ raise RuntimeError(f'上游连接失败:{e}')
202
+
203
  def calculate_sentiment(text):
204
  """
205
  Simulate sentiment analysis.
 
379
 
380
  return jsonify({"success": False}), 404
381
 
382
+ @app.route('/api/health')
383
+ def agent_health():
384
+ return jsonify({
385
+ 'ok': True,
386
+ 'time': now_iso(),
387
+ 'model': SILICONFLOW_MODEL,
388
+ 'baseUrl': SILICONFLOW_BASE_URL,
389
+ 'hasKey': bool(SILICONFLOW_API_KEY),
390
+ })
391
+
392
+ @app.route('/api/conversations')
393
+ def list_conversations():
394
+ items = []
395
+ for name in os.listdir(CONV_DIR):
396
+ if not name.endswith('.json'):
397
+ continue
398
+ p = os.path.join(CONV_DIR, name)
399
+ conv = read_json_if_exists(p)
400
+ if not conv:
401
+ continue
402
+ items.append({
403
+ 'id': conv.get('id'),
404
+ 'title': conv.get('title') or '新会话',
405
+ 'updatedAt': conv.get('updatedAt') or conv.get('createdAt'),
406
+ 'createdAt': conv.get('createdAt'),
407
+ })
408
+ items.sort(key=lambda x: str(x.get('updatedAt') or ''), reverse=True)
409
+ return jsonify({'items': items})
410
+
411
+ @app.route('/api/conversations/<conversation_id>')
412
+ def get_conversation(conversation_id):
413
+ conv = read_json_if_exists(conversation_path(conversation_id))
414
+ if not conv:
415
+ return jsonify({'error': '会话不存在'}), 404
416
+ return jsonify({'conversation': conv})
417
+
418
+ @app.route('/api/export/<conversation_id>')
419
+ def export_conversation(conversation_id):
420
+ conv = read_json_if_exists(conversation_path(conversation_id))
421
+ if not conv:
422
+ return jsonify({'error': '会话不存在'}), 404
423
+ payload = json.dumps(conv, ensure_ascii=False, indent=2)
424
+ resp = Response(payload, mimetype='application/json; charset=utf-8')
425
+ resp.headers['Content-Disposition'] = f'attachment; filename="conversation-{conversation_id}.json"'
426
+ return resp
427
+
428
+ @app.route('/api/feedback', methods=['POST'])
429
+ def agent_feedback():
430
+ data = request.json or {}
431
+ conversation_id = normalize_text(data.get('conversationId'))
432
+ message_id = normalize_text(data.get('messageId'))
433
+ rating_raw = data.get('rating')
434
+ note = normalize_text(data.get('note'))
435
+ if not conversation_id:
436
+ return jsonify({'error': 'conversationId 不能为空'}), 400
437
+ if not message_id:
438
+ return jsonify({'error': 'messageId 不能为空'}), 400
439
+ try:
440
+ rating = int(round(float(rating_raw)))
441
+ except Exception:
442
+ return jsonify({'error': 'rating 不能为空'}), 400
443
+ rating = max(1, min(5, rating))
444
+ p = conversation_path(conversation_id)
445
+ conv = read_json_if_exists(p)
446
+ if not conv:
447
+ return jsonify({'error': '会话不存在'}), 404
448
+ feedback = conv.get('feedback')
449
+ if not isinstance(feedback, list):
450
+ feedback = []
451
+ feedback.append({
452
+ 'id': uuid.uuid4().hex,
453
+ 'messageId': message_id,
454
+ 'rating': rating,
455
+ 'note': note,
456
+ 'createdAt': now_iso(),
457
+ })
458
+ conv['feedback'] = feedback
459
+ conv['updatedAt'] = now_iso()
460
+ atomic_write_json(p, conv)
461
+ return jsonify({'ok': True})
462
+
463
+ @app.route('/api/chat', methods=['POST'])
464
+ def agent_chat():
465
+ data = request.json or {}
466
+ user_text = normalize_text(data.get('message'))
467
+ conversation_id_raw = normalize_text(data.get('conversationId'))
468
+ if not user_text:
469
+ return jsonify({'error': 'message 不能为空'}), 400
470
+ if len(user_text) > 4000:
471
+ return jsonify({'error': 'message 太长(最大 4000 字符)'}), 400
472
+ if not SILICONFLOW_API_KEY:
473
+ return jsonify({'error': '未配置 SILICONFLOW_API_KEY(请在环境变量中设置)'}), 500
474
+
475
+ conversation_id = conversation_id_raw or str(uuid.uuid4())
476
+ p = conversation_path(conversation_id)
477
+ conv = read_json_if_exists(p) or {
478
+ 'id': conversation_id,
479
+ 'title': '新会话',
480
+ 'createdAt': now_iso(),
481
+ 'updatedAt': now_iso(),
482
+ 'messages': [],
483
+ 'meta': {'provider': 'siliconflow', 'model': SILICONFLOW_MODEL},
484
+ }
485
+ messages = conv.get('messages')
486
+ if not isinstance(messages, list):
487
+ messages = []
488
+
489
+ user_msg = {'id': uuid.uuid4().hex, 'role': 'user', 'content': user_text, 'createdAt': now_iso()}
490
+ messages.append(user_msg)
491
+ conv['messages'] = messages
492
+ if conv.get('title') in [None, '', '新会话']:
493
+ conv['title'] = safe_title_from_text(user_text)
494
+ conv['updatedAt'] = now_iso()
495
+
496
+ kb_snippets = pick_kb_snippets(user_text)
497
+ system_prompt = build_system_prompt(kb_snippets)
498
+ history = trim_messages_for_context(messages, 10)
499
+ upstream_messages = [{'role': 'system', 'content': system_prompt}] + history
500
+
501
+ try:
502
+ temperature_raw = data.get('temperature')
503
+ max_tokens_raw = data.get('maxTokens')
504
+ try:
505
+ temperature = float(temperature_raw) if temperature_raw is not None else 0.4
506
+ except Exception:
507
+ temperature = 0.4
508
+ temperature = max(0.0, min(1.0, temperature))
509
+
510
+ try:
511
+ max_tokens = int(max_tokens_raw) if max_tokens_raw is not None else 1024
512
+ except Exception:
513
+ max_tokens = 1024
514
+ max_tokens = max(64, min(2048, max_tokens))
515
+
516
+ content, usage = call_siliconflow_chat(
517
+ api_key=SILICONFLOW_API_KEY,
518
+ base_url=SILICONFLOW_BASE_URL,
519
+ model=SILICONFLOW_MODEL,
520
+ messages=upstream_messages,
521
+ temperature=temperature,
522
+ max_tokens=max_tokens,
523
+ )
524
+ assistant_msg = {
525
+ 'id': uuid.uuid4().hex,
526
+ 'role': 'assistant',
527
+ 'content': content,
528
+ 'createdAt': now_iso(),
529
+ 'usage': usage,
530
+ }
531
+ messages.append(assistant_msg)
532
+ conv['messages'] = messages
533
+ conv['updatedAt'] = now_iso()
534
+ atomic_write_json(p, conv)
535
+ return jsonify({
536
+ 'conversationId': conversation_id,
537
+ 'assistantMessage': assistant_msg,
538
+ 'conversation': {'id': conv.get('id'), 'title': conv.get('title'), 'updatedAt': conv.get('updatedAt')},
539
+ })
540
+ except Exception as e:
541
+ atomic_write_json(p, conv)
542
+ return jsonify({'error': str(e)}), 502
543
+
544
  @app.errorhandler(404)
545
  def page_not_found(e):
546
  return render_template('index.html'), 404 # SPA fallback mostly
data/conversations/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
data/faq.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "items": [
3
+ {
4
+ "q": "如何查询订单物流?",
5
+ "a": "请提供订单号后 4 位 + 收货手机号后 4 位。我会帮你查询:1) 当前物流公司与运单号;2) 最新节点;3) 预计送达时间。若超过 48 小时未更新,可发起人工催件。",
6
+ "tags": ["订单", "物流", "运单", "查询"]
7
+ },
8
+ {
9
+ "q": "退款/退货流程是什么?",
10
+ "a": "请先确认:1) 订单号后 4 位;2) 是否已签收;3) 退货原因(质量/不喜欢/发错货)。流程:提交申请 → 审核通过 → 生成退货地址与单号 → 仓库签收 → 原路退款。若为质量问题建议先上传照片/视频以便加速处理。",
11
+ "tags": ["退款", "退货", "售后", "流程"]
12
+ },
13
+ {
14
+ "q": "如何开具发票?",
15
+ "a": "请提供:抬头类型(个人/企业)、抬头名称、税号(企业必填)、邮箱。我们支持电子发票,通常会在订单完成后生成并发送到邮箱。",
16
+ "tags": ["发票", "电子发票", "税号"]
17
+ },
18
+ {
19
+ "q": "账号登录不了怎么办?",
20
+ "a": "请按顺序排查:1) 确认验证码是否过期;2) 切换网络或关闭代理;3) 清理浏览器缓存后重试;4) 仍失败请提供手机号后 4 位 + 错误截图关键提示(可打码)。必要时可发起人工复核。",
21
+ "tags": ["账号", "登录", "验证码"]
22
+ }
23
+ ]
24
+ }
25
+
templates/index.html CHANGED
@@ -45,6 +45,10 @@
45
  <i class="fa-solid fa-cloud-upload"></i> 上传日志
46
  </button>
47
  <input type="file" ref="fileInput" @change="handleFileUpload" class="hidden">
 
 
 
 
48
 
49
  <button @click="simulateTraffic" :disabled="isSimulating"
50
  class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
@@ -241,6 +245,110 @@
241
  </div>
242
  </div>
243
  </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  </div>
245
 
246
  <script>
@@ -256,6 +364,25 @@
256
  const newTicketsCount = ref(0);
257
  const fileInput = ref(null);
258
  const uploadStatus = ref('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
  let sentimentChart = null;
261
  let priorityChart = null;
@@ -366,6 +493,170 @@
366
  return t.split(' ')[1]; // HH:MM:SS
367
  };
368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  // Charts
370
  const initCharts = () => {
371
  const chartDom1 = document.getElementById('sentimentChart');
@@ -450,7 +741,10 @@
450
 
451
  return {
452
  tickets, stats, isSimulating, currentTime, activeAgents, newTicketsCount, fileInput, uploadStatus,
453
- simulateTraffic, resolveTicket, getPriorityClass, formatTime, triggerUpload, handleFileUpload
 
 
 
454
  };
455
  }
456
  }).mount('#app');
 
45
  <i class="fa-solid fa-cloud-upload"></i> 上传日志
46
  </button>
47
  <input type="file" ref="fileInput" @change="handleFileUpload" class="hidden">
48
+
49
+ <button @click="openAgentPanel" class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-sm">
50
+ <i class="fa-solid fa-comments"></i> 智能客服 Agent
51
+ </button>
52
 
53
  <button @click="simulateTraffic" :disabled="isSimulating"
54
  class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
 
245
  </div>
246
  </div>
247
  </main>
248
+
249
+ <div v-if="agentOpen" class="fixed inset-0 z-[100]">
250
+ <div class="absolute inset-0 bg-black/30" @click="closeAgentPanel"></div>
251
+ <div class="absolute right-0 top-0 h-full w-full max-w-6xl bg-white shadow-2xl flex">
252
+ <div class="w-80 border-r border-gray-200 bg-gray-50 flex flex-col">
253
+ <div class="p-4 border-b border-gray-200 bg-white">
254
+ <div class="flex items-start justify-between gap-3">
255
+ <div>
256
+ <div class="font-bold text-gray-900">智能客服 Agent</div>
257
+ <div class="text-xs text-gray-500">硅基流 ${ agentHealth.model || '' }</div>
258
+ </div>
259
+ <button @click="closeAgentPanel" class="text-gray-400 hover:text-gray-700">
260
+ <i class="fa-solid fa-xmark text-lg"></i>
261
+ </button>
262
+ </div>
263
+ <div class="mt-3 flex items-center gap-2 text-xs">
264
+ <span :class="['inline-flex items-center gap-2 px-2 py-1 rounded-full border', agentHealth.hasKey ? 'bg-green-50 text-green-700 border-green-200' : 'bg-red-50 text-red-700 border-red-200']">
265
+ <span :class="['w-2 h-2 rounded-full', agentHealth.hasKey ? 'bg-green-500' : 'bg-red-500']"></span>
266
+ ${ agentHealth.hasKey ? '服务正常' : '缺少 API Key' }
267
+ </span>
268
+ <span class="text-gray-400">${ agentHealth.baseUrl || '' }</span>
269
+ </div>
270
+ <button @click="agentNewConversation" class="mt-4 w-full bg-indigo-600 hover:bg-indigo-700 text-white px-3 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2">
271
+ <i class="fa-solid fa-plus"></i> 新建会话
272
+ </button>
273
+ </div>
274
+
275
+ <div class="px-4 pt-4 text-xs text-gray-500 font-medium">历史会话</div>
276
+ <div class="p-4 pt-2 overflow-y-auto flex-1 space-y-2">
277
+ <button v-for="c in agentConversations" :key="c.id"
278
+ @click="agentOpenConversation(c.id)"
279
+ :class="['w-full text-left px-3 py-2 rounded-lg border transition-colors', agentConversationId === c.id ? 'bg-indigo-50 border-indigo-200' : 'bg-white border-gray-200 hover:border-indigo-200']">
280
+ <div class="text-sm font-semibold text-gray-900 truncate">${ c.title || '新会话' }</div>
281
+ <div class="text-xs text-gray-500 mt-1">${ c.updatedAt ? formatAgentTime(c.updatedAt) : '' }</div>
282
+ </button>
283
+ <div v-if="agentConversations.length === 0" class="text-xs text-gray-400 text-center py-6">
284
+ 暂无会话记录
285
+ </div>
286
+ </div>
287
+ </div>
288
+
289
+ <div class="flex-1 flex flex-col">
290
+ <div class="p-4 border-b border-gray-200 flex items-center justify-between gap-3 bg-white">
291
+ <div>
292
+ <div class="font-bold text-gray-900">${ agentTitle || '新会话' }</div>
293
+ <div class="text-xs text-gray-500">${ agentMeta || '' }</div>
294
+ </div>
295
+ <div class="flex items-center gap-2">
296
+ <button @click="agentExport" :disabled="!agentConversationId"
297
+ class="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 px-3 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
298
+ <i class="fa-solid fa-download"></i> 导出 JSON
299
+ </button>
300
+ </div>
301
+ </div>
302
+
303
+ <div ref="agentChatBox" class="flex-1 overflow-y-auto p-5 bg-gray-50">
304
+ <div class="max-w-3xl mx-auto space-y-4">
305
+ <div v-for="m in agentMessages" :key="m.id" :class="m.role === 'user' ? 'flex justify-end' : 'flex justify-start'">
306
+ <div :class="['max-w-[86%] rounded-2xl px-4 py-3 border shadow-sm', m.role === 'user' ? 'bg-indigo-600 text-white border-indigo-500' : 'bg-white text-gray-900 border-gray-200']">
307
+ <div class="whitespace-pre-wrap text-sm leading-relaxed">${ m.content }</div>
308
+ <div class="mt-2 text-[11px] flex items-center justify-between gap-3" :class="m.role === 'user' ? 'text-indigo-100' : 'text-gray-400'">
309
+ <span>${ m.createdAt ? formatAgentTime(m.createdAt) : '' }</span>
310
+ <div v-if="m.role === 'assistant' && m.id && agentConversationId" class="flex items-center gap-1">
311
+ <button v-for="n in 5" :key="n" @click="agentRate(m.id, n)"
312
+ :disabled="!!agentRated[m.id]"
313
+ :class="['w-5 h-5 rounded border flex items-center justify-center text-[10px] transition-colors', agentRated[m.id] ? 'opacity-50 cursor-not-allowed' : 'hover:border-yellow-300', (agentRated[m.id] ? agentRated[m.id] : 0) >= n ? 'bg-yellow-400 border-yellow-400 text-white' : 'bg-white border-gray-200 text-gray-600']">
314
+ <i class="fa-solid fa-star"></i>
315
+ </button>
316
+ </div>
317
+ </div>
318
+ </div>
319
+ </div>
320
+ <div v-if="agentUsageText" class="max-w-3xl mx-auto text-xs text-gray-400 text-right">
321
+ ${ agentUsageText }
322
+ </div>
323
+ </div>
324
+ </div>
325
+
326
+ <div class="p-4 border-t border-gray-200 bg-white">
327
+ <div class="max-w-3xl mx-auto">
328
+ <div class="flex flex-wrap gap-2 mb-3">
329
+ <button v-for="q in agentQuickPrompts" :key="q.label" @click="agentFill(q.template)"
330
+ class="text-xs bg-white border border-gray-200 hover:border-indigo-200 text-gray-700 px-3 py-1.5 rounded-full transition-colors">
331
+ ${ q.label }
332
+ </button>
333
+ </div>
334
+ <div class="flex gap-3">
335
+ <textarea v-model="agentInput" @keydown.enter.exact.prevent="agentSend" @keydown.enter.shift.exact.stop
336
+ class="flex-1 border border-gray-200 rounded-xl px-3 py-2 text-sm outline-none focus:ring-4 focus:ring-indigo-100 focus:border-indigo-300 resize-none"
337
+ rows="2" placeholder="输入你的问题(例如:如何查询订单物流?)"></textarea>
338
+ <button @click="agentSend" :disabled="agentSending || !agentInput.trim()"
339
+ class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-xl text-sm font-medium transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
340
+ <i :class="['fa-solid', agentSending ? 'fa-spinner fa-spin' : 'fa-paper-plane']"></i>
341
+ ${ agentSending ? '发送中' : '发送' }
342
+ </button>
343
+ </div>
344
+ <div class="mt-2 text-xs text-gray-400">
345
+ 提示:涉及隐私信息时,仅需提供必要字段(如订单号/手机号后 4 位)。
346
+ </div>
347
+ </div>
348
+ </div>
349
+ </div>
350
+ </div>
351
+ </div>
352
  </div>
353
 
354
  <script>
 
364
  const newTicketsCount = ref(0);
365
  const fileInput = ref(null);
366
  const uploadStatus = ref('');
367
+
368
+ const agentOpen = ref(false);
369
+ const agentHealth = ref({ ok: false, hasKey: false, model: '', baseUrl: '' });
370
+ const agentConversations = ref([]);
371
+ const agentConversationId = ref('');
372
+ const agentMessages = ref([]);
373
+ const agentInput = ref('');
374
+ const agentSending = ref(false);
375
+ const agentTitle = ref('新会话');
376
+ const agentMeta = ref('');
377
+ const agentUsageText = ref('');
378
+ const agentRated = ref({});
379
+ const agentChatBox = ref(null);
380
+ const agentQuickPrompts = ref([
381
+ { label: '查询物流', template: '我想查询物流,订单号后4位是____,手机号后4位是____。' },
382
+ { label: '退款/退货', template: '我想申请退款/退货,订单号后4位是____,原因是____,是否需要提供照片?' },
383
+ { label: '开票', template: '我需要开具电子发票:抬头类型____,抬头名称____,税号____,邮箱____。' },
384
+ { label: '登录问题', template: '我登录失败,手机号后4位是____,提示错误是____。' }
385
+ ]);
386
 
387
  let sentimentChart = null;
388
  let priorityChart = null;
 
493
  return t.split(' ')[1]; // HH:MM:SS
494
  };
495
 
496
+ const formatAgentTime = (iso) => {
497
+ if (!iso) return '';
498
+ const d = new Date(iso);
499
+ if (Number.isNaN(d.getTime())) return '';
500
+ const y = d.getFullYear();
501
+ const m = String(d.getMonth() + 1).padStart(2, '0');
502
+ const dd = String(d.getDate()).padStart(2, '0');
503
+ const hh = String(d.getHours()).padStart(2, '0');
504
+ const mm = String(d.getMinutes()).padStart(2, '0');
505
+ return `${y}-${m}-${dd} ${hh}:${mm}`;
506
+ };
507
+
508
+ const agentScrollToBottom = async () => {
509
+ await nextTick();
510
+ if (agentChatBox.value) {
511
+ agentChatBox.value.scrollTop = agentChatBox.value.scrollHeight;
512
+ }
513
+ };
514
+
515
+ const fetchAgentHealth = async () => {
516
+ try {
517
+ const res = await fetch('/api/health');
518
+ const data = await res.json();
519
+ agentHealth.value = data;
520
+ } catch {
521
+ agentHealth.value = { ok: false, hasKey: false, model: '', baseUrl: '' };
522
+ }
523
+ };
524
+
525
+ const fetchAgentConversations = async () => {
526
+ try {
527
+ const res = await fetch('/api/conversations');
528
+ const data = await res.json();
529
+ agentConversations.value = data.items || [];
530
+ } catch {
531
+ agentConversations.value = [];
532
+ }
533
+ };
534
+
535
+ const agentNewConversation = async () => {
536
+ agentConversationId.value = '';
537
+ agentTitle.value = '新会话';
538
+ agentMeta.value = '';
539
+ agentUsageText.value = '';
540
+ agentRated.value = {};
541
+ agentMessages.value = [{
542
+ id: 'welcome',
543
+ role: 'assistant',
544
+ content: '你好,我是智能客服 Agent。\\n\\n你可以直接描述问题,或点击下方快捷意图。\\n\\n为了保护隐私,请仅提供必要字段(如订单号/手机号后 4 位)。',
545
+ createdAt: new Date().toISOString()
546
+ }];
547
+ await agentScrollToBottom();
548
+ };
549
+
550
+ const agentOpenConversation = async (id) => {
551
+ try {
552
+ const res = await fetch(`/api/conversations/${encodeURIComponent(id)}`);
553
+ if (!res.ok) return;
554
+ const data = await res.json();
555
+ const conv = data.conversation;
556
+ agentConversationId.value = conv.id;
557
+ agentTitle.value = conv.title || '新会话';
558
+ agentMeta.value = conv.updatedAt ? `更新:${formatAgentTime(conv.updatedAt)}` : '';
559
+ agentUsageText.value = '';
560
+ agentRated.value = {};
561
+ agentMessages.value = (conv.messages || []).map(m => ({
562
+ id: m.id,
563
+ role: m.role,
564
+ content: m.content,
565
+ createdAt: m.createdAt,
566
+ usage: m.usage
567
+ }));
568
+ await agentScrollToBottom();
569
+ } finally {
570
+ await fetchAgentConversations();
571
+ }
572
+ };
573
+
574
+ const agentExport = () => {
575
+ if (!agentConversationId.value) return;
576
+ const a = document.createElement('a');
577
+ a.href = `/api/export/${encodeURIComponent(agentConversationId.value)}`;
578
+ a.download = `conversation-${agentConversationId.value}.json`;
579
+ document.body.appendChild(a);
580
+ a.click();
581
+ a.remove();
582
+ };
583
+
584
+ const agentFill = (t) => {
585
+ agentInput.value = t;
586
+ };
587
+
588
+ const agentRate = async (messageId, rating) => {
589
+ if (!agentConversationId.value) return;
590
+ if (agentRated.value[messageId]) return;
591
+ agentRated.value = { ...agentRated.value, [messageId]: rating };
592
+ const note = window.prompt('可选:补充一句反馈(可留空)', '') ?? '';
593
+ try {
594
+ await fetch('/api/feedback', {
595
+ method: 'POST',
596
+ headers: { 'Content-Type': 'application/json' },
597
+ body: JSON.stringify({ conversationId: agentConversationId.value, messageId, rating, note })
598
+ });
599
+ } catch {}
600
+ };
601
+
602
+ const agentSend = async () => {
603
+ const text = (agentInput.value || '').trim();
604
+ if (!text) return;
605
+ agentInput.value = '';
606
+ agentUsageText.value = '';
607
+ agentMessages.value = [...agentMessages.value, { id: `u-${Date.now()}`, role: 'user', content: text, createdAt: new Date().toISOString() }];
608
+ const pendingId = `p-${Date.now()}`;
609
+ agentMessages.value = [...agentMessages.value, { id: pendingId, role: 'assistant', content: '正在思考…', createdAt: new Date().toISOString() }];
610
+ await agentScrollToBottom();
611
+ agentSending.value = true;
612
+ try {
613
+ const res = await fetch('/api/chat', {
614
+ method: 'POST',
615
+ headers: { 'Content-Type': 'application/json' },
616
+ body: JSON.stringify({ conversationId: agentConversationId.value || undefined, message: text })
617
+ });
618
+ const data = await res.json();
619
+ if (!res.ok) throw new Error(data.error || '请求失败');
620
+ agentConversationId.value = data.conversationId;
621
+ agentTitle.value = data.conversation?.title || agentTitle.value;
622
+ agentMeta.value = data.conversation?.updatedAt ? `更新:${formatAgentTime(data.conversation.updatedAt)}` : '';
623
+
624
+ const usage = data.assistantMessage?.usage;
625
+ if (usage) {
626
+ agentUsageText.value = `tokens:prompt ${usage.prompt_tokens ?? '-'} / completion ${usage.completion_tokens ?? '-'} / total ${usage.total_tokens ?? '-'}`;
627
+ }
628
+
629
+ agentMessages.value = agentMessages.value.map(m => {
630
+ if (m.id !== pendingId) return m;
631
+ return {
632
+ id: data.assistantMessage.id,
633
+ role: 'assistant',
634
+ content: data.assistantMessage.content,
635
+ createdAt: data.assistantMessage.createdAt,
636
+ usage: data.assistantMessage.usage
637
+ };
638
+ });
639
+ await fetchAgentConversations();
640
+ await agentScrollToBottom();
641
+ } catch (e) {
642
+ agentMessages.value = agentMessages.value.map(m => m.id === pendingId ? { ...m, content: `请求失败:${String(e?.message || e)}` } : m);
643
+ await agentScrollToBottom();
644
+ } finally {
645
+ agentSending.value = false;
646
+ }
647
+ };
648
+
649
+ const openAgentPanel = async () => {
650
+ agentOpen.value = true;
651
+ await fetchAgentHealth();
652
+ await fetchAgentConversations();
653
+ if (!agentMessages.value.length) await agentNewConversation();
654
+ };
655
+
656
+ const closeAgentPanel = () => {
657
+ agentOpen.value = false;
658
+ };
659
+
660
  // Charts
661
  const initCharts = () => {
662
  const chartDom1 = document.getElementById('sentimentChart');
 
741
 
742
  return {
743
  tickets, stats, isSimulating, currentTime, activeAgents, newTicketsCount, fileInput, uploadStatus,
744
+ simulateTraffic, resolveTicket, getPriorityClass, formatTime, triggerUpload, handleFileUpload,
745
+ agentOpen, agentHealth, agentConversations, agentConversationId, agentMessages, agentInput, agentSending,
746
+ agentTitle, agentMeta, agentUsageText, agentRated, agentChatBox, agentQuickPrompts,
747
+ openAgentPanel, closeAgentPanel, agentNewConversation, agentOpenConversation, agentExport, agentSend, agentFill, agentRate, formatAgentTime
748
  };
749
  }
750
  }).mount('#app');