Refining the speech-to-text conversion structure

This commit is contained in:
2026-09-22 11:02:08 +03:30
parent 17fb33b3e7
commit 4b335aa7ff
16 changed files with 634 additions and 333 deletions
+6 -3
View File
@@ -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() };
+4 -4
View File
@@ -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
+29 -7
View File
@@ -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` بگذارید.
+129 -28
View File
@@ -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})
+3 -1
View File
@@ -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
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
open: boolean;
/** start = لبه راست تریگر در RTL؛ end = لبه چپ */
align?: 'start' | 'end';
}>(),
{ align: 'end' },
);
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const anchorRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const panelStyle = ref<Record<string, string>>({});
function toggle() {
emit('update:open', !props.open);
}
function close() {
if (props.open) emit('update:open', false);
}
function onPanelClickCapture() {
// بعد از handler آیتم‌ها ببند (حتی اگر stopPropagation زده باشند)
queueMicrotask(() => close());
}
function place() {
const anchor = anchorRef.value;
const panel = panelRef.value;
if (!anchor || !panel) return;
const r = anchor.getBoundingClientRect();
const menuH = panel.offsetHeight || 140;
const menuW = panel.offsetWidth || 160;
const gap = 6;
const pad = 8;
const spaceBelow = window.innerHeight - r.bottom;
const openUp = spaceBelow < menuH + gap + pad && r.top > menuH + gap + pad;
let left =
props.align === 'start' ? r.left : r.right - menuW;
left = Math.max(pad, Math.min(left, window.innerWidth - menuW - pad));
let top = openUp ? r.top - menuH - gap : r.bottom + gap;
top = Math.max(pad, Math.min(top, window.innerHeight - menuH - pad));
panelStyle.value = {
position: 'fixed',
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
zIndex: 'var(--z-dropdown, 50)',
};
}
async function placeSoon() {
await nextTick();
place();
// بعد از رندر محتوا ارتفاع دقیق‌تر می‌شود
requestAnimationFrame(place);
}
watch(
() => props.open,
(open) => {
if (open) void placeSoon();
},
);
function onPointerDown(ev: PointerEvent) {
if (!props.open) return;
const t = ev.target as Node;
if (anchorRef.value?.contains(t) || panelRef.value?.contains(t)) return;
close();
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') close();
}
function onReposition() {
if (props.open) place();
}
onMounted(() => {
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKey);
window.addEventListener('resize', onReposition);
window.addEventListener('scroll', onReposition, true);
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKey);
window.removeEventListener('resize', onReposition);
window.removeEventListener('scroll', onReposition, true);
});
defineExpose({ toggle, close });
</script>
<template>
<span ref="anchorRef" class="ui-dd-anchor">
<slot name="trigger" :open="open" :toggle="toggle" :close="close" />
</span>
<Teleport to="body">
<div
v-if="open"
ref="panelRef"
class="ui-dd-panel"
role="menu"
:style="panelStyle"
@click.capture="onPanelClickCapture"
>
<slot :close="close" />
</div>
</Teleport>
</template>
<style scoped>
.ui-dd-anchor {
display: inline-flex;
vertical-align: middle;
}
.ui-dd-panel {
min-width: 10rem;
max-width: min(20rem, calc(100vw - 16px));
background: var(--surface, #fff);
border: 1px solid var(--line, #e2e8f0);
border-radius: var(--radius, 10px);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.14);
padding: 0.35rem;
display: grid;
gap: 1px;
}
.ui-dd-panel :deep(button),
.ui-dd-panel :deep(a) {
display: block;
width: 100%;
box-sizing: border-box;
border: 0;
background: transparent;
text-align: right;
padding: 0.55rem 0.7rem;
border-radius: var(--radius, 10px);
font: inherit;
font-size: 0.875rem;
color: var(--ink, #0f172a);
cursor: pointer;
text-decoration: none;
line-height: 1.4;
}
.ui-dd-panel :deep(button:hover),
.ui-dd-panel :deep(a:hover),
.ui-dd-panel :deep(button.on),
.ui-dd-panel :deep(a.on) {
background: var(--accent-soft, #ecfdf8);
color: var(--accent-deep, #0f766e);
}
.ui-dd-panel :deep(button.danger),
.ui-dd-panel :deep(a.danger) {
color: #b91c1c;
}
.ui-dd-panel :deep(button.danger:hover),
.ui-dd-panel :deep(a.danger:hover) {
background: #fef2f2;
color: #991b1b;
}
.ui-dd-panel :deep(li) {
list-style: none;
margin: 0;
padding: 0;
}
.ui-dd-panel :deep(ul) {
list-style: none;
margin: 0;
padding: 0;
display: grid;
}
</style>
+1
View File
@@ -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';
+38 -49
View File
@@ -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"
>
<div class="title-cell">
@@ -516,29 +511,31 @@ onMounted(() => {
</span>
</div>
<div class="row-more" @click.stop>
<button
class="icon-more"
type="button"
aria-label="عملیات"
@click="menuId = menuId === item.id ? null : item.id"
<UiDropdownMenu
:open="menuId === item.id"
align="end"
@update:open="(v) => (menuId = v ? item.id : null)"
>
<UiIcon name="dots-v" :size="16" />
</button>
<div
v-if="menuId === item.id"
class="popover"
:class="{ 'drop-up': isLastItem(item.id) }"
>
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }" @click="menuId = null">
<template #trigger="{ toggle }">
<button
class="icon-more"
type="button"
aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }">
مشاهده
</RouterLink>
<RouterLink :to="{ name: 'activity-edit', params: { id: item.id } }" @click="menuId = null">
<RouterLink :to="{ name: 'activity-edit', params: { id: item.id } }">
ویرایش
</RouterLink>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
انجام شد
</button>
</div>
</UiDropdownMenu>
</div>
</article>
</div>
@@ -565,31 +562,32 @@ onMounted(() => {
</p>
</div>
<div class="card-side">
<button
class="icon-more"
type="button"
aria-label="عملیات"
@click.stop="menuId = menuId === item.id ? null : item.id"
<UiDropdownMenu
:open="menuId === item.id"
align="end"
@update:open="(v) => (menuId = v ? item.id : null)"
>
<UiIcon name="dots-v" :size="16" />
</button>
<span class="type-ico" :class="item.type">
<UiIcon :name="typeIcon[item.type]" :size="18" />
</span>
<span class="type-pill" :class="item.type">{{ typeLabel[item.type] }}</span>
<div
v-if="menuId === item.id"
class="popover"
:class="{ 'drop-up': isLastItem(item.id) }"
@click.stop
>
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }" @click="menuId = null">
<template #trigger="{ toggle }">
<button
class="icon-more"
type="button"
aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }">
مشاهده
</RouterLink>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
انجام شد
</button>
</div>
</UiDropdownMenu>
<span class="type-ico" :class="item.type">
<UiIcon :name="typeIcon[item.type]" :size="18" />
</span>
<span class="type-pill" :class="item.type">{{ typeLabel[item.type] }}</span>
</div>
</div>
<footer class="card-foot">
@@ -849,11 +847,6 @@ h1 {
z-index: 20;
}
.popover.drop-up {
top: auto;
bottom: calc(100% + 6px);
}
.popover a,
.popover button {
display: block;
@@ -968,10 +961,6 @@ h1 {
border-radius: 0 0 var(--radius) var(--radius);
}
.trow.menu-open {
z-index: 12;
}
.title-cell {
display: flex;
gap: 0.55rem;
+44 -35
View File
@@ -9,6 +9,7 @@ import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
import {
UiButton,
UiDropdownMenu,
UiModal,
UiPageHeader,
UiPagination,
@@ -372,9 +373,27 @@ onMounted(load);
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<button class="more" type="button" @click.stop="menuId = menuId === p.id ? null : p.id">
<UiIcon name="dots-v" :size="16" />
</button>
<UiDropdownMenu
:open="menuId === p.id"
align="end"
@update:open="(v) => (menuId = v ? p.id : null)"
>
<template #trigger="{ toggle }">
<button class="more" type="button" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink>
<RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink>
<button
v-for="mood in PERSON_MOODS"
:key="mood"
type="button"
@click.stop="setMoodFor(p, mood)"
>
{{ moodLabel(mood) }}
</button>
</UiDropdownMenu>
</div>
<p class="role">
{{ [p.position, p.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }}
@@ -389,17 +408,6 @@ onMounted(load);
</p>
<span class="badge" :class="moodOf(p)">{{ moodLabel(moodOf(p)) }}</span>
</div>
<ul v-if="menuId === p.id" class="menu row-menu">
<li>
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink>
</li>
<li>
<RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink>
</li>
<li v-for="mood in PERSON_MOODS" :key="mood">
<button type="button" @click.stop="setMoodFor(p, mood)">{{ moodLabel(mood) }}</button>
</li>
</ul>
</article>
</div>
@@ -449,21 +457,28 @@ onMounted(load);
</span>
</td>
<td>{{ formatDate(p.createdAt) }}</td>
<td class="ops">
<button type="button" class="more" @click.stop="menuId = menuId === p.id ? null : p.id">
<UiIcon name="dots-v" :size="16" />
</button>
<ul v-if="menuId === p.id" class="menu row-menu">
<li>
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink>
</li>
<li>
<RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink>
</li>
<li v-for="mood in PERSON_MOODS" :key="mood">
<button type="button" @click.stop="setMoodFor(p, mood)">{{ moodLabel(mood) }}</button>
</li>
</ul>
<td class="ops" @click.stop>
<UiDropdownMenu
:open="menuId === p.id"
align="end"
@update:open="(v) => (menuId = v ? p.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="more" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink>
<RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink>
<button
v-for="mood in PERSON_MOODS"
:key="mood"
type="button"
@click.stop="setMoodFor(p, mood)"
>
{{ moodLabel(mood) }}
</button>
</UiDropdownMenu>
</td>
</tr>
</tbody>
@@ -1005,12 +1020,6 @@ strong.newcomer {
flex-shrink: 0;
}
.row-menu {
inset-inline-start: auto;
inset-inline-end: 0;
top: 2.4rem;
}
.empty {
margin: 0;
padding: 1.4rem;
+24 -12
View File
@@ -11,6 +11,7 @@ import {
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import {
UiButton,
UiDropdownMenu,
UiModal,
UiPageHeader,
UiPagination,
@@ -522,12 +523,18 @@ onMounted(load);
<RouterLink class="icon-btn sm" :to="r.href" title="ویرایش">
<UiIcon name="pencil" :size="15" />
</RouterLink>
<button type="button" class="icon-btn sm" @click="menuId = menuId === r.id ? null : r.id">
<UiIcon name="dots-v" :size="15" />
</button>
<div v-if="menuId === r.id" class="menu left">
<UiDropdownMenu
:open="menuId === r.id"
align="end"
@update:open="(v) => (menuId = v ? r.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="icon-btn sm" @click.stop="toggle">
<UiIcon name="dots-v" :size="15" />
</button>
</template>
<RouterLink :to="r.href">باز کردن</RouterLink>
</div>
</UiDropdownMenu>
</td>
</tr>
</tbody>
@@ -548,14 +555,20 @@ onMounted(load);
{{ r.statusLabel }}
</span>
<div class="mob-aside">
<button type="button" class="icon-btn sm ghost" @click="menuId = menuId === r.id ? null : r.id">
<UiIcon name="dots-v" :size="16" />
</button>
<UiDropdownMenu
:open="menuId === r.id"
align="end"
@update:open="(v) => (menuId = v ? r.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="icon-btn sm ghost" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<RouterLink :to="r.href">باز کردن</RouterLink>
</UiDropdownMenu>
<span class="avatar">{{ r.customerName.slice(0, 1) }}</span>
</div>
<div v-if="menuId === r.id" class="menu left mob-menu">
<RouterLink :to="r.href">باز کردن</RouterLink>
</div>
</div>
<div class="mob-card-bottom">
<em :class="{ neg: r.amount < 0 }">{{ money(r.amount, r.currency) }}</em>
@@ -835,7 +848,6 @@ tbody tr:nth-child(even) { background: #fcfdff; }
.mob-info { text-decoration: none; color: inherit; display: grid; gap: 0.12rem; min-width: 0; }
.mob-info .mono, .mob-info .muted { font-size: 0.8rem; color: var(--muted); }
.mob-aside { display: grid; justify-items: center; gap: 0.35rem; }
.mob-menu { top: 2.2rem; }
.mob-card-bottom { display: flex; justify-content: flex-start; }
.mob-card-bottom em { font-style: normal; font-weight: 600; font-size: 0.92rem; }
+26 -24
View File
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu } from '@/components/ui';
import { formatRelativeContact, toFaDigits } from '@/lib/jalali';
import type {
CreateLoyaltyRewardRequest,
@@ -785,14 +786,20 @@ onBeforeUnmount(() => {
<button class="act minus" type="button" title="کسر امتیاز" @click="openAdjust(m, -1)">
<UiIcon name="minus" :size="14" />
</button>
<button class="act more" type="button" @click="menuId = menuId === m.id ? null : m.id">
<UiIcon name="dots" :size="14" />
</button>
<div v-if="menuId === m.id" class="menu">
<UiDropdownMenu
:open="menuId === m.id"
align="end"
@update:open="(v) => (menuId = v ? m.id : null)"
>
<template #trigger="{ toggle }">
<button class="act more" type="button" @click.stop="toggle">
<UiIcon name="dots" :size="14" />
</button>
</template>
<RouterLink :to="`/contacts/people/${m.id}`">پروفایل</RouterLink>
<button type="button" @click="openLedger(m)">تاریخچه امتیاز</button>
<button type="button" @click="openRedeem(undefined, m)">ثبت پاداش</button>
</div>
</UiDropdownMenu>
</td>
</tr>
</tbody>
@@ -815,9 +822,20 @@ onBeforeUnmount(() => {
<b class="mob-pts"><span>امتیاز</span><em>{{ faNum(m.loyaltyPoints) }}</em></b>
<div class="mob-side">
<div class="ops">
<button class="act more" type="button" @click.stop="menuId = menuId === m.id ? null : m.id">
<UiIcon name="dots" :size="14" />
</button>
<UiDropdownMenu
:open="menuId === m.id"
align="end"
@update:open="(v) => (menuId = v ? m.id : null)"
>
<template #trigger="{ toggle }">
<button class="act more" type="button" @click.stop="toggle">
<UiIcon name="dots" :size="14" />
</button>
</template>
<RouterLink :to="`/contacts/people/${m.id}`">پروفایل</RouterLink>
<button type="button" @click="openLedger(m)">تاریخچه امتیاز</button>
<button type="button" @click="openRedeem(undefined, m)">ثبت پاداش</button>
</UiDropdownMenu>
<button class="act plus" type="button" @click="openAdjust(m, 1)">
<UiIcon name="plus" :size="14" />
</button>
@@ -825,11 +843,6 @@ onBeforeUnmount(() => {
<UiIcon name="minus" :size="14" />
</button>
</div>
<div v-if="menuId === m.id" class="menu" @click.stop>
<RouterLink :to="`/contacts/people/${m.id}`">پروفایل</RouterLink>
<button type="button" @click="openLedger(m)">تاریخچه امتیاز</button>
<button type="button" @click="openRedeem(undefined, m)">ثبت پاداش</button>
</div>
</div>
</li>
</ul>
@@ -1287,17 +1300,6 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; }
.act.plus { background: #dcfce7; color: #15803d; }
.act.minus { background: #fee2e2; color: #dc2626; }
.act.more { background: #f1f5f9; color: #64748b; }
.menu {
position: absolute; inset-inline-end: 0; top: 34px; z-index: 5;
background: #fff; border: 1px solid #e2e8f0; border-radius: var(--radius);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
display: grid; min-width: 140px; overflow: hidden;
}
.menu a, .menu button {
background: none; border: 0; text-align: right; padding: 0.55rem 0.75rem;
font: inherit; cursor: pointer; color: var(--ink); text-decoration: none;
}
.menu a:hover, .menu button:hover { background: #f8fafc; }
.mob-list { display: none; list-style: none; margin: 0; padding: 0; gap: 0.65rem; }
.mob-list li {
+16 -35
View File
@@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu } from '@/components/ui';
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import type {
CreateMessageAutomationRuleRequest,
@@ -414,17 +415,23 @@ onUnmounted(() => {
>
<header>
<div class="m-menu">
<button
type="button"
aria-label="عملیات"
@click="menuId = menuId === row.id ? null : row.id"
<UiDropdownMenu
:open="menuId === row.id"
align="end"
@update:open="(v) => (menuId = v ? row.id : null)"
>
<UiIcon name="dots-v" :size="18" />
</button>
<div v-if="menuId === row.id" class="pop">
<template #trigger="{ toggle }">
<button
type="button"
aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="18" />
</button>
</template>
<button type="button" @click="openMobileEdit(row)">ویرایش</button>
<button type="button" class="danger" @click="askRemove(row)">حذف</button>
</div>
</UiDropdownMenu>
</div>
<div class="m-copy">
<strong>{{ row.name }}</strong>
@@ -2144,7 +2151,7 @@ textarea {
position: relative;
}
.m-menu > button,
.m-menu :deep(button),
.m-back {
width: 2rem;
height: 2rem;
@@ -2157,32 +2164,6 @@ textarea {
padding: 0;
}
.m-menu .pop {
position: absolute;
inset-inline-start: 0;
top: 2.1rem;
z-index: 4;
min-width: 7.5rem;
display: grid;
padding: 0.35rem;
border-radius: 12px;
background: #fff;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
}
.m-menu .pop button {
border: 0;
background: transparent;
text-align: start;
padding: 0.5rem 0.6rem;
font: inherit;
cursor: pointer;
}
.m-menu .pop .danger {
color: #b91c1c;
}
.m-card footer {
display: flex;
align-items: center;
+74 -65
View File
@@ -8,6 +8,7 @@ import { formatMoney } from '@/lib/money';
import {
UiBadge,
UiButton,
UiDropdownMenu,
UiEmptyState,
UiInput,
UiModal,
@@ -272,22 +273,22 @@ onMounted(load);
<UiIcon name="plus" :size="16" />
محصول جدید
</UiButton>
<div class="more-wrap" @click.stop>
<UiButton
variant="ghost"
size="md"
aria-label="عملیات بیشتر"
@click="moreOpen = !moreOpen"
>
<UiIcon name="dots" :size="18" />
</UiButton>
<div v-if="moreOpen" class="menu">
<button type="button" :disabled="!selected.size" @click="removeSelected">
حذف انتخاب‌شده‌ها
</button>
<button type="button" @click="load">بروزرسانی لیست</button>
</div>
</div>
<UiDropdownMenu v-model:open="moreOpen" align="end">
<template #trigger="{ toggle }">
<UiButton
variant="ghost"
size="md"
aria-label="عملیات بیشتر"
@click.stop="toggle"
>
<UiIcon name="dots" :size="18" />
</UiButton>
</template>
<button type="button" :disabled="!selected.size" @click="removeSelected">
حذف انتخاب‌شده‌ها
</button>
<button type="button" @click="load">بروزرسانی لیست</button>
</UiDropdownMenu>
</template>
</UiPageHeader>
@@ -312,23 +313,23 @@ onMounted(load);
<option value="active">فعال</option>
<option value="inactive">غیرفعال</option>
</UiSelect>
<div class="sort-wrap">
<UiButton variant="outline" @click="sortOpen = !sortOpen">
<UiIcon name="filter" :size="16" />
مرتب‌سازی
</UiButton>
<div v-if="sortOpen" class="menu">
<button type="button" :class="{ on: sort === 'name' }" @click="sort = 'name'; sortOpen = false">
نام
</button>
<button type="button" :class="{ on: sort === 'newest' }" @click="sort = 'newest'; sortOpen = false">
جدیدترین
</button>
<button type="button" :class="{ on: sort === 'price' }" @click="sort = 'price'; sortOpen = false">
قیمت
</button>
</div>
</div>
<UiDropdownMenu v-model:open="sortOpen" align="end">
<template #trigger="{ toggle }">
<UiButton variant="outline" @click.stop="toggle">
<UiIcon name="filter" :size="16" />
مرتب‌سازی
</UiButton>
</template>
<button type="button" :class="{ on: sort === 'name' }" @click="sort = 'name'">
نام
</button>
<button type="button" :class="{ on: sort === 'newest' }" @click="sort = 'newest'">
جدیدترین
</button>
<button type="button" :class="{ on: sort === 'price' }" @click="sort = 'price'">
قیمت
</button>
</UiDropdownMenu>
</div>
<p v-if="error" class="error">{{ error }}</p>
@@ -398,23 +399,27 @@ onMounted(load);
<small>{{ formatTime(p.createdAt) }}</small>
</td>
<td class="ops" @click.stop>
<div class="row-more">
<button
type="button"
class="icon-btn"
aria-label="عملیات"
@click="menuId = menuId === p.id ? null : p.id"
>
<UiIcon name="dots" :size="16" />
</button>
<div v-if="menuId === p.id" class="menu">
<button type="button" @click="goEdit(p.id)">ویرایش</button>
<button type="button" @click="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
<UiDropdownMenu
:open="menuId === p.id"
align="end"
@update:open="(v) => (menuId = v ? p.id : null)"
>
<template #trigger="{ toggle }">
<button
type="button"
class="icon-btn"
aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots" :size="16" />
</button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
</div>
</div>
</template>
<button type="button" @click="goEdit(p.id)">ویرایش</button>
<button type="button" @click="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
</UiDropdownMenu>
</td>
</tr>
</tbody>
@@ -443,23 +448,27 @@ onMounted(load);
</span>
</div>
<div class="mob-side">
<div class="row-more">
<button
type="button"
class="icon-btn"
aria-label="عملیات"
@click="menuId = menuId === p.id ? null : p.id"
>
<UiIcon name="dots-v" :size="16" />
</button>
<div v-if="menuId === p.id" class="menu left">
<button type="button" @click="goEdit(p.id)">ویرایش</button>
<button type="button" @click="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
<UiDropdownMenu
:open="menuId === p.id"
align="end"
@update:open="(v) => (menuId = v ? p.id : null)"
>
<template #trigger="{ toggle }">
<button
type="button"
class="icon-btn"
aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="16" />
</button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
</div>
</div>
</template>
<button type="button" @click="goEdit(p.id)">ویرایش</button>
<button type="button" @click="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
</UiDropdownMenu>
<UiBadge :variant="p.isActive ? 'success' : 'neutral'" size="sm" dot>
{{ p.isActive ? 'فعال' : 'غیرفعال' }}
</UiBadge>
+27 -23
View File
@@ -15,6 +15,7 @@ import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import UiIcon from '@/components/UiIcon.vue';
import {
UiDropdownMenu,
UiPageHeader,
UiPagination,
UiChart,
@@ -649,13 +650,19 @@ onMounted(() => {
<button type="button" class="icon-btn sm" title="ویرایش" @click="openEdit(g)">
<UiIcon name="pencil" :size="15" />
</button>
<button type="button" class="icon-btn sm" @click="menuId = menuId === g.id ? null : g.id">
<UiIcon name="dots-v" :size="15" />
</button>
<div v-if="menuId === g.id" class="menu">
<UiDropdownMenu
:open="menuId === g.id"
align="end"
@update:open="(v) => (menuId = v ? g.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="icon-btn sm" @click.stop="toggle">
<UiIcon name="dots-v" :size="15" />
</button>
</template>
<button type="button" @click="openEdit(g)">ویرایش</button>
<button type="button" class="danger" @click="removeGoal(g.id)">حذف</button>
</div>
</UiDropdownMenu>
</td>
</tr>
</tbody>
@@ -672,13 +679,19 @@ onMounted(() => {
</button>
</div>
<article v-for="g in filteredGoals" :key="g.id" class="goal-card" @click.stop>
<button type="button" class="dots" @click="menuId = menuId === g.id ? null : g.id">
<UiIcon name="dots-v" :size="16" />
</button>
<div v-if="menuId === g.id" class="menu left">
<UiDropdownMenu
:open="menuId === g.id"
align="end"
@update:open="(v) => (menuId = v ? g.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="dots" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<button type="button" @click="openEdit(g)">ویرایش</button>
<button type="button" class="danger" @click="removeGoal(g.id)">حذف</button>
</div>
</UiDropdownMenu>
<div class="goal-body">
<div>
<strong>{{ g.title }}</strong>
@@ -872,18 +885,6 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: #64748b;
}
.icon-btn.sm { width: 30px; height: 30px; }
.menu {
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 120px;
background: #fff; border: 1px solid #e2e8f0; border-radius: var(--radius); box-shadow: 0 10px 24px rgba(15,23,42,.12);
padding: 0.3rem; display: grid;
}
.menu.left { right: 0; left: auto; }
.menu button {
border: 0; background: transparent; text-align: right; padding: 0.5rem 0.65rem; border-radius: var(--radius);
cursor: pointer; font: inherit;
}
.menu button:hover { background: var(--teal-soft); color: var(--teal); }
.menu button.danger { color: #b91c1c; }
.pager { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem; align-items: center; }
.pager-meta { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }
@@ -907,8 +908,11 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
display: grid; place-items: center; cursor: pointer;
}
.goal-card { padding: 0.9rem; display: grid; gap: 0.65rem; position: relative; }
.goal-card :deep(.ui-dd-anchor) {
position: absolute; top: 0.6rem; left: 0.5rem;
}
.goal-card .dots {
position: absolute; top: 0.6rem; left: 0.5rem; border: 0; background: transparent; color: #94a3b8; cursor: pointer;
border: 0; background: transparent; color: #94a3b8; cursor: pointer;
}
.goal-body { display: grid; grid-template-columns: 1fr auto; gap: 0.65rem; align-items: center; }
.goal-body strong { display: block; }
+14 -43
View File
@@ -5,7 +5,7 @@ import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import UiIcon from '@/components/UiIcon.vue';
import UiModal from '@/components/ui/UiModal.vue';
import { UiDropdownMenu, UiModal } from '@/components/ui';
import {
ROLE_PERMISSION_ITEMS,
type InviteUserRequest,
@@ -754,16 +754,20 @@ onUnmounted(() => {
</td>
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
<td class="ops" @click.stop>
<div class="kebab">
<button type="button" class="ghost-ico" aria-label="عملیات" @click.stop="menuUserId = menuUserId === user.id ? null : user.id">
<UiIcon name="dots-v" :size="16" />
</button>
<div v-if="menuUserId === user.id" class="menu" @click.stop>
<button type="button" @click="toggleStatus(user)">
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
<UiDropdownMenu
:open="menuUserId === user.id"
align="end"
@update:open="(v) => (menuUserId = v ? user.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="ghost-ico" aria-label="عملیات" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</div>
</div>
</template>
<button type="button" @click="toggleStatus(user)">
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
</button>
</UiDropdownMenu>
</td>
</tr>
<tr v-if="!loading && !pagedUsers.length">
@@ -1218,39 +1222,6 @@ tbody tr + tr td {
color: #94a3b8;
}
.kebab {
position: relative;
}
.menu {
position: absolute;
inset-inline-start: 0;
top: 100%;
z-index: 5;
min-width: 9rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
padding: 0.35rem;
}
.menu button {
width: 100%;
appearance: none;
background: none;
border: 0;
text-align: right;
padding: 0.45rem 0.55rem;
border-radius: 8px;
font: inherit;
cursor: pointer;
}
.menu button:hover {
background: #f8fafc;
}
.ghost-ico {
width: 32px;
height: 32px;
+5 -4
View File
@@ -141,9 +141,9 @@ mkdir -p uploads
# اختیاری یک‌بار: npm run db:seed
```
### ۶) تبدیل صوت به متن (Pipecat)
### ۶) تبدیل صوت به متن (Pipecat — Whisper محلی)
فقط STT. روی سرور Python 3.11+ لازم است.
فقط STT روی همین سرور؛ **بدون GapGPT**. Python 3.11+ و ترجیحاً `ffmpeg`.
```bash
cd /var/www/crm/apps/stt
@@ -153,8 +153,9 @@ cp .env.example .env
nano .env
```
`OPENAI_API_KEY` و `OPENAI_BASE_URL` را مثل `AI_*` در `apps/api/.env` بگذارید.
`STT_INTERNAL_TOKEN` باید با مقدار همین کلید در `apps/api/.env` یکی باشد.
در `.env` فقط مدل محلی لازم است (`STT_MODEL=small`، `STT_DEVICE=cpu`).
`OPENAI_API_KEY` برای دیکته لازم نیست.
`STT_INTERNAL_TOKEN` اگر پر شد باید با `apps/api/.env` یکی باشد.
### ۷) PM2 — فقط پروسه‌های جدید