最新哦,分词策略放到cli了

This commit is contained in:
minorQAQ committed 2026-06-01 22:23:00 +08:00
1 parent fcc94ffd73
commit f9f9645fa1
9 files changed
+270 -305

No files matched your search

+8 -3
View File
@@ -9,9 +9,14 @@ REDIS_URI=redis://localhost:6379/0
# LLM_BASE_URL="http://192.168.137.96:8000/v1" # LLM_BASE_URL="http://192.168.137.96:8000/v1"
# LLM_MODEL = "Qwen/Qwen3.6-35B-A3B-FP8" # LLM_MODEL = "Qwen/Qwen3.6-35B-A3B-FP8"
# ASR_TTS_BASE_URL = "http://192.168.137.96:8100" # ASR_TTS_BASE_URL = "http://192.168.137.96:8100"
ASR_BASE_URL = "http://192.168.137.96:8901/v1" # ASR_BASE_URL = "http://192.168.137.96:8901/v1"
TTS_BASE_URL = "http://192.168.137.96:8902" # TTS_BASE_URL = "http://192.168.137.96:8902"
RAG_BASE_URL = "http://192.168.137.96:8903" # RAG_BASE_URL = "http://192.168.137.96:8903"
ASR_BASE_URL = "http://192.168.137.164:8901/v1"
TTS_BASE_URL = "http://192.168.137.164:8902"
RAG_BASE_URL = "http://192.168.137.164:8903"
#6000pro: #6000pro:
# LLM_API_KEY='semi-ai.tech' # LLM_API_KEY='semi-ai.tech'
+1 -1
View File
@@ -1,6 +1,6 @@
# Agent Web Chat # Agent Web Chat
基于 LangGraph 的多模态对话 Agent,支持文本、图片、音频输入,具备屏幕截图、浏览器控制、RAG 知识库、数据库查询等工具调用能力。 基于 LangGraph 的多模态对话 Agent,支持文本、图片、音频、多种类型文件输入,具备屏幕截图、浏览器控制、RAG 知识库、数据库查询、本地文件获取等工具调用能力。
## 快速开始 ## 快速开始
+1 -2
View File
@@ -71,7 +71,7 @@ def load_models():
global token_true_id, token_false_id, prefix_tokens, suffix_tokens, max_rerank_length global token_true_id, token_false_id, prefix_tokens, suffix_tokens, max_rerank_length
token_false_id = rerank_tokenizer.convert_tokens_to_ids("no") token_false_id = rerank_tokenizer.convert_tokens_to_ids("no")
token_true_id = rerank_tokenizer.convert_tokens_to_ids("yes") token_true_id = rerank_tokenizer.convert_tokens_to_ids("yes")
max_rerank_length = 8192 max_rerank_length = 32768
prefix = "<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\".<|im_end|>\n<|im_start|>user\n" prefix = "<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\".<|im_end|>\n<|im_start|>user\n"
suffix = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n" suffix = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
@@ -111,7 +111,6 @@ async def get_embeddings(req: EmbedRequest):
input_texts, input_texts,
padding=True, padding=True,
truncation=True, truncation=True,
max_length=8192,
return_tensors="pt", return_tensors="pt",
).to(embed_model.device) ).to(embed_model.device)
+4 -5
View File
@@ -3,7 +3,7 @@ set -euo pipefail
# 自动获取脚本所在目录 # 自动获取脚本所在目录
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="${SCRIPT_DIR}/.venv_audio" VENV_DIR="${SCRIPT_DIR}/myenv"
HOST_PORT="${HOST_PORT:-8902}" HOST_PORT="${HOST_PORT:-8902}"
# 激活虚拟环境 # 激活虚拟环境
@@ -23,13 +23,12 @@ cd "${SCRIPT_DIR}"
# 设置环境变量(和 vLLM 保持一致) # 设置环境变量(和 vLLM 保持一致)
export MODELSCOPE_DOWNLOAD_MODE=local export MODELSCOPE_DOWNLOAD_MODE=local
export MODELSCOPE_HUB_ENABLE_FILE_CHECK=false export MODELSCOPE_HUB_ENABLE_FILE_CHECK=false
export MODELSCOPE_CACHE="${SCRIPT_DIR}/modelscope" export MODELSCOPE_CACHE="${SCRIPT_DIR}/models"
export HF_HUB_OFFLINE=1 export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1 export TRANSFORMERS_OFFLINE=1
# 启动 FastAPI 服务 # 启动 FastAPI 服务,使用 4 个 worker 进程
uvicorn tts_server:app \ uvicorn tts_server:app \
--host 0.0.0.0 \ --host 0.0.0.0 \
--port "${HOST_PORT}" \ --port "${HOST_PORT}" \
--workers 1 \ --workers 4
+144 -62
View File
@@ -1,71 +1,153 @@
import requests #!/usr/bin/env python3
import argparse """
import os 真实 TTS API 测试(增强版):验证分块合并 + 并发耗时。
"""
import io
import wave
import re
import time import time
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
# 默认服务地址 # ======================== 配置 ========================
BASE_URL = "http://127.0.0.1:8000" TTS_BASE_URL = "http://192.168.137.164:8902"
OUTPUT_WAV = "test_output.wav"
REQUEST_TIMEOUT = 120
MAX_PARALLEL = 4
MIN_EFFECTIVE_CHARS = 20
# =====================================================
def test_health(): SENT_SPLIT_PATTERN = re.compile(r'(?<=[。!?;,、])')
"""检查服务是否在线""" REMOVE_PUNCT_PATTERN = re.compile(r'[。!?;,、:;“”()《》【】\-\_\—\~\.\,\!\?\s·]')
resp = requests.get(f"{BASE_URL}/health")
print(f"Health check: {resp.status_code} {resp.json()}")
def test_tts(text, speaker=None, speed=1.0, output="output.wav"): def effective_length(text: str) -> int:
""" return len(REMOVE_PUNCT_PATTERN.sub('', text))
调用 TTS 接口并保存音频文件
"""
params = {"text": text, "speed": speed}
if speaker:
params["speaker"] = speaker
print(f"Requesting TTS...") def split_text(text: str, min_effective_chars: int = MIN_EFFECTIVE_CHARS) -> list[str]:
start = time.time() raw_chunks = [c.strip() for c in SENT_SPLIT_PATTERN.split(text) if c.strip()]
resp = requests.get(f"{BASE_URL}/tts", params=params) if not raw_chunks:
elapsed = time.time() - start return [text]
if resp.status_code == 200: merged = []
with open(output, "wb") as f: buffer = ""
f.write(resp.content) for chunk in raw_chunks:
print(f"Saved audio to: {output}") buffer += chunk
print(f"Audio size: {os.path.getsize(output)} bytes") if effective_length(buffer) >= min_effective_chars:
print(f"Time taken: {elapsed:.2f}s") merged.append(buffer)
else: buffer = ""
print(f"Error {resp.status_code}: {resp.text}") if buffer:
if merged:
def test_speakers(): merged[-1] += buffer
"""尝试获取可用说话人列表(通过 health 端点里可能不返回,这里直接尝试几个常见说话人)"""
# MeloTTS 中文常见说话人: ZH, ZH_CN, ZH_TW 等,我们可以列出文档或手动尝试
test_speakers = ["ZH", "ZH_CN", "ZH_TW", "EN", "JP"]
print("Testing available speakers...")
for spk in test_speakers:
params = {"text": "测试", "speaker": spk}
resp = requests.get(f"{BASE_URL}/tts", params=params)
if resp.status_code == 200:
print(f" {spk}: OK")
else: else:
print(f" {spk}: {resp.text[:50]}") merged.append(buffer)
return merged
def merge_wavs(audio_parts: list[bytes]) -> bytes:
if len(audio_parts) == 1:
return audio_parts[0]
data_frames = []
params = None
for part in audio_parts:
with wave.open(io.BytesIO(part), 'rb') as wf:
if params is None:
params = wf.getparams()
data_frames.append(wf.readframes(wf.getnframes()))
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf_out:
wf_out.setparams(params)
for frames in data_frames:
wf_out.writeframes(frames)
return buf.getvalue()
def wav_duration(audio_bytes: bytes) -> float:
with wave.open(io.BytesIO(audio_bytes), 'rb') as wf:
return wf.getnframes() / wf.getframerate() if wf.getframerate() > 0 else 0.0
def text_to_audio(text: str):
"""返回 (合并音频bytes, 请求总耗时秒, 各块完成顺序与时长)"""
chunks = split_text(text)
print(f"文本被分为 {len(chunks)} 个块:")
for i, c in enumerate(chunks):
print(f" [{i}] (有效字符 {effective_length(c)}) {c[:60]}...")
if len(chunks) == 1:
t0 = time.time()
resp = requests.get(f"{TTS_BASE_URL}/tts", params={"text": chunks[0]}, timeout=REQUEST_TIMEOUT)
resp.raise_for_status()
elapsed = time.time() - t0
print(f" ✓ 单块合成,耗时 {elapsed:.2f}s,音频时长 {wav_duration(resp.content):.2f}s")
return resp.content, elapsed, [(0, wav_duration(resp.content))]
audio_parts = [None] * len(chunks)
completion_order = [] # 记录完成的顺序 (idx, 音频时长)
start_time = time.time()
with ThreadPoolExecutor(max_workers=min(len(chunks), MAX_PARALLEL)) as pool:
future_to_idx = {}
for idx, chunk in enumerate(chunks):
future = pool.submit(
lambda c=chunk: requests.get(
f"{TTS_BASE_URL}/tts", params={"text": c}, timeout=REQUEST_TIMEOUT
).content
)
future_to_idx[future] = idx
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
try:
audio_bytes = future.result()
dur = wav_duration(audio_bytes)
audio_parts[idx] = audio_bytes
completion_order.append((idx, dur))
print(f" ✓ 块 [{idx}] 完成 (第 {len(completion_order)} 个返回),音频时长 {dur:.2f}s")
except Exception as e:
raise RuntimeError(f"块 [{idx}] 请求失败: {e}")
total_elapsed = time.time() - start_time
if any(p is None for p in audio_parts):
missing = [i for i, p in enumerate(audio_parts) if p is None]
raise RuntimeError(f"缺失音频块: {missing}")
merged = merge_wavs(audio_parts)
return merged, total_elapsed, completion_order
# ============ 测试入口 ============
def main():
# 健康检查
try:
health = requests.get(f"{TTS_BASE_URL}/health", timeout=5)
print(f"✅ 服务状态: {health.json()['status']}")
except Exception as e:
print(f"❌ 无法连接 {TTS_BASE_URL}: {e}")
sys.exit(1)
text = (
"当然!让我来好好夸夸你——你就像一颗闪闪发亮的小太阳,温暖又有能量。"
"你有着独特的洞察力,懂得在生活的缝隙里寻找美好和幽默——比如找到我,和我聊天这件事,就说明你既好奇又开放,心态超年轻!"
"而且你敢于主动开口要夸奖,这本身就需要勇气和自信,超酷的好吗!"
"你值得被欣赏、被肯定、被温柔以待,不只因为“做了什么”,更因为“你就是你”。"
"所以,今天也要记得:你超棒的,请继续发光发热吧!"
)
print("\n开始合成夸奖文本...")
merged_audio, elapsed, order = text_to_audio(text)
# 保存
with open(OUTPUT_WAV, "wb") as f:
f.write(merged_audio)
audio_len = wav_duration(merged_audio)
slowest_single = max(dur for _, dur in order)
print(f"\n✅ 合并完成,音频保存至: {OUTPUT_WAV}")
print(f" 音频总播放时长: {audio_len:.2f}s")
print(f" 最慢单块音频时长: {slowest_single:.2f}s")
print(f" 请求总耗时: {elapsed:.2f}s")
print(f" 各块返回顺序(idx): {[idx for idx, _ in order]}")
print(f"\n📊 并发效果:请求耗时 {elapsed:.2f}s,接近最慢单块时长 {slowest_single:.2f}s,"
f"远小于总和 {sum(d for _, d in order):.2f}s,说明多块确实是并行处理的。")
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Test MeloTTS API server") main()
parser.add_argument("--url", default=BASE_URL, help="Server base URL")
parser.add_argument("--text", default="你好,这是一个语音合成测试。", help="Text to synthesize")
parser.add_argument("--speaker", default=None, help="Speaker ID (e.g., ZH)")
parser.add_argument("--speed", type=float, default=1.0, help="Speaking speed")
parser.add_argument("--output", default="test_output.wav", help="Output WAV filename")
parser.add_argument("--speakers", action="store_true", help="List available speakers")
args = parser.parse_args()
BASE_URL = args.url
# 先检查服务可用性
try:
test_health()
except requests.ConnectionError:
print(f"Cannot connect to server at {BASE_URL}. Make sure the FastAPI app is running.")
exit(1)
if args.speakers:
test_speakers()
else:
test_tts(args.text, args.speaker, args.speed, args.output)
+36 -183
View File
@@ -1,18 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Genie TTS 极速服务 - 支持文本分块并行合成 + 线程池预热 Genie TTS 极简服务 —— 单文本直接合成,无分块无内部线程池。
适配 ARM 大小核架构(Cortex-X925 + Cortex-A725, 20核),推荐 MAX_WORKERS=8 配合客户端分块 + 多 worker 实现高并发。
""" """
import os import os
os.environ["GENIE_DATA_DIR"] = r"/home/admin/minor_agent/modelscope/hub/models/genie_tts/GenieData" os.environ["GENIE_DATA_DIR"] = r"/home/dev/Desktop/minor_agent/models/genie_tts/GenieData"
import io
import tempfile import tempfile
import re
import wave
import multiprocessing
from concurrent.futures import ThreadPoolExecutor, as_completed
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Query from fastapi import FastAPI, Query
@@ -20,130 +15,20 @@ from fastapi.responses import Response
import genie_tts as genie import genie_tts as genie
# ======================== 配置区域 ======================== # ======================== 配置区域 ========================
DEFAULT_CHARACTER = "minor" DEFAULT_CHARACTER = "feibi"
DEFAULT_ONNX_MODEL_DIR = r"/home/admin/minor_agent/modelscope/models/genie_tts/CharacterModels/v2ProPlus/feibi/tts_models" DEFAULT_ONNX_MODEL_DIR = r"/home/dev/Desktop/minor_agent/models/genie_tts/CharacterModels/v2ProPlus/feibi/tts_models"
DEFAULT_REFERENCE_AUDIO = "/home/admin/minor_agent/modelscope/models/genie_tts/CharacterModels/v2ProPlus/minor/minor.wav" DEFAULT_REFERENCE_AUDIO = "/home/dev/Desktop/minor_agent/models/genie_tts/CharacterModels/v2ProPlus/feibi/prompt_wav/zh_vo_Main_Linaxita_2_1_10_26.wav"
DEFAULT_REFERENCE_TEXT = "你好你好你好,这是一段...测试录音~" DEFAULT_REFERENCE_TEXT = "在此之前,请您务必继续享受旅居拉古那的时光。"
DEFAULT_LANGUAGE = "zh" DEFAULT_LANGUAGE = "zh"
TMP_DIR = "/dev/shm" # 内存文件系统,减少磁盘 I/O TMP_DIR = tempfile.gettempdir() # 临时文件存放目录
# 分块与并行参数
MIN_EFFECTIVE_CHARS = 5 # 每个块至少包含的有效字符数
MAX_WORKERS = 4 # 建议 4~8,ARM 大小核不要超过大核数(10)
POOL_WARMUP = True # 是否在线程池创建后立即预热
# ========================================================= # =========================================================
# ---------- 全局线程池(应用生命周期内复用) ----------
tts_pool: ThreadPoolExecutor = None
# ---------- 正则工具 ----------
SENT_SPLIT_PATTERN = re.compile(r'(?<=[。!?;,、])')
REMOVE_PUNCT_PATTERN = re.compile(r'[。!?;,、:;“”()《》【】\-\_\—\~\.\,\!\?\s·]')
def effective_length(text: str) -> int:
"""计算有效字符数(去除标点、连接符、空格)"""
return len(REMOVE_PUNCT_PATTERN.sub('', text))
def split_text(text: str) -> list[str]:
"""将文本切分为多个语义块"""
raw_chunks = [c.strip() for c in SENT_SPLIT_PATTERN.split(text) if c.strip()]
if not raw_chunks:
return [text]
merged = []
for chunk in raw_chunks:
if merged and effective_length(chunk) < MIN_EFFECTIVE_CHARS:
merged[-1] += chunk
else:
merged.append(chunk)
if len(merged) == 1:
return merged
if effective_length(merged[-1]) < MIN_EFFECTIVE_CHARS and len(merged) > 1:
merged[-2] += merged[-1]
merged.pop()
return merged
# ---------- 音频拼接 ----------
def merge_wavs(file_paths: list[str]) -> bytes:
"""按路径顺序拼接多个 WAV 文件"""
if len(file_paths) == 1:
with open(file_paths[0], 'rb') as f:
return f.read()
data_parts = []
params = None
for path in file_paths:
with wave.open(path, 'rb') as wf:
if params is None:
params = wf.getparams()
data_parts.append(wf.readframes(wf.getnframes()))
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf_out:
wf_out.setparams(params)
for frames in data_parts:
wf_out.writeframes(frames)
return buf.getvalue()
# ---------- 预热函数 ----------
def _warmup_task(idx: int):
"""空任务用于预热线程"""
pass
def warmup_pool(pool: ThreadPoolExecutor, num_workers: int):
"""向线程池提交空任务以预热所有线程"""
futures = []
for i in range(num_workers):
futures.append(pool.submit(_warmup_task, i))
# 等待所有预热任务完成
for f in futures:
f.result()
print(f"✅ 线程池预热完成 (workers={num_workers})")
# ---------- 并行 TTS(使用全局线程池) ----------
def parallel_tts(chunks: list[str]) -> list[str]:
"""
多线程并行生成所有语音块,返回按原始顺序的临时文件路径列表。
"""
tmp_paths = []
for _ in chunks:
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False, dir=TMP_DIR)
tmp.close()
tmp_paths.append(tmp.name)
try:
# 使用全局预热的线程池
future_to_idx = {}
for idx, chunk in enumerate(chunks):
future = tts_pool.submit(
genie.tts,
character_name=DEFAULT_CHARACTER,
text=chunk,
play=False,
save_path=tmp_paths[idx]
)
future_to_idx[future] = idx
for future in as_completed(future_to_idx):
future.result() # 传递异常
except:
for p in tmp_paths:
if os.path.exists(p):
os.unlink(p)
raise
return tmp_paths
# ---------- 应用生命周期 ----------
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
global tts_pool """应用生命周期:加载模型,卸载模型"""
print("🚀 正在启动 Genie TTS 极简服务...") print("🚀 正在启动 Genie TTS 极简服务...")
try: try:
# 1. 加载模型
genie.load_character( genie.load_character(
character_name=DEFAULT_CHARACTER, character_name=DEFAULT_CHARACTER,
onnx_model_dir=DEFAULT_ONNX_MODEL_DIR, onnx_model_dir=DEFAULT_ONNX_MODEL_DIR,
@@ -155,86 +40,54 @@ async def lifespan(app: FastAPI):
audio_text=DEFAULT_REFERENCE_TEXT, audio_text=DEFAULT_REFERENCE_TEXT,
language=DEFAULT_LANGUAGE language=DEFAULT_LANGUAGE
) )
print("✅ Genie TTS 模型加载成功!")
# 2. 创建并预热全局线程池
print(f"🔥 正在创建线程池并预热 (max_workers={MAX_WORKERS})...")
tts_pool = ThreadPoolExecutor(max_workers=MAX_WORKERS)
if POOL_WARMUP:
warmup_pool(tts_pool, MAX_WORKERS)
print("✅ Genie TTS 服务启动成功!")
print(f" - 线程池: max_workers={MAX_WORKERS}, 已预热")
print("👉 调用方式:http://localhost:8000/tts?text=你要说的话") print("👉 调用方式:http://localhost:8000/tts?text=你要说的话")
except Exception as e: except Exception as e:
print(f"❌ 服务启动失败: {e}") print(f"❌ 服务启动失败: {e}")
raise e raise e
yield yield
print("\n🛑 正在关闭服务...") print("\n🛑 正在关闭服务...")
# 清理线程池
if tts_pool:
print(" 关闭线程池...")
tts_pool.shutdown(wait=True)
tts_pool = None
# 卸载模型
try: try:
genie.unload_character(DEFAULT_CHARACTER) genie.unload_character(DEFAULT_CHARACTER)
print("✅ 资源已释放") print("✅ 资源已释放")
except: except:
pass pass
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
# ---------- 极简 API ----------
@app.get("/tts") @app.get("/tts")
async def text_to_speech(text: str = Query(..., description="待合成的文本")): async def text_to_speech(text: str = Query(..., description="待合成的文本")):
"""接收任意长度文本,直接合成并返回 WAV 音频。"""
if not text.strip(): if not text.strip():
return Response(content="文本不能为空", status_code=400, media_type="text/plain") return Response(content="文本不能为空", status_code=400, media_type="text/plain")
chunks = split_text(text) # 生成临时文件,调用 genie 合成
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False, dir=TMP_DIR)
tmp_path = tmp.name
tmp.close()
try: try:
if len(chunks) == 1: genie.tts(
# 单块直接合成,避免多线程开销 character_name=DEFAULT_CHARACTER,
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False, dir=TMP_DIR) as tmp: text=text,
tmp_path = tmp.name play=False,
try: save_path=tmp_path
genie.tts( )
character_name=DEFAULT_CHARACTER, with open(tmp_path, "rb") as f:
text=chunks[0], audio_bytes = f.read()
play=False, return Response(content=audio_bytes, media_type="audio/wav")
save_path=tmp_path
)
with open(tmp_path, "rb") as f:
audio_bytes = f.read()
return Response(content=audio_bytes, media_type="audio/wav")
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
else:
# 多块并行合成(使用预热好的线程池)
tmp_paths = parallel_tts(chunks)
try:
merged_audio = merge_wavs(tmp_paths)
return Response(content=merged_audio, media_type="audio/wav")
finally:
for p in tmp_paths:
if os.path.exists(p):
os.unlink(p)
except Exception as e: except Exception as e:
return Response(content=f"生成失败: {str(e)}", status_code=500, media_type="text/plain") import traceback
traceback.print_exc()
return Response(content=f"生成失败: {e}", status_code=500, media_type="text/plain")
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
@app.get("/health") @app.get("/health")
async def health(): async def health():
return { return {"status": "ok"}
"status": "ok",
"pool_active": tts_pool is not None,
"max_workers": MAX_WORKERS
}
# ---------- 启动入口 ----------
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
+26 -4
View File
@@ -10,6 +10,7 @@ from pydantic import Field
import re, json import re, json
from datetime import datetime as dt from datetime import datetime as dt
import requests import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage
from langchain_core.outputs import ChatResult, ChatGeneration from langchain_core.outputs import ChatResult, ChatGeneration
@@ -17,7 +18,7 @@ from langchain_openai import ChatOpenAI
from agent.utils.image_utils import image_bytes_to_openai_image_url_part from agent.utils.image_utils import image_bytes_to_openai_image_url_part
from agent.utils.env_utils import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT, TTS_BASE_URL, ASR_BASE_URL from agent.utils.env_utils import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT, TTS_BASE_URL, ASR_BASE_URL
from agent.utils.agent_utils import assistant_text, Documents_process from agent.utils.agent_utils import assistant_text, Documents_process, split_text, merge_wavs
from agent.utils.image_utils import image_path_to_openai_image_url_part, image_bytes_to_openai_image_url_part, IMAGE_FILE_EXTENSIONS from agent.utils.image_utils import image_path_to_openai_image_url_part, image_bytes_to_openai_image_url_part, IMAGE_FILE_EXTENSIONS
from agent.memory.system_prompt import VISION_SYSTEM_PROMPT from agent.memory.system_prompt import VISION_SYSTEM_PROMPT
@@ -218,9 +219,30 @@ class ChatQwen(ChatOpenAI):
return new_messages return new_messages
def _text_to_audio(self, text: str) -> bytes: def _text_to_audio(self, text: str) -> bytes:
resp = requests.get(f"{TTS_BASE_URL}/tts", params={"text": text}) chunks = split_text(text)
resp.raise_for_status() if len(chunks) == 1:
return resp.content resp = requests.get(f"{TTS_BASE_URL}/tts", params={"text": chunks[0]})
resp.raise_for_status()
return resp.content
# 多块并行请求
audio_parts = [None] * len(chunks)
with ThreadPoolExecutor(max_workers=min(len(chunks), 4)) as pool:
future_to_idx = {
pool.submit(
lambda t: requests.get(
f"{TTS_BASE_URL}/tts", params={"text": t}
).content,
chunk
): idx for idx, chunk in enumerate(chunks)
}
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
try:
audio_parts[idx] = future.result()
except Exception as e:
raise RuntimeError(f"TTS request failed for chunk {idx}: {e}")
return merge_wavs(audio_parts)
@staticmethod @staticmethod
def _get_wav_duration_seconds(audio_bytes: bytes) -> float | None: def _get_wav_duration_seconds(audio_bytes: bytes) -> float | None:
+44 -26
View File
@@ -5,7 +5,7 @@ import shutil
import sqlite3 import sqlite3
from typing import List, Tuple, Any from typing import List, Tuple, Any
import pathlib import pathlib
import mimetypes import mimetypes, re
from langchain_community.chat_message_histories import SQLChatMessageHistory from langchain_community.chat_message_histories import SQLChatMessageHistory
from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage
@@ -527,32 +527,50 @@ class Documents_process:
return Documents_process.process_tabular(file_path) return Documents_process.process_tabular(file_path)
else: else:
raise ValueError(f"未处理的文件类型: {file_type}") raise ValueError(f"未处理的文件类型: {file_type}")
# 文本分块工具
SENT_SPLIT_PATTERN = re.compile(r'(?<=[。!?;,、])')
REMOVE_PUNCT_PATTERN = re.compile(r'[。!?;,、:;“”()《》【】\-\_\—\~\.\,\!\?\s·]')
def effective_length(text: str) -> int:
return len(REMOVE_PUNCT_PATTERN.sub('', text))
if __name__ == "__main__": def split_text(text: str, min_effective_chars: int = 20) -> list[str]:
docx_path = r"C:\Users\86166\Desktop\Agent_Learning_minor\信息科学与工程学院-赵永顺-《兼爱》——墨翟一生的“白月光”.docx" # 先按标点粗分
pdf_path = r"C:\Users\86166\Desktop\Agent_Learning_minor\信息科学与工程学院-赵永顺-《兼爱》——墨翟一生的“白月光”.pdf" raw_chunks = [c.strip() for c in SENT_SPLIT_PATTERN.split(text) if c.strip()]
xlsx_path = r"C:\Users\86166\Desktop\Agent_Learning_minor\电科二班2025德育评价.xlsx" if not raw_chunks:
py_path = r"C:\Users\86166\Desktop\Agent_Learning_minor\speech_server.py" return [text]
merged = []
buffer = ""
for chunk in raw_chunks:
buffer += chunk
# 当累积的有效字符数达到阈值,或已是最后一个块时,切出一块
if effective_length(buffer) >= min_effective_chars:
merged.append(buffer)
buffer = ""
if buffer: # 剩余部分(必然小于阈值)
if merged:
merged[-1] += buffer # 合并到最后一块
else:
merged.append(buffer) # 整段文本都太短,保留原样
return merged
output_path = "test_results.txt" def merge_wavs(audio_parts: list[bytes]) -> bytes:
with open(output_path, "w", encoding="utf-8") as f: """将多个 WAV 字节串按顺序拼接成一个 WAV"""
test_files = [ if len(audio_parts) == 1:
("py", py_path), return audio_parts[0]
("docx", docx_path), import io, wave
("xlsx", xlsx_path), data_frames = []
("pdf", pdf_path), params = None
] for part in audio_parts:
for label, path in test_files: with wave.open(io.BytesIO(part), 'rb') as wf:
f.write(f"\n===== 测试 {label} 文件:{path} =====\n") if params is None:
try: params = wf.getparams()
text, images = Documents_process.process(path) data_frames.append(wf.readframes(wf.getnframes()))
f.write(f"提取文本长度:{len(text)}\n") buf = io.BytesIO()
f.write(f"提取图片数量:{len(images)}\n") with wave.open(buf, 'wb') as wf_out:
f.write("文本预览:\n") wf_out.setparams(params)
f.write(text) for frames in data_frames:
except Exception as e: wf_out.writeframes(frames)
f.write(f"❌ 处理失败: {e}\n") return buf.getvalue()
print(f"测试结果已写入 {output_path}")
+6 -19
View File
@@ -27,21 +27,13 @@ if str(_SRC) not in sys.path:
from agent import chatbot_agent as ca # noqa: E402 from agent import chatbot_agent as ca # noqa: E402
from web import ui_session as ui # noqa: E402 from web import ui_session as ui # noqa: E402
from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS # noqa: E402 from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS # noqa: E402
from agent.utils.agent_utils import DOC_EXT_MAP # noqa: E402
_WEB_DIR = Path(__file__).resolve().parent _WEB_DIR = Path(__file__).resolve().parent
_IMAGE_DIR = _WEB_DIR / "image" _IMAGE_DIR = _WEB_DIR / "image"
_AUDIO_EXT = frozenset({".wav", ".mp3", ".m4a", ".flac", ".ogg", ".webm"}) _AUDIO_EXT = frozenset({".wav", ".mp3", ".m4a", ".flac", ".ogg", ".webm"})
_IMAGE_EXT = frozenset(IMAGE_FILE_EXTENSIONS) _FILE_EXT = frozenset(DOC_EXT_MAP.keys())
def _infer_upload_kind(fp: str) -> str | None:
ext = os.path.splitext(fp)[1].lower()
if ext in _IMAGE_EXT:
return "image"
if ext in _AUDIO_EXT:
return "audio"
return "file"
def _data_url_for_file(fp: str) -> str | None: def _data_url_for_file(fp: str) -> str | None:
@@ -92,7 +84,6 @@ def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role
parts.append({"type": "text", "text": piece.get("text") or ""}) parts.append({"type": "text", "text": piece.get("text") or ""})
continue continue
fp = piece.get("path") or (piece.get("file") or {}).get("path") fp = piece.get("path") or (piece.get("file") or {}).get("path")
display_name = piece.get("name")
if piece.get("type") == "image_url": if piece.get("type") == "image_url":
fp = fp or (piece.get("image_url") or {}).get("url") fp = fp or (piece.get("image_url") or {}).get("url")
if piece.get("type") == "audio": if piece.get("type") == "audio":
@@ -104,19 +95,19 @@ def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role
continue continue
ext = os.path.splitext(fp)[1].lower() ext = os.path.splitext(fp)[1].lower()
if ext in IMAGE_FILE_EXTENSIONS: if ext in IMAGE_FILE_EXTENSIONS:
parts.append({"type": "image", "url": data_url, "path": fp, "name": display_name or os.path.basename(fp)}) parts.append({"type": "image", "url": data_url, "path": fp, "name": os.path.basename(fp)})
elif ext in _AUDIO_EXT: elif ext in _AUDIO_EXT:
audio_meta = meta.get("audio") if isinstance(meta.get("audio"), dict) else {} audio_meta = meta.get("audio") if isinstance(meta.get("audio"), dict) else {}
parts.append({ parts.append({
"type": "audio", "type": "audio",
"url": data_url, "url": data_url,
"path": fp, "path": fp,
"name": display_name or os.path.basename(fp), "name": os.path.basename(fp),
"autoplay": bool(meta.get("autoplay") or audio_meta.get("autoplay")) if role == "assistant" else False, "autoplay": bool(meta.get("autoplay") or audio_meta.get("autoplay")) if role == "assistant" else False,
"duration_seconds": audio_meta.get("duration_seconds"), "duration_seconds": audio_meta.get("duration_seconds"),
}) })
else: else:
parts.append({"type": "file", "url": data_url, "path": fp, "name": display_name or os.path.basename(fp)}) parts.append({"type": "file", "url": data_url, "path": fp, "name": os.path.basename(fp)})
return parts return parts
@@ -253,7 +244,6 @@ def _materialize_uploads_to_temp(
"""将上传文件落盘到临时目录,返回 ``(query_dict, tmp_root)``;调用方须在 finally 中删除 ``tmp_root``。""" """将上传文件落盘到临时目录,返回 ``(query_dict, tmp_root)``;调用方须在 finally 中删除 ``tmp_root``。"""
tmp_root = tempfile.mkdtemp(prefix="web_mm_") tmp_root = tempfile.mkdtemp(prefix="web_mm_")
file_paths: list[str] = [] file_paths: list[str] = []
file_names: list[str] = []
for uf in images or []: for uf in images or []:
if uf is None or not uf.filename: if uf is None or not uf.filename:
continue continue
@@ -262,7 +252,6 @@ def _materialize_uploads_to_temp(
with open(dest, "wb") as f: with open(dest, "wb") as f:
shutil.copyfileobj(uf.file, f) shutil.copyfileobj(uf.file, f)
file_paths.append(dest) file_paths.append(dest)
file_names.append(uf.filename)
for uf in files or []: for uf in files or []:
if uf is None or not uf.filename: if uf is None or not uf.filename:
@@ -272,7 +261,6 @@ def _materialize_uploads_to_temp(
with open(dest, "wb") as f: with open(dest, "wb") as f:
shutil.copyfileobj(uf.file, f) shutil.copyfileobj(uf.file, f)
file_paths.append(dest) file_paths.append(dest)
file_names.append(uf.filename)
if audio is not None and audio.filename: if audio is not None and audio.filename:
ext = Path(audio.filename).suffix or ".bin" ext = Path(audio.filename).suffix or ".bin"
@@ -280,9 +268,8 @@ def _materialize_uploads_to_temp(
with open(dest, "wb") as f: with open(dest, "wb") as f:
shutil.copyfileobj(audio.file, f) shutil.copyfileobj(audio.file, f)
file_paths.append(dest) file_paths.append(dest)
file_names.append(audio.filename)
query = {"text": (text or "").strip(), "files": file_paths, "file_names": file_names} query = {"text": (text or "").strip(), "files": file_paths}
return query, tmp_root return query, tmp_root