diff --git a/apps/api/src/ai/ai-stt.service.ts b/apps/api/src/ai/ai-stt.service.ts index 8b80785..a8d27ec 100644 --- a/apps/api/src/ai/ai-stt.service.ts +++ b/apps/api/src/ai/ai-stt.service.ts @@ -66,9 +66,12 @@ export class AiSttService { const detail = Array.isArray(payload.detail) ? payload.detail.map((item) => item.msg).filter(Boolean).join('، ') : payload.detail; - throw new ServiceUnavailableException( - detail || 'تبدیل صوت به متن ناموفق بود', - ); + const raw = String(detail || '').trim(); + let message = raw || 'تبدیل صوت به متن ناموفق بود'; + if (raw.length > 220) { + message = 'تبدیل صوت به متن ناموفق بود. سرویس STT محلی را بررسی کنید.'; + } + throw new ServiceUnavailableException(message); } return { text: (payload.text ?? '').trim() }; diff --git a/apps/stt/.env.example b/apps/stt/.env.example index d252860..fdbc090 100644 --- a/apps/stt/.env.example +++ b/apps/stt/.env.example @@ -1,7 +1,7 @@ -# Pipecat STT only — same OpenAI-compatible key as the Nest AI chat (GapGPT). -OPENAI_API_KEY= -OPENAI_BASE_URL=https://api.gapgpt.app/v1 -STT_MODEL=whisper-1 +# Local Pipecat Whisper — no GapGPT / OpenAI cloud key required for STT. +STT_MODEL=small +STT_DEVICE=cpu +STT_COMPUTE_TYPE=int8 STT_PROMPT=یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس. STT_HOST=127.0.0.1 diff --git a/apps/stt/README.md b/apps/stt/README.md index 611f698..82bc9d2 100644 --- a/apps/stt/README.md +++ b/apps/stt/README.md @@ -1,12 +1,12 @@ -# Montact STT (Pipecat) +# Montact STT (Pipecat — Whisper محلی) -تبدیل صوت به متن — فقط STT. بدون TTS، بدون مکالمهٔ صوتی. +تبدیل صوت به متن — فقط STT. بدون TTS، بدون مکالمهٔ صوتی، **بدون GapGPT**. -Pipecat اینجا فقط `OpenAISTTService` را اجرا می‌کند (HTTP Whisper). مدل از API سازگار با OpenAI می‌آید (مثلاً GapGPT). +از [Pipecat](https://www.pipecat.ai/) سرویس `WhisperSTTService` استفاده می‌شود؛ مدل Faster-Whisper روی **همین سرور** اجرا می‌شود و کلید ابری لازم نیست. ## لوکال (ویندوز) -Python 3.11+ لازم است. +Python 3.11+ و ترجیحاً `ffmpeg` در PATH. ```powershell cd apps/stt @@ -16,8 +16,6 @@ pip install -r requirements.txt copy .env.example .env ``` -در `.env` همان `AI_API_KEY` / `AI_BASE_URL` پنل را بگذارید (`OPENAI_API_KEY` و `OPENAI_BASE_URL`). - ```powershell uvicorn main:app --host 127.0.0.1 --port 3040 ``` @@ -30,4 +28,28 @@ STT_URL=http://127.0.0.1:3040 STT_INTERNAL_TOKEN= ``` -اگر `STT_INTERNAL_TOKEN` را پر کردید، همان مقدار را در هر دو `.env` بگذارید. +## تنظیمات `apps/stt/.env` + +| کلید | پیش‌فرض | توضیح | +|------|---------|--------| +| `STT_MODEL` | `small` | `tiny` / `base` / `small` / `medium` (فارسی: حداقل `small`) | +| `STT_DEVICE` | `cpu` | یا `cuda` اگر GPU دارید | +| `STT_COMPUTE_TYPE` | `int8` | برای CPU؛ روی GPU معمولاً `float16` | +| `STT_PROMPT` | متن فارسی فروش | راهنمای املا/اصطلاحات | +| `STT_INTERNAL_TOKEN` | خالی | اگر پر شود باید با API یکی باشد | + +**دیگر به `OPENAI_API_KEY` / GapGPT برای دیکته نیاز نیست.** + +اولین اجرا مدل را از Hugging Face دانلود می‌کند (چند صد مگابایت تا چند گیگ و بسته به مدل). + +## سرور + +```bash +cd /var/www/crm/apps/stt +source .venv/bin/activate # یا مسیر venv فعلی +pip install -r requirements.txt +# OPENAI_* را از .env بردارید یا نادیده بگیرید +pm2 restart mento-stt +``` + +اگر RAM کم است، `STT_MODEL=base` یا `tiny` بگذارید. diff --git a/apps/stt/main.py b/apps/stt/main.py index 3ff0a76..e0526b0 100644 --- a/apps/stt/main.py +++ b/apps/stt/main.py @@ -1,17 +1,20 @@ -"""Speech-to-text sidecar. Uses Pipecat OpenAISTTService only — no TTS, no voice agent.""" +"""Speech-to-text sidecar — Pipecat local Whisper only (no GapGPT / cloud STT).""" from __future__ import annotations +import io import os import shutil import subprocess import tempfile +import wave from pathlib import Path +import numpy as np from dotenv import load_dotenv from fastapi import FastAPI, File, Header, HTTPException, UploadFile from fastapi.responses import JSONResponse -from pipecat.services.openai.stt import OpenAISTTService +from pipecat.services.whisper.stt import WhisperSTTService from pipecat.transcriptions.language import Language load_dotenv(Path(__file__).resolve().parent / ".env") @@ -20,7 +23,7 @@ MAX_BYTES = 8 * 1024 * 1024 WAV_MAGIC = (b"RIFF", b"WAVE") app = FastAPI(title="Mento STT", docs_url=None, redoc_url=None) -_stt: OpenAISTTService | None = None +_stt: WhisperSTTService | None = None def _env(name: str, default: str = "") -> str: @@ -35,31 +38,53 @@ def _require_token(header_token: str | None) -> None: raise HTTPException(status_code=401, detail="توکن سرویس تبدیل صوت نامعتبر است") -def _stt_service() -> OpenAISTTService: +def _model_name() -> str: + # faster-whisper / Pipecat: tiny | base | small | medium | large-v3 | ... + return _env("STT_MODEL", "small") or "small" + + +def _stt_service() -> WhisperSTTService: + """Local Faster-Whisper via Pipecat — no OPENAI_API_KEY / GapGPT.""" global _stt if _stt is not None: return _stt - api_key = _env("OPENAI_API_KEY") - if not api_key: - raise HTTPException(status_code=503, detail="کلید تبدیل صوت تنظیم نشده است") - - model = _env("STT_MODEL", "whisper-1") + device = _env("STT_DEVICE", "cpu") or "cpu" + compute = _env("STT_COMPUTE_TYPE", "int8") or "int8" prompt = _env( "STT_PROMPT", "یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس.", ) - base_url = _env("OPENAI_BASE_URL") or None - _stt = OpenAISTTService( - api_key=api_key, - base_url=base_url, - settings=OpenAISTTService.Settings( - model=model, - language=Language.FA, - prompt=prompt, - ), - ) + try: + _stt = WhisperSTTService( + device=device, + compute_type=compute, + settings=WhisperSTTService.Settings( + model=_model_name(), + language=Language.FA, + initial_prompt=prompt or None, + ), + ) + except Exception as exc: + raise HTTPException( + status_code=503, + detail=f"بارگذاری مدل Whisper محلی ناموفق بود: {exc}", + ) from exc + + # force model load at startup path (first request otherwise downloads silently) + if getattr(_stt, "_model", None) is None and hasattr(_stt, "_load"): + try: + _stt._load() + except Exception as exc: + raise HTTPException( + status_code=503, + detail=( + "مدل Whisper دانلود/بارگذاری نشد. " + f"اینترنت سرور و فضای دیسک را چک کنید. جزئیات: {exc}" + ), + ) from exc + return _stt @@ -105,13 +130,93 @@ def _to_wav(data: bytes, filename: str) -> bytes: return dst.read_bytes() +def _wav_to_float32(wav_bytes: bytes) -> tuple[np.ndarray, int]: + with wave.open(io.BytesIO(wav_bytes), "rb") as wf: + channels = wf.getnchannels() + sample_width = wf.getsampwidth() + rate = wf.getframerate() + frames = wf.readframes(wf.getnframes()) + + if sample_width == 2: + audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0 + elif sample_width == 4: + audio = np.frombuffer(frames, dtype=np.int32).astype(np.float32) / 2147483648.0 + else: + raise HTTPException(status_code=400, detail="فرمت نمونهٔ صوتی پشتیبانی نمی‌شود") + + if channels > 1: + audio = audio.reshape(-1, channels).mean(axis=1) + + return audio, rate + + +def _transcribe_local(wav_bytes: bytes) -> str: + service = _stt_service() + model = getattr(service, "_model", None) + if model is None: + raise HTTPException(status_code=503, detail="مدل Whisper محلی آماده نیست") + + audio, rate = _wav_to_float32(wav_bytes) + # faster-whisper expects 16k mono float32 ideally + if rate != 16000: + # simple resample via ffmpeg path already targets 16k for non-wav; + # for wav at other rates, re-encode quickly + ffmpeg = shutil.which("ffmpeg") + if not ffmpeg: + raise HTTPException(status_code=400, detail="نرخ نمونه‌برداری صوت پشتیبانی نشد") + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "in.wav" + dst = Path(tmp) / "out.wav" + src.write_bytes(wav_bytes) + result = subprocess.run( + [ffmpeg, "-y", "-i", str(src), "-ac", "1", "-ar", "16000", "-f", "wav", str(dst)], + capture_output=True, + check=False, + ) + if result.returncode != 0 or not dst.exists(): + raise HTTPException(status_code=400, detail="نرمال‌سازی صوت ناموفق بود") + audio, rate = _wav_to_float32(dst.read_bytes()) + + settings = getattr(service, "_settings", None) + language = getattr(settings, "language", Language.FA) + lang_code = getattr(language, "value", language) if language is not None else "fa" + if isinstance(lang_code, str) and "-" in lang_code: + lang_code = lang_code.split("-")[0] + initial_prompt = getattr(settings, "initial_prompt", None) + + segments, _info = model.transcribe( + audio, + language=lang_code or "fa", + initial_prompt=initial_prompt, + vad_filter=True, + ) + parts = [seg.text.strip() for seg in segments if getattr(seg, "text", None)] + return " ".join(p for p in parts if p).strip() + + +def _friendly_stt_error(exc: BaseException) -> tuple[int, str]: + text = str(exc) + lowered = text.lower() + if "timeout" in lowered or "timed out" in lowered: + return 504, "زمان تبدیل صوت به پایان رسید. دوباره با صدای کوتاه‌تر تلاش کنید." + if "memory" in lowered or "oom" in lowered: + return 503, "حافظهٔ سرور برای مدل Whisper کافی نیست. مدل کوچک‌تر (tiny/base) را امتحان کنید." + if len(text) > 180: + return 502, "تبدیل صوت به متن محلی ناموفق بود. لاگ mento-stt را بررسی کنید." + return 502, f"تبدیل صوت به متن ناموفق بود: {text}" + + @app.get("/health") async def health() -> dict[str, object]: return { "ok": True, "engine": "pipecat", - "service": "OpenAISTTService", - "configured": bool(_env("OPENAI_API_KEY")), + "service": "WhisperSTTService", + "mode": "local", + "model": _env("STT_MODEL", "small"), + "device": _env("STT_DEVICE", "cpu") or "cpu", + "cloud": False, + "gapgpt": False, } @@ -129,17 +234,13 @@ async def transcribe( raise HTTPException(status_code=400, detail="حجم فایل صوتی بیش از حد مجاز است") wav = _to_wav(data, audio.filename or "audio.wav") - service = _stt_service() try: - result = await service._transcribe(wav) + text = _transcribe_local(wav) except HTTPException: raise except Exception as exc: - raise HTTPException( - status_code=502, - detail=f"تبدیل صوت به متن ناموفق بود: {exc}", - ) from exc + status, detail = _friendly_stt_error(exc) + raise HTTPException(status_code=status, detail=detail) from exc - text = (getattr(result, "text", None) or "").strip() return JSONResponse({"text": text}) diff --git a/apps/stt/requirements.txt b/apps/stt/requirements.txt index 88a0410..ea739bf 100644 --- a/apps/stt/requirements.txt +++ b/apps/stt/requirements.txt @@ -2,4 +2,6 @@ fastapi==0.116.1 uvicorn[standard]==0.35.0 python-multipart==0.0.20 python-dotenv==1.1.1 -pipecat-ai[openai]==1.8.1 +numpy>=1.26,<3 +# Local Whisper via Pipecat (Faster-Whisper). No OpenAI/GapGPT cloud STT. +pipecat-ai[whisper]==1.8.1 diff --git a/apps/web/src/components/ui/UiDropdownMenu.vue b/apps/web/src/components/ui/UiDropdownMenu.vue new file mode 100644 index 0000000..bec484f --- /dev/null +++ b/apps/web/src/components/ui/UiDropdownMenu.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts index 45029d7..c3916f8 100644 --- a/apps/web/src/components/ui/index.ts +++ b/apps/web/src/components/ui/index.ts @@ -13,3 +13,4 @@ export { default as UiSearch } from './UiSearch.vue'; export { default as UiFilterBar } from './UiFilterBar.vue'; export { default as UiSegmented } from './UiSegmented.vue'; export { default as UiChart, CHART_PALETTE, type UiChartOption } from './UiChart.vue'; +export { default as UiDropdownMenu } from './UiDropdownMenu.vue'; diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index 0e176c6..c29c7c5 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -12,6 +12,7 @@ import { } from '@/lib/jalali'; import { UiButton, + UiDropdownMenu, UiModal, UiPageHeader, UiPagination, @@ -129,11 +130,6 @@ const filterLabel = computed(() => TABS.find((t) => t.key === filter.value)?.lab const trulyEmpty = computed(() => !loading.value && counts.value.all === 0 && !q.value.trim()); const listEmpty = computed(() => !loading.value && !items.value.length); -function isLastItem(id: string) { - const rows = items.value; - return rows.length > 0 && rows[rows.length - 1]?.id === id; -} - function isOverdue(item: ActivityDto) { if (item.status !== 'open' || !item.dueAt) return false; return startOfLocalDay(new Date(item.dueAt)).getTime() < startOfLocalDay().getTime(); @@ -480,7 +476,6 @@ onMounted(() => { v-for="item in items" :key="item.id" class="trow" - :class="{ 'menu-open': menuId === item.id }" role="row" >
@@ -516,29 +511,31 @@ onMounted(() => {
- -
- + + مشاهده - + ویرایش -
+
@@ -565,31 +562,32 @@ onMounted(() => {

- - - - - {{ typeLabel[item.type] }} -
- + + مشاهده -
+ + + + + {{ typeLabel[item.type] }}