diff --git a/server/corpus-agent/requirements.txt b/server/corpus-agent/requirements.txt
index d403a4f..d35939e 100644
--- a/server/corpus-agent/requirements.txt
+++ b/server/corpus-agent/requirements.txt
@@ -1,5 +1,6 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
+httpx>=0.25.0
websocket-client>=1.6.0
pyautogui>=0.9.53
Pillow>=10.0.0
diff --git a/server/corpus-agent/server.py b/server/corpus-agent/server.py
index 0cd4e4c..289170e 100644
--- a/server/corpus-agent/server.py
+++ b/server/corpus-agent/server.py
@@ -1,20 +1,21 @@
"""
语料采集系统 · 服务端
====================
-FastAPI + Gitea OAuth + WebSocket实时采集
+FastAPI + Gitea OAuth + WebSocket实时采集 + 对话Agent
"""
import os
import json
import uuid
import time
import hashlib
+import asyncio
from pathlib import Path
from typing import Optional, List
from datetime import datetime, timedelta
-from fastapi import FastAPI, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
+from fastapi import FastAPI, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect, Request
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import uvicorn
@@ -32,7 +33,7 @@ from engine import (
class Settings:
APP_NAME = "语料采集系统 Corpus Agent"
- VERSION = "1.0.0"
+ VERSION = "1.0.1"
# 存储路径
DATA_DIR = Path(os.environ.get("CORPUS_DATA_DIR", "./data"))
@@ -50,6 +51,12 @@ class Settings:
# 服务器
HOST = os.environ.get("CORPUS_HOST", "0.0.0.0")
PORT = int(os.environ.get("CORPUS_PORT", "8084"))
+
+ # Portal Chat-v2 API(广州服务器本地端口)
+ PORTAL_CHAT_URL = os.environ.get("PORTAL_CHAT_URL", "http://127.0.0.1:3000/api/chat-v2")
+
+ # 对话记忆最大轮数
+ MAX_MEMORY_ROUNDS = 30
settings = Settings()
@@ -78,11 +85,67 @@ class CorpusSave(BaseModel):
class LoginRequest(BaseModel):
gitea_token: str
+class ChatRequest(BaseModel):
+ message: str
+ persona: str = "zhuyuan" # zhuyuan | shuangyan
+ engine: str = "deepseek" # deepseek | zhipu | tongyi | huoshan
+
+class ChatClearRequest(BaseModel):
+ session_id: Optional[str] = None
+
class SessionData(BaseModel):
username: str
login_time: float
exprire_at: float
+# ============================================================
+# 对话记忆管理(每用户30轮)
+# ============================================================
+
+class ConversationMemory:
+ """
+ 每用户每 session 的对话记忆
+ 支持30轮滚动:超出时从最旧的消息开始丢弃
+ """
+
+ def __init__(self, max_rounds: int = 30):
+ self.max_rounds = max_rounds
+ self._stores: dict[str, list] = {} # key: "username:session_id" -> [{"role":..., "content":...}, ...]
+
+ def _key(self, username: str, session_id: str = "default") -> str:
+ return f"{username}:{session_id}"
+
+ def get_history(self, username: str, session_id: str = "default") -> list:
+ """获取对话历史(用于chat-v2 API的history参数)"""
+ key = self._key(username, session_id)
+ return self._stores.get(key, [])
+
+ def add_exchange(self, username: str, user_msg: str, assistant_msg: str, session_id: str = "default"):
+ """添加一轮对话"""
+ key = self._key(username, session_id)
+ if key not in self._stores:
+ self._stores[key] = []
+
+ self._stores[key].append({"role": "user", "content": user_msg})
+ self._stores[key].append({"role": "assistant", "content": assistant_msg})
+
+ # 超出30轮时裁掉最旧的一对(2条消息)
+ while len(self._stores[key]) > self.max_rounds * 2:
+ self._stores[key] = self._stores[key][2:]
+
+ def clear(self, username: str, session_id: str = "default"):
+ """清空对话记忆"""
+ key = self._key(username, session_id)
+ self._stores.pop(key, None)
+
+ def get_rounds_count(self, username: str, session_id: str = "default") -> int:
+ """获取当前对话轮数"""
+ history = self.get_history(username, session_id)
+ return len(history) // 2
+
+# 全局对话记忆实例
+chat_memory = ConversationMemory(max_rounds=settings.MAX_MEMORY_ROUNDS)
+
# ============================================================
# App
# ============================================================
@@ -180,14 +243,29 @@ async def root():
@app.get("/api/health")
async def health():
- return {"status": "ok", "app": settings.APP_NAME, "version": settings.VERSION}
+ """健康检查 + Portal chat-v2 探活"""
+ portal_ok = False
+ try:
+ import httpx
+ async with httpx.AsyncClient(timeout=3) as client:
+ r = await client.get("http://127.0.0.1:3000/api/health")
+ portal_ok = r.status_code == 200
+ except:
+ pass
+
+ return {
+ "status": "ok",
+ "app": settings.APP_NAME,
+ "version": settings.VERSION,
+ "portal_chat_v2": portal_ok,
+ "active_memories": len(chat_memory._stores),
+ }
# --- 登录 ---
@app.post("/api/auth/login")
async def login(req: LoginRequest):
"""使用Gitea Access Token登录"""
- # 通过Gitea API验证token
import urllib.request
try:
gitea_req = urllib.request.Request(
@@ -249,7 +327,6 @@ async def collect_chunk(chunk: TextChunk, token: str = Query(...)):
if "user" in r and "assistant" in r:
sft_samples.append(to_chatml(r["user"], r["assistant"]))
else:
- # 单条文本也存起来
sft_samples.append({
"messages": [{"role": "user", "content": r["text"]}],
"source": r["source"],
@@ -371,6 +448,204 @@ async def corpus_stats(token: str = Query(...)):
"by_tag": by_tag,
}
+# ============================================================
+# 对话Agent API(通过Portal Chat-v2连接商业模型)
+# ============================================================
+
+# SSE事件类型常量
+SSE_TYPE_PERSONA = "persona-loaded"
+SSE_TYPE_TOKEN = "token"
+SSE_TYPE_TOOL_CALL = "tool-call"
+SSE_TYPE_TOOL_RESULT = "tool-result"
+SSE_TYPE_TOOL_UNAVAILABLE = "tool-unavailable"
+SSE_TYPE_DONE = "done"
+
+@app.post("/api/corpus/chat")
+async def chat_with_agent(req: ChatRequest, token: str = Query(...)):
+ """
+ 对话Agent API(SSE流式)
+
+ 通过 Portal Chat-v2 API 连接商业模型(DeepSeek/智谱/通义/火山)
+ 自动携带30轮对话记忆
+ 返回 SSE 流式响应
+ """
+ username = get_user_from_token(token)
+ if not username:
+ raise HTTPException(401, "登录已过期")
+
+ if not req.message.strip():
+ raise HTTPException(400, "消息不能为空")
+
+ # 获取对话历史
+ history = chat_memory.get_history(username)
+ rounds = chat_memory.get_rounds_count(username)
+
+ async def event_stream():
+ full_response = ""
+
+ try:
+ import httpx
+
+ # 调用 Portal Chat-v2 API
+ payload = {
+ "persona": req.persona,
+ "engine": req.engine,
+ "message": req.message,
+ "history": history,
+ "user": username,
+ "isTemp": False,
+ }
+
+ async with httpx.AsyncClient(timeout=60) as client:
+ async with client.stream(
+ "POST",
+ settings.PORTAL_CHAT_URL,
+ json=payload,
+ headers={"Content-Type": "application/json"},
+ ) as resp:
+
+ if resp.status_code != 200:
+ error_body = await resp.aread()
+ error_text = error_body.decode()[:500]
+ yield f"data: {json.dumps({'type': 'error', 'message': f'API错误 ({resp.status_code}): {error_text}'})}\n\n"
+ yield f"data: {json.dumps({'type': 'done'})}\n\n"
+ return
+
+ # 流式解析 SSE
+ buffer = ""
+ async for chunk in resp.aiter_text():
+ buffer += chunk
+
+ while "\n\n" in buffer:
+ line, buffer = buffer.split("\n\n", 1)
+ line = line.strip()
+
+ if not line:
+ continue
+
+ # 解析 data: 前缀
+ if line.startswith("data: "):
+ data_str = line[6:]
+
+ # 处理 [DONE] 标记
+ if data_str.strip() == "[DONE]":
+ # 保存对话到记忆
+ chat_memory.add_exchange(
+ username, req.message, full_response
+ )
+ yield f"data: {json.dumps({'type': SSE_TYPE_DONE, 'rounds': rounds + 1, 'max_rounds': settings.MAX_MEMORY_ROUNDS})}\n\n"
+ return
+
+ try:
+ data = json.loads(data_str)
+ event_type = data.get("type", "")
+
+ # 转发 persona-loaded 事件
+ if event_type == SSE_TYPE_PERSONA:
+ yield f"data: {json.dumps({'type': SSE_TYPE_PERSONA, 'persona': data.get('persona', ''), 'fromDB': data.get('fromDB', False)})}\n\n"
+
+ # 转发 token 事件
+ elif event_type == SSE_TYPE_TOKEN:
+ token_text = data.get("token", "")
+ full_response += token_text
+ yield f"data: {json.dumps({'type': SSE_TYPE_TOKEN, 'token': token_text})}\n\n"
+
+ # 转发工具调用事件
+ elif event_type == SSE_TYPE_TOOL_CALL:
+ yield f"data: {json.dumps({'type': SSE_TYPE_TOOL_CALL, 'tool': data.get('tool', ''), 'name': data.get('name', '')})}\n\n"
+
+ elif event_type == SSE_TYPE_TOOL_RESULT:
+ yield f"data: {json.dumps({'type': SSE_TYPE_TOOL_RESULT, 'tool': data.get('tool', ''), 'result': str(data.get('result', ''))[:200]})}\n\n"
+
+ elif event_type == SSE_TYPE_TOOL_UNAVAILABLE:
+ yield f"data: {json.dumps({'type': SSE_TYPE_TOOL_UNAVAILABLE, 'tool': data.get('tool', '')})}\n\n"
+
+ except json.JSONDecodeError:
+ pass
+
+ # 流结束但未收到 [DONE] - 仍然保存
+ if full_response:
+ chat_memory.add_exchange(username, req.message, full_response)
+ yield f"data: {json.dumps({'type': SSE_TYPE_DONE, 'rounds': rounds + 1, 'max_rounds': settings.MAX_MEMORY_ROUNDS})}\n\n"
+
+ except httpx.ConnectError:
+ yield f"data: {json.dumps({'type': 'error', 'message': '无法连接到对话引擎(127.0.0.1:3000),请确认Portal服务已启动'})}\n\n"
+ yield f"data: {json.dumps({'type': SSE_TYPE_DONE})}\n\n"
+ except httpx.TimeoutException:
+ # 超时但仍可能有部分回复
+ if full_response:
+ chat_memory.add_exchange(username, req.message, full_response)
+ yield f"data: {json.dumps({'type': 'error', 'message': '对话引擎响应超时,已保存部分回复'})}\n\n"
+ yield f"data: {json.dumps({'type': SSE_TYPE_DONE, 'rounds': rounds + 1, 'max_rounds': settings.MAX_MEMORY_ROUNDS, 'partial': True})}\n\n"
+ except Exception as e:
+ yield f"data: {json.dumps({'type': 'error', 'message': f'对话错误: {str(e)}'})}\n\n"
+ yield f"data: {json.dumps({'type': SSE_TYPE_DONE})}\n\n"
+
+ return StreamingResponse(
+ event_stream(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ }
+ )
+
+@app.get("/api/corpus/chat/history")
+async def get_chat_history(token: str = Query(...)):
+ """获取当前对话历史"""
+ username = get_user_from_token(token)
+ if not username:
+ raise HTTPException(401, "登录已过期")
+
+ history = chat_memory.get_history(username)
+ rounds = chat_memory.get_rounds_count(username)
+
+ return {
+ "ok": True,
+ "username": username,
+ "rounds": rounds,
+ "max_rounds": settings.MAX_MEMORY_ROUNDS,
+ "history": history,
+ }
+
+@app.post("/api/corpus/chat/clear")
+async def clear_chat_history(token: str = Query(...)):
+ """清空对话记忆"""
+ username = get_user_from_token(token)
+ if not username:
+ raise HTTPException(401, "登录已过期")
+
+ chat_memory.clear(username)
+
+ return {
+ "ok": True,
+ "message": "对话记忆已清空",
+ }
+
+@app.get("/api/corpus/chat/models")
+async def get_available_models(token: str = Query(...)):
+ """获取可用的模型和人格体列表"""
+ username = get_user_from_token(token)
+ if not username:
+ raise HTTPException(401, "登录已过期")
+
+ return {
+ "ok": True,
+ "personas": [
+ {"id": "zhuyuan", "name": "铸渊", "desc": "现实执行层 · 系统决策、执行规划、技术实现"},
+ {"id": "shuangyan", "name": "霜砚", "desc": "语言主控架构层 · 理解语言、组织表达、控制输出质量"},
+ ],
+ "engines": [
+ {"id": "deepseek", "name": "DeepSeek", "model": "deepseek-v4-pro", "provider": "DeepSeek"},
+ {"id": "zhipu", "name": "智谱清言", "model": "glm-4-plus", "provider": "智谱AI"},
+ {"id": "tongyi", "name": "通义千问", "model": "qwen-max", "provider": "阿里云"},
+ {"id": "huoshan", "name": "火山引擎", "model": "doubao-pro-32k", "provider": "字节跳动"},
+ ],
+ "current_rounds": chat_memory.get_rounds_count(username),
+ "max_rounds": settings.MAX_MEMORY_ROUNDS,
+ }
+
# --- 统计数据 ---
@app.get("/api/system/stats")
@@ -386,6 +661,7 @@ async def system_stats():
"total_users": total_users,
"total_samples": total_samples,
"active_sessions": len(sessions),
+ "active_chat_memories": len(chat_memory._stores),
}
# ============================================================
@@ -499,6 +775,8 @@ if __name__ == "__main__":
print(f"🚀 {settings.APP_NAME} v{settings.VERSION}")
print(f"📂 数据目录: {settings.DATA_DIR.absolute()}")
print(f"🌐 服务地址: http://{settings.HOST}:{settings.PORT}")
+ print(f"🤖 对话引擎: {settings.PORTAL_CHAT_URL}")
+ print(f"💬 最大记忆轮数: {settings.MAX_MEMORY_ROUNDS}")
settings.USERS_DIR.mkdir(parents=True, exist_ok=True)
diff --git a/server/corpus-agent/static/index.html b/server/corpus-agent/static/index.html
index c501685..8cad180 100644
--- a/server/corpus-agent/static/index.html
+++ b/server/corpus-agent/static/index.html
@@ -10,35 +10,39 @@
.container { max-width: 1000px; margin: 0 auto; padding: 20px; }
/* Header */
- .header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 12px; margin-bottom: 24px; }
- .header h1 { font-size: 28px; margin-bottom: 8px; }
- .header p { color: #8899aa; font-size: 14px; }
+ .header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: white; padding: 32px 20px; text-align: center; border-radius: 12px; margin-bottom: 24px; }
+ .header h1 { font-size: 26px; margin-bottom: 6px; }
+ .header p { color: #8899aa; font-size: 13px; }
+ .header .version { color: #556; font-size: 11px; margin-top: 4px; }
/* Cards */
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.card h2 { font-size: 18px; margin-bottom: 16px; color: #1a1a2e; }
- /* Login / User area */
+ /* Login */
.login-box { text-align: center; padding: 40px; }
- .login-box input { padding: 12px 16px; width: 300px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; margin-bottom: 12px; }
+ .login-box input { padding: 12px 16px; width: 320px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; margin-bottom: 12px; }
.login-box input:focus { outline: none; border-color: #4a6cf7; }
- .btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; transition: 0.2s; font-weight: 500; }
+ .btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; transition: 0.2s; font-weight: 500; display: inline-flex; align-items: center; gap: 6px; }
.btn-primary { background: #4a6cf7; color: white; }
.btn-primary:hover { background: #3b5de7; }
.btn-success { background: #10b981; color: white; }
.btn-success:hover { background: #059669; }
.btn-danger { background: #ef4444; color: white; }
.btn-danger:hover { background: #dc2626; }
+ .btn-outline { background: transparent; color: #666; border: 1px solid #d0d0d0; }
+ .btn-outline:hover { background: #f5f5f5; }
.btn-sm { padding: 6px 14px; font-size: 12px; }
+ .btn:disabled { opacity: 0.6; cursor: not-allowed; }
.user-info { display: flex; justify-content: space-between; align-items: center; }
.user-info .badge { background: #e8f0fe; color: #4a6cf7; padding: 4px 12px; border-radius: 20px; font-size: 13px; }
- /* Stats grid */
- .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin: 16px 0; }
- .stat-card { background: #f8f9fc; border-radius: 8px; padding: 16px; text-align: center; }
- .stat-card .num { font-size: 28px; font-weight: 700; color: #1a1a2e; }
- .stat-card .label { font-size: 12px; color: #8899aa; margin-top: 4px; }
+ /* Stats */
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin: 16px 0; }
+ .stat-card { background: #f8f9fc; border-radius: 8px; padding: 14px; text-align: center; }
+ .stat-card .num { font-size: 24px; font-weight: 700; color: #1a1a2e; }
+ .stat-card .label { font-size: 11px; color: #8899aa; margin-top: 4px; }
/* Sample list */
.sample-list { max-height: 400px; overflow-y: auto; }
@@ -50,36 +54,74 @@
.sample-item .tag { display: inline-block; background: #e8f0fe; color: #4a6cf7; padding: 2px 8px; border-radius: 10px; font-size: 11px; margin-right: 4px; }
/* Tabs */
- .tabs { display: flex; gap: 8px; margin-bottom: 16px; }
- .tab { padding: 8px 20px; border-radius: 8px; cursor: pointer; font-size: 14px; background: #f0f2f5; color: #666; transition: 0.2s; border: none; }
+ .tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
+ .tab { padding: 8px 18px; border-radius: 8px; cursor: pointer; font-size: 13px; background: #f0f2f5; color: #666; transition: 0.2s; border: none; }
.tab.active { background: #4a6cf7; color: white; }
+ .tab:hover:not(.active) { background: #e0e2e5; }
- .hidden { display: none; }
+ .hidden { display: none !important; }
- /* Preview / input area */
+ /* Chat Styles */
+ .chat-container { display: flex; flex-direction: column; height: 550px; }
+ .chat-messages { flex: 1; overflow-y: auto; padding: 16px; background: #f8f9fc; border-radius: 8px 8px 0 0; border: 1px solid #e8e8e8; border-bottom: none; }
+ .chat-message { margin-bottom: 16px; animation: fadeIn 0.2s ease; }
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
+ .chat-message .role { font-size: 11px; font-weight: 600; color: #8899aa; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
+ .chat-message .role .dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
+ .chat-message .role .dot.user { background: #4a6cf7; }
+ .chat-message .role .dot.assistant { background: #10b981; }
+ .chat-message .role .dot.system { background: #f59e0b; }
+ .chat-message .bubble { padding: 10px 14px; border-radius: 8px; font-size: 14px; line-height: 1.7; max-width: 85%; }
+ .chat-message.user { text-align: right; }
+ .chat-message.user .bubble { background: #4a6cf7; color: white; display: inline-block; text-align: left; }
+ .chat-message.assistant .bubble { background: white; border: 1px solid #e8e8e8; display: inline-block; }
+ .chat-message.system { text-align: center; }
+ .chat-message.system .bubble { background: #fef3c7; color: #92400e; font-size: 13px; display: inline-block; max-width: 90%; }
+ .chat-message .typing-indicator { display: inline-flex; gap: 4px; padding: 4px 0; }
+ .chat-message .typing-indicator span { width: 8px; height: 8px; background: #bbb; border-radius: 50%; animation: typing 1.2s infinite ease-in-out; }
+ .chat-message .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
+ .chat-message .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
+ @keyframes typing { 0%, 80%, 100% { transform: scale(0.6); } 40% { transform: scale(1); } }
+
+ .chat-input-area { display: flex; gap: 8px; padding: 12px; background: white; border: 1px solid #e8e8e8; border-top: none; border-radius: 0 0 8px 8px; }
+ .chat-input-area input { flex: 1; padding: 10px 14px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; }
+ .chat-input-area input:focus { outline: none; border-color: #4a6cf7; }
+
+ .chat-controls { display: flex; gap: 8px; align-items: center; }
+ .chat-controls select { padding: 8px 10px; border: 1px solid #d0d0d0; border-radius: 6px; font-size: 12px; background: white; color: #555; }
+ .chat-rounds { font-size: 11px; color: #999; text-align: center; padding: 6px; }
+
+ /* Preview */
.preview-area { background: #f8f9fc; border-radius: 8px; padding: 16px; margin: 12px 0; font-size: 13px; line-height: 1.6; max-height: 300px; overflow-y: auto; }
.preview-area .valuable { border-left: 3px solid #10b981; padding-left: 12px; margin: 8px 0; }
.preview-area .filtered { border-left: 3px solid #ef4444; padding-left: 12px; margin: 8px 0; opacity: 0.5; }
- /* Mac client download */
- .download-section { text-align: center; padding: 20px; }
+ /* Client download */
+ .download-section { text-align: center; padding: 10px; }
.download-section p { color: #666; font-size: 13px; margin: 8px 0; }
code { background: #f0f2f5; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
/* Footer */
- .footer { text-align: center; color: #999; font-size: 12px; padding: 20px 0; }
+ .footer { text-align: center; color: #999; font-size: 11px; padding: 16px 0; }
/* Empty state */
.empty-state { text-align: center; padding: 40px; color: #999; }
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
+
+ /* Scrollbar */
+ ::-webkit-scrollbar { width: 6px; }
+ ::-webkit-scrollbar-track { background: transparent; }
+ ::-webkit-scrollbar-thumb { background: #ccc; border-radius: 3px; }
+ ::-webkit-scrollbar-thumb:hover { background: #aaa; }
@@ -98,7 +140,7 @@
-
+
@@ -128,19 +170,20 @@
-
-
-
+
+
+
+
📥 实时采集
粘贴对话文本,引擎自动判断价值并脱敏存储
-
+
-
+
@@ -170,9 +213,9 @@
📋 安装步骤
- - 下载
mac-corpus-agent.py 到你的 Mac
+ - 下载
mac_client.py 到你的 Mac
- 安装依赖:
pip3 install pyautogui pillow websocket-client
- - 运行:
python3 mac-corpus-agent.py --token YOUR_TOKEN
+ - 运行:
python3 mac_client.py --token YOUR_TOKEN
- 选择模式:
--mode auto(实时)或 --mode scroll(滚屏)
@@ -198,11 +241,54 @@
+
+
+
+
+
🤖 语料助手对话
+
+ 0/30 轮
+
+
+
+
+
+
+
+
+ 👋 你好!我是语料助手,基于 DeepSeek/智谱/通义/火山 引擎驱动。
+ 我可以帮你分析语料、讨论技术方案、解答训练相关的问题。
+ 当前使用「铸渊」人格 + DeepSeek 引擎 · 支持30轮连续对话
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
对话将自动保留最近30轮上下文
+
+
@@ -210,7 +296,11 @@
let token = localStorage.getItem('corpus_token') || '';
let username = '';
const API = '';
+let chatInitialized = false;
+let chatAbortController = null;
+let isStreaming = false;
+// ─── API Helper ─────────────────────────────────────────────
async function api(method, path, body) {
const url = `${API}${path}${path.includes('?') ? '&' : '?'}token=${token}`;
const opts = { method, headers: {'Content-Type': 'application/json'} };
@@ -219,7 +309,7 @@ async function api(method, path, body) {
return resp.json();
}
-// --- Login ---
+// ─── Login ─────────────────────────────────────────────────
async function login() {
const t = document.getElementById('token-input').value.trim();
if (!t) return alert('请输入Token');
@@ -248,7 +338,7 @@ function logout() {
document.getElementById('main-section').classList.add('hidden');
}
-// Auto login on load
+// Auto login
if (token) {
api('GET', '/api/auth/check').then(d => {
if (d.ok) {
@@ -264,15 +354,15 @@ if (token) {
});
}
-// --- Tabs ---
-function switchTab(name) {
+// ─── Tabs ──────────────────────────────────────────────────
+function switchTab(name, btn) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('[id^="tab-"]').forEach(t => t.classList.add('hidden'));
- event.target.classList.add('active');
+ if (btn) btn.classList.add('active');
document.getElementById('tab-' + name).classList.remove('hidden');
}
-// --- Collect ---
+// ─── Collect ───────────────────────────────────────────────
async function collectText() {
const text = document.getElementById('input-text').value.trim();
if (!text) return;
@@ -289,7 +379,7 @@ async function collectText() {
}
}
-// --- Stats ---
+// ─── Stats ─────────────────────────────────────────────────
async function loadStats() {
const r = await api('GET', '/api/corpus/stats');
if (!r.ok) return;
@@ -299,7 +389,7 @@ async function loadStats() {
document.getElementById('sample-count').textContent = (r.total_samples || 0) + ' 条语料';
}
-// --- Samples ---
+// ─── Samples ───────────────────────────────────────────────
async function loadSamples() {
const r = await api('GET', '/api/corpus/list?page=1&size=50');
if (!r.ok) return;
@@ -323,7 +413,7 @@ async function loadSamples() {
container.innerHTML = html;
}
-// --- Export ---
+// ─── Export ────────────────────────────────────────────────
async function exportCorpus() {
const r = await fetch(`${API}/api/corpus/export?token=${token}&format=jsonl`);
if (r.ok) {
@@ -335,7 +425,269 @@ async function exportCorpus() {
}
}
+// ─── Chat ──────────────────────────────────────────────────
+let streamBuffer = '';
+
+function initChat() {
+ if (chatInitialized) return;
+ chatInitialized = true;
+ loadChatHistory();
+}
+
+function addChatMessage(role, content, isHtml) {
+ const container = document.getElementById('chat-messages');
+ const div = document.createElement('div');
+ div.className = 'chat-message ' + role;
+
+ const roleLabels = { user: '你', assistant: '助手', system: '系统' };
+ const dotColors = { user: 'user', assistant: 'assistant', system: 'system' };
+
+ let contentHtml = isHtml ? content : escapeHtml(content);
+ // Convert markdown-style bold and code for assistant messages
+ if (role === 'assistant' && !isHtml) {
+ contentHtml = contentHtml
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/`(.+?)`/g, '$1')
+ .replace(/\n/g, '
');
+ } else if (role === 'user' && !isHtml) {
+ contentHtml = contentHtml.replace(/\n/g, '
');
+ }
+
+ div.innerHTML = `
+ ${roleLabels[role] || role}
+ ${contentHtml}
+ `;
+
+ container.appendChild(div);
+ container.scrollTop = container.scrollHeight;
+ return div;
+}
+
+function showTyping() {
+ const container = document.getElementById('chat-messages');
+ const div = document.createElement('div');
+ div.className = 'chat-message assistant';
+ div.id = 'typing-indicator';
+ div.innerHTML = `
+ 助手
+
+ `;
+ container.appendChild(div);
+ container.scrollTop = container.scrollHeight;
+}
+
+function removeTyping() {
+ const el = document.getElementById('typing-indicator');
+ if (el) el.remove();
+}
+
+async function loadChatHistory() {
+ try {
+ const r = await api('GET', '/api/corpus/chat/history');
+ if (r.ok) {
+ document.getElementById('chat-rounds-display').textContent = `${r.rounds}/${r.max_rounds} 轮`;
+ }
+ } catch(e) {}
+}
+
+async function sendChatMessage() {
+ if (isStreaming) return;
+
+ const input = document.getElementById('chat-input');
+ const message = input.value.trim();
+ if (!message) return;
+
+ input.value = '';
+ isStreaming = true;
+
+ // Disable send, show stop
+ document.getElementById('chat-send-btn').classList.add('hidden');
+ document.getElementById('chat-stop-btn').classList.remove('hidden');
+
+ // Add user message
+ addChatMessage('user', message);
+
+ // Show typing indicator
+ showTyping();
+
+ // Get config
+ const persona = document.getElementById('chat-persona').value;
+ const engine = document.getElementById('chat-engine').value;
+
+ // Build SSE URL
+ const url = `${API}/api/corpus/chat?token=${token}`;
+
+ try {
+ chatAbortController = new AbortController();
+ streamBuffer = '';
+
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ message: message,
+ persona: persona,
+ engine: engine
+ }),
+ signal: chatAbortController.signal
+ });
+
+ if (!resp.ok) {
+ removeTyping();
+ addChatMessage('system', `❌ 请求失败 (${resp.status})`);
+ isStreaming = false;
+ document.getElementById('chat-send-btn').classList.remove('hidden');
+ document.getElementById('chat-stop-btn').classList.add('hidden');
+ return;
+ }
+
+ // Read SSE stream
+ const reader = resp.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let fullText = '';
+ let hasContent = false;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ // Process complete SSE events
+ while (buffer.includes('\n\n')) {
+ const idx = buffer.indexOf('\n\n');
+ const event = buffer.slice(0, idx);
+ buffer = buffer.slice(idx + 2);
+
+ if (event.startsWith('data: ')) {
+ const dataStr = event.slice(6).trim();
+
+ if (dataStr === '[DONE]') continue;
+
+ try {
+ const data = JSON.parse(dataStr);
+ const type = data.type;
+
+ if (type === 'persona-loaded') {
+ // Remove typing, show persona loaded
+ removeTyping();
+ hasContent = true;
+ // Start a new bubble for the assistant
+ addChatMessage('assistant', '');
+ streamBuffer = '';
+ } else if (type === 'token') {
+ const token = data.token || '';
+ fullText += token;
+ streamBuffer += token;
+
+ if (hasContent) {
+ // Update the last assistant message
+ const container = document.getElementById('chat-messages');
+ const lastMsg = container.lastElementChild;
+ if (lastMsg && lastMsg.classList.contains('assistant')) {
+ const bubble = lastMsg.querySelector('.bubble');
+ if (bubble) {
+ let displayText = fullText
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/`(.+?)`/g, '$1')
+ .replace(/\n/g, '
');
+ bubble.innerHTML = displayText;
+ }
+ }
+ } else {
+ removeTyping();
+ addChatMessage('assistant', token);
+ fullText = token;
+ streamBuffer = token;
+ hasContent = true;
+ }
+
+ // Auto scroll
+ const container = document.getElementById('chat-messages');
+ container.scrollTop = container.scrollHeight;
+
+ } else if (type === 'tool-call') {
+ addChatMessage('system', `🔧 正在调用工具: ${data.name || data.tool}`);
+ } else if (type === 'tool-result') {
+ // Tool result, continue
+ } else if (type === 'done') {
+ // Finished
+ if (data.rounds) {
+ document.getElementById('chat-rounds-display').textContent = `${data.rounds}/${data.max_rounds || 30} 轮`;
+ }
+ } else if (type === 'error') {
+ removeTyping();
+ addChatMessage('system', `⚠️ ${data.message || '未知错误'}`);
+ }
+ } catch(e) {
+ // Skip malformed JSON
+ }
+ }
+ }
+ }
+
+ // Handle leftover buffer
+ if (buffer.startsWith('data: ')) {
+ const dataStr = buffer.slice(6).trim();
+ if (dataStr !== '[DONE]') {
+ try {
+ const data = JSON.parse(dataStr);
+ if (data.type === 'token') {
+ fullText += data.token || '';
+ }
+ } catch(e) {}
+ }
+ }
+
+ } catch (e) {
+ if (e.name === 'AbortError') {
+ addChatMessage('system', '⏹️ 对话已停止');
+ } else {
+ removeTyping();
+ addChatMessage('system', `❌ 连接错误: ${e.message}`);
+ }
+ }
+
+ // Cleanup
+ removeTyping();
+ isStreaming = false;
+ chatAbortController = null;
+ document.getElementById('chat-send-btn').classList.remove('hidden');
+ document.getElementById('chat-stop-btn').classList.add('hidden');
+}
+
+function stopChatStream() {
+ if (chatAbortController) {
+ chatAbortController.abort();
+ }
+}
+
+async function clearChat() {
+ if (!confirm('确定清空对话记忆吗?')) return;
+ const r = await api('POST', '/api/corpus/chat/clear');
+ if (r.ok) {
+ // Clear messages
+ const container = document.getElementById('chat-messages');
+ container.innerHTML = `
+
+
+ 👋 记忆已清空,开始新的对话吧!
+ 当前使用「铸渊」人格 + DeepSeek 引擎 · 支持30轮连续对话
+
+
+ `;
+ document.getElementById('chat-rounds-display').textContent = '0/30 轮';
+ }
+}
+
+// ─── Utilities ─────────────────────────────────────────────
function escapeHtml(s) {
+ if (typeof s !== 'string') s = String(s || '');
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;