From 4b335aa7ffd698e97c205b193717ef48b445c3f0 Mon Sep 17 00:00:00 2001
From: Mahdi
Date: Tue, 22 Sep 2026 11:02:08 +0330
Subject: [PATCH] Refining the speech-to-text conversion structure
---
apps/api/src/ai/ai-stt.service.ts | 9 +-
apps/stt/.env.example | 8 +-
apps/stt/README.md | 36 +++-
apps/stt/main.py | 157 +++++++++++---
apps/stt/requirements.txt | 4 +-
apps/web/src/components/ui/UiDropdownMenu.vue | 194 ++++++++++++++++++
apps/web/src/components/ui/index.ts | 1 +
apps/web/src/views/ActivitiesView.vue | 87 ++++----
apps/web/src/views/ContactsView.vue | 79 +++----
apps/web/src/views/InvoicesView.vue | 36 ++--
apps/web/src/views/LoyaltyClubView.vue | 50 ++---
apps/web/src/views/MessageAutomationsView.vue | 51 ++---
apps/web/src/views/ProductsSettingsView.vue | 139 +++++++------
apps/web/src/views/SalesForecastView.vue | 50 ++---
apps/web/src/views/TeamSettingsView.vue | 57 ++---
deploy/DEPLOY.md | 9 +-
16 files changed, 634 insertions(+), 333 deletions(-)
create mode 100644 apps/web/src/components/ui/UiDropdownMenu.vue
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] }}