Files
tw/scripts/chat_ui.py
ZuoWei a6c42d505a feat: 完整功能部署 v1.0
新增功能:
- 天网协作系统 (HTTP API 端口 6060)
- 三种工作流 (查找图片/处理图片/转人工派单)
- 图片任务数据库 (支持客户后续增加需求)
- 图绘派单系统集成 (API: 8005)
- 文字检测与加价 (60-80 元高价值订单)
- 风险评估与接单判断
- 作图失败自动转人工

新增文档:
- 项目功能汇总.md
- 三种工作流功能说明.md
- 文字加价功能说明.md
- 风险评估功能说明.md
- 图片任务数据库功能说明.md
- 图绘派单系统集成说明.md
- 作图失败转接人工说明.md
- DEPLOYMENT.md
- TIANWANG_INTEGRATION.md

核心修改:
- core/pydantic_ai_agent.py
- core/workflow.py
- core/websocket_client.py
- image/image_analyzer.py
- services/service_tuhui_dispatch.py
- db/image_tasks_db.py

版本:v1.0
日期:2026-02-28
2026-02-28 11:20:40 +08:00

521 lines
18 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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,'&lt;').replace(/>/g,'&gt;');
return esc;
});
return segs.join('<br>');
}
const escaped = msg.replace(/</g,'&lt;').replace(/>/g,'&gt;');
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,'&lt;').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)})