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:
commit
e76236dc55
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal 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
108
AUDIT.md
Normal 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
72
CHANGELOG.md
Normal 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.
|
||||
17
PAPA YU — Сборка и запуск.command
Executable file
17
PAPA YU — Сборка и запуск.command
Executable 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
25
PAPA YU.command
Executable 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
111
README.md
Normal 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
70
docs/AGENT_CONTRACT.md
Normal 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** | Обязан вернуть: диагноз (1–3 пункта), 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** — сколько уточняющих вопросов допустимо за один оборот.
|
||||
- Формат ответа по умолчанию: 3–7 буллетов; код/патч — отдельным блоком; в конце: «Что сделать сейчас: …».
|
||||
|
||||
---
|
||||
|
||||
## Безопасность apply_patch
|
||||
|
||||
При реализации инструмента `apply_patch`:
|
||||
|
||||
- dry-run валидация diff перед применением;
|
||||
- запрет изменений вне repo-root;
|
||||
- лимит размера патча;
|
||||
- обязательный backup/undo (например, через tx в papa-yu).
|
||||
38
docs/E2E_SCENARIO.md
Normal file
38
docs/E2E_SCENARIO.md
Normal 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
261
docs/FIX_PLAN_CONTRACT.md
Normal 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
109
docs/IMPROVEMENTS.md
Normal 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
271
docs/LLM_PLAN_FORMAT.md
Normal 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)
|
||||
|
||||
Память разделена на три слоя; в промпт подставляется только устойчивый минимум (~1–2 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
125
docs/OPENAI_SETUP.md
Normal 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 и т.п.).
|
||||
30
docs/TEST-AUTO-ROLLBACK.md
Normal file
30
docs/TEST-AUTO-ROLLBACK.md
Normal 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 выше.
|
||||
75
docs/fix_plan_response_schema.json
Normal file
75
docs/fix_plan_response_schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
121
docs/openai_tools_schema.json
Normal file
121
docs/openai_tools_schema.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
92
docs/papa_yu_response_schema.json
Normal file
92
docs/papa_yu_response_schema.json
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
docs/preferences.example.json
Normal file
10
docs/preferences.example.json
Normal 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
13
docs/project.example.json
Normal 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."
|
||||
}
|
||||
}
|
||||
17
docs/test-auto-rollback-fs-payload.json
Normal file
17
docs/test-auto-rollback-fs-payload.json
Normal 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
|
||||
}
|
||||
17
docs/test-auto-rollback-payload.json
Normal file
17
docs/test-auto-rollback-payload.json
Normal 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
|
||||
}
|
||||
71
docs/ЕДИНАЯ_ПАПКА_ПРОЕКТА.md
Normal file
71
docs/ЕДИНАЯ_ПАПКА_ПРОЕКТА.md
Normal 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`.
|
||||
141
docs/ОЦЕНКА_И_СЦЕНАРИЙ_РАССКАЗА.md
Normal file
141
docs/ОЦЕНКА_И_СЦЕНАРИЙ_РАССКАЗА.md
Normal 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-планировщик и контекст прикреплённых файлов.
|
||||
142
docs/РЕКОМЕНДАЦИИ_ОБЪЕДИНЕНИЕ_ПАПОК.md
Normal file
142
docs/РЕКОМЕНДАЦИИ_ОБЪЕДИНЕНИЕ_ПАПОК.md
Normal 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`, `старт/` (этапы 1–7, 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
33
env.openai.example
Normal 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
15
index.html
Normal 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
2610
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal 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
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
BIN
public/send-icon.png
Normal file
BIN
public/send-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
55
scripts/export-icon.js
Normal file
55
scripts/export-icon.js
Normal 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
34
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal file
11
src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
77
src-tauri/config/llm_response_schema.json
Normal file
77
src-tauri/config/llm_response_schema.json
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src-tauri/config/verify_allowlist.json
Normal file
19
src-tauri/config/verify_allowlist.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal 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"]}}
|
||||
2630
src-tauri/gen/schemas/desktop-schema.json
Normal file
2630
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2630
src-tauri/gen/schemas/macOS-schema.json
Normal file
2630
src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
17
src-tauri/icons/README.md
Normal file
17
src-tauri/icons/README.md
Normal 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
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
24
src-tauri/icons/icon.svg
Normal 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 |
312
src-tauri/src/commands/agentic_run.rs
Normal file
312
src-tauri/src/commands/agentic_run.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
246
src-tauri/src/commands/analyze_project.rs
Normal file
246
src-tauri/src/commands/analyze_project.rs
Normal 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)
|
||||
}
|
||||
|
||||
214
src-tauri/src/commands/apply_actions.rs
Normal file
214
src-tauri/src/commands/apply_actions.rs
Normal 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('.')
|
||||
}
|
||||
521
src-tauri/src/commands/apply_actions_tx.rs
Normal file
521
src-tauri/src/commands/apply_actions_tx.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
57
src-tauri/src/commands/auto_check.rs
Normal file
57
src-tauri/src/commands/auto_check.rs
Normal 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(())
|
||||
}
|
||||
30
src-tauri/src/commands/folder_links.rs
Normal file
30
src-tauri/src/commands/folder_links.rs
Normal 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())
|
||||
}
|
||||
153
src-tauri/src/commands/generate_actions.rs
Normal file
153
src-tauri/src/commands/generate_actions.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
164
src-tauri/src/commands/generate_actions_from_report.rs
Normal file
164
src-tauri/src/commands/generate_actions_from_report.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
190
src-tauri/src/commands/get_project_profile.rs
Normal file
190
src-tauri/src/commands/get_project_profile.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
1352
src-tauri/src/commands/llm_planner.rs
Normal file
1352
src-tauri/src/commands/llm_planner.rs
Normal file
File diff suppressed because it is too large
Load Diff
40
src-tauri/src/commands/mod.rs
Normal file
40
src-tauri/src/commands/mod.rs
Normal 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};
|
||||
141
src-tauri/src/commands/preview_actions.rs
Normal file
141
src-tauri/src/commands/preview_actions.rs
Normal 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('.')
|
||||
}
|
||||
145
src-tauri/src/commands/project_content.rs
Normal file
145
src-tauri/src/commands/project_content.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src-tauri/src/commands/projects.rs
Normal file
102
src-tauri/src/commands/projects.rs
Normal 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)
|
||||
}
|
||||
249
src-tauri/src/commands/propose_actions.rs
Normal file
249
src-tauri/src/commands/propose_actions.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
57
src-tauri/src/commands/redo_last.rs
Normal file
57
src-tauri/src/commands/redo_last.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
87
src-tauri/src/commands/run_batch.rs
Normal file
87
src-tauri/src/commands/run_batch.rs
Normal 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)
|
||||
}
|
||||
223
src-tauri/src/commands/settings_export.rs
Normal file
223
src-tauri/src/commands/settings_export.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
184
src-tauri/src/commands/trends.rs
Normal file
184
src-tauri/src/commands/trends.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
53
src-tauri/src/commands/undo_last.rs
Normal file
53
src-tauri/src/commands/undo_last.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
97
src-tauri/src/commands/undo_last_tx.rs
Normal file
97
src-tauri/src/commands/undo_last_tx.rs
Normal 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)
|
||||
}
|
||||
36
src-tauri/src/commands/undo_status.rs
Normal file
36
src-tauri/src/commands/undo_status.rs
Normal 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
554
src-tauri/src/context.rs
Normal 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
90
src-tauri/src/lib.rs
Normal 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
5
src-tauri/src/main.rs
Normal 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
301
src-tauri/src/memory.rs
Normal 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 (~1–2 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
120
src-tauri/src/store.rs
Normal 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
107
src-tauri/src/tx/limits.rs
Normal 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
264
src-tauri/src/tx/mod.rs
Normal 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
80
src-tauri/src/tx/store.rs
Normal 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
509
src-tauri/src/types.rs
Normal 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
200
src-tauri/src/verify.rs
Normal 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
37
src-tauri/tauri.conf.json
Normal 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
90
src/App.tsx
Normal 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
179
src/index.css
Normal 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
221
src/lib/tauri.ts
Normal 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
207
src/lib/types.ts
Normal 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
57
src/lib/useTheme.ts
Normal 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
10
src/main.tsx
Normal 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
111
src/pages/Dashboard.tsx
Normal 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
2069
src/pages/Tasks.tsx
Normal file
File diff suppressed because it is too large
Load Diff
88
src/pages/tasks/AgenticResult.tsx
Normal file
88
src/pages/tasks/AgenticResult.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
src/pages/tasks/PathSelector.tsx
Normal file
149
src/pages/tasks/PathSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/pages/tasks/useUndoRedo.ts
Normal file
79
src/pages/tasks/useUndoRedo.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
22
start-with-openai.sh
Executable file
22
start-with-openai.sh
Executable 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
61
tests/README.md
Normal 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
2
tests/fixtures/minimal-node/index.js
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
// Minimal Node.js entry point for testing
|
||||
console.log("Hello from test project");
|
||||
10
tests/fixtures/minimal-node/package.json
vendored
Normal file
10
tests/fixtures/minimal-node/package.json
vendored
Normal 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'"
|
||||
}
|
||||
}
|
||||
6
tests/fixtures/minimal-rust/Cargo.toml
vendored
Normal file
6
tests/fixtures/minimal-rust/Cargo.toml
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "test-project"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
3
tests/fixtures/minimal-rust/src/main.rs
vendored
Normal file
3
tests/fixtures/minimal-rust/src/main.rs
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("Hello from test project");
|
||||
}
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal 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
12
tsconfig.node.json
Normal 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
2
vite.config.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
17
vite.config.js
Normal file
17
vite.config.js
Normal 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
19
vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user