initial commit
Build and Push Docker Images / build-and-push (push) Failing after 3m33s

This commit is contained in:
admin committed 2026-11-07 16:39:43 +03:00
commit 46af290db3
100 files changed
+16663

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
# Node modules (устанавливаются в Docker)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Next.js build output (собирается в Docker)
.next/
out/
# Git
.git
.gitignore
# IDE
.vscode/
.idea/
*.swp
*.swo
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Документация
*.md
!README.md
# Тесты
tests/
.pytest_cache/
.coverage
# Прочее
.env*
!.env.example
*.log
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+55
View File
@@ -0,0 +1,55 @@
# Этап 1: Установка зависимостей
FROM node:20-alpine AS deps
WORKDIR /app
# Копируем package.json и package-lock.json
COPY package.json package-lock.json* ./
# Устанавливаем зависимости
RUN npm ci --only=production
# Этап 2: Сборка приложения
FROM node:20-alpine AS builder
WORKDIR /app
# Копируем зависимости из предыдущего этапа
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Переменные окружения для сборки (если нужны)
ENV NEXT_TELEMETRY_DISABLED=1
# Сборка Next.js
RUN npm run build
# Этап 3: Production runtime
FROM node:20-alpine AS runner
WORKDIR /app
# Переменные окружения
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME="0.0.0.0"
# Создаём группу и пользователя для безопасности
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Копируем собранные файлы из builder
# Standalone output содержит только необходимое для запуска
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Устанавливаем владельца файлов
RUN chown -R nextjs:nodejs /app
# Переключаемся на непривилегированного пользователя
USER nextjs
# Открываем порт
EXPOSE 3000
# Команда запуска
CMD ["node", "server.js"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone', // Критично для Docker!
reactStrictMode: true,
}
module.exports = nextConfig
+17
View File
@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
turbopack: {
rules: {
"*.css": {
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
export default nextConfig;
+7619
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.20",
"@tiptap/extension-blockquote": "^3.32.2",
"@tiptap/extension-bubble-menu": "^3.32.2",
"@tiptap/extension-color": "^3.32.2",
"@tiptap/extension-font-family": "^3.32.2",
"@tiptap/extension-text-style": "^3.32.2",
"@tiptap/extension-underline": "^3.32.2",
"@tiptap/react": "^3.32.2",
"@tiptap/starter-kit": "^3.32.2",
"axios": "^1.21.0",
"lucide-react": "^1.67.0",
"next": "16.4.4",
"react": "19.3.0",
"react-dom": "19.3.0",
"zustand": "^5.0.16"
},
"devDependencies": {
"@tailwindcss/turbopack": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.4.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+5
View File
@@ -0,0 +1,5 @@
import DiffTest from '@/components/test/DiffTest';
export default function DiffTestPage() {
return <DiffTest />;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+27
View File
@@ -0,0 +1,27 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useAppStore } from '@/stores/appStore';
import AuthScreen from '@/components/auth/AuthScreen';
import LeftPanel from '@/components/sidebar/LeftPanel';
import StoryBiblePanel from '@/components/sidebar/StoryBiblePanel';
import { Editor } from '@/components/editor/Editor';
import ModelSettingsPanel from '@/components/settings/ModelSettings';
export default function Home() {
const { isAuthenticated, isLoadingAuth } = useAppStore();
if (isLoadingAuth) {
return null; // AuthScreen показывает спиннер при checkAuth
}
if (!isAuthenticated) {
return <AuthScreen />;
}
return (
<div className="flex h-screen bg-gray-900 overflow-hidden">
{/* Left panel - Projects + Document Tree */}
<div className="w-80 border-r border-gray-700 flex-shrink-0">
<LeftPanel />
</div>
{/* Middle panel - Story Bible */}
<div className="w-80 border-r border-gray-700 flex-shrink-0">
<StoryBiblePanel />
</div>
{/* Main content - Editor */}
<div className="flex-1 flex flex-col min-w-0">
<Editor />
</div>
{/* Model settings button */}
<ModelSettingsPanel />
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import AITest from '@/components/test/AITest';
export default function TestPage() {
return (
<div className="min-h-screen bg-gray-900 p-8">
<div className="max-w-2xl mx-auto">
<AITest />
</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useState } from 'react';
import { useAppStore } from '@/stores/appStore';
import { BookOpen, Loader2 } from 'lucide-react';
export default function AuthScreen() {
const { login, register, isLoadingAuth } = useAppStore();
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
if (isLogin) {
await login(username, password);
} else {
await register(username, email, password);
}
} catch (err: any) {
setError(err.message || 'Ошибка авторизации');
} finally {
setIsSubmitting(false);
}
};
if (isLoadingAuth) {
return (
<div className="flex h-screen items-center justify-center bg-gray-900 text-gray-100">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
</div>
);
}
return (
<div className="flex h-screen items-center justify-center bg-gray-900 p-4">
<div className="w-full max-w-md bg-gray-800 rounded-xl shadow-2xl border border-gray-700 overflow-hidden">
<div className="p-8 text-center border-b border-gray-700">
<BookOpen className="w-12 h-12 text-blue-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-100">Writer IDE</h1>
<p className="text-gray-400 mt-2">Ваше пространство для творчества</p>
</div>
<div className="p-8">
<div className="flex mb-6 bg-gray-700 rounded-lg p-1">
<button
onClick={() => { setIsLogin(true); setError(''); }}
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
isLogin ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-gray-200'
}`}
>
Вход
</button>
<button
onClick={() => { setIsLogin(false); setError(''); }}
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
!isLogin ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-gray-200'
}`}
>
Регистрация
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Имя пользователя</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full p-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
required
/>
</div>
{!isLogin && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
required
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Пароль</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
required
/>
</div>
{error && (
<div className="p-3 bg-red-900/30 border border-red-800 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
{isLogin ? 'Войти' : 'Создать аккаунт'}
</button>
</form>
<p className="text-center text-xs text-gray-500 mt-6">
{isLogin ? 'Нет аккаунта? ' : 'Уже есть аккаунт? '}
<button
onClick={() => { setIsLogin(!isLogin); setError(''); }}
className="text-blue-400 hover:text-blue-300"
>
{isLogin ? 'Зарегистрироваться' : 'Войти'}
</button>
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
'use client';
import { Check, X } from 'lucide-react';
interface AIDiffViewProps {
originalText: string;
aiText: string;
onAccept: () => void;
onReject: () => void;
}
export default function AIDiffView({ originalText, aiText, onAccept, onReject }: AIDiffViewProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">Предпросмотр изменений</h2>
<div className="flex gap-2">
<button
onClick={onAccept}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
<Check className="w-4 h-4" />
Принять
</button>
<button
onClick={onReject}
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
<X className="w-4 h-4" />
Отклонить
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6 grid grid-cols-2 gap-6">
{/* Original */}
<div className="flex flex-col">
<div className="flex items-center gap-2 mb-3">
<div className="w-3 h-3 rounded-full bg-red-500" />
<h3 className="text-sm font-semibold text-gray-300 uppercase">Оригинал</h3>
</div>
<div className="flex-1 p-4 bg-red-900/20 border border-red-800/30 rounded-lg text-gray-100 whitespace-pre-wrap leading-relaxed">
{originalText}
</div>
</div>
{/* AI Suggestion */}
<div className="flex flex-col">
<div className="flex items-center gap-2 mb-3">
<div className="w-3 h-3 rounded-full bg-green-500" />
<h3 className="text-sm font-semibold text-gray-300 uppercase">Предложение AI</h3>
</div>
<div className="flex-1 p-4 bg-green-900/20 border border-green-800/30 rounded-lg text-gray-100 whitespace-pre-wrap leading-relaxed">
{aiText}
</div>
</div>
</div>
{/* Footer hint */}
<div className="p-4 border-t border-gray-700 bg-gray-850">
<p className="text-sm text-gray-400 text-center">
Нажмите "Принять" для замены выделенного текста или "Отклонить" для отмены
</p>
</div>
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { useState } from 'react';
import { Editor } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
import { Sparkles, RefreshCw, Expand, Minimize, MessageSquare, PlusCircle } from 'lucide-react';
interface AIMenuProps {
editor: Editor | null;
onAction: (action: string, selectedText: string) => void;
}
type AIAction = 'rewrite' | 'expand' | 'simplify' | 'custom' | 'add_to_bible';
export default function AIMenu({ editor, onAction }: AIMenuProps) {
const [showDropdown, setShowDropdown] = useState(false);
if (!editor) return null;
const handleAction = (action: AIAction) => {
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
if (!selectedText.trim()) {
return;
}
onAction(action, selectedText);
setShowDropdown(false);
};
return (
<BubbleMenu
editor={editor}
shouldShow={({ editor, from, to }) => {
return from !== to && !editor.isActive('image');
}}
>
<div className="relative inline-block">
{/* Кнопка вызова AI меню */}
<button
onClick={() => setShowDropdown(!showDropdown)}
className="flex items-center gap-2 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg shadow-lg transition-colors text-sm font-medium"
title="AI действия"
>
<Sparkles className="w-4 h-4" />
AI
</button>
{/* Dropdown с действиями */}
{showDropdown && (
<div className="absolute top-full left-0 mt-2 w-56 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50">
<div className="p-2 space-y-1">
<button
onClick={() => handleAction('rewrite')}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
>
<RefreshCw className="w-4 h-4 text-blue-400" />
<div>
<div className="text-sm font-medium">Переписать</div>
<div className="text-xs text-gray-400">Сделать более живым</div>
</div>
</button>
<button
onClick={() => handleAction('expand')}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
>
<Expand className="w-4 h-4 text-green-400" />
<div>
<div className="text-sm font-medium">Расширить</div>
<div className="text-xs text-gray-400">Добавить деталей</div>
</div>
</button>
<button
onClick={() => handleAction('simplify')}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
>
<Minimize className="w-4 h-4 text-yellow-400" />
<div>
<div className="text-sm font-medium">Упростить</div>
<div className="text-xs text-gray-400">Сделать короче</div>
</div>
</button>
<div className="border-t border-gray-700 my-1" />
<button
onClick={() => handleAction('custom')}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
>
<MessageSquare className="w-4 h-4 text-purple-400" />
<div>
<div className="text-sm font-medium">Свой промпт...</div>
<div className="text-xs text-gray-400">Указать задачу</div>
</div>
</button>
<button
onClick={() => handleAction('add_to_bible')}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
>
<PlusCircle className="w-4 h-4 text-pink-400" />
<div>
<div className="text-sm font-medium">Добавить в Story Bible</div>
<div className="text-xs text-gray-400">Извлечь сущности</div>
</div>
</button>
</div>
</div>
)}
</div>
</BubbleMenu>
);
}
@@ -0,0 +1,237 @@
'use client';
import { useState } from 'react';
import { X, User, MapPin, Calendar, Check } from 'lucide-react';
interface ExtractedEntity {
name: string;
description: string;
traits?: string[];
appearance?: string;
role?: string;
atmosphere?: string;
type?: string;
importance?: string;
title?: string;
}
interface AddToBibleModalProps {
extractedEntities: {
new_characters: ExtractedEntity[];
new_locations: ExtractedEntity[];
new_events: ExtractedEntity[];
};
onConfirm: (selected: {
characters: ExtractedEntity[];
locations: ExtractedEntity[];
events: ExtractedEntity[];
}) => void;
onClose: () => void;
}
export default function AddToBibleModal({ extractedEntities, onConfirm, onClose }: AddToBibleModalProps) {
const [selectedChars, setSelectedChars] = useState<Set<number>>(
new Set(extractedEntities.new_characters.map((_, i) => i))
);
const [selectedLocs, setSelectedLocs] = useState<Set<number>>(
new Set(extractedEntities.new_locations.map((_, i) => i))
);
const [selectedEvents, setSelectedEvents] = useState<Set<number>>(
new Set(extractedEntities.new_events.map((_, i) => i))
);
const handleConfirm = () => {
onConfirm({
characters: extractedEntities.new_characters.filter((_, i) => selectedChars.has(i)),
locations: extractedEntities.new_locations.filter((_, i) => selectedLocs.has(i)),
events: extractedEntities.new_events.filter((_, i) => selectedEvents.has(i)),
});
};
const totalSelected = selectedChars.size + selectedLocs.size + selectedEvents.size;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">Добавить в Story Bible</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200">
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6 space-y-6">
{/* Characters */}
{extractedEntities.new_characters.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-100 flex items-center gap-2 mb-3">
<User className="w-5 h-5 text-blue-400" />
Персонажи ({extractedEntities.new_characters.length})
</h3>
<div className="space-y-2">
{extractedEntities.new_characters.map((char, idx) => (
<label
key={idx}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedChars.has(idx)
? 'bg-blue-900/20 border-blue-600'
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
}`}
>
<input
type="checkbox"
checked={selectedChars.has(idx)}
onChange={(e) => {
const newSet = new Set(selectedChars);
if (e.target.checked) {
newSet.add(idx);
} else {
newSet.delete(idx);
}
setSelectedChars(newSet);
}}
className="mt-1"
/>
<div className="flex-1">
<div className="font-semibold text-gray-100">{char.name}</div>
<div className="text-sm text-gray-400 mt-1">{char.description}</div>
{char.traits && char.traits.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{char.traits.map((trait, i) => (
<span key={i} className="px-2 py-0.5 bg-blue-900/30 text-blue-300 text-xs rounded">
{trait}
</span>
))}
</div>
)}
</div>
</label>
))}
</div>
</div>
)}
{/* Locations */}
{extractedEntities.new_locations.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-100 flex items-center gap-2 mb-3">
<MapPin className="w-5 h-5 text-green-400" />
Локации ({extractedEntities.new_locations.length})
</h3>
<div className="space-y-2">
{extractedEntities.new_locations.map((loc, idx) => (
<label
key={idx}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedLocs.has(idx)
? 'bg-green-900/20 border-green-600'
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
}`}
>
<input
type="checkbox"
checked={selectedLocs.has(idx)}
onChange={(e) => {
const newSet = new Set(selectedLocs);
if (e.target.checked) {
newSet.add(idx);
} else {
newSet.delete(idx);
}
setSelectedLocs(newSet);
}}
className="mt-1"
/>
<div className="flex-1">
<div className="font-semibold text-gray-100">{loc.name}</div>
<div className="text-sm text-gray-400 mt-1">{loc.description}</div>
{loc.atmosphere && (
<div className="text-xs text-gray-500 mt-2 italic">{loc.atmosphere}</div>
)}
</div>
</label>
))}
</div>
</div>
)}
{/* Events */}
{extractedEntities.new_events.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-100 flex items-center gap-2 mb-3">
<Calendar className="w-5 h-5 text-purple-400" />
События ({extractedEntities.new_events.length})
</h3>
<div className="space-y-2">
{extractedEntities.new_events.map((event, idx) => (
<label
key={idx}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedEvents.has(idx)
? 'bg-purple-900/20 border-purple-600'
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
}`}
>
<input
type="checkbox"
checked={selectedEvents.has(idx)}
onChange={(e) => {
const newSet = new Set(selectedEvents);
if (e.target.checked) {
newSet.add(idx);
} else {
newSet.delete(idx);
}
setSelectedEvents(newSet);
}}
className="mt-1"
/>
<div className="flex-1">
<div className="font-semibold text-gray-100">{event.name || event.title}</div>
<div className="text-sm text-gray-400 mt-1">{event.description}</div>
</div>
</label>
))}
</div>
</div>
)}
{/* Empty state */}
{extractedEntities.new_characters.length === 0 &&
extractedEntities.new_locations.length === 0 &&
extractedEntities.new_events.length === 0 && (
<div className="text-center text-gray-400 py-8">
<p>Новых сущностей не обнаружено</p>
<p className="text-sm mt-1">Попробуйте выделить другой текст</p>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 flex items-center justify-between">
<p className="text-sm text-gray-400">
Выбрано: {totalSelected} {totalSelected === 1 ? 'сущность' : totalSelected <= 4 ? 'сущности' : 'сущностей'}
</p>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
>
Отмена
</button>
<button
onClick={handleConfirm}
disabled={totalSelected === 0}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-gray-600 disabled:cursor-not-allowed flex items-center gap-2"
>
<Check className="w-4 h-4" />
Добавить
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
'use client';
import { useEditor } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
import { Editor } from '@tiptap/react';
import { ReactNode, useEffect, useRef } from 'react';
interface BubbleMenuWrapperProps {
editor: Editor | null;
children: ReactNode;
shouldShow?: (props: { editor: Editor; from: number; to: number }) => boolean;
tippyOptions?: {
duration?: number;
moveTransition?: string;
};
}
export function BubbleMenuWrapper({
editor,
children,
shouldShow,
tippyOptions,
}: BubbleMenuWrapperProps) {
const bubbleMenuRef = useRef<HTMLDivElement>(null);
// Создаем BubbleMenu extension
const bubbleMenuExtension = BubbleMenu.configure({
shouldShow: shouldShow || ({ from, to }) => from !== to,
tippyOptions: {
duration: 100,
moveTransition: 'transform 0.15s ease-out',
...tippyOptions,
},
});
// Добавляем extension к редактору (если еще не добавлен)
useEffect(() => {
if (!editor) return;
const hasBubbleMenu = editor.options.extensions.some(
(ext: { name: string }) => ext.name === 'bubbleMenu'
);
if (!hasBubbleMenu) {
editor.extendGlobalExtension?.(bubbleMenuExtension);
}
}, [editor, bubbleMenuExtension]);
if (!editor) return null;
return (
<div ref={bubbleMenuRef}>
{children}
</div>
);
}
+480
View File
@@ -0,0 +1,480 @@
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import type { Editor as TiptapEditor } from '@tiptap/core';
import { Toolbar } from './Toolbar';
import AIMenu from './AIMenu';
import AddToBibleModal from './AddToBibleModal';
import StarterKit from '@tiptap/starter-kit';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
// StarterKit уже включает underline и blockquote, поэтому не добавляем их отдельно
import { FontFamily } from '@tiptap/extension-font-family';
import { FontSize } from '@/lib/tiptap-extensions/FontSize';
import { useEffect, useRef, useCallback, useState } from 'react';
import { useAppStore } from '@/stores/appStore';
import { generateText, autoUpdateBible, extractEntities } from '@/lib/ai-api';
import AIDiffView from './AIDiffView';
import { Loader2, AlertCircle } from 'lucide-react';
// Расширяем Window для глобального callback
declare global {
interface Window {
saveCurrentEditorContent?: () => Promise<void>;
}
}
interface ExtractedEntity {
name: string;
description: string;
traits?: string[];
appearance?: string;
role?: string;
atmosphere?: string;
type?: string;
importance?: string;
title?: string;
}
interface ExtractedEntitiesData {
new_characters: ExtractedEntity[];
new_locations: ExtractedEntity[];
new_events: ExtractedEntity[];
}
export function Editor() {
const {
documents,
activeDocumentId,
activeProjectId,
updateDocumentContent,
loadStoryBible,
autoUpdateBible: autoUpdateEnabled,
isSaving
} = useAppStore();
// Находим активный документ
const activeDocument = documents.find(d => d.id === activeDocumentId);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const bibleUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const editorRef = useRef<TiptapEditor | null>(null);
// AI workflow state
const [selectedText, setSelectedText] = useState('');
const [aiGeneratedText, setAiGeneratedText] = useState('');
const [showDiffView, setShowDiffView] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectionRange, setSelectionRange] = useState<{ from: number; to: number } | null>(null);
// State для модального окна добавления в Story Bible
const [showAddToBibleModal, setShowAddToBibleModal] = useState(false);
const [extractedEntities, setExtractedEntities] = useState<{
new_characters: ExtractedEntity[];
new_locations: ExtractedEntity[];
new_events: ExtractedEntity[];
} | null>(null);
// Функция для извлечения чистого текста из HTML
const getPlainText = (html: string): string => {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
};
// Функция для debounced обновления контента с сохранением на backend
const debouncedUpdate = useCallback(
(html: string) => {
if (!activeProjectId || !activeDocumentId) return;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(async () => {
await updateDocumentContent(activeProjectId, activeDocumentId, html);
// Автообновление Story Bible (с задержкой после сохранения)
if (autoUpdateEnabled) {
if (bibleUpdateTimeoutRef.current) {
clearTimeout(bibleUpdateTimeoutRef.current);
}
bibleUpdateTimeoutRef.current = setTimeout(async () => {
try {
const plainText = getPlainText(html);
if (plainText.trim().length > 50) {
const result = await autoUpdateBible(
activeProjectId,
plainText,
autoUpdateEnabled
);
if (result.updated) {
console.log('Story Bible updated:', result.message);
// Перезагружаем Story Bible в store
loadStoryBible(activeProjectId);
}
}
} catch (error) {
console.error('Auto-update Bible error:', error);
}
}, 3000); // Задержка 3 секунды после сохранения
}
}, 1000); // Задержка 1 секунда для сохранения
},
[activeProjectId, activeDocumentId, updateDocumentContent, autoUpdateEnabled, loadStoryBible]
);
// Функция для немедленного сохранения текущего контента
const saveCurrentContent = useCallback(async (ed: TiptapEditor | null) => {
const currentEditor = ed || editorRef.current;
if (currentEditor && activeProjectId && activeDocumentId) {
const currentContent = currentEditor.getHTML();
// Очищаем таймер debounce
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
// Немедленно сохраняем
try {
await updateDocumentContent(activeProjectId, activeDocumentId, currentContent);
console.log('[Editor] Content saved before switching:', currentContent.substring(0, 100));
} catch (error) {
console.error('[Editor] Failed to save before switching:', error);
}
}
}, [activeProjectId, activeDocumentId, updateDocumentContent]);
// Очистка таймеров при размонтировании
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (bibleUpdateTimeoutRef.current) {
clearTimeout(bibleUpdateTimeoutRef.current);
}
};
}, []);
// Обработчик действий AI
const handleAIAction = async (action: string, text: string) => {
if (!editor) return;
const { from, to } = editor.state.selection;
setSelectedText(text);
setSelectionRange({ from, to });
// Специальная обработка для add_to_bible
if (action === 'add_to_bible') {
setIsLoading(true);
setError(null);
try {
const result = await extractEntities(text);
setExtractedEntities(result.entities);
setShowAddToBibleModal(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка при извлечении сущностей');
console.error('Extract entities error:', err);
} finally {
setIsLoading(false);
}
return;
}
setIsLoading(true);
setError(null);
try {
let prompt = '';
switch (action) {
case 'rewrite':
prompt = `Перепиши следующий текст, сделай его более живым, образным и кинематографичным. Сохрани основной смысл, но улучши стиль:\n\n${text}`;
break;
case 'expand':
prompt = `Расширь следующий текст, добавь больше деталей, описаний, сенсорных деталей (запахи, звуки, ощущения). Увеличь объем примерно в 1.5 раза:\n\n${text}`;
break;
case 'simplify':
prompt = `Упрости следующий текст, сделай его более кратким и понятным. Убери лишние слова и сложные конструкции, сохрани основной смысл:\n\n${text}`;
break;
case 'custom':
prompt = `Улучши следующий текст:\n\n${text}`;
break;
default:
prompt = `Улучши следующий текст:\n\n${text}`;
}
const generatedText = await generateText({
prompt,
preset: 'creative_draft',
taskType: 'generation',
selected_text: text,
});
setAiGeneratedText(generatedText);
setShowDiffView(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка при генерации');
console.error('AI generation error:', err);
} finally {
setIsLoading(false);
}
};
const editor = useEditor({
extensions: [
StarterKit, // включает underline и blockquote
TextStyle,
Color,
FontFamily,
FontSize,
],
content: activeDocument?.content || '',
onUpdate: ({ editor: ed }) => {
// Sync editor ref
editorRef.current = ed;
if (activeProjectId && activeDocumentId) {
const content = ed.getHTML();
console.log('[Editor] onUpdate - content length:', content.length);
console.log('[Editor] onUpdate - content preview:', content.substring(0, 150));
debouncedUpdate(content);
}
},
editorProps: {
attributes: {
class: 'prose prose-invert max-w-none focus:outline-none font-serif text-lg leading-relaxed',
},
},
immediatelyRender: false,
onCreate: ({ editor: ed }) => {
editorRef.current = ed;
},
});
// Регистрируем глобальный callback для сохранения
useEffect(() => {
if (editor) {
editorRef.current = editor;
// Создаём обёртку без параметров для глобального доступа
window.saveCurrentEditorContent = () => saveCurrentContent(editor);
return () => {
delete window.saveCurrentEditorContent;
};
}
}, [editor, saveCurrentContent]);
// Синхронизация редактора при смене активного документа
useEffect(() => {
if (!editor || !activeDocument) return;
console.log('=== Loading document ===');
console.log('[Editor] Document ID:', activeDocument.id);
console.log('[Editor] Document title:', activeDocument.title);
console.log('[Editor] Content length:', activeDocument.content?.length || 0);
console.log('[Editor] Content preview:', activeDocument.content?.substring(0, 200));
// Проверяем, что контент действительно изменился
const currentContent = editor.getHTML();
const newContent = activeDocument.content || '';
// Загружаем только если контент действительно другой
if (currentContent !== newContent) {
console.log('[Editor] Content differs, loading new content');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(editor.commands as any).setContent(newContent, false, { preserveWhitespace: 'full' });
} else {
console.log('[Editor] Content is the same, skipping reload');
}
}, [activeDocumentId, editor, activeDocument]);
// Если нет активного документа или это не сцена
if (!activeDocument || activeDocument.type !== 'scene') {
return (
<div className="flex-1 flex items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-lg">Выберите сцену для редактирования</p>
<p className="text-sm mt-1">Кликните на сцену в дереве документов</p>
</div>
</div>
);
}
// Обработчики для AIDiffView
const handleAcceptChanges = () => {
if (editor && selectionRange && aiGeneratedText) {
editor
.chain()
.focus()
.setTextSelection({ from: selectionRange.from, to: selectionRange.to })
.deleteRange({ from: selectionRange.from, to: selectionRange.to })
.insertContent(aiGeneratedText)
.run();
}
setShowDiffView(false);
setAiGeneratedText('');
setSelectedText('');
setSelectionRange(null);
};
const handleRejectChanges = () => {
setShowDiffView(false);
setAiGeneratedText('');
setSelectedText('');
setSelectionRange(null);
};
// Обработчик подтверждения добавления в Story Bible
const handleAddToBibleConfirm = async (selected: {
characters: ExtractedEntity[];
locations: ExtractedEntity[];
events: ExtractedEntity[];
}) => {
// Получаем данные из store
const state = useAppStore.getState();
const { activeProjectId, storyBible, updateStoryBible } = state;
if (!activeProjectId || !storyBible) return;
try {
// Создаем копию текущей Bible
const updatedBible = JSON.parse(JSON.stringify(storyBible));
// Добавляем выбранные персонажи
selected.characters.forEach(char => {
updatedBible.characters.push({
id: crypto.randomUUID(),
name: char.name,
description: char.description || '',
traits: char.traits || [],
appearance: char.appearance || '',
role: char.role || 'supporting'
});
});
// Добавляем выбранные локации
selected.locations.forEach(loc => {
updatedBible.locations.push({
id: crypto.randomUUID(),
name: loc.name,
description: loc.description || '',
atmosphere: loc.atmosphere || '',
type: loc.type || 'other'
});
});
// Добавляем выбранные события
selected.events.forEach(event => {
updatedBible.events.push({
id: crypto.randomUUID(),
title: event.title || event.name || '',
description: event.description || '',
importance: event.importance || 'minor'
});
});
// Сохраняем обновленную Bible через API
await updateStoryBible(activeProjectId, updatedBible);
// Закрываем модальное окно
setShowAddToBibleModal(false);
setExtractedEntities(null);
// Логируем результат
const totalAdded = selected.characters.length + selected.locations.length + selected.events.length;
console.log(`Добавлено в Story Bible: ${totalAdded} сущностей`);
} catch (error) {
console.error('Failed to add to Bible:', error);
setError('Ошибка при добавлении в Story Bible');
}
};
// Проверка на наличие активного проекта
if (!activeProjectId) {
return (
<div className="flex-1 flex items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-lg">Выберите проект</p>
<p className="text-sm mt-1">Или создайте новый в левой панели</p>
</div>
</div>
);
}
// Проверка на наличие активного документа
if (!activeDocument) {
return (
<div className="flex-1 flex items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-lg">Нет документов в проекте</p>
<p className="text-sm mt-1">Создайте документ через Story Bible или добавьте его позже</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Индикатор сохранения */}
<div className="flex justify-end px-4 py-2 bg-gray-800 border-b border-gray-700 shrink-0">
<span className={`text-xs ${isSaving ? 'text-yellow-400' : 'text-green-400'}`}>
{isSaving ? 'Сохранение...' : 'Сохранено'}
</span>
</div>
<Toolbar editor={editor} />
{/* Скроллируемая область редактора */}
<div className="flex-1 overflow-auto">
<div className="prose prose-invert max-w-3xl mx-auto p-8">
<EditorContent editor={editor} />
</div>
</div>
<AIMenu editor={editor} onAction={handleAIAction} />
{/* Diff view для предпросмотра AI изменений */}
{showDiffView && (
<AIDiffView
originalText={selectedText}
aiText={aiGeneratedText}
onAccept={handleAcceptChanges}
onReject={handleRejectChanges}
/>
)}
{/* Модальное окно добавления в Story Bible */}
{showAddToBibleModal && extractedEntities && (
<AddToBibleModal
extractedEntities={extractedEntities}
onConfirm={handleAddToBibleConfirm}
onClose={() => {
setShowAddToBibleModal(false);
setExtractedEntities(null);
}}
/>
)}
{/* Индикатор загрузки */}
{isLoading && (
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg shadow-lg z-50">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">AI генерирует...</span>
</div>
)}
{/* Индикатор ошибки */}
{error && (
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg shadow-lg z-50">
<AlertCircle className="w-4 h-4" />
<span className="text-sm">{error}</span>
<button onClick={() => setError(null)} className="ml-2 text-white/70 hover:text-white">
✕
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { X, Download, Loader2, FileText, BookOpen } from 'lucide-react';
import { exportDocument } from '@/lib/api';
interface ExportModalProps {
projectId: string;
documentId: string;
documentTitle: string;
documentType: 'book' | 'chapter';
onClose: () => void;
}
export default function ExportModal({ projectId, documentId, documentTitle, documentType, onClose }: ExportModalProps) {
const [selectedFormat, setSelectedFormat] = useState<'epub' | 'fb2' | 'pdf' | 'docx'>('epub');
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleExport = async () => {
setIsExporting(true);
setError(null);
try {
await exportDocument(projectId, documentId, selectedFormat);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка при экспорте');
} finally {
setIsExporting(false);
}
};
const formats = [
{ id: 'epub' as const, name: 'EPUB', desc: 'Стандарт для электронных книг (Apple Books, Kindle)', icon: BookOpen },
{ id: 'fb2' as const, name: 'FB2', desc: 'Популярный формат в СНГ (FBReader, LitRes)', icon: FileText },
{ id: 'pdf' as const, name: 'PDF', desc: 'Для печати и фиксированного форматирования', icon: FileText },
{ id: 'docx' as const, name: 'DOCX', desc: 'Для передачи на редактуру (Word, LibreOffice)', icon: FileText },
];
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">Экспорт: {documentTitle}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200" disabled={isExporting}>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
<p className="text-sm text-gray-400">
Выберите формат для экспорта {documentType === 'book' ? 'всей книги' : 'этой главы и всех вложенных сцен'}.
</p>
<div className="space-y-2">
{formats.map((fmt) => {
const Icon = fmt.icon;
return (
<label
key={fmt.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedFormat === fmt.id
? 'bg-blue-900/20 border-blue-600'
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
}`}
>
<input
type="radio"
name="format"
value={fmt.id}
checked={selectedFormat === fmt.id}
onChange={() => setSelectedFormat(fmt.id)}
className="text-blue-600 focus:ring-blue-500"
disabled={isExporting}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-gray-300" />
<span className="font-semibold text-gray-100">{fmt.name}</span>
</div>
<p className="text-xs text-gray-400 mt-1">{fmt.desc}</p>
</div>
</label>
);
})}
</div>
{error && (
<div className="p-3 bg-red-900/20 border border-red-800 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 flex gap-2">
<button
onClick={onClose}
disabled={isExporting}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg disabled:opacity-50"
>
Отмена
</button>
<button
onClick={handleExport}
disabled={isExporting}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-blue-800 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Генерация...
</>
) : (
<>
<Download className="w-4 h-4" />
Скачать
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,203 @@
'use client';
import { useState } from 'react';
import { X, Loader2, Sparkles, Download } from 'lucide-react';
import { generateImage, GenerateImageResponse } from '@/lib/api';
interface GenerateImageModalProps {
projectId: string;
documentId: string;
documentTitle: string;
documentType: 'book' | 'chapter';
onClose: () => void;
}
export default function GenerateImageModal({
projectId,
documentId,
documentTitle,
documentType,
onClose
}: GenerateImageModalProps) {
const [prompt, setPrompt] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [generatedImage, setGeneratedImage] = useState<GenerateImageResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const suggestedPrompts = documentType === 'book'
? [
'Epic fantasy book cover, magical castle in the clouds, dramatic lighting, digital art',
'Mystery novel cover, dark city street at night, fog, noir style',
'Romance book cover, couple silhouette at sunset, warm colors, artistic'
]
: [
'Detailed illustration of a medieval tavern interior, warm lighting, fantasy style',
'Character portrait, young adventurer with a sword, determined expression, digital painting',
'Landscape illustration, mysterious forest path, magical atmosphere, concept art'
];
const handleGenerate = async () => {
if (!prompt.trim()) return;
setIsGenerating(true);
setError(null);
setGeneratedImage(null);
try {
const result = await generateImage({
prompt,
project_id: projectId,
document_id: documentId,
document_type: documentType,
});
setGeneratedImage(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка при генерации изображения');
console.error('Generate image error:', err);
} finally {
setIsGenerating(false);
}
};
const handleDownload = () => {
if (!generatedImage) return;
// Скачиваем через blob из base64 данных
const byteString = atob(generatedImage.data_url.split(',')[1]);
const mimeString = generatedImage.data_url.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${documentTitle.replace(/[^a-z0-9]/gi, '_')}_${generatedImage.filename}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">
Генерация изображения: {documentTitle}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200" disabled={isGenerating}>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6 space-y-6">
{/* Prompt input */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Описание изображения (промпт)
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Опишите, что вы хотите увидеть на изображении... (лучше на английском)"
className="w-full p-3 bg-gray-700 text-gray-200 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none min-h-[120px] resize-none"
disabled={isGenerating}
/>
<p className="text-xs text-gray-500 mt-1">
💡 Совет: Пишите промпт на английском для лучшего результата. Будьте конкретны: стиль, освещение, настроение.
</p>
</div>
{/* Suggested prompts */}
<div>
<p className="text-sm text-gray-400 mb-2">Примеры промптов:</p>
<div className="flex flex-wrap gap-2">
{suggestedPrompts.map((suggestion, idx) => (
<button
key={idx}
onClick={() => setPrompt(suggestion)}
disabled={isGenerating}
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-xs rounded-lg border border-gray-600 transition-colors disabled:opacity-50"
>
{suggestion.length > 50 ? suggestion.substring(0, 50) + '...' : suggestion}
</button>
))}
</div>
</div>
{/* Generate button */}
<button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
className="w-full py-3 bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isGenerating ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Генерация изображения... (может занять 10-30 секунд)
</>
) : (
<>
<Sparkles className="w-5 h-5" />
Сгенерировать изображение
</>
)}
</button>
{/* Error message */}
{error && (
<div className="p-4 bg-red-900/20 border border-red-800 rounded-lg text-red-300">
<p className="font-medium">Ошибка генерации</p>
<p className="text-sm mt-1">{error}</p>
</div>
)}
{/* Generated image */}
{generatedImage && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-100">Результат</h3>
<div className="flex gap-2">
<button
onClick={handleDownload}
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
Скачать
</button>
</div>
</div>
<div className="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
<img
src={generatedImage.data_url}
alt="Generated image"
className="w-full h-auto max-h-[500px] object-contain"
/>
</div>
<div className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
<p className="text-xs text-gray-400 mb-1">Промпт:</p>
<p className="text-sm text-gray-300">{generatedImage.prompt}</p>
<p className="text-xs text-gray-500 mt-2">Модель: {generatedImage.model}</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 bg-gray-850">
<p className="text-xs text-gray-500 text-center">
💰 Генерация изображений использует API OpenRouter и может стоить ~$0.02-0.05 за изображение
</p>
</div>
</div>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { type Editor } from '@tiptap/react';
import {
Bold,
Italic,
Underline as UnderlineIcon,
Quote,
Eraser,
} from 'lucide-react';
interface ToolbarProps {
editor: Editor | null;
}
export function Toolbar({ editor }: ToolbarProps) {
if (!editor) {
return null;
}
const buttons = [
{
name: 'bold',
icon: Bold,
isActive: () => editor.isActive('bold'),
onClick: () => editor.chain().focus().toggleBold().run(),
},
{
name: 'italic',
icon: Italic,
isActive: () => editor.isActive('italic'),
onClick: () => editor.chain().focus().toggleItalic().run(),
},
{
name: 'underline',
icon: UnderlineIcon,
isActive: () => editor.isActive('underline'),
onClick: () => editor.chain().focus().toggleUnderline().run(),
},
{
name: 'blockquote',
icon: Quote,
isActive: () => editor.isActive('blockquote'),
onClick: () => editor.chain().focus().toggleBlockquote().run(),
},
];
return (
<div className="flex items-center gap-2 p-2 bg-gray-800 border-b border-gray-700 flex-wrap">
{/* Группировка: Bold, Italic, Underline */}
{buttons.map(({ name, icon: Icon, isActive, onClick }) => (
<button
key={name}
onClick={onClick}
className={`p-2 rounded transition-colors ${
isActive()
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
title={name.charAt(0).toUpperCase() + name.slice(1)}
>
<Icon className="w-5 h-5" />
</button>
))}
<div className="w-px h-6 bg-gray-600 mx-2" />
{/* Группировка: Blockquote, Color, Clear */}
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('blockquote')
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
title="Цитата"
>
<Quote className="w-5 h-5" />
</button>
{/* Цвет текста */}
<div className="relative">
<input
type="color"
onChange={(e) => editor.chain().focus().setColor(e.target.value).run()}
className="p-1 rounded w-8 h-8 cursor-pointer bg-gray-700 text-gray-300 hover:bg-gray-600"
title="Цвет текста"
/>
</div>
{/* Очистить форматирование */}
<button
onClick={() => editor.chain().focus().unsetAllMarks().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('bold') || editor.isActive('italic') || editor.isActive('underline')
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
title="Очистить форматирование"
>
<Eraser className="w-5 h-5" />
</button>
<div className="w-px h-6 bg-gray-600 mx-2" />
{/* Выбор шрифта */}
<select
onChange={(e) => editor.chain().focus().setFontFamily(e.target.value).run()}
className="bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1 text-sm"
title="Шрифт"
>
<option value="Georgia, serif">Serif</option>
<option value="Inter, sans-serif">Sans-serif</option>
<option value="'Courier New', monospace">Monospace</option>
</select>
{/* Размер шрифта */}
<select
onChange={(e) => editor.chain().focus().setFontSize(e.target.value).run()}
className="bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1 text-sm"
title="Размер шрифта"
>
<option value="14px">14px</option>
<option value="16px">16px</option>
<option value="18px">18px</option>
<option value="20px">20px</option>
<option value="24px">24px</option>
<option value="28px">28px</option>
</select>
</div>
);
}
@@ -0,0 +1,203 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useAppStore } from '@/stores/appStore';
import { getAvailableModels, ModelInfo } from '@/lib/ai-api';
import { Settings, X, Loader2 } from 'lucide-react';
export default function ModelSettingsPanel() {
const { modelSettings, updateModelSetting, autoUpdateBible, setAutoUpdateBible } = useAppStore();
const [isOpen, setIsOpen] = useState(false);
const [availableModels, setAvailableModels] = useState<{
ollama: ModelInfo[];
openrouter: ModelInfo[];
}>({ ollama: [], openrouter: [] });
const [loading, setLoading] = useState(false);
const loadModels = useCallback(async () => {
setLoading(true);
try {
const [ollamaModels, openrouterModels] = await Promise.all([
getAvailableModels('ollama'),
getAvailableModels('openrouter'),
]);
setAvailableModels({
ollama: ollamaModels.models || [],
openrouter: openrouterModels.models || [],
});
} catch (error) {
console.error('Failed to load models:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
loadModels();
}
}, [isOpen, loadModels]);
const taskLabels = {
generation: {
label: 'Генерация текста',
description: 'Основная модель для переписывания, расширения и улучшения текста',
},
background: {
label: 'Фоновая обработка',
description: 'Модель для извлечения сущностей и обновления Story Bible',
},
analysis: {
label: 'Анализ текста',
description: 'Модель для проверки логики и консистентности',
},
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-4 left-4 p-3 bg-gray-700 hover:bg-gray-600 text-white rounded-full shadow-lg transition-colors z-40"
title="Настройки моделей"
>
<Settings className="w-5 h-5" />
</button>
);
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">Настройки моделей AI</h2>
<button onClick={() => setIsOpen(false)} className="text-gray-400 hover:text-gray-200">
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-blue-400" />
<span className="ml-2 text-gray-400">Загрузка списка моделей...</span>
</div>
) : (
<>
{(Object.keys(taskLabels) as Array<keyof typeof taskLabels>).map((taskType) => (
<div
key={taskType}
className="p-4 bg-gray-700/50 rounded-lg border border-gray-600"
>
<div className="mb-3">
<h3 className="text-lg font-semibold text-gray-100">
{taskLabels[taskType].label}
</h3>
<p className="text-sm text-gray-400 mt-1">
{taskLabels[taskType].description}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Provider */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Провайдер
</label>
<select
value={modelSettings[taskType].provider}
onChange={(e) => {
const provider = e.target.value as 'ollama' | 'openrouter';
const defaultModel =
availableModels[provider][0]?.id || '';
updateModelSetting(taskType, provider, defaultModel);
}}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="ollama">Ollama (Локально)</option>
<option value="openrouter">OpenRouter (Облако)</option>
</select>
</div>
{/* Model */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Модель
</label>
<select
value={modelSettings[taskType].model}
onChange={(e) =>
updateModelSetting(
taskType,
modelSettings[taskType].provider,
e.target.value
)
}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
{availableModels[modelSettings[taskType].provider]?.length > 0 ? (
availableModels[modelSettings[taskType].provider]?.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))
) : (
<option value="">Нет доступных моделей</option>
)}
</select>
</div>
</div>
{/* Model description */}
{availableModels[modelSettings[taskType].provider]?.find(
(m) => m.id === modelSettings[taskType].model
)?.description && (
<p className="text-xs text-gray-500 mt-2 italic">
{availableModels[modelSettings[taskType].provider]?.find(
(m) => m.id === modelSettings[taskType].model
)?.description}
</p>
)}
</div>
))}
{/* Автоматическое обновление Story Bible */}
<div className="p-4 bg-gray-700/50 rounded-lg border border-gray-600">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-100">
Автоматическое обновление Story Bible
</h3>
<p className="text-sm text-gray-400 mt-1">
При сохранении сцены AI автоматически извлекает новых персонажей, локации и события
</p>
</div>
<button
onClick={() => setAutoUpdateBible(!autoUpdateBible)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
autoUpdateBible ? 'bg-blue-600' : 'bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
autoUpdateBible ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 bg-gray-850">
<p className="text-sm text-gray-400 text-center">
Настройки сохраняются автоматически в localStorage браузера
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,357 @@
'use client';
import { useState, useEffect } from 'react';
import { useAppStore, DocumentTreeNode } from '@/stores/appStore';
import { Book, FileText, ChevronRight, ChevronDown, Plus, Trash2, Edit2, Download, Image as ImageIcon } from 'lucide-react';
import ExportModal from '../editor/ExportModal';
import GenerateImageModal from '../editor/GenerateImageModal';
interface TreeNodeProps {
node: DocumentTreeNode;
level: number;
onContextMenu: (e: React.MouseEvent, node: DocumentTreeNode) => void;
}
function TreeNode({ node, level, onContextMenu }: TreeNodeProps) {
const { activeDocumentId, switchToDocument } = useAppStore();
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children && node.children.length > 0;
const isActive = activeDocumentId === node.id;
const handleClick = async () => {
if (node.type === 'scene') {
// Сначала сохраняем текущий документ (через глобальный callback)
if (window.saveCurrentEditorContent) {
await window.saveCurrentEditorContent();
}
// Затем безопасно переключаем
await switchToDocument(node.id);
} else if (hasChildren) {
setIsExpanded(!isExpanded);
}
};
const getIcon = () => {
switch (node.type) {
case 'book':
return <Book className="w-4 h-4 text-blue-400" />;
case 'chapter':
return <FileText className="w-4 h-4 text-green-400" />;
case 'scene':
return <FileText className="w-4 h-4 text-gray-400" />;
default:
return <FileText className="w-4 h-4" />;
}
};
return (
<div>
<div
onClick={handleClick}
onContextMenu={(e) => onContextMenu(e, node)}
className={`flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-gray-700/50 rounded transition-colors ${
isActive ? 'bg-blue-900/30 border-l-2 border-blue-500' : ''
}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)
) : (
<div className="w-4" />
)}
{getIcon()}
<span className="text-sm text-gray-200 flex-1 truncate">{node.title}</span>
</div>
{hasChildren && isExpanded && (
<div>
{node.children.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
onContextMenu={onContextMenu}
/>
))}
</div>
)}
</div>
);
}
export default function DocumentTree() {
const { activeProjectId, documentsTree, loadDocumentsTree, createDocument, deleteDocument, updateDocument } = useAppStore();
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; node: DocumentTreeNode } | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [showGenerateImageModal, setShowGenerateImageModal] = useState(false);
const [exportTarget, setExportTarget] = useState<{ id: string; title: string; type: 'book' | 'chapter' } | null>(null);
const [imageTarget, setImageTarget] = useState<{ id: string; title: string; type: 'book' | 'chapter' } | null>(null);
const [createParentId, setCreateParentId] = useState<string | null>(null);
const [createType, setCreateType] = useState<'book' | 'chapter' | 'scene'>('book');
const [newTitle, setNewTitle] = useState('');
useEffect(() => {
if (activeProjectId) {
loadDocumentsTree(activeProjectId);
}
}, [activeProjectId, loadDocumentsTree]);
const handleContextMenu = (e: React.MouseEvent, node: DocumentTreeNode) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, node });
};
const handleCloseContextMenu = () => {
setContextMenu(null);
};
const handleExport = () => {
if (!contextMenu) return;
setExportTarget({
id: contextMenu.node.id,
title: contextMenu.node.title,
type: contextMenu.node.type as 'book' | 'chapter',
});
setShowExportModal(true);
handleCloseContextMenu();
};
const handleGenerateImage = () => {
if (!contextMenu) return;
setImageTarget({
id: contextMenu.node.id,
title: contextMenu.node.title,
type: contextMenu.node.type as 'book' | 'chapter',
});
setShowGenerateImageModal(true);
handleCloseContextMenu();
};
const handleCreate = (parentType: string) => {
let type: 'book' | 'chapter' | 'scene';
if (parentType === 'root') {
type = 'book';
} else if (parentType === 'book') {
type = 'chapter';
} else {
type = 'scene';
}
setCreateType(type);
setCreateParentId(parentType === 'root' ? null : contextMenu?.node.id || null);
setShowCreateModal(true);
handleCloseContextMenu();
};
const handleCreateSubmit = async () => {
if (!newTitle.trim() || !activeProjectId) return;
await createDocument(activeProjectId, newTitle, createType, createParentId || undefined);
setShowCreateModal(false);
setNewTitle('');
};
const handleDelete = async () => {
if (!contextMenu || !activeProjectId) return;
if (confirm(`Удалить "${contextMenu.node.title}" и все вложенные элементы?`)) {
await deleteDocument(activeProjectId, contextMenu.node.id);
}
handleCloseContextMenu();
};
const handleRename = async () => {
if (!contextMenu || !activeProjectId) return;
const newName = prompt('Новое название:', contextMenu.node.title);
if (newName && newName !== contextMenu.node.title) {
await updateDocument(activeProjectId, contextMenu.node.id, { title: newName });
await loadDocumentsTree(activeProjectId);
}
handleCloseContextMenu();
};
if (!activeProjectId) {
return <div className="p-4 text-gray-400">Выберите проект</div>;
}
return (
<div className="h-full flex flex-col" onClick={handleCloseContextMenu}>
{/* Header — фиксированный */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between flex-shrink-0">
<h2 className="text-lg font-bold text-gray-100">Структура книги</h2>
<button
onClick={() => handleCreate('root')}
className="p-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
title="Создать книгу"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Tree — скроллируемая область */}
<div className="flex-1 overflow-auto min-h-0 p-2">
{documentsTree.length === 0 ? (
<div className="text-center text-gray-400 py-8">
<Book className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Нет книг</p>
<p className="text-sm mt-1">Создайте первую книгу</p>
</div>
) : (
<div>
{documentsTree.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
onContextMenu={handleContextMenu}
/>
))}
</div>
)}
</div>
{/* Context Menu */}
{contextMenu && (
<div
className="fixed bg-gray-800 border border-gray-700 rounded-lg shadow-xl py-1 z-50"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{contextMenu.node.type === 'book' && (
<button
onClick={() => handleCreate('book')}
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Добавить главу
</button>
)}
{contextMenu.node.type === 'chapter' && (
<button
onClick={() => handleCreate('chapter')}
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Добавить сцену
</button>
)}
<button
onClick={handleRename}
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
>
<Edit2 className="w-4 h-4" />
Переименовать
</button>
<div className="border-t border-gray-700 my-1" />
{(contextMenu.node.type === 'book' || contextMenu.node.type === 'chapter') && (
<button
onClick={handleExport}
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
>
<Download className="w-4 h-4" />
Экспортировать
</button>
)}
{(contextMenu.node.type === 'book' || contextMenu.node.type === 'chapter') && (
<button
onClick={handleGenerateImage}
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
>
<ImageIcon className="w-4 h-4" />
Сгенерировать изображение
</button>
)}
<button
onClick={handleDelete}
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Удалить
</button>
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full">
<div className="p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">
Создать {createType === 'book' ? 'книгу' : createType === 'chapter' ? 'главу' : 'сцену'}
</h2>
</div>
<div className="p-4">
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Название..."
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateSubmit();
}}
/>
</div>
<div className="p-4 border-t border-gray-700 flex gap-2">
<button
onClick={handleCreateSubmit}
disabled={!newTitle.trim()}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-gray-600 disabled:cursor-not-allowed"
>
Создать
</button>
<button
onClick={() => {
setShowCreateModal(false);
setNewTitle('');
}}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
>
Отмена
</button>
</div>
</div>
</div>
)}
{/* Export Modal */}
{showExportModal && exportTarget && activeProjectId && (
<ExportModal
projectId={activeProjectId}
documentId={exportTarget.id}
documentTitle={exportTarget.title}
documentType={exportTarget.type}
onClose={() => {
setShowExportModal(false);
setExportTarget(null);
}}
/>
)}
{/* Generate Image Modal */}
{showGenerateImageModal && imageTarget && activeProjectId && (
<GenerateImageModal
projectId={activeProjectId}
documentId={imageTarget.id}
documentTitle={imageTarget.title}
documentType={imageTarget.type}
onClose={() => {
setShowGenerateImageModal(false);
setImageTarget(null);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,16 @@
export function FileExplorer() {
return (
<div className="h-full bg-gray-800 border-r border-gray-700 flex flex-col">
<div className="p-4 border-b border-gray-700">
<h2 className="text-sm font-semibold uppercase text-gray-400">
File Explorer
</h2>
</div>
<div className="flex-1 p-4">
<div className="text-gray-500 text-sm">
Дерево файлов проекта...
</div>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
'use client';
import { useAppStore } from '@/stores/appStore';
import ProjectManager from './ProjectManager';
import DocumentTree from './DocumentTree';
export default function LeftPanel() {
const { activeProjectId } = useAppStore();
return (
<div className="h-full flex flex-col bg-gray-800">
{/* Верхняя часть - ProjectManager (фиксированная высота) */}
<div className="flex-shrink-0 border-b border-gray-700" style={{ maxHeight: '40%' }}>
<ProjectManager />
</div>
{/* Нижняя часть - DocumentTree (занимает оставшееся пространство) */}
<div className="flex-1 overflow-hidden min-h-0">
{activeProjectId ? (
<DocumentTree />
) : (
<div className="p-4 text-center text-gray-400">
<p className="text-sm">Выберите проект</p>
<p className="text-xs mt-1">для просмотра структуры</p>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,174 @@
'use client';
import { useState, useEffect } from 'react';
import { useAppStore } from '@/stores/appStore';
import { Plus, Trash2, Folder, LogOut } from 'lucide-react';
export default function ProjectManager() {
const {
projects,
activeProjectId,
loadProjects,
setActiveProject,
createProject,
deleteProject,
logout,
user
} = useAppStore();
const [showCreateModal, setShowCreateModal] = useState(false);
const [newProjectTitle, setNewProjectTitle] = useState('');
const [newProjectDescription, setNewProjectDescription] = useState('');
useEffect(() => {
loadProjects();
}, []);
const handleCreateProject = async () => {
if (!newProjectTitle.trim()) return;
const project = await createProject(newProjectTitle, newProjectDescription);
await setActiveProject(project.id);
setShowCreateModal(false);
setNewProjectTitle('');
setNewProjectDescription('');
};
const handleDeleteProject = async (projectId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (confirm('Вы уверены, что хотите удалить этот проект? Все данные будут потеряны.')) {
await deleteProject(projectId);
}
};
return (
<div className="flex flex-col" style={{ height: '100%' }}>
{/* Header */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-gray-100">Проекты</h2>
{user && <p className="text-xs text-gray-400">{user.username}</p>}
</div>
<div className="flex gap-2">
<button
onClick={() => setShowCreateModal(true)}
className="p-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
title="Создать проект"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={logout}
className="p-2 bg-gray-700 hover:bg-red-600 text-gray-300 hover:text-white rounded-lg transition-colors"
title="Выйти"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
{/* Projects list */}
<div className="overflow-auto p-4" style={{ maxHeight: 'calc(100% - 60px)' }}>
{projects.length === 0 ? (
<div className="text-center text-gray-400 py-8">
<Folder className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Нет проектов</p>
<p className="text-sm mt-1">Создайте первый проект</p>
</div>
) : (
<div className="space-y-2">
{projects.map((project) => (
<div
key={project.id}
onClick={() => setActiveProject(project.id)}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
activeProjectId === project.id
? 'bg-blue-900/30 border-blue-600'
: 'bg-gray-700/50 border-gray-600 hover:bg-gray-700'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-gray-100 flex items-center gap-2">
<Folder className="w-4 h-4" />
{project.title}
</h3>
{project.description && (
<p className="text-sm text-gray-400 mt-1">{project.description}</p>
)}
<p className="text-xs text-gray-500 mt-2">
Создан: {new Date(project.created_at).toLocaleDateString('ru-RU')}
</p>
</div>
<button
onClick={(e) => handleDeleteProject(project.id, e)}
className="p-1 text-gray-400 hover:text-red-400 transition-colors"
title="Удалить проект"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Create project modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full">
<div className="p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">Новый проект</h2>
</div>
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Название проекта *
</label>
<input
type="text"
value={newProjectTitle}
onChange={(e) => setNewProjectTitle(e.target.value)}
placeholder="Например: Мой первый роман"
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Описание (опционально)
</label>
<textarea
value={newProjectDescription}
onChange={(e) => setNewProjectDescription(e.target.value)}
placeholder="Краткое описание проекта..."
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 focus:border-blue-500 focus:outline-none min-h-[100px]"
/>
</div>
</div>
<div className="p-4 border-t border-gray-700 flex gap-2">
<button
onClick={handleCreateProject}
disabled={!newProjectTitle.trim()}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-gray-600 disabled:cursor-not-allowed"
>
Создать
</button>
<button
onClick={() => {
setShowCreateModal(false);
setNewProjectTitle('');
setNewProjectDescription('');
}}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
>
Отмена
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,547 @@
'use client';
import { useState, useEffect } from 'react';
import { useAppStore } from '@/stores/appStore';
import { storyBibleApi, type Character, type Location, type Event, type StoryThread } from '@/lib/api';
import { Users, MapPin, Calendar, GitBranch, Plus, Edit2, Trash2, X, Sparkles } from 'lucide-react';
type TabType = 'characters' | 'locations' | 'events' | 'threads';
interface AddEditModalProps {
type: TabType;
item: Character | Location | Event | StoryThread | null;
onSave: (data: unknown) => Promise<void>;
onClose: () => void;
}
export default function StoryBiblePanel() {
const { storyBible, activeProjectId, loadStoryBible, setStoryBible } = useAppStore();
const [activeTab, setActiveTab] = useState<TabType>('characters');
const [showAddModal, setShowAddModal] = useState(false);
const [editingItem, setEditingItem] = useState<Character | Location | Event | StoryThread | null>(null);
useEffect(() => {
if (activeProjectId && !storyBible) {
loadStoryBible(activeProjectId);
}
}, [activeProjectId, storyBible, loadStoryBible]);
const handleAdd = async (data: unknown) => {
if (!activeProjectId) return;
try {
let updatedBible: StoryBible;
const { getProjectStoryBible, updateProjectStoryBible } = storyBibleApi;
// Получаем текущую Bible
updatedBible = await getProjectStoryBible(activeProjectId);
// Добавляем новый элемент (используем Date для генерации ID)
const newItem = {
id: new Date().toISOString().replace(/[:.]/g, '-'),
...data,
};
switch (activeTab) {
case 'characters':
updatedBible.characters = [...updatedBible.characters, newItem as Character];
break;
case 'locations':
updatedBible.locations = [...updatedBible.locations, newItem as Location];
break;
case 'events':
updatedBible.events = [...updatedBible.events, newItem as Event];
break;
case 'threads':
updatedBible.threads = [...updatedBible.threads, newItem as StoryThread];
break;
}
// Сохраняем обратно
await updateProjectStoryBible(activeProjectId, updatedBible);
setStoryBible(updatedBible);
setShowAddModal(false);
} catch (error) {
console.error('Failed to add item:', error);
}
};
const handleDelete = async (id: string) => {
if (!storyBible || !activeProjectId) return;
const updatedBible = { ...storyBible };
switch (activeTab) {
case 'characters':
updatedBible.characters = updatedBible.characters.filter((c: Character) => c.id !== id);
break;
case 'locations':
updatedBible.locations = updatedBible.locations.filter((l: Location) => l.id !== id);
break;
case 'events':
updatedBible.events = updatedBible.events.filter((e: Event) => e.id !== id);
break;
case 'threads':
updatedBible.threads = updatedBible.threads.filter((t: StoryThread) => t.id !== id);
break;
}
try {
const { updateProjectStoryBible } = storyBibleApi;
await updateProjectStoryBible(activeProjectId, updatedBible);
setStoryBible(updatedBible);
} catch (error) {
console.error('Failed to delete item:', error);
}
};
const handleEdit = async (data: unknown) => {
if (!activeProjectId || !editingItem) return;
try {
const { getProjectStoryBible, updateProjectStoryBible } = storyBibleApi;
let updatedBible = await getProjectStoryBible(activeProjectId);
switch (activeTab) {
case 'characters':
updatedBible.characters = updatedBible.characters.map((c: Character) =>
c.id === (editingItem as Character).id ? { ...c, ...(data as Partial<Character>) } : c
);
break;
case 'locations':
updatedBible.locations = updatedBible.locations.map((l: Location) =>
l.id === (editingItem as Location).id ? { ...l, ...(data as Partial<Location>) } : l
);
break;
case 'events':
updatedBible.events = updatedBible.events.map((e: Event) =>
e.id === (editingItem as Event).id ? { ...e, ...(data as Partial<Event>) } : e
);
break;
case 'threads':
updatedBible.threads = updatedBible.threads.map((t: StoryThread) =>
t.id === (editingItem as StoryThread).id ? { ...t, ...(data as Partial<StoryThread>) } : t
);
break;
}
await updateProjectStoryBible(activeProjectId, updatedBible);
setStoryBible(updatedBible);
setShowAddModal(false);
setEditingItem(null);
} catch (error) {
console.error('Failed to edit item:', error);
}
};
const handleCleanup = async () => {
if (!activeProjectId) return;
if (confirm('Очистить дубликаты в Story Bible? Это действие нельзя отменить.')) {
try {
const result = await storyBibleApi.cleanupDuplicates(activeProjectId);
alert(result.message);
await loadStoryBible(activeProjectId);
} catch (error) {
console.error('Cleanup failed:', error);
alert('Ошибка при очистке дубликатов');
}
}
};
// Если нет активного проекта, показываем сообщение
if (!activeProjectId) {
return (
<div className="h-full flex items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-sm">Выберите проект</p>
<p className="text-xs mt-1">для просмотра Story Bible</p>
</div>
</div>
);
}
// Если нет Story Bible после загрузки проекта
if (!storyBible) {
return <div className="p-4 text-gray-400">Загрузка Story Bible...</div>;
}
const tabs = [
{
id: 'characters' as TabType,
label: 'Персонажи',
icon: Users,
count: storyBible.characters.length,
},
{ id: 'locations' as TabType, label: 'Локации', icon: MapPin, count: storyBible.locations.length },
{ id: 'events' as TabType, label: 'События', icon: Calendar, count: storyBible.events.length },
{ id: 'threads' as TabType, label: 'Нити', icon: GitBranch, count: storyBible.threads.length },
];
return (
<div className="h-full flex flex-col bg-gray-800">
{/* Header */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-100">Story Bible</h2>
<button
onClick={handleCleanup}
className="p-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors"
title="Очистить дубликаты"
>
<Sparkles className="w-4 h-4" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-700">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 flex flex-col items-center gap-1 p-3 text-xs transition-colors ${
activeTab === tab.id
? 'bg-gray-700 text-blue-400 border-b-2 border-blue-400'
: 'text-gray-400 hover:bg-gray-700/50'
}`}
>
<Icon className="w-4 h-4" />
<span>{tab.label}</span>
<span className="text-xs opacity-60">{tab.count}</span>
</button>
);
})}
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4">
<div className="space-y-3">
{activeTab === 'characters' &&
storyBible.characters.map((char: Character) => (
<div key={char.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-gray-100">{char.name}</h3>
<p className="text-sm text-gray-400 mt-1">{char.description}</p>
{char.traits.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{char.traits.map((trait: string, idx: number) => (
<span
key={idx}
className="px-2 py-0.5 bg-blue-900/30 text-blue-300 text-xs rounded"
>
{trait}
</span>
))}
</div>
)}
</div>
<div className="flex gap-1 ml-2">
<button
onClick={() => { setEditingItem(char); setShowAddModal(true); }}
className="p-1 text-gray-400 hover:text-blue-400"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(char.id)}
className="p-1 text-gray-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
{activeTab === 'locations' &&
storyBible.locations.map((loc: Location) => (
<div key={loc.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-gray-100">{loc.name}</h3>
<p className="text-sm text-gray-400 mt-1">{loc.description}</p>
{loc.atmosphere && <p className="text-xs text-gray-500 mt-2 italic">{loc.atmosphere}</p>}
</div>
<div className="flex gap-1 ml-2">
<button onClick={() => { setEditingItem(loc); setShowAddModal(true); }} className="p-1 text-gray-400 hover:text-blue-400">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(loc.id)} className="p-1 text-gray-400 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
{activeTab === 'events' &&
storyBible.events.map((event: Event) => (
<div key={event.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-gray-100">{event.title}</h3>
<p className="text-sm text-gray-400 mt-1">{event.description}</p>
{event.chapter && <p className="text-xs text-gray-500 mt-2">{event.chapter}</p>}
</div>
<div className="flex gap-1 ml-2">
<button onClick={() => { setEditingItem(event); setShowAddModal(true); }} className="p-1 text-gray-400 hover:text-blue-400">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(event.id)} className="p-1 text-gray-400 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
{activeTab === 'threads' &&
storyBible.threads.map((thread: StoryThread) => (
<div key={thread.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-100">{thread.title}</h3>
<span
className={`px-2 py-0.5 text-xs rounded ${
thread.status === 'active'
? 'bg-green-900/30 text-green-300'
: thread.status === 'resolved'
? 'bg-blue-900/30 text-blue-300'
: 'bg-gray-900/30 text-gray-400'
}`}
>
{thread.status === 'active'
? 'Активна'
: thread.status === 'resolved'
? 'Завершена'
: 'Отменена'}
</span>
</div>
<p className="text-sm text-gray-400 mt-1">{thread.description}</p>
</div>
<div className="flex gap-1 ml-2">
<button onClick={() => { setEditingItem(thread); setShowAddModal(true); }} className="p-1 text-gray-400 hover:text-blue-400">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(thread.id)} className="p-1 text-gray-400 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
{/* Add button */}
<div className="p-4 border-t border-gray-700">
<button
onClick={() => { setEditingItem(null); setShowAddModal(true); }}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
{/* Add/Edit Modal */}
{showAddModal && (
<AddEditModal
type={activeTab}
item={editingItem}
onSave={editingItem ? handleEdit : handleAdd}
onClose={() => {
setShowAddModal(false);
setEditingItem(null);
}}
/>
)}
</div>
);
}
// Модальное окно для добавления/редактирования
function AddEditModal({ type, item, onSave, onClose }: AddEditModalProps) {
const [formData, setFormData] = useState<unknown>(item || {});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full max-h-[90vh] overflow-auto">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-gray-100">{item ? 'Редактировать' : 'Добавить'}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{type === 'characters' && (
<>
<input
type="text"
placeholder="Имя"
value={(formData as Character).name || ''}
onChange={(e) => setFormData({ ...(formData as Character), name: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
required
/>
<textarea
placeholder="Описание"
value={(formData as Character).description || ''}
onChange={(e) => setFormData({ ...(formData as Character), description: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
/>
<input
type="text"
placeholder="Черты характера (через запятую)"
value={(formData as Character).traits?.join(', ') || ''}
onChange={(e) =>
setFormData({ ...(formData as Character), traits: e.target.value.split(',').map((t: string) => t.trim()) })
}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
/>
<textarea
placeholder="Внешность"
value={(formData as Character).appearance || ''}
onChange={(e) => setFormData({ ...(formData as Character), appearance: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[60px]"
/>
<select
value={(formData as Character).role || 'supporting'}
onChange={(e) => setFormData({ ...(formData as Character), role: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="protagonist">Протагонист</option>
<option value="antagonist">Антагонист</option>
<option value="supporting">Второстепенный</option>
<option value="minor">Эпизодический</option>
</select>
</>
)}
{type === 'locations' && (
<>
<input
type="text"
placeholder="Название"
value={(formData as Location).name || ''}
onChange={(e) => setFormData({ ...(formData as Location), name: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
required
/>
<textarea
placeholder="Описание"
value={(formData as Location).description || ''}
onChange={(e) => setFormData({ ...(formData as Location), description: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
/>
<textarea
placeholder="Атмосфера"
value={(formData as Location).atmosphere || ''}
onChange={(e) => setFormData({ ...(formData as Location), atmosphere: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[60px]"
/>
<select
value={(formData as Location).type || 'other'}
onChange={(e) => setFormData({ ...(formData as Location), type: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="city">Город</option>
<option value="building">Здание</option>
<option value="nature">Природа</option>
<option value="interior">Интерьер</option>
<option value="other">Другое</option>
</select>
</>
)}
{type === 'events' && (
<>
<input
type="text"
placeholder="Название"
value={(formData as Event).title || ''}
onChange={(e) => setFormData({ ...(formData as Event), title: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
required
/>
<textarea
placeholder="Описание"
value={(formData as Event).description || ''}
onChange={(e) => setFormData({ ...(formData as Event), description: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
/>
<input
type="text"
placeholder="Глава"
value={(formData as Event).chapter || ''}
onChange={(e) => setFormData({ ...(formData as Event), chapter: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
/>
<select
value={(formData as Event).importance || 'minor'}
onChange={(e) => setFormData({ ...(formData as Event), importance: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="major">Важное</option>
<option value="minor">Незначительное</option>
<option value="background">Фоновое</option>
</select>
</>
)}
{type === 'threads' && (
<>
<input
type="text"
placeholder="Название"
value={(formData as StoryThread).title || ''}
onChange={(e) => setFormData({ ...(formData as StoryThread), title: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
required
/>
<textarea
placeholder="Описание"
value={(formData as StoryThread).description || ''}
onChange={(e) => setFormData({ ...(formData as StoryThread), description: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
/>
<select
value={(formData as StoryThread).status || 'active'}
onChange={(e) => setFormData({ ...(formData as StoryThread), status: e.target.value })}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="active">Активна</option>
<option value="resolved">Завершена</option>
<option value="abandoned">Отменена</option>
</select>
</>
)}
<div className="flex gap-2 pt-4">
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
>
Сохранить
</button>
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
>
Отмена
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,14 @@
export function StoryExplorer() {
return (
<div className="h-full bg-gray-800 border-r border-gray-700 flex flex-col">
<div className="p-4 border-b border-gray-700">
<h2 className="text-sm font-semibold uppercase text-gray-400">
Story Explorer
</h2>
</div>
<div className="flex-1 p-4">
<div className="text-gray-500 text-sm">Дерево сюжета...</div>
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
import { useState } from 'react';
import { generateText, Preset, Provider } from '@/lib/ai-api';
export default function AITest() {
const [prompt, setPrompt] = useState('');
const [result, setResult] = useState('');
const [loading, setLoading] = useState(false);
const [preset, setPreset] = useState<Preset>('creative_draft');
const [provider, setProvider] = useState<Provider>('ollama');
const handleGenerate = async () => {
if (!prompt.trim()) return;
setLoading(true);
setResult('');
try {
const text = await generateText({
prompt,
preset,
provider,
});
setResult(text);
} catch (error) {
setResult(`Ошибка: ${error instanceof Error ? error.message : 'Неизвестная ошибка'}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-4 bg-gray-800 rounded-lg space-y-4">
<h2 className="text-xl font-bold text-gray-100">AI Test Component</h2>
<div className="space-y-2">
<label className="text-sm text-gray-400">Provider:</label>
<select
value={provider}
onChange={(e) => setProvider(e.target.value as Provider)}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="ollama">Ollama (Local)</option>
<option value="openrouter">OpenRouter (Cloud)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm text-gray-400">Preset:</label>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as Preset)}
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
>
<option value="creative_draft">Creative Draft (temp 0.85)</option>
<option value="editor">Editor (temp 0.3)</option>
<option value="brainstorm">Brainstorm (temp 1.0)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm text-gray-400">Prompt:</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Введите промпт..."
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[100px]"
/>
</div>
<button
onClick={handleGenerate}
disabled={loading || !prompt.trim()}
className="w-full p-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed"
>
{loading ? 'Генерация...' : 'Сгенерировать'}
</button>
{result && (
<div className="space-y-2">
<label className="text-sm text-gray-400">Результат:</label>
<div className="p-3 bg-gray-900 text-gray-100 rounded border border-gray-700 whitespace-pre-wrap">
{result}
</div>
</div>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
'use client';
import { useState } from 'react';
import AIDiffView from '../editor/AIDiffView';
export default function DiffTest() {
const [showDiff, setShowDiff] = useState(false);
if (!showDiff) {
return (
<div className="p-8 bg-gray-900 min-h-screen">
<button
onClick={() => setShowDiff(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Показать Diff View
</button>
</div>
);
}
return (
<AIDiffView
originalText="Это оригинальный текст, который нужно улучшить. Он содержит базовую информацию, но lacks деталей и образности."
aiText="Это оригинальный текст, требующий существенного улучшения. Он содержит лишь поверхностную информацию, лишённую живой образности и глубины, которые могли бы сделать его по-настоящему увлекательным для читателя."
onAccept={() => {
console.log('Accepted!');
setShowDiff(false);
}}
onReject={() => {
console.log('Rejected!');
setShowDiff(false);
}}
/>
);
}
+197
View File
@@ -0,0 +1,197 @@
import axios from 'axios';
import type { StoryBible } from '@/lib/api';
// Типы для запроса
export type Provider = 'ollama' | 'openrouter';
export type Preset = 'creative_draft' | 'editor' | 'brainstorm';
export type TaskType = 'generation' | 'background' | 'analysis';
export interface ModelInfo {
id: string;
name: string;
description?: string;
models?: ModelInfo[];
}
export interface GenerateRequest {
prompt: string;
provider?: Provider;
model?: string;
api_key?: string;
preset?: Preset;
options?: Record<string, unknown>;
story_bible?: StoryBible; // Story Bible для контекста
selected_text?: string; // Выделенный текст для анализа
taskType?: TaskType;
}
// Типы для ответа
export interface GenerateResponse {
generated_text: string;
provider: string;
model: string;
preset: string;
}
// API клиент
const aiApi = axios.create({
baseURL: 'http://localhost:8000/api',
headers: {
'Content-Type': 'application/json',
},
});
// Интерсептор для автоматического добавления токена авторизации
aiApi.interceptors.request.use((config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
/**
* Получение списка доступных моделей для провайдера
*/
export async function getAvailableModels(
provider: Provider
): Promise<ModelInfo> {
try {
const response = await aiApi.get<{ models: ModelInfo[] }>(
'/ai/models',
{ params: { provider } }
);
return {
id: provider,
name: provider === 'ollama' ? 'Ollama Models' : 'OpenRouter Models',
models: response.data.models || [],
};
} catch (error) {
console.error(`Failed to load models from ${provider}:`, error);
return {
id: provider,
name: provider === 'ollama' ? 'Ollama Models' : 'OpenRouter Models',
models: [],
};
}
}
/**
* Генерация текста через AI с использованием настроек из store
*/
export async function generateText(
request: Omit<GenerateRequest, 'provider' | 'model'> & { provider?: Provider; model?: string }
): Promise<string> {
const { useAppStore } = await import('@/stores/appStore');
const { modelSettings, storyBible } = useAppStore.getState();
// Если provider и model явно указаны, используем их
if (request.provider && request.model) {
try {
const response = await aiApi.post<GenerateResponse>('/ai/generate', {
...request,
story_bible: storyBible,
});
return response.data.generated_text;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`AI generation failed: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
// Иначе используем настройки из store
const taskType = (request as Record<string, unknown>).taskType || 'generation';
const settings = modelSettings[taskType as keyof typeof modelSettings];
try {
const response = await aiApi.post<GenerateResponse>('/ai/generate', {
...request,
provider: settings.provider,
model: settings.model,
story_bible: storyBible,
});
return response.data.generated_text;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`AI generation failed: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
/**
* Упрощенная функция для быстрого вызова
*/
export async function quickGenerate(
prompt: string,
preset: Preset = 'creative_draft',
provider: Provider = 'ollama',
model?: string
): Promise<string> {
return generateText({
prompt,
preset,
provider,
model,
});
}
// ─── Auto-update Story Bible ───
export interface AutoUpdateBibleResponse {
updated: boolean;
new_characters: number;
new_locations: number;
new_events: number;
message: string;
}
export async function autoUpdateBible(
projectId: string,
sceneText: string,
enabled: boolean = true
): Promise<AutoUpdateBibleResponse> {
try {
const response = await aiApi.post<AutoUpdateBibleResponse>('/ai/auto-update-bible', {
project_id: projectId,
scene_text: sceneText,
auto_update_enabled: enabled,
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Auto-update failed: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
// ─── Extract Entities ───
export interface ExtractEntitiesResponse {
entities: {
new_characters: any[];
new_locations: any[];
new_events: any[];
};
model_used: string;
provider: string;
}
export async function extractEntities(text: string): Promise<ExtractEntitiesResponse> {
try {
const response = await aiApi.post<ExtractEntitiesResponse>('/ai/extract-entities', {
text,
existing_bible: null, // Можно передать текущую Bible для дедупликации
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Extract entities failed: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
+471
View File
@@ -0,0 +1,471 @@
import axios from 'axios';
import type { DocumentNode, DocumentTreeNode, ModelSettings } from '@/stores/appStore';
// Конфигурация API
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
// Типы
export interface User {
id: string;
username: string;
email: string;
}
export interface AuthResponse {
access_token: string;
token_type: string;
user_id: string;
username: string;
}
// Типы для конфигурации моделей
export interface ModelInfo {
id: string;
name: string;
description: string;
context_length?: number;
pricing?: string;
}
export interface ModelsResponse {
provider: string;
default_model: string;
models: ModelInfo[];
}
export type Provider = 'ollama' | 'openrouter';
// Типы для Project
export interface Project {
id: string;
title: string;
description: string;
created_at: string;
updated_at: string;
}
// Типы для Story Bible
export interface Character {
id: string;
name: string;
description: string;
traits: string[];
appearance: string;
role: 'protagonist' | 'antagonist' | 'supporting' | 'minor';
}
export interface Location {
id: string;
name: string;
description: string;
atmosphere: string;
type: 'city' | 'building' | 'nature' | 'interior' | 'other';
}
export interface Event {
id: string;
title: string;
description: string;
chapter?: string;
importance: 'major' | 'minor' | 'background';
}
export interface StoryThread {
id: string;
title: string;
description: string;
status: 'active' | 'resolved' | 'abandoned';
related_characters: string[];
related_events: string[];
}
export interface StoryBible {
characters: Character[];
locations: Location[];
events: Event[];
threads: StoryThread[];
last_updated?: string;
}
export interface CleanupDuplicatesResponse {
removed_characters: number;
removed_locations: number;
removed_events: number;
message: string;
}
// Экспортируем все типы Story Bible
export type { Character, Location, Event, StoryThread, StoryBible };
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Интерсептор для автоматического добавления токена
apiClient.interceptors.request.use((config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
interface ApiProjectResponse {
id: string;
title: string;
description: string;
created_at: string;
updated_at: string;
}
interface ApiProjectCreate {
title: string;
description: string;
}
// API функции для работы с проектами
export const projectsApi = {
/**
* Получить все проекты
*/
async getAllProjects(): Promise<Project[]> {
const response = await apiClient.get<ApiProjectResponse[]>('/projects');
return response.data.map((p) => ({
...p,
created_at: p.created_at,
updated_at: p.updated_at,
}));
},
/**
* Создать новый проект
*/
async createProject(project: Omit<ApiProjectCreate, 'id'>): Promise<Project> {
const response = await apiClient.post<ApiProjectResponse>('/projects', project);
return {
...response.data,
created_at: response.data.created_at,
updated_at: response.data.updated_at,
};
},
/**
* Обновить проект
*/
async updateProject(
projectId: string,
updates: Partial<Omit<ApiProjectCreate, 'id'>>
): Promise<Project> {
const response = await apiClient.put<ApiProjectResponse>(`/projects/${projectId}`, updates);
return {
...response.data,
created_at: response.data.created_at,
updated_at: response.data.updated_at,
};
},
/**
* Удалить проект
*/
async deleteProject(projectId: string): Promise<void> {
await apiClient.delete(`/projects/${projectId}`);
},
};
// API функции для работы с документами (иерархические)
export const documentsApi = {
/**
* Получить документы в виде дерева
*/
async getDocumentsTree(projectId: string): Promise<DocumentTreeNode[]> {
const response = await apiClient.get<DocumentTreeNode[]>(
`/projects/${projectId}/documents/tree`
);
return response.data;
},
/**
* Получить все документы (плоский список)
*/
async getProjectDocuments(projectId: string): Promise<DocumentNode[]> {
const response = await apiClient.get<DocumentNode[]>(
`/projects/${projectId}/documents`
);
return response.data;
},
/**
* Получить документ по ID
*/
async getDocument(projectId: string, documentId: string): Promise<DocumentNode> {
const response = await apiClient.get<DocumentNode>(
`/projects/${projectId}/documents/${documentId}`
);
return response.data;
},
/**
* Создать документ в проекте
*/
async createDocument(
projectId: string,
title: string,
type: 'book' | 'chapter' | 'scene',
parentId?: string,
content: string = ''
): Promise<DocumentNode> {
const response = await apiClient.post<DocumentNode>(
`/projects/${projectId}/documents`,
{
title,
type,
parent_id: parentId,
content,
}
);
return response.data;
},
/**
* Обновить документ (включая перенос)
*/
async updateDocument(
projectId: string,
documentId: string,
updates: {
title?: string;
content?: string;
parent_id?: string;
order?: number;
}
): Promise<DocumentNode> {
const response = await apiClient.put<DocumentNode>(
`/projects/${projectId}/documents/${documentId}`,
updates
);
return response.data;
},
/**
* Удалить документ с каскадом
*/
async deleteDocument(projectId: string, documentId: string): Promise<void> {
await apiClient.delete(`/projects/${projectId}/documents/${documentId}`);
},
/**
* Получить прямых детей узла
*/
async getChildren(projectId: string, parentId: string): Promise<DocumentNode[]> {
const response = await apiClient.get<DocumentNode[]>(
`/projects/${projectId}/documents/${parentId}/children`
);
return response.data;
},
// --- Legacy функции для обратной совместимости ---
/**
* Обновить контент документа (legacy)
*/
async updateDocumentContent(
projectId: string,
documentId: string,
content: string
): Promise<DocumentNode> {
const updatedDoc = await documentsApi.updateDocument(projectId, documentId, {
content,
});
return updatedDoc;
},
};
// API функции для Story Bible
export const storyBibleApi = {
/**
* Получить Story Bible проекта
*/
async getProjectStoryBible(projectId: string): Promise<StoryBible> {
const response = await apiClient.get<StoryBible>(`/projects/${projectId}/bible`);
return response.data;
},
/**
* Обновить Story Bible проекта
*/
async updateProjectStoryBible(
projectId: string,
bible: StoryBible
): Promise<StoryBible> {
const response = await apiClient.put<StoryBible>(`/projects/${projectId}/bible`, bible);
return response.data;
},
/**
* Очистить дубликаты в Story Bible
*/
async cleanupDuplicates(projectId: string): Promise<CleanupDuplicatesResponse> {
const response = await apiClient.post<CleanupDuplicatesResponse>(
`/projects/${projectId}/bible/cleanup-duplicates`
);
return response.data;
},
};
const apiClientFactory = () => apiClient;
export default apiClientFactory;
/**
* Экспорт документа в выбранный формат (epub, fb2, pdf, docx)
*/
export async function exportDocument(
projectId: string,
documentId: string,
format: 'epub' | 'fb2' | 'pdf' | 'docx'
): Promise<void> {
try {
const response = await apiClient.post(`/export`,
{ project_id: projectId, document_id: documentId, format },
{ responseType: 'blob' }
);
// Создаем временную ссылку для скачивания
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
// Определяем расширение файла
const extension = format === 'docx' ? 'docx' : format;
link.setAttribute('download', `export.${extension}`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Export failed: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
/**
* Получить список доступных моделей для провайдера
*/
export async function getAvailableModels(provider: Provider): Promise<ModelsResponse> {
try {
const response = await apiClient.get<ModelsResponse>(`/ai/models?provider=${provider}`);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Failed to fetch models: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
/**
* Получить данные модели для конкретного endpoint (generation, background, analysis)
*/
export async function getModelForTask(taskType: keyof ModelSettings): Promise<ModelsResponse> {
try {
// Получаем настройки из localStorage
if (typeof window !== 'undefined') {
const savedSettings = localStorage.getItem('modelSettings');
if (savedSettings) {
const settings: ModelSettings = JSON.parse(savedSettings);
const modelSetting = settings[taskType];
if (modelSetting) {
const response = await apiClient.get<ModelsResponse>(
`/ai/models?provider=${modelSetting.provider}`
);
// Обновляем default_model на выбранный пользователем
return {
...response.data,
default_model: modelSetting.model,
};
}
}
}
// Если нет настроек, используем дефолтные
const response = await apiClient.get<ModelsResponse>(`/ai/models?provider=ollama`);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Failed to fetch model for ${taskType}: ${error.response?.data?.detail || error.message}`);
}
throw error;
}
}
// === ФУНКЦИИ ГЕНЕРАЦИИ ИЗОБРАЖЕНИЙ ===
export interface GenerateImageRequest {
prompt: string;
project_id: string;
document_id: string; // ID документа (книги или главы)
document_type: 'book' | 'chapter'; // Тип документа
model?: string;
size?: string;
}
export interface GenerateImageResponse {
image_id: string;
filename: string;
path: string;
prompt: string;
model: string;
data_url: string; // base64 изображение для немедленного отображения
document_id: string;
document_type: string;
}
/**
* Сгенерировать изображение
*/
export async function generateImage(request: GenerateImageRequest): Promise<GenerateImageResponse> {
const response = await apiClient.post<GenerateImageResponse>('/ai/generate-image', request);
return response.data;
}
/**
* Получить URL изображения
*/
export function getImageUrl(projectId: string, filename: string): string {
const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace('/api', '') || 'http://localhost:8000';
return `${baseUrl}/api/images/${projectId}/${filename}`;
}
// === ФУНКЦИИ АВТОРИЗАЦИИ ===
/**
* Войти в систему
*/
export async function login(username: string, password: string): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/login', { username, password });
return response.data;
}
/**
* Зарегистрировать нового пользователя
*/
export async function register(username: string, email: string, password: string): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/register', { username, email, password });
return response.data;
}
/**
* Получить текущего пользователя
*/
export async function fetchCurrentUser(): Promise<User> {
const response = await apiClient.get<User>('/auth/me');
return response.data;
}
@@ -0,0 +1,88 @@
import { Mark, mergeAttributes } from '@tiptap/core';
export interface FontSizeOptions {
types: string[];
HTMLAttributes: Record<string, unknown>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (size: string) => ReturnType;
unsetFontSize: () => ReturnType;
};
}
}
export const FontSize = Mark.create<FontSizeOptions>({
name: 'fontSize',
addOptions() {
return {
types: ['fontSize'],
HTMLAttributes: {},
};
},
addAttributes() {
return {
fontSize: {
default: null as string | null,
parseHTML: element => element.getAttribute('data-font-size'),
renderHTML: attributes => {
if (!attributes.fontSize) {
return {};
}
return {
'data-font-size': attributes.fontSize,
};
},
},
};
},
parseHTML() {
return [
{
tag: 'span[data-font-size]',
},
];
},
renderHTML({ mark, HTMLAttributes }) {
const fontSize = mark.attrs.fontSize;
if (!fontSize) {
return {};
}
return [
'span',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `font-size: ${fontSize}`,
}),
0,
];
},
addCommands() {
return {
setFontSize: (size: string) => {
return ({ chain }) => {
return chain()
.setMark(this.name, { fontSize: size })
.run();
};
},
unsetFontSize: () => {
return ({ chain }) => {
return chain()
.setMark(this.name, { fontSize: null })
.run();
};
},
};
},
addKeyboardShortcuts() {
return {};
},
});
+422
View File
@@ -0,0 +1,422 @@
import { create } from 'zustand';
import { projectsApi, documentsApi, storyBibleApi, type StoryBible, type Project, type User, login, register, fetchCurrentUser } from '@/lib/api';
// Типы данных
export type DocumentType = 'book' | 'chapter' | 'scene';
export interface DocumentNode {
id: string;
project_id: string;
parent_id: string | null;
title: string;
content: string;
type: 'book' | 'chapter' | 'scene';
order: number;
created_at: string;
updated_at: string;
}
export interface DocumentTreeNode extends Omit<DocumentNode, 'children'> {
children: DocumentTreeNode[];
}
// Для обратной совместимости
export type Document = DocumentNode;
export interface ModelSettings {
generation: {
provider: 'ollama' | 'openrouter';
model: string;
};
background: {
provider: 'ollama' | 'openrouter';
model: string;
};
analysis: {
provider: 'ollama' | 'openrouter';
model: string;
};
}
export interface AppState {
// Авторизация
user: User | null;
isAuthenticated: boolean;
isLoadingAuth: boolean;
// Проекты
projects: Project[];
activeProjectId: string | null;
// Документы (иерархические)
documentsTree: DocumentTreeNode[];
documents: DocumentNode[]; // Плоский список для быстрого доступа
activeDocumentId: string | null;
// Story Bible
storyBible: StoryBible | null;
// Настройки
isLoading: boolean;
isSaving: boolean;
modelSettings: ModelSettings;
autoUpdateBible: boolean;
// Actions для проектов
setUser: (user: User | null) => void;
login: (username: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>;
logout: () => void;
checkAuth: () => Promise<void>;
loadProjects: () => Promise<void>;
setActiveProject: (projectId: string) => Promise<void>;
createProject: (title: string, description?: string) => Promise<Project>;
deleteProject: (projectId: string) => Promise<void>;
// Actions для документов (иерархические)
loadDocumentsTree: (projectId: string) => Promise<void>;
setActiveDocument: (documentId: string) => void;
switchToDocument: (documentId: string) => Promise<void>;
createDocument: (
projectId: string,
title: string,
type: 'book' | 'chapter' | 'scene',
parentId?: string,
content?: string
) => Promise<DocumentNode>;
updateDocument: (
projectId: string,
documentId: string,
updates: { title?: string; content?: string; parent_id?: string; order?: number }
) => Promise<void>;
updateDocumentContent: (
projectId: string,
documentId: string,
content: string
) => Promise<void>;
deleteDocument: (projectId: string, documentId: string) => Promise<void>;
// Actions для Story Bible (теперь требуют projectId)
loadStoryBible: (projectId: string) => Promise<void>;
setStoryBible: (bible: StoryBible) => void;
updateStoryBible: (projectId: string, bible: StoryBible) => Promise<void>;
// Actions для настроек
setModelSettings: (settings: ModelSettings) => void;
updateModelSetting: (
taskType: keyof ModelSettings,
provider: 'ollama' | 'openrouter',
model: string
) => void;
setAutoUpdateBible: (enabled: boolean) => void;
}
const defaultModelSettings: ModelSettings = {
generation: { provider: 'ollama', model: 'qwen2.5:1.5b' },
background: { provider: 'ollama', model: 'qwen2.5:1.5b' },
analysis: { provider: 'ollama', model: 'qwen2.5:1.5b' },
};
export const useAppStore = create<AppState>((set, get) => ({
// Авторизация
user: null,
isAuthenticated: false,
isLoadingAuth: true,
// Проекты
projects: [],
activeProjectId: null,
// Документы (иерархические)
documentsTree: [],
documents: [], // Плоский список для быстрого доступа
activeDocumentId: null,
// Story Bible
storyBible: null,
// Загрузка/сохранение
isLoading: false,
isSaving: false,
// Настройки моделей
modelSettings: defaultModelSettings,
autoUpdateBible: true,
// === МЕТОДЫ АВТОРИЗАЦИИ ===
setUser: (user) => set({ user, isAuthenticated: !!user }),
login: async (username, password) => {
const response = await login(username, password);
localStorage.setItem('auth_token', response.access_token);
const user = await fetchCurrentUser();
set({ user, isAuthenticated: true });
await get().loadProjects(); // Загружаем проекты после входа
},
register: async (username, email, password) => {
const response = await register(username, email, password);
localStorage.setItem('auth_token', response.access_token);
const user = await fetchCurrentUser();
set({ user, isAuthenticated: true });
await get().loadProjects();
},
logout: () => {
localStorage.removeItem('auth_token');
set({ user: null, isAuthenticated: false, projects: [], activeProjectId: null });
},
checkAuth: async () => {
set({ isLoadingAuth: true });
const token = localStorage.getItem('auth_token');
if (token) {
try {
const user = await fetchCurrentUser();
set({ user, isAuthenticated: true });
await get().loadProjects();
} catch (error) {
console.error('Token invalid, logging out');
get().logout();
}
}
set({ isLoadingAuth: false });
},
// Загрузка проектов
loadProjects: async () => {
try {
const { getAllProjects } = projectsApi;
const projects = await getAllProjects();
set({ projects });
// Если есть проекты и нет активного, выбираем первый
if (projects.length > 0 && !get().activeProjectId) {
await get().setActiveProject(projects[0].id);
}
} catch (error) {
console.error('Failed to load projects:', error);
}
},
// Установка активного проекта
setActiveProject: async (projectId: string) => {
set({ activeProjectId: projectId });
if (typeof window !== 'undefined') {
localStorage.setItem('activeProjectId', projectId);
}
// Загружаем документы и Bible этого проекта
await Promise.all([
get().loadDocumentsTree(projectId),
get().loadStoryBible(projectId),
]);
},
// Создание проекта
createProject: async (title, description = '') => {
const { createProject: apiCreateProject } = projectsApi;
const project = await apiCreateProject({ title, description });
set((state) => ({ projects: [...state.projects, project] }));
return project;
},
// Удаление проекта
deleteProject: async (projectId) => {
const { deleteProject: apiDeleteProject } = projectsApi;
await apiDeleteProject(projectId);
set((state) => ({
projects: state.projects.filter(p => p.id !== projectId),
activeProjectId: state.activeProjectId === projectId ? null : state.activeProjectId,
}));
},
// Загрузка дерева документов проекта
loadDocumentsTree: async (projectId) => {
set({ isLoading: true });
try {
const { getDocumentsTree, getProjectDocuments } = documentsApi;
const [tree, flat] = await Promise.all([
getDocumentsTree(projectId),
getProjectDocuments(projectId),
]);
// Выбираем активным документом первую сцену, или null
const firstScene = flat.find(d => d.type === 'scene');
set({
documentsTree: tree,
documents: flat,
activeDocumentId: firstScene?.id || null,
isLoading: false,
});
} catch (error) {
console.error('Failed to load documents tree:', error);
set({ isLoading: false });
}
},
// Установка активного документа
setActiveDocument: (documentId) => {
set({ activeDocumentId: documentId });
},
// Безопасное переключение документа с предварительным сохранением
switchToDocument: async (documentId: string) => {
const { activeDocumentId, activeProjectId, documents } = get();
// Сначала сохраняем текущий документ (если есть)
if (activeDocumentId && activeProjectId) {
const currentDoc = documents.find(d => d.id === activeDocumentId);
if (currentDoc) {
try {
// Вызываем глобальный callback из Editor для сохранения текущего контента
if (window.saveCurrentEditorContent) {
console.log('[Store] Saving current document before switch:', activeDocumentId);
await window.saveCurrentEditorContent();
}
} catch (error) {
console.error('[Store] Failed to save before switch:', error);
}
}
}
// Переключаем активный документ
set({ activeDocumentId: documentId });
},
// Создание нового документа
createDocument: async (projectId, title, type, parentId, content = '') => {
const { createDocument: apiCreate } = documentsApi;
const doc = await apiCreate(projectId, title, type, parentId, content);
await get().loadDocumentsTree(projectId);
return doc;
},
// Обновление документа
updateDocument: async (projectId, documentId, updates) => {
const { updateDocument: apiUpdate } = documentsApi;
await apiUpdate(projectId, documentId, updates);
// Обновляем в store (плоский список)
set((state) => ({
documents: state.documents.map(doc =>
doc.id === documentId ? { ...doc, ...updates, updated_at: new Date().toISOString() } : doc
),
}));
},
// Удаление документа
deleteDocument: async (projectId, documentId) => {
const { deleteDocument: apiDelete } = documentsApi;
await apiDelete(projectId, documentId);
await get().loadDocumentsTree(projectId);
// Если удалённый документ был активным, сбрасываем
if (get().activeDocumentId === documentId) {
const flat = get().documents;
const firstScene = flat.find(d => d.type === 'scene');
set({ activeDocumentId: firstScene?.id || null });
}
},
// Загрузка Story Bible проекта
loadStoryBible: async (projectId) => {
try {
const { getProjectStoryBible } = storyBibleApi;
const bible = await getProjectStoryBible(projectId);
set({ storyBible: bible });
} catch (error) {
console.error('Failed to load Story Bible:', error);
}
},
// Установка Story Bible
setStoryBible: (bible) => set({ storyBible: bible }),
// Обновление Story Bible через API
updateStoryBible: async (projectId, bible) => {
const { updateProjectStoryBible } = storyBibleApi;
await updateProjectStoryBible(projectId, bible);
set({ storyBible: bible });
},
// Управление настройками моделей
setModelSettings: (settings) => {
set({ modelSettings: settings });
// Сохраняем в localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('modelSettings', JSON.stringify(settings));
}
},
updateModelSetting: (taskType, provider, model) => {
set((state) => {
const newSettings = {
...state.modelSettings,
[taskType]: { provider, model },
};
if (typeof window !== 'undefined') {
localStorage.setItem('modelSettings', JSON.stringify(newSettings));
}
return { modelSettings: newSettings };
});
},
// Обновление только контента документа (обёртка над updateDocument)
updateDocumentContent: async (projectId, documentId, content) => {
try {
const { updateDocumentContent: apiUpdate } = documentsApi;
await apiUpdate(projectId, documentId, content);
// ВАЖНО: Обновляем в store сразу, не дожидаясь перезагрузки
set((state) => ({
documents: state.documents.map(doc =>
doc.id === documentId ? { ...doc, content, updated_at: new Date().toISOString() } : doc
),
}));
console.log('[Store] Document content updated in store:', documentId);
} catch (error) {
console.error('[Store] Failed to update document:', error);
throw error;
}
},
// Переключатель автообновления Story Bible
setAutoUpdateBible: (enabled) => {
set({ autoUpdateBible: enabled });
if (typeof window !== 'undefined') {
localStorage.setItem('autoUpdateBible', JSON.stringify(enabled));
}
},
}));
// Загрузка сохранённых состояний при инициализации
if (typeof window !== 'undefined') {
const savedProjectId = localStorage.getItem('activeProjectId');
if (savedProjectId) {
useAppStore.setState({ activeProjectId: savedProjectId });
}
const savedSettings = localStorage.getItem('modelSettings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
useAppStore.setState({ modelSettings: parsed });
} catch (e) {
console.error('Failed to load model settings:', e);
}
}
const savedAutoUpdateBible = localStorage.getItem('autoUpdateBible');
if (savedAutoUpdateBible !== null) {
try {
useAppStore.setState({ autoUpdateBible: JSON.parse(savedAutoUpdateBible) });
} catch (e) {
console.error('Failed to load autoUpdateBible setting:', e);
}
}
// Проверка авторизации
useAppStore.getState().checkAuth();
}
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Скрипт для сборки и тестирования Docker-образа frontend локально
echo "🔨 Building frontend Docker image..."
docker build -t writer-ide-frontend:local .
if [ $? -ne 0 ]; then
echo "❌ Build failed!"
exit 1
fi
echo "✅ Build successful!"
echo ""
echo "🚀 To run the container:"
echo "docker run -p 3000:3000 -e NEXT_PUBLIC_API_URL=http://localhost:8000/api writer-ide-frontend:local"
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}