Initial commit: papa-yu v2.4.4

- Schema version (x_schema_version, schema_hash) в prompt/trace
- Кеш read/search/logs/env (ContextCache) в plan-цикле
- Контекст-диета: MAX_FILES=8, MAX_FILE_CHARS=20k, MAX_TOTAL_CHARS=120k
- Plan→Apply двухфазность, NO_CHANGES, path sanitization
- Protected paths, content validation, EOL normalization
- Trace (PAPAYU_TRACE), redaction (PAPAYU_TRACE_RAW)
- Preview diff, undo/redo, transactional apply

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yuriy 2026-01-31 11:33:19 +03:00
commit e76236dc55
98 changed files with 20527 additions and 0 deletions

44
.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# Секреты — не коммитить
.env
.env.*
!.env.example
*.key
*.pem
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Vite / Build
dist/
*.local
# Rust / Tauri
src-tauri/target/
**/*.rs.bk
Cargo.lock
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# TypeScript cache
*.tsbuildinfo
tsconfig.tsbuildinfo
tsconfig.node.tsbuildinfo
# Временные файлы
*.tmp
*.temp

108
AUDIT.md Normal file
View File

@ -0,0 +1,108 @@
# Полный аудит приложения PAPA YU
**Дата:** 2026-01-28
**Цель:** проверка компонентов, связей UI ↔ backend, исправление отображения блока выбора пути к папкам и заключение.
---
## 1. Структура проекта
| Путь | Назначение |
|------|------------|
| `src/` | React (Vite) — UI |
| `src/pages/Tasks.tsx` | Страница «Задачи» — анализ проекта, выбор папок, preview/apply/undo/redo |
| `src/pages/Dashboard.tsx` | Страница «Панель управления» |
| `src/App.tsx` | Роутинг, Layout (header + main) |
| `src-tauri/src/` | Rust (Tauri) — команды, tx, types |
---
## 2. Проверенные компоненты
### 2.1 UI
- **App.tsx** — маршруты `/` (Tasks), `/control-panel` (Dashboard). Layout: header с навигацией, main с `overflow: visible` (исправлено).
- **Tasks.tsx** — блок «Путь к папке проекта»:
- Расположен **первым** под заголовком «Анализ проекта».
- Секция с `data-section="path-selection"` и классом `tasks-sources`.
- Две кнопки: **«Выбрать папку»** (основная синяя), **«+ Добавить ещё папку»**.
- Список выбранных папок или текст «Папки не выбраны. Нажмите кнопку «Выбрать папку» выше.».
- Ниже: поле ввода пути и кнопка «Отправить».
- **index.css** — правило для `.tasks-sources[data-section="path-selection"]`: `display: block !important`, `visibility: visible !important`, чтобы блок не скрывался.
### 2.2 Связи UI → Backend (invoke)
| Действие в UI | Команда Tauri | Файл Rust |
|---------------|---------------|-----------|
| Загрузка списка папок при монтировании | `get_folder_links` | folder_links.rs |
| Сохранение списка папок | `set_folder_links` (links: { paths }) | folder_links.rs |
| Анализ + preview + apply (пакет) | `run_batch_cmd` (payload: paths, confirm_apply, auto_check, selected_actions) | run_batch.rs |
| Состояние undo/redo | `get_undo_redo_state_cmd` | undo_last.rs, tx/store.rs |
| Откат | `undo_last` | undo_last.rs |
| Повтор | `redo_last` | redo_last.rs |
| Генерация плана (v2.4) | `generate_actions` | generate_actions.rs |
Выбор папки через диалог: `open({ directory: true })` из `@tauri-apps/plugin-dialog` — плагин зарегистрирован в `lib.rs` (`tauri_plugin_dialog::init()`).
---
## 3. Backend (Rust)
### 3.1 Зарегистрированные команды (lib.rs)
- `analyze_project_cmd`, `preview_actions_cmd`, `apply_actions_cmd`, `run_batch_cmd`
- `undo_last`, `undo_available`, `redo_last`, `get_undo_redo_state_cmd`
- `generate_actions`
- `get_folder_links`, `set_folder_links`
### 3.2 Модули
- **commands/** — analyze_project, apply_actions, preview_actions, run_batch, undo_last, redo_last, generate_actions, folder_links, auto_check.
- **tx/** — limits (preflight), store (undo/redo stacks), mod (snapshot_before, rollback_tx, apply_actions_to_disk, collect_rel_paths, write_manifest, read_manifest, etc.).
- **types** — ApplyPayload, ApplyResult, TxManifest, Action, ActionKind, AnalyzeReport, BatchPayload, BatchEvent, etc.
### 3.3 Folder links
- `FolderLinks { paths: Vec<String> }` — сериализуется в `app_data_dir/folder_links.json`.
- `load_folder_links`, `save_folder_links` — используются в `get_folder_links` / `set_folder_links`.
Связь с UI: при загрузке Tasks вызывается `get_folder_links` и при необходимости обновляется `folderLinks`; при добавлении/удалении папки вызывается `set_folder_links`. Формат `{ links: { paths } }` соответствует типу `FolderLinks`.
---
## 4. Внесённые исправления
1. **Блок выбора пути к папке (Tasks.tsx)**
- Секция «Путь к папке проекта» вынесена в начало страницы (сразу под заголовком).
- Заголовок секции: «Путь к папке проекта», подпись с указанием нажать кнопку или ввести путь.
- Кнопки «Выбрать папку» и «+ Добавить ещё папку» оформлены заметно (размер, контраст, тень у основной).
- Добавлены `className="tasks-sources"` и `data-section="path-selection"` для стилей и отладки.
- Секция с рамкой, фоном и `minHeight: 140px`, чтобы блок всегда занимал место и был виден.
- В строке ввода оставлены только поле пути и «Отправить» (дублирующая кнопка «Выбрать папку» убрана, чтобы не путать с блоком выше).
2. **Layout (App.tsx)**
- Для `main` заданы `overflow: visible` и `minHeight: 0`, чтобы контент не обрезался.
3. **Глобальные стили (index.css)**
- Добавлено правило для `.tasks-sources[data-section="path-selection"]`: блок принудительно видим.
---
## 5. Рекомендации после обновления кода
- Перезапустить приложение: `cd papa-yu/src-tauri && cargo tauri dev`.
- В браузере/WebView сделать жёсткое обновление (Ctrl+Shift+R / Cmd+Shift+R), чтобы подтянуть новый UI без кэша.
- Если используется только фронт (Vite): перезапустить `npm run dev` и обновить страницу.
После этого в начале страницы «Задачи» должен отображаться блок «Путь к папке проекта» с кнопками «Выбрать папку» и «+ Добавить ещё папку» и списком выбранных папок.
---
## 6. Заключение
- **Компоненты:** App, Tasks, Dashboard, Layout и точки входа (main.tsx, index.html) проверены; маршруты и вложенность корректны.
- **Связи UI ↔ backend:** вызовы `get_folder_links`, `set_folder_links`, `run_batch_cmd`, `get_undo_redo_state_cmd`, `undo_last`, `redo_last` соответствуют зарегистрированным командам и типам (FolderLinks, BatchPayload, ApplyPayload и т.д.).
- **Исправления:** блок выбора пути к папкам сделан первым и визуально выделен; добавлены гарантии видимости через разметку и CSS; дублирование кнопки убрано.
- **Ошибки:** явных ошибок в компонентах и связях не выявлено. Если на экране по-прежнему не видно кнопок и блока, наиболее вероятны кэш сборки или WebView — выполнить перезапуск и жёсткое обновление по п. 5.
Аудит выполнен. Состояние: **исправления внесены, рекомендации по обновлению даны.**

72
CHANGELOG.md Normal file
View File

@ -0,0 +1,72 @@
# Changelog
Все значимые изменения в проекте PAPA YU фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/).
---
## [2.4.4] — 2025-01-31
### Добавлено
- **UX:** история сессий по проекту — блок «История сессий» с раскрывающимся списком сессий (дата, количество событий, последнее сообщение); обновление списка после agentic run.
- **UX:** в блоке профиля отображаются лимиты (max_actions_per_tx, timeout_sec).
- **UX:** фильтр расширений в диалоге «Прикрепить файл» (исходники и конфиги: .ts, .tsx, .js, .jsx, .rs, .py, .json, .toml, .md, .yml, .yaml, .css, .html, .xml).
- **UX:** горячие клавиши — Ctrl+Enter (Cmd+Enter): отправить/запустить анализ; Escape: сбросить превью изменений.
- **UX:** тёмная тема — переключатель в боковой панели, CSS-переменные для обоих режимов, сохранение выбора в localStorage, поддержка системных настроек.
- **UX:** экспорт/импорт настроек — кнопки в боковой панели для сохранения и восстановления всех настроек (проекты, профили, сессии, папки) в JSON-файл.
- **Тестирование:** юнит-тесты в Rust для `detect_project_type`, `get_project_limits`, `is_protected_file`, `is_text_allowed`, `settings_export` (18 тестов).
- **Тестирование:** тестовые фикстуры в `tests/fixtures/` — минимальные проекты для E2E тестирования (minimal-node, minimal-rust).
- **Документация:** E2E сценарий в `docs/E2E_SCENARIO.md`; обновлён README до v2.4.4; README для тестов в `tests/README.md`.
- **Контекст прикреплённых файлов:** в отчёт и batch передаётся список прикреплённых файлов (`attached_files` в `BatchPayload` и `AnalyzeReport`); фронт передаёт его при вызове `runBatchCmd`.
- **LLM-планировщик:** при заданном `PAPAYU_LLM_API_URL` команда «Предложить исправления» вызывает OpenAI-совместимый API (OpenAI, Ollama и др.); ответ парсится в план действий (CREATE_FILE, CREATE_DIR и т.д.). Без настройки — эвристический план по отчёту.
- **Бэкенд:** команды `export_settings` и `import_settings` для резервного копирования и переноса настроек между машинами.
- **Конфиг:** расширенный allowlist команд verify (`verify_allowlist.json`) — добавлены cargo clippy, tsc --noEmit, mypy, pytest --collect-only.
- **Инфраструктура:** инициализирован Git-репозиторий с улучшенным .gitignore.
- **Preview diff в propose flow:** после получения плана автоматически вызывается `preview_actions`, diffs отображаются в UI.
- **ERR_UPDATE_WITHOUT_BASE:** в режиме APPLY UPDATE_FILE разрешён только для файлов, прочитанных в Plan (FILE[path] или === path ===).
- **Protected paths:** denylist для `.env`, `*.pem`, `*.key`, `*.p12`, `id_rsa*`, `**/secrets/**`.
- **Content validation:** запрет NUL, >10% non-printable = ERR_PSEUDO_BINARY; лимиты max_path_len=240, max_actions=200, max_total_content_bytes=5MB.
- **EOL:** `PAPAYU_NORMALIZE_EOL=lf` — нормализация \r\n→\n и trailing newline.
- **Наблюдаемость:** trace_id (UUID) на каждый propose; лог-ивенты LLM_REQUEST_SENT, LLM_RESPONSE_OK, VALIDATION_FAILED, APPLY_SUCCESS, APPLY_ROLLBACK, PREVIEW_READY.
- **Трассировка:** `PAPAYU_TRACE=1` — запись в `.papa-yu/traces/<trace_id>.json`.
- **Детерминизм LLM:** temperature=0, max_tokens=65536, top_p=1, presence_penalty=0, frequency_penalty=0 (PAPAYU_LLM_TEMPERATURE, PAPAYU_LLM_MAX_TOKENS).
- **Capability detection:** при ошибке API response_format — автоматический retry без response_format (Ollama и др.).
- **Schema version:** `x_schema_version` в llm_response_schema.json; schema_hash (sha256) в trace; LLM_PLAN_SCHEMA_VERSION в prompt.
- **Кеш контекста:** read_file/search/logs/env кешируются в plan-цикле; CONTEXT_CACHE_HIT/MISS.
- **Контекст-диета:** PAPAYU_CONTEXT_MAX_FILES=8, MAX_FILE_CHARS=20k, MAX_TOTAL_CHARS=120k; head+tail truncation; CONTEXT_DIET_APPLIED.
### Изменено
- Лимиты профиля применяются в `apply_actions_tx` и `run_batch` — при превышении `max_actions_per_tx` возвращается ошибка TOO_MANY_ACTIONS.
- Таймаут проверок в verify и auto_check задаётся из профиля (`timeout_sec`); в `verify_project` добавлен таймаут на выполнение каждой проверки (spawn + try_wait + kill при превышении).
- Синхронизированы версии в package.json, Cargo.toml и tauri.conf.json.
---
## [2.4.3] — ранее
### Реализовано
- Профиль по пути (тип проекта, лимиты, goal_template).
- Agentic run — цикл анализ → план → превью → применение → проверка → откат при ошибке.
- Прикрепление файлов, кнопка «Прикрепить файл».
- Guard опасных изменений (is_protected_file, is_text_allowed).
- Подтверждение Apply (user_confirmed).
- Единый API-слой (src/lib/tauri.ts), типы в src/lib/types.ts.
- Компоненты PathSelector, AgenticResult, хук useUndoRedo.
- Транзакционное apply с snapshot и откатом при падении auto_check.
- Undo/Redo по последней транзакции.
- Единый batch endpoint (run_batch): analyze → preview → apply (при confirmApply) → autoCheck.
---
## [2.3.2] — ранее
- Apply + Real Undo (snapshot в userData/history, откат при падении check).
- AutoCheck для Node, Rust, Python.
- Actions: README, .gitignore, tests/, .env.example.
- UX: двухфазное применение, кнопки «Показать исправления», «Применить», «Отмена», «Откатить последнее».
- Folder Links (localStorage + userData/folder_links.json).
- Брендинг PAPA YU, минимальный размер окна 1024×720.

View File

@ -0,0 +1,17 @@
#!/bin/bash
# PAPA YU — сборка приложения и запуск (первая установка или после обновления кода).
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
echo " Сборка PAPA YU..."
export CI=false
if ! npm run tauri build; then
echo ""
echo " Ошибка сборки. Проверьте: npm install, Rust и Xcode Command Line Tools."
read -n 1 -s -r -p " Нажмите любую клавишу..."
exit 1
fi
BUNDLE="$SCRIPT_DIR/src-tauri/target/release/bundle/macos/PAPA YU.app"
[ -d "$BUNDLE" ] && open "$BUNDLE" || open "$SCRIPT_DIR/src-tauri/target/release/bundle/macos"
echo " Готово."

25
PAPA YU.command Executable file
View File

@ -0,0 +1,25 @@
#!/bin/bash
# PAPA YU — запуск приложения (двойной клик). Сборка не выполняется.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUNDLE_DIR="$SCRIPT_DIR/src-tauri/target/release/bundle/macos"
find_app() {
[ -d "$BUNDLE_DIR/PAPA YU.app" ] && echo "$BUNDLE_DIR/PAPA YU.app" && return
for f in "$BUNDLE_DIR"/*.app; do
[ -d "$f" ] && echo "$f" && return
done
echo ""
}
APP=$(find_app)
if [ -n "$APP" ]; then
open "$APP"
exit 0
fi
echo ""
echo " PAPA YU не найден."
echo " Для первой сборки запустите: «PAPA YU — Сборка и запуск.command»"
echo ""
read -n 1 -s -r -p " Нажмите любую клавишу..."
exit 1

111
README.md Normal file
View File

@ -0,0 +1,111 @@
# PAPA YU v2.4.4
Десктопное приложение для анализа проекта и автоматических исправлений (README, .gitignore, tests/, структура) с **транзакционным apply**, **реальным undo** и **autoCheck с откатом**.
## Единственная папка проекта
Вся разработка, сборка и запуск ведутся из **этой папки** (например `/Users/.../Desktop/papa-yu`). ТЗ и спецификации лежат отдельно в папке **папа-ю** на рабочем столе (не переносятся). Подробнее: `docs/ЕДИНАЯ_ПАПКАРОЕКТА.md`.
**Запуск без терминала:** двойной клик по `PAPA YU.command` (только запуск) или по `PAPA YU — Сборка и запуск.command` (сборка + запуск).
## Требования
- Node.js 18+
- Rust 1.70+
- npm
## Запуск
```bash
cd papa-yu
npm install
npm run tauri dev
```
Из корня проекта можно также: `cd src-tauri && cargo tauri dev`.
**Если в окне видно «Could not fetch a valid…»** — фронт не загрузился. Запускайте приложение только так: в терминале из папки проекта выполните `npm run tauri dev` (это поднимает и Vite, и Tauri). Не открывайте скомпилированный .app без dev-сервера, если хотите видеть интерфейс.
## Сборка
```bash
npm run tauri build
```
## v2.4.4 — что реализовано
### Анализ и профиль
- **Анализ по пути** — выбор папки или ввод пути вручную; анализ возвращает отчёт (findings, recommendations, actions, action_groups, fix_packs).
- **Профиль по пути** — автоматическое определение типа проекта (React/Vite, Next.js, Node, Rust, Python) и лимитов (max_actions_per_tx, timeout_sec, max_files). Профиль и лимиты отображаются в форме.
### Применение и откат
- **Транзакционное apply** — перед применением создаётся снимок; после apply выполняется autoCheck (cargo check / npm run build и т.д.) с таймаутом из профиля. При падении проверки — автоматический откат.
- **Лимиты профиля** — в `apply_actions_tx` и `run_batch` проверяется число действий против `max_actions_per_tx`; при превышении возвращается ошибка TOO_MANY_ACTIONS. Таймаут проверок задаётся из профиля.
- **Undo/Redo** — откат последней транзакции и повтор; состояние отображается в UI.
### Безопасность
- **Защита путей** — запрещено изменение служебных путей (.git, node_modules, target, dist и т.д.) и бинарных файлов; разрешены только текстовые расширения (см. guard в коде).
- **Подтверждение** — применение только при явном подтверждении пользователя (user_confirmed).
- **Allowlist команд** — в verify и auto_check выполняются только разрешённые команды с фиксированными аргументами (конфиг в `src-tauri/config/verify_allowlist.json`).
### UX
- **Папки и файлы** — выбор папки, прикрепление файлов (с фильтром расширений: .ts, .tsx, .rs, .py, .json, .toml и др.), ручной ввод пути.
- **История сессий** — по выбранному проекту отображается список сессий (дата, количество событий); после agentic run список обновляется.
- **Горячие клавиши** — Ctrl+Enter (Cmd+Enter на Mac): отправить/запустить анализ; Escape: сбросить превью изменений.
- **Тёмная тема** — переключатель в боковой панели; выбор сохраняется в localStorage; поддержка системных настроек темы.
- **Экспорт/импорт настроек** — кнопки «Экспорт» и «Импорт» в боковой панели для сохранения и восстановления всех настроек (проекты, профили, сессии, папки) в JSON-файл.
### Режимы
- **Batch** — анализ → превью → при необходимости применение с проверками (одна команда `run_batch`).
- **Исправить автоматически (agentic run)** — цикл: анализ → план → превью → применение → проверка; при неудаче проверки — откат и повтор в пределах max_attempts.
- **Безопасные исправления в один клик** — генерация безопасных действий по отчёту → превью → применение с проверкой.
- **Предложить исправления** — план по отчёту и цели: при наличии настройки LLM — вызов внешнего API (OpenAI/Ollama и др.), иначе эвристика.
### LLM-планировщик (опционально)
Для кнопки «Предложить исправления» можно включить генерацию плана через OpenAI-совместимый API. Задайте переменные окружения перед запуском приложения:
- **`PAPAYU_LLM_API_URL`** — URL API (обязательно), например:
- OpenAI: `https://api.openai.com/v1/chat/completions`
- Ollama (локально): `http://localhost:11434/v1/chat/completions`
- **`PAPAYU_LLM_API_KEY`** — API-ключ (для OpenAI и облачных API; для Ollama можно не задавать).
- **`PAPAYU_LLM_MODEL`** — модель (по умолчанию `gpt-4o-mini`), для Ollama — например `llama3.2`.
- **`PAPAYU_LLM_STRICT_JSON`** — при `1`/`true` добавляет `response_format: { type: "json_schema", ... }` (OpenAI Structured Outputs; Ollama может не поддерживать).
**Поведение strict / best-effort:**
- Если strict включён: приложение отправляет `response_format` в API; при невалидном ответе — локальная валидация схемы отклоняет и выполняется **1 авто-ретрай** с repair-подсказкой («Верни ТОЛЬКО валидный JSON…»).
- Если strict выключен или провайдер не поддерживает: **best-effort** парсинг (извлечение из markdown), затем локальная валидация схемы; при неудаче — тот же repair-ретрай.
Пример для Ollama (без ключа, локально):
```bash
export PAPAYU_LLM_API_URL="http://localhost:11434/v1/chat/completions"
export PAPAYU_LLM_MODEL="llama3.2"
npm run tauri dev
```
После этого кнопка «Предложить исправления» будет строить план через выбранный LLM.
Если `PAPAYU_LLM_API_URL` не задан или пуст, используется встроенная эвристика (README, .gitignore, LICENSE, .env.example по правилам).
### Тестирование
- **Юнит-тесты (Rust)** — тесты для `detect_project_type`, `get_project_limits`, `is_protected_file`, `is_text_allowed` (см. `src-tauri/src/commands/get_project_profile.rs` и `apply_actions_tx.rs`). Запуск: `cd src-tauri && cargo test`.
- **E2E сценарий** — описание сценария «анализ → применение → undo» и критерии успеха см. в `docs/E2E_SCENARIO.md`.
### Архитектура
- **Фронт:** React 18, Vite 5, TypeScript; типы в `src/lib/types.ts`, единый API-слой в `src/lib/tauri.ts`; компоненты PathSelector, AgenticResult, хук useUndoRedo.
- **Бэкенд:** Tauri 2, Rust; команды в `src-tauri/src/commands/`, транзакции и undo/redo в `tx/`, verify с таймаутом в `verify.rs`.
## Документация
- `docs/IMPROVEMENTS.md` — рекомендации по улучшениям.
- `docs/E2E_SCENARIO.md` — E2E сценарий и критерии успеха.
- `docs/ОЦЕНКА_И_СЦЕНАРИЙ_РАССКАЗА.md` — оценка необходимости обновлений и сценарий рассказа о программе по модулям.
- `CHANGELOG.md` — история изменений по версиям.

70
docs/AGENT_CONTRACT.md Normal file
View File

@ -0,0 +1,70 @@
# Контракт поведения агента (оркестратор)
Это не prompt, а логика приложения: когда агент должен спрашивать, когда действовать, что запрещено.
---
## Когда агент должен спрашивать
- Нет языка/версии/runtime (неясно, Python 3.11 или Node 18).
- Отсутствуют логи/stacktrace (пользователь написал «падает», но не приложил вывод).
- Не ясно, «исправить» или «объяснить» (нужно уточнить намерение).
- Конфликт требований (скорость vs читаемость vs безопасность) — предложить варианты.
---
## Когда агент должен действовать сразу
- Есть stacktrace + доступ к коду (файлы в контексте).
- Есть конкретный файл/функция в запросе.
- Просьба однозначна: «написать тест», «рефактор этого блока», «добавь README».
---
## Запреты (оркестратор должен проверять)
- **Нельзя** писать «тесты прошли», если инструмент `run_tests` не вызывался и результат не передан.
- **Нельзя** ссылаться на «файл X» или «строка N», если инструмент `read_file` не вызывался и содержимое не в контексте.
- **Нельзя** утверждать, что команда/сборка выполнена, если `run` не вызывался и вывода нет.
---
## Режимы
| Режим | Назначение |
|--------|------------|
| **Chat** | Инженер-коллега: обсуждение, уточнения, план; ответы точные и проверяемые. |
| **Fix-it** | Обязан вернуть: диагноз (13 пункта), patch/diff, команды проверки, риски при наличии. |
Режим задаётся переменной окружения `PAPAYU_LLM_MODE=chat` (по умолчанию) или `PAPAYU_LLM_MODE=fixit`.
---
## Связь с Tools (function calling)
При использовании OpenAI-совместимого API с tool calling оркестратор должен:
1. Выполнять вызовы инструментов (list_files, read_file, search_in_repo, run_tests, apply_patch и т.д.) в приложении.
2. Передавать результаты обратно в модель (tool output с привязкой к call_id).
3. Не считать задачу «выполненной» (тесты прошли, патч применён), пока соответствующий инструмент не вернул успех и результат не передан агенту.
Схема tools: см. `docs/openai_tools_schema.json`.
---
## Стиль ответа (опционально)
- **verbosity: 0..2** — 0 ультракоротко, 2 с объяснениями (runtime-настройка).
- **ask_budget: 0..2** — сколько уточняющих вопросов допустимо за один оборот.
- Формат ответа по умолчанию: 37 буллетов; код/патч — отдельным блоком; в конце: «Что сделать сейчас: …».
---
## Безопасность apply_patch
При реализации инструмента `apply_patch`:
- dry-run валидация diff перед применением;
- запрет изменений вне repo-root;
- лимит размера патча;
- обязательный backup/undo (например, через tx в papa-yu).

38
docs/E2E_SCENARIO.md Normal file
View File

@ -0,0 +1,38 @@
# E2E сценарий: анализ → применение → undo
Сценарий для ручной или автоматической проверки полного цикла работы приложения.
## Предусловия
- Установлены зависимости: `npm install`, `cargo build` (или `npm run tauri build`).
- Приложение запущено: `npm run tauri dev`.
- Есть тестовая папка с минимальным проектом (например, пустая папка с `package.json` или `Cargo.toml` без README).
## Шаги
1. **Выбор папки**
Нажать «Выбрать папку» и указать путь к тестовой папке (или ввести путь вручную и нажать «Отправить» / Ctrl+Enter).
2. **Анализ**
Убедиться, что запустился анализ и в ленте появилось сообщение ассистента вида «Нашёл X проблем. Могу исправить Y.» и отчёт (findings, actions).
3. **Применение**
Нажать «Применить рекомендованные исправления» или выбрать действия и нажать «Предпросмотр изменений», затем «Применить». Убедиться, что появилось сообщение об успешном применении и при необходимости — о прохождении проверок (auto_check).
4. **Откат (Undo)**
Нажать «Откатить» (или кнопку «Откатить изменения (Undo)» в блоке результата). Убедиться, что появилось сообщение «Последнее действие отменено.» или «Откат выполнен.» и что файлы на диске вернулись в состояние до применения (например, созданный README удалён).
## Критерии успеха
- Анализ возвращает отчёт с путём и списком действий.
- Применение создаёт/изменяет файлы и при включённом auto_check выполняет проверки (cargo check / npm run build и т.д.).
- После undo состояние проекта на диске соответствует состоянию до применения; кнопка «Откатить» снова неактивна (или активна для предыдущей транзакции).
## Автоматизация (будущее)
Для автоматического E2E можно использовать:
- **Tauri test** — запуск приложения и вызов команд через контекст теста.
- **Playwright** — управление окном приложения (WebView) и клики по кнопкам, проверка текста в ленте.
Текущие юнит-тесты эвристик (detect_project_type, is_protected_file, is_text_allowed) запускаются командой: `cd src-tauri && cargo test`.

261
docs/FIX_PLAN_CONTRACT.md Normal file
View File

@ -0,0 +1,261 @@
# Fix-plan оркестратор: контракты JSON и автосбор контекста
papa-yu — **Rust/Tauri**, не Python. Ниже — текущий JSON-ответ, расширенный контракт Fix-plan/Apply и как это встроено в приложение.
---
## 1) Текущий JSON-ответ (как есть сейчас)
Модель возвращает **один** JSON. Приложение парсит и применяет действия по подтверждению пользователя.
### Вариант A: массив действий
```json
[
{ "kind": "CREATE_FILE", "path": "README.md", "content": "# Project\n" },
{ "kind": "CREATE_DIR", "path": "src" }
]
```
### Вариант B: объект с actions + memory_patch
```json
{
"actions": [
{ "kind": "UPDATE_FILE", "path": "src/main.py", "content": "..." }
],
"memory_patch": {
"project.default_test_command": "pytest -q"
}
}
```
### Поля элемента actions
| Поле | Тип | Обязательность | Описание |
|----------|--------|----------------|----------|
| `kind` | string | да | `CREATE_FILE` \| `CREATE_DIR` \| `UPDATE_FILE` \| `DELETE_FILE` \| `DELETE_DIR` |
| `path` | string | да | Относительный путь от корня проекта |
| `content`| string | нет | Для CREATE_FILE / UPDATE_FILE |
### Результат в приложении
- `AgentPlan`: `{ ok, summary, actions, error?, error_code? }`
- `memory_patch` применяется по whitelist и сохраняется в `preferences.json` / `.papa-yu/project.json`
---
## 2) Расширенный контракт: Fix-plan и Apply
Один JSON-объект с полем `mode`. Приложение понимает оба формата (текущий и расширенный).
### Режим fix-plan (только план, применение после подтверждения)
```json
{
"mode": "fix-plan",
"summary": "Коротко: почему падает и что делаем",
"questions": ["Нужен ли тест на X?"],
"context_requests": [
{ "type": "read_file", "path": "src/x.py", "start_line": 1, "end_line": 220 },
{ "type": "search", "query": "SomeSymbol", "glob": "**/*.py" },
{ "type": "logs", "source": "runtime", "last_n": 200 }
],
"plan": [
{ "step": "Диагностика", "details": "..." },
{ "step": "Правка", "details": "..." },
{ "step": "Проверка", "details": "Запустить pytest -q" }
],
"proposed_changes": {
"patch": "unified diff (optional в fix-plan)",
"actions": [
{ "kind": "UPDATE_FILE", "path": "src/x.py", "content": "..." }
],
"commands_to_run": ["pytest -q"]
},
"risks": ["Затрагивает миграции"],
"memory_patch": {
"project.default_test_command": "pytest -q"
}
}
```
- Если есть `context_requests`, приложение подтягивает контекст (read_file, search, logs) и повторяет запрос к модели (до 2 раундов).
- Действия для UI/apply берутся из `proposed_changes.actions` (если есть), иначе из корневого `actions` (обратная совместимость).
### Режим apply (после «ок» пользователя)
```json
{
"mode": "apply",
"summary": "Что применяем",
"patch": "unified diff (обязательно при применении diff)",
"commands_to_run": ["pytest -q", "ruff check ."],
"verification": ["Ожидаем: все тесты зелёные"],
"rollback": ["git checkout -- <files>"],
"memory_patch": {}
}
```
В текущей реализации применение идёт по списку **actions** (CREATE_FILE/UPDATE_FILE/…). Поле `patch` (unified diff) зарезервировано под будущую поддержку `apply_patch` в бэкенде.
---
## 3) System prompt под один JSON (Fix-plan)
Ядро, которое вставляется при режиме Fix-plan (переменная `PAPAYU_LLM_MODE=fix-plan` или отдельный промпт):
```text
Ты — инженерный ассистент внутри программы для создания, анализа и исправления кода. Оператор один: я.
Всегда отвечай ОДНИМ валидным JSON-объектом. Никакого текста вне JSON.
Режимы:
- "fix-plan": предлагаешь план и (опционально) proposed_changes (actions, patch, commands_to_run). Ничего не применяешь.
- "apply": выдаёшь финальный patch и команды для применения/проверки (после подтверждения оператора).
Правила:
- Не выдумывай содержимое файлов/логов. Если нужно — запроси через context_requests.
- Никогда не утверждай, что тесты/команды запускались, если их не запускало приложение.
- Если данных не хватает — задай максимум 2 вопроса в questions и/или добавь context_requests.
- Минимальные изменения. Без широких рефакторингов без явного запроса.
ENGINEERING_MEMORY:
{...вставляется приложением...}
Схема JSON:
- mode: "fix-plan" | "apply" (или опусти для обратной совместимости — тогда ожидается массив actions или объект с actions)
- summary: string
- questions: string[]
- context_requests: [{ type: "read_file"|"search"|"logs"|"env", path?, start_line?, end_line?, query?, glob?, source?, last_n? }]
- plan: [{ step, details }]
- proposed_changes: { patch?, actions?, commands_to_run? }
- patch: string (обязательно в apply при применении diff)
- commands_to_run: string[]
- verification: string[]
- risks: string[]
- rollback: string[]
- memory_patch: object (только ключи из whitelist)
```
---
## 4) Автосбор контекста (без tools)
Приложение **до** первого запроса к модели собирает базовый контекст и подставляет в user-сообщение:
### Базовый набор
- **env**: версия Python/Node/OS, venv, менеджер зависимостей (если определимо по проекту).
- **project prefs**: команды тестов/линта/формата из `.papa-yu/project.json` (уже в ENGINEERING_MEMORY).
- **recent_files**: список недавно открытых/изменённых файлов из `report_json` (если передан).
- **logs**: последние N строк логов (runtime/build) — если приложение имеет к ним доступ.
### При ошибке/stacktrace в запросе
- Распарсить пути и номера строк из Traceback.
- Добавить в контекст фрагменты файлов ±80 строк вокруг указанных строк.
- При «падает тест X» — подтянуть файл теста и (по возможности) тестируемый модуль.
Эвристики реализованы в Rust: см. `context::gather_base_context`, `context::fulfill_context_requests`.
---
## 5) JSON Schema для response_format (OpenAI Chat Completions)
Используется endpoint **Chat Completions** (`/v1/chat/completions`). Для строгого JSON можно передать `response_format: { type: "json_schema", json_schema: { ... } }` (если провайдер поддерживает).
Пример схемы под объединённый контракт (и текущий, и Fix-plan):
```json
{
"name": "papa_yu_plan_response",
"strict": true,
"schema": {
"type": "object",
"properties": {
"mode": { "type": "string", "enum": ["fix-plan", "apply"] },
"summary": { "type": "string" },
"questions": { "type": "array", "items": { "type": "string" } },
"context_requests": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["read_file", "search", "logs", "env"] },
"path": { "type": "string" },
"start_line": { "type": "integer" },
"end_line": { "type": "integer" },
"query": { "type": "string" },
"glob": { "type": "string" },
"source": { "type": "string" },
"last_n": { "type": "integer" }
}
}
},
"plan": {
"type": "array",
"items": { "type": "object", "properties": { "step": { "type": "string" }, "details": { "type": "string" } } }
},
"actions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["CREATE_FILE", "CREATE_DIR", "UPDATE_FILE", "DELETE_FILE", "DELETE_DIR"] },
"path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["kind", "path"]
}
},
"proposed_changes": {
"type": "object",
"properties": {
"patch": { "type": "string" },
"actions": { "type": "array", "items": { "$ref": "#/definitions/action" } },
"commands_to_run": { "type": "array", "items": { "type": "string" } }
}
},
"patch": { "type": "string" },
"commands_to_run": { "type": "array", "items": { "type": "string" } },
"verification": { "type": "array", "items": { "type": "string" } },
"risks": { "type": "array", "items": { "type": "string" } },
"rollback": { "type": "array", "items": { "type": "string" } },
"memory_patch": { "type": "object", "additionalProperties": true }
},
"additionalProperties": true
}
}
```
Для «только массив actions» схему можно упростить или использовать два варианта (массив vs объект) на стороне парсера — текущий парсер в Rust принимает и массив, и объект с `actions` и `memory_patch`.
---
## 6) Режим в приложении
Переменная окружения **`PAPAYU_LLM_MODE`**:
- `chat` (по умолчанию) — инженер-коллега, ответ массив/объект с `actions`.
- `fixit` — обязан вернуть патч и проверку (текущий FIXIT prompt).
- **`fix-plan`** — один JSON с `mode`, `summary`, `context_requests`, `plan`, `proposed_changes`, `memory_patch`; автосбор контекста и до 2 раундов по `context_requests`.
ENGINEERING_MEMORY подставляется в system prompt приложением (см. `memory::build_memory_block`).
---
## 7) Как подключить в UI
- **«Fix (plan)»** / текущий сценарий: вызов `propose_actions` → показ `summary`, `plan`, `risks`, `questions`, превью по `proposed_changes.actions` или `actions`.
- **«Применить»**: вызов `apply_actions_tx` с выбранными `actions` (из ответа модели). Память уже обновлена по `memory_patch` при парсинге ответа.
Flow «сначала план → подтверждение → применение» обеспечивается тем, что приложение не применяет действия до явного подтверждения пользователя; модель может отдавать как короткий формат (массив/actions), так и расширенный (mode fix-plan + proposed_changes).
---
## 8) Инженерная память
- MEMORY BLOCK подставляется в system prompt.
- Модель заполняет `commands_to_run` из `project.default_test_command` и т.п.
- При явной просьбе «запомни …» модель возвращает `memory_patch`; приложение применяет его по whitelist и сохраняет в файлы.
Whitelist и логика — в `src-tauri/src/memory.rs`.

109
docs/IMPROVEMENTS.md Normal file
View File

@ -0,0 +1,109 @@
# Рекомендации по улучшению PAPAYU
## 1. Архитектура и код
- **Вынести типы UI в один модуль**
Сейчас интерфейсы (`Action`, `AnalyzeReport`, `AgenticRunResult` и т.д.) объявлены в `Tasks.tsx`. Имеет смысл вынести их в `src/types/` или `src/lib/types.ts` и импортировать в страницах — так проще переиспользовать и тестировать.
- **Разбить Tasks.tsx**
Файл очень большой (1200+ строк). Имеет смысл вынести:
- блок выбора папок/файлов — в `components/PathSelector.tsx`;
- ленту сообщений — в `components/ChatFeed.tsx`;
- блок результата agentic run — в `components/AgenticResult.tsx`;
- хуки (`useAgenticRun`, `useBatch`, `useUndoRedo`) — в отдельные файлы в `hooks/`.
- **Единый слой вызова бэкенда**
Сделать один модуль `src/lib/tauri.ts` (или `api.ts`) с функциями `analyze()`, `runBatch()`, `agenticRun()`, `getProjectProfile()` и т.д., которые внутри вызывают `invoke`. В компонентах использовать только этот слой — проще менять контракты и добавлять логирование/обработку ошибок.
---
## 2. Безопасность и надёжность
- **Жёсткие лимиты из профиля (v2.4.4)**
Сейчас `profile.limits.max_actions_per_tx` и `timeout_sec` используются в agentic_run, но не в `apply_actions_tx` и `run_batch`. Имеет смысл передавать лимиты в эти команды и на Rust отклонять запросы, превышающие `max_actions_per_tx`, и ограничивать время выполнения проверок (например, 60 с на `verify`).
- **Таймаут в verify_project**
В `verify.rs` команды (`cargo check`, `npm run build` и т.д.) запускаются без таймаута. Стоит добавить таймаут (например, 60 с) через `std::process::Command` + отдельный поток или `tokio::process` с `kill_on_drop`, чтобы зависшая сборка не блокировала приложение.
- **Расширить allowlist в verify**
Сейчас список разрешённых команд жёстко задан в коде. Имеет смысл вынести его в конфиг (например, в `tauri.conf.json` или отдельный JSON) и проверять только команды из allowlist с фиксированными аргументами.
---
## 3. UX и интерфейс
- **История сессий в UI**
На бэкенде уже есть `list_sessions`, `append_session_event`. В интерфейсе можно добавить боковую панель или выпадающий список «Последние сессии» по проекту и показывать историю сообщений/запусков.
- **Использование профиля при запуске**
Показывать в форме выбранный тип проекта (React/Vite, Rust, Python и т.д.) и лимиты (max_attempts, max_actions). Для agentic run можно подставлять `goal_template` из профиля в поле «Цель» или показывать подсказку.
- **Фильтр типов файлов при «Прикрепить файл»**
В диалоге выбора файлов задать `filters` (например, `.ts`, `.tsx`, `.rs`, `.py`, `.json`, `.toml`), чтобы по умолчанию предлагать только исходники и конфиги.
- **Клавиатурные сокращения**
Например: Ctrl+Enter — отправить сообщение/запустить анализ, Escape — сбросить превью.
---
## 4. Тестирование
- **Юнит-тесты для эвристик**
Покрыть тестами `build_plan` в `agentic_run.rs`, `generate_actions_from_report`, `detect_project_type` и `is_protected_file` / `is_text_allowed` — на них легко писать тесты с временными директориями.
- **Интеграционные тесты**
Один-два E2E сценария (например, через `tauri test` или Playwright): выбор папки → анализ → применение безопасных правок → проверка, что файлы созданы и undo работает.
- **Тестовые фикстуры**
В `docs/` уже есть JSON для auto-rollback. Имеет смысл добавить фикстуры «минимальный проект» (папка с `package.json` или `Cargo.toml` без README) и использовать их в тестах и вручную.
---
## 5. Документация и конфиг
- **Обновить README**
Привести версию к 2.4.3, описать: профиль по пути, agentic run, прикрепление файлов, кнопку «Прикрепить файл», guard опасных изменений и подтверждение Apply.
- **CHANGELOG**
Ведение краткого CHANGELOG (по версиям) упростит онбординг и откат изменений.
- **Команда запуска в одном месте**
В README указать одну команду, например: `cd src-tauri && cargo tauri dev` (или `npm run tauri dev` из корня), чтобы не было расхождений.
---
## 6. Следующие фичи (по приоритету)
1. **v2.4.4 — Profile-driven limits**
Жёстко ограничивать в `apply_actions_tx` и `run_batch` число действий и таймаут проверок из `ProjectProfile`.
2. **LLM-планировщик (v2.4.1)** *(реализовано в v2.4.4)*
В `propose_actions`: при заданном `PAPAYU_LLM_API_URL` вызывается OpenAI-совместимый API; ответ (JSON-массив действий) парсится в `AgentPlan`. Контур выполнения без изменений: preview → apply_tx → verify → rollback. Переменные: `PAPAYU_LLM_API_URL`, `PAPAYU_LLM_API_KEY`, `PAPAYU_LLM_MODEL`. См. README.
3. **Контекст прикреплённых файлов** *(реализовано в v2.4.4)*
В бэкенд передаётся список прикреплённых файлов (`attached_files` в `BatchPayload` и `AnalyzeReport`); он пробрасывается в `run_batch``analyze_project`. В отчёте поле `attached_files` доступно для дальнейшего использования (например, пометка затронутых файлов в превью).
4. **Экспорт/импорт настроек**
Сохранение и загрузка `ProjectSettings` (и при желании списка папок) в файл для переноса между машинами.
5. **Тёмная тема**
Переменные CSS или контекст темы и переключатель в шапке.
---
## 7. Краткий чек-лист
- [x] Вынести типы и разбить `Tasks.tsx` на компоненты и хуки (v2.4.3)
- [x] Единый API-слой для `invoke` в `src/lib/` (v2.4.3)
- [x] Лимиты профиля в `apply_actions_tx` и `run_batch` + таймаут в `verify_project` (v2.4.4)
- [x] История сессий и отображение профиля в UI (v2.4.4)
- [x] Фильтр расширений в диалоге «Прикрепить файл» (v2.4.4)
- [x] Юнит-тесты для guard и эвристик (v2.4.4, 18 тестов)
- [x] Обновить README и добавить CHANGELOG (v2.4.4)
- [x] Контекст прикреплённых файлов (v2.4.4)
- [x] LLM-планировщик (v2.4.4, env: PAPAYU_LLM_API_URL / API_KEY / MODEL)
- [x] Тёмная тема с переключателем (v2.4.4)
- [x] Экспорт/импорт настроек (v2.4.4)
- [x] Расширенный allowlist команд verify в конфиге (v2.4.4)
- [x] Тестовые фикстуры для E2E (v2.4.4)
- [x] Git-репозиторий инициализирован (v2.4.4)

271
docs/LLM_PLAN_FORMAT.md Normal file
View File

@ -0,0 +1,271 @@
# Стек papa-yu и JSON-контракт ответа (план)
## На чём написан papa-yu
| Слой | Стек | Примечание |
|-----------|---------------------|-------------------------------------|
| **Backend** | **Rust** (Tauri) | Команды, LLM, FS, apply, undo, tx |
| **Frontend** | **TypeScript + React** (Vite) | UI, запросы к Tauri (invoke) |
Не Python/Node/Go — бэкенд полностью на Rust; фронт — React/Vite.
---
## JSON Schema для response_format
Полная схема для `response_format` (OpenAI Responses API и др.) — см. `docs/papa_yu_response_schema.json`.
Схема для Chat Completions (`response_format: { type: "json_schema", ... }`) — `src-tauri/config/llm_response_schema.json`. Включается через `PAPAYU_LLM_STRICT_JSON=1`.
**Поведение strict / best-effort:**
- **strict включён** — приложение отправляет `response_format` в API; при ответе, не проходящем JSON schema, локально отклоняет и выполняет 1 авто-ретрай с repair-подсказкой.
- **strict выключен или провайдер не поддерживает** — best-effort парсинг (извлечение из ```json ... ```), затем локальная валидация; при неудаче — тот же repair-ретрай.
---
## Текущий JSON-контракт ответа (план от LLM)
LLM должен вернуть **только валидный JSON** — либо массив действий, либо объект с полями.
### Принимаемые форматы
1. **Массив действий**`[{ kind, path, content? }, ...]`
2. **Объект**`{ actions?, proposed_changes.actions?, summary?, context_requests?, memory_patch? }`
### Формат Action (элемент массива)
| Поле | Тип | Обязательность | Описание |
|----------|--------|----------------|----------|
| `kind` | string | да | Один из: `CREATE_FILE`, `CREATE_DIR`, `UPDATE_FILE`, `DELETE_FILE`, `DELETE_DIR` |
| `path` | string | да | Относительный путь от корня проекта (без `../`, без абсолютных путей, без `~`) |
| `content`| string | да для CREATE_FILE, UPDATE_FILE | Содержимое файла; макс. ~1MB на файл; для `CREATE_DIR`/`DELETE_*` не используется |
**Ограничения на path:** no `../`, no абсолютные (`/`, `C:\`), no `~`. Локальная валидация отклоняет.
**DELETE_*:** требует подтверждения пользователя в UI (кнопка «Применить»).
**Plan→Apply без кнопок:**
- Префиксы: `plan: <текст>` → режим Plan; `apply: <текст>` → режим Apply.
- Триггеры перехода: `ok`, `ок`, `apply`, `применяй`, `да` — при наличии сохранённого плана переключают на Apply.
- По умолчанию: «исправь/почини» → Plan; «создай/сгенерируй» → Apply.
**APPLY без изменений (каноничный маркер):**
- Если изменений не требуется — верни `actions: []` и `summary`, **начинающийся с `NO_CHANGES:`** (строго).
- Пример: `"summary": "NO_CHANGES: Проверка завершена, правок не требуется."`
**Конфликты действий:**
- Один path не должен иметь несовместимых действий: CREATE_FILE + UPDATE_FILE, DELETE + CREATE/UPDATE.
- Порядок применения: CREATE_DIR → CREATE_FILE/UPDATE_FILE → DELETE_FILE → DELETE_DIR.
**Пути:**
- Запрещены: абсолютные (`/`, `//`), Windows drive (`C:/`), UNC (`//server/share`), `~`, сегменты `..` и `.`, пустой или только `.`.
- Лимиты: max_path_len=240, max_actions=200, max_total_content_bytes=5MB.
**ERR_UPDATE_WITHOUT_BASE:**
- В режиме APPLY каждый UPDATE_FILE должен ссылаться на файл, прочитанный в Plan (FILE[path]: или === path === в plan_context).
**Protected paths (denylist):**
- `.env`, `*.pem`, `*.key`, `*.p12`, `id_rsa*`, `**/secrets/**` — запрещены для UPDATE/DELETE.
**Content:**
- Запрещён NUL (`\0`), >10% non-printable = ERR_PSEUDO_BINARY.
**EOL:**
- `PAPAYU_NORMALIZE_EOL=lf` — нормализовать \r\n→\n, trailing newline.
**Наблюдаемость:**
- Каждый propose имеет `trace_id` (UUID). Лог-ивенты в stderr: `LLM_REQUEST_SENT` (token_budget, input_chars), `LLM_RESPONSE_OK`, `VALIDATION_FAILED`, `LLM_REQUEST_TIMEOUT`, `LLM_RESPONSE_FORMAT_FALLBACK`.
- `PAPAYU_TRACE=1` — трасса в `.papa-yu/traces/<trace_id>.json`. По умолчанию raw_content не сохраняется (риск секретов); `PAPAYU_TRACE_RAW=1` — сохранять с маскировкой sk-/Bearer. В трассе — `config_snapshot`.
**Параметры генерации:** temperature=0, max_tokens=16384 (авто-кэп: при input>80k → 4096), top_p=1, presence_penalty=0, frequency_penalty=0. `PAPAYU_LLM_TIMEOUT_SEC=90`. Capability detection: при ошибке response_format — retry без него.
**Версия схемы:** `LLM_PLAN_SCHEMA_VERSION=1` — в system prompt и trace; для будущей поддержки v1/v2 при расширении kinds/полей. `x_schema_version` в llm_response_schema.json. `schema_hash` (sha256) в config_snapshot.
**Кеш контекста:** read_file/search/logs/env кешируются в пределах plan-цикла. Логи: CONTEXT_CACHE_HIT, CONTEXT_CACHE_MISS.
**Контекст-диета:** PAPAYU_CONTEXT_MAX_FILES=8, PAPAYU_CONTEXT_MAX_FILE_CHARS=20000, PAPAYU_CONTEXT_MAX_TOTAL_CHARS=120000. Файлы: head+tail truncation. Лог: CONTEXT_DIET_APPLIED.
### Fix-plan режим (user.output_format)
- **PLAN** (`plan`): `actions` пустой массив `[]`, `summary` обязателен (диагноз + шаги + команды проверки), при необходимости — `context_requests`.
- **APPLY** (`apply`): `actions` непустой, если нужны изменения; иначе пустой + `summary` «изменений не требуется». `summary` — что сделано и как проверить (используй `project.default_test_command` если задан).
### Пример ответа (объект)
```json
{
"actions": [
{ "kind": "CREATE_FILE", "path": "README.md", "content": "# Project\n\n## Run\n\n`make run`\n" },
{ "kind": "CREATE_DIR", "path": "src" }
],
"summary": "Созданы README и папка src.",
"context_requests": [],
"memory_patch": {}
}
```
### Пример Fix-plan (plan-режим)
```json
{
"actions": [],
"summary": "Диагноз: ...\nПлан:\n1) ...\n2) ...\nПроверка: pytest -q",
"context_requests": [
{ "type": "read_file", "path": "src/app.py", "start_line": 1, "end_line": 240 }
],
"memory_patch": { "user.output_format": "plan" }
}
```
### Как приложение обрабатывает ответ
1. Парсит JSON из ответа (извлекает из ```json ... ``` при наличии).
2. Берёт `actions` из корня или `proposed_changes.actions`.
3. Валидирует: path (no `../`, no absolute), content обязателен для CREATE_FILE/UPDATE_FILE.
4. `summary` используется если есть; иначе формируется в коде.
5. `context_requests` — выполняется в следующем раунде (до MAX_CONTEXT_ROUNDS).
6. `memory_patch` — применяется только ключи из whitelist.
---
## context_requests (типы запросов)
| type | Обязательные поля | Описание |
|------------|-------------------|----------|
| `read_file`| path | Прочитать файл (опционально start_line, end_line) |
| `search` | query | Поиск по проекту (опционально glob) |
| `logs` | source | Логи (приложение ограничено) |
| `env` | — | Информация об окружении |
---
## Автосбор контекста (до первого вызова LLM)
Эвристики по содержимому user_goal и отчёта:
- **Traceback / Exception** → извлекаются пути и номера строк, читаются файлы ±80 строк вокруг
- **ImportError / ModuleNotFoundError / cannot find module** → добавляются ENV + содержимое pyproject.toml, requirements.txt, package.json, poetry.lock
---
## Типы в Rust (справочно)
- `Action`: `{ kind: ActionKind, path: String, content: Option<String> }`
- `ActionKind`: enum `CreateFile | CreateDir | UpdateFile | DeleteFile | DeleteDir` (сериализуется в SCREAMING_SNAKE_CASE)
- `AgentPlan`: `{ ok: bool, summary: String, actions: Vec<Action>, error?: String, error_code?: String }`
---
## memory_patch + whitelist + пример промпта (под этот контракт)
### 1) memory_patch (что подставлять в промпт как контекст «памяти»)
Хранить в приложении (файл/БД/локальное хранилище) и подставлять в system или в начало user-сообщения:
```json
{
"preferred_style": "коротко, по делу",
"default_language": "python",
"test_command": "pytest -q",
"lint_command": "ruff check .",
"format_command": "ruff format .",
"project_root_hint": "src/ — код, tests/ — тесты"
}
```
В промпте: один абзац, например:
«Память: стиль — коротко по делу; язык по умолчанию — python; тесты — pytest -q; линт — ruff check .; структура — src/, tests/.»
### 2) whitelist (разрешённые пути для действий)
При парсинге плана и перед apply проверять: все `path` должны быть относительно корня проекта и не выходить за его пределы. Дополнительно можно ограничить типы файлов/папок.
Пример whitelist (Rust/конфиг):
- Разрешены расширения для CREATE_FILE/UPDATE_FILE: `.py`, `.ts`, `.tsx`, `.js`, `.jsx`, `.json`, `.md`, `.yaml`, `.yml`, `.toml`, `.css`, `.html`, `.sql`, `.sh`, `.env.example`, без расширения — только известные имена: `README`, `Makefile`, `.gitignore`, `.editorconfig`, `Dockerfile`.
- Запрещены пути: содержащие `..`, абсолютные пути, `.env` (без .example), `*.key`, `*.pem`, каталоги `node_modules/`, `.git/`, `__pycache__/`.
Файл конфига (например `config/llm_whitelist.json`):
```json
{
"allowed_extensions": [".py", ".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".yaml", ".yml", ".toml", ".css", ".html", ".sql", ".sh"],
"allowed_no_extension": ["README", "Makefile", ".gitignore", ".editorconfig", "Dockerfile", "LICENSE"],
"forbidden_paths": [".env", "*.key", "*.pem", "node_modules", ".git", "__pycache__"],
"forbidden_prefixes": ["..", "/"]
}
```
### 3) Пример промпта (фрагмент под твой JSON-контракт)
Добавить в промпт явное напоминание формата ответа:
```text
Верни ТОЛЬКО валидный JSON — массив действий, без markdown и пояснений.
Формат каждого элемента: { "kind": "CREATE_FILE" | "CREATE_DIR" | "UPDATE_FILE" | "DELETE_FILE" | "DELETE_DIR", "path": "относительный/путь", "content": "опционально для CREATE_FILE/UPDATE_FILE" }.
Пример: [{"kind":"CREATE_FILE","path":"README.md","content":"# Project\n"},{"kind":"CREATE_DIR","path":"src"}]
```
Это уже есть в build_prompt; при добавлении memory_patch в начало user-сообщения можно добавить блок:
```text
Память (предпочтения оператора): preferred_style=коротко по делу; default_language=python; test_command=pytest -q; lint_command=ruff check .; format_command=ruff format .; структура проекта: src/, tests/.
```
После применения whitelist при apply отклонять действия с path вне whitelist и возвращать в AgentPlan error с кодом FORBIDDEN_PATH.
---
## Инженерная память (Engineering Memory)
Память разделена на три слоя; в промпт подставляется только устойчивый минимум (~12 KB).
### A) User prefs (оператор)
- Расположение: `app_data_dir()/papa-yu/preferences.json` (локально).
- Поля: `preferred_style` (brief|normal|verbose), `ask_budget` (0..2), `risk_tolerance` (low|medium|high), `default_language`, `output_format` (patch_first|plan_first).
### B) Project prefs (для репо)
- Расположение: в репо `.papa-yu/project.json` (шарится между машинами при коммите).
- Поля: `default_test_command`, `default_lint_command`, `default_format_command`, `package_manager`, `build_command`, `src_roots`, `test_roots`, `ci_notes`.
### C) Session state
- В памяти процесса (не в файлах): current_task_goal, current_branch, recent_files, recent_errors. В текущей реализации не подставляется в промпт.
### MEMORY BLOCK в промпте
Добавляется в **system message** после основного system prompt:
```text
ENGINEERING_MEMORY (trusted by user; update only when user requests):
{"user":{"preferred_style":"brief","ask_budget":1,"risk_tolerance":"medium","default_language":"python"},"project":{"default_test_command":"pytest -q","default_lint_command":"ruff check .","default_format_command":"ruff format .","src_roots":["src"],"test_roots":["tests"]}}
Use ENGINEERING_MEMORY as defaults. If user explicitly asks to change — suggest updating memory and show new JSON.
```
### Ответ с memory_patch
Если пользователь просит «запомни, что тесты запускать так-то», LLM может вернуть объект:
```json
{
"actions": [],
"memory_patch": {
"project.default_test_command": "pytest -q",
"user.preferred_style": "brief"
}
}
```
Приложение применяет только ключи из whitelist и сохраняет в `preferences.json` / `.papa-yu/project.json`.
**Безопасность memory_patch:** при парсинге удаляются все ключи не из whitelist; валидируются типы (`ask_budget` int, `src_roots` массив строк и т.д.). Рекомендуется применять patch только при явной просьбе пользователя.
### Whitelist memory_patch (ключи через точку)
- `user.preferred_style`, `user.ask_budget`, `user.risk_tolerance`, `user.default_language`, `user.output_format`
- `project.default_test_command`, `project.default_lint_command`, `project.default_format_command`, `project.package_manager`, `project.build_command`, `project.src_roots`, `project.test_roots`, `project.ci_notes`
Примеры файлов: см. `docs/preferences.example.json` и `docs/project.example.json`.

125
docs/OPENAI_SETUP.md Normal file
View File

@ -0,0 +1,125 @@
# Подключение PAPA-YU к OpenAI
Инструкция по настройке кнопки **«Предложить исправления»** для работы через API OpenAI.
---
## 1. Получение API-ключа OpenAI
1. Зайдите на [platform.openai.com](https://platform.openai.com).
2. Войдите в аккаунт или зарегистрируйтесь.
3. Откройте **API keys** (раздел **Settings****API keys** или [прямая ссылка](https://platform.openai.com/api-keys)).
4. Нажмите **Create new secret key**, задайте имя (например, `PAPA-YU`) и скопируйте ключ.
5. Сохраните ключ в надёжном месте — повторно его показать нельзя.
---
## 2. Переменные окружения
Перед запуском приложения задайте три переменные.
### Обязательные
| Переменная | Значение | Описание |
|------------|----------|----------|
| `PAPAYU_LLM_API_URL` | `https://api.openai.com/v1/chat/completions` | URL эндпоинта Chat Completions OpenAI. |
| `PAPAYU_LLM_API_KEY` | Ваш API-ключ OpenAI | Ключ передаётся в заголовке `Authorization: Bearer <ключ>`. |
### Опциональные
| Переменная | Значение по умолчанию | Описание |
|------------|------------------------|----------|
| `PAPAYU_LLM_MODEL` | `gpt-4o-mini` | Модель для генерации плана (например, `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo`). |
| `PAPAYU_LLM_MODE` | `chat` | Режим агента: `chat` (инженер-коллега) или `fixit` (обязан вернуть патч + проверку). См. `docs/AGENT_CONTRACT.md`. |
---
## 3. Запуск с OpenAI
### Вариант A: В текущей сессии терминала (macOS / Linux)
```bash
cd /Users/yrippertgmail.com/Desktop/papa-yu
export PAPAYU_LLM_API_URL="https://api.openai.com/v1/chat/completions"
export PAPAYU_LLM_API_KEY="sk-ваш-ключ-openai"
export PAPAYU_LLM_MODEL="gpt-4o-mini"
npm run tauri dev
```
Подставьте вместо `sk-ваш-ключ-openai` свой ключ.
### Вариант B: Одна строкой (без сохранения ключа в истории)
```bash
cd /Users/yrippertgmail.com/Desktop/papa-yu
PAPAYU_LLM_API_URL="https://api.openai.com/v1/chat/completions" \
PAPAYU_LLM_API_KEY="sk-ваш-ключ" \
PAPAYU_LLM_MODEL="gpt-4o-mini" \
npm run tauri dev
```
### Вариант C: Файл `.env` в корне проекта (если приложение его подхватывает)
В PAPA-YU переменные читаются из окружения процесса. Tauri сам по себе не загружает `.env`. Чтобы использовать `.env`, можно запускать через `env` или скрипт:
```bash
# В papa-yu создайте файл .env (добавьте .env в .gitignore, чтобы не коммитить ключ):
# PAPAYU_LLM_API_URL=https://api.openai.com/v1/chat/completions
# PAPAYU_LLM_API_KEY=sk-ваш-ключ
# PAPAYU_LLM_MODEL=gpt-4o-mini
# Запуск с подгрузкой .env (macOS/Linux, если установлен dotenv-cli):
# npm install -g dotenv-cli
# dotenv -e .env -- npm run tauri dev
```
Или простой скрипт `start-with-openai.sh`:
```bash
#!/bin/bash
cd "$(dirname "$0")"
set -a
source .env # или export переменные здесь
set +a
npm run tauri dev
```
---
## 4. Проверка
1. Запустите приложение с заданными переменными.
2. Выберите проект (папку или путь).
3. Запустите **Анализ**.
4. Введите цель (например: «Добавить README и .gitignore»).
5. Нажмите **«Предложить исправления»**.
Если всё настроено верно, план будет сформирован через OpenAI. В случае ошибки в интерфейсе или в логах будет указание на API (например, 401 — неверный ключ, 429 — лимиты).
---
## 5. Безопасность
- Не коммитьте API-ключ в репозиторий и не вставляйте его в скрипты, которые попадают в историю.
- Добавьте `.env` в `.gitignore`, если храните ключ в `.env`.
- При утечке ключа отзовите его в [OpenAI API keys](https://platform.openai.com/api-keys) и создайте новый.
---
## 6. Другие модели OpenAI
Можно указать другую модель через `PAPAYU_LLM_MODEL`, например:
- `gpt-4o` — более способная модель.
- `gpt-4o-mini` — быстрее и дешевле (по умолчанию в коде).
- `gpt-4-turbo` — баланс качества и скорости.
Актуальный список и цены: [OpenAI Pricing](https://openai.com/pricing).
---
## 7. Если переменные не заданы
Если `PAPAYU_LLM_API_URL` не задана или пустая, кнопка **«Предложить исправления»** работает без API: используется встроенная эвристика (правила для README, .gitignore, LICENSE, .env.example и т.п.).

View File

@ -0,0 +1,30 @@
# Тест AUTO_ROLLBACK (v2.3.3)
Проверка: первый шаг применяется, второй падает → откат первого шага, в UI сообщения «Обнаружены ошибки. Откатываю изменения…» и «Изменения привели к ошибкам, откат выполнен.»
## Формат payload в papa-yu
- Команда: `apply_actions` (или `apply_actions_cmd`).
- Payload: `ApplyPayload` с полями **`root_path`**, **`actions`**, **`auto_check`**.
- В **`actions`** поле **`kind`** в формате **SCREAMING_SNAKE_CASE**: `CREATE_FILE`, `UPDATE_FILE`, `DELETE_FILE`, `CREATE_DIR`, `DELETE_DIR`.
## Вариант 1 — падение на safe_join (..)
Подставь свой путь в `root_path` и вызови apply с `actions` из `test-auto-rollback-payload.json`:
1. Создаётся `papayu_test_ok.txt`.
2. Второй action с путём `../../forbidden.txt``safe_join` возвращает ошибку.
3. Rollback удаляет `papayu_test_ok.txt`.
4. Ответ: `ok: false`, `error_code: "AUTO_ROLLBACK_DONE"`, `failed_at: 1`.
## Вариант 2 — падение через ОС (permission denied)
Используй `test-auto-rollback-fs-payload.json`: второй шаг пишет в `/System/...` — в papa-yu абсолютный путь отсекается в `safe_join`, так что отказ будет до записи в ФС, результат тот же (AUTO_ROLLBACK_DONE + откат).
## Запуск
```bash
cd ~/Desktop/papa-yu/src-tauri && cargo tauri dev
```
Проверку можно делать через UI (предпросмотр → применить с действиями, которые содержат запрещённый путь) или через invoke с payload из JSON выше.

View File

@ -0,0 +1,75 @@
{
"name": "papa_yu_plan_response",
"strict": false,
"schema": {
"type": "object",
"properties": {
"mode": { "type": "string", "enum": ["fix-plan", "apply"] },
"summary": { "type": "string" },
"questions": { "type": "array", "items": { "type": "string" } },
"context_requests": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["read_file", "search", "logs", "env"] },
"path": { "type": "string" },
"start_line": { "type": "integer" },
"end_line": { "type": "integer" },
"query": { "type": "string" },
"glob": { "type": "string" },
"source": { "type": "string" },
"last_n": { "type": "integer" }
}
}
},
"plan": {
"type": "array",
"items": {
"type": "object",
"properties": {
"step": { "type": "string" },
"details": { "type": "string" }
}
}
},
"actions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["CREATE_FILE", "CREATE_DIR", "UPDATE_FILE", "DELETE_FILE", "DELETE_DIR"] },
"path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["kind", "path"]
}
},
"proposed_changes": {
"type": "object",
"properties": {
"patch": { "type": "string" },
"actions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": { "type": "string" },
"path": { "type": "string" },
"content": { "type": "string" }
}
}
},
"commands_to_run": { "type": "array", "items": { "type": "string" } }
}
},
"patch": { "type": "string" },
"commands_to_run": { "type": "array", "items": { "type": "string" } },
"verification": { "type": "array", "items": { "type": "string" } },
"risks": { "type": "array", "items": { "type": "string" } },
"rollback": { "type": "array", "items": { "type": "string" } },
"memory_patch": { "type": "object" }
},
"additionalProperties": true
}
}

View File

@ -0,0 +1,121 @@
[
{
"type": "function",
"function": {
"name": "list_files",
"description": "List project files (optionally under a directory).",
"parameters": {
"type": "object",
"properties": {
"root": { "type": "string", "description": "Directory root, e.g. '.' or 'src'." },
"glob": { "type": "string", "description": "Glob filter, e.g. '**/*.py'." },
"max_results": { "type": "integer", "minimum": 1, "maximum": 500, "default": 200 }
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a text file (optionally a line range).",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string" },
"start_line": { "type": "integer", "minimum": 1, "description": "1-based inclusive." },
"end_line": { "type": "integer", "minimum": 1, "description": "1-based inclusive." },
"max_chars": { "type": "integer", "minimum": 200, "maximum": 200000, "default": 40000 }
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "search_in_repo",
"description": "Search for a pattern across the repository.",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Literal or regex depending on implementation." },
"glob": { "type": "string", "description": "Optional glob scope." },
"case_sensitive": { "type": "boolean", "default": false },
"max_results": { "type": "integer", "minimum": 1, "maximum": 500, "default": 50 }
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "get_env",
"description": "Get environment info: OS, language versions, package manager, tool versions.",
"parameters": { "type": "object", "properties": {}, "required": [] }
}
},
{
"type": "function",
"function": {
"name": "run",
"description": "Run a command (lint/build/custom).",
"parameters": {
"type": "object",
"properties": {
"command": { "type": "string" },
"cwd": { "type": "string", "default": "." },
"timeout_sec": { "type": "integer", "minimum": 1, "maximum": 600, "default": 120 }
},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "run_tests",
"description": "Run the project's test suite with a command.",
"parameters": {
"type": "object",
"properties": {
"command": { "type": "string", "description": "e.g. 'pytest -q' or 'npm test'." },
"cwd": { "type": "string", "default": "." },
"timeout_sec": { "type": "integer", "minimum": 1, "maximum": 1800, "default": 600 }
},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "get_logs",
"description": "Get recent application logs / build logs / runtime logs.",
"parameters": {
"type": "object",
"properties": {
"source": { "type": "string", "description": "e.g. 'app', 'build', 'runtime'." },
"last_n": { "type": "integer", "minimum": 1, "maximum": 5000, "default": 200 }
},
"required": ["source"]
}
}
},
{
"type": "function",
"function": {
"name": "apply_patch",
"description": "Apply a unified diff patch to the repository.",
"parameters": {
"type": "object",
"properties": {
"diff": { "type": "string", "description": "Unified diff" }
},
"required": ["diff"]
}
}
}
]

View File

@ -0,0 +1,92 @@
{
"name": "papa_yu_response",
"description": "JSON Schema для response_format LLM (OpenAI Responses API, строгий JSON-вывод). Принимает: массив actions ИЛИ объект с полями.",
"schema": {
"oneOf": [
{
"type": "array",
"description": "Прямой массив действий (обратная совместимость)",
"items": { "$ref": "#/$defs/action" },
"minItems": 0
},
{
"type": "object",
"description": "Объект Fix-plan: actions, summary, context_requests, memory_patch",
"additionalProperties": true,
"properties": {
"mode": {
"type": "string",
"enum": ["fix-plan", "apply"],
"description": "Опционально: fix-plan = план без изменений, apply = план с действиями"
},
"actions": {
"type": "array",
"items": { "$ref": "#/$defs/action" }
},
"proposed_changes": {
"type": "object",
"additionalProperties": true,
"properties": {
"actions": {
"type": "array",
"items": { "$ref": "#/$defs/action" }
}
}
},
"summary": { "type": "string" },
"questions": { "type": "array", "items": { "type": "string" } },
"context_requests": {
"type": "array",
"items": { "$ref": "#/$defs/context_request" }
},
"plan": {
"type": "array",
"items": {
"type": "object",
"properties": { "step": { "type": "string" }, "details": { "type": "string" } }
}
},
"memory_patch": {
"type": "object",
"additionalProperties": true,
"description": "Только ключи из whitelist: user.*, project.*"
},
"risks": { "type": "array", "items": { "type": "string" } }
}
}
],
"$defs": {
"action": {
"type": "object",
"additionalProperties": true,
"required": ["kind", "path"],
"properties": {
"kind": {
"type": "string",
"enum": ["CREATE_FILE", "CREATE_DIR", "UPDATE_FILE", "DELETE_FILE", "DELETE_DIR"]
},
"path": { "type": "string" },
"content": {
"type": "string",
"description": "Обязательно для CREATE_FILE и UPDATE_FILE"
}
}
},
"context_request": {
"type": "object",
"additionalProperties": true,
"required": ["type"],
"properties": {
"type": { "type": "string", "enum": ["read_file", "search", "logs", "env"] },
"path": { "type": "string" },
"start_line": { "type": "integer", "minimum": 1 },
"end_line": { "type": "integer", "minimum": 1 },
"query": { "type": "string" },
"glob": { "type": "string" },
"source": { "type": "string" },
"last_n": { "type": "integer", "minimum": 1, "maximum": 5000 }
}
}
}
}
}

View File

@ -0,0 +1,10 @@
{
"schema_version": 1,
"user": {
"preferred_style": "brief",
"ask_budget": 1,
"risk_tolerance": "medium",
"default_language": "python",
"output_format": "plan_first"
}
}

13
docs/project.example.json Normal file
View File

@ -0,0 +1,13 @@
{
"schema_version": 1,
"project": {
"default_test_command": "pytest -q",
"default_lint_command": "ruff check .",
"default_format_command": "ruff format .",
"package_manager": "pip",
"build_command": "python -c \"import app.main; print('ok')\"",
"src_roots": ["src"],
"test_roots": ["tests"],
"ci_notes": "Тесты долго, по умолчанию smoke."
}
}

View File

@ -0,0 +1,17 @@
{
"_comment": "v2.3.3 — тест AUTO_ROLLBACK через ОС: шаг 2 пишет в /System/... → permission denied. kind = SCREAMING_SNAKE_CASE.",
"root_path": "/ПУТЬ/К/ПРОЕКТУ",
"actions": [
{
"kind": "CREATE_FILE",
"path": "rollback_test.txt",
"content": "will be rolled back"
},
{
"kind": "UPDATE_FILE",
"path": "/System/Library/forbidden.txt",
"content": "permission denied"
}
],
"auto_check": false
}

View File

@ -0,0 +1,17 @@
{
"_comment": "v2.3.3 — тест AUTO_ROLLBACK: шаг 1 создаёт файл, шаг 2 падает на safe_join (..). В papa-yu kind = SCREAMING_SNAKE_CASE.",
"root_path": "/ПУТЬ/К/ПРОЕКТУ",
"actions": [
{
"kind": "CREATE_FILE",
"path": "papayu_test_ok.txt",
"content": "Эта строка будет создана, а потом удалена rollback-ом"
},
{
"kind": "UPDATE_FILE",
"path": "../../forbidden.txt",
"content": "Эта операция должна упасть из-за safe_join"
}
],
"auto_check": false
}

View File

@ -0,0 +1,71 @@
# Единая папка проекта PAPA YU
После объединения **вся разработка, сборка и запуск** десктопного приложения ведутся из одной папки. Папка **папа-ю** (документы) не переносилась — по вашему требованию она остаётся отдельно.
---
## Проверенные пути (состояние на момент объединения)
| Путь | Существование | Содержимое |
|------|----------------|------------|
| `/Users/yrippertgmail.com/Desktop/papa-yu` | ✅ | **Единая папка проекта.** Код (src/, src-tauri/), скрипты, docs. Здесь вносятся правки и собирается приложение. |
| `/Users/yrippertgmail.com/Desktop/папа-ю` | ✅ | Только документы и ТЗ. **Не переносилась.** Ссылка в README. |
| `/Users/yrippertgmail.com/PAPA-YU` | ✅ | Та же файловая система, что и `~/papa-yu` (один inode на macOS). Другая структура: desktop/ui, desktop/src-tauri. |
| `/Users/yrippertgmail.com/papa-yu` | ✅ | То же, что PAPA-YU (одна папка в домашнем каталоге). |
**Итог:** физически есть две разные копии кода:
1. **Desktop/papa-yu** — плоская структура (src/, src-tauri/ в корне), актуальные правки, диалог с агентом, рекомендации ИИ и т.д.
2. **~/papa-yu** (= ~/PAPA-YU) — вложенная структура (desktop/ui, desktop/src-tauri), другая версия (другие страницы, плагин updater).
Объединение выполнено так: **единой рабочей папкой выбран Desktop/papa-yu.** Из домашней копии перенесены только идеи скриптов запуска; пути и скрипты переписаны под структуру Desktop/papa-yu.
---
## Что сделано в Desktop/papa-yu
1. **Добавлены кнопки запуска (двойной клик):**
- **`PAPA YU.command`** — только запуск собранного приложения (.app). Если сборки ещё нет, скрипт подскажет запустить «Сборка и запуск».
- **`PAPA YU — Сборка и запуск.command`** — первая сборка или после обновления кода: `npm run tauri build`, затем открытие .app.
2. **Пути в скриптах:** оба .command используют `$(dirname "$0")` — работают из любой текущей директории, привязаны к папке, где лежит скрипт (корень Desktop/papa-yu). Сборка ищет .app в `src-tauri/target/release/bundle/macos/`.
3. **Документация:** в `docs/` обновлён файл рекомендаций; этот файл (`ЕДИНАЯ_ПАПКАРОЕКТА.md`) фиксирует канонический путь и связи.
4. **README:** добавлена секция «Единственная папка проекта» с путём и способами запуска.
---
## Что сделать вам (рекомендации)
1. **Работать только из одной папки:**
`/Users/yrippertgmail.com/Desktop/papa-yu`
Все изменения кода, сборка, запуск — только здесь.
2. **Домашняя копия (~/papa-yu / ~/PAPA-YU):**
Чтобы не путать две копии, после проверки работы из Desktop можно переименовать домашнюю, например:
`mv ~/papa-yu ~/papa-yu-archive`
Или удалить, если архив не нужен. Перед удалением убедитесь, что в Desktop/papa-yu есть всё нужное (в т.ч. .env.openai при необходимости скопировать вручную).
3. **Папка папа-ю:**
Не трогать. ТЗ и спецификации остаются в `Desktop/папа-ю`. В README указано, где они лежат.
4. **Пересборка после объединения:**
- Открыть терминал.
- `cd /Users/yrippertgmail.com/Desktop/papa-yu`
- `npm install` (если ещё не выполняли).
- Запуск для разработки: `./start-with-openai.sh` или `npm run tauri dev`.
- Сборка .app: двойной клик по **«PAPA YU — Сборка и запуск.command»** или `npm run tauri build`.
После сборки запуск — двойной клик по **«PAPA YU.command»**.
---
## Связи по новой
| Кто | Ссылается на |
|-----|----------------|
| **Код, сборка, запуск** | Только `Desktop/papa-yu` |
| **ТЗ и спецификации** | Папка `Desktop/папа-ю` (в README/docs указан путь) |
| **PAPA YU.command** | Ищет .app в `Desktop/papa-yu/src-tauri/target/release/bundle/macos/` |
| **Сборка и запуск.command** | Выполняет `npm run tauri build` в `Desktop/papa-yu` |
Жёстко прописанных путей в исходном коде (src, src-tauri) нет — везде относительные пути от корня проекта. Ошибки путей после объединения не ожидаются, если открывать и собирать проект из `Desktop/papa-yu`.

View File

@ -0,0 +1,141 @@
# PAPA YU: оценка обновлений и сценарий рассказа о программе
---
## Часть 1. Нуждается ли программа в обновлении или улучшении?
### Текущее состояние (после внедрённых улучшений)
**Уже сделано:**
- **Архитектура:** типы вынесены в `src/lib/types.ts`, единый API-слой в `src/lib/tauri.ts`; страница Tasks разбита на компоненты PathSelector, AgenticResult и хук useUndoRedo.
- **Безопасность (v2.4.4):** лимиты профиля (`max_actions_per_tx`, `timeout_sec`) применяются в `apply_actions_tx` и `run_batch`; в `verify_project` добавлен таймаут на выполнение проверок (cargo check, npm run build и т.д.).
- **Иконка:** используется RGBA-иконка из папки «папа-ю», сборка Tauri проходит успешно.
**Программа работоспособна и готова к использованию.** При этом остаются направления для развития.
### Что имеет смысл улучшить (по приоритету)
| Область | Рекомендация | Зачем |
|--------|---------------|--------|
| **UX** | История сессий в UI, отображение профиля и лимитов в форме, фильтр расширений при «Прикрепить файл», горячие клавиши (Ctrl+Enter, Escape) | Удобство и прозрачность для пользователя |
| **Тестирование** | Юнит-тесты для `detect_project_type`, `is_protected_file`, `build_plan`; E2E-сценарии (анализ → применение → undo) | Надёжность и уверенность при изменениях |
| **Документация** | Обновить README до актуальной версии (2.4.x), вести CHANGELOG | Проще онбординг и откат версий |
| **Дальнейшие фичи** | LLM-планировщик вместо чисто эвристического плана; контекст прикреплённых файлов при анализе; allowlist команд verify в конфиге | Расширение возможностей и гибкость |
**Вывод:** программа не «сломана» и не требует срочного обновления для базового сценария. Улучшения из списка выше повысят удобство, предсказуемость и расширяемость — их можно планировать поэтапно.
---
## Часть 2. Сценарий рассказа о программе (по модулям, технологиям, защите и уникальности)
Ниже — готовый сценарий для устного или письменного рассказа о PAPA YU: модули, достоинства, технологии, защищённость, возможности и уникальность.
---
### Введение
PAPA YU — это десктопное приложение для анализа кодовой базы и безопасного автоматического улучшения проектов: добавление README, .gitignore, тестов, приведение структуры к принятым практикам. Всё это делается с полным контролем пользователя: предпросмотр изменений, подтверждение применения и возможность отката одной кнопкой.
Рассказ удобно строить по слоям: интерфейс, единый слой работы с бэкендом, бэкенд по модулям, безопасность и уникальность.
---
### Модуль 1. Интерфейс (React + Vite)
**Что это:** одностраничное приложение на React 18 и Vite 5. Одна основная страница — «Задачи» (Tasks): выбор папок и файлов, поле ввода пути, лента сообщений (чат с системой и ассистентом), блоки с отчётом, превью изменений и результатами автоматического прогона.
**Модульная структура фронта:**
- **PathSelector** — блок выбора папок и прикреплённых файлов, кнопки «Выбрать папку» и «Прикрепить файл», список выбранных путей.
- **AgenticResult** — блок результата «исправить автоматически»: таблица попыток, статусы проверок, кнопки «Скачать отчёт», «Скачать diff», «Откатить».
- **useUndoRedo** — хук для состояния отката/повтора и вызова команд undo/redo через единый API-слой.
- **Единая точка типов и API:** все типы (Action, AnalyzeReport, AgenticRunResult и др.) лежат в `src/lib/types.ts`; все вызовы бэкенда идут через `src/lib/tauri.ts`, без прямых `invoke` в компонентах.
**Достоинства:** понятная структура, переиспользуемые компоненты и типы, один слой общения с Tauri — проще поддерживать и тестировать.
**Технологии:** React 18, Vite 5, TypeScript, React Router, Tauri 2 (плагины: dialog, shell). Алиас `@/` для импортов, строгий TypeScript.
---
### Модуль 2. Единый слой API (src/lib/tauri.ts)
**Что это:** все обращения к бэкенду проходят через функции в `tauri.ts`: `getProjectProfile`, `runBatchCmd`, `applyActionsTx`, `agenticRun`, `generateActionsFromReport`, `proposeActions`, `undoLastTx`, `undoLast`, `redoLast`, работа с папками и сессиями и т.д.
**Достоинства:** один контракт между UI и Rust; проще менять форматы запросов/ответов, добавлять логирование и обработку ошибок без правок во всех компонентах.
**Технологии:** `@tauri-apps/api/core` (invoke), типы из `@/lib/types`.
---
### Модуль 3. Бэкенд: команды и оркестрация (Tauri 2, Rust)
**Структура по модулям:**
- **analyze_project** — сканирование выбранных путей, эвристики (README, .gitignore, tests, структура), формирование отчёта с findings, recommendations, actions, action_groups, fix_packs.
- **get_project_profile** — определение типа проекта (React/Vite, Next.js, Node, Rust, Python, unknown) по файлам в корне; возврат профиля с лимитами (`max_actions_per_tx`, `timeout_sec`, `max_files`), шаблоном цели и флагом safe_mode.
- **run_batch** — единый сценарий: анализ → превью (если есть действия) → при необходимости применение с проверками. Перед применением проверяется лимит `max_actions_per_tx` из профиля; при превышении возвращается ошибка.
- **apply_actions_tx** — транзакционное применение: снимок состояния до изменений, применение действий, при включённом auto_check — запуск проверок (cargo check / npm run build и т.д.) с таймаутом из профиля; при падении проверки — автоматический откат. Проверка числа действий против `max_actions_per_tx`.
- **preview_actions** — расчёт diff без записи на диск.
- **undo_last_tx / undo_last / redo_last, get_undo_redo_state_cmd, undo_status** — откат и повтор последней транзакции, состояние undo/redo для UI.
- **generate_actions_from_report** — генерация списка безопасных действий по отчёту (например, только создание файлов/папок) без LLM.
- **propose_actions** — план исправлений по отчёту и цели пользователя (эвристический планировщик).
- **agentic_run** — цикл: анализ → план → превью → применение → проверка (verify); при неудаче проверки — откат и повтор в пределах max_attempts.
- **verify** — проверка проекта после изменений (cargo check, npm run build/test и т.д.) по allowlist команд, с таймаутом на каждый запуск.
- **projects / store** — проекты и сессии (list_projects, add_project, append_session_event, list_sessions), хранение в userData.
**Достоинства:** чёткое разделение по командам, переиспользование типов и лимитов профиля, единый контур «превью → применение → проверка → откат при ошибке».
**Технологии:** Rust, Tauri 2, serde, uuid, стандартная библиотека (fs, path, process, thread, time), allowlist команд и таймауты для внешних процессов.
---
### Модуль 4. Транзакции и откат (tx/)
**Что это:** снимки состояния файлов до применения, запись манифестов транзакций, откат к снимку при падении проверок или по запросу пользователя, стек undo/redo.
**Достоинства:** пользователь и система всегда могут откатить последнее применение; данные на диске остаются консистентными после отката.
**Технологии:** копирование/восстановление файлов и директорий, манифесты в JSON, привязка к AppHandle и путям проекта.
---
### Защищённость и ограничения рисков
- **Защита путей:** запрет изменения системных и служебных путей (например, .git, node_modules, target, dist); проверка, что изменяются только допустимые текстовые файлы (`is_protected_file`, `is_text_allowed`).
- **Подтверждение пользователя:** применение изменений только при явном `user_confirmed`; в UI — кнопки «Применить» и при необходимости диалог подтверждения.
- **Лимиты из профиля:** ограничение числа действий в одной транзакции (`max_actions_per_tx`) и таймауты на проверки (`timeout_sec`) снижают риск «зависания» и перегрузки.
- **Allowlist команд в verify:** запускаются только заранее разрешённые команды (cargo check, npm run build и т.д.) с фиксированными аргументами, без произвольного shell.
- **Таймауты:** проверки (verify, auto_check) выполняются с ограничением по времени; при превышении процесс завершается, пользователь получает сообщение (например, TIMEOUT), откат при необходимости уже выполнен.
В совокупности это даёт контролируемую среду: автоматизация без неограниченного доступа к системе и без «тихого» изменения критичных путей.
---
### Возможности для пользователя
- Выбор одной или нескольких папок и прикрепление файлов; ввод пути вручную.
- Один запуск анализа с получением отчёта: проблемы, рекомендации, группы действий и пакеты исправлений.
- Предпросмотр изменений (diff) до применения.
- Применение выбранных или рекомендованных исправлений с автоматической проверкой (сборка/тесты); при падении проверки — автоматический откат.
- Режим «исправить автоматически» (agentic run): несколько попыток с откатом при неудачной проверке.
- Режим «безопасные исправления в один клик»: генерация безопасных действий по отчёту → превью → применение с проверкой.
- Откат последнего применения и повтор (undo/redo).
- Скачивание отчёта и diff для аудита и отладки.
- Определение типа проекта и отображение профиля (в т.ч. лимитов) для прозрачности.
---
### Уникальность
- **Транзакционность и откат на уровне файловой системы:** не «патч и надежда», а снимок → применение → проверка → при ошибке откат к снимку и явное сообщение пользователю.
- **Профиль проекта и лимиты:** тип и лимиты (число действий, таймауты) задаются по структуре проекта и соблюдаются в apply и run_batch, что снижает риски и предсказуемо ограничивает автоматизацию.
- **Единый сценарий от анализа до отката:** анализ → план → превью → применение → проверка с таймаутом → откат при неудаче — реализован и в batch, и в agentic run, с одной и той же моделью безопасности.
- **Десктоп на Tauri 2:** нативный бэкенд на Rust, быстрый и контролируемый доступ к файлам и процессам, без веб-сервера и без открытия кода в браузере.
- **Гибрид эвристик и подготовки к LLM:** уже есть структура (propose_actions, agentic_run, отчёт с narrative и actions); планировщик можно заменить на LLM без смены контура выполнения.
---
### Заключение сценария
PAPA YU — это не просто «генератор README», а инструмент с чёткой архитектурой, контролем рисков и расширяемостью. Модули фронта и бэкенда разделены, типы и API централизованы, применение изменений транзакционно с откатом и проверками по таймауту и лимитам. Защита путей, подтверждение пользователя и allowlist команд делают автоматизацию предсказуемой и безопасной. Уникальность — в сочетании транзакционности, профилей проекта и единого сценария «анализ → превью → применение → проверка → откат при ошибке» в одном десктопном приложении.
При необходимости следующий шаг — улучшения UX (история сессий, горячие клавиши, фильтр файлов), тесты и обновление README/CHANGELOG, а затем — опционально LLM-планировщик и контекст прикреплённых файлов.

View File

@ -0,0 +1,142 @@
# Анализ папок проекта PAPA YU и рекомендации по объединению
Документ описывает текущее состояние папок, связанных с PAPA YU, и итог объединения в одну рабочую папку (папка **папа-ю** по вашему требованию не переносилась).
**Проверенные пути:**
- `/Users/yrippertgmail.com/Desktop/papa-yu`**единая папка проекта** (код, сборка, скрипты).
- `/Users/yrippertgmail.com/Desktop/папа-ю` — только документы и ТЗ (не переносилась).
- `/Users/yrippertgmail.com/PAPA-YU` и `/Users/yrippertgmail.com/papa-yu` — одна и та же папка в домашнем каталоге (другая структура: desktop/ui, desktop/src-tauri); после объединения можно архивировать или удалить.
Итог объединения: см. `docs/ЕДИНАЯ_ПАПКАРОЕКТА.md`.
---
## 1. Текущее состояние папок
### 1.1. `papa-yu` (латиница, на рабочем столе)
| Назначение | Содержимое |
|------------|------------|
| **Роль** | Единственная папка с исходным кодом десктопного приложения PAPA YU |
| **Стек** | React, Vite, TypeScript, Tauri 2, Rust |
| **Структура** | `src/`, `src-tauri/`, `package.json`, `docs/`, скрипты запуска (`start-with-openai.sh`), конфиги |
| **Запуск** | `npm run tauri dev` или `./start-with-openai.sh` |
| **Сборка** | `npm run tauri build` → .app в `src-tauri/target/release/bundle/macos/` |
**Вывод:** это основная рабочая папка для кода и сборки. Все правки приложения должны вноситься здесь.
---
### 1.2. `папа-ю` (кириллица, на рабочем столе)
| Назначение | Содержимое |
|------------|------------|
| **Роль** | Только документация и ТЗ по проекту PAPA-YU |
| **Содержимое** | `ЗАПУСКРИЛОЖЕНИЯ.txt`, `старт/` (этапы 17, DOCX), `коррект/`, `ТЗ/`, архивы zip |
| **Код** | Нет исходного кода приложения |
| **Ссылки** | В тексте указано: запуск из `papa-yu/` (например `papa-yu/Собрать и запустить PAPA-YU.command`) |
**Вывод:** папка используется как хранилище спецификаций и инструкций. Для «одной папки» её можно либо оставить как внешний архив документов, либо привязать к `papa-yu` через ссылку/подпапку (см. ниже).
---
### 1.3. «PAPA YU» как имя папки
Отдельной папки с названием **PAPA YU** (с пробелом) на рабочем столе нет.
**PAPA YU** — это название приложения (окно, `tauri.conf.json`). На macOS при открытии пути типа `~/Desktop/PAPA-YU` с учётом регистра может открываться та же файловая система, что и для `papa-yu` (зависит от настроек тома). Имеет смысл считать «одной папкой проекта» именно **`papa-yu`** и все пути вести к ней.
---
### 1.4. `papa-app` (на рабочем столе)
| Назначение | Содержимое |
|------------|------------|
| **Роль** | Отдельное веб-приложение (Next.js), не десктоп PAPA YU |
| **Содержимое** | `app/`, `components/`, `lib/`, Next.js-конфиги, а также «Новая папка» / «Новая папка с объектами» с материалами по PAPA (SQL, DOCX, скриншоты) |
| **Связь с PAPA YU** | Общее имя «PAPA», но другой продукт (веб vs десктоп) |
**Вывод:** для объединения именно **десктопного PAPA YU** `papa-app` не объединять с `papa-yu` в один репозиторий/проект. Документы по PAPA из `papa-app` при необходимости можно копировать в общую структуру документов (см. ниже).
---
## 2. Рекомендуемая «одна папка» для загрузки, изменений и правок
Цель: **всё, что касается десктопного приложения PAPA YU, вести из одной папки** — загрузка (clone/build), правки кода, запуск, сборка, документация.
### 2.1. Базовая рекомендация: единая точка входа — `papa-yu`
- **Загрузка / клонирование:** один репозиторий или один архив — папка **`papa-yu`**.
- **Изменения кода:** только в **`papa-yu`** (src, src-tauri, конфиги).
- **Запуск и сборка:** всегда из корня **`papa-yu`**:
- разработка: `cd papa-yu && npm run tauri dev` или `./start-with-openai.sh`;
- сборка: `cd papa-yu && npm run tauri build`.
- **Документация по приложению:** хранить внутри **`papa-yu/docs/`** (как сейчас: OPENAI_SETUP, E2E, IMPROVEMENTS и т.д.). Все инструкции по запуску/сборке ссылаются на пути относительно `papa-yu`.
Итог: **«одна папка» = `papa-yu`**. Все операции с десктопным PAPA YU выполняются из неё.
---
### 2.2. Как учесть папку `папа-ю` (документы), не перемещая файлы
Варианты **без** физического переноса файлов (только рекомендации):
1. **Оставить как есть**
- Рабочая папка — `papa-yu`.
- `папа-ю` — отдельный каталог с ТЗ и этапами.
- В `papa-yu/README.md` или в `docs/` один раз явно написать: «ТЗ и спецификации проекта лежат в папке `папа-ю` на рабочем столе (или по пути …)».
2. **Ссылка в документации**
- В `papa-yu/docs/` добавить файл (например `СВЯЗЬ_СОКУМЕНТАМИ.md`) с единственной строкой: где физически лежит `папа-ю` и что там (ТЗ, этапы, архивы). Все продолжают открывать код только в `papa-yu`, а документы — по этой ссылке.
3. **Симлинк (если нужен «один корень»)**
- Внутри `papa-yu` создать, например, `docs/specs-from-papa-yu-cyrillic` → симлинк на `~/Desktop/папа-ю`. Тогда «всё видно» из одного дерева `papa-yu`, но файлы кириллической папки не копируются. Рекомендация: делать только если действительно нужен единый корень в проводнике/IDE.
---
### 2.3. Как учесть `papa-app`
- **Не объединять** с `papa-yu` в один проект/репозиторий: разный стек и назначение.
- Если нужно хранить общие материалы по бренду/продукту PAPA:
- либо оставить их в `papa-app` и в `papa-yu/README.md` кратко указать: «Веб-интерфейс и доп. материалы — в проекте papa-app»;
- либо вынести общие документы в отдельную папку (например `Desktop/PAPA-docs`) и из обеих папок на неё ссылаться.
Объединение в одну папку для загрузки/правок здесь не рекомендуется.
---
## 3. Конкретные шаги (рекомендации, без автоматических изменений)
1. **Определить единственную рабочую папку**
- Для десктопного приложения: **`/Users/.../Desktop/papa-yu`**.
- Все пути в инструкциях (README, docs, скрипты) вести относительно неё.
2. **В README или docs папы-yu**
- Явно написать: «Проект ведётся из одной папки — papa-yu. Запуск, сборка и правки — только из её корня.»
- Указать при необходимости: «ТЗ и этапы — в папке папа-ю (кириллица) по пути …».
3. **Скрипты запуска**
- Все скрипты (например `start-with-openai.sh`, будущий `Собрать и запустить PAPA-YU.command`) должны:
- находиться в `papa-yu/`;
- выполнять `cd` в корень `papa-yu` (например `cd "$(dirname "$0")"`);
- не ссылаться на «PAPA-YU» или «папа-ю» как на каталог с кодом.
4. **Имя папки в системе**
- Для избежания путаницы с регистром и пробелами лучше везде использовать **`papa-yu`** (латиница, один регистр). Не создавать дубликат с именем «PAPA YU» или «PAPA-YU» для кода.
5. **Документация из папа-ю**
- Если позже понадобится «всё в одном месте»: можно скопировать выбранные DOCX/PDF из `папа-ю` в `papa-yu/docs/specs/` (или аналогичную подпапку) и при желании обновить ссылки в README. Это уже будет решение по переносу файлов; в текущем документе достаточно понимать, что логически «одна папка» — это `papa-yu`, а `папа-ю` — внешний архив, связь с которым задаётся явной ссылкой в документации.
---
## 4. Краткая сводка
| Вопрос | Ответ |
|--------|--------|
| Какую папку считать «одной» для загрузки и правок? | **`papa-yu`** (латиница). |
| Где вносить изменения в код и конфиги? | Только в **`papa-yu`**. |
| Откуда запускать и собирать приложение? | Из корня **`papa-yu`**. |
| Что делать с папкой `папа-ю`? | Оставить как хранилище ТЗ; в `papa-yu` описать путь к ней в README/docs или (по желанию) добавить симлинк в `papa-yu/docs/`. |
| Нужно ли объединять с `papa-app`? | Нет; это другой продукт. При необходимости — только ссылка в документации. |
| «PAPA YU» как папка? | Отдельной папки с таким именем нет; это название приложения. Рабочая папка — **`papa-yu`**. |
Все рекомендации выше можно выполнять вручную; автоматических изменений в файлы этот документ не вносит.

33
env.openai.example Normal file
View File

@ -0,0 +1,33 @@
# Скопируйте этот файл в .env.openai и подставьте свой ключ OpenAI.
# Команда: cp env.openai.example .env.openai
# Затем откройте .env.openai и замените your-openai-key-here на ваш ключ.
PAPAYU_LLM_API_URL=https://api.openai.com/v1/chat/completions
PAPAYU_LLM_API_KEY=your-openai-key-here
PAPAYU_LLM_MODEL=gpt-4o-mini
# Строгий JSON (OpenAI Structured Outputs): добавляет response_format с JSON Schema.
# Работает с OpenAI; Ollama и др. могут не поддерживать — не задавать или =0.
# PAPAYU_LLM_STRICT_JSON=1
# memory_patch: 0 (по умолчанию) — игнорировать; 1 — применять по whitelist.
# PAPAYU_MEMORY_AUTOPATCH=0
# EOL: keep (по умолчанию) — не менять; lf — нормализовать \r\n→\n, trailing newline.
# PAPAYU_NORMALIZE_EOL=lf
# LLM: температура 0 (детерминизм), max_tokens 16384 (авто-кэп при input>80k → 4096).
# PAPAYU_LLM_TEMPERATURE=0
# PAPAYU_LLM_MAX_TOKENS=16384
# Таймаут запроса к LLM (сек).
# PAPAYU_LLM_TIMEOUT_SEC=90
# Трассировка: PAPAYU_TRACE=1 → пишет в .papa-yu/traces/<trace_id>.json (без raw_content по умолчанию).
# PAPAYU_TRACE=1
# PAPAYU_TRACE_RAW=1 — сохранять raw_content (с маскировкой sk-/Bearer)
# Контекст-диета: max 8 файлов, 20k на файл, 120k total.
# PAPAYU_CONTEXT_MAX_FILES=8
# PAPAYU_CONTEXT_MAX_FILE_CHARS=20000
# PAPAYU_CONTEXT_MAX_TOTAL_CHARS=120000

15
index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PAPA YU</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2610
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "papa-yu",
"version": "2.4.4",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri",
"icons:export": "node scripts/export-icon.js"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"sharp": "^0.34.5",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

BIN
public/send-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

55
scripts/export-icon.js Normal file
View File

@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Экспорт иконки: SVG PNG 1024x1024 для сборки Tauri.
* Варианты: ImageMagick (convert/magick), иначе npm install sharp && node scripts/export-icon.js
*/
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const src = path.join(__dirname, '../src-tauri/icons/icon.svg');
const out = path.join(__dirname, '../src-tauri/icons/icon.png');
if (!fs.existsSync(src)) {
console.error('Не найден файл:', src);
process.exit(1);
}
function tryImageMagick() {
try {
execSync('convert -version', { stdio: 'ignore' });
execSync(`convert -background none -resize 1024x1024 "${src}" "${out}"`, { stdio: 'inherit' });
return true;
} catch (_) {}
try {
execSync('magick -version', { stdio: 'ignore' });
execSync(`magick convert -background none -resize 1024x1024 "${src}" "${out}"`, { stdio: 'inherit' });
return true;
} catch (_) {}
return false;
}
async function run() {
if (tryImageMagick()) {
console.log('Иконка экспортирована (ImageMagick):', out);
return;
}
try {
const sharp = require('sharp');
await sharp(src)
.resize(1024, 1024)
.png()
.toFile(out);
console.log('Иконка экспортирована (sharp):', out);
} catch (e) {
if (e.code === 'MODULE_NOT_FOUND') {
console.error('Установите sharp: npm install --save-dev sharp');
console.error('Или экспортируйте вручную: откройте src-tauri/icons/icon.svg в браузере/редакторе и сохраните как PNG 1024×1024 в icon.png');
} else {
console.error(e.message);
}
process.exit(1);
}
}
run();

34
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,34 @@
[package]
name = "papa-yu"
version = "2.4.4"
edition = "2021"
description = "PAPA YU — анализ и исправление проектов"
[lib]
name = "papa_yu_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
walkdir = "2"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
jsonschema = "0.18"
sha2 = "0.10"
[dev-dependencies]
tempfile = "3"
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
strip = true

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,11 @@
{
"identifier": "default",
"description": "Default capability for PAPA YU",
"windows": ["*"],
"permissions": [
"core:default",
"core:path:default",
"shell:allow-open",
"dialog:allow-open"
]
}

View File

@ -0,0 +1,77 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"x_schema_version": 1,
"oneOf": [
{
"type": "array",
"items": { "$ref": "#/$defs/action" },
"minItems": 0
},
{
"type": "object",
"additionalProperties": true,
"properties": {
"mode": { "type": "string", "enum": ["fix-plan", "apply"] },
"actions": {
"type": "array",
"items": { "$ref": "#/$defs/action" }
},
"proposed_changes": {
"type": "object",
"additionalProperties": true,
"properties": {
"actions": {
"type": "array",
"items": { "$ref": "#/$defs/action" }
}
}
},
"summary": { "type": "string" },
"questions": { "type": "array", "items": { "type": "string" } },
"context_requests": {
"type": "array",
"items": { "$ref": "#/$defs/context_request" }
},
"plan": {
"type": "array",
"items": {
"type": "object",
"properties": { "step": { "type": "string" }, "details": { "type": "string" } }
}
},
"memory_patch": { "type": "object", "additionalProperties": true },
"risks": { "type": "array", "items": { "type": "string" } }
}
}
],
"$defs": {
"action": {
"type": "object",
"additionalProperties": true,
"required": ["kind", "path"],
"properties": {
"kind": {
"type": "string",
"enum": ["CREATE_FILE", "CREATE_DIR", "UPDATE_FILE", "DELETE_FILE", "DELETE_DIR"]
},
"path": { "type": "string" },
"content": { "type": "string" }
}
},
"context_request": {
"type": "object",
"additionalProperties": true,
"required": ["type"],
"properties": {
"type": { "type": "string", "enum": ["read_file", "search", "logs", "env"] },
"path": { "type": "string" },
"start_line": { "type": "integer", "minimum": 1 },
"end_line": { "type": "integer", "minimum": 1 },
"query": { "type": "string" },
"glob": { "type": "string" },
"source": { "type": "string" },
"last_n": { "type": "integer", "minimum": 1, "maximum": 5000 }
}
}
}
}

View File

@ -0,0 +1,19 @@
{
"rust": [
{ "exe": "cargo", "args": ["check"], "name": "cargo check", "timeout_sec": 120 },
{ "exe": "cargo", "args": ["test", "--no-run"], "name": "cargo test --no-run", "timeout_sec": 180 },
{ "exe": "cargo", "args": ["clippy", "--", "-D", "warnings"], "name": "cargo clippy", "timeout_sec": 120 }
],
"node": [
{ "exe": "npm", "args": ["run", "-s", "test"], "name": "npm test", "timeout_sec": 120 },
{ "exe": "npm", "args": ["run", "-s", "build"], "name": "npm run build", "timeout_sec": 180 },
{ "exe": "npm", "args": ["run", "-s", "lint"], "name": "npm run lint", "timeout_sec": 60 },
{ "exe": "npm", "args": ["run", "-s", "typecheck"], "name": "npm run typecheck", "timeout_sec": 60 },
{ "exe": "npx", "args": ["tsc", "--noEmit"], "name": "tsc --noEmit", "timeout_sec": 60 }
],
"python": [
{ "exe": "python3", "args": ["-m", "compileall", ".", "-q"], "name": "python -m compileall", "timeout_sec": 60 },
{ "exe": "python3", "args": ["-m", "pytest", "--collect-only", "-q"], "name": "pytest --collect-only", "timeout_sec": 60 },
{ "exe": "python3", "args": ["-m", "mypy", "."], "name": "mypy", "timeout_sec": 120 }
]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capability for PAPA YU","local":true,"windows":["*"],"permissions":["core:default","core:path:default","shell:allow-open","dialog:allow-open"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

17
src-tauri/icons/README.md Normal file
View File

@ -0,0 +1,17 @@
# Иконка приложения PAPA YU
- **icon.svg** — исходная иконка (код/скобки + галочка «исправлено», синий фон, оранжевый акцент).
- **icon.png** — используется в сборке Tauri (1024×1024).
Чтобы пересобрать PNG из SVG (после изменения иконки):
```bash
# из корня проекта papa-yu
npm run icons:export
```
Требуется один из вариантов:
- **ImageMagick:** `brew install imagemagick` (на macOS)
- **sharp:** `npm install --save-dev sharp`
Либо откройте `icon.svg` в браузере или редакторе (Figma, Inkscape) и экспортируйте как PNG 1024×1024 в `icon.png`.

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

24
src-tauri/icons/icon.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1e3a5f"/>
<stop offset="100%" style="stop-color:#2d5a87"/>
</linearGradient>
<linearGradient id="accent" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#f59e0b"/>
<stop offset="100%" style="stop-color:#ea580c"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-opacity="0.2"/>
</filter>
</defs>
<!-- Rounded square background -->
<rect width="1024" height="1024" rx="192" ry="192" fill="url(#bg)"/>
<!-- Code brackets { } -->
<g fill="none" stroke="url(#accent)" stroke-width="72" stroke-linecap="round" stroke-linejoin="round" transform="translate(512,512)" filter="url(#shadow)">
<path d="M -140 -200 Q -220 -200 -220 -120 L -220 120 Q -220 200 -140 200" stroke-width="80"/>
<path d="M 140 -200 Q 220 -200 220 -120 L 220 120 Q 220 200 140 200" stroke-width="80"/>
</g>
<!-- Checkmark inside (fix/done) -->
<path d="M -90 -30 L 10 50 L 130 -90" fill="none" stroke="url(#accent)" stroke-width="72" stroke-linecap="round" stroke-linejoin="round" transform="translate(512,512)" filter="url(#shadow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,312 @@
//! v2.4: Agentic Loop — analyze → plan → preview → apply → verify → auto-rollback → retry.
use std::path::Path;
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, Window};
use crate::commands::{analyze_project, apply_actions_tx, generate_actions_from_report, get_project_profile, preview_actions, undo_last_tx};
use crate::types::{
Action, ActionKind, AgenticRunRequest, AgenticRunResult, AttemptResult,
ApplyOptions, ApplyPayload, VerifyResult,
};
use crate::verify::verify_project;
const AGENTIC_PROGRESS: &str = "agentic_progress";
#[derive(Clone, Serialize, Deserialize)]
pub struct AgenticProgressPayload {
pub stage: String,
pub message: String,
pub attempt: u8,
}
fn emit_progress(window: &Window, stage: &str, message: &str, attempt: u8) {
let _ = window.emit(
AGENTIC_PROGRESS,
AgenticProgressPayload {
stage: stage.to_string(),
message: message.to_string(),
attempt,
},
);
}
fn has_readme(root: &Path) -> bool {
["README.md", "README.MD", "README.txt", "README"]
.iter()
.any(|f| root.join(f).exists())
}
fn has_gitignore(root: &Path) -> bool {
root.join(".gitignore").exists()
}
fn has_src(root: &Path) -> bool {
root.join("src").is_dir()
}
fn has_tests(root: &Path) -> bool {
root.join("tests").is_dir()
}
fn has_editorconfig(root: &Path) -> bool {
root.join(".editorconfig").exists()
}
/// v2.4.0: эвристический план (без LLM). README, .gitignore, tests/README.md, .editorconfig.
fn build_plan(
path: &str,
_goal: &str,
max_actions: u16,
) -> (String, Vec<Action>) {
let root = Path::new(path);
let mut actions: Vec<Action> = vec![];
let mut plan_parts: Vec<String> = vec![];
if !has_readme(root) {
actions.push(Action {
kind: ActionKind::CreateFile,
path: "README.md".to_string(),
content: Some(
"# Project\n\n## Описание\n\nКратко опишите проект.\n\n## Запуск\n\n- dev: ...\n- build: ...\n\n## Структура\n\n- src/\n- tests/\n".into(),
),
});
plan_parts.push("README.md".into());
}
if !has_gitignore(root) {
actions.push(Action {
kind: ActionKind::CreateFile,
path: ".gitignore".to_string(),
content: Some(
"node_modules/\ndist/\nbuild/\n.next/\ncoverage/\n.env\n.env.*\n.DS_Store\n.target/\n".into(),
),
});
plan_parts.push(".gitignore".into());
}
if has_src(root) && !has_tests(root) {
actions.push(Action {
kind: ActionKind::CreateFile,
path: "tests/README.md".to_string(),
content: Some("# Тесты\n\nДобавьте unit- и интеграционные тесты.\n".into()),
});
plan_parts.push("tests/README.md".into());
}
if !has_editorconfig(root) {
actions.push(Action {
kind: ActionKind::CreateFile,
path: ".editorconfig".to_string(),
content: Some(
"root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\n".into(),
),
});
plan_parts.push(".editorconfig".into());
}
let n = max_actions as usize;
if actions.len() > n {
actions.truncate(n);
}
let plan = if plan_parts.is_empty() {
"Нет безопасных правок для применения.".to_string()
} else {
format!("План: добавить {}", plan_parts.join(", "))
};
(plan, actions)
}
#[tauri::command]
pub async fn agentic_run(window: Window, payload: AgenticRunRequest) -> AgenticRunResult {
let path = payload.path.clone();
let user_goal = payload.goal.clone();
let constraints = payload.constraints.clone();
let app = window.app_handle();
let profile = match get_project_profile(window.clone(), path.clone()).await {
Ok(p) => p,
Err(e) => {
return AgenticRunResult {
ok: false,
attempts: vec![],
final_summary: format!("Ошибка профиля: {}", e),
error: Some(e.clone()),
error_code: Some("PROFILE_ERROR".into()),
};
}
};
let max_attempts = profile.max_attempts.max(1);
let max_actions = (profile.limits.max_actions_per_tx as u16).max(1);
let goal = profile.goal_template.replace("{goal}", &user_goal);
let _safe_mode = profile.safe_mode;
let mut attempts: Vec<AttemptResult> = vec![];
for attempt in 1..=max_attempts {
let attempt_u8 = attempt.min(255) as u8;
emit_progress(&window, "analyze", "Сканирую проект…", attempt_u8);
let report = match analyze_project(vec![path.clone()], None) {
Ok(r) => r,
Err(e) => {
emit_progress(&window, "failed", "Ошибка анализа.", attempt_u8);
return AgenticRunResult {
ok: false,
attempts,
final_summary: format!("Ошибка анализа: {}", e),
error: Some(e),
error_code: Some("ANALYZE_FAILED".into()),
};
}
};
emit_progress(&window, "plan", "Составляю план исправлений…", attempt_u8);
let gen = generate_actions_from_report(
path.clone(),
report.clone(),
"safe_create_only".to_string(),
)
.await;
let (plan, actions) = if gen.ok && !gen.actions.is_empty() {
let n = max_actions as usize;
let mut a = gen.actions;
if a.len() > n {
a.truncate(n);
}
(
format!("План из отчёта: {} действий.", a.len()),
a,
)
} else {
build_plan(&path, &goal, max_actions)
};
if actions.is_empty() {
emit_progress(&window, "done", "Готово.", attempt_u8);
return AgenticRunResult {
ok: true,
attempts,
final_summary: plan.clone(),
error: None,
error_code: None,
};
}
emit_progress(&window, "preview", "Показываю, что изменится…", attempt_u8);
let preview = match preview_actions(ApplyPayload {
root_path: path.clone(),
actions: actions.clone(),
auto_check: None,
label: None,
user_confirmed: false,
}) {
Ok(p) => p,
Err(e) => {
emit_progress(&window, "failed", "Ошибка предпросмотра.", attempt_u8);
return AgenticRunResult {
ok: false,
attempts,
final_summary: format!("Ошибка предпросмотра: {}", e),
error: Some(e),
error_code: Some("PREVIEW_FAILED".into()),
};
}
};
emit_progress(&window, "apply", "Применяю изменения…", attempt_u8);
let apply_result = apply_actions_tx(
app.clone(),
path.clone(),
actions.clone(),
ApplyOptions {
auto_check: false,
user_confirmed: true,
},
)
.await;
if !apply_result.ok {
emit_progress(&window, "failed", "Не удалось безопасно применить изменения.", attempt_u8);
let err = apply_result.error.clone();
let code = apply_result.error_code.clone();
attempts.push(AttemptResult {
attempt: attempt_u8,
plan: plan.clone(),
actions: actions.clone(),
preview,
apply: apply_result,
verify: VerifyResult {
ok: false,
checks: vec![],
error: None,
error_code: None,
},
});
return AgenticRunResult {
ok: false,
attempts,
final_summary: "Apply не выполнен.".to_string(),
error: err,
error_code: code,
};
}
let verify = if constraints.auto_check {
emit_progress(&window, "verify", "Проверяю сборку/типы…", attempt_u8);
let v = verify_project(&path);
if !v.ok {
emit_progress(&window, "revert", "Обнаружены ошибки. Откатываю изменения…", attempt_u8);
let _ = undo_last_tx(app.clone(), path.clone()).await;
attempts.push(AttemptResult {
attempt: attempt_u8,
plan: plan.clone(),
actions: actions.clone(),
preview,
apply: apply_result,
verify: v,
});
continue;
}
v
} else {
VerifyResult {
ok: true,
checks: vec![],
error: None,
error_code: None,
}
};
attempts.push(AttemptResult {
attempt: attempt_u8,
plan: plan.clone(),
actions: actions.clone(),
preview,
apply: apply_result,
verify: verify.clone(),
});
emit_progress(&window, "done", "Готово.", attempt_u8);
return AgenticRunResult {
ok: true,
attempts,
final_summary: plan,
error: None,
error_code: None,
};
}
emit_progress(&window, "failed", "Не удалось безопасно применить изменения.", max_attempts.min(255) as u8);
AgenticRunResult {
ok: false,
attempts,
final_summary: "Превышено число попыток. Изменения откачены.".to_string(),
error: Some("max_attempts exceeded".into()),
error_code: Some("MAX_ATTEMPTS_EXCEEDED".into()),
}
}

View File

@ -0,0 +1,246 @@
use crate::types::{Action, ActionGroup, ActionKind, AnalyzeReport, Finding, FixPack, ProjectSignal};
use std::path::Path;
pub fn analyze_project(paths: Vec<String>, attached_files: Option<Vec<String>>) -> Result<AnalyzeReport, String> {
let path = paths.first().cloned().unwrap_or_else(|| ".".to_string());
let root = Path::new(&path);
if !root.is_dir() {
return Ok(AnalyzeReport {
path: path.clone(),
narrative: format!("Папка не найдена: {}", path),
findings: vec![],
recommendations: vec![],
actions: vec![],
action_groups: vec![],
fix_packs: vec![],
recommended_pack_ids: vec![],
attached_files,
});
}
let has_readme = root.join("README.md").is_file();
let has_gitignore = root.join(".gitignore").is_file();
let has_env = root.join(".env").is_file();
let has_src = root.join("src").is_dir();
let has_tests = root.join("tests").is_dir();
let has_package = root.join("package.json").is_file();
let has_cargo = root.join("Cargo.toml").is_file();
let mut findings = Vec::new();
let recommendations = Vec::new();
let action_groups = build_action_groups(root, has_readme, has_gitignore, has_src, has_tests, has_package, has_cargo);
let mut actions: Vec<Action> = action_groups.iter().flat_map(|g| g.actions.clone()).collect();
if !has_readme {
findings.push(Finding {
title: "Нет README.md".to_string(),
details: "Рекомендуется добавить описание проекта.".to_string(),
path: Some(path.clone()),
});
}
if !has_gitignore {
findings.push(Finding {
title: "Нет .gitignore".to_string(),
details: "Рекомендуется добавить .gitignore для типа проекта.".to_string(),
path: Some(path.clone()),
});
}
if has_env {
findings.push(Finding {
title: "Найден .env".to_string(),
details: "Рекомендуется создать .env.example и не коммитить .env.".to_string(),
path: Some(path.clone()),
});
actions.push(Action {
kind: ActionKind::CreateFile,
path: ".env.example".to_string(),
content: Some("# Copy to .env and fill\n".to_string()),
});
}
if has_src && !has_tests {
findings.push(Finding {
title: "Нет папки tests/".to_string(),
details: "Рекомендуется добавить tests/ и README в ней.".to_string(),
path: Some(path.clone()),
});
}
let signals = build_signals_from_findings(&findings);
let (fix_packs, recommended_pack_ids) = build_fix_packs(&action_groups, &signals);
let narrative = format!(
"Проанализировано: {}. Найдено проблем: {}, рекомендаций: {}, действий: {}.",
path,
findings.len(),
recommendations.len(),
actions.len()
);
Ok(AnalyzeReport {
path,
narrative,
findings,
recommendations,
actions,
action_groups,
fix_packs,
recommended_pack_ids,
attached_files,
})
}
fn build_action_groups(
_path: &Path,
has_readme: bool,
has_gitignore: bool,
has_src: bool,
has_tests: bool,
has_package: bool,
has_cargo: bool,
) -> Vec<ActionGroup> {
let mut groups: Vec<ActionGroup> = vec![];
if !has_readme {
groups.push(ActionGroup {
id: "readme".into(),
title: "Добавить README".into(),
description: "Создаст README.md с базовой структурой.".into(),
actions: vec![Action {
kind: ActionKind::CreateFile,
path: "README.md".into(),
content: Some("# Project\n\n## Overview\n\n## How to run\n\n## Tests\n\n".into()),
}],
});
}
if !has_gitignore {
let content = if has_package {
"node_modules/\n.env\n.DS_Store\ndist/\n*.log\n"
} else if has_cargo {
"target/\n.env\n.DS_Store\nCargo.lock\n"
} else {
".env\n.DS_Store\n__pycache__/\n*.pyc\n.venv/\n"
};
groups.push(ActionGroup {
id: "gitignore".into(),
title: "Добавить .gitignore".into(),
description: "Создаст .gitignore со стандартными исключениями.".into(),
actions: vec![Action {
kind: ActionKind::CreateFile,
path: ".gitignore".into(),
content: Some(content.to_string()),
}],
});
}
if !has_tests && has_src {
groups.push(ActionGroup {
id: "tests".into(),
title: "Добавить tests/".into(),
description: "Создаст папку tests/ и README для тестов.".into(),
actions: vec![
Action {
kind: ActionKind::CreateDir,
path: "tests".into(),
content: None,
},
Action {
kind: ActionKind::CreateFile,
path: "tests/README.md".into(),
content: Some("# Tests\n\nAdd tests here.\n".into()),
},
],
});
}
groups
}
fn build_signals_from_findings(findings: &[Finding]) -> Vec<ProjectSignal> {
let mut signals: Vec<ProjectSignal> = vec![];
for f in findings {
if f.title.contains("gitignore") {
signals.push(ProjectSignal {
category: "security".into(),
level: "high".into(),
});
}
if f.title.contains("README") {
signals.push(ProjectSignal {
category: "quality".into(),
level: "warn".into(),
});
}
if f.title.contains("tests") || f.details.to_lowercase().contains("тест") {
signals.push(ProjectSignal {
category: "quality".into(),
level: "warn".into(),
});
}
}
signals
}
fn build_fix_packs(action_groups: &[ActionGroup], signals: &[ProjectSignal]) -> (Vec<FixPack>, Vec<String>) {
let mut security: Vec<String> = vec![];
let mut quality: Vec<String> = vec![];
let structure: Vec<String> = vec![];
for g in action_groups {
match g.id.as_str() {
"gitignore" => security.push(g.id.clone()),
"readme" => quality.push(g.id.clone()),
"tests" => quality.push(g.id.clone()),
_ => {}
}
}
let mut recommended: Vec<String> = vec![];
let has_high_security = signals
.iter()
.any(|s| s.category == "security" && (s.level == "high" || s.level == "critical"));
let has_quality_issues = signals
.iter()
.any(|s| s.category == "quality" && (s.level == "warn" || s.level == "high"));
let has_structure_issues = signals
.iter()
.any(|s| s.category == "structure" && (s.level == "warn" || s.level == "high"));
if has_high_security && !security.is_empty() {
recommended.push("security".into());
}
if has_quality_issues && !quality.is_empty() {
recommended.push("quality".into());
}
if has_structure_issues && !structure.is_empty() {
recommended.push("structure".into());
}
if recommended.is_empty() && !quality.is_empty() {
recommended.push("quality".into());
}
let packs = vec![
FixPack {
id: "security".into(),
title: "Безопасность".into(),
description: "Снижает риск утечки секретов и мусора в репозитории.".into(),
group_ids: security,
},
FixPack {
id: "quality".into(),
title: "Качество".into(),
description: "Базовые улучшения читаемости и проверяемости проекта.".into(),
group_ids: quality,
},
FixPack {
id: "structure".into(),
title: "Структура".into(),
description: "Наводит порядок в структуре проекта и соглашениях.".into(),
group_ids: structure,
},
];
(packs, recommended)
}

View File

@ -0,0 +1,214 @@
use std::path::Path;
use tauri::AppHandle;
use crate::commands::auto_check::auto_check;
use crate::tx::{
apply_one_action, clear_redo, collect_rel_paths, ensure_history, new_tx_id,
preflight_actions, push_undo, rollback_tx, snapshot_before, sort_actions_for_apply, write_manifest,
};
use crate::types::{ApplyPayload, ApplyResult, TxManifest};
pub const AUTO_CHECK_FAILED_REVERTED: &str = "AUTO_CHECK_FAILED_REVERTED";
#[allow(dead_code)]
pub const APPLY_FAILED_REVERTED: &str = "APPLY_FAILED_REVERTED";
/// v2.3.3: apply failed at step N, rolled back applied steps
pub const AUTO_ROLLBACK_DONE: &str = "AUTO_ROLLBACK_DONE";
pub fn apply_actions(app: AppHandle, payload: ApplyPayload) -> ApplyResult {
let root = match Path::new(&payload.root_path).canonicalize() {
Ok(p) => p,
Err(_) => Path::new(&payload.root_path).to_path_buf(),
};
if !root.exists() || !root.is_dir() {
return ApplyResult {
ok: false,
tx_id: None,
applied_count: None,
failed_at: None,
error: Some("path invalid".into()),
error_code: Some("PATH_INVALID".into()),
};
}
if !payload.user_confirmed {
return ApplyResult {
ok: false,
tx_id: None,
applied_count: None,
failed_at: None,
error: Some("confirmation required".into()),
error_code: Some("CONFIRM_REQUIRED".into()),
};
}
if ensure_history(&app).is_err() {
return ApplyResult {
ok: false,
tx_id: None,
applied_count: None,
failed_at: None,
error: Some("history init failed".into()),
error_code: Some("HISTORY_INIT_FAILED".into()),
};
}
if payload.actions.is_empty() {
return ApplyResult {
ok: true,
tx_id: None,
applied_count: Some(0),
failed_at: None,
error: None,
error_code: None,
};
}
if let Err((msg, code)) = preflight_actions(&root, &payload.actions) {
return ApplyResult {
ok: false,
tx_id: None,
applied_count: None,
failed_at: None,
error: Some(msg),
error_code: Some(code),
};
}
let tx_id = new_tx_id();
let rel_paths = collect_rel_paths(&payload.actions);
let touched = match snapshot_before(&app, &tx_id, &root, &rel_paths) {
Ok(t) => t,
Err(e) => {
return ApplyResult {
ok: false,
tx_id: Some(tx_id.clone()),
applied_count: None,
failed_at: None,
error: Some(e),
error_code: Some("SNAPSHOT_FAILED".into()),
};
}
};
let mut manifest = TxManifest {
tx_id: tx_id.clone(),
root_path: payload.root_path.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
label: payload.label.clone(),
status: "pending".into(),
applied_actions: payload.actions.clone(),
touched: touched.clone(),
auto_check: payload.auto_check.unwrap_or(false),
snapshot_items: None,
};
if let Err(e) = write_manifest(&app, &manifest) {
return ApplyResult {
ok: false,
tx_id: Some(tx_id),
applied_count: None,
failed_at: None,
error: Some(e.to_string()),
error_code: Some("MANIFEST_WRITE_FAILED".into()),
};
}
// v2.4.2: guard — запрет lock/бинарников/не-текстовых
for action in &payload.actions {
let rel = action.path.as_str();
if is_protected_file(rel) || !is_text_allowed(rel) {
return ApplyResult {
ok: false,
tx_id: Some(tx_id.clone()),
applied_count: None,
failed_at: None,
error: Some(format!("protected or non-text file: {}", rel)),
error_code: Some("PROTECTED_PATH".into()),
};
}
}
// v2.3.3: apply one-by-one; on first failure rollback and return AUTO_ROLLBACK_DONE
// Порядок применения: CREATE_DIR → CREATE/UPDATE → DELETE_FILE → DELETE_DIR
let mut sorted_actions = payload.actions.clone();
sort_actions_for_apply(&mut sorted_actions);
for (i, action) in sorted_actions.iter().enumerate() {
if let Err(e) = apply_one_action(&root, action) {
let _ = rollback_tx(&app, &tx_id);
manifest.status = "rolled_back".into();
let _ = write_manifest(&app, &manifest);
return ApplyResult {
ok: false,
tx_id: Some(tx_id.clone()),
applied_count: Some(i),
failed_at: Some(i),
error: Some(format!("apply failed, rolled back: {}", e)),
error_code: Some(AUTO_ROLLBACK_DONE.into()),
};
}
}
if payload.auto_check.unwrap_or(false) {
if let Err(_) = auto_check(&root) {
let _ = rollback_tx(&app, &tx_id);
return ApplyResult {
ok: false,
tx_id: Some(tx_id),
applied_count: Some(payload.actions.len()),
failed_at: None,
error: Some("Ошибки после изменений. Откат выполнен.".into()),
error_code: Some(AUTO_CHECK_FAILED_REVERTED.into()),
};
}
}
manifest.status = "committed".into();
let _ = write_manifest(&app, &manifest);
let _ = push_undo(&app, tx_id.clone());
let _ = clear_redo(&app);
ApplyResult {
ok: true,
tx_id: Some(tx_id),
applied_count: Some(payload.actions.len()),
failed_at: None,
error: None,
error_code: None,
}
}
fn is_protected_file(p: &str) -> bool {
let lower = p.to_lowercase().replace('\\', "/");
if lower == ".env" || lower.ends_with("/.env") { return true; }
if lower.ends_with(".pem") || lower.ends_with(".key") || lower.ends_with(".p12") { return true; }
if lower.contains("id_rsa") { return true; }
if lower.contains("/secrets/") || lower.starts_with("secrets/") { return true; }
if lower.ends_with("cargo.lock") { return true; }
if lower.ends_with("package-lock.json") { return true; }
if lower.ends_with("pnpm-lock.yaml") { return true; }
if lower.ends_with("yarn.lock") { return true; }
if lower.ends_with("composer.lock") { return true; }
if lower.ends_with("poetry.lock") { return true; }
if lower.ends_with("pipfile.lock") { return true; }
let bin_ext = [
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
".pdf", ".zip", ".7z", ".rar", ".dmg", ".pkg",
".exe", ".dll", ".so", ".dylib", ".bin",
".mp3", ".mp4", ".mov", ".avi",
".wasm", ".class",
];
for ext in bin_ext {
if lower.ends_with(ext) { return true; }
}
false
}
fn is_text_allowed(p: &str) -> bool {
let lower = p.to_lowercase();
let ok_ext = [
".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt", ".toml", ".yaml", ".yml",
".rs", ".py", ".go", ".java", ".kt", ".c", ".cpp", ".h", ".hpp",
".css", ".scss", ".html", ".env", ".gitignore", ".editorconfig",
];
ok_ext.iter().any(|e| lower.ends_with(e)) || !lower.contains('.')
}

View File

@ -0,0 +1,521 @@
//! v3.1: транзакция — snapshot + apply + autocheck + autorollback (history/tx, history/snapshots)
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use serde_json::json;
use tauri::{AppHandle, Emitter, Manager};
use uuid::Uuid;
use crate::commands::get_project_profile::get_project_limits;
use crate::tx::{normalize_content_for_write, safe_join, sort_actions_for_apply};
use crate::types::{Action, ActionKind, ApplyOptions, ApplyTxResult, CheckStageResult};
const PROGRESS_EVENT: &str = "analyze_progress";
fn clip(s: String, n: usize) -> String {
if s.len() <= n {
s
} else {
format!("{}", &s[..n])
}
}
fn emit_progress(app: &AppHandle, msg: &str) {
let _ = app.emit(PROGRESS_EVENT, msg);
}
fn write_tx_record(
app: &AppHandle,
tx_id: &str,
record: &serde_json::Value,
) -> Result<(), String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let tx_dir = dir.join("history").join("tx");
fs::create_dir_all(&tx_dir).map_err(|e| e.to_string())?;
let p = tx_dir.join(format!("{tx_id}.json"));
let bytes =
serde_json::to_vec_pretty(record).map_err(|e| e.to_string())?;
fs::write(&p, bytes).map_err(|e| e.to_string())
}
fn copy_dir_recursive(
src: &Path,
dst: &Path,
exclude: &[&str],
) -> Result<(), String> {
if exclude
.iter()
.any(|x| src.file_name().map(|n| n == *x).unwrap_or(false))
{
return Ok(());
}
fs::create_dir_all(dst).map_err(|e| e.to_string())?;
for entry in fs::read_dir(src).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let p = entry.path();
let name = entry.file_name();
let dstp = dst.join(name);
let ft = entry.file_type().map_err(|e| e.to_string())?;
if ft.is_dir() {
copy_dir_recursive(&p, &dstp, exclude)?;
} else if ft.is_file() {
fs::copy(&p, &dstp).map_err(|e| e.to_string())?;
}
}
Ok(())
}
fn snapshot_project(
app: &AppHandle,
project_root: &Path,
tx_id: &str,
) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let snap_dir = dir.join("history").join("snapshots").join(tx_id);
if snap_dir.exists() {
fs::remove_dir_all(&snap_dir).map_err(|e| e.to_string())?;
}
fs::create_dir_all(&snap_dir).map_err(|e| e.to_string())?;
let exclude = [
".git", "node_modules", "dist", "build", ".next", "target", ".cache", "coverage",
];
copy_dir_recursive(project_root, &snap_dir, &exclude)?;
Ok(snap_dir)
}
fn restore_snapshot(project_root: &Path, snap_dir: &Path) -> Result<(), String> {
let exclude = [
".git", "node_modules", "dist", "build", ".next", "target", ".cache", "coverage",
];
for entry in fs::read_dir(project_root).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let p = entry.path();
let name = entry.file_name();
if exclude
.iter()
.any(|x| name.to_string_lossy().as_ref() == *x)
{
continue;
}
if p.is_dir() {
fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
} else {
fs::remove_file(&p).map_err(|e| e.to_string())?;
}
}
copy_dir_recursive(snap_dir, project_root, &[])?;
Ok(())
}
fn apply_one_action(root: &Path, action: &Action) -> Result<(), String> {
let p = safe_join(root, &action.path)?;
match action.kind {
ActionKind::CreateFile | ActionKind::UpdateFile => {
let content = action.content.as_deref().unwrap_or("");
let normalized = normalize_content_for_write(content, &p);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(&p, normalized.as_bytes()).map_err(|e| e.to_string())?;
Ok(())
}
ActionKind::DeleteFile => {
if p.exists() {
fs::remove_file(&p).map_err(|e| e.to_string())?;
}
Ok(())
}
ActionKind::CreateDir => {
fs::create_dir_all(&p).map_err(|e| e.to_string())
}
ActionKind::DeleteDir => {
if p.exists() {
fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
}
Ok(())
}
}
}
fn run_cmd_allowlisted(
cwd: &Path,
exe: &str,
args: &[&str],
timeout: Duration,
) -> Result<String, String> {
let start = Instant::now();
let mut cmd = std::process::Command::new(exe);
cmd.current_dir(cwd);
cmd.args(args);
cmd.env("CI", "1");
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| e.to_string())?;
loop {
if start.elapsed() > timeout {
let _ = child.kill();
return Err("TIMEOUT".into());
}
match child.try_wait().map_err(|e| e.to_string())? {
Some(_status) => {
let out = child.wait_with_output().map_err(|e| e.to_string())?;
let mut text = String::new();
text.push_str(&String::from_utf8_lossy(&out.stdout));
text.push_str(&String::from_utf8_lossy(&out.stderr));
let text = clip(text, 20_000);
if out.status.success() {
return Ok(text);
}
return Err(text);
}
None => std::thread::sleep(Duration::from_millis(100)),
}
}
}
fn auto_check(project_root: &Path, timeout_sec: u32) -> Vec<CheckStageResult> {
let mut res: Vec<CheckStageResult> = vec![];
let timeout = Duration::from_secs(timeout_sec as u64);
let cargo = project_root.join("Cargo.toml").exists();
let pkg = project_root.join("package.json").exists();
if cargo {
match run_cmd_allowlisted(project_root, "cargo", &["check"], timeout) {
Ok(out) => res.push(CheckStageResult {
stage: "verify".into(),
ok: true,
output: out,
}),
Err(out) => res.push(CheckStageResult {
stage: "verify".into(),
ok: false,
output: out,
}),
}
} else if pkg {
match run_cmd_allowlisted(project_root, "npm", &["run", "-s", "typecheck"], timeout) {
Ok(out) => res.push(CheckStageResult {
stage: "verify".into(),
ok: true,
output: out,
}),
Err(out) => res.push(CheckStageResult {
stage: "verify".into(),
ok: false,
output: out,
}),
}
}
if pkg {
let build_timeout = Duration::from_secs((timeout_sec as u64).max(120));
match run_cmd_allowlisted(project_root, "npm", &["run", "-s", "build"], build_timeout) {
Ok(out) => res.push(CheckStageResult {
stage: "build".into(),
ok: true,
output: out,
}),
Err(out) => res.push(CheckStageResult {
stage: "build".into(),
ok: false,
output: out,
}),
}
} else if cargo {
let build_timeout = Duration::from_secs((timeout_sec as u64).max(120));
match run_cmd_allowlisted(project_root, "cargo", &["build"], build_timeout) {
Ok(out) => res.push(CheckStageResult {
stage: "build".into(),
ok: true,
output: out,
}),
Err(out) => res.push(CheckStageResult {
stage: "build".into(),
ok: false,
output: out,
}),
}
}
if pkg {
match run_cmd_allowlisted(project_root, "npm", &["test"], timeout) {
Ok(out) => res.push(CheckStageResult {
stage: "smoke".into(),
ok: true,
output: out,
}),
Err(out) => res.push(CheckStageResult {
stage: "smoke".into(),
ok: false,
output: out,
}),
}
} else if cargo {
match run_cmd_allowlisted(project_root, "cargo", &["test"], timeout) {
Ok(out) => res.push(CheckStageResult {
stage: "smoke".into(),
ok: true,
output: out,
}),
Err(out) => res.push(CheckStageResult {
stage: "smoke".into(),
ok: false,
output: out,
}),
}
}
res
}
#[tauri::command]
pub async fn apply_actions_tx(
app: AppHandle,
path: String,
actions: Vec<Action>,
options: ApplyOptions,
) -> ApplyTxResult {
let root = PathBuf::from(&path);
if !root.exists() || !root.is_dir() {
return ApplyTxResult {
ok: false,
tx_id: None,
applied: false,
rolled_back: false,
checks: vec![],
error: Some("path not found".into()),
error_code: Some("PATH_NOT_FOUND".into()),
};
}
if !options.user_confirmed {
return ApplyTxResult {
ok: false,
tx_id: None,
applied: false,
rolled_back: false,
checks: vec![],
error: Some("confirmation required".into()),
error_code: Some("CONFIRM_REQUIRED".into()),
};
}
let limits = get_project_limits(&root);
if actions.len() > limits.max_actions_per_tx as usize {
return ApplyTxResult {
ok: false,
tx_id: None,
applied: false,
rolled_back: false,
checks: vec![],
error: Some(format!(
"too many actions: {} > {}",
actions.len(),
limits.max_actions_per_tx
)),
error_code: Some("TOO_MANY_ACTIONS".into()),
};
}
for a in &actions {
let rel = a.path.as_str();
if is_protected_file(rel) || !is_text_allowed(rel) {
return ApplyTxResult {
ok: false,
tx_id: None,
applied: false,
rolled_back: false,
checks: vec![],
error: Some(format!("protected or non-text file: {}", rel)),
error_code: Some("PROTECTED_PATH".into()),
};
}
}
let tx_id = Uuid::new_v4().to_string();
emit_progress(&app, "Сохраняю точку отката…");
let snap_dir = match snapshot_project(&app, &root, &tx_id) {
Ok(p) => p,
Err(e) => {
return ApplyTxResult {
ok: false,
tx_id: Some(tx_id),
applied: false,
rolled_back: false,
checks: vec![],
error: Some(e),
error_code: Some("SNAPSHOT_FAILED".into()),
};
}
};
emit_progress(&app, "Применяю изменения…");
let mut actions = actions;
sort_actions_for_apply(&mut actions);
for a in &actions {
if let Err(e) = apply_one_action(&root, a) {
let _ = restore_snapshot(&root, &snap_dir);
eprintln!("[APPLY_ROLLBACK] tx_id={} path={} reason={}", tx_id, path, e);
return ApplyTxResult {
ok: false,
tx_id: Some(tx_id.clone()),
applied: false,
rolled_back: true,
checks: vec![],
error: Some(e),
error_code: Some("APPLY_FAILED_ROLLED_BACK".into()),
};
}
}
let mut checks: Vec<CheckStageResult> = vec![];
if options.auto_check {
emit_progress(&app, "Проверяю типы…");
checks = auto_check(&root, limits.timeout_sec);
let any_fail = checks.iter().any(|c| !c.ok);
if any_fail {
emit_progress(&app, "Обнаружены ошибки. Откатываю изменения…");
let _ = restore_snapshot(&root, &snap_dir);
eprintln!("[APPLY_ROLLBACK] tx_id={} path={} reason=autoCheck_failed", tx_id, path);
let record = json!({
"txId": tx_id,
"path": path,
"rolledBack": true,
"checks": checks,
});
let _ = write_tx_record(&app, &tx_id, &record);
return ApplyTxResult {
ok: false,
tx_id: Some(tx_id),
applied: true,
rolled_back: true,
checks,
error: Some("autoCheck failed — rolled back".into()),
error_code: Some("AUTO_CHECK_FAILED_ROLLED_BACK".into()),
};
}
}
let record = json!({
"txId": tx_id,
"path": path,
"rolledBack": false,
"checks": checks,
});
let _ = write_tx_record(&app, &tx_id, &record);
eprintln!("[APPLY_SUCCESS] tx_id={} path={} actions={}", tx_id, path, actions.len());
ApplyTxResult {
ok: true,
tx_id: Some(tx_id),
applied: true,
rolled_back: false,
checks,
error: None,
error_code: None,
}
}
fn is_protected_file(p: &str) -> bool {
let lower = p.to_lowercase().replace('\\', "/");
// Секреты и ключи (denylist)
if lower == ".env" || lower.ends_with("/.env") { return true; }
if lower.ends_with(".pem") || lower.ends_with(".key") || lower.ends_with(".p12") { return true; }
if lower.contains("id_rsa") { return true; }
if lower.contains("/secrets/") || lower.starts_with("secrets/") { return true; }
// Lock-файлы
if lower.ends_with("cargo.lock") { return true; }
if lower.ends_with("package-lock.json") { return true; }
if lower.ends_with("pnpm-lock.yaml") { return true; }
if lower.ends_with("yarn.lock") { return true; }
if lower.ends_with("composer.lock") { return true; }
if lower.ends_with("poetry.lock") { return true; }
if lower.ends_with("pipfile.lock") { return true; }
let bin_ext = [
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
".pdf", ".zip", ".7z", ".rar", ".dmg", ".pkg",
".exe", ".dll", ".so", ".dylib", ".bin",
".mp3", ".mp4", ".mov", ".avi",
".wasm", ".class",
];
for ext in bin_ext {
if lower.ends_with(ext) { return true; }
}
false
}
fn is_text_allowed(p: &str) -> bool {
let lower = p.to_lowercase();
let ok_ext = [
".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt", ".toml", ".yaml", ".yml",
".rs", ".py", ".go", ".java", ".kt", ".c", ".cpp", ".h", ".hpp",
".css", ".scss", ".html", ".env", ".gitignore", ".editorconfig",
];
ok_ext.iter().any(|e| lower.ends_with(e)) || !lower.contains('.')
}
#[cfg(test)]
mod tests {
use super::{is_protected_file, is_text_allowed};
#[test]
fn test_is_protected_file_secrets() {
assert!(is_protected_file(".env"));
assert!(is_protected_file("config/.env"));
assert!(is_protected_file("key.pem"));
assert!(is_protected_file("id_rsa"));
assert!(is_protected_file(".ssh/id_rsa"));
assert!(is_protected_file("secrets/secret.json"));
}
#[test]
fn test_is_protected_file_lock_and_binary() {
assert!(is_protected_file("Cargo.lock"));
assert!(is_protected_file("package-lock.json"));
assert!(is_protected_file("node_modules/foo/package-lock.json"));
assert!(is_protected_file("image.PNG"));
assert!(is_protected_file("file.pdf"));
assert!(is_protected_file("lib.so"));
}
#[test]
fn test_is_protected_file_allows_source() {
assert!(!is_protected_file("src/main.rs"));
assert!(!is_protected_file("src/App.tsx"));
assert!(!is_protected_file("package.json"));
}
#[test]
fn test_is_text_allowed_extensions() {
assert!(is_text_allowed("src/main.rs"));
assert!(is_text_allowed("App.tsx"));
assert!(is_text_allowed("config.json"));
assert!(is_text_allowed("README.md"));
assert!(is_text_allowed(".env"));
assert!(is_text_allowed(".gitignore"));
}
#[test]
fn test_is_text_allowed_no_extension() {
assert!(is_text_allowed("Dockerfile"));
assert!(is_text_allowed("Makefile"));
}
#[test]
fn test_is_text_allowed_rejects_binary_ext() {
assert!(!is_text_allowed("photo.png"));
assert!(!is_text_allowed("doc.pdf"));
}
}

View File

@ -0,0 +1,57 @@
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
pub fn auto_check(root: &Path) -> Result<(), String> {
let start = Instant::now();
let timeout = Duration::from_secs(120);
let pkg = root.join("package.json");
let cargo = root.join("Cargo.toml");
let pyproject = root.join("pyproject.toml");
let reqs = root.join("requirements.txt");
if pkg.exists() {
let mut cmd = Command::new("npm");
cmd.arg("-s").arg("run").arg("build").current_dir(root);
let out = cmd.output();
if start.elapsed() > timeout {
return Err("AUTO_CHECK_TIMEOUT".into());
}
if let Ok(o) = out {
if !o.status.success() {
let mut cmd2 = Command::new("npm");
cmd2.arg("-s").arg("test").current_dir(root);
let o2 = cmd2.output().map_err(|e| e.to_string())?;
if !o2.status.success() {
return Err("AUTO_CHECK_NODE_FAILED".into());
}
}
} else {
return Err("AUTO_CHECK_NODE_FAILED".into());
}
}
if cargo.exists() {
let mut cmd = Command::new("cargo");
cmd.arg("check").current_dir(root);
let o = cmd.output().map_err(|e| e.to_string())?;
if start.elapsed() > timeout {
return Err("AUTO_CHECK_TIMEOUT".into());
}
if !o.status.success() {
return Err("AUTO_CHECK_RUST_FAILED".into());
}
}
if pyproject.exists() || reqs.exists() {
let mut cmd = Command::new("python3");
cmd.arg("-c").arg("print('ok')").current_dir(root);
let o = cmd.output().map_err(|e| e.to_string())?;
if !o.status.success() {
return Err("AUTO_CHECK_PY_FAILED".into());
}
}
Ok(())
}

View File

@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FolderLinks {
pub paths: Vec<String>,
}
const FILENAME: &str = "folder_links.json";
pub fn load_folder_links(app_data_dir: &Path) -> FolderLinks {
let p = app_data_dir.join(FILENAME);
if let Ok(s) = fs::read_to_string(&p) {
if let Ok(links) = serde_json::from_str::<FolderLinks>(&s) {
return links;
}
}
FolderLinks::default()
}
pub fn save_folder_links(app_data_dir: &Path, links: &FolderLinks) -> Result<(), String> {
fs::create_dir_all(app_data_dir).map_err(|e| e.to_string())?;
let p = app_data_dir.join(FILENAME);
fs::write(
&p,
serde_json::to_string_pretty(links).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())
}

View File

@ -0,0 +1,153 @@
//! v2.4: Build ActionPlan from analyze report (recommendations → actions).
use std::path::Path;
use crate::types::{ActionItem, ActionKind, ActionPlan, AnalyzeReport, GenerateActionsPayload};
fn rel(p: &str) -> String {
p.replace('\\', "/")
}
fn mk_id(prefix: &str, n: usize) -> String {
format!("{}-{}", prefix, n)
}
fn report_mentions_readme(report: &AnalyzeReport) -> bool {
report
.findings
.iter()
.any(|f| f.title.contains("README") || f.details.to_lowercase().contains("readme"))
|| report
.recommendations
.iter()
.any(|r| r.title.to_lowercase().contains("readme") || r.details.to_lowercase().contains("readme"))
}
fn report_mentions_gitignore(report: &AnalyzeReport) -> bool {
report
.findings
.iter()
.any(|f| f.title.contains("gitignore") || f.details.to_lowercase().contains("gitignore"))
|| report
.recommendations
.iter()
.any(|r| r.title.to_lowercase().contains("gitignore") || r.details.to_lowercase().contains("gitignore"))
}
fn report_mentions_tests(report: &AnalyzeReport) -> bool {
report
.findings
.iter()
.any(|f| f.title.contains("tests") || f.details.to_lowercase().contains("тест"))
|| report
.recommendations
.iter()
.any(|r| r.title.to_lowercase().contains("test") || r.details.to_lowercase().contains("тест"))
}
pub fn build_actions_from_report(report: &AnalyzeReport, mode: &str) -> Vec<ActionItem> {
let mut out: Vec<ActionItem> = vec![];
if report_mentions_readme(report) {
out.push(ActionItem {
id: mk_id("action", out.len() + 1),
kind: ActionKind::CreateFile,
path: rel("README.md"),
content: Some(
"# PAPA YU Project\n\n## Описание\n\nКратко опишите проект.\n\n## Запуск\n\n- dev: ...\n- build: ...\n\n## Структура\n\n- src/\n- tests/\n".into(),
),
summary: "Добавить README.md".into(),
rationale: "Улучшает понимание проекта и снижает риск ошибок при работе с кодом.".into(),
tags: vec!["docs".into(), "quality".into()],
risk: "low".into(),
});
}
if report_mentions_gitignore(report) {
out.push(ActionItem {
id: mk_id("action", out.len() + 1),
kind: ActionKind::CreateFile,
path: rel(".gitignore"),
content: Some(
"node_modules/\ndist/\nbuild/\n.next/\ncoverage/\n.env\n.env.*\n.DS_Store\n".into(),
),
summary: "Добавить .gitignore".into(),
rationale: "Исключает мусор и потенциально секретные файлы из репозитория.".into(),
tags: vec!["quality".into(), "security".into()],
risk: "low".into(),
});
}
if report_mentions_tests(report) {
out.push(ActionItem {
id: mk_id("action", out.len() + 1),
kind: ActionKind::CreateDir,
path: rel("tests"),
content: None,
summary: "Создать папку tests/".into(),
rationale: "Готовит структуру под тесты.".into(),
tags: vec!["quality".into(), "tests".into()],
risk: "low".into(),
});
out.push(ActionItem {
id: mk_id("action", out.len() + 1),
kind: ActionKind::CreateFile,
path: rel("tests/smoke.test.txt"),
content: Some("TODO: add smoke tests\n".into()),
summary: "Добавить tests/smoke.test.txt".into(),
rationale: "Минимальный маркер тестов. Замените на реальные тесты позже.".into(),
tags: vec!["tests".into()],
risk: "low".into(),
});
}
if mode == "balanced" {
let root = Path::new(&report.path);
let has_node = root.join("package.json").exists();
let has_react = root.join("package.json").exists() && (root.join("src").join("App.jsx").exists() || root.join("src").join("App.tsx").exists());
if has_node || has_react {
out.push(ActionItem {
id: mk_id("action", out.len() + 1),
kind: ActionKind::CreateFile,
path: rel(".prettierrc"),
content: Some("{\n \"singleQuote\": true,\n \"semi\": true\n}\n".into()),
summary: "Добавить .prettierrc".into(),
rationale: "Стабилизирует форматирование кода.".into(),
tags: vec!["quality".into()],
risk: "low".into(),
});
}
}
out
}
#[tauri::command]
pub async fn generate_actions(payload: GenerateActionsPayload) -> Result<ActionPlan, String> {
let path = payload.path.clone();
let mode = if payload.mode.is_empty() { "safe" } else { payload.mode.as_str() };
let report = crate::commands::analyze_project(vec![path.clone()], None)?;
let mut actions = build_actions_from_report(&report, mode);
if !payload.selected.is_empty() {
let sel: Vec<String> = payload.selected.iter().map(|s| s.to_lowercase()).collect();
actions = actions
.into_iter()
.filter(|a| {
let txt = format!("{} {} {} {:?}", a.summary, a.rationale, a.risk, a.tags).to_lowercase();
sel.iter().any(|k| txt.contains(k))
})
.collect();
}
let warnings = vec!["Все изменения применяются только через предпросмотр и могут быть отменены.".into()];
Ok(ActionPlan {
plan_id: format!("plan-{}", chrono::Utc::now().timestamp_millis()),
root_path: path,
title: "План исправлений (MVP)".into(),
actions,
warnings,
})
}

View File

@ -0,0 +1,164 @@
//! v3.2: generate Action[] from AnalyzeReport (safe create-only, no LLM).
use std::path::Path;
use crate::tx::safe_join;
use crate::types::{Action, ActionKind, AnalyzeReport, GenerateActionsResult};
const MAX_ACTIONS: usize = 20;
/// Forbidden path segments (no write under these).
const FORBIDDEN: &[&str] = &[".git", "node_modules", "target", "dist", "build", ".next"];
fn rel(p: &str) -> String {
p.replace('\\', "/")
}
fn is_path_forbidden(rel: &str) -> bool {
let r = rel.trim_start_matches('/');
if r.contains("..") || rel.starts_with('/') || rel.starts_with('\\') {
return true;
}
let parts: Vec<&str> = r.split('/').collect();
for part in &parts {
if FORBIDDEN.contains(part) {
return true;
}
}
false
}
fn has_readme(root: &Path) -> bool {
["README.md", "README.MD", "README.txt", "README"]
.iter()
.any(|f| root.join(f).exists())
}
fn has_gitignore(root: &Path) -> bool {
root.join(".gitignore").exists()
}
fn has_license(root: &Path) -> bool {
["LICENSE", "LICENSE.md", "LICENSE.txt"]
.iter()
.any(|f| root.join(f).exists())
}
fn has_src(root: &Path) -> bool {
root.join("src").is_dir()
}
fn has_tests(root: &Path) -> bool {
root.join("tests").is_dir()
}
#[tauri::command]
pub async fn generate_actions_from_report(
path: String,
report: AnalyzeReport,
mode: String,
) -> GenerateActionsResult {
let _ = report; // reserved for future use (e.g. narrative/signals)
let root = Path::new(&path);
if !root.exists() || !root.is_dir() {
return GenerateActionsResult {
ok: false,
actions: vec![],
skipped: vec![],
error: Some("path not found".into()),
error_code: Some("PATH_NOT_FOUND".into()),
};
}
let create_only = mode == "safe_create_only" || mode == "safe" || mode.is_empty();
let mut actions: Vec<Action> = vec![];
let mut skipped: Vec<String> = vec![];
// 1. README
if !has_readme(root) {
let rel_path = rel("README.md");
if is_path_forbidden(&rel_path) {
skipped.push("README.md (forbidden path)".into());
} else if safe_join(root, &rel_path).is_ok() {
actions.push(Action {
kind: ActionKind::CreateFile,
path: rel_path.clone(),
content: Some(
"# Project\n\n## Описание\n\nКратко опишите проект.\n\n## Запуск\n\n- dev: ...\n- build: ...\n\n## Структура\n\n- src/\n- tests/\n".into(),
),
});
}
}
// 2. .gitignore
if !has_gitignore(root) {
let rel_path = rel(".gitignore");
if is_path_forbidden(&rel_path) {
skipped.push(".gitignore (forbidden path)".into());
} else if safe_join(root, &rel_path).is_ok() {
actions.push(Action {
kind: ActionKind::CreateFile,
path: rel_path,
content: Some(
"node_modules/\ndist/\nbuild/\n.next/\ncoverage/\n.env\n.env.*\n.DS_Store\n.target/\n".into(),
),
});
}
}
// 3. LICENSE
if !has_license(root) {
let rel_path = rel("LICENSE");
if is_path_forbidden(&rel_path) {
skipped.push("LICENSE (forbidden path)".into());
} else if safe_join(root, &rel_path).is_ok() {
actions.push(Action {
kind: ActionKind::CreateFile,
path: rel_path,
content: Some("MIT License\n\nCopyright (c) <year> <copyright holders>\n".into()),
});
}
}
// 4. tests/ + tests/.gitkeep (when src exists and tests missing)
if has_src(root) && !has_tests(root) {
let dir_path = rel("tests");
if !is_path_forbidden(&dir_path) && safe_join(root, &dir_path).is_ok() {
actions.push(Action {
kind: ActionKind::CreateDir,
path: dir_path,
content: None,
});
}
let keep_path = rel("tests/.gitkeep");
if !is_path_forbidden(&keep_path) && safe_join(root, &keep_path).is_ok() {
actions.push(Action {
kind: ActionKind::CreateFile,
path: keep_path,
content: Some("".into()),
});
}
}
if create_only {
// v3.3: only CreateFile and CreateDir; any other kind would be skipped (we already only create)
}
if actions.len() > MAX_ACTIONS {
return GenerateActionsResult {
ok: false,
actions: vec![],
skipped: vec![format!("more than {} actions", MAX_ACTIONS)],
error: Some(format!("max {} actions per run", MAX_ACTIONS)),
error_code: Some("TOO_MANY_ACTIONS".into()),
};
}
GenerateActionsResult {
ok: true,
actions,
skipped,
error: None,
error_code: None,
}
}

View File

@ -0,0 +1,190 @@
//! v2.4.3: detect project profile by path (type, limits, goal_template).
use std::path::Path;
use std::time::Instant;
use tauri::{Emitter, Window};
use crate::types::{ProjectLimits, ProjectProfile, ProjectType};
fn has_file(root: &Path, rel: &str) -> bool {
root.join(rel).is_file()
}
fn has_dir(root: &Path, rel: &str) -> bool {
root.join(rel).is_dir()
}
pub fn detect_project_type(root: &Path) -> ProjectType {
if has_file(root, "next.config.js")
|| has_file(root, "next.config.mjs")
|| has_file(root, "next.config.ts")
|| (has_dir(root, "app") || has_dir(root, "pages"))
{
return ProjectType::NextJs;
}
if has_file(root, "vite.config.ts")
|| has_file(root, "vite.config.js")
|| has_file(root, "vite.config.mjs")
{
return ProjectType::ReactVite;
}
if has_file(root, "package.json") {
return ProjectType::Node;
}
if has_file(root, "Cargo.toml") {
return ProjectType::Rust;
}
if has_file(root, "pyproject.toml")
|| has_file(root, "requirements.txt")
|| has_file(root, "setup.py")
{
return ProjectType::Python;
}
ProjectType::Unknown
}
fn build_goal_template(pt: &ProjectType) -> String {
let tone = "Отвечай коротко и по-человечески, как коллега в чате.";
match pt {
ProjectType::ReactVite => format!("Цель: {{goal}}\nКонтекст: это React+Vite проект. Действуй безопасно: только preview → apply_tx → auto_check → undo при ошибке.\nОграничения: никаких shell; только safe FS; не трогать секреты.\n{}", tone),
ProjectType::NextJs => format!("Цель: {{goal}}\nКонтекст: это Next.js проект. Действуй безопасно: только preview → apply_tx → auto_check → undo при ошибке.\nОграничения: никаких shell; только safe FS; не трогать секреты.\n{}", tone),
ProjectType::Rust => format!("Цель: {{goal}}\nКонтекст: это Rust/Cargo проект. Действуй безопасно: только preview → apply_tx → auto_check → undo при ошибке.\nОграничения: никаких shell; только safe FS; не трогать секреты.\n{}", tone),
ProjectType::Python => format!("Цель: {{goal}}\nКонтекст: это Python проект. Действуй безопасно: только preview → apply_tx → auto_check → undo при ошибке.\nОграничения: никаких shell; только safe FS; не трогать секреты.\n{}", tone),
ProjectType::Node => format!("Цель: {{goal}}\nКонтекст: это Node проект. Действуй безопасно: только preview → apply_tx → auto_check → undo при ошибке.\nОграничения: никаких shell; только safe FS; не трогать секреты.\n{}", tone),
ProjectType::Unknown => format!("Цель: {{goal}}\nКонтекст: тип проекта не определён. Действуй максимально безопасно: только preview → apply_tx → auto_check → undo при ошибке.\nОграничения: никаких shell; только safe FS; не трогать секреты.\n{}", tone),
}
}
/// v2.4.4: get limits for a path (used by apply_actions_tx and run_batch for max_actions_per_tx and timeout).
pub fn get_project_limits(root: &Path) -> ProjectLimits {
default_limits(&detect_project_type(root))
}
fn default_limits(pt: &ProjectType) -> ProjectLimits {
match pt {
ProjectType::ReactVite | ProjectType::NextJs | ProjectType::Node => ProjectLimits {
max_files: 50_000,
timeout_sec: 60,
max_actions_per_tx: 25,
},
ProjectType::Rust => ProjectLimits {
max_files: 50_000,
timeout_sec: 60,
max_actions_per_tx: 20,
},
ProjectType::Python => ProjectLimits {
max_files: 50_000,
timeout_sec: 60,
max_actions_per_tx: 20,
},
ProjectType::Unknown => ProjectLimits {
max_files: 30_000,
timeout_sec: 45,
max_actions_per_tx: 15,
},
}
}
#[tauri::command]
pub async fn get_project_profile(window: Window, path: String) -> Result<ProjectProfile, String> {
let root = Path::new(&path);
if !root.exists() {
return Err("PATH_NOT_FOUND".to_string());
}
if !root.is_dir() {
return Err("PATH_NOT_DIRECTORY".to_string());
}
let _ = window.emit("analyze_progress", "Определяю профиль проекта…");
let start = Instant::now();
let project_type = detect_project_type(root);
let limits = default_limits(&project_type);
let safe_mode = true;
let max_attempts = match project_type {
ProjectType::Unknown => 2,
_ => 3,
};
let goal_template = build_goal_template(&project_type);
let _elapsed = start.elapsed();
Ok(ProjectProfile {
path,
project_type,
safe_mode,
max_attempts,
goal_template,
limits,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_detect_project_type_unknown_empty() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
assert_eq!(detect_project_type(root), ProjectType::Unknown);
}
#[test]
fn test_detect_project_type_node() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
fs::write(root.join("package.json"), "{}").unwrap();
assert_eq!(detect_project_type(root), ProjectType::Node);
}
#[test]
fn test_detect_project_type_rust() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
fs::write(root.join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
assert_eq!(detect_project_type(root), ProjectType::Rust);
}
#[test]
fn test_detect_project_type_react_vite() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
fs::write(root.join("vite.config.ts"), "export default {}").unwrap();
assert_eq!(detect_project_type(root), ProjectType::ReactVite);
}
#[test]
fn test_detect_project_type_python() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
fs::write(root.join("pyproject.toml"), "[project]\nname = \"x\"").unwrap();
assert_eq!(detect_project_type(root), ProjectType::Python);
}
#[test]
fn test_get_project_limits_unknown() {
let dir = tempfile::TempDir::new().unwrap();
let limits = get_project_limits(dir.path());
assert_eq!(limits.max_actions_per_tx, 15);
assert_eq!(limits.timeout_sec, 45);
}
#[test]
fn test_get_project_limits_rust() {
let dir = tempfile::TempDir::new().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
let limits = get_project_limits(dir.path());
assert_eq!(limits.max_actions_per_tx, 20);
assert_eq!(limits.timeout_sec, 60);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
mod agentic_run;
mod analyze_project;
mod apply_actions;
mod apply_actions_tx;
mod auto_check;
mod folder_links;
mod generate_actions;
mod generate_actions_from_report;
mod get_project_profile;
mod llm_planner;
mod preview_actions;
mod project_content;
mod projects;
mod propose_actions;
mod redo_last;
mod run_batch;
mod settings_export;
mod trends;
mod undo_last;
mod undo_last_tx;
mod undo_status;
pub use agentic_run::agentic_run;
pub use get_project_profile::get_project_profile;
pub use projects::{add_project, append_session_event, get_project_settings, list_projects, list_sessions, set_project_settings};
pub use analyze_project::analyze_project;
pub use apply_actions::apply_actions;
pub use apply_actions_tx::apply_actions_tx;
pub use generate_actions::generate_actions;
pub use generate_actions_from_report::generate_actions_from_report;
pub use propose_actions::propose_actions;
pub use folder_links::{load_folder_links, save_folder_links, FolderLinks};
pub use preview_actions::preview_actions;
pub use redo_last::redo_last;
pub use run_batch::run_batch;
pub use trends::{fetch_trends_recommendations, get_trends_recommendations};
pub use undo_last::{get_undo_redo_state_cmd, undo_available, undo_last};
pub use undo_last_tx::undo_last_tx;
pub use undo_status::undo_status;
pub use settings_export::{export_settings, import_settings};

View File

@ -0,0 +1,141 @@
use crate::tx::safe_join;
use crate::types::{ActionKind, ApplyPayload, DiffItem, PreviewResult};
use std::fs;
const MAX_PREVIEW_SIZE: usize = 200_000;
pub fn preview_actions(payload: ApplyPayload) -> Result<PreviewResult, String> {
let root = std::path::Path::new(&payload.root_path);
let mut diffs = Vec::new();
for a in &payload.actions {
let rel = a.path.as_str();
if is_protected_file(rel) || !is_text_allowed(rel) {
diffs.push(DiffItem {
kind: "blocked".to_string(),
path: a.path.clone(),
old_content: Some("(blocked)".to_string()),
new_content: Some("(blocked)".to_string()),
summary: Some("BLOCKED: protected or non-text file".to_string()),
});
continue;
}
let item = match &a.kind {
ActionKind::CreateFile => DiffItem {
kind: "create".to_string(),
path: a.path.clone(),
old_content: None,
new_content: a.content.clone(),
summary: None,
},
ActionKind::CreateDir => DiffItem {
kind: "mkdir".to_string(),
path: a.path.clone(),
old_content: None,
new_content: None,
summary: None,
},
ActionKind::UpdateFile => {
let old = read_text_if_exists(root, &a.path);
DiffItem {
kind: "update".to_string(),
path: a.path.clone(),
old_content: old,
new_content: a.content.clone(),
summary: None,
}
}
ActionKind::DeleteFile => {
let old = read_text_if_exists(root, &a.path);
DiffItem {
kind: "delete".to_string(),
path: a.path.clone(),
old_content: old,
new_content: None,
summary: None,
}
}
ActionKind::DeleteDir => DiffItem {
kind: "rmdir".to_string(),
path: a.path.clone(),
old_content: None,
new_content: None,
summary: None,
},
};
diffs.push(item);
}
let summary = summarize(&diffs);
let files = diffs.len();
let bytes = diffs
.iter()
.map(|d| d.old_content.as_ref().unwrap_or(&String::new()).len() + d.new_content.as_ref().unwrap_or(&String::new()).len())
.sum::<usize>();
eprintln!("[PREVIEW_READY] path={} files={} diffs={} bytes={}", payload.root_path, files, diffs.len(), bytes);
Ok(PreviewResult { diffs, summary })
}
fn read_text_if_exists(root: &std::path::Path, rel: &str) -> Option<String> {
let p = safe_join(root, rel).ok()?;
if !p.is_file() {
return None;
}
let s = fs::read_to_string(&p).ok()?;
if s.len() > MAX_PREVIEW_SIZE {
Some(format!("{}... (truncated)", &s[..MAX_PREVIEW_SIZE]))
} else {
Some(s)
}
}
fn summarize(diffs: &[DiffItem]) -> String {
let create = diffs.iter().filter(|d| d.kind == "create").count();
let update = diffs.iter().filter(|d| d.kind == "update").count();
let delete = diffs.iter().filter(|d| d.kind == "delete").count();
let mkdir = diffs.iter().filter(|d| d.kind == "mkdir").count();
let rmdir = diffs.iter().filter(|d| d.kind == "rmdir").count();
let blocked = diffs.iter().filter(|d| d.kind == "blocked").count();
let mut s = format!(
"Создать: {}, изменить: {}, удалить: {}, mkdir: {}, rmdir: {}",
create, update, delete, mkdir, rmdir
);
if blocked > 0 {
s.push_str(&format!(", заблокировано: {}", blocked));
}
s
}
fn is_protected_file(p: &str) -> bool {
let lower = p.to_lowercase().replace('\\', "/");
if lower == ".env" || lower.ends_with("/.env") { return true; }
if lower.ends_with(".pem") || lower.ends_with(".key") || lower.ends_with(".p12") { return true; }
if lower.contains("id_rsa") { return true; }
if lower.contains("/secrets/") || lower.starts_with("secrets/") { return true; }
if lower.ends_with("cargo.lock") { return true; }
if lower.ends_with("package-lock.json") { return true; }
if lower.ends_with("pnpm-lock.yaml") { return true; }
if lower.ends_with("yarn.lock") { return true; }
if lower.ends_with("composer.lock") { return true; }
if lower.ends_with("poetry.lock") { return true; }
if lower.ends_with("pipfile.lock") { return true; }
let bin_ext = [
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
".pdf", ".zip", ".7z", ".rar", ".dmg", ".pkg",
".exe", ".dll", ".so", ".dylib", ".bin",
".mp3", ".mp4", ".mov", ".avi",
".wasm", ".class",
];
for ext in bin_ext {
if lower.ends_with(ext) { return true; }
}
false
}
fn is_text_allowed(p: &str) -> bool {
let lower = p.to_lowercase();
let ok_ext = [
".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt", ".toml", ".yaml", ".yml",
".rs", ".py", ".go", ".java", ".kt", ".c", ".cpp", ".h", ".hpp",
".css", ".scss", ".html", ".env", ".gitignore", ".editorconfig",
];
ok_ext.iter().any(|e| lower.ends_with(e)) || !lower.contains('.')
}

View File

@ -0,0 +1,145 @@
//! Сбор полного содержимого проекта для ИИ: все релевантные файлы/папки в пределах лимитов.
//! Анализ ИИ-агентом делается по всему содержимому, а не по трём файлам.
use std::fs;
use std::path::Path;
/// Расширения текстовых файлов для включения в контекст ИИ
const TEXT_EXT: &[&str] = &[
"ts", "tsx", "js", "jsx", "mjs", "cjs", "rs", "py", "json", "toml", "md", "yml", "yaml",
"css", "scss", "html", "xml", "vue", "svelte", "go", "rb", "java", "kt", "swift", "c", "h",
"cpp", "hpp", "sh", "bash", "zsh", "sql", "graphql",
];
/// Папки, которые не сканируем
const EXCLUDE_DIRS: &[&str] = &[
"node_modules", "target", "dist", "build", ".git", ".next", ".nuxt", ".cache",
"coverage", "__pycache__", ".venv", "venv", ".idea", ".vscode", "vendor",
];
/// Макс. символов на файл (чтобы не перегружать контекст)
const MAX_BYTES_PER_FILE: usize = 80_000;
/// Макс. суммарных символов для контекста LLM (~200k токенов)
const MAX_TOTAL_CHARS: usize = 600_000;
/// Макс. число файлов
const MAX_FILES: usize = 500;
/// Собирает содержимое релевантных файлов проекта в одну строку для передачи в LLM.
/// Сканирует всю папку/папки (без искусственного ограничения «тремя файлами»).
pub fn get_project_content_for_llm(root: &Path, max_total_chars: Option<usize>) -> String {
let limit = max_total_chars.unwrap_or(MAX_TOTAL_CHARS);
let mut out = String::with_capacity(limit.min(MAX_TOTAL_CHARS + 1024));
let mut total = 0usize;
let mut files_added = 0usize;
if !root.exists() || !root.is_dir() {
return "Папка не найдена или пуста. Можно создать проект с нуля.".to_string();
}
if let Ok(entries) = fs::read_dir(root) {
for entry in entries.flatten() {
if total >= limit || files_added >= MAX_FILES {
break;
}
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if path.is_dir() {
if EXCLUDE_DIRS.contains(&name) {
continue;
}
collect_dir(&path, root, &mut out, &mut total, &mut files_added, limit);
} else if path.is_file() {
if let Some(ext) = path.extension() {
let ext = ext.to_str().unwrap_or("").to_lowercase();
if TEXT_EXT.iter().any(|e| *e == ext) {
if let Ok(content) = fs::read_to_string(&path) {
let rel = path.strip_prefix(root).unwrap_or(&path);
let rel_str = rel.display().to_string();
let truncated = if content.len() > MAX_BYTES_PER_FILE {
format!("{}\n(обрезано, всего {} байт)", &content[..MAX_BYTES_PER_FILE], content.len())
} else {
content
};
let block = format!("\n=== {} ===\n{}\n", rel_str, truncated);
if total + block.len() <= limit {
out.push_str(&block);
total += block.len();
files_added += 1;
}
}
}
}
}
}
}
if out.is_empty() {
out = "В папке нет релевантных исходных файлов. Можно создать проект с нуля.".to_string();
} else {
out.insert_str(0, "Содержимое файлов проекта (полный контекст для анализа):\n");
}
out
}
fn collect_dir(
dir: &Path,
root: &Path,
out: &mut String,
total: &mut usize,
files_added: &mut usize,
limit: usize,
) {
if *total >= limit || *files_added >= MAX_FILES {
return;
}
let read = match fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return,
};
let mut entries: Vec<_> = read.flatten().collect();
entries.sort_by(|a, b| {
let a = a.path();
let b = b.path();
let a_dir = a.is_dir();
let b_dir = b.is_dir();
match (a_dir, b_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.file_name().cmp(&b.file_name()),
}
});
for entry in entries {
if *total >= limit || *files_added >= MAX_FILES {
break;
}
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if path.is_dir() {
if EXCLUDE_DIRS.contains(&name) {
continue;
}
collect_dir(&path, root, out, total, files_added, limit);
} else if path.is_file() {
if let Some(ext) = path.extension() {
let ext = ext.to_str().unwrap_or("").to_lowercase();
if TEXT_EXT.iter().any(|e| *e == ext) {
if let Ok(content) = fs::read_to_string(&path) {
let rel = path.strip_prefix(root).unwrap_or(&path);
let rel_str = rel.display().to_string();
let truncated = if content.len() > MAX_BYTES_PER_FILE {
format!("{}\n(обрезано, всего {} байт)", &content[..MAX_BYTES_PER_FILE], content.len())
} else {
content
};
let block = format!("\n=== {} ===\n{}\n", rel_str, truncated);
if *total + block.len() <= limit {
out.push_str(&block);
*total += block.len();
*files_added += 1;
}
}
}
}
}
}
}

View File

@ -0,0 +1,102 @@
//! v2.5: Projects & sessions — list/add projects, profiles, session history.
use crate::store::{
add_session_event as store_add_session_event, load_profiles, load_projects, load_sessions,
save_profiles, save_projects,
};
use crate::types::{Project, ProjectSettings, Session, SessionEvent};
use tauri::Manager;
fn app_data_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
app.path()
.app_data_dir()
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_projects(app: tauri::AppHandle) -> Result<Vec<Project>, String> {
let dir = app_data_dir(&app)?;
Ok(load_projects(&dir))
}
#[tauri::command]
pub fn add_project(app: tauri::AppHandle, path: String, name: Option<String>) -> Result<Project, String> {
let dir = app_data_dir(&app)?;
let mut projects = load_projects(&dir);
let name = name.unwrap_or_else(|| {
std::path::Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Project")
.to_string()
});
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().to_rfc3339();
let project = Project {
id: id.clone(),
path: path.clone(),
name,
created_at: created_at.clone(),
};
if projects.iter().any(|p| p.path == path) {
return Err("Project with this path already exists".to_string());
}
projects.push(project.clone());
save_projects(&dir, &projects)?;
Ok(project)
}
#[tauri::command]
pub fn get_project_settings(app: tauri::AppHandle, project_id: String) -> Result<ProjectSettings, String> {
let dir = app_data_dir(&app)?;
let profiles = load_profiles(&dir);
Ok(profiles
.get(&project_id)
.cloned()
.unwrap_or_else(|| ProjectSettings {
project_id: project_id.clone(),
auto_check: true,
max_attempts: 2,
max_actions: 12,
goal_template: None,
}))
}
#[tauri::command]
pub fn set_project_settings(app: tauri::AppHandle, profile: ProjectSettings) -> Result<(), String> {
let dir = app_data_dir(&app)?;
let mut profiles = load_profiles(&dir);
profiles.insert(profile.project_id.clone(), profile);
save_profiles(&dir, &profiles)?;
Ok(())
}
#[tauri::command]
pub fn list_sessions(app: tauri::AppHandle, project_id: Option<String>) -> Result<Vec<Session>, String> {
let dir = app_data_dir(&app)?;
let mut sessions = load_sessions(&dir);
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
if let Some(pid) = project_id {
sessions.retain(|s| s.project_id == pid);
}
Ok(sessions)
}
#[tauri::command]
pub fn append_session_event(
app: tauri::AppHandle,
project_id: String,
kind: String,
role: Option<String>,
text: Option<String>,
) -> Result<Session, String> {
let dir = app_data_dir(&app)?;
let at = chrono::Utc::now().to_rfc3339();
let event = SessionEvent {
kind,
role,
text,
at,
};
store_add_session_event(&dir, &project_id, event)
}

View File

@ -0,0 +1,249 @@
//! v3.0: агент предложения исправлений (эвристика или LLM по конфигу).
//! Для LLM передаётся полное содержимое проекта (все файлы), не только отчёт.
//! Инженерная память: user prefs (app_data/papa-yu/preferences.json), project prefs (.papa-yu/project.json).
use std::path::Path;
use crate::types::{Action, ActionKind, AgentPlan};
use tauri::Manager;
use super::llm_planner;
use super::project_content;
fn has_readme(root: &str) -> bool {
["README.md", "README.MD", "README.txt", "README"]
.iter()
.any(|f| Path::new(root).join(f).exists())
}
fn has_gitignore(root: &str) -> bool {
Path::new(root).join(".gitignore").exists()
}
fn has_license(root: &str) -> bool {
["LICENSE", "LICENSE.md", "LICENSE.txt"]
.iter()
.any(|f| Path::new(root).join(f).exists())
}
/// Триггеры перехода Plan→Apply (пользователь подтвердил план).
const APPLY_TRIGGERS: &[&str] = &[
"ok", "ок", "apply", "применяй", "применить", "делай", "да", "yes", "go", "вперёд",
];
#[tauri::command]
pub async fn propose_actions(
app: tauri::AppHandle,
path: String,
report_json: String,
user_goal: String,
design_style: Option<String>,
trends_context: Option<String>,
last_plan_json: Option<String>,
last_context: Option<String>,
) -> AgentPlan {
let goal_trim = user_goal.trim();
let goal_lower = goal_trim.to_lowercase();
let root = Path::new(&path);
if !root.exists() || !root.is_dir() {
return AgentPlan {
ok: false,
summary: String::new(),
actions: vec![],
error: Some("path not found".into()),
error_code: Some("PATH_NOT_FOUND".into()),
plan_json: None,
plan_context: None,
};
}
if llm_planner::is_llm_configured() {
let app_data = match app.path().app_data_dir() {
Ok(d) => d,
Err(e) => {
return AgentPlan {
ok: false,
summary: String::new(),
actions: vec![],
error: Some(format!("app data dir: {}", e)),
error_code: Some("APP_DATA_DIR".into()),
plan_json: None,
plan_context: None,
};
}
};
let user_prefs_path = app_data.join("papa-yu").join("preferences.json");
let project_prefs_path = root.join(".papa-yu").join("project.json");
let full_content = project_content::get_project_content_for_llm(root, None);
let content_for_plan = if full_content.is_empty() {
None
} else {
Some(full_content.as_str())
};
let design_ref = design_style.as_deref();
let trends_ref = trends_context.as_deref();
// Определение режима: префиксы plan:/apply:, триггер "ok/применяй" + last_plan, или по умолчанию
let output_format_override: Option<&str> = if goal_lower.starts_with("plan:") {
Some("plan")
} else if goal_lower.starts_with("apply:") {
Some("apply")
} else if APPLY_TRIGGERS.contains(&goal_lower.as_str()) && last_plan_json.is_some() {
Some("apply")
} else if goal_lower.contains("исправь") || goal_lower.contains("почини") || goal_lower.contains("fix ") || goal_lower.contains("исправить") {
Some("plan")
} else if goal_lower.contains("создай") || goal_lower.contains("сгенерируй") || goal_lower.contains("create") || goal_lower.contains("с нуля") {
Some("apply")
} else {
None
};
let last_plan_ref = last_plan_json.as_deref();
let last_ctx_ref = last_context.as_deref();
return match llm_planner::plan(
&user_prefs_path,
&project_prefs_path,
&path,
&report_json,
goal_trim,
content_for_plan,
design_ref,
trends_ref,
output_format_override,
last_plan_ref,
last_ctx_ref,
)
.await
{
Ok(plan) => plan,
Err(e) => AgentPlan {
ok: false,
summary: String::new(),
actions: vec![],
error: Some(e),
error_code: Some("LLM_ERROR".into()),
plan_json: None,
plan_context: None,
},
};
}
// Запросы не про код/проект — не предлагать план с LICENSE, а ответить коротко.
let goal_trim = user_goal.trim();
let goal_lower = goal_trim.to_lowercase();
let off_topic = goal_lower.is_empty()
|| goal_lower.contains("погода")
|| goal_lower.contains("weather")
|| goal_lower.contains("как дела")
|| goal_lower.contains("what's the")
|| goal_lower == "привет"
|| goal_lower == "hello"
|| goal_lower == "hi";
if off_topic {
return AgentPlan {
ok: true,
summary: "Я помогаю с кодом и проектами. Напиши, например: «сделай README», «добавь тесты», «создай проект с нуля».".into(),
actions: vec![],
error: None,
error_code: None,
plan_json: None,
plan_context: None,
};
}
// При запросе «создать программу» сначала скелет (README, .gitignore, точка входа), LICENSE — в конце.
let want_skeleton = goal_lower.contains("создаю программу")
|| goal_lower.contains("создать программу")
|| goal_lower.contains("create a program")
|| goal_lower.contains("create program")
|| goal_lower.contains("новая программа")
|| goal_lower.contains("с нуля")
|| goal_lower.contains("from scratch");
let mut actions: Vec<Action> = vec![];
let mut summary: Vec<String> = vec![];
if !has_readme(&path) {
actions.push(Action {
kind: ActionKind::CreateFile,
path: "README.md".into(),
content: Some(format!(
"# PAPA YU Project\n\n## Цель\n{}\n\n## Как запустить\n- (добавить)\n\n## Структура\n- (добавить)\n",
user_goal
)),
});
summary.push("Добавлю README.md".into());
}
if !has_gitignore(&path) {
actions.push(Action {
kind: ActionKind::CreateFile,
path: ".gitignore".into(),
content: Some(
"node_modules/\ndist/\nbuild/\n.DS_Store\n.env\n.env.*\ncoverage/\n.target/\n".into(),
),
});
summary.push("Добавлю .gitignore".into());
}
// При «создать программу»: добавить минимальную точку входа, если нет ни src/, ни main.
let has_src = root.join("src").is_dir();
let has_main = root.join("main.py").exists()
|| root.join("main.js").exists()
|| root.join("src").join("main.py").exists()
|| root.join("src").join("main.js").exists();
if want_skeleton && !has_src && !has_main {
let main_path = "main.py";
if !root.join(main_path).exists() {
actions.push(Action {
kind: ActionKind::CreateFile,
path: main_path.into(),
content: Some(
"\"\"\"Точка входа. Запуск: python main.py\"\"\"\n\ndef main() -> None:\n print(\"Hello\")\n\n\nif __name__ == \"__main__\":\n main()\n".into(),
),
});
summary.push("Добавлю main.py (скелет)".into());
}
}
if !has_license(&path) {
actions.push(Action {
kind: ActionKind::CreateFile,
path: "LICENSE".into(),
content: Some("UNLICENSED\n".into()),
});
summary.push("Добавлю LICENSE (пометка UNLICENSED)".into());
}
if report_json.contains(".env") {
actions.push(Action {
kind: ActionKind::CreateFile,
path: ".env.example".into(),
content: Some("VITE_API_URL=\n# пример, без секретов\n".into()),
});
summary.push("Добавлю .env.example (без секретов)".into());
}
if actions.is_empty() {
return AgentPlan {
ok: true,
summary: "Нет безопасных минимальных правок, которые можно применить автоматически.".into(),
actions,
error: None,
error_code: None,
plan_json: None,
plan_context: None,
};
}
AgentPlan {
ok: true,
summary: format!("План действий: {}", summary.join(", ")),
actions,
error: None,
error_code: None,
plan_json: None,
plan_context: None,
}
}

View File

@ -0,0 +1,57 @@
use std::path::Path;
use tauri::AppHandle;
use crate::tx::{apply_actions_to_disk, pop_redo, push_undo, read_manifest};
use crate::types::RedoResult;
#[tauri::command]
pub async fn redo_last(app: AppHandle) -> RedoResult {
let Some(tx_id) = pop_redo(&app) else {
return RedoResult {
ok: false,
tx_id: None,
error: Some("nothing to redo".into()),
error_code: Some("REDO_NOTHING".into()),
};
};
let manifest = match read_manifest(&app, &tx_id) {
Ok(m) => m,
Err(e) => {
return RedoResult {
ok: false,
tx_id: Some(tx_id),
error: Some(e.to_string()),
error_code: Some("REDO_READ_MANIFEST_FAILED".into()),
};
}
};
if manifest.applied_actions.is_empty() {
return RedoResult {
ok: false,
tx_id: Some(tx_id),
error: Some("Legacy transaction cannot be redone (no applied_actions)".into()),
error_code: Some("REDO_LEGACY".into()),
};
}
let root = Path::new(&manifest.root_path);
if let Err(e) = apply_actions_to_disk(root, &manifest.applied_actions) {
return RedoResult {
ok: false,
tx_id: Some(tx_id),
error: Some(e),
error_code: Some("REDO_APPLY_FAILED".into()),
};
}
let _ = push_undo(&app, tx_id.clone());
RedoResult {
ok: true,
tx_id: Some(tx_id),
error: None,
error_code: None,
}
}

View File

@ -0,0 +1,87 @@
use std::path::Path;
use crate::commands::get_project_profile::get_project_limits;
use crate::commands::{analyze_project, apply_actions, preview_actions};
use crate::tx::get_undo_redo_state;
use crate::types::{BatchEvent, BatchPayload};
use tauri::AppHandle;
pub async fn run_batch(app: AppHandle, payload: BatchPayload) -> Result<Vec<BatchEvent>, String> {
let mut events = Vec::new();
let paths = if payload.paths.is_empty() {
vec![".".to_string()]
} else {
payload.paths.clone()
};
let report = analyze_project(paths.clone(), payload.attached_files.clone()).map_err(|e| e.to_string())?;
events.push(BatchEvent {
kind: "report".to_string(),
report: Some(report.clone()),
preview: None,
apply_result: None,
message: Some(report.narrative.clone()),
undo_available: None,
});
let actions = payload
.selected_actions
.unwrap_or(report.actions.clone());
if actions.is_empty() {
return Ok(events);
}
let root_path = report.path.clone();
let preview = preview_actions(crate::types::ApplyPayload {
root_path: root_path.clone(),
actions: actions.clone(),
auto_check: None,
label: None,
user_confirmed: false,
})
.map_err(|e| e.to_string())?;
events.push(BatchEvent {
kind: "preview".to_string(),
report: None,
preview: Some(preview),
apply_result: None,
message: None,
undo_available: None,
});
if !payload.confirm_apply {
return Ok(events);
}
let limits = get_project_limits(Path::new(&root_path));
if actions.len() > limits.max_actions_per_tx as usize {
return Err(format!(
"too many actions: {} > {} (max_actions_per_tx)",
actions.len(),
limits.max_actions_per_tx
));
}
let result = apply_actions(
app.clone(),
crate::types::ApplyPayload {
root_path: root_path.clone(),
actions,
auto_check: Some(payload.auto_check),
label: None,
user_confirmed: payload.user_confirmed,
},
);
let (undo_avail, _) = get_undo_redo_state(&app);
events.push(BatchEvent {
kind: "apply".to_string(),
report: None,
preview: None,
apply_result: Some(result.clone()),
message: result.error.clone(),
undo_available: Some(result.ok && undo_avail),
});
Ok(events)
}

View File

@ -0,0 +1,223 @@
//! v2.4.4: Export/import settings (projects, profiles, sessions, folder_links).
use crate::commands::folder_links::{load_folder_links, save_folder_links, FolderLinks};
use crate::store::{load_profiles, load_projects, load_sessions, save_profiles, save_projects, save_sessions};
use crate::types::{Project, ProjectSettings, Session};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::Manager;
/// Bundle of all exportable settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsBundle {
pub version: String,
pub exported_at: String,
pub projects: Vec<Project>,
pub profiles: HashMap<String, ProjectSettings>,
pub sessions: Vec<Session>,
pub folder_links: FolderLinks,
}
fn app_data_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
app.path().app_data_dir().map_err(|e| e.to_string())
}
/// Export all settings as JSON string
#[tauri::command]
pub fn export_settings(app: tauri::AppHandle) -> Result<String, String> {
let dir = app_data_dir(&app)?;
let bundle = SettingsBundle {
version: "2.4.4".to_string(),
exported_at: chrono::Utc::now().to_rfc3339(),
projects: load_projects(&dir),
profiles: load_profiles(&dir),
sessions: load_sessions(&dir),
folder_links: load_folder_links(&dir),
};
serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())
}
/// Import mode
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportMode {
/// Replace all existing settings
Replace,
/// Merge with existing (don't overwrite existing items)
Merge,
}
/// Import settings from JSON string
#[tauri::command]
pub fn import_settings(
app: tauri::AppHandle,
json: String,
mode: Option<String>,
) -> Result<ImportResult, String> {
let bundle: SettingsBundle = serde_json::from_str(&json)
.map_err(|e| format!("Invalid settings JSON: {}", e))?;
let mode = match mode.as_deref() {
Some("replace") => ImportMode::Replace,
_ => ImportMode::Merge,
};
let dir = app_data_dir(&app)?;
let mut result = ImportResult {
projects_imported: 0,
profiles_imported: 0,
sessions_imported: 0,
folder_links_imported: 0,
};
match mode {
ImportMode::Replace => {
// Replace all
save_projects(&dir, &bundle.projects)?;
result.projects_imported = bundle.projects.len();
save_profiles(&dir, &bundle.profiles)?;
result.profiles_imported = bundle.profiles.len();
save_sessions(&dir, &bundle.sessions)?;
result.sessions_imported = bundle.sessions.len();
save_folder_links(&dir, &bundle.folder_links)?;
result.folder_links_imported = bundle.folder_links.paths.len();
}
ImportMode::Merge => {
// Merge projects
let mut existing_projects = load_projects(&dir);
let existing_paths: std::collections::HashSet<_> =
existing_projects.iter().map(|p| p.path.clone()).collect();
for p in bundle.projects {
if !existing_paths.contains(&p.path) {
existing_projects.push(p);
result.projects_imported += 1;
}
}
save_projects(&dir, &existing_projects)?;
// Merge profiles
let mut existing_profiles = load_profiles(&dir);
for (k, v) in bundle.profiles {
if !existing_profiles.contains_key(&k) {
existing_profiles.insert(k, v);
result.profiles_imported += 1;
}
}
save_profiles(&dir, &existing_profiles)?;
// Merge sessions
let mut existing_sessions = load_sessions(&dir);
let existing_ids: std::collections::HashSet<_> =
existing_sessions.iter().map(|s| s.id.clone()).collect();
for s in bundle.sessions {
if !existing_ids.contains(&s.id) {
existing_sessions.push(s);
result.sessions_imported += 1;
}
}
save_sessions(&dir, &existing_sessions)?;
// Merge folder links
let mut existing_links = load_folder_links(&dir);
let existing_set: std::collections::HashSet<_> =
existing_links.paths.iter().cloned().collect();
for p in bundle.folder_links.paths {
if !existing_set.contains(&p) {
existing_links.paths.push(p);
result.folder_links_imported += 1;
}
}
save_folder_links(&dir, &existing_links)?;
}
}
Ok(result)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult {
pub projects_imported: usize,
pub profiles_imported: usize,
pub sessions_imported: usize,
pub folder_links_imported: usize,
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_bundle() -> SettingsBundle {
SettingsBundle {
version: "2.4.4".to_string(),
exported_at: "2025-01-31T00:00:00Z".to_string(),
projects: vec![Project {
id: "test-id".to_string(),
path: "/test/path".to_string(),
name: "Test Project".to_string(),
created_at: "2025-01-31T00:00:00Z".to_string(),
}],
profiles: HashMap::from([(
"test-id".to_string(),
ProjectSettings {
project_id: "test-id".to_string(),
auto_check: true,
max_attempts: 3,
max_actions: 10,
goal_template: Some("Test goal".to_string()),
},
)]),
sessions: vec![],
folder_links: FolderLinks {
paths: vec!["/test/folder".to_string()],
},
}
}
#[test]
fn test_settings_bundle_serialization() {
let bundle = create_test_bundle();
let json = serde_json::to_string(&bundle).unwrap();
assert!(json.contains("\"version\":\"2.4.4\""));
assert!(json.contains("\"Test Project\""));
assert!(json.contains("\"/test/folder\""));
let parsed: SettingsBundle = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version, "2.4.4");
assert_eq!(parsed.projects.len(), 1);
assert_eq!(parsed.projects[0].name, "Test Project");
}
#[test]
fn test_settings_bundle_deserialization() {
let json = r#"{
"version": "2.4.4",
"exported_at": "2025-01-31T00:00:00Z",
"projects": [],
"profiles": {},
"sessions": [],
"folder_links": { "paths": [] }
}"#;
let bundle: SettingsBundle = serde_json::from_str(json).unwrap();
assert_eq!(bundle.version, "2.4.4");
assert!(bundle.projects.is_empty());
}
#[test]
fn test_import_result_default() {
let result = ImportResult {
projects_imported: 0,
profiles_imported: 0,
sessions_imported: 0,
folder_links_imported: 0,
};
assert_eq!(result.projects_imported, 0);
}
}

View File

@ -0,0 +1,184 @@
//! Мониторинг трендов в программировании: рекомендации в автоматическом режиме не реже раз в месяц.
//! Данные хранятся в app_data_dir/trends.json; при первом запуске или если прошло >= 30 дней — should_update = true.
use std::fs;
use std::time::Duration;
use chrono::{DateTime, Utc};
use tauri::{AppHandle, Manager};
use crate::types::{TrendsRecommendation, TrendsResult};
const TRENDS_FILENAME: &str = "trends.json";
const RECOMMEND_UPDATE_DAYS: i64 = 30;
fn default_recommendations() -> Vec<TrendsRecommendation> {
vec![
TrendsRecommendation {
title: "TypeScript и строгая типизация".to_string(),
summary: Some("Использование TypeScript в веб- и Node-проектах снижает количество ошибок.".to_string()),
url: Some("https://www.typescriptlang.org/".to_string()),
source: Some("PAPA YU".to_string()),
},
TrendsRecommendation {
title: "React Server Components и Next.js".to_string(),
summary: Some("Тренд на серверный рендеринг и стриминг в React-экосистеме.".to_string()),
url: Some("https://nextjs.org/".to_string()),
source: Some("PAPA YU".to_string()),
},
TrendsRecommendation {
title: "Rust для инструментов и WASM".to_string(),
summary: Some("Rust растёт в CLI, инструментах и веб-сборке (WASM).".to_string()),
url: Some("https://www.rust-lang.org/".to_string()),
source: Some("PAPA YU".to_string()),
},
TrendsRecommendation {
title: "Обновляйте зависимости и линтеры".to_string(),
summary: Some("Регулярно обновляйте npm/cargo зависимости и настройте линтеры (ESLint, Clippy).".to_string()),
url: None,
source: Some("PAPA YU".to_string()),
},
]
}
fn app_trends_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir.join(TRENDS_FILENAME))
}
#[derive(serde::Serialize, serde::Deserialize)]
struct StoredTrends {
last_updated: String,
recommendations: Vec<TrendsRecommendation>,
}
/// Возвращает сохранённые тренды и флаг should_update (true, если прошло >= 30 дней или данных нет).
#[tauri::command]
pub fn get_trends_recommendations(app: AppHandle) -> TrendsResult {
let path = match app_trends_path(&app) {
Ok(p) => p,
Err(_) => {
return TrendsResult {
last_updated: String::new(),
recommendations: default_recommendations(),
should_update: true,
};
}
};
if !path.exists() {
return TrendsResult {
last_updated: String::new(),
recommendations: default_recommendations(),
should_update: true,
};
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => {
return TrendsResult {
last_updated: String::new(),
recommendations: default_recommendations(),
should_update: true,
};
}
};
let stored: StoredTrends = match serde_json::from_str(&content) {
Ok(s) => s,
Err(_) => {
return TrendsResult {
last_updated: String::new(),
recommendations: default_recommendations(),
should_update: true,
};
}
};
let should_update = parse_and_check_older_than_days(&stored.last_updated, RECOMMEND_UPDATE_DAYS);
TrendsResult {
last_updated: stored.last_updated,
recommendations: stored.recommendations,
should_update,
}
}
fn parse_and_check_older_than_days(iso: &str, days: i64) -> bool {
if iso.is_empty() {
return true;
}
let dt: DateTime<Utc> = match DateTime::parse_from_rfc3339(iso) {
Ok(d) => d.with_timezone(&Utc),
Err(_) => return true,
};
let now = Utc::now();
(now - dt).num_days() >= days
}
/// Разрешённые URL для запроса трендов (только эти домены).
const ALLOWED_TRENDS_HOSTS: &[&str] = &["raw.githubusercontent.com", "api.github.com", "jsonplaceholder.typicode.com"];
fn url_allowed(url: &str) -> bool {
let url = url.trim().to_lowercase();
if !url.starts_with("https://") {
return false;
}
let rest = url.strip_prefix("https://").unwrap_or("");
let host = rest.split('/').next().unwrap_or("");
ALLOWED_TRENDS_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{}", h)))
}
/// Обновляет тренды: запрашивает данные по allowlist URL (PAPAYU_TRENDS_URL или встроенный список) и сохраняет.
#[tauri::command]
pub async fn fetch_trends_recommendations(app: AppHandle) -> TrendsResult {
let now = Utc::now();
let iso = now.to_rfc3339();
let urls: Vec<String> = std::env::var("PAPAYU_TRENDS_URLS")
.ok()
.map(|s| s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect())
.unwrap_or_else(Vec::new);
let mut recommendations = Vec::new();
if !urls.is_empty() {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.unwrap_or_default();
for url in urls {
if !url_allowed(&url) {
continue;
}
if let Ok(resp) = client.get(&url).send().await {
if let Ok(text) = resp.text().await {
if let Ok(parsed) = serde_json::from_str::<Vec<TrendsRecommendation>>(&text) {
recommendations.extend(parsed);
} else if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(arr) = obj.get("recommendations").and_then(|a| a.as_array()) {
for v in arr {
if let Ok(r) = serde_json::from_value::<TrendsRecommendation>(v.clone()) {
recommendations.push(r);
}
}
}
}
}
}
}
}
if recommendations.is_empty() {
recommendations = default_recommendations();
}
let stored = StoredTrends {
last_updated: iso.clone(),
recommendations: recommendations.clone(),
};
if let Ok(path) = app_trends_path(&app) {
let _ = fs::write(path, serde_json::to_string_pretty(&stored).unwrap_or_default());
}
TrendsResult {
last_updated: iso,
recommendations,
should_update: false,
}
}

View File

@ -0,0 +1,53 @@
use tauri::AppHandle;
use crate::tx::{get_undo_redo_state, pop_undo, push_redo, rollback_tx};
use crate::types::{UndoAvailableResult, UndoRedoState, UndoResult};
#[tauri::command]
pub async fn get_undo_redo_state_cmd(app: AppHandle) -> UndoRedoState {
let (undo_available, redo_available) = get_undo_redo_state(&app);
UndoRedoState {
undo_available,
redo_available,
}
}
#[tauri::command]
pub async fn undo_available(app: AppHandle) -> UndoAvailableResult {
let (undo_avail, _) = get_undo_redo_state(&app);
UndoAvailableResult {
ok: true,
available: undo_avail,
tx_id: None,
}
}
#[tauri::command]
pub async fn undo_last(app: AppHandle) -> UndoResult {
let Some(tx_id) = pop_undo(&app) else {
return UndoResult {
ok: false,
tx_id: None,
error: Some("nothing to undo".into()),
error_code: Some("UNDO_NOTHING".into()),
};
};
if let Err(e) = rollback_tx(&app, &tx_id) {
return UndoResult {
ok: false,
tx_id: Some(tx_id),
error: Some(e),
error_code: Some("ROLLBACK_FAILED".into()),
};
}
let _ = push_redo(&app, tx_id.clone());
UndoResult {
ok: true,
tx_id: Some(tx_id),
error: None,
error_code: None,
}
}

View File

@ -0,0 +1,97 @@
//! v3.1: откат последней транзакции из history/tx + history/snapshots
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager};
fn copy_dir(src: &Path, dst: &Path) -> Result<(), String> {
fs::create_dir_all(dst).map_err(|e| e.to_string())?;
for e in fs::read_dir(src).map_err(|e| e.to_string())? {
let e = e.map_err(|e| e.to_string())?;
let sp = e.path();
let dp = dst.join(e.file_name());
let ft = e.file_type().map_err(|e| e.to_string())?;
if ft.is_dir() {
copy_dir(&sp, &dp)?;
} else if ft.is_file() {
fs::copy(&sp, &dp).map_err(|e| e.to_string())?;
}
}
Ok(())
}
#[tauri::command]
pub async fn undo_last_tx(app: AppHandle, path: String) -> Result<bool, String> {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let tx_dir = data_dir.join("history").join("tx");
let snap_base = data_dir.join("history").join("snapshots");
if !tx_dir.exists() {
return Ok(false);
}
let mut items: Vec<(std::time::SystemTime, PathBuf)> = vec![];
for e in fs::read_dir(&tx_dir).map_err(|e| e.to_string())? {
let e = e.map_err(|e| e.to_string())?;
let meta = e.metadata().map_err(|e| e.to_string())?;
let m = meta.modified().map_err(|e| e.to_string())?;
items.push((m, e.path()));
}
items.sort_by(|a, b| b.0.cmp(&a.0));
let last = match items.first() {
Some((_, p)) => p.clone(),
None => return Ok(false),
};
let raw = fs::read_to_string(&last).map_err(|e| e.to_string())?;
let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
let tx_id = v
.get("txId")
.and_then(|x| x.as_str())
.ok_or("txId missing")?;
let tx_path = v.get("path").and_then(|x| x.as_str()).unwrap_or("");
if tx_path != path {
return Ok(false);
}
let snap_dir = snap_base.join(tx_id);
if !snap_dir.exists() {
return Ok(false);
}
let root = PathBuf::from(&path);
if !root.exists() {
return Ok(false);
}
let exclude = [
".git",
"node_modules",
"dist",
"build",
".next",
"target",
".cache",
"coverage",
];
for entry in fs::read_dir(&root).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let p = entry.path();
let name = entry.file_name();
if exclude
.iter()
.any(|x| name.to_string_lossy().as_ref() == *x)
{
continue;
}
if p.is_dir() {
fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
} else {
fs::remove_file(&p).map_err(|e| e.to_string())?;
}
}
copy_dir(&snap_dir, &root)?;
Ok(true)
}

View File

@ -0,0 +1,36 @@
//! v2.9.3: доступен ли откат (есть ли последняя транзакция в papayu/transactions)
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use crate::types::UndoStatus;
#[tauri::command]
pub async fn undo_status(app: AppHandle) -> UndoStatus {
let base: PathBuf = match app.path().app_data_dir() {
Ok(v) => v,
Err(_) => return UndoStatus { available: false, tx_id: None },
};
let dir = base.join("history").join("tx");
let Ok(rd) = fs::read_dir(&dir) else {
return UndoStatus { available: false, tx_id: None };
};
let last = rd
.filter_map(|e| e.ok())
.max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()));
match last {
Some(f) => {
let name = f.file_name().to_string_lossy().to_string();
UndoStatus {
available: true,
tx_id: Some(name),
}
}
None => UndoStatus { available: false, tx_id: None },
}
}

554
src-tauri/src/context.rs Normal file
View File

@ -0,0 +1,554 @@
//! Автосбор контекста для LLM: env, project prefs, context_requests (read_file, search, logs).
//! Кеш read/search/logs/env в пределах сессии (plan-цикла).
use crate::memory::EngineeringMemory;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
const MAX_CONTEXT_LINE_LEN: usize = 80_000;
const SEARCH_MAX_HITS: usize = 50;
fn context_max_files() -> usize {
std::env::var("PAPAYU_CONTEXT_MAX_FILES")
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(8)
}
fn context_max_file_chars() -> usize {
std::env::var("PAPAYU_CONTEXT_MAX_FILE_CHARS")
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(20_000)
}
fn context_max_total_chars() -> usize {
std::env::var("PAPAYU_CONTEXT_MAX_TOTAL_CHARS")
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(120_000)
}
#[allow(dead_code)]
fn context_max_log_chars() -> usize {
std::env::var("PAPAYU_CONTEXT_MAX_LOG_CHARS")
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(12_000)
}
/// Ключ кеша контекста.
#[derive(Hash, Eq, PartialEq, Clone, Debug)]
pub enum ContextCacheKey {
Env,
Logs { source: String, last_n: u32 },
ReadFile { path: String, start: u32, end: u32 },
Search { query: String, glob: Option<String> },
}
/// Кеш контекста для сессии (plan-цикла).
#[derive(Default)]
pub struct ContextCache {
map: HashMap<ContextCacheKey, String>,
}
impl ContextCache {
pub fn new() -> Self {
Self {
map: HashMap::new(),
}
}
pub fn get(&self, key: &ContextCacheKey) -> Option<&String> {
self.map.get(key)
}
pub fn put(&mut self, key: ContextCacheKey, value: String) {
self.map.insert(key, value);
}
}
/// Собирает базовый контекст перед первым запросом к модели: env, команды из project prefs.
pub fn gather_base_context(_project_root: &Path, mem: &EngineeringMemory) -> String {
let mut parts = Vec::new();
let env_block = gather_env();
if !env_block.is_empty() {
parts.push(format!("ENV:\n{}", env_block));
}
if !mem.project.is_default() {
let mut prefs = Vec::new();
if !mem.project.default_test_command.is_empty() {
prefs.push(format!("default_test_command: {}", mem.project.default_test_command));
}
if !mem.project.default_lint_command.is_empty() {
prefs.push(format!("default_lint_command: {}", mem.project.default_lint_command));
}
if !mem.project.default_format_command.is_empty() {
prefs.push(format!("default_format_command: {}", mem.project.default_format_command));
}
if !mem.project.src_roots.is_empty() {
prefs.push(format!("src_roots: {:?}", mem.project.src_roots));
}
if !mem.project.test_roots.is_empty() {
prefs.push(format!("test_roots: {:?}", mem.project.test_roots));
}
if !prefs.is_empty() {
parts.push(format!("PROJECT_PREFS:\n{}", prefs.join("\n")));
}
}
if parts.is_empty() {
String::new()
} else {
format!("\n\nAUTO_CONTEXT:\n{}\n", parts.join("\n\n"))
}
}
fn gather_env() -> String {
let mut lines = Vec::new();
if let Ok(os) = std::env::var("OS") {
lines.push(format!("OS: {}", os));
}
#[cfg(target_os = "macos")]
lines.push("OS: macOS".to_string());
#[cfg(target_os = "linux")]
lines.push("OS: Linux".to_string());
#[cfg(target_os = "windows")]
lines.push("OS: Windows".to_string());
if let Ok(lang) = std::env::var("LANG") {
lines.push(format!("LANG: {}", lang));
}
if let Ok(py) = std::env::var("VIRTUAL_ENV") {
lines.push(format!("VIRTUAL_ENV: {}", py));
}
if let Ok(node) = std::env::var("NODE_VERSION") {
lines.push(format!("NODE_VERSION: {}", node));
}
lines.join("\n")
}
/// Выполняет context_requests от модели и возвращает текст для добавления в user message.
/// Использует кеш, если передан; логирует CONTEXT_CACHE_HIT/MISS при trace_id.
pub fn fulfill_context_requests(
project_root: &Path,
requests: &[serde_json::Value],
max_log_lines: usize,
mut cache: Option<&mut ContextCache>,
trace_id: Option<&str>,
) -> String {
let mut parts = Vec::new();
for r in requests {
let obj = match r.as_object() {
Some(o) => o,
None => continue,
};
let rtype = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
match rtype {
"read_file" => {
if let Some(path) = obj.get("path").and_then(|v| v.as_str()) {
let start = obj.get("start_line").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
let end = obj
.get("end_line")
.and_then(|v| v.as_u64())
.unwrap_or((start + 200) as u64) as u32;
let key = ContextCacheKey::ReadFile {
path: path.to_string(),
start,
end,
};
let content = if let Some(ref mut c) = cache {
if let Some(v) = c.get(&key) {
if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_HIT key=read_file path={}", tid, path);
}
v.clone()
} else {
let v = read_file_snippet(project_root, path, start as usize, end as usize);
let out = format!("FILE[{}]:\n{}", path, v);
if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_MISS key=read_file path={} size={}", tid, path, out.len());
}
c.put(key, out.clone());
out
}
} else {
let v = read_file_snippet(project_root, path, start as usize, end as usize);
format!("FILE[{}]:\n{}", path, v)
};
parts.push(content);
}
}
"search" => {
if let Some(query) = obj.get("query").and_then(|v| v.as_str()) {
let glob = obj.get("glob").and_then(|v| v.as_str()).map(String::from);
let key = ContextCacheKey::Search {
query: query.to_string(),
glob: glob.clone(),
};
let content = if let Some(ref mut c) = cache {
if let Some(v) = c.get(&key) {
if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_HIT key=search query={}", tid, query);
}
v.clone()
} else {
let hits = search_in_project(project_root, query, glob.as_deref());
let out = format!("SEARCH[{}]:\n{}", query, hits.join("\n"));
if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_MISS key=search query={} hits={}", tid, query, hits.len());
}
c.put(key, out.clone());
out
}
} else {
let hits = search_in_project(project_root, query, glob.as_deref());
format!("SEARCH[{}]:\n{}", query, hits.join("\n"))
};
parts.push(content);
}
}
"logs" => {
let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("runtime");
let last_n = obj
.get("last_n")
.and_then(|v| v.as_u64())
.unwrap_or(max_log_lines as u64) as u32;
let key = ContextCacheKey::Logs {
source: source.to_string(),
last_n,
};
let content = if let Some(ref mut c) = cache {
if let Some(v) = c.get(&key) {
if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_HIT key=logs source={}", tid, source);
}
v.clone()
} else {
let v = format!(
"LOGS[{}]: (last_n={}; приложение не имеет доступа к логам runtime — передай вывод в запросе)\n",
source, last_n
);
if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_MISS key=logs source={}", tid, source);
}
c.put(key, v.clone());
v
}
} else {
format!(
"LOGS[{}]: (last_n={}; приложение не имеет доступа к логам runtime — передай вывод в запросе)\n",
source, last_n
)
};
parts.push(content);
}
"env" => {
let key = ContextCacheKey::Env;
let content = if let Some(ref mut c) = cache {
if let Some(v) = c.get(&key) {
if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_HIT key=env", tid);
}
v.clone()
} else {
let v = format!("ENV (повторно):\n{}", gather_env());
if let Some(tid) = trace_id {
eprintln!("[{}] CONTEXT_CACHE_MISS key=env size={}", tid, v.len());
}
c.put(key, v.clone());
v
}
} else {
format!("ENV (повторно):\n{}", gather_env())
};
parts.push(content);
}
_ => {}
}
}
if parts.is_empty() {
String::new()
} else {
let max_files = context_max_files();
let max_total = context_max_total_chars();
let header = "\n\nFULFILLED_CONTEXT:\n";
let mut total_chars = header.len();
let mut result_parts = Vec::with_capacity(parts.len().min(max_files));
let mut dropped = 0;
for (_i, p) in parts.iter().enumerate() {
if result_parts.len() >= max_files {
dropped += 1;
continue;
}
let part_len = p.len() + if result_parts.is_empty() { 0 } else { 2 };
if total_chars + part_len > max_total && !result_parts.is_empty() {
dropped += 1;
continue;
}
let to_add = if total_chars + part_len > max_total {
let allowed = max_total - total_chars - 30;
if allowed > 100 {
format!("{}...[TRUNCATED]...", &p[..allowed.min(p.len())])
} else {
p.clone()
}
} else {
p.clone()
};
total_chars += to_add.len() + if result_parts.is_empty() { 0 } else { 2 };
result_parts.push(to_add);
}
if let Some(tid) = trace_id {
if dropped > 0 {
eprintln!(
"[{}] CONTEXT_DIET_APPLIED files={} dropped={} total_chars={}",
tid, result_parts.len(), dropped, total_chars
);
}
}
format!("{}{}", header, result_parts.join("\n\n"))
}
}
fn read_file_snippet(root: &Path, rel_path: &str, start_line: usize, end_line: usize) -> String {
let path = root.join(rel_path);
if !path.is_file() {
return format!("(файл не найден: {})", rel_path);
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return "(не удалось прочитать)".to_string(),
};
let lines: Vec<&str> = content.lines().collect();
let start = start_line.saturating_sub(1).min(lines.len());
let end = end_line.min(lines.len()).max(start);
let slice: Vec<&str> = lines.get(start..end).unwrap_or(&[]).into_iter().copied().collect();
let mut out = String::new();
for (i, line) in slice.iter().enumerate() {
let line_no = start + i + 1;
out.push_str(&format!("{}|{}\n", line_no, line));
}
let max_chars = context_max_file_chars().min(MAX_CONTEXT_LINE_LEN);
if out.len() > max_chars {
let head = (max_chars as f32 * 0.6) as usize;
let tail = max_chars - head - 30;
format!(
"{}...[TRUNCATED {} chars]...\n{}",
&out[..head.min(out.len())],
out.len(),
&out[out.len().saturating_sub(tail)..]
)
} else {
out
}
}
fn search_in_project(root: &Path, query: &str, _glob: Option<&str>) -> Vec<String> {
let mut hits = Vec::new();
let walk = walkdir::WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
let n = e.file_name().to_str().unwrap_or("");
!n.starts_with('.')
&& n != "node_modules"
&& n != "target"
&& n != "dist"
&& n != "__pycache__"
});
for entry in walk.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let is_text = ["py", "rs", "ts", "tsx", "js", "jsx", "md", "json", "toml", "yml", "yaml"]
.contains(&ext);
if !is_text {
continue;
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
for (i, line) in content.lines().enumerate() {
if line.contains(query) {
let rel = path
.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string());
hits.push(format!("{}:{}: {}", rel, i + 1, line.trim()));
if hits.len() >= SEARCH_MAX_HITS {
return hits;
}
}
}
}
hits
}
/// Эвристики автосбора контекста до первого вызова LLM.
/// Возвращает дополнительный контекст на основе user_goal/report (Traceback, ImportError и т.д.).
pub fn gather_auto_context_from_message(project_root: &Path, user_message: &str) -> String {
let mut parts = Vec::new();
// Traceback / Exception → извлечь пути и прочитать файлы ±80 строк
let traceback_files = extract_traceback_files(user_message);
let root_str = project_root.display().to_string();
for (path_from_tb, line_no) in traceback_files {
// Преобразовать абсолютный путь в относительный (если project_root — префикс)
let rel_path = if path_from_tb.starts_with('/')
|| (path_from_tb.len() >= 2 && path_from_tb.chars().nth(1) == Some(':'))
{
// Абсолютный путь: убрать префикс project_root
let normalized = path_from_tb.replace('\\', "/");
let root_norm = root_str.replace('\\', "/");
if normalized.starts_with(&root_norm) {
normalized
.strip_prefix(&root_norm)
.map(|s| s.trim_start_matches('/').to_string())
.unwrap_or(path_from_tb)
} else {
path_from_tb
}
} else {
path_from_tb
};
let start = line_no.saturating_sub(80).max(1);
let end = line_no + 80;
let content = read_file_snippet(project_root, &rel_path, start, end);
if !content.contains("не найден") && !content.contains("не удалось") {
parts.push(format!("AUTO_TRACEBACK[{}]:\n{}", rel_path, content));
}
}
// ImportError / ModuleNotFoundError → env + lock/deps файлы
let lower = user_message.to_lowercase();
if lower.contains("importerror")
|| lower.contains("modulenotfounderror")
|| lower.contains("cannot find module")
|| lower.contains("module not found")
{
parts.push(format!("ENV (для ImportError):\n{}", gather_env()));
// Попытаться добавить содержимое pyproject.toml, requirements.txt, package.json
for rel in ["pyproject.toml", "requirements.txt", "package.json", "poetry.lock"] {
let p = project_root.join(rel);
if p.is_file() {
if let Ok(s) = fs::read_to_string(&p) {
let trimmed = if s.len() > 8000 {
format!("{}\n(обрезано)", &s[..8000])
} else {
s
};
parts.push(format!("DEPS[{}]:\n{}", rel, trimmed));
}
}
}
}
if parts.is_empty() {
String::new()
} else {
format!("\n\nAUTO_CONTEXT_FROM_MESSAGE:\n{}\n", parts.join("\n\n"))
}
}
/// Извлекает пути и строки из traceback в тексте (Python). Используется при автосборе контекста по ошибке.
pub fn extract_traceback_files(text: &str) -> Vec<(String, usize)> {
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.starts_with("File \"") {
if let Some(rest) = line.strip_prefix("File \"") {
if let Some(end) = rest.find('\"') {
let path = rest[..end].to_string();
let after = &rest[end + 1..];
let line_no = after
.trim_start_matches(", line ")
.split(',')
.next()
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(0);
if !path.is_empty() && line_no > 0 {
out.push((path, line_no));
}
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_read_file_hit() {
let mut cache = ContextCache::new();
let key = ContextCacheKey::ReadFile {
path: "foo.rs".to_string(),
start: 1,
end: 10,
};
cache.put(key.clone(), "FILE[foo.rs]:\n1|line1".to_string());
assert!(cache.get(&key).is_some());
assert!(cache.get(&key).unwrap().contains("foo.rs"));
}
#[test]
fn test_cache_search_hit() {
let mut cache = ContextCache::new();
let key = ContextCacheKey::Search {
query: "test".to_string(),
glob: None,
};
cache.put(key.clone(), "SEARCH[test]:\nfoo:1: test".to_string());
assert!(cache.get(&key).is_some());
}
#[test]
fn test_cache_env_hit() {
let mut cache = ContextCache::new();
let key = ContextCacheKey::Env;
cache.put(key.clone(), "ENV:\nOS: test".to_string());
assert!(cache.get(&key).is_some());
}
#[test]
fn test_cache_logs_hit() {
let mut cache = ContextCache::new();
let key = ContextCacheKey::Logs {
source: "runtime".to_string(),
last_n: 100,
};
cache.put(key.clone(), "LOGS[runtime]: ...".to_string());
assert!(cache.get(&key).is_some());
}
#[test]
fn test_context_diet_max_files() {
let max = context_max_files();
assert!(max >= 1 && max <= 100);
}
#[test]
fn test_context_diet_limits() {
assert!(context_max_file_chars() > 1000);
assert!(context_max_total_chars() > 10000);
}
#[test]
fn extract_traceback_parses_file_line() {
let t = r#" File "/home/x/src/main.py", line 42, in foo
bar()
"#;
let files = extract_traceback_files(t);
assert_eq!(files.len(), 1);
assert!(files[0].0.contains("main.py"));
assert_eq!(files[0].1, 42);
}
}

90
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,90 @@
mod commands;
mod context;
mod memory;
mod store;
mod tx;
mod types;
mod verify;
use commands::{add_project, agentic_run, analyze_project, append_session_event, apply_actions, apply_actions_tx, export_settings, fetch_trends_recommendations, generate_actions, generate_actions_from_report, get_project_profile, get_project_settings, get_trends_recommendations, get_undo_redo_state_cmd, import_settings, list_projects, list_sessions, load_folder_links, preview_actions, propose_actions, redo_last, run_batch, save_folder_links, set_project_settings, undo_available, undo_last, undo_last_tx, undo_status};
use tauri::Manager;
use commands::FolderLinks;
use types::{ApplyPayload, BatchPayload};
#[tauri::command]
fn analyze_project_cmd(paths: Vec<String>, attached_files: Option<Vec<String>>) -> Result<types::AnalyzeReport, String> {
analyze_project(paths, attached_files)
}
#[tauri::command]
fn preview_actions_cmd(payload: ApplyPayload) -> Result<types::PreviewResult, String> {
preview_actions(payload)
}
#[tauri::command]
fn apply_actions_cmd(app: tauri::AppHandle, payload: ApplyPayload) -> types::ApplyResult {
apply_actions(app, payload)
}
#[tauri::command]
async fn run_batch_cmd(app: tauri::AppHandle, payload: BatchPayload) -> Result<Vec<types::BatchEvent>, String> {
run_batch(app, payload).await
}
#[tauri::command]
fn get_folder_links(app: tauri::AppHandle) -> Result<FolderLinks, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
Ok(load_folder_links(&dir))
}
#[tauri::command]
fn set_folder_links(app: tauri::AppHandle, links: FolderLinks) -> Result<(), String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
save_folder_links(&dir, &links)
}
/// Проверка целостности проекта (типы, сборка, тесты). Вызывается автоматически после применений или вручную.
#[tauri::command]
fn verify_project(path: String) -> types::VerifyResult {
verify::verify_project(&path)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
analyze_project_cmd,
preview_actions_cmd,
apply_actions_cmd,
undo_last,
undo_available,
redo_last,
get_undo_redo_state_cmd,
generate_actions,
run_batch_cmd,
get_folder_links,
set_folder_links,
apply_actions_tx,
verify_project,
undo_last_tx,
undo_status,
propose_actions,
generate_actions_from_report,
agentic_run,
list_projects,
add_project,
get_project_profile,
get_project_settings,
set_project_settings,
list_sessions,
append_session_event,
get_trends_recommendations,
fetch_trends_recommendations,
export_settings,
import_settings,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

5
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
papa_yu_lib::run()
}

301
src-tauri/src/memory.rs Normal file
View File

@ -0,0 +1,301 @@
//! Инженерная память: user prefs + project prefs, загрузка/сохранение, MEMORY BLOCK для промпта, whitelist для memory_patch.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
pub const SCHEMA_VERSION: u32 = 1;
/// User preferences (оператор).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UserPrefs {
#[serde(default)]
pub preferred_style: String, // "brief" | "normal" | "verbose"
#[serde(default)]
pub ask_budget: u8, // 0..2
#[serde(default)]
pub risk_tolerance: String, // "low" | "medium" | "high"
#[serde(default)]
pub default_language: String, // "python" | "node" | "go" etc.
#[serde(default)]
pub output_format: String, // "patch_first" | "plan_first"
}
/// Project preferences (для конкретного репо).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProjectPrefs {
#[serde(default)]
pub default_test_command: String,
#[serde(default)]
pub default_lint_command: String,
#[serde(default)]
pub default_format_command: String,
#[serde(default)]
pub package_manager: String,
#[serde(default)]
pub build_command: String,
#[serde(default)]
pub src_roots: Vec<String>,
#[serde(default)]
pub test_roots: Vec<String>,
#[serde(default)]
pub ci_notes: String,
}
/// Корневой файл пользовательских настроек (~/.papa-yu или app_data/papa-yu).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreferencesFile {
#[serde(default)]
pub schema_version: u32,
#[serde(default)]
pub user: UserPrefs,
}
/// Файл настроек проекта (.papa-yu/project.json).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectPrefsFile {
#[serde(default)]
pub schema_version: u32,
#[serde(default)]
pub project: ProjectPrefs,
}
/// Объединённый вид памяти для промпта (только непустые поля).
#[derive(Debug, Clone, Default)]
pub struct EngineeringMemory {
pub user: UserPrefs,
pub project: ProjectPrefs,
}
impl UserPrefs {
pub(crate) fn is_default(&self) -> bool {
self.preferred_style.is_empty()
&& self.ask_budget == 0
&& self.risk_tolerance.is_empty()
&& self.default_language.is_empty()
&& self.output_format.is_empty()
}
}
impl ProjectPrefs {
pub(crate) fn is_default(&self) -> bool {
self.default_test_command.is_empty()
&& self.default_lint_command.is_empty()
&& self.default_format_command.is_empty()
&& self.package_manager.is_empty()
&& self.build_command.is_empty()
&& self.src_roots.is_empty()
&& self.test_roots.is_empty()
&& self.ci_notes.is_empty()
}
}
/// Разрешённые ключи для memory_patch (dot-notation: user.*, project.*).
const MEMORY_PATCH_WHITELIST: &[&str] = &[
"user.preferred_style",
"user.ask_budget",
"user.risk_tolerance",
"user.default_language",
"user.output_format",
"project.default_test_command",
"project.default_lint_command",
"project.default_format_command",
"project.package_manager",
"project.build_command",
"project.src_roots",
"project.test_roots",
"project.ci_notes",
];
fn is_whitelisted(key: &str) -> bool {
MEMORY_PATCH_WHITELIST.contains(&key)
}
/// Загружает user prefs из файла (создаёт дефолт, если файла нет).
pub fn load_user_prefs(path: &Path) -> UserPrefs {
let s = match fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return UserPrefs::default(),
};
let file: PreferencesFile = match serde_json::from_str(&s) {
Ok(f) => f,
Err(_) => return UserPrefs::default(),
};
file.user
}
/// Загружает project prefs из .papa-yu/project.json (дефолт, если нет файла).
pub fn load_project_prefs(path: &Path) -> ProjectPrefs {
let s = match fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return ProjectPrefs::default(),
};
let file: ProjectPrefsFile = match serde_json::from_str(&s) {
Ok(f) => f,
Err(_) => return ProjectPrefs::default(),
};
file.project
}
/// Собирает объединённую память: user из user_prefs_path, project из project_prefs_path.
pub fn load_memory(user_prefs_path: &Path, project_prefs_path: &Path) -> EngineeringMemory {
let user = load_user_prefs(user_prefs_path);
let project = load_project_prefs(project_prefs_path);
EngineeringMemory { user, project }
}
/// Формирует текст MEMORY BLOCK для вставки в system prompt (~12 KB).
pub fn build_memory_block(mem: &EngineeringMemory) -> String {
if mem.user.is_default() && mem.project.is_default() {
return String::new();
}
let mut obj = serde_json::Map::new();
if !mem.user.is_default() {
let mut user = serde_json::Map::new();
if !mem.user.preferred_style.is_empty() {
user.insert("preferred_style".into(), serde_json::Value::String(mem.user.preferred_style.clone()));
}
if mem.user.ask_budget > 0 {
user.insert("ask_budget".into(), serde_json::Value::Number(serde_json::Number::from(mem.user.ask_budget)));
}
if !mem.user.risk_tolerance.is_empty() {
user.insert("risk_tolerance".into(), serde_json::Value::String(mem.user.risk_tolerance.clone()));
}
if !mem.user.default_language.is_empty() {
user.insert("default_language".into(), serde_json::Value::String(mem.user.default_language.clone()));
}
if !mem.user.output_format.is_empty() {
user.insert("output_format".into(), serde_json::Value::String(mem.user.output_format.clone()));
}
obj.insert("user".into(), serde_json::Value::Object(user));
}
if !mem.project.is_default() {
let mut project = serde_json::Map::new();
if !mem.project.default_test_command.is_empty() {
project.insert("default_test_command".into(), serde_json::Value::String(mem.project.default_test_command.clone()));
}
if !mem.project.default_lint_command.is_empty() {
project.insert("default_lint_command".into(), serde_json::Value::String(mem.project.default_lint_command.clone()));
}
if !mem.project.default_format_command.is_empty() {
project.insert("default_format_command".into(), serde_json::Value::String(mem.project.default_format_command.clone()));
}
if !mem.project.package_manager.is_empty() {
project.insert("package_manager".into(), serde_json::Value::String(mem.project.package_manager.clone()));
}
if !mem.project.build_command.is_empty() {
project.insert("build_command".into(), serde_json::Value::String(mem.project.build_command.clone()));
}
if !mem.project.src_roots.is_empty() {
project.insert("src_roots".into(), serde_json::to_value(&mem.project.src_roots).unwrap_or(serde_json::Value::Array(vec![])));
}
if !mem.project.test_roots.is_empty() {
project.insert("test_roots".into(), serde_json::to_value(&mem.project.test_roots).unwrap_or(serde_json::Value::Array(vec![])));
}
if !mem.project.ci_notes.is_empty() {
project.insert("ci_notes".into(), serde_json::Value::String(mem.project.ci_notes.clone()));
}
obj.insert("project".into(), serde_json::Value::Object(project));
}
if obj.is_empty() {
return String::new();
}
let json_str = serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default();
format!(
"\n\nENGINEERING_MEMORY (trusted by user; update only when user requests):\n{}\n\nUse ENGINEERING_MEMORY as defaults. If user explicitly asks to change — suggest updating memory and show new JSON.",
json_str
)
}
/// Применяет memory_patch (ключи через точку, только whitelist). Возвращает обновлённые user + project.
pub fn apply_memory_patch(
patch: &HashMap<String, serde_json::Value>,
current_user: &UserPrefs,
current_project: &ProjectPrefs,
) -> (UserPrefs, ProjectPrefs) {
let mut user = current_user.clone();
let mut project = current_project.clone();
for (key, value) in patch {
if !is_whitelisted(key) {
continue;
}
if key.starts_with("user.") {
let field = &key[5..];
match field {
"preferred_style" => if let Some(s) = value.as_str() { user.preferred_style = s.to_string(); },
"ask_budget" => if let Some(n) = value.as_u64() { user.ask_budget = n as u8; },
"risk_tolerance" => if let Some(s) = value.as_str() { user.risk_tolerance = s.to_string(); },
"default_language" => if let Some(s) = value.as_str() { user.default_language = s.to_string(); },
"output_format" => if let Some(s) = value.as_str() { user.output_format = s.to_string(); },
_ => {}
}
} else if key.starts_with("project.") {
let field = &key[8..];
match field {
"default_test_command" => if let Some(s) = value.as_str() { project.default_test_command = s.to_string(); },
"default_lint_command" => if let Some(s) = value.as_str() { project.default_lint_command = s.to_string(); },
"default_format_command" => if let Some(s) = value.as_str() { project.default_format_command = s.to_string(); },
"package_manager" => if let Some(s) = value.as_str() { project.package_manager = s.to_string(); },
"build_command" => if let Some(s) = value.as_str() { project.build_command = s.to_string(); },
"src_roots" => if let Some(arr) = value.as_array() {
project.src_roots = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
},
"test_roots" => if let Some(arr) = value.as_array() {
project.test_roots = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
},
"ci_notes" => if let Some(s) = value.as_str() { project.ci_notes = s.to_string(); },
_ => {}
}
}
}
(user, project)
}
/// Сохраняет user prefs в файл. Создаёт родительскую папку при необходимости.
pub fn save_user_prefs(path: &Path, user: &UserPrefs) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let file = PreferencesFile {
schema_version: SCHEMA_VERSION,
user: user.clone(),
};
let s = serde_json::to_string_pretty(&file).map_err(|e| e.to_string())?;
fs::write(path, s).map_err(|e| e.to_string())
}
/// Сохраняет project prefs в .papa-yu/project.json.
pub fn save_project_prefs(path: &Path, project: &ProjectPrefs) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let file = ProjectPrefsFile {
schema_version: SCHEMA_VERSION,
project: project.clone(),
};
let s = serde_json::to_string_pretty(&file).map_err(|e| e.to_string())?;
fs::write(path, s).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn whitelist_accepts_user_and_project() {
assert!(is_whitelisted("user.preferred_style"));
assert!(is_whitelisted("project.default_test_command"));
assert!(!is_whitelisted("session.foo"));
}
#[test]
fn apply_patch_updates_user_and_project() {
let mut patch = HashMap::new();
patch.insert("user.preferred_style".into(), serde_json::Value::String("brief".into()));
patch.insert("project.default_test_command".into(), serde_json::Value::String("pytest -q".into()));
let (user, project) = apply_memory_patch(&patch, &UserPrefs::default(), &ProjectPrefs::default());
assert_eq!(user.preferred_style, "brief");
assert_eq!(project.default_test_command, "pytest -q");
}
}

120
src-tauri/src/store.rs Normal file
View File

@ -0,0 +1,120 @@
//! v2.5: Projects & sessions store (JSON in app_data_dir).
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::types::{Project, ProjectSettings, Session, SessionEvent};
const PROJECTS_FILE: &str = "projects.json";
const PROFILES_FILE: &str = "project_profiles.json";
const SESSIONS_FILE: &str = "sessions.json";
const MAX_SESSIONS_PER_PROJECT: usize = 50;
const MAX_EVENTS_PER_SESSION: usize = 200;
pub fn load_projects(app_data_dir: &Path) -> Vec<Project> {
let p = app_data_dir.join(PROJECTS_FILE);
if let Ok(s) = fs::read_to_string(&p) {
if let Ok(v) = serde_json::from_str::<Vec<Project>>(&s) {
return v;
}
}
vec![]
}
pub fn save_projects(app_data_dir: &Path, projects: &[Project]) -> Result<(), String> {
fs::create_dir_all(app_data_dir).map_err(|e| e.to_string())?;
let p = app_data_dir.join(PROJECTS_FILE);
fs::write(
&p,
serde_json::to_string_pretty(projects).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())
}
pub fn load_profiles(app_data_dir: &Path) -> HashMap<String, ProjectSettings> {
let p = app_data_dir.join(PROFILES_FILE);
if let Ok(s) = fs::read_to_string(&p) {
if let Ok(m) = serde_json::from_str::<HashMap<String, ProjectSettings>>(&s) {
return m;
}
}
HashMap::new()
}
pub fn save_profiles(
app_data_dir: &Path,
profiles: &HashMap<String, ProjectSettings>,
) -> Result<(), String> {
fs::create_dir_all(app_data_dir).map_err(|e| e.to_string())?;
let p = app_data_dir.join(PROFILES_FILE);
fs::write(
&p,
serde_json::to_string_pretty(profiles).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())
}
pub fn load_sessions(app_data_dir: &Path) -> Vec<Session> {
let p = app_data_dir.join(SESSIONS_FILE);
if let Ok(s) = fs::read_to_string(&p) {
if let Ok(v) = serde_json::from_str::<Vec<Session>>(&s) {
return v;
}
}
vec![]
}
pub fn save_sessions(app_data_dir: &Path, sessions: &[Session]) -> Result<(), String> {
fs::create_dir_all(app_data_dir).map_err(|e| e.to_string())?;
let p = app_data_dir.join(SESSIONS_FILE);
fs::write(
&p,
serde_json::to_string_pretty(sessions).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())
}
pub fn add_session_event(
app_data_dir: &Path,
project_id: &str,
event: SessionEvent,
) -> Result<Session, String> {
let mut sessions = load_sessions(app_data_dir);
let now = chrono::Utc::now().to_rfc3339();
let idx = sessions
.iter()
.enumerate()
.filter(|(_, s)| s.project_id == project_id)
.max_by_key(|(_, s)| s.updated_at.as_str())
.map(|(i, _)| i);
if let Some(i) = idx {
let s = &mut sessions[i];
s.updated_at = now.clone();
s.events.push(event);
if s.events.len() > MAX_EVENTS_PER_SESSION {
let n = s.events.len() - MAX_EVENTS_PER_SESSION;
s.events.drain(..n);
}
save_sessions(app_data_dir, &sessions)?;
return Ok(sessions[i].clone());
}
let session_id = uuid::Uuid::new_v4().to_string();
let session = Session {
id: session_id.clone(),
project_id: project_id.to_string(),
created_at: now.clone(),
updated_at: now,
events: vec![event],
};
sessions.push(session.clone());
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
if sessions.len() > MAX_SESSIONS_PER_PROJECT * 10 {
sessions.truncate(MAX_SESSIONS_PER_PROJECT * 10);
}
save_sessions(app_data_dir, &sessions)?;
Ok(session)
}

107
src-tauri/src/tx/limits.rs Normal file
View File

@ -0,0 +1,107 @@
//! v2.3.4 Safe Guards: preflight checks and limits.
use std::path::Path;
use crate::types::{Action, ActionKind};
use crate::tx::safe_join;
pub const MAX_ACTIONS: usize = 50;
pub const MAX_FILES_TOUCHED: usize = 50;
pub const MAX_BYTES_WRITTEN: u64 = 2 * 1024 * 1024; // 2MB
pub const MAX_DIRS_CREATED: usize = 20;
pub const MAX_FILE_SIZE_UPDATE: u64 = 1024 * 1024; // 1MB for UpdateFile content
static FORBIDDEN_PREFIXES: &[&str] = &[
".git/",
"node_modules/",
"target/",
"dist/",
"build/",
".next/",
".cache/",
"coverage/",
];
pub const PRECHECK_DENIED: &str = "PRECHECK_DENIED";
pub const LIMIT_EXCEEDED: &str = "LIMIT_EXCEEDED";
pub const PATH_FORBIDDEN: &str = "PATH_FORBIDDEN";
/// Preflight: validate paths and limits. Returns Err((message, error_code)) on failure.
pub fn preflight_actions(root: &Path, actions: &[Action]) -> Result<(), (String, String)> {
if actions.len() > MAX_ACTIONS {
return Err((
format!("Превышен лимит действий: {} (макс. {})", actions.len(), MAX_ACTIONS),
LIMIT_EXCEEDED.into(),
));
}
let mut files_touched = 0usize;
let mut dirs_created = 0usize;
let mut total_bytes: u64 = 0;
for a in actions {
let rel = a.path.replace('\\', "/");
if rel.contains("..") {
return Err(("Путь не должен содержать ..".into(), PATH_FORBIDDEN.into()));
}
if Path::new(&rel).is_absolute() {
return Err(("Абсолютные пути запрещены".into(), PATH_FORBIDDEN.into()));
}
for prefix in FORBIDDEN_PREFIXES {
if rel.starts_with(prefix) || rel == prefix.trim_end_matches('/') {
return Err((
format!("Запрещённая зона: {}", rel),
PATH_FORBIDDEN.into(),
));
}
}
let abs = safe_join(root, &rel).map_err(|e| (e, PATH_FORBIDDEN.into()))?;
if abs.exists() && abs.is_symlink() {
return Err(("Симлинки не поддерживаются".into(), PRECHECK_DENIED.into()));
}
match a.kind {
ActionKind::CreateFile | ActionKind::UpdateFile => {
files_touched += 1;
let len = a.content.as_deref().map(|s| s.len() as u64).unwrap_or(0);
if a.kind == ActionKind::UpdateFile && len > MAX_FILE_SIZE_UPDATE {
return Err((
format!("Файл для обновления слишком большой: {} байт", len),
LIMIT_EXCEEDED.into(),
));
}
total_bytes += len;
}
ActionKind::CreateDir => {
dirs_created += 1;
}
ActionKind::DeleteFile => {
files_touched += 1;
}
ActionKind::DeleteDir => {}
}
}
if files_touched > MAX_FILES_TOUCHED {
return Err((
format!("Превышен лимит файлов: {} (макс. {})", files_touched, MAX_FILES_TOUCHED),
LIMIT_EXCEEDED.into(),
));
}
if dirs_created > MAX_DIRS_CREATED {
return Err((
format!("Превышен лимит создаваемых папок: {} (макс. {})", dirs_created, MAX_DIRS_CREATED),
LIMIT_EXCEEDED.into(),
));
}
if total_bytes > MAX_BYTES_WRITTEN {
return Err((
format!("Превышен лимит объёма записи: {} байт (макс. {})", total_bytes, MAX_BYTES_WRITTEN),
LIMIT_EXCEEDED.into(),
));
}
Ok(())
}

264
src-tauri/src/tx/mod.rs Normal file
View File

@ -0,0 +1,264 @@
mod limits;
mod store;
pub use limits::preflight_actions;
pub use store::{clear_redo, get_undo_redo_state, pop_redo, pop_undo, push_redo, push_undo};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde_json::json;
use tauri::{AppHandle, Manager};
use crate::types::{Action, ActionKind, TxManifest, TxTouchedItem};
pub fn user_data_dir(app: &AppHandle) -> PathBuf {
app.path().app_data_dir().expect("app_data_dir")
}
pub fn history_dir(app: &AppHandle) -> PathBuf {
user_data_dir(app).join("history")
}
pub fn tx_dir(app: &AppHandle, tx_id: &str) -> PathBuf {
history_dir(app).join(tx_id)
}
pub fn tx_manifest_path(app: &AppHandle, tx_id: &str) -> PathBuf {
tx_dir(app, tx_id).join("manifest.json")
}
pub fn tx_before_dir(app: &AppHandle, tx_id: &str) -> PathBuf {
tx_dir(app, tx_id).join("before")
}
pub fn ensure_history(app: &AppHandle) -> io::Result<()> {
fs::create_dir_all(history_dir(app))?;
Ok(())
}
pub fn new_tx_id() -> String {
format!("tx-{}", Utc::now().format("%Y%m%d-%H%M%S-%3f"))
}
pub fn write_manifest(app: &AppHandle, manifest: &TxManifest) -> io::Result<()> {
let tx_id = &manifest.tx_id;
fs::create_dir_all(tx_dir(app, tx_id))?;
let p = tx_manifest_path(app, tx_id);
let bytes = serde_json::to_vec_pretty(manifest).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(p, bytes)?;
Ok(())
}
pub fn read_manifest(app: &AppHandle, tx_id: &str) -> io::Result<TxManifest> {
let p = tx_manifest_path(app, tx_id);
let bytes = fs::read(p)?;
serde_json::from_slice(&bytes).map_err(|e| io::Error::new(io::ErrorKind::Other, e))
}
#[allow(dead_code)]
pub fn set_latest_tx(app: &AppHandle, tx_id: &str) -> io::Result<()> {
let p = history_dir(app).join("latest.json");
let bytes = serde_json::to_vec_pretty(&json!({ "txId": tx_id })).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(p, bytes)?;
Ok(())
}
#[allow(dead_code)]
pub fn clear_latest_tx(app: &AppHandle) -> io::Result<()> {
let p = history_dir(app).join("latest.json");
let _ = fs::remove_file(p);
Ok(())
}
#[allow(dead_code)]
pub fn get_latest_tx(app: &AppHandle) -> Option<String> {
let p = history_dir(app).join("latest.json");
let bytes = fs::read(p).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
v.get("txId")?.as_str().map(|s| s.to_string())
}
/// Safe join: root + relative (forbids absolute and "..")
pub fn safe_join(root: &Path, rel: &str) -> Result<PathBuf, String> {
let rp = Path::new(rel);
if rp.is_absolute() {
return Err("absolute paths forbidden".into());
}
if rel.contains("..") {
return Err("path traversal forbidden".into());
}
Ok(root.join(rp))
}
/// Snapshot: only copy existed files to before/; build touched (rel_path, kind, existed, bytes).
pub fn snapshot_before(
app: &AppHandle,
tx_id: &str,
root: &Path,
rel_paths: &[String],
) -> Result<Vec<TxTouchedItem>, String> {
let before = tx_before_dir(app, tx_id);
fs::create_dir_all(&before).map_err(|e| e.to_string())?;
let mut touched = vec![];
for rel in rel_paths {
let abs = safe_join(root, rel)?;
if abs.exists() && !abs.is_symlink() {
if abs.is_file() {
let bytes = fs::metadata(&abs).map(|m| m.len()).unwrap_or(0);
let dst = safe_join(&before, rel)?;
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(&abs, &dst).map_err(|e| e.to_string())?;
touched.push(TxTouchedItem {
rel_path: rel.clone(),
kind: "file".into(),
existed: true,
bytes,
});
} else if abs.is_dir() {
touched.push(TxTouchedItem {
rel_path: rel.clone(),
kind: "dir".into(),
existed: true,
bytes: 0,
});
}
} else {
touched.push(TxTouchedItem {
rel_path: rel.clone(),
kind: if rel.ends_with('/') || rel.is_empty() { "dir".into() } else { "file".into() },
existed: false,
bytes: 0,
});
}
}
Ok(touched)
}
/// Rollback tx: existed file -> restore from before; created file/dir -> remove; existed dir -> skip.
pub fn rollback_tx(app: &AppHandle, tx_id: &str) -> Result<(), String> {
let mut manifest = read_manifest(app, tx_id).map_err(|e| e.to_string())?;
let root = PathBuf::from(manifest.root_path.clone());
let before = tx_before_dir(app, tx_id);
let items: Vec<(String, String, bool)> = if !manifest.touched.is_empty() {
manifest.touched.iter().map(|t| (t.rel_path.clone(), t.kind.clone(), t.existed)).collect()
} else if let Some(ref snap) = manifest.snapshot_items {
snap.iter().map(|s| (s.rel_path.clone(), s.kind.clone(), s.existed)).collect()
} else {
return Err("manifest has no touched or snapshot_items".into());
};
for (rel, kind, existed) in items {
let abs = safe_join(&root, &rel)?;
let src = safe_join(&before, &rel).ok();
if existed {
if kind == "file" {
if let Some(ref s) = src {
if s.is_file() {
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(s, &abs).map_err(|e| e.to_string())?;
}
}
}
// existed dir: skip (nothing to restore)
} else {
if abs.is_file() {
let _ = fs::remove_file(&abs);
}
if abs.is_dir() {
let _ = fs::remove_dir_all(&abs);
}
}
}
manifest.status = "rolled_back".into();
let _ = write_manifest(app, &manifest);
Ok(())
}
/// Collect unique rel_paths from actions (for snapshot).
pub fn collect_rel_paths(actions: &[Action]) -> Vec<String> {
let mut paths: Vec<String> = actions.iter().map(|a| a.path.clone()).collect();
paths.sort();
paths.dedup();
paths
}
/// PAPAYU_NORMALIZE_EOL=lf — нормализовать \r\n→\n, добавить trailing newline.
pub fn normalize_content_for_write(content: &str, _path: &Path) -> String {
let mode = std::env::var("PAPAYU_NORMALIZE_EOL")
.map(|s| s.trim().to_lowercase())
.unwrap_or_else(|_| "keep".to_string());
if mode != "lf" {
return content.to_string();
}
let mut s = content.replace("\r\n", "\n").replace('\r', "\n");
if !s.is_empty() && !s.ends_with('\n') {
s.push('\n');
}
s
}
/// Apply a single action to disk (v2.3.3: for atomic apply + rollback on first failure).
pub fn apply_one_action(root: &Path, action: &Action) -> Result<(), String> {
let full = safe_join(root, &action.path)?;
match action.kind {
ActionKind::CreateFile | ActionKind::UpdateFile => {
if let Some(p) = full.parent() {
fs::create_dir_all(p).map_err(|e| e.to_string())?;
}
let content = action.content.as_deref().unwrap_or("");
let normalized = normalize_content_for_write(content, &full);
fs::write(&full, normalized).map_err(|e| e.to_string())?;
}
ActionKind::CreateDir => {
fs::create_dir_all(&full).map_err(|e| e.to_string())?;
}
ActionKind::DeleteFile => {
if full.exists() {
fs::remove_file(&full).map_err(|e| e.to_string())?;
}
}
ActionKind::DeleteDir => {
if full.is_dir() {
fs::remove_dir_all(&full).map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
/// Порядок применения: CREATE_DIR → CREATE_FILE/UPDATE_FILE → DELETE_FILE → DELETE_DIR.
pub fn sort_actions_for_apply(actions: &mut [Action]) {
fn order(k: &ActionKind) -> u8 {
match k {
ActionKind::CreateDir => 0,
ActionKind::CreateFile | ActionKind::UpdateFile => 1,
ActionKind::DeleteFile => 2,
ActionKind::DeleteDir => 3,
}
}
actions.sort_by_key(|a| (order(&a.kind), a.path.clone()));
}
/// Apply actions to disk (create/update/delete files and dirs).
/// Actions are sorted: CREATE_DIR → CREATE/UPDATE → DELETE_FILE → DELETE_DIR.
pub fn apply_actions_to_disk(root: &Path, actions: &[Action]) -> Result<(), String> {
let mut sorted: Vec<Action> = actions.to_vec();
sort_actions_for_apply(&mut sorted);
for a in &sorted {
apply_one_action(root, a)?;
}
Ok(())
}

80
src-tauri/src/tx/store.rs Normal file
View File

@ -0,0 +1,80 @@
//! Undo/redo stack in userData/history/state.json
use std::fs;
use std::io;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use super::history_dir;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct UndoRedoStateFile {
pub undo_stack: Vec<String>,
pub redo_stack: Vec<String>,
}
fn state_path(app: &AppHandle) -> PathBuf {
history_dir(app).join("state.json")
}
fn load_state(app: &AppHandle) -> UndoRedoStateFile {
let p = state_path(app);
if let Ok(bytes) = fs::read(&p) {
if let Ok(s) = serde_json::from_slice::<UndoRedoStateFile>(&bytes) {
return s;
}
}
UndoRedoStateFile::default()
}
fn save_state(app: &AppHandle, state: &UndoRedoStateFile) -> io::Result<()> {
let p = state_path(app);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent)?;
}
let bytes = serde_json::to_vec_pretty(state).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(p, bytes)
}
pub fn push_undo(app: &AppHandle, tx_id: String) -> io::Result<()> {
let mut state = load_state(app);
state.undo_stack.push(tx_id);
state.redo_stack.clear();
save_state(app, &state)
}
pub fn pop_undo(app: &AppHandle) -> Option<String> {
let mut state = load_state(app);
let tx_id = state.undo_stack.pop()?;
save_state(app, &state).ok()?;
Some(tx_id)
}
pub fn push_redo(app: &AppHandle, tx_id: String) -> io::Result<()> {
let mut state = load_state(app);
state.redo_stack.push(tx_id);
save_state(app, &state)
}
pub fn pop_redo(app: &AppHandle) -> Option<String> {
let mut state = load_state(app);
let tx_id = state.redo_stack.pop()?;
save_state(app, &state).ok()?;
Some(tx_id)
}
pub fn clear_redo(app: &AppHandle) -> io::Result<()> {
let mut state = load_state(app);
state.redo_stack.clear();
save_state(app, &state)
}
pub fn get_undo_redo_state(app: &AppHandle) -> (bool, bool) {
let state = load_state(app);
(
!state.undo_stack.is_empty(),
!state.redo_stack.is_empty(),
)
}

509
src-tauri/src/types.rs Normal file
View File

@ -0,0 +1,509 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Action {
pub kind: ActionKind,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ActionKind {
CreateFile,
CreateDir,
UpdateFile,
DeleteFile,
DeleteDir,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyPayload {
pub root_path: String,
pub actions: Vec<Action>,
#[serde(default)]
pub auto_check: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// v2.4.2: обязательное подтверждение перед apply
#[serde(default)]
pub user_confirmed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub tx_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub applied_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub failed_at: Option<usize>, // v2.3.3: index where apply failed (before rollback)
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxTouchedItem {
pub rel_path: String,
pub kind: String, // "file" | "dir"
pub existed: bool,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxManifest {
pub tx_id: String,
pub root_path: String,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub status: String, // "pending" | "committed" | "rolled_back"
#[serde(default)]
pub applied_actions: Vec<Action>,
#[serde(default)]
pub touched: Vec<TxTouchedItem>,
#[serde(default)]
pub auto_check: bool,
/// Legacy: old manifests had snapshot_items only
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_items: Option<Vec<TxSnapshotItem>>,
}
/// Legacy alias for rollback reading old manifests
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxSnapshotItem {
pub rel_path: String,
pub kind: String,
pub existed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UndoResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub tx_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UndoAvailableResult {
pub ok: bool,
pub available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub tx_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedoResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub tx_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UndoRedoState {
pub undo_available: bool,
pub redo_available: bool,
}
/// v2.4: action with metadata for plan
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionItem {
pub id: String,
pub kind: ActionKind,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
pub summary: String,
pub rationale: String,
pub tags: Vec<String>,
pub risk: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionPlan {
pub plan_id: String,
pub root_path: String,
pub title: String,
pub actions: Vec<ActionItem>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateActionsPayload {
pub path: String,
pub selected: Vec<String>,
pub mode: String, // "safe" | "balanced"
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffItem {
pub kind: String,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_content: Option<String>,
/// v2.4.2: BLOCKED — защищённый/не-текстовый файл
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreviewResult {
pub diffs: Vec<DiffItem>,
pub summary: String,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyzePayload {
pub paths: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub title: String,
pub details: String,
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recommendation {
pub title: String,
pub details: String,
pub priority: Option<String>,
pub effort: Option<String>,
pub impact: Option<String>,
}
/// v2.9.2: сигнал по проекту (категория + уровень для recommended_pack_ids)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectSignal {
pub category: String, // "security" | "quality" | "structure"
pub level: String, // "warn" | "high" | "critical"
}
/// v2.9.1: группа действий (readme, gitignore, tests, …)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionGroup {
pub id: String,
pub title: String,
pub description: String,
pub actions: Vec<Action>,
}
/// v2.9.2: пакет улучшений (security, quality, structure)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixPack {
pub id: String,
pub title: String,
pub description: String,
pub group_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyzeReport {
pub path: String,
pub narrative: String,
pub findings: Vec<Finding>,
pub recommendations: Vec<Recommendation>,
pub actions: Vec<Action>,
#[serde(default)]
pub action_groups: Vec<ActionGroup>,
#[serde(default)]
pub fix_packs: Vec<FixPack>,
#[serde(default)]
pub recommended_pack_ids: Vec<String>,
/// v2.4.5: прикреплённые файлы, переданные при анализе (контекст для UI/планировщика)
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub attached_files: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchPayload {
pub paths: Vec<String>,
pub confirm_apply: bool,
pub auto_check: bool,
pub selected_actions: Option<Vec<Action>>,
/// v2.4.2: передаётся в ApplyPayload при confirm_apply
#[serde(default)]
pub user_confirmed: bool,
/// v2.4.5: прикреплённые файлы для контекста при анализе
#[serde(default)]
pub attached_files: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchEvent {
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub report: Option<AnalyzeReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview: Option<PreviewResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub apply_result: Option<ApplyResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub undo_available: Option<bool>,
}
/// v2.9.3: транзакционное применение (path + actions + auto_check)
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionSpec {
pub path: String,
pub actions: Vec<Action>,
pub auto_check: bool,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub tx_id: Option<String>,
pub applied_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UndoStatus {
pub available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub tx_id: Option<String>,
}
/// v3.0: сообщение агента (user / system / assistant)
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentMessage {
pub role: String,
pub text: String,
}
/// v3.0: план агента (propose_actions)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentPlan {
pub ok: bool,
pub summary: String,
pub actions: Vec<Action>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
/// JSON плана для передачи в Apply (при Plan-режиме).
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_json: Option<String>,
/// Собранный контекст для передачи в Apply вместе с plan_json.
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_context: Option<String>,
}
/// v3.1: опции применения (auto_check). v2.4.2: user_confirmed для apply_actions_tx.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyOptions {
pub auto_check: bool,
#[serde(default)]
pub user_confirmed: bool,
}
/// v3.1: результат этапа проверки (verify / build / smoke)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckStageResult {
pub stage: String,
pub ok: bool,
pub output: String,
}
/// v3.1: результат транзакционного apply с авто-проверкой и откатом
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyTxResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub tx_id: Option<String>,
pub applied: bool,
pub rolled_back: bool,
pub checks: Vec<CheckStageResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
/// v3.2: результат генерации действий из отчёта (generate_actions_from_report)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateActionsResult {
pub ok: bool,
pub actions: Vec<Action>,
#[serde(default)]
pub skipped: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
// --- v2.4 Agentic Loop ---
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgenticConstraints {
pub auto_check: bool,
pub max_attempts: u8,
pub max_actions: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgenticRunRequest {
pub path: String,
pub goal: String,
pub constraints: AgenticConstraints,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckItem {
pub name: String,
pub ok: bool,
pub output: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyResult {
pub ok: bool,
pub checks: Vec<CheckItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttemptResult {
pub attempt: u8,
pub plan: String,
pub actions: Vec<Action>,
pub preview: PreviewResult,
pub apply: ApplyTxResult,
pub verify: VerifyResult,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgenticRunResult {
pub ok: bool,
pub attempts: Vec<AttemptResult>,
pub final_summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
// --- Тренды и рекомендации (мониторинг не реже раз в месяц) ---
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrendsRecommendation {
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrendsResult {
pub last_updated: String,
pub recommendations: Vec<TrendsRecommendation>,
/// true если прошло >= 30 дней с last_updated — рекомендуется обновить
pub should_update: bool,
}
// --- Projects & sessions (v2.5: entities, history, profiles) ---
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
pub path: String,
pub name: String,
pub created_at: String,
}
/// v2.5: сохранённые настройки проекта (store)
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProjectSettings {
pub project_id: String,
#[serde(default)]
pub auto_check: bool,
#[serde(default)]
pub max_attempts: u8,
#[serde(default)]
pub max_actions: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub goal_template: Option<String>,
}
// --- v2.4.3: detected profile (by path) ---
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ProjectType {
ReactVite,
NextJs,
Node,
Rust,
Python,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectLimits {
pub max_files: u32,
pub timeout_sec: u32,
pub max_actions_per_tx: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectProfile {
pub path: String,
pub project_type: ProjectType,
pub safe_mode: bool,
pub max_attempts: u32,
pub goal_template: String,
pub limits: ProjectLimits,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEvent {
pub kind: String, // "message" | "analyze" | "agentic_run" | "apply"
pub role: Option<String>,
pub text: Option<String>,
pub at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub project_id: String,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub events: Vec<SessionEvent>,
}

200
src-tauri/src/verify.rs Normal file
View File

@ -0,0 +1,200 @@
//! v2.4: verify_project — проверка сборки/типов после apply (allowlisted, timeout 60s).
//! v2.4.5: allowlist команд загружается из config/verify_allowlist.json (или встроенный дефолт).
use std::collections::HashMap;
use std::path::Path;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use crate::types::{CheckItem, VerifyResult};
/// Одна разрешённая команда из конфига.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct VerifyAllowlistEntry {
pub exe: String,
pub args: Vec<String>,
pub name: String,
#[serde(default)]
pub timeout_sec: Option<u64>,
}
fn default_timeout() -> u64 {
60
}
fn load_verify_allowlist() -> HashMap<String, Vec<VerifyAllowlistEntry>> {
const DEFAULT_JSON: &str = include_str!("../config/verify_allowlist.json");
serde_json::from_str(DEFAULT_JSON).unwrap_or_else(|_| HashMap::new())
}
fn run_check(cwd: &Path, exe: &str, args: &[&str], name: &str, timeout_secs: u64) -> CheckItem {
let timeout = Duration::from_secs(timeout_secs);
let mut cmd = Command::new(exe);
cmd.args(args)
.current_dir(cwd)
.env("CI", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let (ok, output_str) = match cmd.spawn() {
Ok(mut child) => {
let start = Instant::now();
let result = loop {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
break (false, format!("TIMEOUT ({}s)", timeout_secs));
}
match child.try_wait() {
Ok(Some(_status)) => {
let out = child.wait_with_output();
let (success, combined) = match out {
Ok(o) => {
let out_str = String::from_utf8_lossy(&o.stdout);
let err_str = String::from_utf8_lossy(&o.stderr);
let combined = format!("{}{}", out_str, err_str);
let combined = if combined.len() > 8000 {
format!("{}", &combined[..8000])
} else {
combined
};
(o.status.success(), combined)
}
Err(e) => (false, e.to_string()),
};
break (success, combined);
}
Ok(None) => {
thread::sleep(Duration::from_millis(100));
}
Err(e) => break (false, e.to_string()),
}
};
result
}
Err(e) => (false, e.to_string()),
};
CheckItem {
name: name.to_string(),
ok,
output: output_str,
}
}
/// Определение типа проекта по наличию файлов в корне.
fn project_type(root: &Path) -> &'static str {
if root.join("Cargo.toml").exists() {
return "rust";
}
if root.join("package.json").exists() {
return "node";
}
if root.join("setup.py").exists() || root.join("pyproject.toml").exists() {
return "python";
}
"unknown"
}
/// Выполняет одну команду из allowlist (exe + args из конфига).
fn run_check_from_entry(cwd: &Path, entry: &VerifyAllowlistEntry) -> CheckItem {
let timeout = entry.timeout_sec.unwrap_or_else(default_timeout);
let args: Vec<&str> = entry.args.iter().map(|s| s.as_str()).collect();
run_check(cwd, &entry.exe, &args, &entry.name, timeout)
}
/// v2.4: проверка проекта после apply. Allowlist из config/verify_allowlist.json.
pub fn verify_project(path: &str) -> VerifyResult {
let root = Path::new(path);
if !root.exists() || !root.is_dir() {
return VerifyResult {
ok: false,
checks: vec![],
error: Some("path not found".to_string()),
error_code: Some("PATH_NOT_FOUND".into()),
};
}
let pt = project_type(root);
let allowlist = load_verify_allowlist();
let mut checks: Vec<CheckItem> = vec![];
match pt {
"rust" => {
if let Some(entries) = allowlist.get("rust") {
if let Some(entry) = entries.first() {
checks.push(run_check_from_entry(root, entry));
}
}
if checks.is_empty() {
checks.push(run_check(root, "cargo", &["check"], "cargo check", 60));
}
}
"node" => {
let (exe, args, name): (String, Vec<String>, String) = {
let pkg = root.join("package.json");
if pkg.exists() {
if let Ok(s) = std::fs::read_to_string(&pkg) {
if s.contains("\"test\"") {
("npm".into(), vec!["run".into(), "-s".into(), "test".into()], "npm test".into())
} else if s.contains("\"build\"") {
("npm".into(), vec!["run".into(), "-s".into(), "build".into()], "npm run build".into())
} else if s.contains("\"lint\"") {
("npm".into(), vec!["run".into(), "-s".into(), "lint".into()], "npm run lint".into())
} else {
("npm".into(), vec!["run".into(), "-s".into(), "build".into()], "npm run build".into())
}
} else {
("npm".into(), vec!["run".into(), "-s".into(), "build".into()], "npm run build".into())
}
} else {
("npm".into(), vec!["run".into(), "-s".into(), "build".into()], "npm run build".into())
}
};
let allowed = allowlist.get("node").and_then(|entries| {
entries.iter().find(|e| e.exe == exe && e.args == args)
});
let timeout = allowed.and_then(|e| e.timeout_sec).unwrap_or(60);
let name_str = allowed.map(|e| e.name.as_str()).unwrap_or(name.as_str());
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
checks.push(run_check(root, &exe, &args_ref, name_str, timeout));
}
"python" => {
if let Some(entries) = allowlist.get("python") {
if let Some(entry) = entries.first() {
checks.push(run_check_from_entry(root, entry));
}
}
if checks.is_empty() {
checks.push(run_check(
root,
"python3",
&["-m", "compileall", ".", "-q"],
"python -m compileall",
60,
));
}
}
_ => {
return VerifyResult {
ok: true,
checks: vec![],
error: None,
error_code: None,
};
}
}
let ok = checks.iter().all(|c| c.ok);
VerifyResult {
ok,
checks,
error: if ok {
None
} else {
Some("verify failed".to_string())
},
error_code: if ok { None } else { Some("VERIFY_FAILED".into()) },
}
}

37
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,37 @@
{
"productName": "PAPA YU",
"version": "2.4.4",
"identifier": "com.papa-yu.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "PAPA YU",
"label": "main",
"width": 1024,
"height": 720,
"minWidth": 1024,
"minHeight": 720,
"resizable": true
}
]
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/icon.png"],
"resources": [],
"externalBin": [],
"copyright": "",
"category": "DeveloperTool",
"shortDescription": "PAPA YU",
"longDescription": "PAPA YU — анализ проекта и автоматические исправления"
},
"plugins": {}
}

90
src/App.tsx Normal file
View File

@ -0,0 +1,90 @@
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import Tasks from "./pages/Tasks";
import Dashboard from "./pages/Dashboard";
function Layout({ children }: { children: React.ReactNode }) {
return (
<div style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>
<header
style={{
padding: "14px 24px",
borderBottom: "1px solid var(--color-border)",
background: "linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%)",
boxShadow: "0 2px 8px rgba(37, 99, 235, 0.2)",
display: "flex",
alignItems: "center",
gap: "16px",
}}
>
<img
src="/logo.png"
alt="PAPAYU"
style={{
height: "44px",
width: "auto",
objectFit: "contain",
filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.2))",
}}
/>
<span
style={{
fontWeight: 700,
fontSize: "20px",
color: "#fff",
letterSpacing: "-0.02em",
textShadow: "0 1px 2px rgba(0,0,0,0.15)",
}}
>
PAPA YU
</span>
<nav style={{ display: "flex", gap: "6px", marginLeft: "28px" }}>
<NavLink
to="/"
end
style={({ isActive }) => ({
padding: "10px 18px",
borderRadius: "999px",
fontWeight: 600,
fontSize: "14px",
textDecoration: "none",
color: isActive ? "#1e3a5f" : "rgba(255,255,255,0.9)",
background: isActive ? "#fff" : "rgba(255,255,255,0.15)",
transition: "background 0.2s ease, color 0.2s ease",
})}
>
Задачи
</NavLink>
<NavLink
to="/panel"
style={({ isActive }) => ({
padding: "10px 18px",
borderRadius: "999px",
fontWeight: 600,
fontSize: "14px",
textDecoration: "none",
color: isActive ? "#1e3a5f" : "rgba(255,255,255,0.9)",
background: isActive ? "#fff" : "rgba(255,255,255,0.15)",
transition: "background 0.2s ease, color 0.2s ease",
})}
>
Панель управления
</NavLink>
</nav>
</header>
<main style={{ flex: 1, padding: "24px", overflow: "visible", minHeight: 0 }}>{children}</main>
</div>
);
}
export default function App() {
return (
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<Tasks />} />
<Route path="/panel" element={<Dashboard />} />
</Routes>
</Layout>
</BrowserRouter>
);
}

179
src/index.css Normal file
View File

@ -0,0 +1,179 @@
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-light: #eff6ff;
--color-secondary: #0d9488;
--color-secondary-hover: #0f766e;
--color-accent: #7c3aed;
--color-accent-hover: #6d28d9;
--color-success: #16a34a;
--color-success-hover: #15803d;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--color-warning: #f59e0b;
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-text-soft: #94a3b8;
--color-surface: #ffffff;
--color-bg: #f1f5f9;
--color-bg-warm: #f8fafc;
--color-border: #e2e8f0;
--color-border-strong: #cbd5e1;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
--transition: 0.2s ease;
}
/* Dark Theme */
[data-theme="dark"] {
--color-primary: #3b82f6;
--color-primary-hover: #60a5fa;
--color-primary-light: #1e3a5f;
--color-secondary: #14b8a6;
--color-secondary-hover: #2dd4bf;
--color-accent: #a78bfa;
--color-accent-hover: #c4b5fd;
--color-success: #22c55e;
--color-success-hover: #4ade80;
--color-danger: #ef4444;
--color-danger-hover: #f87171;
--color-warning: #fbbf24;
--color-text: #f1f5f9;
--color-text-muted: #94a3b8;
--color-text-soft: #64748b;
--color-surface: #1e293b;
--color-bg: #0f172a;
--color-bg-warm: #1e293b;
--color-border: #334155;
--color-border-strong: #475569;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--color-text);
background: linear-gradient(160deg, var(--color-bg-warm) 0%, var(--color-border) 50%, var(--color-bg) 100%);
min-height: 100vh;
transition: background var(--transition), color var(--transition);
}
[data-theme="dark"] body {
background: linear-gradient(160deg, var(--color-bg-warm) 0%, #0f172a 50%, var(--color-bg) 100%);
}
#root {
min-height: 100vh;
}
button {
cursor: pointer;
font: inherit;
transition: background-color var(--transition), transform var(--transition), box-shadow var(--transition);
}
button:hover:not(:disabled) {
transform: translateY(-1px);
}
button:active:not(:disabled) {
transform: translateY(0);
}
button:disabled {
cursor: not-allowed;
opacity: 0.7;
}
input,
textarea {
font: inherit;
transition: border-color var(--transition), box-shadow var(--transition);
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.card {
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
border: 1px solid var(--color-border);
}
/* Блок выбора папки на странице Задачи — всегда видим */
.tasks-sources[data-section="path-selection"] {
display: block !important;
visibility: visible !important;
}
/* Theme Toggle */
.theme-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 13px;
color: var(--color-text-muted);
transition: all var(--transition);
}
.theme-toggle:hover {
background: var(--color-bg-warm);
border-color: var(--color-border-strong);
color: var(--color-text);
}
.theme-toggle-icon {
font-size: 16px;
}
/* Dark theme specific overrides */
[data-theme="dark"] input,
[data-theme="dark"] textarea {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
[data-theme="dark"] input::placeholder,
[data-theme="dark"] textarea::placeholder {
color: var(--color-text-soft);
}
[data-theme="dark"] input:focus,
[data-theme="dark"] textarea:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
}
[data-theme="dark"] select {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
[data-theme="dark"] code,
[data-theme="dark"] pre {
background: #0f172a;
color: #e2e8f0;
}

221
src/lib/tauri.ts Normal file
View File

@ -0,0 +1,221 @@
import { invoke } from "@tauri-apps/api/core";
import type {
Action,
AgenticRunRequest,
AgenticRunResult,
AnalyzeReport,
ApplyTxResult,
BatchEvent,
GenerateActionsResult,
PreviewResult,
ProjectProfile,
Session,
TrendsResult,
UndoStatus,
VerifyResult,
} from "./types";
export interface UndoRedoState {
undo_available: boolean;
redo_available: boolean;
}
export interface RunBatchPayload {
paths: string[];
confirm_apply: boolean;
auto_check: boolean;
selected_actions?: Action[];
user_confirmed?: boolean;
attached_files?: string[];
}
export interface ApplyActionsTxOptions {
auto_check: boolean;
user_confirmed: boolean;
}
export interface ProjectItem {
id: string;
path: string;
}
export interface AddProjectResult {
id: string;
}
export interface UndoLastResult {
ok: boolean;
error_code?: string;
error?: string;
}
export async function getUndoRedoState(): Promise<UndoRedoState> {
return invoke<UndoRedoState>("get_undo_redo_state_cmd");
}
export async function getUndoStatus(): Promise<UndoStatus> {
return invoke<UndoStatus>("undo_status").catch(() => ({ available: false } as UndoStatus));
}
export async function getFolderLinks(): Promise<{ paths: string[] }> {
return invoke<{ paths: string[] }>("get_folder_links");
}
export async function setFolderLinks(paths: string[]): Promise<void> {
return invoke("set_folder_links", { links: { paths } });
}
export async function getProjectProfile(path: string): Promise<ProjectProfile> {
return invoke<ProjectProfile>("get_project_profile", { path });
}
export async function runBatchCmd(payload: RunBatchPayload): Promise<BatchEvent[]> {
return invoke<BatchEvent[]>("run_batch_cmd", { payload });
}
/** Предпросмотр diff для actions (CREATE/UPDATE/DELETE) без записи на диск. */
export async function previewActions(rootPath: string, actions: Action[]): Promise<PreviewResult> {
return invoke<PreviewResult>("preview_actions_cmd", {
payload: {
root_path: rootPath,
actions,
auto_check: null,
label: null,
user_confirmed: false,
},
});
}
export async function applyActionsTx(
path: string,
actions: Action[],
options: ApplyActionsTxOptions | boolean
): Promise<ApplyTxResult> {
const opts: ApplyActionsTxOptions =
typeof options === "boolean"
? { auto_check: options, user_confirmed: true }
: options;
return invoke<ApplyTxResult>("apply_actions_tx", {
path,
actions,
options: opts,
});
}
export async function generateActionsFromReport(
path: string,
report: AnalyzeReport,
mode: string
): Promise<GenerateActionsResult> {
return invoke<GenerateActionsResult>("generate_actions_from_report", {
path,
report,
mode,
});
}
export async function agenticRun(payload: AgenticRunRequest): Promise<AgenticRunResult> {
return invoke<AgenticRunResult>("agentic_run", { payload });
}
export async function listProjects(): Promise<ProjectItem[]> {
return invoke<ProjectItem[]>("list_projects");
}
export async function addProject(path: string, name: string | null): Promise<AddProjectResult> {
return invoke<AddProjectResult>("add_project", { path, name });
}
export async function listSessions(projectId?: string): Promise<Session[]> {
return invoke<Session[]>("list_sessions", { projectId: projectId ?? null });
}
export async function appendSessionEvent(
projectId: string,
kind: string,
role: string,
text: string
): Promise<void> {
return invoke("append_session_event", {
project_id: projectId,
kind,
role,
text,
});
}
export interface AgentPlanResult {
ok: boolean;
summary: string;
actions: Action[];
error?: string;
error_code?: string;
plan_json?: string;
plan_context?: string;
}
export async function proposeActions(
path: string,
reportJson: string,
userGoal: string,
designStyle?: string | null,
trendsContext?: string | null,
lastPlanJson?: string | null,
lastContext?: string | null
): Promise<AgentPlanResult> {
return invoke<AgentPlanResult>("propose_actions", {
path,
reportJson,
userGoal,
designStyle: designStyle ?? null,
trendsContext: trendsContext ?? null,
lastPlanJson: lastPlanJson ?? null,
lastContext: lastContext ?? null,
});
}
export async function undoLastTx(path: string): Promise<boolean> {
return invoke<boolean>("undo_last_tx", { path });
}
export async function undoLast(): Promise<UndoLastResult> {
return invoke<UndoLastResult>("undo_last");
}
export async function redoLast(): Promise<UndoLastResult> {
return invoke<UndoLastResult>("redo_last");
}
/** Проверка целостности проекта (типы, сборка, тесты). Вызывается автоматически после применений или вручную. */
export async function verifyProject(path: string): Promise<VerifyResult> {
return invoke<VerifyResult>("verify_project", { path });
}
/** Тренды и рекомендации: последнее обновление и список. should_update === true если прошло >= 30 дней. */
export async function getTrendsRecommendations(): Promise<TrendsResult> {
return invoke<TrendsResult>("get_trends_recommendations");
}
/** Обновить тренды и рекомендации (запрос к внешним ресурсам по allowlist). */
export async function fetchTrendsRecommendations(): Promise<TrendsResult> {
return invoke<TrendsResult>("fetch_trends_recommendations");
}
// Settings export/import
export interface ImportResult {
projects_imported: number;
profiles_imported: number;
sessions_imported: number;
folder_links_imported: number;
}
/** Export all settings as JSON string */
export async function exportSettings(): Promise<string> {
return invoke<string>("export_settings");
}
/** Import settings from JSON string */
export async function importSettings(json: string, mode?: "replace" | "merge"): Promise<ImportResult> {
return invoke<ImportResult>("import_settings", { json, mode: mode ?? "merge" });
}

207
src/lib/types.ts Normal file
View File

@ -0,0 +1,207 @@
export interface Action {
kind: string;
path: string;
content?: string;
}
export interface Finding {
title: string;
details: string;
path?: string;
}
export interface ActionGroup {
id: string;
title: string;
description: string;
actions: Action[];
}
export interface FixPack {
id: string;
title: string;
description: string;
group_ids: string[];
}
export interface AnalyzeReport {
path: string;
narrative: string;
findings: Finding[];
recommendations: unknown[];
actions: Action[];
action_groups?: ActionGroup[];
fix_packs?: FixPack[];
recommended_pack_ids?: string[];
}
export interface DiffItem {
kind: string;
path: string;
old_content?: string;
new_content?: string;
/** v2.4.2: BLOCKED — защищённый/не-текстовый файл */
summary?: string;
}
export interface PreviewResult {
diffs: DiffItem[];
summary: string;
}
export interface ApplyResult {
ok: boolean;
tx_id?: string;
error?: string;
error_code?: string;
}
/** v2.9.3: доступен ли откат транзакции */
export interface UndoStatus {
available: boolean;
tx_id?: string;
}
/** v3.0: план агента (propose_actions) */
export interface AgentPlan {
ok: boolean;
summary: string;
actions: Action[];
error?: string;
error_code?: string;
/** JSON плана для передачи в Apply */
plan_json?: string;
/** Собранный контекст для Apply */
plan_context?: string;
}
/** Тренды и рекомендации (мониторинг не реже раз в месяц) */
export interface TrendsRecommendation {
title: string;
summary?: string;
url?: string;
source?: string;
}
export interface TrendsResult {
last_updated: string;
recommendations: TrendsRecommendation[];
should_update: boolean;
}
/** v3.1: результат apply_actions_tx с autocheck и откатом */
export interface ApplyTxResult {
ok: boolean;
tx_id?: string | null;
applied: boolean;
rolled_back: boolean;
checks: { stage: string; ok: boolean; output: string }[];
error?: string;
error_code?: string;
}
/** v3.2: результат generate_actions_from_report */
export interface GenerateActionsResult {
ok: boolean;
actions: Action[];
skipped: string[];
error?: string;
error_code?: string;
}
/** v2.4: Agentic Loop */
export interface AgenticConstraints {
auto_check: boolean;
max_attempts: number;
max_actions: number;
}
export interface AgenticRunRequest {
path: string;
goal: string;
constraints: AgenticConstraints;
}
export interface CheckItem {
name: string;
ok: boolean;
output: string;
}
export interface VerifyResult {
ok: boolean;
checks: CheckItem[];
error?: string;
error_code?: string;
}
export interface AttemptResult {
attempt: number;
plan: string;
actions: Action[];
preview: PreviewResult;
apply: ApplyTxResult;
verify: VerifyResult;
}
export interface AgenticRunResult {
ok: boolean;
attempts: AttemptResult[];
final_summary: string;
error?: string;
error_code?: string;
}
/** v2.4.3: detected profile (by path) */
export type ProjectType = "react_vite" | "next_js" | "node" | "rust" | "python" | "unknown";
export interface ProjectLimits {
max_files: number;
timeout_sec: number;
max_actions_per_tx: number;
}
export interface ProjectProfile {
path: string;
project_type: ProjectType;
safe_mode: boolean;
max_attempts: number;
goal_template: string;
limits: ProjectLimits;
}
export interface BatchEvent {
kind: string;
report?: AnalyzeReport;
preview?: PreviewResult;
apply_result?: ApplyResult;
message?: string;
undo_available?: boolean;
}
export type ChatRole = "system" | "user" | "assistant";
export interface ChatMessage {
role: ChatRole;
text: string;
report?: AnalyzeReport;
preview?: PreviewResult;
applyResult?: ApplyResult;
}
/** Событие сессии (agentic_run, message, analyze, apply) */
export interface SessionEvent {
kind: string;
role?: string;
text?: string;
at: string;
}
/** Сессия по проекту */
export interface Session {
id: string;
project_id: string;
created_at: string;
updated_at: string;
events: SessionEvent[];
}

57
src/lib/useTheme.ts Normal file
View File

@ -0,0 +1,57 @@
import { useState, useEffect, useCallback } from "react";
type Theme = "light" | "dark";
const STORAGE_KEY = "papa_yu_theme";
function getInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
// Check localStorage first
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "dark" || stored === "light") {
return stored;
}
// Check system preference
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
useEffect(() => {
// Apply theme to document
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
useEffect(() => {
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => {
const stored = localStorage.getItem(STORAGE_KEY);
// Only auto-switch if user hasn't explicitly set a preference
if (!stored) {
setThemeState(e.matches ? "dark" : "light");
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
const toggleTheme = useCallback(() => {
setThemeState((prev) => (prev === "light" ? "dark" : "light"));
}, []);
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
}, []);
return { theme, toggleTheme, setTheme, isDark: theme === "dark" };
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

111
src/pages/Dashboard.tsx Normal file
View File

@ -0,0 +1,111 @@
import { Link } from "react-router-dom";
const sectionStyle: React.CSSProperties = {
marginBottom: "24px",
padding: "20px 24px",
background: "#fff",
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-sm)",
};
const headingStyle: React.CSSProperties = {
marginBottom: "12px",
fontSize: "16px",
fontWeight: 700,
color: "#1e3a5f",
letterSpacing: "-0.01em",
};
const textStyle: React.CSSProperties = {
color: "var(--color-text)",
marginBottom: "8px",
lineHeight: 1.6,
fontSize: "14px",
};
const listStyle: React.CSSProperties = {
margin: "8px 0 0 0",
paddingLeft: "20px",
lineHeight: 1.7,
color: "var(--color-text-muted)",
fontSize: "13px",
};
export default function Dashboard() {
return (
<div
style={{
maxWidth: 640,
margin: "0 auto",
}}
>
<h1 style={{ marginBottom: "24px", fontSize: "24px", fontWeight: 700, color: "#1e3a5f", letterSpacing: "-0.02em" }}>
Панель управления
</h1>
<section style={sectionStyle}>
<h2 style={headingStyle}>Настройки программы</h2>
<p style={textStyle}>
<strong>PAPA YU</strong> написание программ под ключ, анализ и исправление с улучшениями. Ниже отображаются параметры и подсказки по настройке.
</p>
</section>
<section style={sectionStyle}>
<h2 style={headingStyle}>Подключение ИИ (LLM)</h2>
<p style={textStyle}>
Рекомендации и задачи ИИ работают при заданных переменных окружения. Задайте их в файле <code style={{ background: "#f1f5f9", padding: "2px 6px", borderRadius: "4px", fontSize: "12px" }}>.env</code> или в скрипте запуска:
</p>
<ul style={listStyle}>
<li><strong>PAPAYU_LLM_API_URL</strong> URL API (например OpenAI или Ollama)</li>
<li><strong>PAPAYU_LLM_API_KEY</strong> API-ключ (для OpenAI обязателен)</li>
<li><strong>PAPAYU_LLM_MODEL</strong> модель (например gpt-4o-mini, llama3.2)</li>
</ul>
<p style={{ ...textStyle, marginTop: "12px", fontSize: "13px", color: "var(--color-text-muted)" }}>
Запуск с OpenAI: используйте скрипт <code style={{ background: "#f1f5f9", padding: "2px 6px", borderRadius: "4px" }}>start-with-openai.sh</code> или задайте переменные вручную.
</p>
</section>
<section style={sectionStyle}>
<h2 style={headingStyle}>Поведение по умолчанию</h2>
<p style={textStyle}>
Для каждого проекта можно задать:
</p>
<ul style={listStyle}>
<li><strong>Автопроверка</strong> проверка типов, сборки и тестов после применённых изменений (по умолчанию включена)</li>
<li><strong>Максимум попыток</strong> агента при автоматическом исправлении (по умолчанию 2)</li>
<li><strong>Максимум действий</strong> за одну транзакцию (по умолчанию 12)</li>
</ul>
<p style={{ ...textStyle, marginTop: "12px", fontSize: "13px", color: "var(--color-text-muted)" }}>
Эти настройки применяются при работе с проектом во вкладке «Задачи» (профиль проекта).
</p>
</section>
<section style={sectionStyle}>
<h2 style={headingStyle}>Тренды и рекомендации</h2>
<p style={textStyle}>
Раздел «Тренды и рекомендации» в левой панели «Задач» загружает актуальные рекомендации по разработке. Обновление не реже раза в 30 дней. Кнопка «Обновить тренды» подгружает новые данные.
</p>
</section>
<p style={{ marginTop: "24px" }}>
<Link
to="/"
style={{
display: "inline-block",
padding: "12px 20px",
background: "var(--color-primary)",
color: "#fff",
fontWeight: 600,
borderRadius: "var(--radius-md)",
textDecoration: "none",
boxShadow: "0 2px 8px rgba(37, 99, 235, 0.35)",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
}}
>
Перейти в «Задачи»
</Link>
</p>
</div>
);
}

2069
src/pages/Tasks.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,88 @@
import type { AgenticRunResult, AnalyzeReport } from "@/lib/types";
export interface AgenticResultProps {
agenticResult: AgenticRunResult | null;
lastReport: AnalyzeReport | null;
undoAvailable: boolean;
onUndo: () => void;
onDownloadReport: () => void;
onDownloadDiff: () => void;
}
const sectionStyle: React.CSSProperties = {
marginTop: "16px",
padding: "12px",
background: "#eff6ff",
borderRadius: "8px",
border: "1px solid #bfdbfe",
};
export function AgenticResult({
agenticResult,
lastReport,
undoAvailable,
onUndo,
onDownloadReport,
onDownloadDiff,
}: AgenticResultProps) {
if (!agenticResult) return null;
const lastAttempt = agenticResult.attempts[agenticResult.attempts.length - 1];
const canDownloadDiff = !!lastAttempt?.preview?.diffs?.length;
return (
<section style={sectionStyle}>
<h3 style={{ fontSize: "14px", marginBottom: "8px", fontWeight: 600 }}>Результат исправления</h3>
<p style={{ fontSize: "13px", marginBottom: "10px" }}>{agenticResult.final_summary}</p>
{agenticResult.attempts.length > 0 && (
<div style={{ marginBottom: "10px", overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "12px" }}>
<thead>
<tr style={{ borderBottom: "1px solid #e2e8f0" }}>
<th style={{ textAlign: "left", padding: "6px 8px" }}>Попытка</th>
<th style={{ textAlign: "left", padding: "6px 8px" }}>Verify</th>
<th style={{ textAlign: "left", padding: "6px 8px" }}>Статус</th>
</tr>
</thead>
<tbody>
{agenticResult.attempts.map((a, i) => (
<tr key={i} style={{ borderBottom: "1px solid #f1f5f9" }}>
<td style={{ padding: "6px 8px" }}>{a.attempt}</td>
<td style={{ padding: "6px 8px" }}>{a.verify.ok ? "✓" : "✗"}</td>
<td style={{ padding: "6px 8px" }}>{!a.verify.ok ? "откачено" : a.apply.ok ? "применено" : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<button
type="button"
onClick={onDownloadReport}
disabled={!lastReport}
style={{ padding: "6px 12px", border: "1px solid #3b82f6", borderRadius: "6px", background: "#fff", fontSize: "12px" }}
>
Скачать отчёт (JSON)
</button>
<button
type="button"
onClick={onDownloadDiff}
disabled={!canDownloadDiff}
style={{ padding: "6px 12px", border: "1px solid #3b82f6", borderRadius: "6px", background: "#fff", fontSize: "12px" }}
>
Скачать изменения (diff)
</button>
{undoAvailable && (
<button
type="button"
onClick={onUndo}
style={{ padding: "6px 12px", border: "1px solid #64748b", borderRadius: "6px", background: "#64748b", color: "#fff", fontSize: "12px" }}
>
Откатить изменения (Undo)
</button>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,149 @@
export interface PathSelectorProps {
folderLinks: string[];
attachedFiles: string[];
onAddFolder: () => void;
onAddFile: () => void;
onRemoveLink: (index: number) => void;
onRemoveFile: (index: number) => void;
}
const sectionStyle: React.CSSProperties = {
marginBottom: "24px",
padding: "24px",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-xl)",
background: "linear-gradient(180deg, #ffffff 0%, var(--color-bg-warm) 100%)",
boxShadow: "var(--shadow-md)",
minHeight: "140px",
};
export function PathSelector({
folderLinks,
attachedFiles,
onAddFolder,
onAddFile,
onRemoveLink,
onRemoveFile,
}: PathSelectorProps) {
return (
<section
className="tasks-sources"
data-section="path-selection"
style={sectionStyle}
>
<h2 style={{ fontSize: "17px", color: "var(--color-text)", marginBottom: "6px", fontWeight: 700, letterSpacing: "-0.01em" }}>
Папки и файлы проекта
</h2>
<p style={{ fontSize: "13px", color: "var(--color-text-muted)", marginBottom: "18px", lineHeight: 1.5 }}>
Укажите папку для анализа или прикрепите файлы (исходники, конфиги) для создания и правок. Можно ввести путь вручную в поле ниже.
</p>
<div style={{ display: "flex", gap: "12px", marginBottom: "18px", flexWrap: "wrap", alignItems: "center" }}>
<button
type="button"
onClick={onAddFolder}
aria-label="Выбрать папку"
style={{
padding: "12px 22px",
background: "var(--color-primary)",
color: "#fff",
border: "none",
borderRadius: "var(--radius-md)",
fontSize: "15px",
fontWeight: 600,
boxShadow: "0 2px 8px rgba(37, 99, 235, 0.35)",
}}
>
Выбрать папку
</button>
<button
type="button"
onClick={onAddFile}
aria-label="Прикрепить файл"
style={{
padding: "12px 22px",
background: "var(--color-secondary)",
color: "#fff",
border: "none",
borderRadius: "var(--radius-md)",
fontSize: "15px",
fontWeight: 600,
boxShadow: "0 2px 8px rgba(13, 148, 136, 0.35)",
}}
>
Прикрепить файл
</button>
<button
type="button"
onClick={onAddFolder}
style={{
padding: "12px 20px",
background: "#fff",
border: "1px solid var(--color-border-strong)",
borderRadius: "var(--radius-md)",
fontSize: "14px",
color: "var(--color-text)",
}}
>
+ Добавить ещё папку
</button>
</div>
{folderLinks.length > 0 && (
<>
<p style={{ fontSize: "12px", color: "var(--color-text-muted)", marginBottom: "8px", fontWeight: 600 }}>Папки</p>
<ul style={{ listStyle: "none", padding: 0, margin: 0, marginBottom: "14px" }}>
{folderLinks.map((p, i) => (
<li
key={`f-${i}`}
style={{
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "8px",
padding: "10px 14px",
background: "#fff",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-sm)",
}}
>
<span style={{ flex: 1, fontSize: "13px", overflow: "hidden", textOverflow: "ellipsis" }} title={p}>{p}</span>
<button type="button" onClick={() => onRemoveLink(i)} style={{ padding: "6px 12px", fontSize: "12px", background: "var(--color-bg)", border: "none", borderRadius: "var(--radius-sm)", fontWeight: 500 }}>Удалить</button>
</li>
))}
</ul>
</>
)}
{attachedFiles.length > 0 && (
<>
<p style={{ fontSize: "12px", color: "var(--color-text-muted)", marginBottom: "8px", fontWeight: 600 }}>Прикреплённые файлы</p>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{attachedFiles.map((p, i) => (
<li
key={`file-${i}`}
style={{
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "8px",
padding: "10px 14px",
background: "linear-gradient(90deg, #f0fdfa 0%, #fff 100%)",
borderRadius: "var(--radius-md)",
border: "1px solid #99f6e4",
boxShadow: "var(--shadow-sm)",
}}
>
<span style={{ flex: 1, fontSize: "13px", overflow: "hidden", textOverflow: "ellipsis" }} title={p}>{p.split(/[/\\]/).pop() ?? p}</span>
<button type="button" onClick={() => onRemoveFile(i)} style={{ padding: "6px 12px", fontSize: "12px", background: "#ccfbf1", border: "none", borderRadius: "var(--radius-sm)", fontWeight: 500 }}>Удалить</button>
</li>
))}
</ul>
</>
)}
{folderLinks.length === 0 && attachedFiles.length === 0 && (
<p style={{ fontSize: "13px", color: "var(--color-text-muted)", padding: "10px 0" }}>
Папки и файлы не выбраны. Нажмите «Выбрать папку» или «Прикрепить файл».
</p>
)}
</section>
);
}

View File

@ -0,0 +1,79 @@
import { useState, useCallback } from "react";
import { getUndoRedoState, getUndoStatus, undoLastTx, undoLast, redoLast } from "@/lib/tauri";
import type { Action, AnalyzeReport, ChatMessage, DiffItem } from "@/lib/types";
export interface UseUndoRedoSetters {
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
setPendingPreview: React.Dispatch<React.SetStateAction<{ path: string; actions: Action[]; diffs: DiffItem[] } | null>>;
setLastReport: React.Dispatch<React.SetStateAction<AnalyzeReport | null>>;
}
export function useUndoRedo(lastPath: string | null, setters: UseUndoRedoSetters) {
const [undoAvailable, setUndoAvailable] = useState(false);
const [redoAvailable, setRedoAvailable] = useState(false);
const refreshUndoRedo = useCallback(async () => {
try {
const r = await getUndoRedoState();
const st = await getUndoStatus();
setUndoAvailable(!!r.undo_available || !!st.available);
setRedoAvailable(!!r.redo_available);
} catch (_) {}
}, []);
const handleUndo = useCallback(async () => {
const { setMessages, setPendingPreview, setLastReport } = setters;
try {
if (lastPath) {
try {
const ok = await undoLastTx(lastPath);
if (ok) {
setMessages((m) => [...m, { role: "system", text: "Последнее действие отменено." }]);
setPendingPreview(null);
} else {
setMessages((m) => [...m, { role: "system", text: "Откат недоступен для этого пути." }]);
}
await refreshUndoRedo();
return;
} catch (_) {}
}
const r = await undoLast();
if (r.ok) {
setMessages((m) => [...m, { role: "system", text: "Откат выполнен." }]);
setPendingPreview(null);
setLastReport(null);
} else {
setMessages((m) => [...m, { role: "system", text: r.error || "Откат не выполнен." }]);
}
await refreshUndoRedo();
} catch (e) {
setMessages((m) => [...m, { role: "system", text: `Ошибка отката: ${String(e)}` }]);
await refreshUndoRedo();
}
}, [lastPath, setters, refreshUndoRedo]);
const handleRedo = useCallback(async () => {
const { setMessages } = setters;
try {
const r = await redoLast();
if (r.ok) {
setMessages((m) => [...m, { role: "system", text: "Изменения повторно применены." }]);
} else {
setMessages((m) => [...m, { role: "system", text: r.error || "Повтор не выполнен." }]);
}
await refreshUndoRedo();
} catch (e) {
setMessages((m) => [...m, { role: "system", text: `Ошибка повтора: ${String(e)}` }]);
}
}, [setters, refreshUndoRedo]);
return {
undoAvailable,
redoAvailable,
refreshUndoRedo,
handleUndo,
handleRedo,
setUndoAvailable,
setRedoAvailable,
};
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

22
start-with-openai.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/bash
# Запуск PAPA-YU с подключением к OpenAI.
# Ключ API храните только в .env.openai на своём компьютере (не передавайте в чат и не коммитьте).
cd "$(dirname "$0")"
if [ ! -f .env.openai ]; then
echo "Файл .env.openai не найден."
echo ""
echo "1. Скопируйте шаблон:"
echo " cp env.openai.example .env.openai"
echo ""
echo "2. Откройте .env.openai и замените your-openai-key-here на ваш ключ OpenAI (sk-...)."
echo ""
exit 1
fi
# Загружаем переменные, убирая \r (Windows-переносы), чтобы не было "command not found"
export $(grep -v '^#' .env.openai | grep -v '^$' | sed 's/\r$//' | xargs)
npm run tauri dev

61
tests/README.md Normal file
View File

@ -0,0 +1,61 @@
# PAPA YU Tests
## Структура
```
tests/
├── README.md # Этот файл
└── fixtures/ # Тестовые фикстуры (минимальные проекты)
├── minimal-node/ # Node.js проект без README
└── minimal-rust/ # Rust проект без README
```
## Юнит-тесты (Rust)
Запуск всех юнит-тестов:
```bash
cd src-tauri
cargo test
```
Текущие тесты покрывают:
- `detect_project_type` — определение типа проекта
- `get_project_limits` — лимиты по типу проекта
- `is_protected_file` — защита служебных файлов
- `is_text_allowed` — фильтр текстовых файлов
- `settings_export` — экспорт/импорт настроек
## E2E сценарий (ручной)
См. `docs/E2E_SCENARIO.md` для пошагового сценария:
1. Запустить приложение: `npm run tauri dev`
2. Выбрать одну из фикстур (например, `tests/fixtures/minimal-node`)
3. Запустить анализ
4. Применить рекомендованные исправления
5. Проверить, что README.md создан
6. Откатить изменения (Undo)
7. Проверить, что README.md удалён
## Тестовые фикстуры
### minimal-node
Минимальный Node.js проект:
- `package.json` — манифест пакета
- `index.js` — точка входа
- **Нет README** — должен быть предложен при анализе
### minimal-rust
Минимальный Rust проект:
- `Cargo.toml` — манифест пакета
- `src/main.rs` — точка входа
- **Нет README** — должен быть предложен при анализе
## Автоматизация E2E (будущее)
Планируется использовать:
- **Tauri test** — для тестирования команд
- **Playwright** — для тестирования UI

2
tests/fixtures/minimal-node/index.js vendored Normal file
View File

@ -0,0 +1,2 @@
// Minimal Node.js entry point for testing
console.log("Hello from test project");

View File

@ -0,0 +1,10 @@
{
"name": "test-project",
"version": "1.0.0",
"description": "Minimal test project for E2E testing",
"main": "index.js",
"scripts": {
"test": "echo 'No tests'",
"build": "echo 'Build ok'"
}
}

View File

@ -0,0 +1,6 @@
[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
[dependencies]

View File

@ -0,0 +1,3 @@
fn main() {
println!("Hello from test project");
}

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

12
tsconfig.node.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

2
vite.config.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

17
vite.config.js Normal file
View File

@ -0,0 +1,17 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
var __dirname = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
base: "./",
plugins: [react()],
clearScreen: false,
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
server: {
port: 5173,
strictPort: true,
},
});

19
vite.config.ts Normal file
View File

@ -0,0 +1,19 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
base: "./",
plugins: [react()],
clearScreen: false,
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
server: {
port: 5173,
strictPort: true,
},
});