Initial commit: VoIdeaAI - voice-first AI idea assistant

This commit is contained in:
2026-05-13 12:51:42 +03:00
commit 688d043dad
421 changed files with 47915 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
# VoIdea WebUI Style Guide
## Tech Stack
| Category | Choice | Version |
|----------|--------|---------|
| Framework | React | 18.3.x (LTS) |
| Language | TypeScript | 5.5+ (strict mode) |
| Routing | react-router-dom | 6.26.x |
| Styling | Tailwind CSS | 3.4.x |
| State | Zustand (new stores) + Context (existing) | 5.x |
| Forms | react-hook-form + zod | latest |
| i18n | react-i18next (future) / constants/strings.ts (now) | — |
| Testing | Vitest + @testing-library/react | latest |
| PWA | vite-plugin-pwa | 0.20.x |
| Build | Vite | 5.4.x |
## Project Structure
```
webui/src/
├── api/ # HTTP client + endpoint modules
├── auth/ # Zustand store (future), Context (migration target)
├── components/ # Shared UI (ErrorBoundary, Layout, VoiceChat, etc.)
├── constants/ # strings.ts (i18n-ready), enums
├── hooks/ # Custom hooks
├── pages/ # Route pages (one file per route)
├── stores/ # Zustand stores (auth, ideas, settings...)
├── types/ # Shared TypeScript types
├── utils/ # Pure utility functions
├── App.tsx # Router setup
└── main.tsx # Entry point
```
## Coding Rules
### General
- `any` запрещён. Всегда явный тип.
- Импорты: абсолютные через `@/` alias. Относительные только для соседних файлов.
- Файл — не более 300 строк. Сервисы/компоненты больше — разбить.
- Prettier: 100 символов, двойные кавычки, точка с запятой.
### State Management (Zustand)
```ts
// stores/auth.ts
import { create } from "zustand";
interface AuthState {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: null,
login: async (email, password) => { /* ... */ },
logout: () => { clearTokens(); set({ user: null, token: null }); },
}));
```
- Один store — одна доменная область (auth, ideas, settings, voice).
- Store НЕ содержит UI-логику (только состояние + действия).
- Использовать `useAuthStore()` в компонентах напрямую (без Provider).
- Context API — только для редких случаев (< 3 подписчиков, Legacy).
### Forms (react-hook-form + zod)
```ts
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type FormData = z.infer<typeof schema>;
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
```
- Каждая сложная форма (3+ поля) — свой schema-файл в `pages/<name>.schema.ts`.
- Простые формы (1–2 поля, логин) — schema внутри компонента.
- Ошибки выводить `<span className="text-red-500 text-sm">`.
- Кнопку сабмита дизейблить пока `isSubmitting`.
### Accessibility (WCAG AA)
**Обязательно:**
- `aria-label` на всех кнопках без текста (иконки: Menu, ThemeToggle, Logout, Close, VoiceChat mic)
- `htmlFor` + `id` на всех `<label>`/`<input>` парах
- `role="log"` + `aria-live="polite"` на VoiceChat
- `<SkipToContent />` — первая ссылка в `<body>`
- Focus trap в модалках (первый элемент при открытии, возврат при закрытии)
- Цветовой контраст ≥ 4.5:1 (проверять `text-gray-500` на `bg-gray-800`)
**Проверка:** eslint-plugin-jsx-a11y (strict ruleset, errors = fail CI).
### Error Boundaries
- Глобальный `<ErrorBoundary>` вокруг `<Layout>` в App.tsx.
- Опционально: per-page ErrorBoundary для VoiceChat, AdminPage (высокая сложность).
- Fallback: `<ServerErrorPage />` с кнопкой «На главную».
```tsx
<ErrorBoundary fallback={<ServerErrorPage />}>
<Layout><Outlet /></Layout>
</ErrorBoundary>
```
### i18n-ready
Сейчас — только русский. Но строки НЕ пишутся хардкодом в JSX.
```ts
// constants/strings.ts
export const strings = {
nav: { ideas: "Идеи", new: "Новая", voice: "Голос" },
auth: { login: "Войти", register: "Регистрация" },
} as const;
```
```tsx
// Использование
<T path="auth.login" />
<T path="nav.ideas" />
```
При переходе на react-i18next — только замена реализации `<T>`, строки не трогать.
### ESLint Ruleset (для CI и будущих проектов)
**Плагины:**
- `@eslint/js` — базовые JS правила
- `typescript-eslint` — TS strict ruleset
- `eslint-plugin-jsx-a11y` — WCAG AA enforcement
**Ключевые правила:**
```js
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/strict-boolean-expressions": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"jsx-a11y/alt-text": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-role": "error",
"jsx-a11y/label-has-associated-control": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/no-static-element-interactions": "warn",
"no-console": ["error", { allow: ["warn", "error"] }],
"prefer-const": "error",
"no-var": "error",
```
**Установка в новом проекте:**
```bash
npm install -D eslint @eslint/js typescript-eslint eslint-plugin-jsx-a11y
```
### Testing (Vitest)
- Имена файлов: `*.test.ts` / `*.test.tsx` рядом с тестируемым модулем.
- Smoke: каждая страница рендерится без падения.
- Store: каждый action тестируется (login, logout, create, delete).
- Формы: заполнить → submit → проверить вызов API.
- JSON-репортёр для QATesterAgent: `vitest run --reporter=json`.
- Минимальный coverage: 30% (только критические пути: auth, forms, ErrorBoundary).
## React Version Policy
- Фиксирована: React 18.3.x LTS
- Переход на React 19 — только когда:
1. `vite-plugin-pwa` обновит поддержку
2. Все зависимости совместимы
3. Отдельный этап миграции
## VPS Compatibility
Весь инструментарий (Zustand, react-hook-form, zod, Vitest, ESLint) — npm-пакеты, работают на любой OS. Сборка под VPS:
```bash
npm ci # чистая установка под Ubuntu
npm run build # Vite -> dist/
```
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/png" href="/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-16x16.png" sizes="16x16" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="VoIdeaAI — идеи рождаются вслух, решения приходят мгновенно! Голосовой AI-ассистент с 26 агентами для генерации и анализа идей." />
<meta name="keywords" content="AI, генерация идей, голосовой ассистент, анализ идей, искусственный интеллект" />
<meta name="author" content="VoIdeaAI" />
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#1e1b4b" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="VoIdeaAI — Идеи рождаются вслух" />
<meta property="og:description" content="Голосовой AI-ассистент с 26 агентами для генерации и анализа идей. Записывайте мысли голосом, получайте анализ от экспертных агентов." />
<meta property="og:image" content="/logo.jpeg" />
<meta property="og:url" content="https://voidea.ai" />
<meta property="og:locale" content="ru_RU" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="VoIdeaAI — Идеи рождаются вслух" />
<meta name="twitter:description" content="Голосовой AI-ассистент с 26 агентами для генерации и анализа идей." />
<meta name="twitter:image" content="/logo.jpeg" />
<!-- JSON-LD -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "VoIdeaAI",
"applicationCategory": "Productivity",
"operatingSystem": "Web",
"description": "Голосовой AI-ассистент с 26 агентами для генерации и анализа идей.",
"author": { "@type": "Organization", "name": "VoIdeaAI" },
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "RUB" }
}
</script>
<title>VoIdeaAI — Идеи рождаются вслух</title>
</head>
<body class="bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9030
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "voidea-webui",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"genversion": "python3 ../tools/generate_version.py > public/version.json",
"prebuild": "npm run genversion",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.75.0",
"react-router-dom": "^6.26.0",
"zod": "^4.4.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"jsdom": "^29.1.1",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.4.0",
"vite-plugin-pwa": "^0.20.1",
"vitest": "^4.1.6"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#4f46e5"/>
<text x="50" y="68" font-size="55" font-family="Arial" font-weight="bold" fill="white" text-anchor="middle">V</text>
</svg>

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://voidea.ai/sitemap.xml
+1
View File
@@ -0,0 +1 @@
{"name":"VoIdeaAI","short_name":"VoIdeaAI","start_url":"/","display":"standalone","background_color":"#f9fafb","theme_color":"#6366f1","icons":[{"src":"\/icons\/android-chrome-192x192.png","sizes":"192x192","type":"image\/png"},{"src":"\/icons\/android-chrome-512x512.png","sizes":"512x512","type":"image\/png"},{"src":"\/apple-touch-icon.png","sizes":"180x180","type":"image\/png"},{"src":"\/favicon-16x16.png","sizes":"16x16","type":"image\/png"},{"src":"\/favicon-32x32.png","sizes":"32x32","type":"image\/png"},{"src":"\/favicon.ico","sizes":"48x48","type":"image\/x-icon"}]}
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://voidea.ai/</loc>
<priority>1.0</priority>
</url>
<url>
<loc>https://voidea.ai/privacy</loc>
<priority>0.5</priority>
</url>
<url>
<loc>https://voidea.ai/terms</loc>
<priority>0.5</priority>
</url>
<url>
<loc>https://voidea.ai/data-processing</loc>
<priority>0.5</priority>
</url>
<url>
<loc>https://voidea.ai/user-guide</loc>
<priority>0.6</priority>
</url>
</urlset>
Binary file not shown.
+145
View File
@@ -0,0 +1,145 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import { AdminRoute, ProtectedRoute } from "./components/ProtectedRoute";
import ErrorBoundary from "./components/ErrorBoundary";
import SkipToContent from "./components/SkipToContent";
import Layout from "./components/Layout";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
import OAuthCallback from "./pages/OAuthCallback";
import ForgotPasswordPage from "./pages/ForgotPasswordPage";
import ResetPasswordPage from "./pages/ResetPasswordPage";
import Dashboard from "./pages/Dashboard";
import IdeaView from "./pages/IdeaView";
import IdeaCreate from "./pages/IdeaCreate";
import IdeaEdit from "./pages/IdeaEdit";
import AdminPage from "./pages/AdminPage";
import SettingsPage from "./pages/SettingsPage";
import VoiceChat from "./components/VoiceChat";
import VoiceHelpPage from "./pages/VoiceHelpPage";
import PrivacyPage from "./pages/PrivacyPage";
import TermsPage from "./pages/TermsPage";
import DataProcessingPage from "./pages/DataProcessingPage";
import UserGuidePage from "./pages/UserGuidePage";
import FeedbackPage from "./pages/FeedbackPage";
export default function App() {
return (
<BrowserRouter>
<SkipToContent />
<AuthProvider>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms" element={<TermsPage />} />
<Route path="/data-processing" element={<DataProcessingPage />} />
<Route path="/user-guide" element={<UserGuidePage />} />
<Route path="/feedback" element={<FeedbackPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<ErrorBoundary>
<Layout>
<Dashboard />
</Layout>
</ErrorBoundary>
</ProtectedRoute>
}
/>
<Route
path="/voice"
element={
<ProtectedRoute>
<ErrorBoundary>
<Layout>
<VoiceChat />
</Layout>
</ErrorBoundary>
</ProtectedRoute>
}
/>
<Route
path="/voice/help"
element={
<ProtectedRoute>
<ErrorBoundary>
<Layout>
<VoiceHelpPage />
</Layout>
</ErrorBoundary>
</ProtectedRoute>
}
/>
<Route
path="/create"
element={
<ProtectedRoute>
<ErrorBoundary>
<Layout>
<IdeaCreate />
</Layout>
</ErrorBoundary>
</ProtectedRoute>
}
/>
<Route
path="/ideas/:id"
element={
<ProtectedRoute>
<ErrorBoundary>
<Layout>
<IdeaView />
</Layout>
</ErrorBoundary>
</ProtectedRoute>
}
/>
<Route
path="/ideas/:id/edit"
element={
<ProtectedRoute>
<ErrorBoundary>
<Layout>
<IdeaEdit />
</Layout>
</ErrorBoundary>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<ErrorBoundary>
<Layout>
<SettingsPage />
</Layout>
</ErrorBoundary>
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<AdminRoute>
<ErrorBoundary>
<Layout>
<AdminPage />
</Layout>
</ErrorBoundary>
</AdminRoute>
}
/>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}
+382
View File
@@ -0,0 +1,382 @@
import { apiFetch } from "./client";
export interface AdminUser {
id: string;
email: string;
display_name: string;
avatar_url: string | null;
is_active: boolean;
is_superuser: boolean;
role: string;
is_owner: boolean;
permissions: Record<string, boolean> | null;
accepted_terms_at: string | null;
oauth_provider: string | null;
created_at: string;
updated_at: string;
}
export interface UserAdminUpdate {
display_name?: string;
email?: string;
role?: string;
is_active?: boolean;
permissions?: Record<string, boolean>;
}
export interface AgentInfo {
agent_name: string;
description: string;
is_enabled: boolean;
version: string;
last_run_at: string | null;
}
export interface AgentUpdate {
description?: string;
is_enabled?: boolean;
}
export interface LogEntry {
id: string;
level: string;
source: string;
message: string;
details: Record<string, unknown> | null;
user_id: string | null;
created_at: string;
}
export interface FeedbackItem {
id: string;
user_id: string | null;
text: string;
page_url: string | null;
status: string;
created_at: string;
updated_at: string;
}
export interface TariffPlan {
id: string;
name: string;
code: string;
description: string | null;
price_monthly: number;
price_yearly: number | null;
features: Record<string, unknown> | null;
is_active: boolean;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface FeatureItem {
id: string;
title: string;
description: string | null;
priority: string;
status: string;
category: string;
source_agent: string | null;
created_at: string;
updated_at: string;
}
export interface ServiceStatus {
service: string;
description: string;
status: string;
is_running: boolean;
}
export interface ServiceActionResult {
service: string;
action: string;
success: boolean;
message: string;
}
export interface SystemInfo {
version: string;
environment: string;
database_status: string;
redis_status: string;
python_version: string;
uptime_seconds: number | null;
}
export function adminListUsers(search?: string): Promise<AdminUser[]> {
const params = search ? `?search=${encodeURIComponent(search)}` : "";
return apiFetch(`/admin/users${params}`);
}
export function adminUpdateUser(id: string, data: UserAdminUpdate): Promise<AdminUser> {
return apiFetch(`/admin/users/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
export function adminDeleteUser(id: string): Promise<void> {
return apiFetch(`/admin/users/${id}`, { method: "DELETE" });
}
export function adminListAgents(): Promise<AgentInfo[]> {
return apiFetch("/admin/agents");
}
export function adminUpdateAgent(name: string, data: AgentUpdate): Promise<AgentInfo> {
return apiFetch(`/admin/agents/${encodeURIComponent(name)}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
export function adminListLogs(level?: string, source?: string): Promise<LogEntry[]> {
const params = new URLSearchParams();
if (level) params.set("level", level);
if (source) params.set("source", source);
return apiFetch(`/admin/logs?${params}`);
}
export function adminListFeedback(status?: string): Promise<FeedbackItem[]> {
const params = status ? `?status=${status}` : "";
return apiFetch(`/admin/feedback${params}`);
}
export function adminUpdateFeedback(id: string, status: string): Promise<FeedbackItem> {
return apiFetch(`/admin/feedback/${id}`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
}
export function adminDeleteFeedback(id: string): Promise<void> {
return apiFetch(`/admin/feedback/${id}`, { method: "DELETE" });
}
export function adminListTariffs(): Promise<TariffPlan[]> {
return apiFetch("/admin/tariffs");
}
export function adminCreateTariff(data: Partial<TariffPlan>): Promise<TariffPlan> {
return apiFetch("/admin/tariffs", {
method: "POST",
body: JSON.stringify(data),
});
}
export function adminUpdateTariff(id: string, data: Partial<TariffPlan>): Promise<TariffPlan> {
return apiFetch(`/admin/tariffs/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
export function adminDeleteTariff(id: string): Promise<void> {
return apiFetch(`/admin/tariffs/${id}`, { method: "DELETE" });
}
export function adminListFeatures(): Promise<FeatureItem[]> {
return apiFetch("/admin/features");
}
export function adminCreateFeature(data: Partial<FeatureItem>): Promise<FeatureItem> {
return apiFetch("/admin/features", {
method: "POST",
body: JSON.stringify(data),
});
}
export function adminUpdateFeature(id: string, data: Partial<FeatureItem>): Promise<FeatureItem> {
return apiFetch(`/admin/features/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
export function adminListServices(): Promise<ServiceStatus[]> {
return apiFetch("/admin/services");
}
export function adminRestartService(service: string): Promise<ServiceActionResult[]> {
return apiFetch("/admin/services/restart", {
method: "POST",
body: JSON.stringify({ service, action: "restart" }),
});
}
export function adminSystemInfo(): Promise<SystemInfo> {
return apiFetch("/admin/system");
}
export function adminHealth(): Promise<{ status: string; database: string; redis: string; version: string }> {
return apiFetch("/admin/health");
}
export function admin2faStatus(): Promise<{ globally_enabled: boolean }> {
return apiFetch("/admin/2fa/status");
}
export function admin2faToggle(): Promise<{ globally_enabled: boolean }> {
return apiFetch("/admin/2fa/toggle", { method: "POST" });
}
// ── Debug Mode ──
export interface DebugConfig {
debug_mode: boolean;
debug_mode_expires_at: string | null;
cleanup_interval_hours: number;
log_retention_days: number;
log_max_bytes: number;
notify_at_percent: number;
}
export interface DebugStatus {
debug_mode: boolean;
debug_config: DebugConfig;
logs_size_bytes: number;
logs_count: number;
logs_max_bytes: number;
usage_percent: number;
needs_cleanup: boolean;
}
export interface CleanupResult {
deleted: number;
retention_days: number;
cutoff: string;
}
export function adminGetDebugConfig(): Promise<DebugConfig> {
return apiFetch("/admin/debug/config");
}
export function adminUpdateDebugConfig(data: Partial<DebugConfig>): Promise<DebugConfig> {
return apiFetch("/admin/debug/config", {
method: "PATCH",
body: JSON.stringify(data),
});
}
export function adminEnableDebugMode(): Promise<DebugConfig> {
return apiFetch("/admin/debug/enable", { method: "POST" });
}
export function adminDisableDebugMode(): Promise<DebugConfig> {
return apiFetch("/admin/debug/disable", { method: "POST" });
}
export function adminDebugStatus(): Promise<DebugStatus> {
return apiFetch("/admin/debug/status");
}
export function adminCleanupLogs(): Promise<CleanupResult> {
return apiFetch("/admin/debug/cleanup", { method: "POST" });
}
// ── Log Export ──
export function adminExportLogs(level?: string, source?: string): string {
const params = new URLSearchParams();
if (level) params.set("level", level);
if (source) params.set("source", source);
const qs = params.toString();
return `/admin/logs/export${qs ? "?" + qs : ""}`;
}
// ── Pipeline ──
export interface PipelineStageConfig {
enabled: boolean;
noise_threshold?: number;
silence_timeout_ms?: number;
min_audio_duration_ms?: number;
word?: string;
timeout_minutes?: number;
sensitivity?: number;
mode?: string;
timeout_ms?: number;
verified_threshold?: number;
warning_threshold?: number;
max_agents_per_dialog?: number;
min_samples?: number;
learning_rate?: number;
}
export interface PipelineConfig {
vad: PipelineStageConfig;
wake_word: PipelineStageConfig;
semantic_validation: PipelineStageConfig;
confidence: PipelineStageConfig;
agent_chaining: PipelineStageConfig;
auto_tuning: PipelineStageConfig;
stages_order: string[];
}
export interface PipelineStatsEntry {
id: string;
user_id: string | null;
stage: string;
passed: boolean;
reason: string | null;
duration_ms: number | null;
created_at: string;
}
export interface PipelineStatsSummary {
total_entries: number;
stages: Record<string, number>;
passed_ratio: number;
avg_duration_ms: number | null;
fail_reasons: [string, number][];
}
export function adminGetPipelineConfig(): Promise<PipelineConfig> {
return apiFetch("/admin/pipeline");
}
export function adminUpdatePipelineConfig(data: Partial<PipelineConfig>): Promise<PipelineConfig> {
return apiFetch("/admin/pipeline", {
method: "PATCH",
body: JSON.stringify(data),
});
}
export function adminListPipelineStats(stage?: string): Promise<PipelineStatsEntry[]> {
const params = stage ? `?stage=${encodeURIComponent(stage)}` : "";
return apiFetch(`/admin/pipeline/stats${params}`);
}
export function adminPipelineStatsSummary(): Promise<PipelineStatsSummary> {
return apiFetch("/admin/pipeline/stats/summary");
}
// ── Bot Commands ──
export interface BotCommand {
id: string;
name: string;
description: string;
enabled: boolean;
requires_auth: boolean;
created_at: string;
updated_at: string;
}
export function adminListBotCommands(): Promise<BotCommand[]> {
return apiFetch("/admin/bot");
}
export function adminToggleBotCommand(id: string, enabled: boolean): Promise<BotCommand> {
return apiFetch(`/admin/bot/${id}`, {
method: "PATCH",
body: JSON.stringify({ name: "", enabled }),
});
}
export function adminSyncBotCommands(): Promise<{ synced_count: number; telegram_sync: boolean }> {
return apiFetch("/admin/bot/sync", { method: "POST" });
}
+83
View File
@@ -0,0 +1,83 @@
const API_BASE = "/api/v1";
function getTokens() {
const access = localStorage.getItem("access_token");
const refresh = localStorage.getItem("refresh_token");
return { access, refresh };
}
export function setTokens(access: string, refresh: string) {
localStorage.setItem("access_token", access);
localStorage.setItem("refresh_token", refresh);
}
export function clearTokens() {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
}
export function isAuthenticated(): boolean {
return !!localStorage.getItem("access_token");
}
async function refreshAccessToken(): Promise<string | null> {
const refresh = localStorage.getItem("refresh_token");
if (!refresh) return null;
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
});
if (!res.ok) {
clearTokens();
return null;
}
const data = await res.json();
setTokens(data.access_token, data.refresh_token);
return data.access_token;
} catch {
clearTokens();
return null;
}
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const { access } = getTokens();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (access) {
headers["Authorization"] = `Bearer ${access}`;
}
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401 && access) {
const newToken = await refreshAccessToken();
if (newToken) {
headers["Authorization"] = `Bearer ${newToken}`;
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
} else {
clearTokens();
window.location.href = "/login";
throw new Error("Session expired");
}
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
+18
View File
@@ -0,0 +1,18 @@
import { apiFetch } from "./client";
export interface PublicConfig {
app_name: string;
app_version: string;
environment: string;
tariffs_enabled: boolean;
oauth_yandex_enabled: boolean;
oauth_google_enabled: boolean;
oauth_apple_enabled: boolean;
max_file_size_mb: number;
max_voice_duration_seconds: number;
max_ideas_per_user: number | null;
}
export function fetchPublicConfig(): Promise<PublicConfig> {
return apiFetch("/config/public");
}
+13
View File
@@ -0,0 +1,13 @@
import { apiFetch } from "./client";
export interface FeedbackCreate {
text: string;
page_url?: string;
}
export function submitFeedback(data: FeedbackCreate): Promise<{ id: string; status: string }> {
return apiFetch("/feedback", {
method: "POST",
body: JSON.stringify(data),
});
}
+86
View File
@@ -0,0 +1,86 @@
import { apiFetch } from "./client";
export interface Idea {
id: string;
user_id: string;
title: string;
content: string;
status: string;
tags: string[] | null;
is_public: boolean;
created_at: string;
updated_at: string;
}
export interface IdeaCreatePayload {
title: string;
content: string;
tags?: string[];
is_public?: boolean;
}
export interface IdeaUpdatePayload {
title?: string;
content?: string;
status?: string;
tags?: string[];
is_public?: boolean;
}
export interface AnalyzeResponse {
status: string;
idea_id: string;
task_count: number;
tasks: { role: string; task_id: string }[];
}
export interface AnalysisResult {
id: string;
role: string;
status: string;
message: string | null;
success: boolean;
duration_ms: number;
created_at: string | null;
}
export function listIdeas(skip = 0, limit = 50): Promise<Idea[]> {
return apiFetch(`/ideas?skip=${skip}&limit=${limit}`);
}
export function getIdea(id: string): Promise<Idea> {
return apiFetch(`/ideas/${id}`);
}
export function createIdea(data: IdeaCreatePayload): Promise<Idea> {
return apiFetch("/ideas", {
method: "POST",
body: JSON.stringify(data),
});
}
export function updateIdea(
id: string,
data: IdeaUpdatePayload,
): Promise<Idea> {
return apiFetch(`/ideas/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
export function deleteIdea(id: string): Promise<void> {
return apiFetch(`/ideas/${id}`, { method: "DELETE" });
}
export function analyzeIdea(id: string): Promise<AnalyzeResponse> {
return apiFetch(`/ideas/${id}/analyze`, { method: "POST" });
}
export function getAnalysisResults(
id: string,
role?: string,
): Promise<AnalysisResult[]> {
const params = role ? `?role=${role}` : "";
return apiFetch(`/ideas/${id}/analysis${params}`);
}
+16
View File
@@ -0,0 +1,16 @@
import { apiFetch } from "./client";
export interface PublicTariffPlan {
id: string;
name: string;
code: string;
description: string | null;
price_monthly: number;
price_yearly: number | null;
features: Record<string, unknown> | null;
sort_order: number;
}
export function listPublicTariffs(): Promise<PublicTariffPlan[]> {
return apiFetch("/tariffs");
}
+32
View File
@@ -0,0 +1,32 @@
import { createContext, useContext, useEffect, type ReactNode } from "react";
import { useAuthStore, type User } from "../stores/auth";
interface AuthContextType {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, displayName: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const { user, loading, login, register, logout, loadUser } = useAuthStore();
useEffect(() => {
loadUser();
}, [loadUser]);
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}
@@ -0,0 +1,51 @@
import { Component } from "react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { BrowserRouter } from "react-router-dom";
import ErrorBoundary from "./ErrorBoundary";
class Bomb extends Component {
render(): React.ReactNode {
throw new Error("💥");
}
}
describe("ErrorBoundary", () => {
it("renders children when no error", () => {
render(
<BrowserRouter>
<ErrorBoundary>
<p>hello</p>
</ErrorBoundary>
</BrowserRouter>
);
expect(screen.getByText("hello")).toBeTruthy();
});
it("catches error and shows fallback", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
render(
<BrowserRouter>
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
</BrowserRouter>
);
expect(screen.getByText("Что-то пошло не так")).toBeTruthy();
expect(screen.getByText("На главную")).toBeTruthy();
spy.mockRestore();
});
it("shows custom fallback when provided", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
render(
<BrowserRouter>
<ErrorBoundary fallback={<p>custom error</p>}>
<Bomb />
</ErrorBoundary>
</BrowserRouter>
);
expect(screen.getByText("custom error")).toBeTruthy();
spy.mockRestore();
});
});
+50
View File
@@ -0,0 +1,50 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Link } from "react-router-dom";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("ErrorBoundary caught:", error, info.componentStack);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md rounded-xl bg-white p-8 text-center shadow-lg dark:bg-gray-800">
<h1 className="mb-2 text-2xl font-bold text-gray-900 dark:text-gray-100">
Что-то пошло не так
</h1>
<p className="mb-6 text-gray-600 dark:text-gray-400">
Произошла непредвиденная ошибка. Попробуйте обновить страницу.
</p>
<Link
to="/"
className="rounded-lg bg-primary-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-primary-700"
onClick={() => this.setState({ hasError: false, error: null })}
>
На главную
</Link>
</div>
</div>
);
}
return this.props.children;
}
}
+82
View File
@@ -0,0 +1,82 @@
import { useState, type FormEvent } from "react";
import { submitFeedback } from "../api/feedback";
interface FeedbackFormProps {
pageUrl?: string;
onClose?: () => void;
}
export default function FeedbackForm({ pageUrl, onClose }: FeedbackFormProps) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!text.trim()) return;
setSending(true);
setError("");
try {
await submitFeedback({ text: text.trim(), page_url: pageUrl });
setSent(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка отправки");
} finally {
setSending(false);
}
}
if (sent) {
return (
<div className="rounded-xl border bg-white p-4 text-center shadow-sm dark:border-gray-700 dark:bg-gray-900">
<p className="text-green-600 font-medium">Спасибо за отзыв!</p>
<p className="mt-1 text-sm text-gray-500">Мы учтём ваше мнение.</p>
{onClose && (
<button
onClick={onClose}
className="mt-3 text-sm text-primary-600 hover:underline"
>
Закрыть
</button>
)}
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-3">
{error && (
<p className="rounded bg-red-50 p-2 text-sm text-red-600 dark:bg-red-950 dark:text-red-400">
{error}
</p>
)}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Расскажите, что можно улучшить…"
rows={3}
required
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
<div className="flex gap-2">
<button
type="submit"
disabled={sending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
{sending ? "Отправка…" : "Отправить"}
</button>
{onClose && (
<button
type="button"
onClick={onClose}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
Отмена
</button>
)}
</div>
</form>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { useEffect } from "react";
interface GAScriptProps {
measurementId?: string;
}
export default function GAScript({ measurementId }: GAScriptProps) {
useEffect(() => {
if (!measurementId) return;
const script = document.createElement("script");
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
script.async = true;
document.head.appendChild(script);
(window as any).dataLayer = (window as any).dataLayer || [];
function gtag(...args: unknown[]) {
(window as any).dataLayer.push(args);
}
gtag("js", new Date());
gtag("config", measurementId);
}, [measurementId]);
return null;
}
+76
View File
@@ -0,0 +1,76 @@
import { Link } from "react-router-dom";
interface HelpDrawerProps {
onClose: () => void;
}
export default function HelpDrawer({ onClose }: HelpDrawerProps) {
return (
<div className="fixed inset-0 z-50 flex">
<div
className="flex-1 bg-black/30 backdrop-blur-sm"
onClick={onClose}
/>
<div className="flex w-80 flex-col bg-white shadow-xl dark:bg-gray-900">
<div className="flex items-center justify-between border-b px-4 py-3 dark:border-gray-700">
<h3 className="font-semibold">Помощь</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<nav className="space-y-1 text-sm">
<Link
to="/user-guide"
onClick={onClose}
className="block rounded-lg px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Руководство пользователя
</Link>
<Link
to="/voice/help"
onClick={onClose}
className="block rounded-lg px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Голосовые команды
</Link>
<Link
to="/feedback"
onClick={onClose}
className="block rounded-lg px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Обратная связь
</Link>
<hr className="my-2 dark:border-gray-700" />
<Link
to="/privacy"
onClick={onClose}
className="block rounded-lg px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Конфиденциальность
</Link>
<Link
to="/terms"
onClick={onClose}
className="block rounded-lg px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Соглашение
</Link>
<Link
to="/data-processing"
onClick={onClose}
className="block rounded-lg px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800"
>
Обработка данных
</Link>
</nav>
</div>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useState } from "react";
import HelpDrawer from "./HelpDrawer";
export default function HelpFAB() {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-white shadow-lg transition hover:bg-primary-700 hover:shadow-xl"
aria-label="Помощь"
title="Помощь"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
{open && <HelpDrawer onClose={() => setOpen(false)} />}
</>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import T from "./T";
import HelpFAB from "./HelpFAB";
function GitHubIcon() {
return (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
);
}
function TelegramIcon() {
return (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.944 0A12 12 0 000 12a12 12 0 0012 12 12 12 0 0012-12A12 12 0 0012 0a12 12 0 00-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 01.171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
);
}
export default function Layout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
useEffect(() => {
fetch("/version.json")
.then((r) => r.json())
.then((data) => {
const el = document.getElementById("voidea-version");
if (el) el.textContent = `v${data.version}`;
})
.catch(() => {});
}, []);
function handleLogout() {
logout();
navigate("/login");
}
return (
<div className="flex min-h-screen flex-col">
<header className="border-b bg-white dark:border-gray-700 dark:bg-gray-900">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
<Link to="/dashboard" className="text-xl font-bold text-primary-700">
VoIdea
</Link>
<nav className="flex items-center gap-4 text-sm">
<Link to="/dashboard" className="hover:text-primary-600">
<T path="nav.ideas" />
</Link>
<Link to="/create" className="hover:text-primary-600">
<T path="nav.new" />
</Link>
<Link to="/voice" className="hover:text-primary-600">
<T path="nav.voice" />
</Link>
<Link to="/voice/help" className="hover:text-primary-600">
<T path="nav.commands" />
</Link>
<Link to="/settings" className="hover:text-primary-600">
<T path="nav.settings" />
</Link>
{user?.is_superuser && (
<Link to="/admin" className="hover:text-primary-600">
<T path="nav.admin" />
</Link>
)}
<span className="text-gray-400">{user?.display_name}</span>
<button
onClick={handleLogout}
aria-label="Выйти"
className="rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
>
<T path="nav.logout" />
</button>
</nav>
</div>
</header>
<main id="main-content" className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">{children}</main>
<HelpFAB />
<footer className="border-t bg-white dark:border-gray-700 dark:bg-gray-900">
<div className="mx-auto flex max-w-5xl flex-col items-center gap-4 px-4 py-6 sm:flex-row sm:justify-between">
<div className="flex gap-4 text-sm text-gray-500">
<Link to="/privacy" className="hover:text-primary-600">
Конфиденциальность
</Link>
<Link to="/terms" className="hover:text-primary-600">
Соглашение
</Link>
<Link to="/data-processing" className="hover:text-primary-600">
Обработка данных
</Link>
</div>
<div className="flex items-center gap-3 text-gray-400">
<span className="text-xs" id="voidea-version" />
<a
href="https://github.com/anomalyco/voidea"
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary-600"
aria-label="GitHub"
>
<GitHubIcon />
</a>
<a
href="https://t.me/voidea"
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary-600"
aria-label="Telegram"
>
<TelegramIcon />
</a>
</div>
</div>
</footer>
</div>
);
}
@@ -0,0 +1,64 @@
import { useState } from "react";
const STEPS = [
{ title: "Добро пожаловать в VoIdeaAI!", text: "Здесь ваши идеи превращаются в проекты. Давайте быстро покажем, как это работает." },
{ title: "Голосовой ввод", text: "Нажмите на иконку микрофона и просто говорите. Идеи рождаются вслух!" },
{ title: "Агенты-аналитики", text: "13 ИИ-агентов проанализируют вашу идею с разных сторон: бизнес, финансы, право, технологии." },
{ title: "Всё готово!", text: "Начните с новой идеи или попробуйте голосовой ассистент. Удачи!" },
];
const STORAGE_KEY = "voidea_onboarding_done";
export function isOnboardingDone(): boolean {
return localStorage.getItem(STORAGE_KEY) === "true";
}
export function markOnboardingDone() {
localStorage.setItem(STORAGE_KEY, "true");
}
export function resetOnboarding() {
localStorage.removeItem(STORAGE_KEY);
}
export default function OnboardingOverlay({ onDone }: { onDone: () => void }) {
const [step, setStep] = useState(0);
function next() {
if (step < STEPS.length - 1) {
setStep(s => s + 1);
} else {
markOnboardingDone();
onDone();
}
}
function skip() {
markOnboardingDone();
onDone();
}
const s = STEPS[step];
const progress = ((step + 1) / STEPS.length) * 100;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="mx-auto max-w-md rounded-2xl bg-white p-8 shadow-2xl dark:bg-gray-900">
<div className="mb-6 h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div className="h-1.5 rounded-full bg-primary-600 transition-all" style={{ width: `${progress}%` }} />
</div>
<h2 className="mb-3 text-xl font-bold">{s.title}</h2>
<p className="mb-8 text-gray-600 dark:text-gray-400">{s.text}</p>
<div className="flex items-center justify-between">
<button onClick={skip} className="text-sm text-gray-400 hover:text-gray-600">Пропустить</button>
<button
onClick={next}
className="rounded-lg bg-primary-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-primary-700"
>
{step < STEPS.length - 1 ? "Далее" : "Начать!"}
</button>
</div>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { type ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export function ProtectedRoute({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export function AdminRoute({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
if (!user.is_superuser) return <Navigate to="/" replace />;
return <>{children}</>;
}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect } from "react";
interface SEOHeadProps {
title?: string;
description?: string;
}
export default function SEOHead({ title, description }: SEOHeadProps) {
useEffect(() => {
const base = "VoIdeaAI";
document.title = title ? `${title} | ${base}` : `${base} — Идеи рождаются вслух`;
if (description) {
let meta = document.querySelector('meta[name="description"]');
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("name", "description");
document.head.appendChild(meta);
}
meta.setAttribute("content", description);
const ogDesc = document.querySelector('meta[property="og:description"]');
if (ogDesc) ogDesc.setAttribute("content", description);
}
}, [title, description]);
return null;
}
@@ -0,0 +1,12 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import SkipToContent from "./SkipToContent";
describe("SkipToContent", () => {
it("renders a link with aria text", () => {
render(<SkipToContent />);
const link = screen.getByText("Перейти к содержанию");
expect(link).toBeTruthy();
expect(link.getAttribute("href")).toBe("#main-content");
});
});
+10
View File
@@ -0,0 +1,10 @@
export default function SkipToContent() {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-lg focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:text-white"
>
Перейти к содержанию
</a>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { strings } from "../constants/strings";
function resolvePath(path: string): string {
const keys = path.split(".");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let result: any = strings;
for (const key of keys) {
if (result == null || typeof result !== "object") return path;
result = result[key as keyof typeof result];
}
return typeof result === "string" ? result : path;
}
export default function T({ path }: { path: string }) {
return <>{resolvePath(path)}</>;
}
+542
View File
@@ -0,0 +1,542 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { apiFetch } from "../api/client";
import VoiceInput from "./VoiceInput";
import { useVoiceCommands } from "../hooks/useVoiceCommands";
interface Message {
role: "user" | "assistant";
text: string;
agent_name?: string;
confidence?: number;
verification_status?: string;
interaction_id?: string;
}
interface SessionItem {
id: string;
title: string;
status: string;
created_at: string;
updated_at: string;
}
function ConfidenceBadge({ confidence, status }: { confidence: number; status?: string }) {
const color =
status === "needs_clarification"
? "bg-red-100 text-red-700"
: confidence >= 80
? "bg-green-100 text-green-700"
: confidence >= 50
? "bg-yellow-100 text-yellow-700"
: "bg-red-100 text-red-700";
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
<span
className={`h-1.5 w-1.5 rounded-full ${
status === "needs_clarification" || confidence < 50
? "bg-red-500"
: confidence >= 80
? "bg-green-500"
: "bg-yellow-500"
}`}
/>
{confidence}%
</span>
);
}
function StarRating({ interactionId, onRated }: { interactionId: string; onRated: () => void }) {
const [hovered, setHovered] = useState(0);
const [selected, setSelected] = useState(0);
async function handleRate(rating: number) {
try {
await apiFetch("/voice/rate", {
method: "POST",
body: JSON.stringify({ interaction_id: interactionId, rating }),
});
setSelected(rating);
onRated();
} catch {
/* silent */
}
}
if (selected > 0) {
return (
<div className="flex gap-0.5 opacity-50">
{[1, 2, 3, 4, 5].map((star) => (
<span key={star} className="text-sm text-yellow-500">
{star <= selected ? "★" : "☆"}
</span>
))}
</div>
);
}
return (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onMouseEnter={() => setHovered(star)}
onMouseLeave={() => setHovered(0)}
onClick={() => handleRate(star)}
className={`text-sm transition-colors ${
star <= hovered ? "text-yellow-500" : "text-gray-300 dark:text-gray-600"
} hover:text-yellow-500`}
>
</button>
))}
</div>
);
}
export default function VoiceChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [autoPlay, setAutoPlay] = useState(true);
const [error, setError] = useState("");
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessions, setSessions] = useState<SessionItem[]>([]);
const [savedIdeaId, setSavedIdeaId] = useState<string | null>(null);
const [suggestedCmd, setSuggestedCmd] = useState<string | null>(null);
const [sessionsLoading, setSessionsLoading] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(true);
const listRef = useRef<HTMLDivElement>(null);
const lastResponseRef = useRef("");
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
}, [messages]);
useEffect(() => {
loadSessions();
}, []);
async function loadSessions() {
setSessionsLoading(true);
try {
const data = await apiFetch<SessionItem[]>("/voice/sessions");
setSessions(data);
if (data.length > 0 && !sessionId) {
setSessionId(data[0].id);
}
} catch {
/* silent */
} finally {
setSessionsLoading(false);
}
}
async function handleNewChat() {
setMessages([]);
setSessionId(null);
setError("");
lastResponseRef.current = "";
}
async function handleSelectSession(id: string) {
if (id === sessionId) return;
setSessionId(id);
setMessages([]);
setError("");
try {
const history = await apiFetch<{
id: string; input: string; agent: string; response: string;
confidence: number; rating: number; created_at: string;
}[]>(`/voice/sessions/${id}/history`);
setMessages(
history.flatMap((h) => [
{ role: "user" as const, text: h.input },
{
role: "assistant" as const,
text: h.response,
agent_name: h.agent,
confidence: h.confidence,
interaction_id: h.id,
},
])
);
} catch {
/* silent */
}
}
async function handleDeleteSession(id: string) {
if (!confirm("Удалить этот чат?")) return;
try {
await apiFetch(`/voice/sessions/${id}`, { method: "DELETE" });
setSessions((prev) => prev.filter((s) => s.id !== id));
if (sessionId === id) {
setSessionId(null);
setMessages([]);
}
} catch {
/* silent */
}
}
async function handleSaveIdea() {
if (!sessionId) return;
setSaving(true);
try {
const data = await apiFetch<{ idea_id: string; title: string }>("/voice/save-idea", {
method: "POST",
body: JSON.stringify({ session_id: sessionId }),
});
setSavedIdeaId(data.idea_id);
setTimeout(() => setSavedIdeaId(null), 4000);
loadSessions();
} catch {
setError("Не удалось сохранить идею");
} finally {
setSaving(false);
}
}
const speak = useCallback((text: string) => {
if (!autoPlay || !window.speechSynthesis) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "ru-RU";
utterance.rate = 0.9;
utterance.pitch = 1.0;
const voices = window.speechSynthesis.getVoices();
const ruVoice = voices.find((v) => v.lang.startsWith("ru"));
if (ruVoice) utterance.voice = ruVoice;
window.speechSynthesis.speak(utterance);
}, [autoPlay]);
async function handleVoiceResult(text: string) {
setMessages((prev) => [...prev, { role: "user", text }]);
setLoading(true);
setError("");
try {
const data = await apiFetch<{
response: string;
agent_name: string;
confidence: number;
verification_status: string;
processing_time_ms: number;
interaction_id: string;
session_id: string;
suggested_command: string | null;
}>("/voice/chat", {
method: "POST",
body: JSON.stringify({ text, session_id: sessionId }),
});
lastResponseRef.current = data.response;
if (data.session_id && data.session_id !== sessionId) {
setSessionId(data.session_id);
loadSessions();
}
setMessages((prev) => [
...prev,
{
role: "assistant",
text: data.response,
agent_name: data.agent_name,
confidence: data.confidence,
verification_status: data.verification_status,
interaction_id: data.interaction_id,
},
]);
if (data.verification_status !== "needs_clarification") {
speak(data.response);
}
if (data.suggested_command) {
setSuggestedCmd(data.suggested_command);
setTimeout(() => setSuggestedCmd(null), 10000);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка обработки");
} finally {
setLoading(false);
}
}
function handleStopSpeech() {
window.speechSynthesis?.cancel();
}
function handleClarify(originalText?: string) {
const text = originalText || messages[messages.length - 2]?.text || "";
const clarify = prompt("Уточните запрос:", text);
if (clarify) handleVoiceResult(clarify);
}
function handleRepeat() {
if (lastResponseRef.current) speak(lastResponseRef.current);
}
const cmdDisabled = !messages.length;
const { listening: cmdListening } = useVoiceCommands({
onStop: handleStopSpeech,
onRepeat: handleRepeat,
onClarify: () => handleClarify(),
disabled: cmdDisabled,
});
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
}
return (
<div className="flex gap-4">
{/* Sidebar */}
<div
className={`flex-shrink-0 transition-all ${
sidebarOpen ? "w-64" : "w-0 overflow-hidden"
}`}
>
<div className="flex h-full flex-col gap-2 rounded-xl border bg-white p-3 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<button
type="button"
onClick={handleNewChat}
className="flex items-center justify-center gap-2 rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Новый чат
</button>
<div className="flex-1 overflow-y-auto space-y-1" role="log" aria-live="polite">
{sessionsLoading ? (
<div className="flex justify-center py-4">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-500 border-t-transparent" />
</div>
) : sessions.length === 0 ? (
<p className="py-4 text-center text-xs text-gray-400">
Нет обсуждений
</p>
) : (
sessions.map((s) => (
<div
key={s.id}
className={`group flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm cursor-pointer transition-colors ${
s.id === sessionId
? "bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
}`}
onClick={() => handleSelectSession(s.id)}
>
<div className="flex-1 min-w-0">
<div className="truncate font-medium">{s.title}</div>
<div className="text-xs text-gray-400">{formatDate(s.updated_at)}</div>
</div>
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleDeleteSession(s.id); }}
className="shrink-0 rounded p-1 text-gray-400 opacity-0 group-hover:opacity-100 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 transition-all"
title="Удалить"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))
)}
</div>
</div>
</div>
{/* Suggested command toast */}
{suggestedCmd && (
<div className="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 animate-fade-in">
<div className="rounded-lg bg-primary-600 px-4 py-2 text-sm text-white shadow-lg">
{suggestedCmd}
</div>
</div>
)}
{/* Main chat */}
<div className="mx-auto flex max-w-2xl flex-1 flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title={sidebarOpen ? "Скрыть список" : "Показать список"}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{sidebarOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
)}
</svg>
</button>
<h1 className="text-2xl font-bold">Голосовой ассистент</h1>
</div>
<div className="flex items-center gap-3">
{cmdListening && messages.length > 0 && (
<span className="flex items-center gap-1 text-xs text-gray-400" title="Слушаю команды">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-green-400" />
Команды
</span>
)}
{sessionId && messages.some((m) => m.role === "assistant") && (
<button
type="button"
onClick={handleSaveIdea}
disabled={saving}
className="flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{saving ? (
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
{saving ? "Сохранение..." : "Сохранить идею"}
</button>
)}
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={autoPlay}
onChange={(e) => setAutoPlay(e.target.checked)}
className="rounded"
/>
Озвучивать ответ
</label>
<button
type="button"
onClick={handleStopSpeech}
className="rounded bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
title="Остановить"
>
Стоп
</button>
</div>
</div>
{savedIdeaId && (
<div className="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200 animate-fade-in">
<svg className="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>Идея сохранена!</span>
<a
href={`/ideas/${savedIdeaId}`}
className="ml-auto font-medium text-emerald-700 underline hover:text-emerald-800 dark:text-emerald-300"
>
Открыть
</a>
</div>
)}
<div
ref={listRef}
className="flex h-[60vh] flex-col gap-3 overflow-y-auto rounded-xl border bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900"
>
{messages.length === 0 && (
<div className="flex flex-1 items-center justify-center text-gray-400">
<p className="text-center">
Нажмите на микрофон и начните говорить
<br />
<span className="text-xs">
Например: «Придумай идею для стартапа в сфере экологии»
</span>
</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"}`}
>
<div
className={`max-w-[80%] rounded-xl px-4 py-2 ${
msg.role === "user"
? "bg-primary-600 text-white"
: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
}`}
>
{msg.role === "assistant" && msg.agent_name && (
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-xs font-medium text-primary-500">
{msg.agent_name}
</span>
{msg.confidence !== undefined && (
<ConfidenceBadge confidence={msg.confidence} status={msg.verification_status} />
)}
</div>
)}
<p className="whitespace-pre-wrap text-sm">{msg.text}</p>
</div>
{msg.role === "assistant" && msg.interaction_id && (
<div className="mt-1 flex items-center gap-2 px-1">
<StarRating
interactionId={msg.interaction_id}
onRated={() => {}}
/>
{msg.verification_status === "needs_clarification" && (
<button
type="button"
onClick={() => handleClarify(messages[i - 1]?.text || "")}
className="text-xs text-primary-600 hover:underline"
>
Уточнить
</button>
)}
</div>
)}
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="rounded-xl bg-gray-100 px-4 py-2 dark:bg-gray-800">
<div className="flex gap-1">
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.1s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.2s]" />
</div>
</div>
</div>
)}
{error && (
<p className="rounded bg-red-50 p-2 text-sm text-red-600 dark:bg-red-950 dark:text-red-400">
{error}
</p>
)}
</div>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Напишите сообщение..."
className="flex-1 rounded-xl border bg-white px-4 py-3 text-sm shadow-sm outline-none transition-colors focus:border-primary-500 focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:focus:border-primary-400"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.target as HTMLInputElement).value.trim()) {
handleVoiceResult((e.target as HTMLInputElement).value.trim());
(e.target as HTMLInputElement).value = "";
}
}}
/>
<VoiceInput onResult={handleVoiceResult} />
</div>
</div>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useState, useRef, useCallback, type KeyboardEvent } from "react";
interface VoiceInputProps {
onResult: (text: string) => void;
language?: string;
}
export default function VoiceInput({ onResult, language = "ru-RU" }: VoiceInputProps) {
const [listening, setListening] = useState(false);
const [supported, setSupported] = useState(true);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const chunks = useRef<Blob[]>([]);
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const handleWebSpeech = useCallback(() => {
if (!SpeechRecognition) {
startMediaRecorder();
return;
}
const recognition = new SpeechRecognition();
recognition.lang = language;
recognition.interimResults = false;
recognition.maxAlternatives = 1;
recognition.onresult = (event: any) => {
const text = event.results[0][0].transcript;
onResult(text);
setListening(false);
};
recognition.onerror = () => {
setListening(false);
startMediaRecorder();
};
recognition.onend = () => setListening(false);
recognition.start();
setListening(true);
}, [SpeechRecognition, language, onResult]);
const startMediaRecorder = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
mediaRecorder.current = recorder;
chunks.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.current.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(chunks.current, { type: "audio/webm" });
chunks.current = [];
const formData = new FormData();
formData.append("file", blob, "voice.webm");
try {
const res = await fetch("/api/v1/voice/transcribe", {
method: "POST",
body: formData,
});
if (res.ok) {
const data = await res.json();
onResult(data.text);
}
} catch {
/* silent */
}
};
recorder.start();
setListening(true);
setTimeout(() => {
if (recorder.state === "recording") {
recorder.stop();
}
}, 5000);
} catch {
setSupported(false);
}
}, [onResult]);
const stopRecording = useCallback(() => {
if (mediaRecorder.current?.state === "recording") {
mediaRecorder.current.stop();
}
setListening(false);
}, []);
if (!supported) {
return null;
}
return (
<button
type="button"
onMouseDown={handleWebSpeech}
onMouseUp={stopRecording}
onTouchStart={handleWebSpeech}
onTouchEnd={stopRecording}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (listening) stopRecording();
else handleWebSpeech();
}
}}
aria-label={listening ? "Остановить запись" : "Записать голос"}
className={`rounded-full p-3 transition-colors ${
listening
? "bg-red-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
}`}
title={listening ? "Отпустите для остановки" : "Записать голос"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
>
<path d="M12 2a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V5a3 3 0 0 1 3-3Z" />
<path d="M19 11a7 7 0 0 1-14 0h-2a9 9 0 0 0 8 8.94V22h2v-2.06A9 9 0 0 0 21 11h-2Z" />
</svg>
</button>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect } from "react";
interface YandexMetrikaProps {
counterId?: string;
}
export default function YandexMetrika({ counterId }: YandexMetrikaProps) {
useEffect(() => {
if (!counterId) return;
const script = document.createElement("script");
script.src = `https://mc.yandex.ru/metrika/tag.js`;
script.async = true;
document.head.appendChild(script);
window.addEventListener("load", () => {
(window as any).ym?.(counterId, "init", {
clickmap: true,
trackLinks: true,
accurateTrackBounce: true,
});
});
}, [counterId]);
if (!counterId) return null;
return (
<noscript>
<div>
<img
src={`https://mc.yandex.ru/watch/${counterId}`}
style={{ position: "absolute", left: "-9999px" }}
alt=""
/>
</div>
</noscript>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import { strings } from "./strings";
describe("strings", () => {
it("has nav section", () => {
expect(strings.nav.ideas).toBe("Идеи");
expect(strings.nav.logout).toBe("Выйти");
});
it("has auth section", () => {
expect(strings.auth.login).toBe("Войти");
expect(strings.auth.register).toBe("Регистрация");
});
it("has accessibility section", () => {
expect(strings.accessibility.skipToContent).toBe("Перейти к содержанию");
});
});
+65
View File
@@ -0,0 +1,65 @@
export const strings = {
nav: {
ideas: "Идеи",
new: "Новая",
voice: "Голос",
commands: "Команды",
settings: "Настройки",
admin: "Админ",
logout: "Выйти",
},
auth: {
login: "Войти",
register: "Регистрация",
email: "Email",
password: "Пароль",
displayName: "Отображаемое имя",
forgotPassword: "Забыли пароль?",
resetPassword: "Сбросить пароль",
noAccount: "Нет аккаунта?",
hasAccount: "Уже есть аккаунт?",
},
idea: {
create: "Новая идея",
edit: "Редактировать",
delete: "Удалить",
save: "Сохранить",
cancel: "Отмена",
title: "Название",
content: "Описание",
analyze: "Анализировать",
},
voice: {
start: "Начать запись",
stop: "Остановить",
placeholder: "Напишите сообщение...",
},
settings: {
profile: "Профиль",
security: "Безопасность",
voice: "Голос",
theme: "Тема",
integrations: "Интеграции",
tariff: "Тариф",
},
common: {
loading: "Загрузка...",
error: "Ошибка",
success: "Успешно",
confirm: "Подтвердить",
back: "Назад",
toMain: "На главную",
somethingWrong: "Что-то пошло не так",
retry: "Повторить",
},
accessibility: {
skipToContent: "Перейти к содержанию",
github: "GitHub",
telegram: "Telegram",
help: "Помощь",
menu: "Меню",
close: "Закрыть",
},
} as const;
export type StringKey = keyof typeof strings;
+40
View File
@@ -0,0 +1,40 @@
/** BroadcastChannel hook for cross-tab communication in VoIdeaAI. */
import { useEffect, useRef } from "react";
export type BroadcastEvent =
| { type: "theme_change"; payload: { theme: "dark" | "light" } }
| { type: "logout" }
| { type: "idea_saved"; payload: { idea_id: string; title: string } }
| { type: "voice_command" };
const CHANNEL_NAME = "voidea-sync";
const channel = typeof BroadcastChannel !== "undefined"
? new BroadcastChannel(CHANNEL_NAME)
: null;
export function broadcast(event: BroadcastEvent): void {
if (channel) {
channel.postMessage(event);
}
}
export function useBroadcastListener(
handler: (event: BroadcastEvent) => void,
deps: unknown[] = [],
): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
if (!channel) return;
function onMessage(e: MessageEvent) {
handlerRef.current(e.data as BroadcastEvent);
}
channel.addEventListener("message", onMessage);
return () => channel.removeEventListener("message", onMessage);
}, deps);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useRef, useCallback, useState } from "react";
const COMMAND_MAP: Record<string, string[]> = {
stop: ["стоп", "хватит", "замолчи", "остановись", "тихо"],
repeat: ["повтори", "ещё раз", "заново", "ещё"],
clarify: ["уточнить", "уточни", "поправь", "дополни"],
};
export interface VoiceCommandsOptions {
onStop?: () => void;
onRepeat?: () => void;
onClarify?: () => void;
/** Disable listener while VoiceInput is active */
disabled?: boolean;
}
export function useVoiceCommands({
onStop,
onRepeat,
onClarify,
disabled = false,
}: VoiceCommandsOptions) {
const [listening, setListening] = useState(false);
const recognitionRef = useRef<any>(null);
const enabledRef = useRef(false);
const start = useCallback(() => {
const SpeechRecognition =
(window as any).SpeechRecognition ||
(window as any).webkitSpeechRecognition;
if (!SpeechRecognition) return;
const recognition = new SpeechRecognition();
recognition.lang = "ru-RU";
recognition.interimResults = false;
recognition.continuous = true;
recognition.maxAlternatives = 3;
recognition.onresult = (event: any) => {
if (disabled) return;
for (let i = event.resultIndex; i < event.results.length; i++) {
if (!event.results[i].isFinal) continue;
for (let j = 0; j < event.results[i].length; j++) {
const transcript = event.results[i][j].transcript
.toLowerCase()
.trim();
if (event.results[i][j].confidence < 0.5) continue;
if (COMMAND_MAP.stop.some((cmd) => transcript.includes(cmd))) {
onStop?.();
} else if (COMMAND_MAP.repeat.some((cmd) => transcript.includes(cmd))) {
onRepeat?.();
} else if (COMMAND_MAP.clarify.some((cmd) => transcript.includes(cmd))) {
onClarify?.();
}
}
}
};
recognition.onerror = () => {};
recognition.onend = () => {
setListening(false);
if (enabledRef.current && !disabled) {
try {
recognition.start();
setListening(true);
} catch {
/* silent */
}
}
};
recognitionRef.current = recognition;
try {
recognition.start();
setListening(true);
} catch {
/* silent */
}
}, [onStop, onRepeat, onClarify, disabled]);
const stop = useCallback(() => {
enabledRef.current = false;
setListening(false);
try {
recognitionRef.current?.stop();
} catch {
/* silent */
}
}, []);
useEffect(() => {
if (disabled) {
stop();
return;
}
enabledRef.current = true;
start();
return () => {
stop();
};
}, [disabled, start, stop]);
return { listening, stop, start };
}
+9
View File
@@ -0,0 +1,9 @@
{
"nav": {},
"auth": {},
"idea": {},
"voice": {},
"settings": {},
"common": {},
"accessibility": {}
}
+48
View File
@@ -0,0 +1,48 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@keyframes fade-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
/* CSS Wave animation */
@keyframes wave {
0% { transform: translateX(0) translateZ(0) scaleY(1); }
50% { transform: translateX(-25%) translateZ(0) scaleY(0.8); }
100% { transform: translateX(-50%) translateZ(0) scaleY(1); }
}
.wave-bg {
position: relative;
overflow: hidden;
}
.wave-bg::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: 60px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 60'%3E%3Cpath fill='%236366f1' fill-opacity='0.15' d='M0,30 C360,60 720,0 1080,30 C1260,45 1350,30 1440,30 L1440,60 L0,60 Z'/%3E%3C/svg%3E") repeat-x;
background-size: 720px 60px;
animation: wave 8s linear infinite;
pointer-events: none;
}
.dark .wave-bg::after {
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 60'%3E%3Cpath fill='%238180f4' fill-opacity='0.2' d='M0,30 C360,60 720,0 1080,30 C1260,45 1350,30 1440,30 L1440,60 L0,60 Z'/%3E%3C/svg%3E") repeat-x;
background-size: 720px 60px;
}
/* Dark mode scrollbar */
.dark ::-webkit-scrollbar { width: 8px; }
.dark ::-webkit-scrollbar-track { background: #1f2937; }
.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+94
View File
@@ -0,0 +1,94 @@
import { useAuth } from "../auth/AuthContext";
export default function AdminDocumentationTab() {
const { user } = useAuth();
return (
<div className="prose prose-sm max-w-none dark:prose-invert">
<h2>Документация администратора</h2>
<h3>Ролевая модель</h3>
<ul>
<li><strong>Owner</strong> полный доступ, управление сервисами systemd, debug mode</li>
<li><strong>Admin</strong> полный доступ к данным, управление пользователями и тарифами</li>
<li><strong>Moderator</strong> ограниченные права, настраиваемые через чекбоксы</li>
<li><strong>User</strong> стандартный пользователь</li>
</ul>
<h3>Вкладки админ-панели</h3>
<dl>
<dt>Пользователи</dt>
<dd>Поиск, редактирование ролей, блокировка, назначение прав модераторам</dd>
<dt>Агенты</dt>
<dd>Просмотр и управление агентами, включение/отключение</dd>
<dt>Журналы</dt>
<dd>Просмотр логов, debug mode, очистка, экспорт. Фильтрация по уровню и источнику</dd>
<dt>Обратная связь</dt>
<dd>Управление отзывами пользователей, изменение статуса</dd>
<dt>Тарифы</dt>
<dd>Создание и редактирование тарифных планов</dd>
<dt>Фичи</dt>
<dd>Трекер новых функций, 2FA глобальный переключатель</dd>
<dt>Пайплайн</dt>
<dd>Настройка VAD, wake word, семантической валидации, цепочек агентов и авто-тюнинга</dd>
<dt>Сервисы</dt>
<dd>Управление systemd-сервисами (только owner, только production)</dd>
<dt>Система</dt>
<dd>Информация о версии, БД, окружении</dd>
</dl>
<h3>Настройка VAD (шумоподавление)</h3>
<p>
Вкладка «Пайплайн» позволяет точно настроить параметры обнаружения голоса:
</p>
<ul>
<li><strong>noise_threshold</strong> (01) порог шума. Чем ниже, тем тише звуки будут считаться речью. Для тихого окружения ставьте 0.10.2, для шумного 0.50.7</li>
<li><strong>silence_timeout_ms</strong> сколько миллисекунд тишины нужно, чтобы считать фразу законченной. Больше = дольше ждёт паузу</li>
<li><strong>min_audio_duration_ms</strong> минимальная длина аудио в миллисекундах. Короткие звуки (щелчки, кашель) отбрасываются</li>
</ul>
<p>
Пользователи выбирают пресеты голоса в настройках. Администратор может задать
точные значения в пайплайне они станут базой для всех пользователей.
</p>
<h3>2FA (двухфакторная аутентификация)</h3>
<p>
Глобальный переключатель 2FA находится во вкладке «Фичи». Если 2FA отключена
пользователи входят без кода, даже если у каждого лично 2FA была включена.
</p>
<h3>Debug Mode</h3>
<p>
Во вкладке «Журналы» доступен режим расширенного логирования (1000%).
Включается owner-ом. При активации логируются:
</p>
<ul>
<li>Каждый HTTP-запрос (метод, path, статус, длительность)</li>
<li>Каждый LLM-вызов (промпт, ответ, модель)</li>
<li>Все вызовы агентов с полными данными</li>
<li>Все этапы пайплайна</li>
</ul>
<p>
Логи авто-очищаются по расписанию (настраивается). Owner также может очистить
вручную. При переполнении (80%) отправляется уведомление.
</p>
<h3>Экспорт логов</h3>
<p>
Вкладка «Журналы» кнопка «Скачать». Все логи выгружаются одним JSON-файлом
для скачивания на локальную машину. Удобно для анализа ошибок вне VPS.
</p>
{user?.role === "owner" && (
<>
<h3>Управление сервисами</h3>
<p>
Вкладка «Сервисы» доступна только владельцу. Позволяет перезапускать
systemd-юниты: <code>voidea-api</code>, <code>voidea-worker</code>, <code>voidea-beat</code>.
Работает только в production-окружении.
</p>
</>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { listIdeas, type Idea } from "../api/ideas";
export default function Dashboard() {
const [ideas, setIdeas] = useState<Idea[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
listIdeas()
.then(setIdeas)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</div>
);
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Мои идеи</h1>
<Link
to="/create"
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
+ Новая идея
</Link>
</div>
{ideas.length === 0 ? (
<div className="rounded-xl border border-dashed p-12 text-center text-gray-400 dark:border-gray-700">
<p className="text-lg">У вас пока нет идей</p>
<p className="mt-1 text-sm">
Создайте первую идею, чтобы начать
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{ideas.map((idea) => (
<Link
key={idea.id}
to={`/ideas/${idea.id}`}
className="rounded-xl border bg-white p-4 shadow-sm transition hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
>
<h3 className="mb-1 font-semibold">{idea.title}</h3>
<p className="mb-2 line-clamp-2 text-sm text-gray-500">
{idea.content}
</p>
<div className="flex flex-wrap gap-1">
{idea.tags?.map((tag) => (
<span
key={tag}
className="rounded-full bg-primary-100 px-2 py-0.5 text-xs text-primary-700 dark:bg-primary-900 dark:text-primary-300"
>
{tag}
</span>
))}
</div>
<p className="mt-2 text-xs text-gray-400">
{new Date(idea.created_at).toLocaleDateString("ru-RU")}
</p>
</Link>
))}
</div>
)}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
export default function DataProcessingPage() {
return (
<div className="prose prose-sm mx-auto max-w-3xl dark:prose-invert">
<h1>Согласие на обработку персональных данных</h1>
<p className="text-gray-500">Последнее обновление: 1 января 2025 г.</p>
<h2>1. Цели обработки</h2>
<p>
Нажимая кнопку «Зарегистрироваться» и устанавливая флажок согласия, вы
предоставляете согласие на обработку ваших персональных данных в следующих целях:
</p>
<ul>
<li>Регистрация и поддержка учётной записи</li>
<li>Предоставление функциональности Сервиса</li>
<li>Техническая поддержка</li>
<li>Улучшение качества Сервиса</li>
</ul>
<h2>2. Состав данных</h2>
<ul>
<li>Адрес электронной почты</li>
<li>Имя пользователя</li>
<li>Содержимое идей и голосовых записей</li>
<li>Технические данные (IP, user-agent)</li>
</ul>
<h2>3. Срок обработки</h2>
<p>
Срок обработки персональных данных составляет весь период использования
Сервиса и 3 года после последнего входа в аккаунт.
</p>
<h2>4. Отзыв согласия</h2>
<p>
Вы можете отозвать согласие, направив запрос на электронную почту владельца
Сервиса. После отзыва согласия аккаунт будет удалён в течение 30 дней.
</p>
<h2>5. Контакты</h2>
<p>
По всем вопросам, связанным с обработкой персональных данных, обращайтесь
через форму обратной связи в Сервисе.
</p>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import FeedbackForm from "../components/FeedbackForm";
import { useNavigate } from "react-router-dom";
export default function FeedbackPage() {
const navigate = useNavigate();
return (
<div className="mx-auto max-w-md">
<h1 className="mb-6 text-2xl font-bold">Обратная связь</h1>
<p className="mb-4 text-sm text-gray-500">
Расскажите нам о вашем опыте использования VoIdeaAI. Мы внимательно
читаем каждое сообщение.
</p>
<FeedbackForm pageUrl={window.location.href} onClose={() => navigate("/")} />
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/v1/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || "Ошибка отправки");
}
setSent(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка отправки");
} finally {
setLoading(false);
}
}
if (sent) {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm text-center">
<h2 className="mb-4 text-xl font-semibold text-green-600">
Письмо отправлено
</h2>
<p className="mb-4 text-gray-500">
Если email зарегистрирован, вы получите письмо со ссылкой для сброса пароля.
</p>
<Link to="/login" className="text-primary-600 hover:underline">
Вернуться на страницу входа
</Link>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="mb-6 text-center text-3xl font-bold text-primary-700">
VoIdeaAI
</h1>
<form
onSubmit={handleSubmit}
className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900"
>
<h2 className="mb-4 text-xl font-semibold">Восстановление пароля</h2>
{error && (
<p className="mb-4 rounded bg-red-50 p-2 text-sm text-red-600 dark:bg-red-950 dark:text-red-400">
{error}
</p>
)}
<label className="mb-4 block text-sm font-medium">
Введите ваш email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
</label>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
{loading ? "Отправка..." : "Отправить"}
</button>
<p className="mt-4 text-center text-sm text-gray-500">
<Link to="/login" className="text-primary-600 hover:underline">
Вернуться ко входу
</Link>
</p>
</form>
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNavigate } from "react-router-dom";
import { createIdea } from "../api/ideas";
import VoiceInput from "../components/VoiceInput";
const ideaSchema = z.object({
title: z.string().min(1, "Название обязательно").max(255),
content: z.string().min(1, "Описание обязательно"),
tags: z.string().optional(),
});
type IdeaForm = z.infer<typeof ideaSchema>;
export default function IdeaCreate() {
const navigate = useNavigate();
const contentRef = useRef<HTMLTextAreaElement>(null);
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
setError,
} = useForm<IdeaForm>({
resolver: zodResolver(ideaSchema),
defaultValues: { title: "", content: "", tags: "" },
});
function handleVoiceResult(text: string) {
const el = contentRef.current;
if (el) {
const start = el.selectionStart;
const end = el.selectionEnd;
const prev = el.value;
const newVal = prev.slice(0, start) + text + prev.slice(end);
setValue("content", newVal, { shouldValidate: true });
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + text.length;
});
}
}
async function onSubmit(data: IdeaForm) {
const tags = (data.tags || "")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
try {
const idea = await createIdea({
title: data.title,
content: data.content,
tags: tags.length > 0 ? tags : undefined,
});
navigate(`/ideas/${idea.id}`);
} catch (err) {
setError("root", {
message: err instanceof Error ? err.message : "Ошибка создания",
});
}
}
return (
<div>
<h1 className="mb-6 text-2xl font-bold">Новая идея</h1>
<form
onSubmit={handleSubmit(onSubmit)}
className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900"
noValidate
>
{errors.root && (
<p
className="mb-4 rounded bg-red-50 p-2 text-sm text-red-600 dark:bg-red-950 dark:text-red-400"
role="alert"
>
{errors.root.message}
</p>
)}
<div className="mb-4">
<label htmlFor="idea-title" className="mb-1 block text-sm font-medium">
Название
</label>
<input
id="idea-title"
{...register("title")}
maxLength={255}
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.title && (
<p className="mt-1 text-xs text-red-500">{errors.title.message}</p>
)}
</div>
<div className="mb-4">
<div className="mb-1 flex items-center justify-between">
<label htmlFor="idea-content" className="block text-sm font-medium">
Описание
</label>
<VoiceInput onResult={handleVoiceResult} />
</div>
<textarea
id="idea-content"
rows={6}
{...register("content")}
ref={(e) => {
register("content").ref(e);
(contentRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
}}
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.content && (
<p className="mt-1 text-xs text-red-500">{errors.content.message}</p>
)}
</div>
<div className="mb-4">
<label htmlFor="idea-tags" className="mb-1 block text-sm font-medium">
Теги (через запятую)
</label>
<input
id="idea-tags"
{...register("tags")}
placeholder="бизнес, технологии, стартап"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={isSubmitting}
className="rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
{isSubmitting ? "Сохранение..." : "Создать"}
</button>
<button
type="button"
onClick={() => navigate(-1)}
className="rounded-lg border px-5 py-2.5 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
Отмена
</button>
</div>
</form>
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNavigate, useParams } from "react-router-dom";
import { getIdea, updateIdea } from "../api/ideas";
import VoiceInput from "../components/VoiceInput";
const ideaSchema = z.object({
title: z.string().min(1, "Название обязательно").max(255),
content: z.string().min(1, "Описание обязательно"),
tags: z.string().optional(),
});
type IdeaForm = z.infer<typeof ideaSchema>;
export default function IdeaEdit() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const contentRef = useRef<HTMLTextAreaElement>(null);
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors, isSubmitting },
setError,
} = useForm<IdeaForm>({
resolver: zodResolver(ideaSchema),
defaultValues: { title: "", content: "", tags: "" },
});
useEffect(() => {
if (!id) return;
getIdea(id)
.then((idea) =>
reset({
title: idea.title,
content: idea.content,
tags: (idea.tags || []).join(", "),
})
)
.catch((err) =>
setError("root", {
message: err instanceof Error ? err.message : "Ошибка загрузки",
})
);
}, [id, reset, setError]);
function handleVoiceResult(text: string) {
const el = contentRef.current;
if (el) {
const start = el.selectionStart;
const end = el.selectionEnd;
const prev = el.value;
const newVal = prev.slice(0, start) + text + prev.slice(end);
setValue("content", newVal, { shouldValidate: true });
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + text.length;
});
}
}
async function onSubmit(data: IdeaForm) {
if (!id) return;
const tags = (data.tags || "")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
try {
await updateIdea(id, {
title: data.title,
content: data.content,
tags: tags.length > 0 ? tags : undefined,
});
navigate(`/ideas/${id}`);
} catch (err) {
setError("root", {
message: err instanceof Error ? err.message : "Ошибка сохранения",
});
}
}
return (
<div>
<h1 className="mb-6 text-2xl font-bold">Редактировать идею</h1>
<form
onSubmit={handleSubmit(onSubmit)}
className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900"
noValidate
>
{errors.root && (
<p
className="mb-4 rounded bg-red-50 p-2 text-sm text-red-600 dark:bg-red-950 dark:text-red-400"
role="alert"
>
{errors.root.message}
</p>
)}
<div className="mb-4">
<label htmlFor="edit-idea-title" className="mb-1 block text-sm font-medium">
Название
</label>
<input
id="edit-idea-title"
{...register("title")}
maxLength={255}
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.title && (
<p className="mt-1 text-xs text-red-500">{errors.title.message}</p>
)}
</div>
<div className="mb-4">
<div className="mb-1 flex items-center justify-between">
<label htmlFor="edit-idea-content" className="block text-sm font-medium">
Описание
</label>
<VoiceInput onResult={handleVoiceResult} />
</div>
<textarea
id="edit-idea-content"
rows={6}
{...register("content")}
ref={(e) => {
register("content").ref(e);
(contentRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
}}
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.content && (
<p className="mt-1 text-xs text-red-500">{errors.content.message}</p>
)}
</div>
<div className="mb-4">
<label htmlFor="edit-idea-tags" className="mb-1 block text-sm font-medium">
Теги (через запятую)
</label>
<input
id="edit-idea-tags"
{...register("tags")}
placeholder="бизнес, технологии, стартап"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={isSubmitting}
className="rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
{isSubmitting ? "Сохранение..." : "Сохранить"}
</button>
<button
type="button"
onClick={() => navigate(-1)}
className="rounded-lg border px-5 py-2.5 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
Отмена
</button>
</div>
</form>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
analyzeIdea,
deleteIdea,
getAnalysisResults,
getIdea,
type AnalysisResult,
type Idea,
} from "../api/ideas";
export default function IdeaView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [idea, setIdea] = useState<Idea | null>(null);
const [loading, setLoading] = useState(true);
const [analyzing, setAnalyzing] = useState(false);
const [results, setResults] = useState<AnalysisResult[]>([]);
const [showResults, setShowResults] = useState(false);
useEffect(() => {
if (!id) return;
getIdea(id)
.then(setIdea)
.catch(console.error)
.finally(() => setLoading(false));
}, [id]);
async function handleAnalyze() {
if (!id) return;
setAnalyzing(true);
try {
await analyzeIdea(id);
setShowResults(true);
const res = await getAnalysisResults(id);
setResults(res);
} catch (err) {
console.error(err);
} finally {
setAnalyzing(false);
}
}
async function handleDelete() {
if (!id || !confirm("Удалить идею?")) return;
try {
await deleteIdea(id);
navigate("/");
} catch (err) {
console.error(err);
}
}
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</div>
);
}
if (!idea) {
return (
<div className="py-12 text-center">
<p className="text-gray-500">Идея не найдена</p>
<Link to="/" className="mt-2 inline-block text-primary-600 hover:underline">
Назад к списку
</Link>
</div>
);
}
return (
<div>
<div className="mb-4">
<Link to="/" className="text-sm text-primary-600 hover:underline">
Назад
</Link>
</div>
<div className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<div className="mb-4 flex items-start justify-between">
<h1 className="text-2xl font-bold">{idea.title}</h1>
<div className="flex gap-2">
<Link
to={`/ideas/${id}/edit`}
className="rounded-lg border px-3 py-1.5 text-sm hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
Редактировать
</Link>
<button
onClick={handleDelete}
className="rounded-lg border px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-950"
>
Удалить
</button>
</div>
</div>
<div className="mb-4 flex gap-2">
{(["md", "json", "html"] as const).map((fmt) => (
<a
key={fmt}
href={`/api/v1/ideas/${id}/export?format=${fmt}`}
download
className="rounded-lg border px-3 py-1.5 text-xs hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
Экспорт {fmt.toUpperCase()}
</a>
))}
</div>
<p className="mb-4 whitespace-pre-wrap text-gray-700 dark:text-gray-300">
{idea.content}
</p>
<div className="mb-4 flex flex-wrap gap-1">
{idea.tags?.map((tag) => (
<span
key={tag}
className="rounded-full bg-primary-100 px-2 py-0.5 text-xs text-primary-700 dark:bg-primary-900 dark:text-primary-300"
>
{tag}
</span>
))}
</div>
<p className="text-xs text-gray-400">
Статус: {idea.status} {" "}
{new Date(idea.created_at).toLocaleDateString("ru-RU")}
</p>
</div>
<div className="mt-6">
<button
onClick={handleAnalyze}
disabled={analyzing}
className="rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
{analyzing
? "Анализ запущен..."
: showResults
? "Обновить анализ"
: "Запустить анализ ИИ"}
</button>
</div>
{showResults && (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{results.length === 0 && (
<p className="col-span-full text-sm text-gray-400">
Результаты появятся после завершения анализа
</p>
)}
{results.map((r) => (
<div
key={r.id}
className={`rounded-xl border p-4 ${
r.success
? "border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950"
: "border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950"
}`}
>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium capitalize">
{r.role.replace(/_/g, " ")}
</span>
<span
className={`text-xs ${
r.success ? "text-green-600" : "text-red-600"
}`}
>
{r.success ? "✓" : "✗"}
</span>
</div>
{r.message && (
<p className="line-clamp-3 text-xs text-gray-600 dark:text-gray-400">
{r.message}
</p>
)}
<p className="mt-1 text-xs text-gray-400">
{r.duration_ms}ms {" "}
{r.created_at
? new Date(r.created_at).toLocaleTimeString("ru-RU")
: ""}
</p>
</div>
))}
</div>
)}
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { Link } from "react-router-dom";
import SEOHead from "../components/SEOHead";
function GitHubIcon() {
return (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
);
}
function TelegramIcon() {
return (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.944 0A12 12 0 000 12a12 12 0 0012 12 12 12 0 0012-12A12 12 0 0012 0a12 12 0 00-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 01.171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
);
}
export default function LandingPage() {
return (
<div className="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
<SEOHead />
<header className="border-b bg-white/80 backdrop-blur-sm dark:border-gray-700 dark:bg-gray-900/80">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
<span className="text-xl font-bold text-primary-700">VoIdeaAI</span>
<nav className="flex items-center gap-3 text-sm">
<Link to="/login" className="text-gray-600 hover:text-primary-600 dark:text-gray-400">
Войти
</Link>
<Link
to="/register"
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
Регистрация
</Link>
</nav>
</div>
</header>
<main className="flex-1">
<section className="wave-bg from-primary-950 via-primary-900 to-primary-950 bg-gradient-to-br text-white">
<div className="mx-auto max-w-4xl px-4 py-20 text-center">
<h1 className="mb-4 text-4xl font-bold tracking-tight sm:text-5xl">
Идеи рождаются вслух
</h1>
<p className="mx-auto mb-8 max-w-2xl text-lg text-primary-200">
Голосовой AI-ассистент с 26 агентами. Записывайте мысли голосом,
получайте анализ от экспертных агентов, воплощайте идеи в жизнь.
</p>
<div className="flex justify-center gap-4">
<Link
to="/register"
className="rounded-lg bg-white px-6 py-3 font-semibold text-primary-900 hover:bg-primary-50"
>
Начать бесплатно
</Link>
<Link
to="/login"
className="rounded-lg border border-primary-400 px-6 py-3 font-semibold text-primary-200 hover:bg-primary-800"
>
Войти
</Link>
</div>
</div>
</section>
<section className="border-b py-16 dark:border-gray-800">
<div className="mx-auto max-w-5xl px-4">
<h2 className="mb-12 text-center text-2xl font-bold">Возможности</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[
{
title: "Голосовой ввод",
desc: "Записывайте идеи голосом через браузер. Распознавание речи в реальном времени.",
},
{
title: "26 AI-агентов",
desc: "Дирижёр координирует работу 13 ролевых и 12 технических агентов для всестороннего анализа.",
},
{
title: "Анализ идей",
desc: "Маркетолог, Финансист, Юрист и другие агенты оценят вашу идею с разных сторон.",
},
{
title: "Голосовые команды",
desc: "Настройте собственные команды для быстрого доступа к функциям.",
},
{
title: "OAuth вход",
desc: "Входите через Яндекс, Google или Apple. Без лишних паролей.",
},
{
title: "Яндекс.Диск",
desc: "Автоматическое сохранение идей на Яндекс.Диск в папку VoIdeaAI.",
},
].map((f) => (
<div
key={f.title}
className="rounded-xl border bg-white p-6 shadow-sm transition hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
>
<h3 className="mb-2 font-semibold">{f.title}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{f.desc}</p>
</div>
))}
</div>
</div>
</section>
<section className="py-16">
<div className="mx-auto max-w-5xl px-4 text-center">
<h2 className="mb-4 text-2xl font-bold">Готовы попробовать?</h2>
<p className="mb-8 text-gray-500">
Начните генерировать и анализировать идеи уже сегодня.
</p>
<Link
to="/register"
className="inline-block rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white hover:bg-primary-700"
>
Зарегистрироваться
</Link>
</div>
</section>
</main>
<footer className="border-t bg-white dark:border-gray-700 dark:bg-gray-900">
<div className="mx-auto flex max-w-5xl flex-col items-center gap-4 px-4 py-6 sm:flex-row sm:justify-between">
<div className="flex gap-4 text-sm text-gray-500">
<Link to="/privacy" className="hover:text-primary-600">Конфиденциальность</Link>
<Link to="/terms" className="hover:text-primary-600">Соглашение</Link>
<Link to="/data-processing" className="hover:text-primary-600">Обработка данных</Link>
</div>
<div className="flex gap-3 text-gray-400">
<a href="https://github.com/anomalyco/voidea" target="_blank" rel="noopener noreferrer" className="hover:text-primary-600" aria-label="GitHub"><GitHubIcon /></a>
<a href="https://t.me/voidea" target="_blank" rel="noopener noreferrer" className="hover:text-primary-600" aria-label="Telegram"><TelegramIcon /></a>
</div>
</div>
</footer>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export default function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [error, setError] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
try {
await login(email, password);
navigate("/");
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка входа");
}
}
async function handleYandexLogin() {
try {
const res = await fetch("/api/v1/auth/oauth/yandex");
const data = await res.json();
window.location.href = data.url;
} catch {
setError("Не удалось получить ссылку для входа через Яндекс");
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="mb-6 text-center text-3xl font-bold text-primary-700">
VoIdeaAI
</h1>
<form
onSubmit={handleSubmit}
className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900"
>
<h2 className="mb-4 text-xl font-semibold">Вход</h2>
{error && (
<p
className="mb-4 rounded bg-red-50 p-2 text-sm text-red-600 dark:bg-red-950 dark:text-red-400"
role="alert"
>
{error}
</p>
)}
<div className="mb-4">
<label htmlFor="login-email" className="mb-1 block text-sm font-medium">
Email
</label>
<input
id="login-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<div className="mb-2">
<label htmlFor="login-password" className="mb-1 block text-sm font-medium">
Пароль
</label>
<input
id="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<div className="mb-4 text-right">
<Link to="/forgot-password" className="text-sm text-primary-600 hover:underline">
Забыли пароль?
</Link>
</div>
<button
type="submit"
className="w-full rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300"
>
Войти
</button>
<div className="my-4 flex items-center gap-2">
<hr className="flex-1 border-gray-300 dark:border-gray-600" />
<span className="text-sm text-gray-400">или</span>
<hr className="flex-1 border-gray-300 dark:border-gray-600" />
</div>
<button
type="button"
onClick={handleYandexLogin}
className="w-full rounded-lg border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
Войти через Яндекс
</button>
<p className="mt-4 text-center text-sm text-gray-500">
Нет аккаунта?{" "}
<Link to="/register" className="text-primary-600 hover:underline">
Зарегистрироваться
</Link>
</p>
</form>
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { setTokens } from "../api/client";
export default function OAuthCallback() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [error, setError] = useState("");
useEffect(() => {
const code = searchParams.get("code");
const provider = searchParams.get("provider") || "yandex";
if (!code) {
setError("Отсутствует код авторизации");
return;
}
fetch(`/api/v1/auth/oauth/${provider}/callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
})
.then(async (res) => {
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || "Ошибка авторизации");
}
return res.json();
})
.then((data) => {
setTokens(data.access_token, data.refresh_token);
navigate("/", { replace: true });
})
.catch((err) => {
setError(err.message);
});
}, [searchParams, navigate]);
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm text-center">
{error ? (
<>
<h2 className="mb-4 text-xl font-semibold text-red-600">
Ошибка входа
</h2>
<p className="mb-4 text-gray-500">{error}</p>
<button
onClick={() => navigate("/login")}
className="text-primary-600 hover:underline"
>
Вернуться на страницу входа
</button>
</>
) : (
<>
<h2 className="mb-4 text-xl font-semibold text-primary-700">
Выполняется вход...
</h2>
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</>
)}
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
export default function PrivacyPage() {
return (
<div className="prose prose-sm mx-auto max-w-3xl dark:prose-invert">
<h1>Политика конфиденциальности</h1>
<p className="text-gray-500">Последнее обновление: 1 января 2025 г.</p>
<h2>1. Общие положения</h2>
<p>
Настоящая Политика конфиденциальности определяет порядок обработки и защиты
персональных данных пользователей сервиса VoIdeaAI (далее «Сервис»).
</p>
<h2>2. Какие данные мы собираем</h2>
<ul>
<li>Адрес электронной почты</li>
<li>Имя пользователя (отображаемое имя)</li>
<li>Идентификаторы OAuth-провайдеров (при входе через Яндекс, Google или Apple)</li>
<li>Содержимое созданных идей и голосовых заметок</li>
<li>Технические данные: IP-адрес, тип браузера, версия ОС</li>
</ul>
<h2>3. Как мы используем данные</h2>
<ul>
<li>Для предоставления доступа к Сервису и его функциям</li>
<li>Для обработки голосовых команд и генерации идей</li>
<li>Для улучшения качества работы агентов и алгоритмов</li>
<li>Для технической поддержки и обратной связи</li>
</ul>
<h2>4. Хранение данных</h2>
<p>
Ваши данные хранятся на серверах в Российской Федерации. Мы принимаем все
необходимые меры для защиты данных от несанкционированного доступа.
</p>
<h2>5. Передача данных третьим лицам</h2>
<p>
Мы не передаём персональные данные третьим лицам, за исключением случаев,
предусмотренных законодательством РФ.
</p>
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Link, useNavigate } from "react-router-dom";
import { useAuthStore } from "../stores/auth";
const registerSchema = z
.object({
display_name: z.string().min(1, "Имя обязательно"),
email: z.string().email("Некорректный email"),
password: z.string().min(8, "Минимум 8 символов"),
confirm_password: z.string().min(1, "Подтвердите пароль"),
accepted_terms: z.boolean(),
})
.refine((data) => data.password === data.confirm_password, {
message: "Пароли не совпадают",
path: ["confirm_password"],
})
.refine((data) => data.accepted_terms, {
message: "Необходимо принять условия",
path: ["accepted_terms"],
});
type RegisterForm = z.infer<typeof registerSchema>;
export default function RegisterPage() {
const { register: signUp } = useAuthStore();
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
defaultValues: {
display_name: "",
email: "",
password: "",
confirm_password: "",
accepted_terms: false,
},
});
async function onSubmit(data: RegisterForm) {
try {
await signUp(data.email, data.password, data.display_name);
navigate("/");
} catch (err) {
setError("root", {
message: err instanceof Error ? err.message : "Ошибка регистрации",
});
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="mb-6 text-center text-3xl font-bold text-primary-700">
VoIdeaAI
</h1>
<form
onSubmit={handleSubmit(onSubmit)}
className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900"
noValidate
>
<h2 className="mb-4 text-xl font-semibold">Регистрация</h2>
{errors.root && (
<p
className="mb-4 rounded bg-red-50 p-2 text-sm text-red-600 dark:bg-red-950 dark:text-red-400"
role="alert"
>
{errors.root.message}
</p>
)}
<div className="mb-4">
<label htmlFor="reg-display-name" className="mb-1 block text-sm font-medium">
Имя
</label>
<input
id="reg-display-name"
{...register("display_name")}
autoComplete="name"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.display_name && (
<p className="mt-1 text-xs text-red-500">{errors.display_name.message}</p>
)}
</div>
<div className="mb-4">
<label htmlFor="reg-email" className="mb-1 block text-sm font-medium">
Email
</label>
<input
id="reg-email"
type="email"
{...register("email")}
autoComplete="email"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.email && (
<p className="mt-1 text-xs text-red-500">{errors.email.message}</p>
)}
</div>
<div className="mb-4">
<label htmlFor="reg-password" className="mb-1 block text-sm font-medium">
Пароль
</label>
<input
id="reg-password"
type="password"
{...register("password")}
autoComplete="new-password"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.password && (
<p className="mt-1 text-xs text-red-500">{errors.password.message}</p>
)}
</div>
<div className="mb-4">
<label htmlFor="reg-confirm-password" className="mb-1 block text-sm font-medium">
Подтвердите пароль
</label>
<input
id="reg-confirm-password"
type="password"
{...register("confirm_password")}
autoComplete="new-password"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
{errors.confirm_password && (
<p className="mt-1 text-xs text-red-500">{errors.confirm_password.message}</p>
)}
</div>
<div className="mb-4">
<label className="flex items-start gap-2 text-sm text-gray-500">
<input
type="checkbox"
{...register("accepted_terms")}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span>
Я принимаю{" "}
<Link to="/terms" target="_blank" className="text-primary-600 hover:underline">
Пользовательское соглашение
</Link>{" "}
и даю{" "}
<Link to="/data-processing" target="_blank" className="text-primary-600 hover:underline">
согласие на обработку персональных данных
</Link>
</span>
</label>
{errors.accepted_terms && (
<p className="mt-1 text-xs text-red-500">{errors.accepted_terms.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 focus:outline-none focus:ring-4 focus:ring-primary-300"
>
{isSubmitting ? "Регистрация..." : "Зарегистрироваться"}
</button>
<p className="mt-4 text-center text-sm text-gray-500">
Уже есть аккаунт?{" "}
<Link to="/login" className="text-primary-600 hover:underline">
Войти
</Link>
</p>
</form>
</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useState, type FormEvent } from "react";
import { Link, useSearchParams } from "react-router-dom";
export default function ResetPasswordPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token") || "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [done, setDone] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
if (password !== confirm) {
setError("Пароли не совпадают");
return;
}
if (password.length < 8) {
setError("Пароль должен быть не менее 8 символов");
return;
}
if (!token) {
setError("Отсутствует токен сброса пароля");
return;
}
setLoading(true);
try {
const res = await fetch("/api/v1/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, new_password: password }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || "Ошибка сброса пароля");
}
setDone(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка сброса пароля");
} finally {
setLoading(false);
}
}
if (!token) {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm text-center">
<h2 className="mb-4 text-xl font-semibold text-red-600">
Неверная ссылка
</h2>
<p className="mb-4 text-gray-500">
Отсутствует токен сброса пароля. Проверьте ссылку в письме.
</p>
<Link to="/login" className="text-primary-600 hover:underline">
Вернуться на страницу входа
</Link>
</div>
</div>
);
}
if (done) {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm text-center">
<h2 className="mb-4 text-xl font-semibold text-green-600">
Пароль изменён
</h2>
<p className="mb-4 text-gray-500">
Теперь вы можете войти с новым паролем.
</p>
<Link to="/login" className="text-primary-600 hover:underline">
Войти
</Link>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="mb-6 text-center text-3xl font-bold text-primary-700">
VoIdeaAI
</h1>
<form
onSubmit={handleSubmit}
className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900"
>
<h2 className="mb-4 text-xl font-semibold">Новый пароль</h2>
{error && (
<p className="mb-4 rounded bg-red-50 p-2 text-sm text-red-600 dark:bg-red-950 dark:text-red-400">
{error}
</p>
)}
<label className="mb-2 block text-sm font-medium">
Новый пароль
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="mt-1 block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
</label>
<label className="mb-4 block text-sm font-medium">
Подтвердите пароль
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
className="mt-1 block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
</label>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
{loading ? "Сохранение..." : "Сохранить"}
</button>
</form>
</div>
</div>
);
}
+474
View File
@@ -0,0 +1,474 @@
import { useState, useEffect, type FormEvent } from "react";
import { useAuth } from "../auth/AuthContext";
import { apiFetch } from "../api/client";
import SEOHead from "../components/SEOHead";
import { broadcast, useBroadcastListener } from "../hooks/useBroadcastChannel";
export default function SettingsPage() {
const [tab, setTab] = useState<"profile" | "security" | "voice" | "theme" | "integrations" | "tariff">("profile");
const [saved, setSaved] = useState("");
const tabs = [
{ key: "profile" as const, label: "Профиль" },
{ key: "security" as const, label: "Безопасность" },
{ key: "voice" as const, label: "Голос" },
{ key: "theme" as const, label: "Тема" },
{ key: "integrations" as const, label: "Интеграции" },
{ key: "tariff" as const, label: "Тариф" },
];
return (
<div>
<SEOHead title="Настройки" />
<h1 className="mb-6 text-2xl font-bold">Настройки</h1>
<div className="mb-6 flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
tab === t.key
? "bg-primary-600 text-white"
: "border hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
}`}
>
{t.label}
</button>
))}
</div>
{saved && (
<div className="mb-4 rounded-lg bg-green-50 p-3 text-sm text-green-700 dark:bg-green-950 dark:text-green-400">
{saved}
</div>
)}
{tab === "profile" && <ProfileTab onSaved={() => setSaved("Профиль обновлён")} />}
{tab === "security" && <SecurityTab onSaved={() => setSaved("Пароль изменён")} />}
{tab === "voice" && <VoiceTab />}
{tab === "theme" && <ThemeTab />}
{tab === "integrations" && <IntegrationsTab />}
{tab === "tariff" && <TariffTab />}
</div>
);
}
function ProfileTab({ onSaved }: { onSaved: () => void }) {
const { user } = useAuth();
const [displayName, setDisplayName] = useState(user?.display_name || "");
const [sending, setSending] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setSending(true);
try {
await apiFetch("/users/me", {
method: "PATCH",
body: JSON.stringify({ display_name: displayName }),
});
onSaved();
} catch (err) {
console.error(err);
} finally {
setSending(false);
}
}
return (
<form onSubmit={handleSubmit} className="max-w-md space-y-4 rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<div>
<label htmlFor="settings-email" className="mb-1 block text-sm font-medium">Email</label>
<input
id="settings-email"
value={user?.email || ""}
disabled
className="block w-full rounded-lg border border-gray-300 bg-gray-100 p-2.5 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<div>
<label htmlFor="settings-display-name" className="mb-1 block text-sm font-medium">Отображаемое имя</label>
<input
id="settings-display-name"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<button
type="submit"
disabled={sending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
{sending ? "Сохранение…" : "Сохранить"}
</button>
</form>
);
}
function SecurityTab({ onSaved }: { onSaved: () => void }) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [sending, setSending] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
if (newPassword !== confirmPassword) {
setError("Пароли не совпадают");
return;
}
if (newPassword.length < 6) {
setError("Пароль должен быть не менее 6 символов");
return;
}
setSending(true);
try {
await apiFetch("/auth/change-password", {
method: "POST",
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
} finally {
setSending(false);
}
}
return (
<form onSubmit={handleSubmit} className="max-w-md space-y-4 rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="font-semibold">Смена пароля</h3>
{error && <p className="rounded bg-red-50 p-2 text-sm text-red-600 dark:bg-red-950 dark:text-red-400">{error}</p>}
<div>
<label htmlFor="settings-current-password" className="mb-1 block text-sm font-medium">Текущий пароль</label>
<input
id="settings-current-password"
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<div>
<label htmlFor="settings-new-password" className="mb-1 block text-sm font-medium">Новый пароль</label>
<input
id="settings-new-password"
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<div>
<label htmlFor="settings-confirm-password" className="mb-1 block text-sm font-medium">Подтвердите новый пароль</label>
<input
id="settings-confirm-password"
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<button
type="submit"
disabled={sending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
{sending ? "Смена…" : "Сменить пароль"}
</button>
</form>
);
}
const VOICE_PRESET_LABELS: Record<string, string> = {
standard: "Стандартный",
precise: "Чёткий",
fast: "Быстрый",
quiet: "Тихий режим",
expert: "Экспертный",
};
const VOICE_PRESET_DESCRIPTIONS: Record<string, string> = {
standard: "Сбалансированные настройки для повседневного использования",
precise: "Повышенная точность распознавания и верификации ответов",
fast: "Максимальная скорость ответа, минимальные проверки",
quiet: "Оптимизирован для работы в шумной обстановке",
expert: "Полная семантическая валидация и минимальный порог шума",
};
function VoiceTab() {
const [settings, setSettings] = useState<{
preset: string;
overrides: Record<string, unknown>;
available_presets: string[];
preset_values: Record<string, unknown>;
} | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState("");
useEffect(() => {
apiFetch<{
preset: string;
overrides: Record<string, unknown>;
available_presets: string[];
preset_values: Record<string, unknown>;
}>("/users/me/voice-settings")
.then(setSettings)
.catch(console.error);
}, []);
async function changePreset(preset: string) {
setSaving(true);
try {
const updated = await apiFetch<{
preset: string;
overrides: Record<string, unknown>;
available_presets: string[];
preset_values: Record<string, unknown>;
}>("/users/me/voice-settings", {
method: "PATCH",
body: JSON.stringify({ preset }),
});
setSettings(updated);
setSaved("Пресет применён");
setTimeout(() => setSaved(""), 3000);
} catch (e) { console.error(e); }
finally { setSaving(false); }
}
async function toggleOverride(key: string, value: boolean) {
if (!settings) return;
setSaving(true);
try {
const updated = await apiFetch<{
preset: string;
overrides: Record<string, unknown>;
available_presets: string[];
preset_values: Record<string, unknown>;
}>("/users/me/voice-settings", {
method: "PATCH",
body: JSON.stringify({ overrides: { [key]: value } }),
});
setSettings(updated);
} catch (e) { console.error(e); }
finally { setSaving(false); }
}
if (!settings) {
return <p className="text-sm text-gray-400">Загрузка</p>;
}
return (
<div className="max-w-2xl space-y-4">
{saved && (
<div className="rounded-lg bg-green-50 p-3 text-sm text-green-700 dark:bg-green-950 dark:text-green-400">
{saved}
</div>
)}
{/* Preset selector */}
<div className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 font-semibold">Пресет голосового ассистента</h3>
<div className="grid gap-3 sm:grid-cols-2">
{settings.available_presets.map((p) => (
<button
key={p}
onClick={() => changePreset(p)}
disabled={saving}
className={`rounded-xl border p-4 text-left transition ${
settings.preset === p
? "border-primary-500 bg-primary-50 dark:bg-primary-950"
: "hover:border-gray-300 dark:hover:border-gray-600"
} dark:border-gray-700`}
>
<p className="font-medium text-sm">{VOICE_PRESET_LABELS[p] || p}</p>
<p className="mt-1 text-xs text-gray-500">{VOICE_PRESET_DESCRIPTIONS[p] || ""}</p>
</button>
))}
</div>
</div>
{/* Current preset values */}
<div className="rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 font-semibold">Текущие параметры</h3>
<dl className="space-y-3 text-sm">
<div className="flex justify-between">
<dt className="text-gray-500">Пресет</dt>
<dd className="font-medium">{VOICE_PRESET_LABELS[settings.preset] || settings.preset}</dd>
</div>
{Object.entries(settings.preset_values).map(([key, value]) => (
<div key={key} className="flex justify-between items-center">
<dt className="text-gray-500">{key}</dt>
<dd className={typeof value === "boolean" ? "" : "font-mono text-xs"}>
{typeof value === "boolean" ? (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={value}
onChange={(e) => toggleOverride(key, e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600"
/>
{value ? "Да" : "Нет"}
</label>
) : (
String(value)
)}
</dd>
</div>
))}
</dl>
{Object.keys(settings.overrides).length > 0 && (
<div className="mt-4 rounded-lg bg-amber-50 p-3 text-xs text-amber-700 dark:bg-amber-950 dark:text-amber-400">
Есть индивидуальные переопределения: {Object.keys(settings.overrides).join(", ")}
</div>
)}
</div>
{/* Reset to defaults */}
<div className="rounded-xl border border-red-200 bg-white p-6 shadow-sm dark:border-red-900 dark:bg-gray-900">
<h3 className="mb-4 font-semibold text-red-700 dark:text-red-400">Сброс настроек</h3>
<p className="mb-4 text-sm text-gray-500">
Сбросьте все настройки голоса до заводских. Пресет станет «Стандартный», все переопределения удалятся.
</p>
<button
onClick={async () => {
if (!confirm("Сбросить все настройки голоса до стандартных?")) return;
setSaving(true);
try {
const updated = await apiFetch<{
preset: string;
overrides: Record<string, unknown>;
available_presets: string[];
preset_values: Record<string, unknown>;
}>("/users/me/voice-settings", {
method: "PATCH",
body: JSON.stringify({ reset: true }),
});
setSettings(updated);
setSaved("Настройки сброшены до стандартных");
setTimeout(() => setSaved(""), 3000);
} catch (e) { console.error(e); }
finally { setSaving(false); }
}}
disabled={saving}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
Сбросить настройки
</button>
</div>
</div>
);
}
function ThemeTab() {
const [dark, setDark] = useState(() => document.documentElement.classList.contains("dark"));
function toggleTheme() {
const next = !dark;
setDark(next);
if (next) {
document.documentElement.classList.add("dark");
localStorage.setItem("theme", "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("theme", "light");
}
broadcast({ type: "theme_change", payload: { theme: next ? "dark" : "light" } });
}
useBroadcastListener((event) => {
if (event.type === "theme_change") {
const isDark = event.payload.theme === "dark";
setDark(isDark);
if (isDark) {
document.documentElement.classList.add("dark");
localStorage.setItem("theme", "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("theme", "light");
}
}
});
return (
<div className="max-w-md space-y-4 rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="font-semibold">Оформление</h3>
<label className="flex items-center gap-3 text-sm">
<input
type="checkbox"
checked={dark}
onChange={toggleTheme}
className="h-4 w-4 rounded border-gray-300 text-primary-600"
/>
Тёмная тема
</label>
</div>
);
}
function IntegrationsTab() {
return (
<div className="max-w-md space-y-4 rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="font-semibold">Интеграции</h3>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Яндекс.Диск</p>
<p className="text-xs text-gray-500">Автоматическое сохранение идей</p>
</div>
<button className="rounded-lg bg-primary-600 px-4 py-2 text-xs font-medium text-white hover:bg-primary-700">
Подключить
</button>
</div>
<hr className="dark:border-gray-700" />
<p className="text-xs text-gray-400">Google Drive и Apple CloudKit по запросу.</p>
</div>
);
}
function TariffTab() {
const [plan, setPlan] = useState<{ name: string; status: string; expires_at: string | null } | null>(null);
useEffect(() => {
apiFetch<{ name: string; status: string; expires_at: string | null }>("/users/me/subscription")
.then(setPlan)
.catch(() => {});
}, []);
return (
<div className="max-w-md rounded-xl border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 font-semibold">Мой тариф</h3>
{plan ? (
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-500">План</dt>
<dd className="font-medium">{plan.name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Статус</dt>
<dd>{plan.status === "active" ? <span className="text-green-600">Активен</span> : plan.status}</dd>
</div>
{plan.expires_at && (
<div className="flex justify-between">
<dt className="text-gray-500">Действует до</dt>
<dd>{new Date(plan.expires_at).toLocaleDateString("ru-RU")}</dd>
</div>
)}
</dl>
) : (
<p className="text-sm text-gray-500">Информация о подписке недоступна. Вы используете бесплатный тариф.</p>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
export default function TermsPage() {
return (
<div className="prose prose-sm mx-auto max-w-3xl dark:prose-invert">
<h1>Пользовательское соглашение</h1>
<p className="text-gray-500">Последнее обновление: 1 января 2025 г.</p>
<h2>1. Термины</h2>
<p>
<strong>Сервис</strong> программный комплекс VoIdeaAI, доступный через веб-интерфейс.
</p>
<p>
<strong>Пользователь</strong> лицо, прошедшее регистрацию и использующее Сервис.
</p>
<h2>2. Регистрация и аккаунт</h2>
<ul>
<li>Пользователь обязуется предоставлять достоверные данные при регистрации</li>
<li>Запрещается передавать учётные данные третьим лицам</li>
<li>Сервис оставляет за собой право заблокировать аккаунт при нарушении условий</li>
</ul>
<h2>3. Интеллектуальная собственность</h2>
<p>
Все созданные пользователем идеи и материалы являются его интеллектуальной
собственностью. Сервис не претендует на права на созданный контент.
</p>
<h2>4. Ограничение ответственности</h2>
<p>
Сервис предоставляется «как есть». Мы не гарантируем бесперебойную работу
и не несём ответственности за возможные убытки, связанные с использованием Сервиса.
</p>
<h2>5. Изменение условий</h2>
<p>
Мы оставляем за собой право изменять условия соглашения. Пользователи будут
уведомлены об изменениях через электронную почту.
</p>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
export default function UserGuidePage() {
return (
<div className="prose prose-sm mx-auto max-w-3xl dark:prose-invert">
<h1>Руководство пользователя</h1>
<h2>Начало работы</h2>
<ol>
<li>Зарегистрируйтесь или войдите в систему</li>
<li>Создайте новую идею через кнопку «Новая»</li>
<li>Используйте голосовой ассистент для записи мыслей</li>
<li>Анализируйте идеи с помощью ролевых агентов</li>
</ol>
<h2>Голосовой ассистент</h2>
<p>
VoIdeaAI оснащён голосовым ассистентом. Дирижёр главный
агент, который координирует работу всех остальных.
</p>
<ul>
<li><strong>Голосовой ввод</strong> используйте микрофон для записи</li>
<li><strong>Текстовый ввод</strong> введите сообщение в поле ввода</li>
<li><strong>Голосовые команды</strong> команды для быстрых действий</li>
</ul>
<h2>Настройка голоса</h2>
<p>
В <strong>Настройки Голос</strong> вы найдёте пресеты, которые меняют поведение ассистента.
Вот что будет, если выбрать другой пресет:
</p>
<ul>
<li><strong>Стандартный</strong> сбалансированная работа для повседневных задач</li>
<li><strong>Чёткий</strong> ассистент будет дольше вслушиваться и тщательнее проверять ответы. Хорошо для важных идей</li>
<li><strong>Быстрый</strong> максимальная скорость, минимум проверок. Для быстрых заметок</li>
<li><strong>Тихий режим</strong> если вокруг шумно или вы говорите тихо, ассистент подстроится</li>
<li><strong>Экспертный</strong> полная проверка каждой идеи, минимальный порог шума. Для глубокого анализа</li>
</ul>
<p>
Попробуйте разные пресеты и выберите тот, который удобнее именно вам.
Дополнительные параметры можно переопределить вручную под вашу задачу.
</p>
<h2>Управление идеями</h2>
<p>
Все ваши идеи отображаются на главной странице. Вы можете:
</p>
<ul>
<li>Создавать новые идеи (текст или голос)</li>
<li>Редактировать и удалять идеи</li>
<li>Анализировать идеи через ролевых агентов</li>
<li>Делиться идеями (если включено)</li>
</ul>
<h2>Агенты-аналитики</h2>
<p>
После создания идеи вы можете запустить анализ. Ролевые агенты
оценят вашу идею с разных сторон:
</p>
<ul>
<li>Маркетолог, Финансист, Юрист, Технический директор и другие</li>
<li>Каждый агент возвращает структурированный отчёт</li>
<li>Результаты сохраняются в истории анализа</li>
</ul>
<h2>Голосовые команды</h2>
<p>
Настройте собственные голосовые команды на странице «Команды».
Команды позволяют быстро выполнять действия без навигации по меню.
</p>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { useState, useEffect } from "react";
import { apiFetch } from "../api/client";
interface Command {
id: string;
phrase: string;
action: string;
agent_name: string | null;
count: number;
is_active: boolean;
}
const BUILTIN_COMMANDS = [
{
phrase: "Стоп",
action: "stop",
description: "Остановить озвучивание ответа",
},
{
phrase: "Повтори / Ещё раз",
action: "repeat",
description: "Повторить последний ответ голосом",
},
{
phrase: "Уточнить / Поправь",
action: "clarify",
description: "Открыть диалог уточнения запроса",
},
];
export default function VoiceHelpPage() {
const [commands, setCommands] = useState<Command[]>([]);
const [phrase, setPhrase] = useState("");
const [action, setAction] = useState("stop");
const [agentName, setAgentName] = useState("");
useEffect(() => {
apiFetch<Command[]>("/voice/commands")
.then(setCommands)
.catch(() => {});
}, []);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!phrase.trim()) return;
try {
await apiFetch("/voice/commands", {
method: "POST",
body: JSON.stringify({
phrase: phrase.trim(),
action,
agent_name: agentName.trim() || null,
}),
});
setPhrase("");
setAgentName("");
const updated = await apiFetch<Command[]>("/voice/commands");
setCommands(updated);
} catch {
/* silent */
}
}
async function handleDelete(id: string) {
try {
await apiFetch(`/voice/commands/${id}`, { method: "DELETE" });
setCommands((prev) => prev.filter((c) => c.id !== id));
} catch {
/* silent */
}
}
return (
<div className="mx-auto max-w-2xl">
<h1 className="mb-6 text-2xl font-bold">Голосовые команды</h1>
<section className="mb-8">
<h2 className="mb-3 text-lg font-semibold">Встроенные команды</h2>
<div className="space-y-2">
{BUILTIN_COMMANDS.map((cmd) => (
<div
key={cmd.action}
className="flex items-center justify-between rounded-lg border bg-white px-4 py-3 dark:border-gray-700 dark:bg-gray-900"
>
<div>
<span className="font-medium text-primary-600">{cmd.phrase}</span>
<span className="ml-2 text-sm text-gray-500"> {cmd.description}</span>
</div>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-800">
{cmd.action}
</span>
</div>
))}
</div>
</section>
<section className="mb-8">
<h2 className="mb-3 text-lg font-semibold">
Мои команды {commands.length > 0 && `(${commands.length})`}
</h2>
{commands.length === 0 ? (
<p className="text-sm text-gray-400">
У вас пока нет пользовательских команд. Добавьте свою первую команду ниже.
</p>
) : (
<div className="space-y-2">
{commands.map((cmd) => (
<div
key={cmd.id}
className="flex items-center justify-between rounded-lg border bg-white px-4 py-3 dark:border-gray-700 dark:bg-gray-900"
>
<div>
<span className="font-medium">{cmd.phrase}</span>
<span className="ml-2 rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-800">
{cmd.action}
</span>
{cmd.agent_name && (
<span className="ml-2 text-xs text-gray-400"> {cmd.agent_name}</span>
)}
<span className="ml-2 text-xs text-gray-400">
({cmd.count}×)
</span>
</div>
<button
type="button"
onClick={() => handleDelete(cmd.id)}
className="text-sm text-red-500 hover:text-red-700"
>
Удалить
</button>
</div>
))}
</div>
)}
</section>
<section>
<h2 className="mb-3 text-lg font-semibold">Добавить команду</h2>
<form onSubmit={handleAdd} className="space-y-3">
<input
type="text"
placeholder="Фраза (например, «Покажи идеи»)"
value={phrase}
onChange={(e) => setPhrase(e.target.value)}
className="w-full rounded-lg border bg-white px-4 py-2 text-sm outline-none focus:border-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
<div className="flex gap-3">
<select
value={action}
onChange={(e) => setAction(e.target.value)}
className="rounded-lg border bg-white px-3 py-2 text-sm outline-none dark:border-gray-600 dark:bg-gray-800"
>
<option value="stop">Стоп</option>
<option value="repeat">Повтори</option>
<option value="clarify">Уточнить</option>
</select>
<input
type="text"
placeholder="Имя агента (необязательно)"
value={agentName}
onChange={(e) => setAgentName(e.target.value)}
className="flex-1 rounded-lg border bg-white px-4 py-2 text-sm outline-none focus:border-primary-500 dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<button
type="submit"
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
Добавить
</button>
</form>
</section>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { create } from "zustand";
import { apiFetch, setTokens, clearTokens, isAuthenticated } from "../api/client";
export interface User {
id: string;
display_name: string;
email: string;
is_superuser: boolean;
role?: string;
is_owner?: boolean;
permissions?: Record<string, boolean> | null;
}
interface AuthState {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, displayName: string) => Promise<void>;
logout: () => void;
loadUser: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
loading: true,
login: async (email: string, password: string) => {
const data = await apiFetch<{ access_token: string; refresh_token: string }>(
"/auth/login",
{ method: "POST", body: JSON.stringify({ email, password }) }
);
setTokens(data.access_token, data.refresh_token);
const me = await apiFetch<User>("/users/me");
set({ user: me });
},
register: async (email: string, password: string, displayName: string) => {
const data = await apiFetch<{ access_token: string; refresh_token: string }>(
"/auth/register",
{
method: "POST",
body: JSON.stringify({
email,
password,
display_name: displayName,
accepted_terms: true,
}),
}
);
setTokens(data.access_token, data.refresh_token);
const me = await apiFetch<User>("/users/me");
set({ user: me });
},
logout: () => {
clearTokens();
set({ user: null });
},
loadUser: async () => {
if (isAuthenticated()) {
try {
const me = await apiFetch<User>("/users/me");
set({ user: me, loading: false });
} catch {
clearTokens();
set({ loading: false });
}
} else {
set({ loading: false });
}
},
}));
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
+25
View File
@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
primary: {
50: "#eef2ff",
100: "#e0e7ff",
200: "#c7d2fe",
300: "#a5b4fc",
400: "#818cf8",
500: "#6366f1",
600: "#4f46e5",
700: "#4338ca",
800: "#3730a3",
900: "#312e81",
950: "#1e1b4b",
},
},
},
},
plugins: [require("@tailwindcss/typography")],
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+78
View File
@@ -0,0 +1,78 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.svg", "icons/*.png"],
workbox: {
runtimeCaching: [
{
urlPattern: /^https?:\/\/.*\/api\/v1\/ideas\/?/i,
handler: "NetworkFirst",
options: {
cacheName: "voidea-ideas",
expiration: { maxEntries: 50, maxAgeSeconds: 86400 },
networkTimeoutSeconds: 5,
},
},
{
urlPattern: /^https?:\/\/.*\/api\/v1\/voice\/sessions/i,
handler: "NetworkFirst",
options: {
cacheName: "voidea-sessions",
expiration: { maxEntries: 20, maxAgeSeconds: 3600 },
networkTimeoutSeconds: 5,
},
},
{
urlPattern: /^https?:\/\/.*\/api\/v1\/config\/public/i,
handler: "CacheFirst",
options: {
cacheName: "voidea-config",
expiration: { maxEntries: 1, maxAgeSeconds: 86400 },
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|ico|webp)$/i,
handler: "CacheFirst",
options: {
cacheName: "voidea-images",
expiration: { maxEntries: 100, maxAgeSeconds: 604800 },
},
},
{
urlPattern: /\.(?:js|css|woff2?)$/i,
handler: "StaleWhileRevalidate",
options: {
cacheName: "voidea-static",
expiration: { maxEntries: 100, maxAgeSeconds: 604800 },
},
},
],
},
manifest: {
name: "VoIdeaAI",
short_name: "VoIdeaAI",
description: "VoIdeaAI — идеи рождаются вслух, решения приходят мгновенно!",
theme_color: "#1e1b4b",
background_color: "#f9fafb",
display: "standalone",
start_url: "/",
icons: [
{ src: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512x512.png", sizes: "512x512", type: "image/png" },
],
},
}),
],
server: {
port: 3000,
proxy: {
"/api": "http://localhost:8020",
},
},
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: [],
},
});