refactor: migrate workflow to v2 core and archive legacy modules
This commit is contained in:
362
legacy/scripts/chat_log_viewer.py
Normal file
362
legacy/scripts/chat_log_viewer.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
聊天记录查看器
|
||||
用法:
|
||||
python scripts/chat_log_viewer.py # 列出所有客户
|
||||
python scripts/chat_log_viewer.py <客户ID> # 查看某客户全部对话
|
||||
python scripts/chat_log_viewer.py -s <关键词> # 全局搜索
|
||||
python scripts/chat_log_viewer.py -t <客户ID> # 只看今天
|
||||
python scripts/chat_log_viewer.py -l # 实时监听最新消息(10条/刷新)
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import time
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# 强制 UTF-8 输出(Windows 终端需要)
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||
|
||||
from db import chat_log_db as db
|
||||
|
||||
# ========== ANSI 颜色 ==========
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), 7)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
GREEN = "\033[32m"
|
||||
CYAN = "\033[36m"
|
||||
YELLOW = "\033[33m"
|
||||
BLUE = "\033[34m"
|
||||
MAGENTA= "\033[35m"
|
||||
RED = "\033[31m"
|
||||
WHITE = "\033[97m"
|
||||
BG_DARK= "\033[48;5;236m"
|
||||
|
||||
|
||||
def clear():
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
|
||||
|
||||
def header(text: str):
|
||||
width = 60
|
||||
print(f"\n{BOLD}{CYAN}{'─' * width}{RESET}")
|
||||
print(f"{BOLD}{CYAN} {text}{RESET}")
|
||||
print(f"{BOLD}{CYAN}{'─' * width}{RESET}\n")
|
||||
|
||||
|
||||
def fmt_time(ts: str) -> str:
|
||||
"""缩短时间戳显示"""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
if ts.startswith(today):
|
||||
return ts[11:16] # 只显示 HH:MM
|
||||
return ts[:16]
|
||||
|
||||
|
||||
def platform_badge(platform: str) -> str:
|
||||
badges = {
|
||||
"AliWorkbench": f"{YELLOW}[淘宝]{RESET}",
|
||||
"taobao": f"{YELLOW}[淘宝]{RESET}",
|
||||
"pinduoduo": f"{RED}[拼多多]{RESET}",
|
||||
"jd": f"{RED}[京东]{RESET}",
|
||||
"wechat": f"{GREEN}[微信]{RESET}",
|
||||
"email": f"{BLUE}[邮件]{RESET}",
|
||||
}
|
||||
return badges.get(platform, f"{DIM}[{platform}]{RESET}" if platform else "")
|
||||
|
||||
|
||||
def print_bubble(direction: str, message: str, ts: str):
|
||||
"""打印聊天气泡"""
|
||||
time_str = fmt_time(ts)
|
||||
lines = []
|
||||
if "#*#" in (message or ""):
|
||||
parts = [p.strip() for p in message.split("#*#") if p.strip()]
|
||||
if parts:
|
||||
lines = parts
|
||||
if not lines:
|
||||
lines = (message or "").split("\n")
|
||||
|
||||
if direction == "in": # 客户来消息 → 左对齐
|
||||
print(f" {DIM}{time_str}{RESET} {WHITE}买家{RESET}")
|
||||
for line in lines:
|
||||
print(f" {BG_DARK} {line} {RESET}")
|
||||
else: # 客服回复 → 右对齐(缩进)
|
||||
print(f" {DIM}{time_str}{RESET} {GREEN}客服{RESET}")
|
||||
for line in lines:
|
||||
print(f" {GREEN}> {line}{RESET}")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_list_customers():
|
||||
"""列出所有客户"""
|
||||
customers = db.get_customers(limit=100)
|
||||
if not customers:
|
||||
print(f"{YELLOW}暂无聊天记录。{RESET}")
|
||||
return
|
||||
|
||||
header(f"客户列表 共 {len(customers)} 人")
|
||||
print(f" {'#':<4} {'客户ID':<24} {'姓名':<12} {'平台':<10} {'消息数':>6} {'最后活跃'}")
|
||||
print(f" {'─'*4} {'─'*24} {'─'*12} {'─'*10} {'─'*6} {'─'*16}")
|
||||
for i, c in enumerate(customers, 1):
|
||||
badge = platform_badge(c.get("platform", ""))
|
||||
name = (c.get("customer_name") or "")[:10]
|
||||
cid = c["customer_id"]
|
||||
total = c["total_msgs"]
|
||||
last = c.get("last_time", "")[:16]
|
||||
print(f" {i:<4} {CYAN}{cid:<24}{RESET} {name:<12} {badge:<18} {total:>6}条 {DIM}{last}{RESET}")
|
||||
|
||||
print(f"\n{DIM}用法:python chat_log_viewer.py <客户ID>{RESET}\n")
|
||||
|
||||
|
||||
def cmd_show_conversation(customer_id: str, today_only: bool = False):
|
||||
"""显示某客户对话"""
|
||||
if today_only:
|
||||
messages = db.get_conversation_today(customer_id)
|
||||
title = f"今日对话 {customer_id}"
|
||||
else:
|
||||
messages = db.get_conversation(customer_id, limit=300)
|
||||
title = f"对话记录 {customer_id}"
|
||||
|
||||
if not messages:
|
||||
print(f"{YELLOW}该客户暂无记录:{customer_id}{RESET}")
|
||||
return
|
||||
|
||||
header(f"{title} ({len(messages)} 条)")
|
||||
|
||||
last_date = ""
|
||||
for m in messages:
|
||||
ts = m.get("timestamp", "")
|
||||
date = ts[:10]
|
||||
if date != last_date:
|
||||
print(f" {DIM}{'─'*20} {date} {'─'*20}{RESET}")
|
||||
last_date = date
|
||||
print_bubble(m["direction"], m["message"], ts)
|
||||
|
||||
print(f"{DIM} ── 以上共 {len(messages)} 条 ──{RESET}\n")
|
||||
|
||||
|
||||
def cmd_search(keyword: str, customer_id: str = None):
|
||||
"""搜索关键词"""
|
||||
results = db.search_messages(keyword, customer_id=customer_id, limit=50)
|
||||
title = f"搜索 [{keyword}]"
|
||||
if customer_id:
|
||||
title += f" 客户:{customer_id}"
|
||||
header(f"{title} 共 {len(results)} 条")
|
||||
|
||||
if not results:
|
||||
print(f"{YELLOW}未找到包含 [{keyword}] 的消息。{RESET}")
|
||||
return
|
||||
|
||||
last_cid = ""
|
||||
for r in results:
|
||||
cid = r["customer_id"]
|
||||
if cid != last_cid:
|
||||
print(f" {CYAN}{cid}{RESET} {r.get('customer_name','')}")
|
||||
last_cid = cid
|
||||
direction = "买家" if r["direction"] == "in" else "客服"
|
||||
color = WHITE if r["direction"] == "in" else GREEN
|
||||
# 高亮关键词
|
||||
msg = r["message"].replace(keyword, f"{RED}{BOLD}{keyword}{RESET}{color}")
|
||||
print(f" {DIM}{r['timestamp'][:16]}{RESET} {color}[{direction}] {msg}{RESET}")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_live(refresh: int = 3):
|
||||
"""实时监听最新消息"""
|
||||
header("实时消息监听 Ctrl+C 退出")
|
||||
seen_ids = set()
|
||||
|
||||
try:
|
||||
while True:
|
||||
rows = db.get_latest_messages(20)
|
||||
new_rows = [r for r in rows if r["id"] not in seen_ids]
|
||||
if new_rows:
|
||||
new_rows.reverse()
|
||||
for r in new_rows:
|
||||
seen_ids.add(r["id"])
|
||||
cid = r["customer_id"]
|
||||
name = r.get("customer_name") or ""
|
||||
label = f"{CYAN}{cid}{RESET}" + (f" {DIM}({name}){RESET}" if name else "")
|
||||
print(f"\n{label}")
|
||||
print_bubble(r["direction"], r["message"], r["timestamp"])
|
||||
else:
|
||||
print(f"\r {DIM}等待新消息... {datetime.now().strftime('%H:%M:%S')}{RESET}", end="", flush=True)
|
||||
|
||||
time.sleep(refresh)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{DIM}已退出监听。{RESET}")
|
||||
|
||||
|
||||
def _extract_urls(msg: str) -> list:
|
||||
if not msg:
|
||||
return []
|
||||
parts = [p.strip() for p in msg.split("#*#") if p.strip()]
|
||||
urls = []
|
||||
for p in parts:
|
||||
if p.startswith("http://") or p.startswith("https://"):
|
||||
urls.append(p)
|
||||
if not urls and ("http://" in msg or "https://" in msg):
|
||||
import re as _re
|
||||
tokens = _re.findall(r'(https?://\S+)', msg)
|
||||
for t in tokens:
|
||||
tl = t.lower()
|
||||
if any(ext in tl for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]):
|
||||
urls.append(t)
|
||||
return urls
|
||||
|
||||
|
||||
def _msg_refers_images(msg: str) -> bool:
|
||||
if not msg:
|
||||
return False
|
||||
refs = ("图一", "图二", "第一张", "第二张", "这张", "那张", "上面那张", "下面那张", "刚才那张", "上一张", "下一张")
|
||||
return any(r in msg for r in refs)
|
||||
|
||||
|
||||
def _parse_ts(ts: str):
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
return _dt.fromisoformat(ts.replace("Z",""))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def analyze_conversation(messages: list) -> list:
|
||||
issues = []
|
||||
n = len(messages)
|
||||
for i, m in enumerate(messages):
|
||||
msg = m.get("message") or ""
|
||||
dir = m.get("direction")
|
||||
ts = _parse_ts(m.get("timestamp",""))
|
||||
# 图片后未及时回复
|
||||
if dir == "in" and _extract_urls(msg):
|
||||
replied = False
|
||||
delay_ok = True
|
||||
for j in range(i+1, min(i+6, n)):
|
||||
mj = messages[j]
|
||||
if mj.get("direction") == "out":
|
||||
replied = True
|
||||
tsj = _parse_ts(mj.get("timestamp",""))
|
||||
if ts and tsj and (tsj - ts).total_seconds() > 180:
|
||||
delay_ok = False
|
||||
break
|
||||
if not replied:
|
||||
issues.append("图片消息后未回复")
|
||||
elif not delay_ok:
|
||||
issues.append("图片消息后回复延迟超过3分钟")
|
||||
# 引用图片但找不到历史图片
|
||||
if dir == "in" and _msg_refers_images(msg):
|
||||
has_prev_img = False
|
||||
for k in range(max(0, i-10), i):
|
||||
if messages[k].get("direction") == "in" and _extract_urls(messages[k].get("message","")):
|
||||
has_prev_img = True
|
||||
break
|
||||
if not has_prev_img:
|
||||
issues.append("引用图片但历史中未找到对应图片")
|
||||
# 订单后未确认/引导
|
||||
if dir == "in" and ("买家已付款" in msg or "[系统订单信息]" in msg):
|
||||
confirmed = False
|
||||
for j in range(i+1, min(i+6, n)):
|
||||
if messages[j].get("direction") == "out":
|
||||
confirmed = True
|
||||
break
|
||||
if not confirmed:
|
||||
issues.append("订单消息后未进行确认或引导付款")
|
||||
# 合成需求未报价格
|
||||
if dir == "in" and any(k in msg for k in ("抓到", "放到", "合成", "融合", "嵌到", "替换", "P到", "抠出来放到")):
|
||||
priced = False
|
||||
for j in range(i+1, min(i+6, n)):
|
||||
mj = messages[j]
|
||||
if mj.get("direction") == "out":
|
||||
rm = mj.get("message","")
|
||||
if "元" in rm:
|
||||
priced = True
|
||||
break
|
||||
if not priced:
|
||||
issues.append("客户提出合成需求但未给出价格")
|
||||
# 去重
|
||||
dedup = []
|
||||
seen = set()
|
||||
for it in issues:
|
||||
if it not in seen:
|
||||
seen.add(it)
|
||||
dedup.append(it)
|
||||
return dedup
|
||||
|
||||
|
||||
def cmd_analyze_all():
|
||||
customers = db.get_customers(limit=200)
|
||||
if not customers:
|
||||
print(f"{YELLOW}暂无聊天记录。{RESET}")
|
||||
return
|
||||
header("聊天记录上下文分析")
|
||||
total_issues = 0
|
||||
for c in customers:
|
||||
cid = c["customer_id"]
|
||||
msgs = db.get_conversation(cid, limit=500)
|
||||
issues = analyze_conversation(msgs)
|
||||
if issues:
|
||||
total_issues += len(issues)
|
||||
print(f"{CYAN}{cid}{RESET} {c.get('customer_name','')}")
|
||||
for s in issues:
|
||||
print(f" - {RED}{s}{RESET}")
|
||||
print()
|
||||
if total_issues == 0:
|
||||
print(f"{GREEN}未发现明显异常。{RESET}")
|
||||
else:
|
||||
print(f"{YELLOW}共发现 {total_issues} 项问题(按客户汇总)。{RESET}")
|
||||
|
||||
|
||||
def print_help():
|
||||
print(f"""
|
||||
{BOLD}聊天记录查看器{RESET}
|
||||
|
||||
{CYAN}python chat_log_viewer.py{RESET} 列出所有客户
|
||||
{CYAN}python chat_log_viewer.py <客户ID>{RESET} 查看该客户全部对话
|
||||
{CYAN}python chat_log_viewer.py -t <客户ID>{RESET} 只看今天的对话
|
||||
{CYAN}python chat_log_viewer.py -s <关键词>{RESET} 全局搜索
|
||||
{CYAN}python chat_log_viewer.py -l{RESET} 实时监听新消息
|
||||
{CYAN}python chat_log_viewer.py -a{RESET} 分析上下文,输出异常项
|
||||
{CYAN}python chat_log_viewer.py -h{RESET} 显示帮助
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
|
||||
if not args:
|
||||
cmd_list_customers()
|
||||
|
||||
elif args[0] in ("-h", "--help"):
|
||||
print_help()
|
||||
|
||||
elif args[0] == "-s":
|
||||
keyword = args[1] if len(args) > 1 else ""
|
||||
if not keyword:
|
||||
print(f"{RED}请提供搜索关键词:python chat_log_viewer.py -s <关键词>{RESET}")
|
||||
else:
|
||||
cmd_search(keyword)
|
||||
|
||||
elif args[0] == "-t":
|
||||
cid = args[1] if len(args) > 1 else ""
|
||||
if not cid:
|
||||
print(f"{RED}请提供客户ID:python chat_log_viewer.py -t <客户ID>{RESET}")
|
||||
else:
|
||||
cmd_show_conversation(cid, today_only=True)
|
||||
|
||||
elif args[0] == "-l":
|
||||
cmd_live()
|
||||
|
||||
elif args[0] == "-a":
|
||||
cmd_analyze_all()
|
||||
|
||||
else:
|
||||
cmd_show_conversation(args[0])
|
||||
520
legacy/scripts/chat_ui.py
Normal file
520
legacy/scripts/chat_ui.py
Normal file
@@ -0,0 +1,520 @@
|
||||
# -*- 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)})
|
||||
95
legacy/scripts/evolution_cycle.py
Normal file
95
legacy/scripts/evolution_cycle.py
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Self-evolution MVP cycle runner.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
load_dotenv(dotenv_path=PROJECT_ROOT / ".env")
|
||||
|
||||
from evolution.mvp import ChatSourceConfig, DEFAULT_CANDIDATE_PATH, DEFAULT_POLICY_PATH, run_cycle
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run self-evolution MVP cycle")
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
type=str,
|
||||
default="mysql",
|
||||
choices=["auto", "sqlite", "mysql"],
|
||||
help="Chat data source, default mysql (online)",
|
||||
)
|
||||
parser.add_argument("--hours", type=int, default=24, help="Lookback window for chat samples")
|
||||
parser.add_argument("--max-customers", type=int, default=200, help="Max customers sampled")
|
||||
parser.add_argument(
|
||||
"--max-messages-per-customer",
|
||||
type=int,
|
||||
default=80,
|
||||
help="Max messages loaded per customer",
|
||||
)
|
||||
parser.add_argument("--runtime-hours", type=int, default=24, help="Runtime metric window")
|
||||
parser.add_argument(
|
||||
"--publish",
|
||||
action="store_true",
|
||||
help="Write config/evolution_candidate.json when gate passes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--policy-path",
|
||||
type=str,
|
||||
default=str(DEFAULT_POLICY_PATH),
|
||||
help="Path to evolution gate policy file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--candidate-path",
|
||||
type=str,
|
||||
default=str(DEFAULT_CANDIDATE_PATH),
|
||||
help="Path to candidate output file",
|
||||
)
|
||||
parser.add_argument("--db-path", type=str, default="", help="SQLite path when --source sqlite")
|
||||
parser.add_argument("--mysql-host", type=str, default=os.getenv("MYSQL_HOST", "127.0.0.1"))
|
||||
parser.add_argument("--mysql-port", type=int, default=int(os.getenv("MYSQL_PORT", "3306")))
|
||||
parser.add_argument("--mysql-user", type=str, default=os.getenv("MYSQL_USER", "root"))
|
||||
parser.add_argument("--mysql-password", type=str, default=os.getenv("MYSQL_PASSWORD", ""))
|
||||
parser.add_argument("--mysql-database", type=str, default=os.getenv("MYSQL_DATABASE", "ai_cs"))
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
chat_source = ChatSourceConfig(
|
||||
source=args.source,
|
||||
sqlite_path=args.db_path or str(PROJECT_ROOT / "db" / "chat_log_db" / "chats.db"),
|
||||
mysql_host=args.mysql_host,
|
||||
mysql_port=args.mysql_port,
|
||||
mysql_user=args.mysql_user,
|
||||
mysql_password=args.mysql_password,
|
||||
mysql_database=args.mysql_database,
|
||||
)
|
||||
|
||||
result = run_cycle(
|
||||
hours=args.hours,
|
||||
max_customers=args.max_customers,
|
||||
max_messages_per_customer=args.max_messages_per_customer,
|
||||
runtime_hours=args.runtime_hours,
|
||||
publish=args.publish,
|
||||
chat_source=chat_source,
|
||||
policy_path=Path(args.policy_path),
|
||||
candidate_path=Path(args.candidate_path),
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
45
legacy/scripts/init_designer_roster.py
Normal file
45
legacy/scripts/init_designer_roster.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
初始化设计师派单数据(SQLite)
|
||||
|
||||
同一设计师在不同店铺对应不同 group_id。
|
||||
用法:
|
||||
python scripts/init_designer_roster.py
|
||||
# 按提示添加设计师和店铺分组,或直接修改下方示例后运行
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from db.designer_roster_db import add_designer, set_designer_shop, list_designers, update_online
|
||||
|
||||
|
||||
def init_example():
|
||||
"""示例:添加设计师,同一人在不同店铺不同分组"""
|
||||
# 设计师A:在 小威哥1216 用分组 20252916034,在 另一店铺 用 12345678
|
||||
aid = add_designer("设计师A", "user_a")
|
||||
set_designer_shop(aid, "小威哥1216", "20252916034")
|
||||
set_designer_shop(aid, "另一店铺", "12345678")
|
||||
|
||||
# 设计师B:只在 小威哥1216
|
||||
bid = add_designer("设计师B", "user_b")
|
||||
set_designer_shop(bid, "小威哥1216", "99998888")
|
||||
|
||||
# 可选:手动标记上线(否则等企微群解析)
|
||||
update_online("user_a", True)
|
||||
update_online("user_b", True)
|
||||
|
||||
print("示例数据已写入")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "example":
|
||||
init_example()
|
||||
elif len(sys.argv) > 1 and sys.argv[1] == "list":
|
||||
for d in list_designers():
|
||||
print(f"{d['name']} ({d['wechat_user_id']}) 在线={d['is_online']}")
|
||||
for shop, gid in d["shops"].items():
|
||||
print(f" - {shop} -> {gid}")
|
||||
else:
|
||||
print("用法: python scripts/init_designer_roster.py example # 写入示例")
|
||||
print(" python scripts/init_designer_roster.py list # 查看当前数据")
|
||||
175
legacy/scripts/migrate_chat_logs_to_mysql.py
Normal file
175
legacy/scripts/migrate_chat_logs_to_mysql.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
把本地 SQLite 聊天记录迁移到 MySQL:
|
||||
source: db/chat_log_db/chats.db -> table chat_logs
|
||||
|
||||
用法示例:
|
||||
python scripts/migrate_chat_logs_to_mysql.py --host xinhui.cloud --port 3306 \
|
||||
--user ai_cs_user --password xxx --database ai_cs --batch-size 2000 --truncate-target
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pymysql
|
||||
|
||||
|
||||
def ensure_mysql_table(conn):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_logs (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
customer_id VARCHAR(128) NOT NULL,
|
||||
customer_name VARCHAR(255) DEFAULT '',
|
||||
acc_id VARCHAR(128) DEFAULT '',
|
||||
platform VARCHAR(64) DEFAULT '',
|
||||
direction VARCHAR(8) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
msg_type INTEGER DEFAULT 0,
|
||||
timestamp DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
"""
|
||||
)
|
||||
cur.execute("SHOW INDEX FROM chat_logs")
|
||||
exists = {str(r.get("Key_name", "")) for r in cur.fetchall()}
|
||||
if "idx_customer" not in exists:
|
||||
cur.execute("CREATE INDEX idx_customer ON chat_logs(customer_id)")
|
||||
if "idx_ts" not in exists:
|
||||
cur.execute("CREATE INDEX idx_ts ON chat_logs(timestamp)")
|
||||
if "idx_acc" not in exists:
|
||||
cur.execute("CREATE INDEX idx_acc ON chat_logs(acc_id)")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_sqlite_conn(path: Path):
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def get_mysql_conn(host: str, port: int, user: str, password: str, database: str):
|
||||
return pymysql.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database=database,
|
||||
charset="utf8mb4",
|
||||
autocommit=False,
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
)
|
||||
|
||||
|
||||
def migrate(sqlite_path: Path, host: str, port: int, user: str, password: str, database: str, batch_size: int, truncate_target: bool):
|
||||
if not sqlite_path.exists():
|
||||
raise FileNotFoundError(f"SQLite 文件不存在: {sqlite_path}")
|
||||
|
||||
s_conn = get_sqlite_conn(sqlite_path)
|
||||
m_conn = get_mysql_conn(host, port, user, password, database)
|
||||
try:
|
||||
ensure_mysql_table(m_conn)
|
||||
if truncate_target:
|
||||
with m_conn.cursor() as cur:
|
||||
cur.execute("TRUNCATE TABLE chat_logs")
|
||||
m_conn.commit()
|
||||
|
||||
total = s_conn.execute("SELECT COUNT(*) AS c FROM chat_logs").fetchone()["c"]
|
||||
print(f"[MIGRATE] SQLite 源总行数: {total}")
|
||||
if total == 0:
|
||||
return 0
|
||||
|
||||
migrated = 0
|
||||
last_id = 0
|
||||
started = time.time()
|
||||
|
||||
insert_sql = (
|
||||
"INSERT INTO chat_logs "
|
||||
"(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)"
|
||||
)
|
||||
|
||||
while True:
|
||||
rows = s_conn.execute(
|
||||
"""
|
||||
SELECT id, customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp
|
||||
FROM chat_logs
|
||||
WHERE id > ?
|
||||
ORDER BY id ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(last_id, batch_size),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
break
|
||||
|
||||
vals = []
|
||||
for r in rows:
|
||||
vals.append(
|
||||
(
|
||||
r["customer_id"] or "",
|
||||
r["customer_name"] or "",
|
||||
r["acc_id"] or "",
|
||||
r["platform"] or "",
|
||||
r["direction"] or "in",
|
||||
r["message"] or "",
|
||||
int(r["msg_type"] or 0),
|
||||
r["timestamp"],
|
||||
)
|
||||
)
|
||||
last_id = r["id"]
|
||||
|
||||
with m_conn.cursor() as cur:
|
||||
cur.executemany(insert_sql, vals)
|
||||
m_conn.commit()
|
||||
|
||||
migrated += len(vals)
|
||||
elapsed = time.time() - started
|
||||
print(f"[MIGRATE] {migrated}/{total} ({(migrated/total)*100:.1f}%) elapsed={elapsed:.1f}s")
|
||||
|
||||
return migrated
|
||||
finally:
|
||||
try:
|
||||
s_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
m_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="迁移 chat_logs: SQLite -> MySQL")
|
||||
parser.add_argument("--sqlite-path", default=str(Path("db") / "chat_log_db" / "chats.db"))
|
||||
parser.add_argument("--host", required=True)
|
||||
parser.add_argument("--port", type=int, default=3306)
|
||||
parser.add_argument("--user", required=True)
|
||||
parser.add_argument("--password", required=True)
|
||||
parser.add_argument("--database", required=True)
|
||||
parser.add_argument("--batch-size", type=int, default=2000)
|
||||
parser.add_argument("--truncate-target", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
sqlite_path = Path(args.sqlite_path)
|
||||
migrated = migrate(
|
||||
sqlite_path=sqlite_path,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
database=args.database,
|
||||
batch_size=max(100, int(args.batch_size)),
|
||||
truncate_target=bool(args.truncate_target),
|
||||
)
|
||||
print(f"[DONE] 迁移完成,写入 {migrated} 条")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
103
legacy/scripts/migrate_customers_json_to_mysql.py
Normal file
103
legacy/scripts/migrate_customers_json_to_mysql.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
迁移 customer_db/customers.json -> MySQL customer_profiles
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pymysql
|
||||
|
||||
|
||||
def get_conn(host: str, port: int, user: str, password: str, database: str):
|
||||
return pymysql.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database=database,
|
||||
charset="utf8mb4",
|
||||
autocommit=False,
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
)
|
||||
|
||||
|
||||
def ensure_table(conn):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS customer_profiles (
|
||||
customer_id VARCHAR(128) PRIMARY KEY,
|
||||
profile_json LONGTEXT NOT NULL,
|
||||
last_update DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
"""
|
||||
)
|
||||
cur.execute("SHOW INDEX FROM customer_profiles")
|
||||
exists = {str(r.get("Key_name", "")) for r in cur.fetchall()}
|
||||
if "idx_last_update" not in exists:
|
||||
cur.execute("CREATE INDEX idx_last_update ON customer_profiles(last_update)")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def migrate(json_path: Path, host: str, port: int, user: str, password: str, database: str, truncate_target: bool):
|
||||
if not json_path.exists():
|
||||
raise FileNotFoundError(f"customers.json 不存在: {json_path}")
|
||||
customers = json.loads(json_path.read_text(encoding="utf-8") or "{}")
|
||||
if not isinstance(customers, dict):
|
||||
raise RuntimeError("customers.json 格式错误,期望对象映射")
|
||||
|
||||
conn = get_conn(host, port, user, password, database)
|
||||
try:
|
||||
ensure_table(conn)
|
||||
if truncate_target:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("TRUNCATE TABLE customer_profiles")
|
||||
conn.commit()
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
sql = (
|
||||
"REPLACE INTO customer_profiles (customer_id, profile_json, last_update) "
|
||||
"VALUES (%s, %s, %s)"
|
||||
)
|
||||
total = 0
|
||||
with conn.cursor() as cur:
|
||||
for cid, profile in customers.items():
|
||||
cur.execute(sql, (str(cid), json.dumps(profile, ensure_ascii=False), now))
|
||||
total += 1
|
||||
conn.commit()
|
||||
return total
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="迁移 customers.json 到 MySQL")
|
||||
parser.add_argument("--json-path", default=str(Path("customer_db") / "customers.json"))
|
||||
parser.add_argument("--host", required=True)
|
||||
parser.add_argument("--port", type=int, default=3306)
|
||||
parser.add_argument("--user", required=True)
|
||||
parser.add_argument("--password", required=True)
|
||||
parser.add_argument("--database", required=True)
|
||||
parser.add_argument("--truncate-target", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
total = migrate(
|
||||
json_path=Path(args.json_path),
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
database=args.database,
|
||||
truncate_target=bool(args.truncate_target),
|
||||
)
|
||||
print(f"[DONE] customer_profiles 写入 {total} 条")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
109
legacy/scripts/migrate_remaining_sqlite_to_mysql.py
Normal file
109
legacy/scripts/migrate_remaining_sqlite_to_mysql.py
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
迁移其余 SQLite 业务库到 MySQL(保留主键):
|
||||
- deal_outcome_db/outcomes.db -> deal_outcomes
|
||||
- designer_roster_db/roster.db -> designers/designer_shops/designer_online/round_robin
|
||||
- image_tasks.db -> image_tasks/requirement_history
|
||||
- task_db/tasks.db -> tasks/task_logs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import List, Dict
|
||||
|
||||
import pymysql
|
||||
|
||||
|
||||
MAPPINGS = [
|
||||
{"sqlite": Path("db/deal_outcome_db/outcomes.db"), "tables": ["deal_outcomes"]},
|
||||
{"sqlite": Path("db/designer_roster_db/roster.db"), "tables": ["designers", "designer_shops", "designer_online", "round_robin"]},
|
||||
{"sqlite": Path("db/image_tasks.db"), "tables": ["image_tasks", "task_requirement_changes"]},
|
||||
{"sqlite": Path("db/task_db/tasks.db"), "tables": ["tasks"]},
|
||||
]
|
||||
|
||||
|
||||
def mysql_conn(host: str, port: int, user: str, password: str, database: str):
|
||||
return pymysql.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database=database,
|
||||
charset="utf8mb4",
|
||||
autocommit=False,
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
)
|
||||
|
||||
|
||||
def sqlite_table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(table,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def sqlite_fetch_all(conn: sqlite3.Connection, table: str) -> List[sqlite3.Row]:
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn.execute(f"SELECT * FROM {table}").fetchall()
|
||||
|
||||
|
||||
def migrate_table(mysql, rows: List[sqlite3.Row], table: str, truncate_target: bool) -> int:
|
||||
if not rows:
|
||||
return 0
|
||||
cols = list(rows[0].keys())
|
||||
col_sql = ", ".join(cols)
|
||||
val_sql = ", ".join(["%s"] * len(cols))
|
||||
sql = f"REPLACE INTO {table} ({col_sql}) VALUES ({val_sql})"
|
||||
if truncate_target:
|
||||
with mysql.cursor() as cur:
|
||||
try:
|
||||
cur.execute(f"TRUNCATE TABLE {table}")
|
||||
except Exception:
|
||||
try:
|
||||
cur.execute(f"DELETE FROM {table}")
|
||||
except Exception:
|
||||
return 0
|
||||
values = [tuple(r[c] for c in cols) for r in rows]
|
||||
with mysql.cursor() as cur:
|
||||
cur.executemany(sql, values)
|
||||
mysql.commit()
|
||||
return len(values)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="迁移剩余 SQLite 业务库到 MySQL")
|
||||
p.add_argument("--host", required=True)
|
||||
p.add_argument("--port", type=int, default=3306)
|
||||
p.add_argument("--user", required=True)
|
||||
p.add_argument("--password", required=True)
|
||||
p.add_argument("--database", required=True)
|
||||
p.add_argument("--truncate-target", action="store_true")
|
||||
args = p.parse_args()
|
||||
|
||||
total = 0
|
||||
with mysql_conn(args.host, args.port, args.user, args.password, args.database) as mconn:
|
||||
for item in MAPPINGS:
|
||||
sp = item["sqlite"]
|
||||
if not sp.exists():
|
||||
continue
|
||||
sconn = sqlite3.connect(str(sp))
|
||||
try:
|
||||
for table in item["tables"]:
|
||||
if not sqlite_table_exists(sconn, table):
|
||||
continue
|
||||
rows = sqlite_fetch_all(sconn, table)
|
||||
n = migrate_table(mconn, rows, table, truncate_target=bool(args.truncate_target))
|
||||
total += n
|
||||
print(f"[MIGRATE] {sp}::{table} -> {n}")
|
||||
finally:
|
||||
sconn.close()
|
||||
print(f"[DONE] migrated total rows: {total}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
209
legacy/scripts/multi_process_launcher.py
Normal file
209
legacy/scripts/multi_process_launcher.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
多进程异步并行启动器
|
||||
按客户 ID hash 分配到不同进程,实现真正的并行处理
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import signal
|
||||
import logging
|
||||
from multiprocessing import Process, cpu_count
|
||||
from typing import List, Dict
|
||||
import hashlib
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[%(asctime)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkerProcess:
|
||||
"""工作进程"""
|
||||
|
||||
def __init__(self, worker_id: int, shard_keys: List[str], num_workers: int, enable_agent: bool = True):
|
||||
self.worker_id = worker_id
|
||||
self.shard_keys = shard_keys
|
||||
self.num_workers = max(1, int(num_workers))
|
||||
self.enable_agent = enable_agent
|
||||
self.process = None
|
||||
|
||||
def start(self):
|
||||
"""启动工作进程"""
|
||||
self.process = Process(
|
||||
target=self._run,
|
||||
args=(self.worker_id, self.shard_keys, self.num_workers, self.enable_agent),
|
||||
name=f"ai-cs-worker-{self.worker_id}"
|
||||
)
|
||||
self.process.start()
|
||||
logger.info(f"Worker {self.worker_id} 启动 (PID: {self.process.pid})")
|
||||
|
||||
def _run(self, worker_id: int, shard_keys: List[str], num_workers: int, enable_agent: bool):
|
||||
"""工作进程入口"""
|
||||
try:
|
||||
# 设置进程环境变量
|
||||
os.environ['AI_CS_WORKER_ID'] = str(worker_id)
|
||||
os.environ['AI_CS_WORKER_COUNT'] = str(max(1, int(num_workers)))
|
||||
os.environ['AI_CS_SHARD_KEYS'] = ','.join(shard_keys)
|
||||
|
||||
# 导入并启动 WebSocket 客户端
|
||||
from core.websocket_client import QingjianAPIClient
|
||||
|
||||
logger.info(f"Worker {worker_id} 初始化 Agent...")
|
||||
client = QingjianAPIClient(enable_agent=enable_agent)
|
||||
|
||||
# 只处理分配给这个 worker 的客户
|
||||
client.shard_keys = set(shard_keys)
|
||||
|
||||
logger.info(f"Worker {worker_id} 开始处理消息...")
|
||||
import asyncio
|
||||
asyncio.run(client.connect())
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info(f"Worker {worker_id} 收到退出信号")
|
||||
except Exception as e:
|
||||
logger.error(f"Worker {worker_id} 异常:{e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def stop(self):
|
||||
"""停止工作进程"""
|
||||
if self.process and self.process.is_alive():
|
||||
self.process.terminate()
|
||||
self.process.join(timeout=5)
|
||||
logger.info(f"Worker {self.worker_id} 已停止")
|
||||
|
||||
|
||||
class Coordinator:
|
||||
"""协调器 - 管理多个工作进程"""
|
||||
|
||||
def __init__(self, num_workers: int = None, enable_agent: bool = True):
|
||||
self.num_workers = num_workers or max(2, cpu_count())
|
||||
self.workers: List[WorkerProcess] = []
|
||||
self.running = False
|
||||
self._stopping = False
|
||||
self.enable_agent = enable_agent
|
||||
|
||||
def _get_shard_key(self, acc_id: str, from_id: str) -> int:
|
||||
"""根据店铺 ID + 客户 ID 计算分片 key"""
|
||||
key = f"{acc_id}:{from_id}"
|
||||
hash_value = int(hashlib.md5(key.encode()).hexdigest(), 16)
|
||||
return hash_value % self.num_workers
|
||||
|
||||
def _load_customer_shards(self) -> Dict[int, List[str]]:
|
||||
"""加载客户分片信息
|
||||
|
||||
Returns:
|
||||
{shard_id: [customer_key1, customer_key2, ...]}
|
||||
"""
|
||||
# 从数据库或配置文件加载客户列表
|
||||
# 这里简化处理,实际应该从数据库加载活跃客户
|
||||
shards = {i: [] for i in range(self.num_workers)}
|
||||
|
||||
# TODO: 从数据库加载活跃客户列表
|
||||
# customers = db.query(...).all()
|
||||
# for customer in customers:
|
||||
# shard_id = self._get_shard_key(customer.acc_id, customer.from_id)
|
||||
# shards[shard_id].append(f"{customer.acc_id}:{customer.from_id}")
|
||||
|
||||
logger.info(f"已加载 {sum(len(v) for v in shards.values())} 个客户分片")
|
||||
return shards
|
||||
|
||||
def start(self):
|
||||
"""启动所有工作进程"""
|
||||
logger.info(f"启动协调器,工作进程数:{self.num_workers}")
|
||||
|
||||
shards = self._load_customer_shards()
|
||||
|
||||
# 启动工作进程
|
||||
for worker_id in range(self.num_workers):
|
||||
worker = WorkerProcess(
|
||||
worker_id=worker_id,
|
||||
shard_keys=shards.get(worker_id, []),
|
||||
num_workers=self.num_workers,
|
||||
enable_agent=self.enable_agent
|
||||
)
|
||||
worker.start()
|
||||
self.workers.append(worker)
|
||||
|
||||
self.running = True
|
||||
|
||||
# 注册信号处理
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
|
||||
# 监控工作进程
|
||||
self._monitor_workers()
|
||||
|
||||
def _monitor_workers(self):
|
||||
"""监控工作进程健康状态"""
|
||||
import time
|
||||
|
||||
while self.running:
|
||||
# 检查工作进程是否存活
|
||||
for worker in self.workers:
|
||||
if worker.process and not worker.process.is_alive():
|
||||
logger.warning(f"Worker {worker.worker_id} 已退出,尝试重启...")
|
||||
# 重启工作进程
|
||||
worker.start()
|
||||
|
||||
time.sleep(10) # 每 10 秒检查一次
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""信号处理"""
|
||||
if self._stopping:
|
||||
return
|
||||
self._stopping = True
|
||||
logger.info(f"收到信号 {signum},正在停止所有工作进程...")
|
||||
self.stop()
|
||||
|
||||
def stop(self):
|
||||
"""停止所有工作进程"""
|
||||
if self._stopping and not self.running and not any(w.process and w.process.is_alive() for w in self.workers):
|
||||
return
|
||||
self._stopping = True
|
||||
self.running = False
|
||||
|
||||
for worker in self.workers:
|
||||
worker.stop()
|
||||
|
||||
logger.info("所有工作进程已停止")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='AI 客服多进程启动器')
|
||||
parser.add_argument(
|
||||
'--workers',
|
||||
type=int,
|
||||
default=None,
|
||||
help='工作进程数(默认:CPU 核心数)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("AI 客服系统 - 多进程异步并行模式")
|
||||
logger.info("=" * 60)
|
||||
|
||||
coordinator = Coordinator(num_workers=args.workers)
|
||||
|
||||
try:
|
||||
coordinator.start()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到退出信号")
|
||||
coordinator.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"启动失败:{e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
legacy/scripts/run_test_ai_chat.ps1
Normal file
8
legacy/scripts/run_test_ai_chat.ps1
Normal file
@@ -0,0 +1,8 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Use a writable uv cache path on Windows to avoid permission issues
|
||||
# with default cache locations in restricted environments.
|
||||
$env:UV_CACHE_DIR = Join-Path $env:TEMP "uv-cache-tw-runtime"
|
||||
New-Item -ItemType Directory -Force $env:UV_CACHE_DIR | Out-Null
|
||||
|
||||
uv run tests\test_ai_chat.py
|
||||
Reference in New Issue
Block a user