521 lines
18 KiB
Python
521 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
聊天记录 Web UI
|
||
运行: python scripts/chat_ui.py
|
||
访问: http://localhost:5678
|
||
"""
|
||
import sys
|
||
from pathlib import Path
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||
|
||
from flask import Flask, jsonify, render_template_string, request
|
||
import asyncio
|
||
from core.pydantic_ai_agent import CustomerServiceAgent, AgentDeps
|
||
from db import chat_log_db as db
|
||
|
||
app = Flask(__name__)
|
||
pricing_agent = None
|
||
try:
|
||
pricing_agent = CustomerServiceAgent()
|
||
except Exception as e:
|
||
print(f"[ChatUI] 初始化报价Agent失败: {e}")
|
||
|
||
HTML = r"""
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>聊天记录</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
|
||
|
||
/* ── 顶栏 ── */
|
||
.topbar {
|
||
background: #16213e; border-bottom: 1px solid #0f3460;
|
||
padding: 12px 20px; display: flex; align-items: center; gap: 16px; flex-shrink: 0;
|
||
}
|
||
.topbar h1 { font-size: 16px; color: #4cc9f0; font-weight: 600; letter-spacing: 1px; }
|
||
.search-box {
|
||
flex: 1; max-width: 320px;
|
||
background: #0f3460; border: 1px solid #1a5276;
|
||
border-radius: 20px; padding: 6px 14px;
|
||
color: #e0e0e0; font-size: 13px; outline: none;
|
||
}
|
||
.search-box::placeholder { color: #6b7a99; }
|
||
.live-badge {
|
||
margin-left: auto; font-size: 11px; background: #0d7377;
|
||
color: #14ffec; padding: 3px 10px; border-radius: 10px;
|
||
}
|
||
|
||
/* ── 主体 ── */
|
||
.main { display: flex; flex: 1; overflow: hidden; }
|
||
|
||
/* ── 左侧客户列表 ── */
|
||
.sidebar {
|
||
width: 280px; background: #16213e;
|
||
border-right: 1px solid #0f3460;
|
||
display: flex; flex-direction: column; flex-shrink: 0;
|
||
}
|
||
.sidebar-header {
|
||
padding: 10px 14px; font-size: 12px; color: #6b7a99;
|
||
border-bottom: 1px solid #0f3460; flex-shrink: 0;
|
||
display: flex; justify-content: space-between;
|
||
}
|
||
.customer-list { overflow-y: auto; flex: 1; }
|
||
.customer-item {
|
||
padding: 12px 14px; cursor: pointer; border-bottom: 1px solid #0f3460;
|
||
transition: background .15s; position: relative;
|
||
}
|
||
.customer-item:hover { background: #1e3a5f; }
|
||
.customer-item.active { background: #0f3460; border-left: 3px solid #4cc9f0; }
|
||
.customer-item .name { font-size: 13px; font-weight: 500; color: #cce; }
|
||
.customer-item .cid { font-size: 11px; color: #6b7a99; margin-top: 2px; }
|
||
.customer-item .meta { font-size: 11px; color: #8899aa; margin-top: 4px;
|
||
display: flex; justify-content: space-between; }
|
||
.badge-plat {
|
||
font-size: 10px; padding: 1px 6px; border-radius: 8px;
|
||
background: #1a3a5c; color: #4cc9f0;
|
||
}
|
||
.badge-plat.ali { background: #3d1a00; color: #ff9f43; }
|
||
.badge-plat.email { background: #0a2e1a; color: #55efc4; }
|
||
|
||
/* ── 右侧对话区 ── */
|
||
.chat-panel {
|
||
flex: 1; display: flex; flex-direction: column; overflow: hidden;
|
||
}
|
||
.chat-header {
|
||
padding: 12px 20px; background: #16213e;
|
||
border-bottom: 1px solid #0f3460; flex-shrink: 0;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.chat-header .cname { font-size: 15px; font-weight: 600; color: #e0e0e0; }
|
||
.chat-header .cid { font-size: 12px; color: #6b7a99; }
|
||
.chat-header .stats { margin-left: auto; font-size: 12px; color: #6b7a99; }
|
||
|
||
.chat-messages {
|
||
flex: 1; overflow-y: auto; padding: 20px;
|
||
display: flex; flex-direction: column; gap: 12px;
|
||
}
|
||
.day-divider {
|
||
text-align: center; font-size: 11px; color: #6b7a99;
|
||
position: relative; margin: 8px 0;
|
||
}
|
||
.day-divider::before, .day-divider::after {
|
||
content: ""; position: absolute; top: 50%;
|
||
width: 38%; height: 1px; background: #0f3460;
|
||
}
|
||
.day-divider::before { left: 0; }
|
||
.day-divider::after { right: 0; }
|
||
|
||
/* 消息气泡 */
|
||
.msg-row { display: flex; align-items: flex-end; gap: 8px; max-width: 72%; }
|
||
.msg-row.in { align-self: flex-start; }
|
||
.msg-row.out { align-self: flex-end; flex-direction: row-reverse; }
|
||
|
||
.avatar {
|
||
width: 34px; height: 34px; border-radius: 50%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 13px; font-weight: 600; flex-shrink: 0;
|
||
}
|
||
.avatar.buyer { background: #2d4a7a; color: #90caf9; }
|
||
.avatar.seller { background: #1a6644; color: #a8e6cf; }
|
||
|
||
.bubble-wrap { display: flex; flex-direction: column; gap: 3px; }
|
||
.msg-row.out .bubble-wrap { align-items: flex-end; }
|
||
|
||
.bubble {
|
||
padding: 9px 13px; border-radius: 16px;
|
||
font-size: 13px; line-height: 1.55; word-break: break-word;
|
||
max-width: 480px;
|
||
}
|
||
.bubble.in { background: #1e3a5f; color: #dce8f8; border-bottom-left-radius: 4px; }
|
||
.bubble.out { background: #1a6644; color: #d4f5e7; border-bottom-right-radius: 4px; }
|
||
.bubble img { max-width: 200px; border-radius: 8px; display: block; margin-top: 4px; }
|
||
|
||
.msg-time { font-size: 10px; color: #6b7a99; padding: 0 4px; }
|
||
|
||
/* 空状态 */
|
||
.empty-state {
|
||
flex: 1; display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center; color: #6b7a99; gap: 10px;
|
||
}
|
||
.empty-state .icon { font-size: 48px; opacity: .3; }
|
||
.empty-state p { font-size: 14px; }
|
||
|
||
/* 搜索结果覆盖层 */
|
||
#search-overlay {
|
||
display: none; position: absolute; top: 52px; left: 0; right: 0; bottom: 0;
|
||
background: #1a1a2e; z-index: 10; overflow-y: auto; padding: 16px 20px;
|
||
}
|
||
.search-hit {
|
||
padding: 10px 14px; margin-bottom: 8px;
|
||
background: #16213e; border-radius: 10px; cursor: pointer;
|
||
border-left: 3px solid #4cc9f0;
|
||
}
|
||
.search-hit:hover { background: #1e3a5f; }
|
||
.search-hit .hit-cid { font-size: 11px; color: #4cc9f0; }
|
||
.search-hit .hit-msg { font-size: 13px; color: #e0e0e0; margin-top: 4px; }
|
||
.search-hit .hit-time { font-size: 11px; color: #6b7a99; margin-top: 3px; }
|
||
mark { background: transparent; color: #f9ca24; font-weight: 600; }
|
||
|
||
::-webkit-scrollbar { width: 4px; }
|
||
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 2px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="topbar">
|
||
<h1>💬 聊天记录</h1>
|
||
<input id="searchInput" class="search-box" placeholder="搜索消息内容..." autocomplete="off">
|
||
<span class="live-badge" id="liveBadge">● 实时</span>
|
||
</div>
|
||
|
||
<div class="main" style="position:relative;">
|
||
<!-- 搜索覆盖层 -->
|
||
<div id="search-overlay"></div>
|
||
|
||
<!-- 左侧客户列表 -->
|
||
<div class="sidebar">
|
||
<div class="sidebar-header">
|
||
<span id="customerCount">客户</span>
|
||
<span id="lastRefresh"></span>
|
||
</div>
|
||
<div class="customer-list" id="customerList"></div>
|
||
</div>
|
||
|
||
<!-- 右侧对话 -->
|
||
<div class="chat-panel" id="chatPanel">
|
||
<div class="empty-state" id="emptyState">
|
||
<div class="icon">💬</div>
|
||
<p>选择一位客户查看对话记录</p>
|
||
</div>
|
||
<div id="chatHeader" class="chat-header" style="display:none;">
|
||
<div>
|
||
<div class="cname" id="headerName"></div>
|
||
<div class="cid" id="headerId"></div>
|
||
</div>
|
||
<div class="stats" id="headerStats"></div>
|
||
</div>
|
||
<div class="chat-messages" id="chatMessages" style="display:none;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentCid = null;
|
||
let autoRefresh = null;
|
||
let allCustomers = [];
|
||
|
||
// ── 时间格式化 ──
|
||
function fmtTime(ts) {
|
||
if (!ts) return '';
|
||
const today = new Date().toISOString().slice(0,10);
|
||
return ts.startsWith(today) ? ts.slice(11,16) : ts.slice(5,16);
|
||
}
|
||
|
||
// ── 平台徽章 ──
|
||
function platBadge(p) {
|
||
const map = {
|
||
AliWorkbench: ['ali','淘宝'],
|
||
taobao: ['ali','淘宝'],
|
||
pinduoduo: ['','拼多多'],
|
||
jd: ['','京东'],
|
||
email: ['email','邮件'],
|
||
};
|
||
const [cls, label] = map[p] || ['', p || ''];
|
||
return label ? `<span class="badge-plat ${cls}">${label}</span>` : '';
|
||
}
|
||
|
||
// ── 加载客户列表 ──
|
||
async function loadCustomers() {
|
||
const r = await fetch('/api/customers');
|
||
allCustomers = await r.json();
|
||
renderCustomers(allCustomers);
|
||
document.getElementById('customerCount').textContent = `客户 ${allCustomers.length} 人`;
|
||
document.getElementById('lastRefresh').textContent = new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
|
||
}
|
||
|
||
function renderCustomers(list) {
|
||
const el = document.getElementById('customerList');
|
||
el.innerHTML = list.map(c => {
|
||
const active = c.customer_id === currentCid ? 'active' : '';
|
||
const name = c.customer_name || c.customer_id.slice(-8);
|
||
return `<div class="customer-item ${active}" onclick="openChat('${c.customer_id}','${(c.customer_name||'').replace(/'/g,"\\'")}','${c.platform||''}',${c.total_msgs},${c.recv},${c.sent})">
|
||
<div class="name">${name} ${platBadge(c.platform)}</div>
|
||
<div class="cid">${c.customer_id}</div>
|
||
<div class="meta"><span>${c.total_msgs} 条消息</span><span>${fmtTime(c.last_time)}</span></div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── 打开对话 ──
|
||
async function openChat(cid, name, platform, total, recv, sent) {
|
||
currentCid = cid;
|
||
renderCustomers(allCustomers);
|
||
|
||
document.getElementById('emptyState').style.display = 'none';
|
||
document.getElementById('chatHeader').style.display = 'flex';
|
||
document.getElementById('chatMessages').style.display = 'flex';
|
||
document.getElementById('headerName').textContent = name || cid;
|
||
document.getElementById('headerId').textContent = cid;
|
||
document.getElementById('headerStats').textContent = `共 ${total} 条 收 ${recv} 发 ${sent}`;
|
||
|
||
await loadConversation(cid);
|
||
if (autoRefresh) clearInterval(autoRefresh);
|
||
autoRefresh = setInterval(() => loadConversation(cid), 4000);
|
||
}
|
||
|
||
// ── 加载对话 ──
|
||
async function loadConversation(cid) {
|
||
const r = await fetch(`/api/conversation/${encodeURIComponent(cid)}`);
|
||
const msgs = await r.json();
|
||
renderMessages(msgs);
|
||
}
|
||
|
||
function renderMessages(msgs) {
|
||
const el = document.getElementById('chatMessages');
|
||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
|
||
|
||
let lastDate = '';
|
||
const html = msgs.map(m => {
|
||
const date = (m.timestamp || '').slice(0,10);
|
||
let divider = '';
|
||
if (date && date !== lastDate) { divider = `<div class="day-divider">${date}</div>`; lastDate = date; }
|
||
|
||
const dir = m.direction;
|
||
const avatarChar = dir === 'in' ? '买' : '客';
|
||
const avatarCls = dir === 'in' ? 'buyer' : 'seller';
|
||
const content = renderMsgContent(m.message, m.msg_type);
|
||
|
||
return `${divider}
|
||
<div class="msg-row ${dir}">
|
||
<div class="avatar ${avatarCls}">${avatarChar}</div>
|
||
<div class="bubble-wrap">
|
||
<div class="bubble ${dir}">${content}</div>
|
||
<div class="msg-time">${fmtTime(m.timestamp)}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
el.innerHTML = html;
|
||
if (atBottom) el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
function renderMsgContent(msg, msgType) {
|
||
if (!msg) return '';
|
||
const urlRegGlobal = /(https?:\/\/[^\s]+?\.(jpg|jpeg|png|gif|webp)(\?[^\s]*)?)/gi;
|
||
const urlRegSingle = /(https?:\/\/[^\s]+?\.(jpg|jpeg|png|gif|webp)(\?[^\s]*)?)/i;
|
||
const parts = msg.split('#*#').map(s => s.trim()).filter(Boolean);
|
||
if (parts.length > 1) {
|
||
const segs = parts.map(p => {
|
||
const m = p.match(urlRegSingle);
|
||
if (m) {
|
||
const url = m[0];
|
||
return `<a href="${url}" target="_blank"><img src="${url}" onerror="this.style.display='none'"></a>`;
|
||
}
|
||
const esc = p.replace(/</g,'<').replace(/>/g,'>');
|
||
return esc;
|
||
});
|
||
return segs.join('<br>');
|
||
}
|
||
const escaped = msg.replace(/</g,'<').replace(/>/g,'>');
|
||
return escaped.replace(urlRegGlobal, (url) =>
|
||
`<a href="${url}" target="_blank"><img src="${url}" onerror="this.style.display='none'"></a>`
|
||
);
|
||
}
|
||
|
||
// ── 搜索 ──
|
||
let searchTimer = null;
|
||
document.getElementById('searchInput').addEventListener('input', function() {
|
||
clearTimeout(searchTimer);
|
||
const kw = this.value.trim();
|
||
if (!kw) { closeSearch(); return; }
|
||
searchTimer = setTimeout(() => doSearch(kw), 300);
|
||
});
|
||
|
||
async function doSearch(kw) {
|
||
const r = await fetch(`/api/search?q=${encodeURIComponent(kw)}`);
|
||
const results = await r.json();
|
||
const overlay = document.getElementById('search-overlay');
|
||
overlay.style.display = 'block';
|
||
|
||
if (!results.length) {
|
||
overlay.innerHTML = `<p style="color:#6b7a99;text-align:center;margin-top:60px;">未找到匹配消息</p>`;
|
||
return;
|
||
}
|
||
const hi = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const re = new RegExp(hi, 'gi');
|
||
overlay.innerHTML = results.map(r => {
|
||
const dir = r.direction === 'in' ? '买家' : '客服';
|
||
const msg = r.message.replace(/</g,'<').replace(re, m => `<mark>${m}</mark>`);
|
||
return `<div class="search-hit" onclick="closeSearch(); openChat('${r.customer_id}','','','','','')">
|
||
<div class="hit-cid">${r.customer_id} ${r.customer_name||''} · ${dir}</div>
|
||
<div class="hit-msg">${msg}</div>
|
||
<div class="hit-time">${(r.timestamp||'').slice(0,16)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function closeSearch() {
|
||
document.getElementById('search-overlay').style.display = 'none';
|
||
document.getElementById('searchInput').value = '';
|
||
}
|
||
|
||
// ── 初始化 ──
|
||
loadCustomers();
|
||
setInterval(loadCustomers, 10000);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
PRICING_HTML = r"""
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>AI 报价测试</title>
|
||
<style>
|
||
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
background: #1a1a2e; color: #e0e0e0; padding: 20px; }
|
||
.card { background:#16213e; border:1px solid #0f3460; border-radius:12px; padding:16px; max-width:880px; margin:0 auto; }
|
||
.title { font-size:16px; color:#4cc9f0; margin-bottom:12px; }
|
||
.row { display:flex; gap:12px; margin-bottom:10px; }
|
||
.row .col { flex:1; }
|
||
.input { width:100%; background:#0f3460; border:1px solid #1a5276; border-radius:10px; padding:10px 12px; color:#e0e0e0; font-size:13px; outline:none; }
|
||
.input::placeholder { color:#6b7a99; }
|
||
.btn { background:#0d7377; color:#14ffec; border:none; border-radius:10px; padding:10px 16px; cursor:pointer; font-size:13px; }
|
||
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
||
.result { margin-top:14px; background:#0f3460; border:1px solid #1a5276; border-radius:10px; padding:12px; font-size:13px; white-space:pre-wrap; }
|
||
.tip { font-size:12px; color:#6b7a99; margin-top:6px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<div class="title">🧪 AI 报价测试</div>
|
||
<div class="row">
|
||
<div class="col">
|
||
<input id="cid" class="input" placeholder="客户ID,如 tb7518056865:小林">
|
||
</div>
|
||
<div class="col">
|
||
<input id="acc" class="input" placeholder="店铺ID(可留空)">
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="col">
|
||
<textarea id="msg" class="input" rows="4" placeholder="输入消息文本或图片URL(多张用 #*# 分隔)。示例:这两张有原图吗#*#https://...jpg#*#https://...png"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<button class="btn" id="runBtn" onclick="runPricing()">测试报价</button>
|
||
</div>
|
||
<div id="result" class="result" style="display:none;"></div>
|
||
<div class="tip">提示:含图片URL时,Agent会自动调用图片分析并结合复杂度、尺寸、人脸与风险给出建议价;文本砍价低于最近图片底线会被礼貌拒绝。</div>
|
||
</div>
|
||
|
||
<script>
|
||
async function runPricing() {
|
||
const cid = document.getElementById('cid').value.trim();
|
||
const acc = document.getElementById('acc').value.trim();
|
||
const msg = document.getElementById('msg').value.trim();
|
||
const btn = document.getElementById('runBtn');
|
||
const res = document.getElementById('result');
|
||
if (!cid || !msg) { alert('请填写客户ID与消息'); return; }
|
||
btn.disabled = true; res.style.display = 'none'; res.textContent = '';
|
||
try {
|
||
const r = await fetch('/api/pricing/run', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ from_id: cid, acc_id: acc, msg })
|
||
});
|
||
const data = await r.json();
|
||
res.style.display = 'block';
|
||
res.textContent = data.error ? ('错误:'+data.error) : (
|
||
`回复:${data.reply}\n\n【调试】目标Agent:${data.agent}\n最低价:${data.floor}\n应答:${data.should_reply?'是':'否'}`
|
||
);
|
||
} catch(e) {
|
||
res.style.display = 'block';
|
||
res.textContent = '请求失败:'+e;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
@app.route("/")
|
||
def index():
|
||
return render_template_string(HTML)
|
||
|
||
@app.route("/pricing")
|
||
def pricing_index():
|
||
return render_template_string(PRICING_HTML)
|
||
|
||
|
||
@app.route("/api/customers")
|
||
def api_customers():
|
||
return jsonify(db.get_customers(limit=200))
|
||
|
||
|
||
@app.route("/api/conversation/<customer_id>")
|
||
def api_conversation(customer_id):
|
||
return jsonify(db.get_conversation(customer_id, limit=500))
|
||
|
||
|
||
@app.route("/api/search")
|
||
def api_search():
|
||
kw = request.args.get("q", "").strip()
|
||
if not kw:
|
||
return jsonify([])
|
||
return jsonify(db.search_messages(kw, limit=60))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
print("聊天记录 UI 启动中...")
|
||
print("访问 → http://localhost:5678")
|
||
app.run(host="0.0.0.0", port=5678, debug=False)
|
||
|
||
@app.route("/api/pricing/run", methods=["POST"])
|
||
def api_pricing_run():
|
||
global pricing_agent
|
||
if pricing_agent is None:
|
||
return jsonify({"error":"报价Agent未初始化"})
|
||
data = request.get_json(force=True) or {}
|
||
from_id = (data.get("from_id") or "").strip()
|
||
acc_id = (data.get("acc_id") or "").strip()
|
||
msg = (data.get("msg") or "").strip()
|
||
if not from_id or not msg:
|
||
return jsonify({"error":"缺少参数 from_id 或 msg"})
|
||
# 构造提示词:直接使用用户输入,保持与正式场景一致
|
||
user_prompt = msg
|
||
deps = AgentDeps(
|
||
msg_id="pricing-test",
|
||
acc_id=acc_id or "TEST_SHOP",
|
||
from_id=from_id,
|
||
platform="taobao"
|
||
)
|
||
try:
|
||
# 强制使用报价Agent
|
||
result = asyncio.run(pricing_agent.agent_pricing.run(user_prompt, deps=deps, message_history=[]))
|
||
# 读取底线
|
||
try:
|
||
from config.config import MIN_PRICE_FLOOR
|
||
st = pricing_agent._get_conversation_state(from_id)
|
||
floor = st.last_min_price if isinstance(st.last_min_price,int) and st.last_min_price>0 else MIN_PRICE_FLOOR
|
||
except Exception:
|
||
floor = None
|
||
return jsonify({
|
||
"reply": result.output,
|
||
"should_reply": True,
|
||
"agent": "pricing",
|
||
"floor": floor
|
||
})
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)})
|