diff --git a/app.py b/app.py index 18f893b..bd6f1a0 100644 --- a/app.py +++ b/app.py @@ -197,9 +197,14 @@ PWA_HEAD = """ var deferredPrompt = null; if ("serviceWorker" in navigator) { - window.addEventListener("load", function () { + function registerSW() { navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(function () {}); - }); + } + if ("requestIdleCallback" in window) { + requestIdleCallback(registerSW, { timeout: 5000 }); + } else { + setTimeout(registerSW, 3000); + } } function isStandalone() { @@ -711,24 +716,37 @@ def _status_html(title: str, message: str, level: str = "warn") -> str: ) -def ui_check_ollama_html() -> str: - ok, msg = check_ollama_health() +def ui_check_ollama_html(force: bool = False) -> str: + ok, msg = check_ollama_health(force=force) return _status_html("Ollama 节点", msg, "ok" if ok else "err") +def ui_initial_load() -> tuple[str, str]: + """首屏立即返回,不发起网络请求,避免平板白屏等待。""" + return ( + _status_html("Ollama 节点", "后台检测中,请稍候…", "warn"), + ui_speaker_status_html(), + ) + + +def ui_refresh_status_html(force: bool = False) -> tuple[str, str]: + """刷新 Ollama + 音色状态(供 Timer / 按钮调用)。""" + return ui_check_ollama_html(force=force), ui_speaker_status_html() + + def ui_speaker_status_html() -> str: ok, msg = speaker_is_ready() return _status_html("音色状态", msg, "ok" if ok else "warn") def build_theme() -> gr.themes.Base: - """高对比度暗色主题(Gradio 6.0 需在 launch() 传入)。""" + """高对比度暗色主题;使用系统字体,避免平板拉取 Google Fonts 卡顿。""" return gr.themes.Base( primary_hue="blue", secondary_hue="blue", neutral_hue="slate", - font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], - font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "Consolas", "monospace"], + font=["system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"], + font_mono=["Consolas", "Monaco", "Courier New", "monospace"], ).set( body_background_fill="#0f1419", body_background_fill_dark="#0f1419", @@ -780,7 +798,25 @@ def build_app() -> gr.Blocks: refresh_btn = gr.Button("🔄 刷新状态", variant="secondary", scale=0, min_width=120) refresh_btn.click( - fn=lambda: (ui_check_ollama_html(), ui_speaker_status_html()), + fn=lambda: ui_refresh_status_html(force=True), + outputs=[ollama_status, speaker_status], + ) + + # 首屏秒开:仅本地检测音色,Ollama 延后到 Timer + demo.load( + fn=ui_initial_load, + outputs=[ollama_status, speaker_status], + ) + + # 1 秒后后台检测 Ollama;之后每 30s 刷新(30s 内走缓存) + status_timer = gr.Timer(value=1, active=True) + status_timer.tick( + fn=lambda: ui_refresh_status_html(force=False), + outputs=[ollama_status, speaker_status], + ) + status_timer_slow = gr.Timer(value=30, active=True) + status_timer_slow.tick( + fn=lambda: ui_refresh_status_html(force=True), outputs=[ollama_status, speaker_status], ) @@ -886,11 +922,6 @@ def build_app() -> gr.Blocks: [pipe_raw, pipe_polished, pipe_output, pipeline_log], ) - demo.load( - fn=lambda: (ui_check_ollama_html(), ui_speaker_status_html()), - outputs=[ollama_status, speaker_status], - ) - return demo diff --git a/config.py b/config.py index f6b53d7..106bcc8 100644 --- a/config.py +++ b/config.py @@ -1,85 +1,90 @@ -""" -Trading Studio 全局配置模块 -统一存放局域网节点、模型名称、固定 Prompt 及本地路径。 -""" - -from pathlib import Path - -# --------------------------------------------------------------------------- -# 网络与服务 -# --------------------------------------------------------------------------- -# 远程 Ollama 节点(局域网大模型审查润色) -OLLAMA_HOST = "192.168.8.64" -OLLAMA_PORT = 11434 -OLLAMA_URL = f"http://{OLLAMA_HOST}:{OLLAMA_PORT}/api/chat" - -# 指定无限制版 Gemma4 模型 -MODEL_NAME = "huihui_ai/gemma-4-abliterated:e4b" - -# Gradio 中控固定端口(硬性死规则) -HOST = "0.0.0.0" -PORT = 5683 - -# HTTP 请求超时(秒) -OLLAMA_TIMEOUT = 60 - -# --------------------------------------------------------------------------- -# LLM 系统 Prompt -# --------------------------------------------------------------------------- -SYSTEM_PROMPT = ( - "你是一个冷静、极其严格的数字资产量化交易员。" - "请把下面这段口语化、包含结巴和逻辑混乱的交易复盘录音转写," - "润色成一段逻辑清晰、行文通顺的 B 站长视频反思配音稿。" - "语气要内向、克制、严谨。" - "如果原视频中有由于心态不好、违背交易纪律(如手贱乱开仓、提前平仓)" - "导致少赚或亏损的部分,请用冷酷、严厉的语气狠狠地自我吐槽、反思该点。" - "去掉所有无意义的口头禅,字数不做删减。" -) - -# --------------------------------------------------------------------------- -# Faster-Whisper 配置 -# --------------------------------------------------------------------------- -WHISPER_MODEL_SIZE = "small" -WHISPER_DEVICE = "cuda" -WHISPER_COMPUTE_TYPE = "float16" -WHISPER_LANGUAGE = "zh" - -# --------------------------------------------------------------------------- -# ChatTTS 配置 -# --------------------------------------------------------------------------- -# 标准生产安装路径(/opt,root 部署) -INSTALL_DIR = Path("/opt/Trading_Studio") - -# 项目根目录(开发/生产均自适应,以实际 app.py 所在目录为准) -BASE_DIR = Path(__file__).resolve().parent - -# 固定音色 Embedding 存储路径 -SPEAKER_EMB_PATH = BASE_DIR / "speaker_emb.pt" - -# 合成音频输出目录 -OUTPUT_DIR = BASE_DIR / "outputs" -OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - -# ChatTTS 采样率(Hz) -TTS_SAMPLE_RATE = 24000 - -# 音色样本时长建议(秒) -SPEAKER_SAMPLE_MIN_SEC = 10 -SPEAKER_SAMPLE_MAX_SEC = 30 - -# TTS 推理默认参数(低 temperature 有助于音色稳定) -TTS_TEMPERATURE = 0.3 -TTS_TOP_P = 0.7 -TTS_TOP_K = 20 -TTS_SPEED_PROMPT = "[speed_5]" - -# --------------------------------------------------------------------------- -# 上传临时文件目录 -# --------------------------------------------------------------------------- -UPLOAD_DIR = BASE_DIR / "uploads" -UPLOAD_DIR.mkdir(parents=True, exist_ok=True) - -# --------------------------------------------------------------------------- -# Git 仓库(文档引用) -# --------------------------------------------------------------------------- -GIT_REPO_URL = "https://git.bz121.com/dekun/Trading_Studio.git" +""" +Trading Studio 全局配置模块 +统一存放局域网节点、模型名称、固定 Prompt 及本地路径。 +""" + +from pathlib import Path + +# --------------------------------------------------------------------------- +# 网络与服务 +# --------------------------------------------------------------------------- +# 远程 Ollama 节点(局域网大模型审查润色) +OLLAMA_HOST = "192.168.8.64" +OLLAMA_PORT = 11434 +OLLAMA_URL = f"http://{OLLAMA_HOST}:{OLLAMA_PORT}/api/chat" + +# 指定无限制版 Gemma4 模型 +MODEL_NAME = "huihui_ai/gemma-4-abliterated:e4b" + +# Gradio 中控固定端口(硬性死规则) +HOST = "0.0.0.0" +PORT = 5683 + +# HTTP 请求超时(秒) +OLLAMA_TIMEOUT = 60 + +# 健康检查快速超时(秒)— 避免平板首屏被长时间阻塞 +HEALTH_CHECK_CONNECT_TIMEOUT = 2 +HEALTH_CHECK_READ_TIMEOUT = 3 +HEALTH_CHECK_CACHE_SECONDS = 30 + +# --------------------------------------------------------------------------- +# LLM 系统 Prompt +# --------------------------------------------------------------------------- +SYSTEM_PROMPT = ( + "你是一个冷静、极其严格的数字资产量化交易员。" + "请把下面这段口语化、包含结巴和逻辑混乱的交易复盘录音转写," + "润色成一段逻辑清晰、行文通顺的 B 站长视频反思配音稿。" + "语气要内向、克制、严谨。" + "如果原视频中有由于心态不好、违背交易纪律(如手贱乱开仓、提前平仓)" + "导致少赚或亏损的部分,请用冷酷、严厉的语气狠狠地自我吐槽、反思该点。" + "去掉所有无意义的口头禅,字数不做删减。" +) + +# --------------------------------------------------------------------------- +# Faster-Whisper 配置 +# --------------------------------------------------------------------------- +WHISPER_MODEL_SIZE = "small" +WHISPER_DEVICE = "cuda" +WHISPER_COMPUTE_TYPE = "float16" +WHISPER_LANGUAGE = "zh" + +# --------------------------------------------------------------------------- +# ChatTTS 配置 +# --------------------------------------------------------------------------- +# 标准生产安装路径(/opt,root 部署) +INSTALL_DIR = Path("/opt/Trading_Studio") + +# 项目根目录(开发/生产均自适应,以实际 app.py 所在目录为准) +BASE_DIR = Path(__file__).resolve().parent + +# 固定音色 Embedding 存储路径 +SPEAKER_EMB_PATH = BASE_DIR / "speaker_emb.pt" + +# 合成音频输出目录 +OUTPUT_DIR = BASE_DIR / "outputs" +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + +# ChatTTS 采样率(Hz) +TTS_SAMPLE_RATE = 24000 + +# 音色样本时长建议(秒) +SPEAKER_SAMPLE_MIN_SEC = 10 +SPEAKER_SAMPLE_MAX_SEC = 30 + +# TTS 推理默认参数(低 temperature 有助于音色稳定) +TTS_TEMPERATURE = 0.3 +TTS_TOP_P = 0.7 +TTS_TOP_K = 20 +TTS_SPEED_PROMPT = "[speed_5]" + +# --------------------------------------------------------------------------- +# 上传临时文件目录 +# --------------------------------------------------------------------------- +UPLOAD_DIR = BASE_DIR / "uploads" +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + +# --------------------------------------------------------------------------- +# Git 仓库(文档引用) +# --------------------------------------------------------------------------- +GIT_REPO_URL = "https://git.bz121.com/dekun/Trading_Studio.git" diff --git a/llm_service.py b/llm_service.py index e0bc5be..966f092 100644 --- a/llm_service.py +++ b/llm_service.py @@ -1,162 +1,198 @@ -""" -远程 Ollama LLM 润色服务 -通过局域网 HTTP 请求 Gemma4 模型,对交易复盘转写稿进行纪律审判式润色。 -""" - -from __future__ import annotations - -import logging -from typing import Tuple - -import requests - -from config import MODEL_NAME, OLLAMA_TIMEOUT, OLLAMA_URL, SYSTEM_PROMPT - -logger = logging.getLogger(__name__) - - -def _build_payload(raw_text: str) -> dict: - """构造 Ollama /api/chat 非流式请求体。""" - return { - "model": MODEL_NAME, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT}, - { - "role": "user", - "content": ( - "以下是我的交易复盘录音转写原文,请严格按系统要求润色:\n\n" - f"{raw_text}" - ), - }, - ], - "stream": False, - "options": { - "temperature": 0.7, - "num_predict": 4096, - }, - } - - -def _extract_content(response_json: dict) -> str: - """从 Ollama 响应 JSON 中提取 assistant 文本。""" - # /api/chat 标准格式 - message = response_json.get("message") - if isinstance(message, dict): - content = message.get("content", "").strip() - if content: - return content - - # 兼容 /api/generate 格式(部分旧版或代理) - if "response" in response_json: - content = str(response_json["response"]).strip() - if content: - return content - - raise ValueError(f"无法从 Ollama 响应中解析文本内容: {response_json}") - - -def polish_text(raw_text: str) -> Tuple[bool, str]: - """ - 调用远程 Ollama 对原始转写文本进行润色。 - - Args: - raw_text: Whisper 转写得到的原始口语文本 - - Returns: - (success, polished_text_or_error_message) - """ - if not raw_text or not raw_text.strip(): - return False, "润色输入为空,请先完成语音识别。" - - payload = _build_payload(raw_text.strip()) - - try: - logger.info("正在请求 Ollama: %s, model=%s", OLLAMA_URL, MODEL_NAME) - response = requests.post( - OLLAMA_URL, - json=payload, - timeout=OLLAMA_TIMEOUT, - ) - response.raise_for_status() - - data = response.json() - polished = _extract_content(data) - - if not polished: - return False, "Ollama 返回内容为空,请检查模型是否正常加载。" - - logger.info("润色完成,输出字数: %d", len(polished)) - return True, polished - - except requests.exceptions.ConnectTimeout: - err = ( - f"连接 Ollama 超时(>{OLLAMA_TIMEOUT}s)。" - f"请确认 {OLLAMA_URL} 可达且 Ollama 服务已启动。" - ) - logger.error(err) - return False, err - - except requests.exceptions.ReadTimeout: - err = ( - f"Ollama 响应超时(>{OLLAMA_TIMEOUT}s)。" - "模型可能正在加载或生成长度过长,请稍后重试。" - ) - logger.error(err) - return False, err - - except requests.exceptions.ConnectionError as exc: - err = ( - f"无法连接到 Ollama 节点 ({OLLAMA_URL})。" - "请检查局域网连通性、防火墙及 Ollama 是否监听 0.0.0.0:11434。\n" - f"详情: {exc}" - ) - logger.error(err) - return False, err - - except requests.exceptions.HTTPError as exc: - status = exc.response.status_code if exc.response is not None else "?" - body = exc.response.text[:500] if exc.response is not None else "" - err = ( - f"Ollama HTTP 错误 ({status})。" - f"请确认模型 `{MODEL_NAME}` 已通过 ollama pull 下载。\n" - f"响应片段: {body}" - ) - logger.error(err) - return False, err - - except ValueError as exc: - logger.error("Ollama 响应解析失败: %s", exc) - return False, str(exc) - - except requests.exceptions.RequestException as exc: - err = f"Ollama 请求异常: {exc}" - logger.exception(err) - return False, err - - except Exception as exc: - err = f"润色过程发生未知错误: {exc}" - logger.exception(err) - return False, err - - -def check_ollama_health() -> Tuple[bool, str]: - """ - 快速检测 Ollama 节点是否在线(不触发完整推理)。 - - Returns: - (online, message) - """ - base_url = OLLAMA_URL.rsplit("/api/", 1)[0] - try: - resp = requests.get(f"{base_url}/api/tags", timeout=10) - resp.raise_for_status() - tags = resp.json().get("models", []) - model_names = [m.get("name", "") for m in tags] - if any(MODEL_NAME.split(":")[0] in name for name in model_names): - return True, f"Ollama 在线,已检测到模型: {MODEL_NAME}" - return True, ( - f"Ollama 在线,但未找到模型 {MODEL_NAME}," - f"请执行: ollama pull {MODEL_NAME}" - ) - except Exception as exc: - return False, f"Ollama 不可达: {exc}" +""" +远程 Ollama LLM 润色服务 +通过局域网 HTTP 请求 Gemma4 模型,对交易复盘转写稿进行纪律审判式润色。 +""" + +from __future__ import annotations + +import logging +import time +from typing import Tuple + +import requests + +from config import ( + HEALTH_CHECK_CACHE_SECONDS, + HEALTH_CHECK_CONNECT_TIMEOUT, + HEALTH_CHECK_READ_TIMEOUT, + MODEL_NAME, + OLLAMA_TIMEOUT, + OLLAMA_URL, + SYSTEM_PROMPT, +) + +logger = logging.getLogger(__name__) + +# 健康检查短时缓存,避免平板/手机反复打开页面时重复等待 +_health_cache: dict = {"ts": 0.0, "ok": False, "msg": ""} + + +def _build_payload(raw_text: str) -> dict: + """构造 Ollama /api/chat 非流式请求体。""" + return { + "model": MODEL_NAME, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": ( + "以下是我的交易复盘录音转写原文,请严格按系统要求润色:\n\n" + f"{raw_text}" + ), + }, + ], + "stream": False, + "options": { + "temperature": 0.7, + "num_predict": 4096, + }, + } + + +def _extract_content(response_json: dict) -> str: + """从 Ollama 响应 JSON 中提取 assistant 文本。""" + # /api/chat 标准格式 + message = response_json.get("message") + if isinstance(message, dict): + content = message.get("content", "").strip() + if content: + return content + + # 兼容 /api/generate 格式(部分旧版或代理) + if "response" in response_json: + content = str(response_json["response"]).strip() + if content: + return content + + raise ValueError(f"无法从 Ollama 响应中解析文本内容: {response_json}") + + +def polish_text(raw_text: str) -> Tuple[bool, str]: + """ + 调用远程 Ollama 对原始转写文本进行润色。 + + Args: + raw_text: Whisper 转写得到的原始口语文本 + + Returns: + (success, polished_text_or_error_message) + """ + if not raw_text or not raw_text.strip(): + return False, "润色输入为空,请先完成语音识别。" + + payload = _build_payload(raw_text.strip()) + + try: + logger.info("正在请求 Ollama: %s, model=%s", OLLAMA_URL, MODEL_NAME) + response = requests.post( + OLLAMA_URL, + json=payload, + timeout=OLLAMA_TIMEOUT, + ) + response.raise_for_status() + + data = response.json() + polished = _extract_content(data) + + if not polished: + return False, "Ollama 返回内容为空,请检查模型是否正常加载。" + + logger.info("润色完成,输出字数: %d", len(polished)) + return True, polished + + except requests.exceptions.ConnectTimeout: + err = ( + f"连接 Ollama 超时(>{OLLAMA_TIMEOUT}s)。" + f"请确认 {OLLAMA_URL} 可达且 Ollama 服务已启动。" + ) + logger.error(err) + return False, err + + except requests.exceptions.ReadTimeout: + err = ( + f"Ollama 响应超时(>{OLLAMA_TIMEOUT}s)。" + "模型可能正在加载或生成长度过长,请稍后重试。" + ) + logger.error(err) + return False, err + + except requests.exceptions.ConnectionError as exc: + err = ( + f"无法连接到 Ollama 节点 ({OLLAMA_URL})。" + "请检查局域网连通性、防火墙及 Ollama 是否监听 0.0.0.0:11434。\n" + f"详情: {exc}" + ) + logger.error(err) + return False, err + + except requests.exceptions.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else "?" + body = exc.response.text[:500] if exc.response is not None else "" + err = ( + f"Ollama HTTP 错误 ({status})。" + f"请确认模型 `{MODEL_NAME}` 已通过 ollama pull 下载。\n" + f"响应片段: {body}" + ) + logger.error(err) + return False, err + + except ValueError as exc: + logger.error("Ollama 响应解析失败: %s", exc) + return False, str(exc) + + except requests.exceptions.RequestException as exc: + err = f"Ollama 请求异常: {exc}" + logger.exception(err) + return False, err + + except Exception as exc: + err = f"润色过程发生未知错误: {exc}" + logger.exception(err) + return False, err + + +def check_ollama_health(force: bool = False) -> Tuple[bool, str]: + """ + 快速检测 Ollama 节点是否在线(不触发完整推理)。 + 默认 2+3 秒超时,结果缓存 30 秒,避免平板首屏长时间白屏。 + + Returns: + (online, message) + """ + global _health_cache + + now = time.time() + if ( + not force + and _health_cache["msg"] + and (now - _health_cache["ts"]) < HEALTH_CHECK_CACHE_SECONDS + ): + return _health_cache["ok"], _health_cache["msg"] + + base_url = OLLAMA_URL.rsplit("/api/", 1)[0] + timeout = (HEALTH_CHECK_CONNECT_TIMEOUT, HEALTH_CHECK_READ_TIMEOUT) + + try: + resp = requests.get(f"{base_url}/api/tags", timeout=timeout) + resp.raise_for_status() + tags = resp.json().get("models", []) + model_names = [m.get("name", "") for m in tags] + if any(MODEL_NAME.split(":")[0] in name for name in model_names): + msg = f"Ollama 在线,已检测到模型: {MODEL_NAME}" + ok = True + else: + ok = True + msg = ( + f"Ollama 在线,但未找到模型 {MODEL_NAME}," + f"请执行: ollama pull {MODEL_NAME}" + ) + except requests.exceptions.Timeout: + ok, msg = False, ( + f"Ollama 检测超时(>{HEALTH_CHECK_READ_TIMEOUT}s)。" + "页面已加载,可稍后点击「刷新状态」重试。" + ) + except Exception as exc: + ok, msg = False, f"Ollama 不可达: {exc}" + + _health_cache.update({"ts": now, "ok": ok, "msg": msg}) + return ok, msg diff --git a/pwa/sw.js b/pwa/sw.js index 5e91af4..c99d8f7 100644 --- a/pwa/sw.js +++ b/pwa/sw.js @@ -1,14 +1,10 @@ /** - * Trading Studio PWA Service Worker - * 缓存应用壳,支持离线打开已访问页面;API 请求始终走网络。 + * Trading Studio PWA Service Worker(轻量版) + * 仅处理导航请求,不拦截 Gradio 静态资源 — 避免平板端加载变慢。 */ -const CACHE_NAME = "trading-studio-v1"; -const SHELL = ["/", "/manifest.webmanifest"]; +const CACHE_NAME = "trading-studio-v2"; self.addEventListener("install", (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)).catch(() => {}) - ); self.skipWaiting(); }); @@ -23,28 +19,13 @@ self.addEventListener("activate", (event) => { self.addEventListener("fetch", (event) => { const { request } = event; - const url = new URL(request.url); - // API / 文件上传 / Gradio 动态接口不走缓存 - if ( - request.method !== "GET" || - url.pathname.startsWith("/gradio_api") || - url.pathname.startsWith("/file=") || - url.pathname.startsWith("/upload") || - url.pathname.includes("call") - ) { + // 仅缓存页面导航,Gradio JS/CSS/API 全部直连网络 + if (request.method !== "GET" || request.mode !== "navigate") { return; } event.respondWith( - fetch(request) - .then((response) => { - if (response.ok && url.origin === self.location.origin) { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); - } - return response; - }) - .catch(() => caches.match(request).then((r) => r || caches.match("/"))) + fetch(request).catch(() => caches.match("/")) ); });