mirror of
https://github.com/RVC-Boss/GPT-SoVITS.git
synced 2026-07-23 10:55:47 +08:00
Add Nutq: AI voice dictation PWA for mobile and desktop
Wispr Flow-style voice keyboard app: speak naturally, get polished ready-to-send text. Installable PWA (iPhone/Android/desktop) with: - Live dictation via Web Speech API, or Whisper via any OpenAI-compatible endpoint - AI polish with writing modes (message/email/notes/formal/raw) - 80+ recognition languages with auto-detect, personal dictionary - Local-only history, Arabic RTL + English UI, light/dark themes - Offline app shell via service worker, GitHub Pages deploy workflow Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzXuVcjCFcBhTQT1wsEANC
This commit is contained in:
parent
2d9193b0d3
commit
0fe31f0358
31
.github/workflows/deploy-nutq.yml
vendored
Normal file
31
.github/workflows/deploy-nutq.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
name: Deploy Nutq to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, claude/mobile-desktop-app-y1g2sm]
|
||||
paths: ["nutq/**", ".github/workflows/deploy-nutq.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: nutq
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
73
nutq/README.md
Normal file
73
nutq/README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# نُطق (Nutq) — لوحة المفاتيح الصوتية بالذكاء الاصطناعي 🎙️
|
||||
|
||||
**تكلّم بشكل طبيعي، ونُطق يكتب نصاً مصقولاً جاهزاً للإرسال.**
|
||||
|
||||
تطبيق ويب تقدمي (PWA) احترافي مستوحى من Wispr Flow — يعمل على **iPhone و Android والحاسوب (Windows / Mac / Linux)** من كود واحد، ويُثبَّت كتطبيق حقيقي بأيقونة على الشاشة الرئيسية وسطح المكتب.
|
||||
|
||||

|
||||
|
||||
## المميزات
|
||||
|
||||
- 🎙️ **إملاء صوتي فوري** — النص يظهر مباشرة أثناء كلامك (محرك المتصفح المجاني)، أو عبر Whisper API لدقة أعلى.
|
||||
- ✨ **صقل بالذكاء الاصطناعي** — إزالة كلمات الحشو، تصحيح علامات الترقيم، وإعادة صياغة بأسلوبك.
|
||||
- 📝 **أنماط كتابة** — رسالة، بريد إلكتروني، ملاحظات، رسمي، أو نص خام بلا تعديل.
|
||||
- 🌍 **أكثر من 100 لغة** — منها العربية بلهجاتها، مع اكتشاف تلقائي للغة.
|
||||
- 📖 **قاموس شخصي** — أضف أسماءً ومصطلحات خاصة ليحترمها التطبيق عند الكتابة.
|
||||
- 🕘 **سجل محلي** — كل إملاءاتك محفوظة على جهازك فقط (لا تُرسل لأي خادم).
|
||||
- 🌗 **واجهة عربية RTL** كاملة + إنجليزية، مع مظهر فاتح وداكن.
|
||||
- 📵 **يعمل دون إنترنت** — واجهة التطبيق تعمل offline بعد أول زيارة.
|
||||
|
||||
## التشغيل والنشر
|
||||
|
||||
### 1) تجربة محلية سريعة
|
||||
|
||||
```bash
|
||||
cd nutq
|
||||
python3 -m http.server 8080
|
||||
# افتح http://localhost:8080
|
||||
```
|
||||
|
||||
> ملاحظة: الميكروفون يتطلب HTTPS أو localhost.
|
||||
|
||||
### 2) النشر المجاني على GitHub Pages (موصى به)
|
||||
|
||||
المستودع يتضمن Workflow جاهزاً (`.github/workflows/deploy-nutq.yml`):
|
||||
|
||||
1. من إعدادات المستودع على GitHub: **Settings → Pages → Source: GitHub Actions**.
|
||||
2. ادفع أي تعديل لمجلد `nutq/` وسيُنشر تلقائياً.
|
||||
3. سيكون التطبيق على: `https://<اسم-المستخدم>.github.io/<اسم-المستودع>/`
|
||||
|
||||
يمكن أيضاً النشر على Netlify أو Vercel أو Cloudflare Pages بسحب مجلد `nutq/` فقط.
|
||||
|
||||
### 3) التثبيت كتطبيق
|
||||
|
||||
- **iPhone (Safari):** افتح الرابط → زر المشاركة → **إضافة إلى الشاشة الرئيسية**.
|
||||
- **Android (Chrome):** افتح الرابط → قائمة ⋮ → **تثبيت التطبيق**.
|
||||
- **الحاسوب (Chrome / Edge):** افتح الرابط → أيقونة التثبيت ⊕ في شريط العنوان → **تثبيت**. يصبح تطبيقاً مستقلاً بنافذته الخاصة.
|
||||
|
||||
## إعداد الذكاء الاصطناعي (اختياري لكن موصى به)
|
||||
|
||||
بدون مفتاح API يعمل الإملاء الفوري مجاناً عبر محرك المتصفح. لتفعيل **الصقل الذكي** و **Whisper**:
|
||||
|
||||
1. افتح **الإعدادات ⚙️** داخل التطبيق.
|
||||
2. أدخل مفتاح API من أي خدمة متوافقة مع OpenAI:
|
||||
|
||||
| الخدمة | Base URL | ملاحظات |
|
||||
|---|---|---|
|
||||
| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` + `whisper-1` |
|
||||
| Groq (مجاني) | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` + `whisper-large-v3` |
|
||||
| OpenRouter | `https://openrouter.ai/api/v1` | نماذج متعددة |
|
||||
|
||||
> 🔒 **الخصوصية:** المفتاح والسجل والقاموس تُحفظ في متصفحك فقط (localStorage)، ولا يمر أي شيء عبر خوادم وسيطة — الاتصال مباشر من جهازك إلى مزوّد الخدمة الذي تختاره.
|
||||
|
||||
## البنية التقنية
|
||||
|
||||
- HTML/CSS/JS خالص بدون أي اعتماديات أو خطوة بناء — سهل الصيانة والنشر في أي مكان.
|
||||
- `Web Speech API` للإملاء الفوري، و`MediaRecorder` + Whisper للتفريغ عالي الدقة.
|
||||
- Service Worker للعمل دون اتصال + Web App Manifest للتثبيت.
|
||||
|
||||
---
|
||||
|
||||
# Nutq — AI Voice Keyboard (English)
|
||||
|
||||
A professional Wispr Flow–style PWA: speak naturally and get polished, ready-to-send text. Installs as a real app on iPhone, Android, and desktop from a single codebase. Live dictation via the free browser speech engine or any OpenAI-compatible Whisper endpoint, AI polishing with writing modes (message / email / notes / formal), 100+ languages with auto-detect, a personal dictionary, local-only history, full RTL Arabic + English UI, and offline support. Serve the `nutq/` folder from any static host (a GitHub Pages workflow is included) and add your API key in Settings to enable AI features.
|
||||
288
nutq/app.css
Normal file
288
nutq/app.css
Normal file
@ -0,0 +1,288 @@
|
||||
/* ===== Nutq — AI voice keyboard ===== */
|
||||
:root {
|
||||
--bg: #FAF6E9;
|
||||
--bg-soft: #F3EDDA;
|
||||
--card: #FFFDF4;
|
||||
--ink: #17151A;
|
||||
--ink-soft: #5C5850;
|
||||
--line: rgba(23, 21, 26, .1);
|
||||
--accent: #6D4FE0;
|
||||
--accent-soft: #E9DFFF;
|
||||
--accent-ink: #4A32AC;
|
||||
--danger: #C43D3D;
|
||||
--shadow: 0 10px 30px rgba(23, 21, 26, .08);
|
||||
--radius: 22px;
|
||||
--serif: "Noto Naskh Arabic", "Amiri", Georgia, "Times New Roman", serif;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Tahoma, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--bg: #111014;
|
||||
--bg-soft: #1A181F;
|
||||
--card: #1E1C24;
|
||||
--ink: #F3F0E8;
|
||||
--ink-soft: #A5A09A;
|
||||
--line: rgba(243, 240, 232, .12);
|
||||
--accent: #8F75FF;
|
||||
--accent-soft: #2C2542;
|
||||
--accent-ink: #C9BAFF;
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, .4);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #111014; --bg-soft: #1A181F; --card: #1E1C24;
|
||||
--ink: #F3F0E8; --ink-soft: #A5A09A; --line: rgba(243,240,232,.12);
|
||||
--accent: #8F75FF; --accent-soft: #2C2542; --accent-ink: #C9BAFF;
|
||||
--shadow: 0 10px 30px rgba(0,0,0,.4);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
[hidden] { display: none !important; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
min-height: 100dvh;
|
||||
padding-bottom: calc(190px + env(safe-area-inset-bottom));
|
||||
transition: background .3s, color .3s;
|
||||
}
|
||||
|
||||
svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
|
||||
/* ===== Top bar ===== */
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: calc(10px + env(safe-area-inset-top)) 18px 10px;
|
||||
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
||||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 9px; }
|
||||
.brand-mark { width: 26px; height: 26px; fill: var(--ink); }
|
||||
.brand-name { font-family: var(--serif); font-size: 24px; font-weight: 700; }
|
||||
.topbar-actions { display: flex; gap: 8px; }
|
||||
.icon-btn {
|
||||
width: 42px; height: 42px; border-radius: 50%;
|
||||
border: 1px solid var(--line); background: var(--card); color: var(--ink);
|
||||
display: grid; place-items: center; cursor: pointer;
|
||||
transition: transform .15s, background .2s;
|
||||
}
|
||||
.icon-btn:active { transform: scale(.92); }
|
||||
|
||||
/* ===== Main ===== */
|
||||
.app { max-width: 680px; margin: 0 auto; padding: 8px 18px 0; }
|
||||
|
||||
.hero { text-align: center; margin: 18px 0 22px; }
|
||||
.hero-title {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(34px, 9vw, 52px);
|
||||
line-height: 1.15; margin: 0 0 12px; font-weight: 700;
|
||||
}
|
||||
.hero-sub { color: var(--ink-soft); font-size: 16px; line-height: 1.65; margin: 0 auto; max-width: 46ch; }
|
||||
.hero.compact { margin: 6px 0 10px; }
|
||||
.hero.compact .hero-title { font-size: 24px; }
|
||||
.hero.compact .hero-sub { display: none; }
|
||||
|
||||
/* ===== Mode chips ===== */
|
||||
.modes {
|
||||
display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 10px;
|
||||
scrollbar-width: none; -ms-overflow-style: none;
|
||||
}
|
||||
.modes::-webkit-scrollbar { display: none; }
|
||||
.chip {
|
||||
flex: 0 0 auto; padding: 9px 18px; border-radius: 999px;
|
||||
border: 1px solid var(--line); background: var(--card); color: var(--ink);
|
||||
font-size: 14.5px; font-weight: 600; cursor: pointer; font-family: var(--sans);
|
||||
transition: background .2s, color .2s, border-color .2s;
|
||||
}
|
||||
.chip.active { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
||||
|
||||
/* ===== Language pill ===== */
|
||||
.lang-pill {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
margin: 2px 0 14px; padding: 8px 14px; border-radius: 999px;
|
||||
border: 1px solid var(--line); background: var(--accent-soft); color: var(--accent-ink);
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; font-family: var(--sans);
|
||||
}
|
||||
.lang-pill svg { width: 17px; height: 17px; }
|
||||
|
||||
/* ===== Cards ===== */
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 18px; margin-bottom: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.card-title { font-size: 13px; font-weight: 700; letter-spacing: .3px; color: var(--ink-soft); }
|
||||
.badge {
|
||||
background: var(--accent-soft); color: var(--accent-ink);
|
||||
padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.live-dot { width: 10px; height: 10px; border-radius: 50%; background: #E0483F; animation: blink 1.1s infinite; }
|
||||
@keyframes blink { 50% { opacity: .25; } }
|
||||
|
||||
.transcript, .result {
|
||||
min-height: 54px; font-size: 17px; line-height: 1.75; outline: none;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.transcript:empty::before, .result:empty::before {
|
||||
content: attr(data-placeholder); color: var(--ink-soft); opacity: .6;
|
||||
}
|
||||
.transcript .interim { color: var(--ink-soft); opacity: .75; }
|
||||
.result-card { border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
|
||||
|
||||
.card-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 10px 16px; border-radius: 14px; cursor: pointer;
|
||||
border: 1px solid var(--line); background: var(--bg-soft); color: var(--ink);
|
||||
font-size: 14px; font-weight: 700; font-family: var(--sans);
|
||||
transition: transform .15s, opacity .2s;
|
||||
}
|
||||
.btn:active { transform: scale(.96); }
|
||||
.btn svg { width: 17px; height: 17px; }
|
||||
.btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
||||
.btn.danger { background: transparent; color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
|
||||
.btn.small { padding: 7px 13px; font-size: 13px; }
|
||||
.btn.wide { width: 100%; justify-content: center; padding: 14px; font-size: 16px; margin-top: 6px; }
|
||||
|
||||
.status { text-align: center; color: var(--ink-soft); font-size: 14px; min-height: 22px; margin: 4px 0; }
|
||||
.status.error { color: var(--danger); font-weight: 600; }
|
||||
|
||||
/* ===== Features ===== */
|
||||
.features { display: grid; gap: 12px; margin-top: 10px; }
|
||||
.feature {
|
||||
display: flex; gap: 13px; align-items: flex-start;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 18px; padding: 15px 16px;
|
||||
}
|
||||
.f-ico { font-size: 22px; line-height: 1.3; }
|
||||
.feature b { font-size: 15.5px; }
|
||||
.feature p { margin: 3px 0 0; color: var(--ink-soft); font-size: 14px; line-height: 1.6; }
|
||||
|
||||
/* ===== Mic dock ===== */
|
||||
.mic-dock {
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
padding: 14px 0 calc(18px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(to top, var(--bg) 62%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.mic-dock > * { pointer-events: auto; }
|
||||
.mic-btn {
|
||||
position: relative; width: 84px; height: 84px; border-radius: 50%;
|
||||
border: none; cursor: pointer;
|
||||
background: var(--ink); color: var(--bg);
|
||||
display: grid; place-items: center;
|
||||
box-shadow: 0 14px 34px rgba(0,0,0,.25);
|
||||
transition: transform .15s, background .25s;
|
||||
}
|
||||
.mic-btn:active { transform: scale(.93); }
|
||||
.mic-btn svg { width: 34px; height: 34px; }
|
||||
.mic-btn.rec { background: #E0483F; color: #fff; }
|
||||
.mic-ring {
|
||||
position: absolute; inset: 0; border-radius: 50%;
|
||||
border: 2px solid #E0483F; opacity: 0; pointer-events: none;
|
||||
}
|
||||
.mic-btn.rec .mic-ring { animation: ripple 1.8s infinite; }
|
||||
.mic-btn.rec .r2 { animation-delay: .9s; }
|
||||
@keyframes ripple {
|
||||
0% { transform: scale(1); opacity: .7; }
|
||||
100% { transform: scale(1.85); opacity: 0; }
|
||||
}
|
||||
.mic-hint { font-size: 13.5px; color: var(--ink-soft); font-weight: 600; }
|
||||
.timer { font-variant-numeric: tabular-nums; font-weight: 700; font-size: 15px; color: #E0483F; }
|
||||
|
||||
.waveform { display: flex; align-items: center; gap: 3px; height: 34px; }
|
||||
.waveform span {
|
||||
width: 4px; height: 8px; border-radius: 3px; background: var(--accent);
|
||||
animation: wave 1s ease-in-out infinite;
|
||||
}
|
||||
.waveform span:nth-child(3n) { animation-delay: .15s; }
|
||||
.waveform span:nth-child(3n+1){ animation-delay: .35s; }
|
||||
.waveform span:nth-child(4n) { animation-delay: .5s; }
|
||||
.waveform span:nth-child(5n) { animation-delay: .7s; }
|
||||
@keyframes wave { 0%,100% { height: 7px; } 50% { height: 30px; } }
|
||||
|
||||
/* ===== Sheets ===== */
|
||||
.sheet-backdrop {
|
||||
position: fixed; inset: 0; z-index: 50;
|
||||
background: rgba(0,0,0,.45);
|
||||
display: flex; align-items: flex-end; justify-content: center;
|
||||
animation: fadeIn .2s;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } }
|
||||
.sheet {
|
||||
background: var(--bg); width: 100%; max-width: 680px;
|
||||
max-height: 88dvh; overflow-y: auto;
|
||||
border-radius: 26px 26px 0 0; padding: 10px 20px calc(26px + env(safe-area-inset-bottom));
|
||||
animation: slideUp .28s cubic-bezier(.2,.9,.3,1);
|
||||
}
|
||||
@keyframes slideUp { from { transform: translateY(60px); opacity: .4; } }
|
||||
.sheet-grab { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 14px; }
|
||||
.sheet h2 { font-family: var(--serif); font-size: 24px; margin: 4px 0 16px; }
|
||||
.sheet-title-row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.sheet-title-row h2 { margin: 4px 0 12px; }
|
||||
|
||||
.search {
|
||||
width: 100%; padding: 12px 16px; border-radius: 14px;
|
||||
border: 1px solid var(--line); background: var(--card); color: var(--ink);
|
||||
font-size: 16px; margin-bottom: 12px; font-family: var(--sans);
|
||||
}
|
||||
.lang-list { display: flex; flex-direction: column; }
|
||||
.lang-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 13px 10px; border-radius: 14px; cursor: pointer;
|
||||
font-size: 16px; border: none; background: none; color: var(--ink);
|
||||
text-align: start; font-family: var(--sans); width: 100%;
|
||||
}
|
||||
.lang-item .flag { margin-inline-end: 10px; }
|
||||
.lang-item.selected { background: var(--accent-soft); color: var(--accent-ink); font-weight: 700; }
|
||||
.lang-item.selected::after { content: "✓"; font-weight: 900; }
|
||||
|
||||
/* ===== History ===== */
|
||||
.history-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.history-item {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 16px; padding: 14px;
|
||||
}
|
||||
.history-item .h-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--ink-soft); margin-bottom: 7px; }
|
||||
.history-item .h-text { font-size: 15px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
|
||||
.history-item .h-actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
.empty { text-align: center; color: var(--ink-soft); padding: 34px 0; font-size: 15px; }
|
||||
|
||||
/* ===== Settings fields ===== */
|
||||
.field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 15px; }
|
||||
.field > span { font-size: 13.5px; font-weight: 700; color: var(--ink-soft); }
|
||||
.field input[type="text"], .field input[type="password"], .field input[type="url"],
|
||||
.field select, .field textarea {
|
||||
padding: 12px 14px; border-radius: 13px; border: 1px solid var(--line);
|
||||
background: var(--card); color: var(--ink); font-size: 16px; font-family: var(--sans);
|
||||
width: 100%;
|
||||
}
|
||||
.field.row { flex-direction: row; align-items: center; justify-content: space-between; }
|
||||
.field.row input[type="checkbox"] { width: 22px; height: 22px; accent-color: var(--accent); }
|
||||
.field-group {
|
||||
border: 1px dashed var(--line); border-radius: 16px;
|
||||
padding: 14px; margin-bottom: 15px;
|
||||
}
|
||||
.fg-title { font-size: 13.5px; font-weight: 800; margin-bottom: 12px; color: var(--accent-ink); }
|
||||
.hint { font-size: 12.5px; color: var(--ink-soft); line-height: 1.6; margin: 4px 0 0; }
|
||||
.hint.center { text-align: center; margin-top: 14px; }
|
||||
|
||||
/* ===== Toast ===== */
|
||||
.toast {
|
||||
position: fixed; bottom: calc(200px + env(safe-area-inset-bottom));
|
||||
left: 50%; transform: translateX(-50%); z-index: 60;
|
||||
background: var(--ink); color: var(--bg);
|
||||
padding: 11px 20px; border-radius: 999px; font-size: 14.5px; font-weight: 700;
|
||||
box-shadow: var(--shadow); animation: toastIn .25s;
|
||||
max-width: 88vw; text-align: center;
|
||||
}
|
||||
@keyframes toastIn { from { transform: translate(-50%, 14px); opacity: 0; } }
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.hero-title { font-size: 56px; }
|
||||
body { padding-bottom: 210px; }
|
||||
}
|
||||
586
nutq/app.js
Normal file
586
nutq/app.js
Normal file
@ -0,0 +1,586 @@
|
||||
/* ===== Nutq — AI voice keyboard =====
|
||||
* Speak naturally → get polished, ready-to-send text.
|
||||
* Engines: Web Speech API (free, live) or any OpenAI-compatible
|
||||
* Whisper endpoint. Polishing via OpenAI-compatible chat API.
|
||||
* Everything is stored locally on the device.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/* ---------- i18n ---------- */
|
||||
const I18N = {
|
||||
ar: {
|
||||
appName: "نُطق",
|
||||
heroTitle: "إملاء صوتي<br>بلا أخطاء",
|
||||
heroSub: "تكلّم بشكل طبيعي، ونُطق يحوّل كلامك إلى نص مصقول جاهز للإرسال — في أي تطبيق وبأكثر من 100 لغة.",
|
||||
modeMessage: "رسالة", modeEmail: "بريد إلكتروني", modeNotes: "ملاحظات", modeFormal: "رسمي", modeRaw: "نص خام",
|
||||
rawTranscript: "النص المسموع", polished: "✨ النص المصقول",
|
||||
transcriptPh: "سيظهر كلامك هنا…",
|
||||
copy: "نسخ", copied: "تم النسخ ✓", share: "مشاركة", repolish: "إعادة الصقل",
|
||||
tapToTalk: "اضغط وتكلّم", tapToStop: "اضغط للإيقاف",
|
||||
listening: "أستمع إليك…", transcribing: "جارٍ تفريغ الصوت…", polishing: "جارٍ صقل النص…",
|
||||
history: "السجل", clearAll: "مسح الكل", settings: "الإعدادات",
|
||||
uiLang: "لغة الواجهة", engine: "محرك التعرّف على الصوت",
|
||||
engineAuto: "تلقائي (الأفضل المتاح)", engineWeb: "المتصفح (مجاني، فوري)", engineWhisper: "Whisper عبر API (أدق)",
|
||||
aiSection: "إعدادات الذكاء الاصطناعي (للصقل و Whisper)",
|
||||
apiKey: "مفتاح API", baseUrl: "عنوان الخدمة (Base URL)", model: "نموذج الصقل", whisperModel: "نموذج التفريغ الصوتي",
|
||||
apiHint: "يدعم أي خدمة متوافقة مع OpenAI: OpenAI، Groq (مجاني)، OpenRouter وغيرها. المفتاح يُحفظ على جهازك فقط.",
|
||||
dictionary: "القاموس الشخصي (أسماء ومصطلحات، افصل بينها بفواصل)",
|
||||
dictPh: "مثال: جلال، شركة لُمى، GPT-SoVITS",
|
||||
autoPolish: "صقل تلقائي بعد التوقف",
|
||||
theme: "المظهر", themeAuto: "تلقائي", themeLight: "فاتح", themeDark: "داكن",
|
||||
save: "حفظ", saved: "تم الحفظ ✓",
|
||||
searchLang: "ابحث عن لغة…", autoDetect: "اكتشاف تلقائي",
|
||||
f1t: "يعمل مع كل التطبيقات", f1d: "أملِ النص ثم انسخه إلى واتساب، البريد، أو أي تطبيق آخر.",
|
||||
f2t: "صقل بالذكاء الاصطناعي", f2d: "بلا كلمات حشو ولا أخطاء — نص بأسلوبك، جاهز للإرسال.",
|
||||
f3t: "أكثر من 100 لغة", f3d: "العربية، الإنجليزية، الفرنسية وغيرها — مع اكتشاف تلقائي.",
|
||||
f4t: "قاموسك الشخصي", f4d: "أضف أسماءً ومصطلحات خاصة ليتعلم نُطق أسلوبك.",
|
||||
errNoMic: "تعذّر الوصول إلى الميكروفون. تحقّق من الأذونات.",
|
||||
errNoSpeech: "المتصفح لا يدعم التعرّف على الصوت. أضف مفتاح API في الإعدادات لاستخدام Whisper.",
|
||||
errNoKey: "أضف مفتاح API في الإعدادات أولاً لاستخدام هذه الميزة.",
|
||||
errApi: "خطأ من الخدمة: ", errNet: "تعذّر الاتصال بالخدمة. تحقّق من الإنترنت.",
|
||||
noHistory: "لا يوجد شيء في السجل بعد.", confirmClear: "هل تريد مسح كل السجل؟",
|
||||
deleted: "تم الحذف", noPolishKey: "أُبقي النص كما هو — أضف مفتاح API في الإعدادات لتفعيل الصقل الذكي.",
|
||||
installHint: "لتثبيت التطبيق: شارك ← أضف إلى الشاشة الرئيسية",
|
||||
},
|
||||
en: {
|
||||
appName: "Nutq",
|
||||
heroTitle: "Dictation<br>without the mistakes",
|
||||
heroSub: "Talk naturally — Nutq turns your speech into polished, ready-to-send text, in any app and 100+ languages.",
|
||||
modeMessage: "Message", modeEmail: "Email", modeNotes: "Notes", modeFormal: "Formal", modeRaw: "Raw",
|
||||
rawTranscript: "Raw transcript", polished: "✨ Polished text",
|
||||
transcriptPh: "Your speech will appear here…",
|
||||
copy: "Copy", copied: "Copied ✓", share: "Share", repolish: "Re-polish",
|
||||
tapToTalk: "Tap & talk", tapToStop: "Tap to stop",
|
||||
listening: "Listening…", transcribing: "Transcribing…", polishing: "Polishing…",
|
||||
history: "History", clearAll: "Clear all", settings: "Settings",
|
||||
uiLang: "Interface language", engine: "Speech engine",
|
||||
engineAuto: "Auto (best available)", engineWeb: "Browser (free, live)", engineWhisper: "Whisper via API (accurate)",
|
||||
aiSection: "AI settings (polish & Whisper)",
|
||||
apiKey: "API key", baseUrl: "Base URL", model: "Polish model", whisperModel: "Transcription model",
|
||||
apiHint: "Works with any OpenAI-compatible service: OpenAI, Groq (free), OpenRouter… Your key never leaves this device.",
|
||||
dictionary: "Personal dictionary (names & terms, comma-separated)",
|
||||
dictPh: "e.g. Jalal, Luma Inc, GPT-SoVITS",
|
||||
autoPolish: "Auto-polish after stopping",
|
||||
theme: "Theme", themeAuto: "Auto", themeLight: "Light", themeDark: "Dark",
|
||||
save: "Save", saved: "Saved ✓",
|
||||
searchLang: "Search languages…", autoDetect: "Auto-detect",
|
||||
f1t: "Works with every app", f1d: "Dictate, then paste into WhatsApp, email, or anywhere else.",
|
||||
f2t: "AI-polished output", f2d: "No filler words, no typos — your voice, cleaned up and ready to send.",
|
||||
f3t: "100+ languages", f3d: "Arabic, English, French and more — with auto-detect.",
|
||||
f4t: "Personal dictionary", f4d: "Add custom names and terms so Nutq learns your lingo.",
|
||||
errNoMic: "Microphone access failed. Check permissions.",
|
||||
errNoSpeech: "This browser has no speech recognition. Add an API key in Settings to use Whisper.",
|
||||
errNoKey: "Add an API key in Settings first.",
|
||||
errApi: "Service error: ", errNet: "Could not reach the service. Check your connection.",
|
||||
noHistory: "Nothing here yet.", confirmClear: "Clear all history?",
|
||||
deleted: "Deleted", noPolishKey: "Kept raw text — add an API key in Settings to enable AI polish.",
|
||||
installHint: "To install: Share → Add to Home Screen",
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- Languages (speech recognition targets) ---------- */
|
||||
const LANGS = [
|
||||
["auto", "", ""],
|
||||
["ar-SA", "🇸🇦", "العربية"], ["ar-EG", "🇪🇬", "العربية (مصر)"], ["ar-MA", "🇲🇦", "العربية (المغرب)"],
|
||||
["en-US", "🇺🇸", "English (US)"], ["en-GB", "🇬🇧", "English (UK)"], ["en-AU", "🇦🇺", "English (AU)"], ["en-IN", "🇮🇳", "English (India)"],
|
||||
["fr-FR", "🇫🇷", "Français"], ["fr-CA", "🇨🇦", "Français (Canada)"],
|
||||
["es-ES", "🇪🇸", "Español"], ["es-MX", "🇲🇽", "Español (México)"], ["es-AR", "🇦🇷", "Español (Argentina)"],
|
||||
["de-DE", "🇩🇪", "Deutsch"], ["it-IT", "🇮🇹", "Italiano"],
|
||||
["pt-BR", "🇧🇷", "Português (Brasil)"], ["pt-PT", "🇵🇹", "Português"],
|
||||
["ru-RU", "🇷🇺", "Русский"], ["uk-UA", "🇺🇦", "Українська"],
|
||||
["tr-TR", "🇹🇷", "Türkçe"], ["fa-IR", "🇮🇷", "فارسی"], ["ur-PK", "🇵🇰", "اردو"],
|
||||
["hi-IN", "🇮🇳", "हिन्दी"], ["bn-BD", "🇧🇩", "বাংলা"], ["ta-IN", "🇮🇳", "தமிழ்"], ["te-IN", "🇮🇳", "తెలుగు"],
|
||||
["ja-JP", "🇯🇵", "日本語"], ["ko-KR", "🇰🇷", "한국어"],
|
||||
["zh-CN", "🇨🇳", "中文 (简体)"], ["zh-TW", "🇹🇼", "中文 (繁體)"], ["zh-HK", "🇭🇰", "粵語 (香港)"],
|
||||
["id-ID", "🇮🇩", "Bahasa Indonesia"], ["ms-MY", "🇲🇾", "Bahasa Melayu"],
|
||||
["th-TH", "🇹🇭", "ไทย"], ["vi-VN", "🇻🇳", "Tiếng Việt"], ["fil-PH", "🇵🇭", "Filipino"],
|
||||
["nl-NL", "🇳🇱", "Nederlands"], ["sv-SE", "🇸🇪", "Svenska"], ["nb-NO", "🇳🇴", "Norsk"],
|
||||
["da-DK", "🇩🇰", "Dansk"], ["fi-FI", "🇫🇮", "Suomi"], ["is-IS", "🇮🇸", "Íslenska"],
|
||||
["pl-PL", "🇵🇱", "Polski"], ["cs-CZ", "🇨🇿", "Čeština"], ["sk-SK", "🇸🇰", "Slovenčina"],
|
||||
["hu-HU", "🇭🇺", "Magyar"], ["ro-RO", "🇷🇴", "Română"], ["bg-BG", "🇧🇬", "Български"],
|
||||
["el-GR", "🇬🇷", "Ελληνικά"], ["sr-RS", "🇷🇸", "Српски"], ["hr-HR", "🇭🇷", "Hrvatski"],
|
||||
["sl-SI", "🇸🇮", "Slovenščina"], ["lt-LT", "🇱🇹", "Lietuvių"], ["lv-LV", "🇱🇻", "Latviešu"], ["et-EE", "🇪🇪", "Eesti"],
|
||||
["he-IL", "🇮🇱", "עברית"], ["am-ET", "🇪🇹", "አማርኛ"], ["sw-KE", "🇰🇪", "Kiswahili"],
|
||||
["af-ZA", "🇿🇦", "Afrikaans"], ["zu-ZA", "🇿🇦", "isiZulu"],
|
||||
["ka-GE", "🇬🇪", "ქართული"], ["hy-AM", "🇦🇲", "Հայերեն"], ["az-AZ", "🇦🇿", "Azərbaycanca"],
|
||||
["kk-KZ", "🇰🇿", "Қазақша"], ["uz-UZ", "🇺🇿", "Oʻzbekcha"],
|
||||
["ne-NP", "🇳🇵", "नेपाली"], ["si-LK", "🇱🇰", "සිංහල"], ["km-KH", "🇰🇭", "ខ្មែរ"], ["lo-LA", "🇱🇦", "ລາວ"],
|
||||
["my-MM", "🇲🇲", "မြန်မာ"], ["mn-MN", "🇲🇳", "Монгол"],
|
||||
["ca-ES", "🏴", "Català"], ["eu-ES", "🏴", "Euskara"], ["gl-ES", "🏴", "Galego"],
|
||||
["mr-IN", "🇮🇳", "मराठी"], ["gu-IN", "🇮🇳", "ગુજરાતી"], ["kn-IN", "🇮🇳", "ಕನ್ನಡ"], ["ml-IN", "🇮🇳", "മലയാളം"], ["pa-IN", "🇮🇳", "ਪੰਜਾਬੀ"],
|
||||
];
|
||||
|
||||
/* ---------- Polish prompts ---------- */
|
||||
const MODE_PROMPTS = {
|
||||
message: "Rewrite the dictated text as a natural, friendly chat message. Keep it short and casual, as if sent on WhatsApp or iMessage.",
|
||||
email: "Rewrite the dictated text as a clear, well-structured email body. Add a suitable greeting and sign-off placeholder if appropriate. Use short paragraphs and bullet points where they help.",
|
||||
notes: "Rewrite the dictated text as concise personal notes: short bullet points capturing every idea, no fluff.",
|
||||
formal: "Rewrite the dictated text in polished, formal, professional language suitable for official correspondence.",
|
||||
raw: null,
|
||||
};
|
||||
|
||||
/* ---------- State ---------- */
|
||||
const DEFAULTS = {
|
||||
uiLang: (navigator.language || "ar").startsWith("ar") ? "ar" : "en",
|
||||
lang: "auto",
|
||||
mode: "message",
|
||||
engine: "auto",
|
||||
apiKey: "",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
model: "gpt-4o-mini",
|
||||
whisperModel: "whisper-1",
|
||||
dictionary: "",
|
||||
autoPolish: true,
|
||||
theme: "auto",
|
||||
};
|
||||
let S = loadSettings();
|
||||
let history = loadJSON("nutq.history", []);
|
||||
let recState = "idle"; // idle | listening | transcribing | polishing
|
||||
let recognition = null, mediaRecorder = null, mediaStream = null, chunks = [];
|
||||
let finalText = "", timerInt = null, timerStart = 0;
|
||||
|
||||
function loadSettings() {
|
||||
return Object.assign({}, DEFAULTS, loadJSON("nutq.settings", {}));
|
||||
}
|
||||
function loadJSON(k, fb) {
|
||||
try { return JSON.parse(localStorage.getItem(k)) ?? fb; } catch { return fb; }
|
||||
}
|
||||
function saveJSON(k, v) { localStorage.setItem(k, JSON.stringify(v)); }
|
||||
|
||||
/* ---------- DOM helpers ---------- */
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const t = (key) => (I18N[S.uiLang] || I18N.ar)[key] || key;
|
||||
|
||||
function applyI18n() {
|
||||
document.documentElement.lang = S.uiLang;
|
||||
document.documentElement.dir = S.uiLang === "ar" ? "rtl" : "ltr";
|
||||
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||
const key = el.dataset.i18n;
|
||||
if (key === "heroTitle") el.innerHTML = t(key);
|
||||
else el.textContent = t(key);
|
||||
});
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
||||
const v = t(el.dataset.i18nPlaceholder);
|
||||
if ("placeholder" in el) el.placeholder = v;
|
||||
else el.dataset.placeholder = v;
|
||||
});
|
||||
$("transcript").dataset.placeholder = t("transcriptPh");
|
||||
updateLangLabel();
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
const root = document.documentElement;
|
||||
if (S.theme === "auto") root.removeAttribute("data-theme");
|
||||
else root.setAttribute("data-theme", S.theme);
|
||||
}
|
||||
|
||||
function toast(msg, ms = 2200) {
|
||||
const el = $("toast");
|
||||
el.textContent = msg;
|
||||
el.hidden = false;
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(() => (el.hidden = true), ms);
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr = false) {
|
||||
const el = $("status");
|
||||
el.textContent = msg || "";
|
||||
el.classList.toggle("error", isErr);
|
||||
}
|
||||
|
||||
function updateLangLabel() {
|
||||
const item = LANGS.find(([code]) => code === S.lang);
|
||||
$("langLabel").textContent =
|
||||
!item || item[0] === "auto" ? t("autoDetect") : `${item[1]} ${item[2]}`;
|
||||
}
|
||||
|
||||
/* ---------- Recording UI ---------- */
|
||||
function setRecUI(on) {
|
||||
$("btnMic").classList.toggle("rec", on);
|
||||
$("btnMic").querySelector(".mic-ico").hidden = on;
|
||||
$("btnMic").querySelector(".stop-ico").hidden = !on;
|
||||
$("waveform").hidden = !on;
|
||||
$("timer").hidden = !on;
|
||||
$("liveDot").hidden = !on;
|
||||
$("micHint").textContent = on ? t("tapToStop") : t("tapToTalk");
|
||||
$("hero").classList.toggle("compact", on || !!finalText);
|
||||
$("features").hidden = on || !!finalText || !!$("result").textContent;
|
||||
if (on) {
|
||||
$("transcriptCard").hidden = false;
|
||||
timerStart = Date.now();
|
||||
timerInt = setInterval(() => {
|
||||
const s = Math.floor((Date.now() - timerStart) / 1000);
|
||||
$("timer").textContent =
|
||||
String(Math.floor(s / 60)).padStart(2, "0") + ":" + String(s % 60).padStart(2, "0");
|
||||
}, 500);
|
||||
} else {
|
||||
clearInterval(timerInt);
|
||||
$("timer").textContent = "00:00";
|
||||
}
|
||||
}
|
||||
|
||||
function renderTranscript(interim = "") {
|
||||
const el = $("transcript");
|
||||
el.innerHTML = "";
|
||||
el.appendChild(document.createTextNode(finalText));
|
||||
if (interim) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "interim";
|
||||
span.textContent = (finalText ? " " : "") + interim;
|
||||
el.appendChild(span);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Engine selection ---------- */
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
function pickEngine() {
|
||||
if (S.engine === "webspeech") return SR ? "webspeech" : null;
|
||||
if (S.engine === "whisper") return S.apiKey ? "whisper" : null;
|
||||
// auto: prefer live browser engine, fall back to whisper
|
||||
if (SR) return "webspeech";
|
||||
if (S.apiKey) return "whisper";
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- Web Speech engine ---------- */
|
||||
function startWebSpeech() {
|
||||
recognition = new SR();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
if (S.lang !== "auto") recognition.lang = S.lang;
|
||||
else if (S.uiLang === "ar") recognition.lang = "ar-SA";
|
||||
|
||||
recognition.onresult = (e) => {
|
||||
let interim = "";
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
const txt = e.results[i][0].transcript;
|
||||
if (e.results[i].isFinal) finalText = (finalText + " " + txt).trim();
|
||||
else interim += txt;
|
||||
}
|
||||
renderTranscript(interim);
|
||||
};
|
||||
recognition.onerror = (e) => {
|
||||
if (e.error === "not-allowed" || e.error === "service-not-allowed") {
|
||||
stopDictation(false);
|
||||
setStatus(t("errNoMic"), true);
|
||||
}
|
||||
// "no-speech" and "aborted" are benign; onend handles restart
|
||||
};
|
||||
recognition.onend = () => {
|
||||
// Browsers stop after silence — keep listening until user taps stop.
|
||||
if (recState === "listening") {
|
||||
try { recognition.start(); } catch { /* already started */ }
|
||||
}
|
||||
};
|
||||
recognition.start();
|
||||
}
|
||||
|
||||
/* ---------- Whisper engine ---------- */
|
||||
async function startWhisperRecording() {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
chunks = [];
|
||||
const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"]
|
||||
.find((m) => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || "";
|
||||
mediaRecorder = new MediaRecorder(mediaStream, mime ? { mimeType: mime } : undefined);
|
||||
mediaRecorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
|
||||
mediaRecorder.start(1000);
|
||||
}
|
||||
|
||||
async function transcribeWithWhisper() {
|
||||
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || "audio/webm" });
|
||||
const ext = (mediaRecorder.mimeType || "audio/webm").includes("mp4") ? "mp4"
|
||||
: (mediaRecorder.mimeType || "").includes("ogg") ? "ogg" : "webm";
|
||||
const form = new FormData();
|
||||
form.append("file", blob, "audio." + ext);
|
||||
form.append("model", S.whisperModel || "whisper-1");
|
||||
if (S.lang !== "auto") form.append("language", S.lang.split("-")[0]);
|
||||
if (S.dictionary.trim()) form.append("prompt", S.dictionary.trim());
|
||||
|
||||
const res = await fetch(S.baseUrl.replace(/\/$/, "") + "/audio/transcriptions", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer " + S.apiKey },
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(t("errApi") + res.status + " " + body.slice(0, 200));
|
||||
}
|
||||
const data = await res.json();
|
||||
return (data.text || "").trim();
|
||||
}
|
||||
|
||||
/* ---------- AI polish ---------- */
|
||||
async function polish(text) {
|
||||
const sysMode = MODE_PROMPTS[S.mode];
|
||||
if (!sysMode) return text; // raw mode
|
||||
if (!S.apiKey) {
|
||||
toast(t("noPolishKey"), 3200);
|
||||
return text;
|
||||
}
|
||||
const dict = S.dictionary.trim()
|
||||
? `\nPersonal dictionary (respect these exact spellings): ${S.dictionary.trim()}`
|
||||
: "";
|
||||
const sys =
|
||||
"You are Nutq, an AI dictation cleanup engine. The user dictated text by voice. " +
|
||||
"Fix transcription artifacts, remove filler words, add correct punctuation and formatting, " +
|
||||
"and keep the user's own voice and meaning. Always answer in the SAME language as the dictated text. " +
|
||||
"Output ONLY the rewritten text, no explanations." +
|
||||
"\nStyle: " + sysMode + dict;
|
||||
|
||||
const res = await fetch(S.baseUrl.replace(/\/$/, "") + "/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + S.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: S.model || "gpt-4o-mini",
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
{ role: "system", content: sys },
|
||||
{ role: "user", content: text },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(t("errApi") + res.status + " " + body.slice(0, 200));
|
||||
}
|
||||
const data = await res.json();
|
||||
return (data.choices?.[0]?.message?.content || "").trim() || text;
|
||||
}
|
||||
|
||||
/* ---------- Dictation flow ---------- */
|
||||
async function startDictation() {
|
||||
finalText = "";
|
||||
$("result").textContent = "";
|
||||
$("resultCard").hidden = true;
|
||||
renderTranscript();
|
||||
const engine = pickEngine();
|
||||
if (!engine) {
|
||||
setStatus(SR ? t("errNoKey") : t("errNoSpeech"), true);
|
||||
openSheet("settingsSheet");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (engine === "webspeech") startWebSpeech();
|
||||
else await startWhisperRecording();
|
||||
recState = "listening";
|
||||
setRecUI(true);
|
||||
setStatus(t("listening"));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStatus(t("errNoMic"), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDictation(process = true) {
|
||||
const wasEngine = recognition ? "webspeech" : "whisper";
|
||||
recState = "idle";
|
||||
if (recognition) { try { recognition.stop(); } catch {} recognition = null; }
|
||||
if (mediaRecorder && mediaRecorder.state !== "inactive") {
|
||||
await new Promise((r) => { mediaRecorder.onstop = r; mediaRecorder.stop(); });
|
||||
}
|
||||
if (mediaStream) { mediaStream.getTracks().forEach((tr) => tr.stop()); mediaStream = null; }
|
||||
setRecUI(false);
|
||||
if (!process) return;
|
||||
|
||||
try {
|
||||
if (wasEngine === "whisper" && chunks.length) {
|
||||
recState = "transcribing";
|
||||
setStatus(t("transcribing"));
|
||||
finalText = await transcribeWithWhisper();
|
||||
renderTranscript();
|
||||
}
|
||||
finalText = $("transcript").textContent.trim();
|
||||
if (!finalText) { setStatus(""); return; }
|
||||
if (S.autoPolish) await runPolish();
|
||||
else setStatus("");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStatus(err.message.includes("fetch") ? t("errNet") : err.message, true);
|
||||
} finally {
|
||||
recState = "idle";
|
||||
}
|
||||
}
|
||||
|
||||
async function runPolish() {
|
||||
const text = $("transcript").textContent.trim();
|
||||
if (!text) return;
|
||||
recState = "polishing";
|
||||
setStatus(t("polishing"));
|
||||
try {
|
||||
const out = await polish(text);
|
||||
$("result").textContent = out;
|
||||
$("resultCard").hidden = false;
|
||||
$("resultMode").textContent = t("mode" + S.mode[0].toUpperCase() + S.mode.slice(1));
|
||||
setStatus("");
|
||||
addHistory({ raw: text, polished: out, mode: S.mode, ts: Date.now() });
|
||||
$("result").scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStatus(err.message.includes("fetch") ? t("errNet") : err.message, true);
|
||||
} finally {
|
||||
recState = "idle";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- History ---------- */
|
||||
function addHistory(item) {
|
||||
history.unshift(item);
|
||||
history = history.slice(0, 200);
|
||||
saveJSON("nutq.history", history);
|
||||
}
|
||||
function renderHistory() {
|
||||
const list = $("historyList");
|
||||
list.innerHTML = "";
|
||||
if (!history.length) {
|
||||
list.innerHTML = `<div class="empty">${t("noHistory")}</div>`;
|
||||
return;
|
||||
}
|
||||
history.forEach((item, i) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "history-item";
|
||||
const date = new Date(item.ts).toLocaleString(S.uiLang === "ar" ? "ar" : "en", {
|
||||
dateStyle: "medium", timeStyle: "short",
|
||||
});
|
||||
div.innerHTML = `
|
||||
<div class="h-meta"><span>${date}</span><span class="badge">${t("mode" + item.mode[0].toUpperCase() + item.mode.slice(1))}</span></div>
|
||||
<div class="h-text"></div>
|
||||
<div class="h-actions">
|
||||
<button class="btn small primary h-copy">${t("copy")}</button>
|
||||
<button class="btn small danger h-del">✕</button>
|
||||
</div>`;
|
||||
div.querySelector(".h-text").textContent = item.polished || item.raw;
|
||||
div.querySelector(".h-copy").onclick = () => copyText(item.polished || item.raw);
|
||||
div.querySelector(".h-del").onclick = () => {
|
||||
history.splice(i, 1);
|
||||
saveJSON("nutq.history", history);
|
||||
renderHistory();
|
||||
toast(t("deleted"));
|
||||
};
|
||||
list.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Clipboard / share ---------- */
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
ta.remove();
|
||||
}
|
||||
toast(t("copied"));
|
||||
}
|
||||
|
||||
/* ---------- Sheets ---------- */
|
||||
function openSheet(id) { $(id).hidden = false; document.body.style.overflow = "hidden"; }
|
||||
function closeSheet(id) { $(id).hidden = true; document.body.style.overflow = ""; }
|
||||
["langSheet", "historySheet", "settingsSheet"].forEach((id) => {
|
||||
$(id).addEventListener("click", (e) => { if (e.target === $(id)) closeSheet(id); });
|
||||
});
|
||||
|
||||
/* ---------- Language list ---------- */
|
||||
function renderLangs(filter = "") {
|
||||
const list = $("langList");
|
||||
list.innerHTML = "";
|
||||
const q = filter.trim().toLowerCase();
|
||||
LANGS.filter(([code, , name]) =>
|
||||
!q || name.toLowerCase().includes(q) || code.toLowerCase().includes(q)
|
||||
).forEach(([code, flag, name]) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "lang-item" + (S.lang === code ? " selected" : "");
|
||||
btn.innerHTML = code === "auto"
|
||||
? `<span><span class="flag">🌐</span>${t("autoDetect")}</span>`
|
||||
: `<span><span class="flag">${flag}</span>${name}</span>`;
|
||||
btn.onclick = () => {
|
||||
S.lang = code;
|
||||
saveJSON("nutq.settings", S);
|
||||
updateLangLabel();
|
||||
closeSheet("langSheet");
|
||||
};
|
||||
list.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Settings ---------- */
|
||||
function fillSettingsForm() {
|
||||
$("setUiLang").value = S.uiLang;
|
||||
$("setEngine").value = S.engine;
|
||||
$("setApiKey").value = S.apiKey;
|
||||
$("setBaseUrl").value = S.baseUrl;
|
||||
$("setModel").value = S.model;
|
||||
$("setWhisperModel").value = S.whisperModel;
|
||||
$("setDictionary").value = S.dictionary;
|
||||
$("setAutoPolish").checked = S.autoPolish;
|
||||
$("setTheme").value = S.theme;
|
||||
}
|
||||
function saveSettingsForm() {
|
||||
S.uiLang = $("setUiLang").value;
|
||||
S.engine = $("setEngine").value;
|
||||
S.apiKey = $("setApiKey").value.trim();
|
||||
S.baseUrl = $("setBaseUrl").value.trim() || DEFAULTS.baseUrl;
|
||||
S.model = $("setModel").value.trim() || DEFAULTS.model;
|
||||
S.whisperModel = $("setWhisperModel").value.trim() || DEFAULTS.whisperModel;
|
||||
S.dictionary = $("setDictionary").value;
|
||||
S.autoPolish = $("setAutoPolish").checked;
|
||||
S.theme = $("setTheme").value;
|
||||
saveJSON("nutq.settings", S);
|
||||
applyI18n();
|
||||
applyTheme();
|
||||
closeSheet("settingsSheet");
|
||||
toast(t("saved"));
|
||||
}
|
||||
|
||||
/* ---------- Wire up ---------- */
|
||||
$("btnMic").onclick = () => {
|
||||
if (recState === "listening") stopDictation();
|
||||
else if (recState === "idle") startDictation();
|
||||
};
|
||||
$("btnCopy").onclick = () => copyText($("result").textContent);
|
||||
$("btnShare").onclick = async () => {
|
||||
const text = $("result").textContent;
|
||||
if (navigator.share) { try { await navigator.share({ text }); } catch {} }
|
||||
else copyText(text);
|
||||
};
|
||||
$("btnRepolish").onclick = () => { if (recState === "idle") runPolish(); };
|
||||
$("btnLang").onclick = () => { renderLangs(); openSheet("langSheet"); $("langSearch").value = ""; };
|
||||
$("langSearch").oninput = (e) => renderLangs(e.target.value);
|
||||
$("btnHistory").onclick = () => { renderHistory(); openSheet("historySheet"); };
|
||||
$("btnClearHistory").onclick = () => {
|
||||
if (confirm(t("confirmClear"))) {
|
||||
history = [];
|
||||
saveJSON("nutq.history", history);
|
||||
renderHistory();
|
||||
}
|
||||
};
|
||||
$("btnSettings").onclick = () => { fillSettingsForm(); openSheet("settingsSheet"); };
|
||||
$("btnSaveSettings").onclick = saveSettingsForm;
|
||||
|
||||
document.querySelectorAll(".chip").forEach((chip) => {
|
||||
chip.onclick = () => {
|
||||
document.querySelectorAll(".chip").forEach((c) => c.classList.remove("active"));
|
||||
chip.classList.add("active");
|
||||
S.mode = chip.dataset.mode;
|
||||
saveJSON("nutq.settings", S);
|
||||
};
|
||||
});
|
||||
// restore active mode chip
|
||||
document.querySelectorAll(".chip").forEach((c) =>
|
||||
c.classList.toggle("active", c.dataset.mode === S.mode));
|
||||
|
||||
/* ---------- Init ---------- */
|
||||
applyI18n();
|
||||
applyTheme();
|
||||
$("verInfo").textContent = "Nutq v1.0";
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("sw.js").catch(() => {});
|
||||
});
|
||||
}
|
||||
BIN
nutq/icons/apple-touch-icon.png
Normal file
BIN
nutq/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 872 B |
BIN
nutq/icons/icon-192.png
Normal file
BIN
nutq/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
nutq/icons/icon-512.png
Normal file
BIN
nutq/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
nutq/icons/icon-maskable-512.png
Normal file
BIN
nutq/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
216
nutq/index.html
Normal file
216
nutq/index.html
Normal file
@ -0,0 +1,216 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||
<title>نُطق — لوحة المفاتيح الصوتية بالذكاء الاصطناعي</title>
|
||||
<meta name="description" content="تكلّم بشكل طبيعي، ونُطق يكتب نصاً مصقولاً جاهزاً للإرسال. إملاء صوتي بالذكاء الاصطناعي يدعم أكثر من 100 لغة.">
|
||||
<meta name="theme-color" content="#FAF6E9" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#111014" media="(prefers-color-scheme: dark)">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="نُطق">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<svg class="brand-mark" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="9" width="2.6" height="6" rx="1.3"/>
|
||||
<rect x="8" y="5" width="2.6" height="14" rx="1.3"/>
|
||||
<rect x="13" y="8" width="2.6" height="8" rx="1.3"/>
|
||||
<rect x="18" y="10" width="2.6" height="4" rx="1.3"/>
|
||||
</svg>
|
||||
<span class="brand-name" data-i18n="appName">نُطق</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="icon-btn" id="btnHistory" aria-label="السجل" title="السجل">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 4a8 8 0 1 1-7.7 10.1.9.9 0 0 1 1.74-.46A6.2 6.2 0 1 0 5.8 12H8l-3.2 3.6L1.6 12h2.4A8 8 0 0 1 12 4Zm0 3.6c.5 0 .9.4.9.9v3.1l2.6 1.5a.9.9 0 0 1-.9 1.56l-3-1.74a.9.9 0 0 1-.5-.82V8.5c0-.5.4-.9.9-.9Z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="btnSettings" aria-label="الإعدادات" title="الإعدادات">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 8.2a3.8 3.8 0 1 1 0 7.6 3.8 3.8 0 0 1 0-7.6Zm0 1.8a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-1.1-7h2.2c.5 0 .9.35 1 .84l.32 1.6c.55.22 1.07.52 1.54.88l1.55-.53a1 1 0 0 1 1.2.45l1.1 1.9a1 1 0 0 1-.2 1.27l-1.23 1.07a6.9 6.9 0 0 1 0 1.78l1.23 1.07a1 1 0 0 1 .2 1.27l-1.1 1.9a1 1 0 0 1-1.2.45l-1.55-.53c-.47.36-.99.66-1.54.88l-.32 1.6a1 1 0 0 1-1 .84h-2.2a1 1 0 0 1-1-.84l-.32-1.6a6.9 6.9 0 0 1-1.54-.88l-1.55.53a1 1 0 0 1-1.2-.45l-1.1-1.9a1 1 0 0 1 .2-1.27l1.23-1.07a6.9 6.9 0 0 1 0-1.78L4.29 9.41a1 1 0 0 1-.2-1.27l1.1-1.9a1 1 0 0 1 1.2-.45l1.55.53c.47-.36.99-.66 1.54-.88l.32-1.6a1 1 0 0 1 1-.84Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app">
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero" id="hero">
|
||||
<h1 class="hero-title" data-i18n="heroTitle">إملاء صوتي<br>بلا أخطاء</h1>
|
||||
<p class="hero-sub" data-i18n="heroSub">تكلّم بشكل طبيعي، ونُطق يحوّل كلامك إلى نص مصقول جاهز للإرسال — في أي تطبيق وبأكثر من 100 لغة.</p>
|
||||
</section>
|
||||
|
||||
<!-- Mode chips -->
|
||||
<section class="modes" id="modes" role="tablist" aria-label="نمط الكتابة">
|
||||
<button class="chip active" data-mode="message" data-i18n="modeMessage">رسالة</button>
|
||||
<button class="chip" data-mode="email" data-i18n="modeEmail">بريد إلكتروني</button>
|
||||
<button class="chip" data-mode="notes" data-i18n="modeNotes">ملاحظات</button>
|
||||
<button class="chip" data-mode="formal" data-i18n="modeFormal">رسمي</button>
|
||||
<button class="chip" data-mode="raw" data-i18n="modeRaw">نص خام</button>
|
||||
</section>
|
||||
|
||||
<!-- Language pill -->
|
||||
<button class="lang-pill" id="btnLang">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm0 1.8c-.9 0-2 1.5-2.6 4.2h5.2C14 5.3 12.9 3.8 12 3.8Zm-4.4 4.2c.3-1.5.8-2.8 1.4-3.7A8.2 8.2 0 0 0 4.9 8h2.7Zm8.8 0h2.7a8.2 8.2 0 0 0-4.1-3.7c.6.9 1.1 2.2 1.4 3.7ZM4.1 13.2h3.1a22 22 0 0 1 0-3.4H4.1a8.3 8.3 0 0 0 0 3.4Zm4.9 0h6a20 20 0 0 0 0-3.4H9a20 20 0 0 0 0 3.4Zm7.8 0h3.1a8.3 8.3 0 0 0 0-3.4h-3.1a22 22 0 0 1 0 3.4ZM7.6 15h-2.7a8.2 8.2 0 0 0 4.1 3.7c-.6-.9-1.1-2.2-1.4-3.7Zm4.4 5.2c.9 0 2-1.5 2.6-4.2H9.4c.6 2.7 1.7 4.2 2.6 4.2Zm4.4-5.2c-.3 1.5-.8 2.8-1.4 3.7A8.2 8.2 0 0 0 19.1 15h-2.7Z"/></svg>
|
||||
<span id="langLabel">اكتشاف تلقائي</span>
|
||||
</button>
|
||||
|
||||
<!-- Transcript card -->
|
||||
<section class="card transcript-card" id="transcriptCard" hidden>
|
||||
<div class="card-head">
|
||||
<span class="card-title" data-i18n="rawTranscript">النص المسموع</span>
|
||||
<span class="live-dot" id="liveDot" hidden></span>
|
||||
</div>
|
||||
<div class="transcript" id="transcript" contenteditable="true" data-i18n-placeholder="transcriptPh"></div>
|
||||
</section>
|
||||
|
||||
<!-- Polished result card -->
|
||||
<section class="card result-card" id="resultCard" hidden>
|
||||
<div class="card-head">
|
||||
<span class="card-title" data-i18n="polished">✨ النص المصقول</span>
|
||||
<span class="badge" id="resultMode"></span>
|
||||
</div>
|
||||
<div class="result" id="result" contenteditable="true"></div>
|
||||
<div class="card-actions">
|
||||
<button class="btn primary" id="btnCopy">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 7V4.8C8 3.8 8.8 3 9.8 3h9.4c1 0 1.8.8 1.8 1.8v9.4c0 1-.8 1.8-1.8 1.8H17v2.2c0 1-.8 1.8-1.8 1.8H5.8c-1 0-1.8-.8-1.8-1.8V8.8C4 7.8 4.8 7 5.8 7H8Zm1.8 0h5.4c1 0 1.8.8 1.8 1.8v5.4h2.2V4.8H9.8V7ZM5.8 8.8v9.4h9.4V8.8H5.8Z"/></svg>
|
||||
<span data-i18n="copy">نسخ</span>
|
||||
</button>
|
||||
<button class="btn" id="btnShare">
|
||||
<svg viewBox="0 0 24 24"><path d="M17 8.6a3.3 3.3 0 1 0-3.1-4.4L8.3 7a3.3 3.3 0 1 0 0 5l5.6 2.8a3.3 3.3 0 1 0 .8-1.6l-5.6-2.8a3.3 3.3 0 0 0 0-1.8l5.6-2.8c.6.7 1.5 1 2.3 1Z"/></svg>
|
||||
<span data-i18n="share">مشاركة</span>
|
||||
</button>
|
||||
<button class="btn" id="btnRepolish">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 5V2L7.5 6 12 10V7a5 5 0 1 1-4.9 6.1H5.2A6.8 6.8 0 1 0 12 5Z"/></svg>
|
||||
<span data-i18n="repolish">إعادة الصقل</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<!-- Features (shown when idle & empty) -->
|
||||
<section class="features" id="features">
|
||||
<div class="feature"><span class="f-ico">⌨️</span><div><b data-i18n="f1t">يعمل مع كل التطبيقات</b><p data-i18n="f1d">أملِ النص ثم انسخه إلى واتساب، البريد، أو أي تطبيق آخر.</p></div></div>
|
||||
<div class="feature"><span class="f-ico">✨</span><div><b data-i18n="f2t">صقل بالذكاء الاصطناعي</b><p data-i18n="f2d">بلا كلمات حشو ولا أخطاء — نص بأسلوبك، جاهز للإرسال.</p></div></div>
|
||||
<div class="feature"><span class="f-ico">🌍</span><div><b data-i18n="f3t">أكثر من 100 لغة</b><p data-i18n="f3d">العربية، الإنجليزية، الفرنسية وغيرها — مع اكتشاف تلقائي.</p></div></div>
|
||||
<div class="feature"><span class="f-ico">📖</span><div><b data-i18n="f4t">قاموسك الشخصي</b><p data-i18n="f4d">أضف أسماءً ومصطلحات خاصة ليتعلم نُطق أسلوبك.</p></div></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Mic dock -->
|
||||
<div class="mic-dock">
|
||||
<div class="waveform" id="waveform" hidden>
|
||||
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="timer" id="timer" hidden>00:00</div>
|
||||
<button class="mic-btn" id="btnMic" aria-label="ابدأ الإملاء">
|
||||
<span class="mic-ring r1"></span><span class="mic-ring r2"></span>
|
||||
<svg viewBox="0 0 24 24" class="mic-ico">
|
||||
<path d="M12 2a3.5 3.5 0 0 1 3.5 3.5v6a3.5 3.5 0 1 1-7 0v-6A3.5 3.5 0 0 1 12 2Z"/>
|
||||
<path d="M5.5 11.5a.9.9 0 0 1 1.8 0 4.7 4.7 0 1 0 9.4 0 .9.9 0 0 1 1.8 0 6.5 6.5 0 0 1-5.6 6.44V20h2.2a.9.9 0 1 1 0 1.8H8.9a.9.9 0 1 1 0-1.8h2.2v-2.06a6.5 6.5 0 0 1-5.6-6.44Z"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" class="stop-ico" hidden><rect x="6" y="6" width="12" height="12" rx="3"/></svg>
|
||||
</button>
|
||||
<div class="mic-hint" id="micHint" data-i18n="tapToTalk">اضغط وتكلّم</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Language sheet ===== -->
|
||||
<div class="sheet-backdrop" id="langSheet" hidden>
|
||||
<div class="sheet" role="dialog" aria-label="اختيار اللغة">
|
||||
<div class="sheet-grab"></div>
|
||||
<input class="search" id="langSearch" type="search" data-i18n-placeholder="searchLang">
|
||||
<div class="lang-list" id="langList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== History sheet ===== -->
|
||||
<div class="sheet-backdrop" id="historySheet" hidden>
|
||||
<div class="sheet" role="dialog" aria-label="السجل">
|
||||
<div class="sheet-grab"></div>
|
||||
<div class="sheet-title-row">
|
||||
<h2 data-i18n="history">السجل</h2>
|
||||
<button class="btn danger small" id="btnClearHistory" data-i18n="clearAll">مسح الكل</button>
|
||||
</div>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Settings sheet ===== -->
|
||||
<div class="sheet-backdrop" id="settingsSheet" hidden>
|
||||
<div class="sheet" role="dialog" aria-label="الإعدادات">
|
||||
<div class="sheet-grab"></div>
|
||||
<h2 data-i18n="settings">الإعدادات</h2>
|
||||
|
||||
<label class="field">
|
||||
<span data-i18n="uiLang">لغة الواجهة</span>
|
||||
<select id="setUiLang">
|
||||
<option value="ar">العربية</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span data-i18n="engine">محرك التعرّف على الصوت</span>
|
||||
<select id="setEngine">
|
||||
<option value="auto" data-i18n="engineAuto">تلقائي (الأفضل المتاح)</option>
|
||||
<option value="webspeech" data-i18n="engineWeb">المتصفح (مجاني، فوري)</option>
|
||||
<option value="whisper" data-i18n="engineWhisper">Whisper عبر API (أدق)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="field-group">
|
||||
<div class="fg-title" data-i18n="aiSection">إعدادات الذكاء الاصطناعي (للصقل و Whisper)</div>
|
||||
<label class="field">
|
||||
<span data-i18n="apiKey">مفتاح API</span>
|
||||
<input id="setApiKey" type="password" placeholder="sk-..." autocomplete="off">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span data-i18n="baseUrl">عنوان الخدمة (Base URL)</span>
|
||||
<input id="setBaseUrl" type="url" placeholder="https://api.openai.com/v1">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span data-i18n="model">نموذج الصقل</span>
|
||||
<input id="setModel" type="text" placeholder="gpt-4o-mini">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span data-i18n="whisperModel">نموذج التفريغ الصوتي</span>
|
||||
<input id="setWhisperModel" type="text" placeholder="whisper-1">
|
||||
</label>
|
||||
<p class="hint" data-i18n="apiHint">يدعم أي خدمة متوافقة مع OpenAI: OpenAI، Groq (مجاني)، OpenRouter وغيرها. المفتاح يُحفظ على جهازك فقط.</p>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span data-i18n="dictionary">القاموس الشخصي (أسماء ومصطلحات، افصل بينها بفواصل)</span>
|
||||
<textarea id="setDictionary" rows="3" data-i18n-placeholder="dictPh"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="field row">
|
||||
<span data-i18n="autoPolish">صقل تلقائي بعد التوقف</span>
|
||||
<input id="setAutoPolish" type="checkbox" checked>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span data-i18n="theme">المظهر</span>
|
||||
<select id="setTheme">
|
||||
<option value="auto" data-i18n="themeAuto">تلقائي</option>
|
||||
<option value="light" data-i18n="themeLight">فاتح</option>
|
||||
<option value="dark" data-i18n="themeDark">داكن</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="btn primary wide" id="btnSaveSettings" data-i18n="save">حفظ</button>
|
||||
<p class="hint center" id="verInfo"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
nutq/manifest.webmanifest
Normal file
18
nutq/manifest.webmanifest
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "نُطق — لوحة المفاتيح الصوتية بالذكاء الاصطناعي",
|
||||
"short_name": "نُطق",
|
||||
"description": "تكلّم بشكل طبيعي، ونُطق يكتب نصاً مصقولاً جاهزاً للإرسال. يدعم أكثر من 100 لغة.",
|
||||
"lang": "ar",
|
||||
"dir": "rtl",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#FAF6E9",
|
||||
"theme_color": "#FAF6E9",
|
||||
"icons": [
|
||||
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
43
nutq/sw.js
Normal file
43
nutq/sw.js
Normal file
@ -0,0 +1,43 @@
|
||||
/* Nutq service worker — offline-first app shell */
|
||||
const CACHE = "nutq-v1";
|
||||
const SHELL = [
|
||||
"./",
|
||||
"./index.html",
|
||||
"./app.css",
|
||||
"./app.js",
|
||||
"./manifest.webmanifest",
|
||||
"./icons/icon-192.png",
|
||||
"./icons/icon-512.png",
|
||||
"./icons/apple-touch-icon.png",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (e) => {
|
||||
const url = new URL(e.request.url);
|
||||
// Never intercept API calls — only cache same-origin static assets.
|
||||
if (url.origin !== location.origin || e.request.method !== "GET") return;
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(
|
||||
(hit) =>
|
||||
hit ||
|
||||
fetch(e.request).then((res) => {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(e.request, copy));
|
||||
return res;
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user