AI-аудитор: narrative от LLM, AuditLogger, SecretsGuard, PolicyEngine, RAG по проекту
П.1 — LLM-narrative: fetch_narrative_for_report в llm_planner, вызов из analyze_project_cmd и run_batch; fallback на шаблон при отсутствии API. П.2 — В промпт «Предложить исправления» добавлен приоритет по findings из отчёта. П.3 — AuditLogger (audit_log.rs, /audit), SecretsGuard (secrets_guard.rs, /secrets), PolicyEngine (policy_engine.rs, /policies): Rust-модули + страницы в навигации. П.5 — RAG упрощённый: rag_query.rs, rag_query_cmd, страница «Вопрос по проекту» (/project-chat). Документ: docs/ПЛАН_AI_АУДИТОР.md с планом и секцией «Реализовано». Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
de9c2660d5
commit
289b1b6dac
129
docs/ПЛАН_AI_АУДИТОР.md
Normal file
129
docs/ПЛАН_AI_АУДИТОР.md
Normal file
@ -0,0 +1,129 @@
|
||||
# Реалистичный план: PAPA YU как AI-аудитор проектов
|
||||
|
||||
**Цель:** не «ещё один Cursor», а специализированный AI-аудитор — уникальная ниша. План привязан к текущему коду и разбит на шаги с минимальным риском.
|
||||
|
||||
---
|
||||
|
||||
## Реализовано (текущая сборка)
|
||||
|
||||
- **П.1** — LLM-narrative: `fetch_narrative_for_report()` в `llm_planner.rs`, вызов из `analyze_project_cmd` и `run_batch`; fallback на шаблон при отсутствии API.
|
||||
- **П.2** — В промпт «Предложить исправления» добавлено явное указание учитывать findings из отчёта.
|
||||
- **П.3** — **AuditLogger**: `audit_log.rs`, команды `audit_log_list_cmd`, запись при анализе и apply; страница «Журнал» (/audit). **SecretsGuard**: `secrets_guard.rs`, `scan_secrets_cmd`, страница «Секреты» (/secrets). **PolicyEngine**: `policy_engine.rs`, `get_policies_cmd`, `run_policy_check_cmd`, страница «Политики» (/policies).
|
||||
- **П.5 (упрощённый)** — RAG: `commands/rag_query.rs`, `rag_query_cmd` (контекст из файлов проекта + вопрос → LLM), страница «Вопрос по проекту» (/project-chat).
|
||||
|
||||
---
|
||||
|
||||
## 1. LLM → осмысленный narrative вместо шаблонов
|
||||
|
||||
**Сейчас:** в `analyze_project.rs` функция `build_human_narrative()` собирает текст из шаблонов: «Я проанализировал проект X», «Это React+Vite», «Найдено проблем: N. Рекомендую: …».
|
||||
|
||||
**Цель:** отправлять в LLM структурированный контекст (путь, тип проекта, findings, actions, signals) и получать один короткий narrative (2–4 предложения) для отчёта и agent-sync.
|
||||
|
||||
**Шаги:**
|
||||
|
||||
| # | Задача | Где | Оценка |
|
||||
|---|--------|-----|--------|
|
||||
| 1.1 | Вынести сборку «llm_context для narrative» в отдельную функцию (path, project_type, findings, actions, signals → JSON или текст). | `analyze_project.rs` | малый |
|
||||
| 1.2 | Добавить опциональный вызов LLM для narrative: если заданы `PAPAYU_LLM_*`, после построения отчёта вызвать API с этим контекстом и промптом «Напиши краткий вывод аудитора для разработчика (2–4 предложения)». | новый модуль `narrative_llm.rs` или в `llm_planner.rs` | малый |
|
||||
| 1.3 | Подставить ответ LLM в `report.narrative` вместо `build_human_narrative()`. Fallback: при ошибке/отсутствии API — текущий шаблон. | `analyze_project.rs` | малый |
|
||||
|
||||
**Риск:** низкий. Не ломает существующий поток, только обогащает текст.
|
||||
|
||||
---
|
||||
|
||||
## 2. Код-генерация по findings → LLM, preview, apply
|
||||
|
||||
**Сейчас:** есть цепочка: анализ → отчёт (findings + actions) → «Предложить исправления» (LLM генерирует план действий) → preview (diff) → apply. Генерация «безопасных» действий без LLM — в `generate_actions_from_report` (эвристики: README, .gitignore, tests).
|
||||
|
||||
**Цель:** явно вести от findings к исправлениям: findings + контекст кода → LLM → actions → preview → apply.
|
||||
|
||||
**Шаги:**
|
||||
|
||||
| # | Задача | Где | Оценка |
|
||||
|---|--------|-----|--------|
|
||||
| 2.1 | В режиме «Предложить исправления» передавать в LLM не только goal/report, но и список findings с evidence (уже есть в `AnalyzeReport`). Промпт: «По этим находкам предложи конкретные правки (EDIT_FILE, CREATE_FILE)». | `llm_planner.rs`, конструирование prompt | малый |
|
||||
| 2.2 | Опционально: кнопка «Исправить по finding» — по одному выбранному finding отправить в LLM контекст (файл/фрагмент) и получить один или несколько actions, затем preview. | UI: `Tasks.tsx` + новая command `generate_actions_for_finding` | средний |
|
||||
| 2.3 | Оставить текущий preview/apply без изменений (уже показывают diff и применяют с проверкой). | — | — |
|
||||
|
||||
**Риск:** низкий. Расширяем существующий LLM-контур.
|
||||
|
||||
---
|
||||
|
||||
## 3. Оживить «заглушки»: PolicyEngine, SecretsGuard, AuditLogger
|
||||
|
||||
**Сейчас:** в другом проекте (papayu-main) упоминаются маршруты `/policies`, `/audit`, `/secrets` как заглушки. В papa-yu их нет — фокус на задачах и анализе.
|
||||
|
||||
**Цель:** ввести в papa-yu три модуля, привязанные к Rust-бэкенду, без переписывания всего приложения.
|
||||
|
||||
**Шаги:**
|
||||
|
||||
| # | Задача | Где | Оценка |
|
||||
|---|--------|-----|--------|
|
||||
| 3.1 | **AuditLogger** — логирование действий аудитора (анализ, apply, undo, отказ). Rust: команда `audit_log(event_type, payload)` + запись в файл `.papa-yu/audit.log` или SQLite. UI: страница «Журнал» — список последних событий (дата, тип, проект, результат). | `src-tauri/src/audit_log.rs`, `lib.rs`, новая страница `AuditLog.tsx`, маршрут `/audit` | средний |
|
||||
| 3.2 | **SecretsGuard** — проверка проекта на типичные утечки (ключи в коде, .env в репо, хардкод паролей). Rust: сканирование выбранной папки (эвристики + опционально regex из конфига), возврат списка «подозрений» без хранения секретов. UI: страница «Секреты» — кнопка «Проверить», таблица файл/строка/тип. | `src-tauri/src/secrets_guard.rs`, команда `scan_secrets`, страница `SecretsGuard.tsx`, маршрут `/secrets` | средний |
|
||||
| 3.3 | **PolicyEngine** — набор правил аудита (например «обязателен README», «запрещён коммит .env»). Rust: конфиг правил (JSON), проверка проекта по правилам, отчёт pass/fail. UI: страница «Политики» — список правил, результат последней проверки. | `src-tauri/src/policy_engine.rs`, команды `get_policies`, `run_policy_check`, страница `PolicyEngine.tsx`, маршрут `/policies` | средний |
|
||||
|
||||
**Риск:** средний. Новый код, но изолированные модули.
|
||||
|
||||
---
|
||||
|
||||
## 4. Deep analysis: парсинг кода, уязвимости, мёртвый код
|
||||
|
||||
**Сейчас:** анализ в `analyze_project.rs` — обход файловой системы, эвристики (наличие README, tests, .gitignore и т.д.), без разбора содержимого кода.
|
||||
|
||||
**Цель:** не только структура, но и парсинг кода (tree-sitter), поиск типичных уязвимостей, индикация мёртвого кода.
|
||||
|
||||
**Шаги:**
|
||||
|
||||
| # | Задача | Где | Оценка |
|
||||
|---|--------|-----|--------|
|
||||
| 4.1 | Подключить tree-sitter (Rust: `tree-sitter` crate) для одного языка-пилота (например JavaScript/TypeScript). Обход выбранных файлов, построение AST, подсчёт функций/классов, экспортов. | новый модуль `src-tauri/src/deep_analysis/` или `code_parser.rs` | большой |
|
||||
| 4.2 | Добавить в отчёт «глубокие» findings: неиспользуемые экспорты (мёртвый код), простые паттерны риска (eval, innerHTML без санитизации — по правилам). | расширить `Finding`, вызов парсера из `analyze_project` или отдельной command | средний |
|
||||
| 4.3 | Опционально: интеграция с Snyk Code / аналогом (уже есть `snyk_sync`) — показывать код-уязвимости рядом с нашими findings. | уже частично есть; доработать отображение в UI | малый |
|
||||
|
||||
**Риск:** большой для 4.1 (зависимость, поддержка языков). Рационально начать с одного языка и набора из 3–5 правил.
|
||||
|
||||
---
|
||||
|
||||
## 5. RAG по документации и коду: чат с контекстом проекта
|
||||
|
||||
**Сейчас:** domain notes, project_content, выбор папки/файлов. Чат «с агентом» в UI есть, но без RAG по загруженному проекту.
|
||||
|
||||
**Цель:** пользователь выбрал проект → задаёт вопросы в чате → агент отвечает с опорой на индекс кода/документации (RAG).
|
||||
|
||||
**Шаги:**
|
||||
|
||||
| # | Задача | Где | Оценка |
|
||||
|---|--------|-----|--------|
|
||||
| 5.1 | Индексация: по выбранному пути собрать «документы» (файлы с расширениями .md, .ts, .tsx, .rs и т.д.), разбить на чанки, опционально эмбеддинги (внешний API или локальная модель). Хранить индекс в `.papa-yu/rag_index` (SQLite + векторы или только текст для простого поиска). | новый модуль `src-tauri/src/rag/` (index, search), команды `rag_index_project`, `rag_query` | большой |
|
||||
| 5.2 | Поиск по запросу: по тексту вопроса найти релевантные чанки (keyword или semantic), сформировать контекст для LLM. | `rag/query.rs` | средний |
|
||||
| 5.3 | Чат: поле «Вопрос по проекту» → вызов `rag_query` + вызов LLM с контекстом → ответ в UI. Без переделки всего чата — один сценарий «вопрос по коду». | `Tasks.tsx` или отдельная вкладка «Вопросы по проекту», команда `chat_on_project` | средний |
|
||||
|
||||
**Риск:** большой для полного RAG с эмбеддингами. Упрощённый вариант: индексация без эмбеддингов, поиск по ключевым словам + контекст из найденных файлов в промпт — быстрее внедрить.
|
||||
|
||||
---
|
||||
|
||||
## Приоритеты и порядок
|
||||
|
||||
| Приоритет | Блок | Зачем первым |
|
||||
|-----------|------|--------------|
|
||||
| 1 | П.1 — LLM-narrative | Быстрый визуальный выигрыш, малый объём работ, не ломает поток. |
|
||||
| 2 | П.2 — Findings → LLM → apply | Уже есть цепочка; усиление связи findings → исправления усиливает позицию «аудитор». |
|
||||
| 3 | П.3 — AuditLogger | Минимальная «оживляющая» заглушка: всё что делается — логируется. Нужно для доверия и аудита. |
|
||||
| 4 | П.3 — SecretsGuard | Сильно востребовано в аудите; эвристики без тяжёлых зависимостей. |
|
||||
| 5 | П.3 — PolicyEngine | Завершает триаду «аудиторских» модулей. |
|
||||
| 6 | П.5 — RAG упрощённый | Чат по проекту без полного RAG: индекс файлов + keyword-поиск + контекст в LLM. |
|
||||
| 7 | П.4 — Deep analysis | Самый тяжёлый; после закрепления п.1–3 и 5 имеет смысл вводить tree-sitter по одному языку. |
|
||||
| 8 | П.5 — RAG с эмбеддингами | При необходимости углубления чата по коду. |
|
||||
|
||||
---
|
||||
|
||||
## Кратко по файлам
|
||||
|
||||
- **Narrative от LLM:** `analyze_project.rs` (build_human_narrative → вызов LLM), новый хелпер в `llm_planner.rs` или `narrative_llm.rs`.
|
||||
- **Findings → LLM:** `llm_planner.rs` (prompt), `generate_actions_from_report.rs` / новая command для одного finding.
|
||||
- **Audit/Secrets/Policy:** новые модули в `src-tauri/src/`, новые страницы и маршруты в `src/App.tsx`.
|
||||
- **Deep analysis:** новый модуль `code_parser` / `deep_analysis`, расширение `analyze_project` или отдельная command.
|
||||
- **RAG:** новый модуль `rag/`, команды индексации и запроса, блок чата в UI.
|
||||
|
||||
Документ можно использовать как дорожную карту для спринтов и оценок.
|
||||
73
src-tauri/src/audit_log.rs
Normal file
73
src-tauri/src/audit_log.rs
Normal file
@ -0,0 +1,73 @@
|
||||
//! Журнал аудита: запись событий (анализ, apply, undo) в файл.
|
||||
//! Файл: app_data_dir/papa-yu/audit.log или project_path/.papa-yu/audit.log при указании пути проекта.
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditEvent {
|
||||
pub ts: String,
|
||||
pub event_type: String,
|
||||
pub project_path: Option<String>,
|
||||
pub result: Option<String>,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
fn audit_file_path(base_dir: &Path) -> std::path::PathBuf {
|
||||
base_dir.join("papa-yu").join("audit.log")
|
||||
}
|
||||
|
||||
/// Записывает событие в audit.log в app_data_dir (глобальный лог приложения).
|
||||
pub fn log_event(
|
||||
app_audit_dir: &Path,
|
||||
event_type: &str,
|
||||
project_path: Option<&str>,
|
||||
result: Option<&str>,
|
||||
details: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let file_path = audit_file_path(app_audit_dir);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&file_path)
|
||||
.map_err(|e| format!("audit log open: {}", e))?;
|
||||
let ts = Utc::now().to_rfc3339();
|
||||
let line = serde_json::json!({
|
||||
"ts": ts,
|
||||
"event_type": event_type,
|
||||
"project_path": project_path,
|
||||
"result": result,
|
||||
"details": details
|
||||
});
|
||||
writeln!(file, "{}", line).map_err(|e| format!("audit log write: {}", e))?;
|
||||
file.flush().map_err(|e| format!("audit log flush: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Читает последние N строк из audit.log. Возвращает события от новых к старым.
|
||||
pub fn read_events(app_audit_dir: &Path, limit: usize) -> Vec<AuditEvent> {
|
||||
let file_path = audit_file_path(app_audit_dir);
|
||||
let content = match std::fs::read_to_string(&file_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let lines: Vec<&str> = content.lines().rev().take(limit).collect();
|
||||
let mut out = Vec::with_capacity(lines.len());
|
||||
for line in lines.iter().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(ev) = serde_json::from_str::<AuditEvent>(trimmed) {
|
||||
out.push(ev);
|
||||
}
|
||||
}
|
||||
out.reverse();
|
||||
out
|
||||
}
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
use crate::context;
|
||||
use crate::memory;
|
||||
use crate::types::{Action, ActionKind, AgentPlan};
|
||||
use crate::types::{Action, ActionKind, AgentPlan, AnalyzeReport};
|
||||
use jsonschema::JSONSchema;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
@ -516,6 +516,7 @@ fn build_prompt(
|
||||
Путь проекта: {}
|
||||
Цель пользователя: {}
|
||||
{}
|
||||
Важно: предложи исправления в первую очередь по находкам (findings) из отчёта — каждая находка может быть закрыта одним или несколькими действиями (CREATE_FILE, PATCH_FILE и т.д.). В отчёте ниже есть массив findings с полями title, details, path — используй их как приоритет.
|
||||
Отчёт анализа (JSON):
|
||||
{}
|
||||
{}
|
||||
@ -1180,6 +1181,91 @@ fn parse_plan_response(json_str: &str) -> Result<PlanParseResult, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Контекст для генерации narrative: путь, находки, число действий (без вызова LLM).
|
||||
pub fn build_narrative_context(report: &AnalyzeReport) -> String {
|
||||
let mut parts = vec![
|
||||
format!("Проект: {}", report.path),
|
||||
format!("Находок: {}", report.findings.len()),
|
||||
format!("Доступных исправлений: {}", report.actions.len()),
|
||||
];
|
||||
if !report.findings.is_empty() {
|
||||
let list: Vec<String> = report
|
||||
.findings
|
||||
.iter()
|
||||
.take(10)
|
||||
.map(|f| format!("- {}: {}", f.title, f.details))
|
||||
.collect();
|
||||
parts.push("Находки:".to_string());
|
||||
parts.push(list.join("\n"));
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Запрашивает у LLM краткий narrative (2–4 предложения) по отчёту. Без JSON — только текст.
|
||||
/// Использует те же PAPAYU_LLM_* переменные. При ошибке или отсутствии конфига возвращает Err.
|
||||
pub async fn fetch_narrative_for_report(report: &AnalyzeReport) -> Result<String, String> {
|
||||
let api_url = std::env::var("PAPAYU_LLM_API_URL").map_err(|_| "PAPAYU_LLM_API_URL not set".to_string())?;
|
||||
let api_url = api_url.trim();
|
||||
if api_url.is_empty() {
|
||||
return Err("PAPAYU_LLM_API_URL is empty".to_string());
|
||||
}
|
||||
let api_key = std::env::var("PAPAYU_LLM_API_KEY").ok();
|
||||
let model = std::env::var("PAPAYU_LLM_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
|
||||
let timeout_sec = std::env::var("PAPAYU_LLM_TIMEOUT_SEC")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.unwrap_or(30);
|
||||
let max_tokens = std::env::var("PAPAYU_LLM_MAX_TOKENS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
.unwrap_or(512);
|
||||
|
||||
let system = "Ты — аудитор проектов. Напиши краткий вывод для разработчика: 2–4 предложения по контексту ниже. Только текст, без заголовков и маркированных списков. Язык: русский.";
|
||||
let user = build_narrative_context(report);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_sec))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client: {}", e))?;
|
||||
let body = ChatRequest {
|
||||
model: model.trim().to_string(),
|
||||
messages: vec![
|
||||
ChatMessage { role: "system".to_string(), content: system.to_string() },
|
||||
ChatMessage { role: "user".to_string(), content: user },
|
||||
],
|
||||
temperature: Some(0.3),
|
||||
max_tokens: Some(max_tokens),
|
||||
top_p: Some(1.0),
|
||||
presence_penalty: Some(0.0),
|
||||
frequency_penalty: Some(0.0),
|
||||
response_format: None,
|
||||
};
|
||||
let mut req = client.post(api_url).json(&body);
|
||||
if let Some(ref key) = api_key {
|
||||
if !key.trim().is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {}", key.trim()));
|
||||
}
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| format!("Request: {}", e))?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.map_err(|e| format!("Response: {}", e))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("API {}: {}", status, text.chars().take(500).collect::<String>()));
|
||||
}
|
||||
let chat: ChatResponse = serde_json::from_str(&text).map_err(|e| format!("Response JSON: {}", e))?;
|
||||
let content = chat
|
||||
.choices
|
||||
.as_ref()
|
||||
.and_then(|c| c.first())
|
||||
.and_then(|c| c.message.content.as_deref())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if content.is_empty() {
|
||||
return Err("Empty narrative from API".to_string());
|
||||
}
|
||||
Ok(content.to_string())
|
||||
}
|
||||
|
||||
const MAX_CONTEXT_ROUNDS: u32 = 2;
|
||||
|
||||
/// Один запрос к LLM без repair/retry. Для мульти-провайдера: сбор планов от нескольких ИИ.
|
||||
|
||||
@ -13,6 +13,7 @@ mod preview_actions;
|
||||
mod project_content;
|
||||
mod projects;
|
||||
mod propose_actions;
|
||||
mod rag_query;
|
||||
mod redo_last;
|
||||
mod run_batch;
|
||||
mod settings_export;
|
||||
@ -38,10 +39,12 @@ pub use projects::{
|
||||
list_projects, list_sessions, set_project_settings,
|
||||
};
|
||||
pub use propose_actions::propose_actions;
|
||||
pub use rag_query::chat_on_project;
|
||||
pub use redo_last::redo_last;
|
||||
pub use run_batch::run_batch;
|
||||
pub use settings_export::{export_settings, import_settings};
|
||||
pub use trends::{fetch_trends_recommendations, get_trends_recommendations};
|
||||
pub use llm_planner::{fetch_narrative_for_report, is_llm_configured};
|
||||
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;
|
||||
|
||||
103
src-tauri/src/commands/rag_query.rs
Normal file
103
src-tauri/src/commands/rag_query.rs
Normal file
@ -0,0 +1,103 @@
|
||||
//! Упрощённый RAG: контекст из файлов проекта + вопрос → LLM. Без эмбеддингов, keyword-контекст.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::project_content;
|
||||
|
||||
const RAG_CONTEXT_CHARS: usize = 80_000;
|
||||
|
||||
/// Собирает контекст по проекту и отправляет вопрос в LLM. Возвращает ответ или ошибку.
|
||||
pub async fn chat_on_project(project_path: &Path, question: &str) -> Result<String, String> {
|
||||
if !project_path.exists() || !project_path.is_dir() {
|
||||
return Err("Папка проекта не найдена".to_string());
|
||||
}
|
||||
let context = project_content::get_project_content_for_llm(
|
||||
project_path,
|
||||
Some(RAG_CONTEXT_CHARS),
|
||||
);
|
||||
let api_url = std::env::var("PAPAYU_LLM_API_URL").map_err(|_| "PAPAYU_LLM_API_URL не задан".to_string())?;
|
||||
let api_url = api_url.trim();
|
||||
if api_url.is_empty() {
|
||||
return Err("PAPAYU_LLM_API_URL пустой".to_string());
|
||||
}
|
||||
let api_key = std::env::var("PAPAYU_LLM_API_KEY").ok();
|
||||
let model = std::env::var("PAPAYU_LLM_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
|
||||
let timeout_sec = std::env::var("PAPAYU_LLM_TIMEOUT_SEC")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.unwrap_or(60);
|
||||
let max_tokens = std::env::var("PAPAYU_LLM_MAX_TOKENS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
.unwrap_or(4096);
|
||||
|
||||
let system = "Ты — ассистент по коду проекта. Отвечай кратко по контексту ниже. Если в контексте нет ответа — так и скажи. Язык ответа: русский.";
|
||||
let user = format!(
|
||||
"Контекст (файлы проекта):\n\n{}\n\nВопрос: {}",
|
||||
context.chars().take(120_000).collect::<String>(),
|
||||
question
|
||||
);
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ChatMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
#[derive(serde::Serialize)]
|
||||
struct ChatRequest {
|
||||
model: String,
|
||||
messages: Vec<ChatMessage>,
|
||||
temperature: f32,
|
||||
max_tokens: u32,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ChatChoice {
|
||||
message: ChatMessageResponse,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ChatMessageResponse {
|
||||
content: Option<String>,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ChatResponse {
|
||||
choices: Option<Vec<ChatChoice>>,
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_sec))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client: {}", e))?;
|
||||
let body = ChatRequest {
|
||||
model: model.trim().to_string(),
|
||||
messages: vec![
|
||||
ChatMessage { role: "system".to_string(), content: system.to_string() },
|
||||
ChatMessage { role: "user".to_string(), content: user },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens,
|
||||
};
|
||||
let mut req = client.post(api_url).json(&body);
|
||||
if let Some(ref key) = api_key {
|
||||
if !key.trim().is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {}", key.trim()));
|
||||
}
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| format!("Request: {}", e))?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.map_err(|e| format!("Response: {}", e))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("API {}: {}", status, text.chars().take(300).collect::<String>()));
|
||||
}
|
||||
let chat: ChatResponse = serde_json::from_str(&text).map_err(|e| format!("JSON: {}", e))?;
|
||||
let content = chat
|
||||
.choices
|
||||
.as_ref()
|
||||
.and_then(|c| c.first())
|
||||
.and_then(|c| c.message.content.as_deref())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if content.is_empty() {
|
||||
return Err("Пустой ответ от API".to_string());
|
||||
}
|
||||
Ok(content.to_string())
|
||||
}
|
||||
@ -1,12 +1,14 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::agent_sync;
|
||||
use crate::audit_log;
|
||||
use crate::commands::get_project_profile::get_project_limits;
|
||||
use crate::commands::{analyze_project, apply_actions, preview_actions};
|
||||
use crate::commands::{analyze_project, apply_actions, fetch_narrative_for_report, preview_actions};
|
||||
use crate::commands::is_llm_configured;
|
||||
use crate::snyk_sync;
|
||||
use crate::tx::get_undo_redo_state;
|
||||
use crate::types::{BatchEvent, BatchPayload};
|
||||
use tauri::AppHandle;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
pub async fn run_batch(app: AppHandle, payload: BatchPayload) -> Result<Vec<BatchEvent>, String> {
|
||||
let mut events = Vec::new();
|
||||
@ -17,14 +19,28 @@ pub async fn run_batch(app: AppHandle, payload: BatchPayload) -> Result<Vec<Batc
|
||||
payload.paths.clone()
|
||||
};
|
||||
|
||||
let report = analyze_project(paths.clone(), payload.attached_files.clone())
|
||||
let mut report = analyze_project(paths.clone(), payload.attached_files.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
if is_llm_configured() {
|
||||
if let Ok(narrative) = fetch_narrative_for_report(&report).await {
|
||||
report.narrative = narrative;
|
||||
}
|
||||
}
|
||||
let snyk_findings = if snyk_sync::is_snyk_sync_enabled() {
|
||||
snyk_sync::fetch_snyk_code_issues().await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
agent_sync::write_agent_sync_if_enabled(&report, snyk_findings);
|
||||
if let Ok(dir) = app.path().app_data_dir() {
|
||||
let _ = audit_log::log_event(
|
||||
&dir,
|
||||
"analyze",
|
||||
paths.first().map(String::as_str),
|
||||
Some("ok"),
|
||||
Some(&format!("findings={}", report.findings.len())),
|
||||
);
|
||||
}
|
||||
events.push(BatchEvent {
|
||||
kind: "report".to_string(),
|
||||
report: Some(report.clone()),
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
mod agent_sync;
|
||||
mod audit_log;
|
||||
mod commands;
|
||||
mod policy_engine;
|
||||
mod secrets_guard;
|
||||
mod snyk_sync;
|
||||
mod context;
|
||||
mod domain_notes;
|
||||
@ -16,28 +19,44 @@ mod verify;
|
||||
use commands::FolderLinks;
|
||||
use commands::{
|
||||
add_project, agentic_run, analyze_project, analyze_weekly_reports, append_session_event,
|
||||
apply_actions, apply_actions_tx, apply_project_setting_cmd, 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, save_report_to_file,
|
||||
set_project_settings, undo_available, undo_last, undo_last_tx, undo_status,
|
||||
apply_actions, apply_actions_tx, apply_project_setting_cmd, chat_on_project, export_settings,
|
||||
fetch_narrative_for_report, 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, save_report_to_file, set_project_settings, undo_available, undo_last,
|
||||
undo_last_tx, undo_status,
|
||||
};
|
||||
use tauri::Manager;
|
||||
use types::{ApplyPayload, BatchPayload};
|
||||
|
||||
#[tauri::command]
|
||||
async fn analyze_project_cmd(
|
||||
app: tauri::AppHandle,
|
||||
paths: Vec<String>,
|
||||
attached_files: Option<Vec<String>>,
|
||||
) -> Result<types::AnalyzeReport, String> {
|
||||
let report = analyze_project(paths, attached_files)?;
|
||||
let mut report = analyze_project(paths.clone(), attached_files)?;
|
||||
if commands::is_llm_configured() {
|
||||
if let Ok(narrative) = fetch_narrative_for_report(&report).await {
|
||||
report.narrative = narrative;
|
||||
}
|
||||
}
|
||||
let snyk_findings = if snyk_sync::is_snyk_sync_enabled() {
|
||||
snyk_sync::fetch_snyk_code_issues().await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
agent_sync::write_agent_sync_if_enabled(&report, snyk_findings);
|
||||
if let Ok(dir) = app.path().app_data_dir() {
|
||||
let _ = audit_log::log_event(
|
||||
&dir,
|
||||
"analyze",
|
||||
paths.first().map(String::as_str),
|
||||
Some("ok"),
|
||||
Some(&format!("findings={}", report.findings.len())),
|
||||
);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
@ -48,7 +67,17 @@ fn preview_actions_cmd(payload: ApplyPayload) -> Result<types::PreviewResult, St
|
||||
|
||||
#[tauri::command]
|
||||
fn apply_actions_cmd(app: tauri::AppHandle, payload: ApplyPayload) -> types::ApplyResult {
|
||||
apply_actions(app, payload)
|
||||
let result = apply_actions(app.clone(), payload.clone());
|
||||
if let Ok(dir) = app.path().app_data_dir() {
|
||||
let _ = audit_log::log_event(
|
||||
&dir,
|
||||
"apply",
|
||||
Some(&payload.root_path),
|
||||
if result.ok { Some("ok") } else { Some("fail") },
|
||||
result.error.as_deref(),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@ -149,6 +178,37 @@ async fn distill_and_save_domain_note_cmd(
|
||||
domain_notes::distill_and_save_note(path, &query, &answer_md, &sources_tuples, confidence).await
|
||||
}
|
||||
|
||||
/// Журнал аудита: последние события.
|
||||
#[tauri::command]
|
||||
fn audit_log_list_cmd(app: tauri::AppHandle, limit: Option<usize>) -> Result<Vec<audit_log::AuditEvent>, String> {
|
||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
Ok(audit_log::read_events(&dir, limit.unwrap_or(100)))
|
||||
}
|
||||
|
||||
/// Сканирование проекта на подозрительные секреты.
|
||||
#[tauri::command]
|
||||
fn scan_secrets_cmd(project_path: String) -> Vec<secrets_guard::SecretSuspicion> {
|
||||
secrets_guard::scan_secrets(std::path::Path::new(&project_path))
|
||||
}
|
||||
|
||||
/// Список правил политик.
|
||||
#[tauri::command]
|
||||
fn get_policies_cmd() -> Vec<policy_engine::PolicyRule> {
|
||||
policy_engine::get_policies()
|
||||
}
|
||||
|
||||
/// Проверка проекта по правилам.
|
||||
#[tauri::command]
|
||||
fn run_policy_check_cmd(project_path: String) -> Vec<policy_engine::PolicyCheckResult> {
|
||||
policy_engine::run_policy_check(std::path::Path::new(&project_path))
|
||||
}
|
||||
|
||||
/// RAG: вопрос по коду проекта (контекст из файлов + LLM).
|
||||
#[tauri::command]
|
||||
async fn rag_query_cmd(project_path: String, question: String) -> Result<String, String> {
|
||||
chat_on_project(std::path::Path::new(&project_path), &question).await
|
||||
}
|
||||
|
||||
/// Сохранить отчёт в docs/reports/weekly_YYYY-MM-DD.md.
|
||||
#[tauri::command]
|
||||
fn save_report_cmd(
|
||||
@ -172,6 +232,11 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
analyze_project_cmd,
|
||||
audit_log_list_cmd,
|
||||
scan_secrets_cmd,
|
||||
get_policies_cmd,
|
||||
run_policy_check_cmd,
|
||||
rag_query_cmd,
|
||||
preview_actions_cmd,
|
||||
apply_actions_cmd,
|
||||
undo_last,
|
||||
|
||||
112
src-tauri/src/policy_engine.rs
Normal file
112
src-tauri/src/policy_engine.rs
Normal file
@ -0,0 +1,112 @@
|
||||
//! Движок политик: проверка проекта по правилам (README, .gitignore, .env не в репо и т.д.).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicyRule {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub check: String, // "file_exists" | "file_missing" | "no_env_in_repo"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicyCheckResult {
|
||||
pub rule_id: String,
|
||||
pub passed: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
fn default_rules() -> Vec<PolicyRule> {
|
||||
vec![
|
||||
PolicyRule {
|
||||
id: "readme".to_string(),
|
||||
name: "README".to_string(),
|
||||
description: "В корне должен быть README.md или аналог".to_string(),
|
||||
check: "file_exists".to_string(),
|
||||
},
|
||||
PolicyRule {
|
||||
id: "gitignore".to_string(),
|
||||
name: ".gitignore".to_string(),
|
||||
description: "Должен быть .gitignore".to_string(),
|
||||
check: "file_exists".to_string(),
|
||||
},
|
||||
PolicyRule {
|
||||
id: "no_env".to_string(),
|
||||
name: ".env не в репо".to_string(),
|
||||
description: ".env не должен коммититься (должен быть в .gitignore)".to_string(),
|
||||
check: "no_env_in_repo".to_string(),
|
||||
},
|
||||
PolicyRule {
|
||||
id: "tests".to_string(),
|
||||
name: "Папка tests/".to_string(),
|
||||
description: "Рекомендуется иметь tests/ или __tests__".to_string(),
|
||||
check: "dir_exists".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Возвращает список правил по умолчанию.
|
||||
pub fn get_policies() -> Vec<PolicyRule> {
|
||||
default_rules()
|
||||
}
|
||||
|
||||
/// Запускает проверку проекта по правилам.
|
||||
pub fn run_policy_check(project_path: &Path) -> Vec<PolicyCheckResult> {
|
||||
let rules = get_policies();
|
||||
let mut results = Vec::with_capacity(rules.len());
|
||||
for rule in rules {
|
||||
let (passed, message) = match rule.check.as_str() {
|
||||
"file_exists" => {
|
||||
let files = match rule.id.as_str() {
|
||||
"readme" => ["README.md", "README.MD", "README.txt", "README"],
|
||||
"gitignore" => [".gitignore", "", "", ""],
|
||||
_ => continue,
|
||||
};
|
||||
let exists = files.iter().any(|f| !f.is_empty() && project_path.join(f).exists());
|
||||
(
|
||||
exists,
|
||||
if exists {
|
||||
"OK".to_string()
|
||||
} else {
|
||||
format!("Отсутствует: {}", rule.name)
|
||||
},
|
||||
)
|
||||
}
|
||||
"dir_exists" => {
|
||||
let exists = project_path.join("tests").is_dir() || project_path.join("__tests__").is_dir();
|
||||
(
|
||||
exists,
|
||||
if exists { "OK".to_string() } else { "Нет tests/ или __tests__".to_string() },
|
||||
)
|
||||
}
|
||||
"no_env_in_repo" => {
|
||||
let env_path = project_path.join(".env");
|
||||
let gitignore = project_path.join(".gitignore");
|
||||
let env_exists = env_path.is_file();
|
||||
let ignored = if gitignore.is_file() {
|
||||
std::fs::read_to_string(&gitignore).map_or(false, |c| c.lines().any(|l| l.trim() == ".env"))
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let passed = !env_exists || ignored;
|
||||
(
|
||||
passed,
|
||||
if passed {
|
||||
"OK".to_string()
|
||||
} else {
|
||||
".env присутствует; добавьте .env в .gitignore".to_string()
|
||||
},
|
||||
)
|
||||
}
|
||||
_ => (false, "Неизвестное правило".to_string()),
|
||||
};
|
||||
results.push(PolicyCheckResult {
|
||||
rule_id: rule.id.clone(),
|
||||
passed,
|
||||
message,
|
||||
});
|
||||
}
|
||||
results
|
||||
}
|
||||
115
src-tauri/src/secrets_guard.rs
Normal file
115
src-tauri/src/secrets_guard.rs
Normal file
@ -0,0 +1,115 @@
|
||||
//! Сканирование проекта на типичные утечки: ключи в коде, .env в репо, хардкод паролей.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const MAX_FILE_SIZE: usize = 256 * 1024; // 256 KB
|
||||
const SKIP_DIRS: &[&str] = &["node_modules", "target", "dist", ".git", "build", ".next"];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecretSuspicion {
|
||||
pub path: String,
|
||||
pub line: Option<u32>,
|
||||
pub kind: String,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
fn is_skip_dir(name: &str) -> bool {
|
||||
SKIP_DIRS.contains(&name)
|
||||
}
|
||||
|
||||
fn check_content(path: &str, content: &str) -> Vec<SecretSuspicion> {
|
||||
let mut out = Vec::new();
|
||||
let _lower = content.to_lowercase();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.contains("api_key") && line_lower.contains("=") && !line_lower.contains("example") {
|
||||
out.push(SecretSuspicion {
|
||||
path: path.to_string(),
|
||||
line: Some((i + 1) as u32),
|
||||
kind: "api_key".to_string(),
|
||||
snippet: line.trim().chars().take(80).collect::<String>(),
|
||||
});
|
||||
}
|
||||
if line_lower.contains("password") && line_lower.contains("=") && !line_lower.contains("example") && !line_lower.contains("env.example") {
|
||||
out.push(SecretSuspicion {
|
||||
path: path.to_string(),
|
||||
line: Some((i + 1) as u32),
|
||||
kind: "password".to_string(),
|
||||
snippet: line.trim().chars().take(80).collect::<String>(),
|
||||
});
|
||||
}
|
||||
if line_lower.contains("secret") && line_lower.contains("=") && !line_lower.contains("example") {
|
||||
out.push(SecretSuspicion {
|
||||
path: path.to_string(),
|
||||
line: Some((i + 1) as u32),
|
||||
kind: "secret".to_string(),
|
||||
snippet: line.trim().chars().take(80).collect::<String>(),
|
||||
});
|
||||
}
|
||||
if (line_lower.contains("sk-") || line_lower.contains("ghp_") || line_lower.contains("xoxb-")) && line.len() > 10 {
|
||||
out.push(SecretSuspicion {
|
||||
path: path.to_string(),
|
||||
line: Some((i + 1) as u32),
|
||||
kind: "token_like".to_string(),
|
||||
snippet: "[REDACTED]".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if path.ends_with(".env") && !path.ends_with(".env.example") {
|
||||
if !out.is_empty() {
|
||||
return out;
|
||||
}
|
||||
out.push(SecretSuspicion {
|
||||
path: path.to_string(),
|
||||
line: None,
|
||||
kind: "env_file".to_string(),
|
||||
snippet: ".env не должен быть в репозитории; используйте .env.example".to_string(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Сканирует директорию и возвращает список подозрений на утечку секретов.
|
||||
pub fn scan_secrets(project_path: &Path) -> Vec<SecretSuspicion> {
|
||||
let mut out = Vec::new();
|
||||
let walker = walkdir::WalkDir::new(project_path)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
e.path().file_name().map_or(true, |n| {
|
||||
!is_skip_dir(n.to_string_lossy().as_ref())
|
||||
})
|
||||
});
|
||||
for entry in walker.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 name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
let allowed = matches!(
|
||||
(ext, name),
|
||||
("ts", _) | ("tsx", _) | ("js", _) | ("jsx", _) | ("rs", _) | ("py", _) | ("json", _)
|
||||
| ("env", _) | ("yaml", _) | ("yml", _) | ("toml", _) | ("md", _)
|
||||
) || name.starts_with(".env");
|
||||
if !allowed {
|
||||
continue;
|
||||
}
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if content.len() > MAX_FILE_SIZE {
|
||||
continue;
|
||||
}
|
||||
let rel = path.strip_prefix(project_path).unwrap_or(path);
|
||||
let rel_str = rel.to_string_lossy().to_string();
|
||||
for s in check_content(&rel_str, &content) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
68
src/App.tsx
68
src/App.tsx
@ -3,6 +3,10 @@ import Tasks from "./pages/Tasks";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import ProjectNotes from "./pages/ProjectNotes";
|
||||
import Updates from "./pages/Updates";
|
||||
import AuditLog from "./pages/AuditLog";
|
||||
import SecretsGuard from "./pages/SecretsGuard";
|
||||
import PolicyEngine from "./pages/PolicyEngine";
|
||||
import ProjectChat from "./pages/ProjectChat";
|
||||
import Finances from "./pages/Finances";
|
||||
import Personnel from "./pages/Personnel";
|
||||
|
||||
@ -103,6 +107,66 @@ function Layout({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
Обновления
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/audit"
|
||||
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="/secrets"
|
||||
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="/policies"
|
||||
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="/project-chat"
|
||||
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>
|
||||
@ -119,6 +183,10 @@ export default function App() {
|
||||
<Route path="/panel" element={<Dashboard />} />
|
||||
<Route path="/notes" element={<ProjectNotes />} />
|
||||
<Route path="/updates" element={<Updates />} />
|
||||
<Route path="/audit" element={<AuditLog />} />
|
||||
<Route path="/secrets" element={<SecretsGuard />} />
|
||||
<Route path="/policies" element={<PolicyEngine />} />
|
||||
<Route path="/project-chat" element={<ProjectChat />} />
|
||||
<Route path="/finances" element={<Finances />} />
|
||||
<Route path="/personnel" element={<Personnel />} />
|
||||
</Routes>
|
||||
|
||||
92
src/pages/AuditLog.tsx
Normal file
92
src/pages/AuditLog.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface AuditEvent {
|
||||
ts: string;
|
||||
event_type: string;
|
||||
project_path: string | null;
|
||||
result: string | null;
|
||||
details: string | null;
|
||||
}
|
||||
|
||||
export default function AuditLog() {
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await invoke<AuditEvent[]>("audit_log_list_cmd", { limit: 100 });
|
||||
setEvents(list);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 16 }}>Журнал аудита</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", marginBottom: 24 }}>
|
||||
События анализа и применения изменений. Лог хранится локально.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--color-border)",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
{error && <p style={{ color: "#dc2626", marginBottom: 16 }}>{error}</p>}
|
||||
{events.length === 0 && !loading && (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>Событий пока нет.</p>
|
||||
)}
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{events.map((ev, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
marginBottom: 8,
|
||||
background: "var(--color-card)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--color-border)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>{ev.event_type}</span>
|
||||
{ev.project_path && (
|
||||
<span style={{ color: "var(--color-text-muted)", marginLeft: 8 }}>
|
||||
{ev.project_path}
|
||||
</span>
|
||||
)}
|
||||
{ev.result && (
|
||||
<span style={{ marginLeft: 8, color: ev.result === "ok" ? "#16a34a" : "#dc2626" }}>
|
||||
{ev.result}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ marginTop: 4, color: "var(--color-text-muted)", fontSize: 12 }}>
|
||||
{ev.ts}
|
||||
{ev.details && ` · ${ev.details}`}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/pages/PolicyEngine.tsx
Normal file
139
src/pages/PolicyEngine.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
interface PolicyRule {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
check: string;
|
||||
}
|
||||
|
||||
interface PolicyCheckResult {
|
||||
rule_id: string;
|
||||
passed: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default function PolicyEngine() {
|
||||
const [projectPath, setProjectPath] = useState<string | null>(null);
|
||||
const [rules, setRules] = useState<PolicyRule[]>([]);
|
||||
const [results, setResults] = useState<PolicyCheckResult[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadPolicies = async () => {
|
||||
try {
|
||||
const list = await invoke<PolicyRule[]>("get_policies_cmd");
|
||||
setRules(list);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const selectFolder = async () => {
|
||||
const selected = await open({ directory: true });
|
||||
if (selected) setProjectPath(selected);
|
||||
};
|
||||
|
||||
const runCheck = async () => {
|
||||
if (!projectPath) {
|
||||
setError("Сначала выберите папку проекта");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await invoke<PolicyCheckResult[]>("run_policy_check_cmd", {
|
||||
projectPath,
|
||||
});
|
||||
setResults(list);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadPolicies();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 700 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 16 }}>Движок политик</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", marginBottom: 24 }}>
|
||||
Проверка проекта по правилам: README, .gitignore, .env не в репо, наличие tests/.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 24, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectFolder}
|
||||
style={{
|
||||
padding: "10px 18px",
|
||||
background: "var(--color-border)",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Выбрать папку
|
||||
</button>
|
||||
{projectPath && (
|
||||
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>{projectPath}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={runCheck}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
padding: "10px 18px",
|
||||
background: "#2563eb",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
fontWeight: 600,
|
||||
cursor: loading || !projectPath ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{loading ? "Проверка…" : "Проверить"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p style={{ color: "#dc2626", marginBottom: 16 }}>{error}</p>}
|
||||
{results && (
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{results.map((r, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
marginBottom: 8,
|
||||
background: "var(--color-card)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--color-border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
background: r.passed ? "#16a34a" : "#dc2626",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={r.passed ? "Выполнено" : "Не выполнено"}
|
||||
/>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600 }}>{r.rule_id}</span>
|
||||
<div style={{ fontSize: 14, color: "var(--color-text-muted)" }}>{r.message}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/pages/ProjectChat.tsx
Normal file
111
src/pages/ProjectChat.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
export default function ProjectChat() {
|
||||
const [projectPath, setProjectPath] = useState<string | null>(null);
|
||||
const [question, setQuestion] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectFolder = async () => {
|
||||
const selected = await open({ directory: true });
|
||||
if (selected) setProjectPath(selected);
|
||||
};
|
||||
|
||||
const ask = async () => {
|
||||
if (!projectPath || !question.trim()) {
|
||||
setError("Выберите папку проекта и введите вопрос");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAnswer(null);
|
||||
try {
|
||||
const result = await invoke<string>("rag_query_cmd", {
|
||||
projectPath,
|
||||
question: question.trim(),
|
||||
});
|
||||
setAnswer(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 16 }}>Вопрос по проекту</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", marginBottom: 24 }}>
|
||||
Задайте вопрос по коду — ответ будет с учётом контекста файлов проекта (нужен PAPAYU_LLM_API_URL).
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 16, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectFolder}
|
||||
style={{
|
||||
padding: "10px 18px",
|
||||
background: "var(--color-border)",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Выбрать папку
|
||||
</button>
|
||||
{projectPath && (
|
||||
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>{projectPath}</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="Например: где настраивается роутинг? или какие тесты есть для API?"
|
||||
rows={3}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--color-border)",
|
||||
marginBottom: 12,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={ask}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
background: "#2563eb",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
fontWeight: 600,
|
||||
cursor: loading || !projectPath ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{loading ? "Отправка…" : "Спросить"}
|
||||
</button>
|
||||
{error && <p style={{ marginTop: 16, color: "#dc2626", fontSize: 14 }}>{error}</p>}
|
||||
{answer && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
padding: 16,
|
||||
background: "var(--color-card)",
|
||||
borderRadius: 12,
|
||||
border: "1px solid var(--color-border)",
|
||||
whiteSpace: "pre-wrap",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/pages/SecretsGuard.tsx
Normal file
112
src/pages/SecretsGuard.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
interface SecretSuspicion {
|
||||
path: string;
|
||||
line: number | null;
|
||||
kind: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export default function SecretsGuard() {
|
||||
const [projectPath, setProjectPath] = useState<string | null>(null);
|
||||
const [list, setList] = useState<SecretSuspicion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectFolder = async () => {
|
||||
const selected = await open({ directory: true });
|
||||
if (selected) setProjectPath(selected);
|
||||
};
|
||||
|
||||
const runScan = async () => {
|
||||
if (!projectPath) {
|
||||
setError("Сначала выберите папку проекта");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<SecretSuspicion[]>("scan_secrets_cmd", {
|
||||
projectPath,
|
||||
});
|
||||
setList(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 16 }}>Защита секретов</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", marginBottom: 24 }}>
|
||||
Сканирование проекта на типичные утечки: ключи в коде, .env в репо, подозрительные паттерны.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 24, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectFolder}
|
||||
style={{
|
||||
padding: "10px 18px",
|
||||
background: "var(--color-border)",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Выбрать папку
|
||||
</button>
|
||||
{projectPath && (
|
||||
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>{projectPath}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={runScan}
|
||||
disabled={loading || !projectPath}
|
||||
style={{
|
||||
padding: "10px 18px",
|
||||
background: "#2563eb",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
fontWeight: 600,
|
||||
cursor: loading || !projectPath ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{loading ? "Сканирование…" : "Проверить"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p style={{ color: "#dc2626", marginBottom: 16 }}>{error}</p>}
|
||||
{list.length > 0 && (
|
||||
<p style={{ marginBottom: 12, fontSize: 14 }}>
|
||||
Найдено подозрений: <strong>{list.length}</strong>
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{list.map((s, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
marginBottom: 8,
|
||||
background: "var(--color-card)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--color-border)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>{s.path}</span>
|
||||
{s.line != null && (
|
||||
<span style={{ color: "var(--color-text-muted)", marginLeft: 8 }}>стр. {s.line}</span>
|
||||
)}
|
||||
<span style={{ marginLeft: 8, color: "#b45309" }}>{s.kind}</span>
|
||||
<div style={{ marginTop: 6, fontSize: 13, fontFamily: "monospace" }}>{s.snippet}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user