feat: AI code generation — LLM generates fix actions from findings
This commit is contained in:
parent
2be548d078
commit
af3c81b04d
134
desktop/src-tauri/src/commands/generate_ai_actions.rs
Normal file
134
desktop/src-tauri/src/commands/generate_ai_actions.rs
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
use crate::types::{Action, ActionKind};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GenerateActionsRequest {
|
||||||
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
pub context: String, // llm_context JSON
|
||||||
|
pub findings_json: String, // findings array JSON
|
||||||
|
pub project_path: String,
|
||||||
|
pub max_tokens: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GenerateActionsResponse {
|
||||||
|
pub ok: bool,
|
||||||
|
pub actions: Vec<Action>,
|
||||||
|
pub explanation: String,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LlmActionsOutput {
|
||||||
|
actions: Vec<LlmAction>,
|
||||||
|
explanation: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LlmAction {
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
kind: String, // "create_file" | "update_file" | "create_dir"
|
||||||
|
path: String,
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn generate_ai_actions(
|
||||||
|
request: GenerateActionsRequest,
|
||||||
|
) -> Result<GenerateActionsResponse, String> {
|
||||||
|
let api_key = request.api_key.clone().unwrap_or_default();
|
||||||
|
if api_key.is_empty() && request.provider != "ollama" {
|
||||||
|
return Ok(GenerateActionsResponse {
|
||||||
|
ok: false,
|
||||||
|
actions: vec![],
|
||||||
|
explanation: String::new(),
|
||||||
|
error: Some("API-ключ не указан.".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_prompt = format!(
|
||||||
|
"Ты — PAPA YU, AI-аудитор проектов. На основе контекста и списка найденных проблем сгенерируй конкретные действия для исправления.\n\nВАЖНО: Отвечай ТОЛЬКО валидным JSON без markdown-обёртки. Формат:\n{{\n \"actions\": [\n {{\n \"id\": \"уникальный-id\",\n \"title\": \"Краткое название\",\n \"description\": \"Что делает\",\n \"kind\": \"create_file\",\n \"path\": \"путь/к/файлу\",\n \"content\": \"содержимое\"\n }}\n ],\n \"explanation\": \"Краткое объяснение\"\n}}\n\nДопустимые kind: \"create_file\", \"update_file\", \"create_dir\"\nПуть — относительный от корня проекта. Не более 10 действий.\nПуть проекта: {}\n\nПроблемы:\n{}",
|
||||||
|
request.project_path,
|
||||||
|
request.findings_json
|
||||||
|
);
|
||||||
|
|
||||||
|
let llm_request = super::ask_llm::LlmRequest {
|
||||||
|
provider: request.provider,
|
||||||
|
model: request.model,
|
||||||
|
api_key: request.api_key,
|
||||||
|
base_url: request.base_url,
|
||||||
|
context: request.context,
|
||||||
|
prompt: user_prompt,
|
||||||
|
max_tokens: request.max_tokens.or(Some(4096)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let llm_response = super::ask_llm::ask_llm(llm_request).await?;
|
||||||
|
|
||||||
|
if !llm_response.ok {
|
||||||
|
return Ok(GenerateActionsResponse {
|
||||||
|
ok: false,
|
||||||
|
actions: vec![],
|
||||||
|
explanation: String::new(),
|
||||||
|
error: llm_response.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON from LLM response
|
||||||
|
let content = llm_response.content.trim().to_string();
|
||||||
|
// Strip markdown code fences if present
|
||||||
|
let json_str = content
|
||||||
|
.strip_prefix("```json")
|
||||||
|
.or_else(|| content.strip_prefix("```"))
|
||||||
|
.unwrap_or(&content)
|
||||||
|
.strip_suffix("```")
|
||||||
|
.unwrap_or(&content)
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
match serde_json::from_str::<LlmActionsOutput>(json_str) {
|
||||||
|
Ok(output) => {
|
||||||
|
let actions: Vec<Action> = output
|
||||||
|
.actions
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|a| {
|
||||||
|
let kind = match a.kind.as_str() {
|
||||||
|
"create_file" => ActionKind::CreateFile,
|
||||||
|
"update_file" => ActionKind::UpdateFile,
|
||||||
|
"create_dir" => ActionKind::CreateDir,
|
||||||
|
"delete_file" => ActionKind::DeleteFile,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(Action {
|
||||||
|
id: format!("ai-{}", a.id),
|
||||||
|
title: a.title,
|
||||||
|
description: a.description,
|
||||||
|
kind,
|
||||||
|
path: a.path,
|
||||||
|
content: a.content,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(GenerateActionsResponse {
|
||||||
|
ok: true,
|
||||||
|
actions,
|
||||||
|
explanation: output.explanation,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Ok(GenerateActionsResponse {
|
||||||
|
ok: false,
|
||||||
|
actions: vec![],
|
||||||
|
explanation: String::new(),
|
||||||
|
error: Some(format!(
|
||||||
|
"Ошибка парсинга ответа LLM: {}. Ответ: {}",
|
||||||
|
e,
|
||||||
|
&json_str[..json_str.len().min(200)]
|
||||||
|
)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
mod analyze_project;
|
mod analyze_project;
|
||||||
mod apply_actions;
|
mod apply_actions;
|
||||||
mod ask_llm;
|
pub mod ask_llm;
|
||||||
|
mod generate_ai_actions;
|
||||||
mod get_app_info;
|
mod get_app_info;
|
||||||
mod preview_actions;
|
mod preview_actions;
|
||||||
mod undo_last;
|
mod undo_last;
|
||||||
@ -8,6 +9,7 @@ mod undo_last;
|
|||||||
pub use analyze_project::analyze_project;
|
pub use analyze_project::analyze_project;
|
||||||
pub use apply_actions::apply_actions;
|
pub use apply_actions::apply_actions;
|
||||||
pub use ask_llm::ask_llm;
|
pub use ask_llm::ask_llm;
|
||||||
|
pub use generate_ai_actions::generate_ai_actions;
|
||||||
pub use get_app_info::get_app_info;
|
pub use get_app_info::get_app_info;
|
||||||
pub use preview_actions::preview_actions;
|
pub use preview_actions::preview_actions;
|
||||||
pub use undo_last::undo_last;
|
pub use undo_last::undo_last;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
use commands::{analyze_project, apply_actions, ask_llm, get_app_info, preview_actions, undo_last};
|
use commands::{analyze_project, apply_actions, ask_llm, generate_ai_actions, get_app_info, preview_actions, undo_last};
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@ -26,6 +26,7 @@ pub fn run() {
|
|||||||
undo_last,
|
undo_last,
|
||||||
get_app_info,
|
get_app_info,
|
||||||
ask_llm,
|
ask_llm,
|
||||||
|
generate_ai_actions,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -187,3 +187,30 @@ export async function askLlm(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- AI Code Generation ----
|
||||||
|
|
||||||
|
export interface GenerateActionsResponse {
|
||||||
|
ok: boolean;
|
||||||
|
actions: Action[];
|
||||||
|
explanation: string;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateAiActions(
|
||||||
|
settings: LlmSettings,
|
||||||
|
report: AnalyzeReport,
|
||||||
|
): Promise<GenerateActionsResponse> {
|
||||||
|
return invoke<GenerateActionsResponse>('generate_ai_actions', {
|
||||||
|
request: {
|
||||||
|
provider: settings.provider,
|
||||||
|
model: settings.model,
|
||||||
|
api_key: settings.apiKey || null,
|
||||||
|
base_url: settings.baseUrl || null,
|
||||||
|
context: JSON.stringify(report.llm_context),
|
||||||
|
findings_json: JSON.stringify(report.findings),
|
||||||
|
project_path: report.path,
|
||||||
|
max_tokens: 4096,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { analyzeProject, askLlm, type AnalyzeReport, type Action, type ApplyResult, type UndoResult, type PreviewResult, type DiffItem, type LlmSettings, DEFAULT_LLM_SETTINGS } from '../lib/analyze';
|
import { analyzeProject, askLlm, generateAiActions, type AnalyzeReport, type Action, type ApplyResult, type UndoResult, type PreviewResult, type DiffItem, type LlmSettings, DEFAULT_LLM_SETTINGS } from '../lib/analyze';
|
||||||
import { animateFadeInUp } from '../lib/anime-utils';
|
import { animateFadeInUp } from '../lib/anime-utils';
|
||||||
import { useAppStore } from '../store/app-store';
|
import { useAppStore } from '../store/app-store';
|
||||||
|
|
||||||
@ -121,6 +121,73 @@ export function Tasks() {
|
|||||||
setIsAiAnalyzing(false);
|
setIsAiAnalyzing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [isGeneratingActions, setIsGeneratingActions] = useState(false);
|
||||||
|
|
||||||
|
const handleAiCodeGen = async (report: AnalyzeReport) => {
|
||||||
|
const settings = loadLlmSettings();
|
||||||
|
if (!settings.apiKey && settings.provider !== 'ollama') {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'system', text: '⚠️ API-ключ не настроен. Перейдите в Настройки LLM.' },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingActions(true);
|
||||||
|
setMessages((prev) => [...prev, { role: 'system', text: '🔧 AI генерирует исправления...' }]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await generateAiActions(settings, report);
|
||||||
|
if (resp.ok && resp.actions.length > 0) {
|
||||||
|
// Merge AI actions into the report
|
||||||
|
const updatedReport = {
|
||||||
|
...report,
|
||||||
|
actions: [...(report.actions ?? []), ...resp.actions],
|
||||||
|
};
|
||||||
|
setLastReport(updatedReport);
|
||||||
|
storeSetLastReport(updatedReport, report.path);
|
||||||
|
|
||||||
|
// Init selection for new actions
|
||||||
|
const newSelection: Record<string, boolean> = { ...selectedActions };
|
||||||
|
resp.actions.forEach((a) => { newSelection[a.id] = true; });
|
||||||
|
setSelectedActions(newSelection);
|
||||||
|
|
||||||
|
// Update the last assistant message with new report
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
// Find the last assistant message with this report and update it
|
||||||
|
for (let i = updated.length - 1; i >= 0; i--) {
|
||||||
|
const msg = updated[i];
|
||||||
|
if ('report' in msg && msg.report.path === report.path) {
|
||||||
|
updated[i] = { ...msg, report: updatedReport };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...updated,
|
||||||
|
{ role: 'assistant', text: `🔧 **AI сгенерировал ${resp.actions.length} исправлений** (${settings.model}):\n\n${resp.explanation}` },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
} else if (resp.ok && resp.actions.length === 0) {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'system', text: '✓ AI не нашёл дополнительных исправлений — проект в хорошем состоянии.' },
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'system', text: `❌ Ошибка генерации: ${resp.error}` },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'system', text: `❌ Ошибка: ${e}` },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setIsGeneratingActions(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
@ -605,6 +672,8 @@ export function Tasks() {
|
|||||||
onUndo={handleUndoLast}
|
onUndo={handleUndoLast}
|
||||||
onAiAnalysis={handleAiAnalysis}
|
onAiAnalysis={handleAiAnalysis}
|
||||||
isAiAnalyzing={isAiAnalyzing}
|
isAiAnalyzing={isAiAnalyzing}
|
||||||
|
onAiCodeGen={handleAiCodeGen}
|
||||||
|
isGeneratingActions={isGeneratingActions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -803,6 +872,8 @@ function ReportBlock({
|
|||||||
onUndo: (projectPath: string) => void;
|
onUndo: (projectPath: string) => void;
|
||||||
onAiAnalysis?: (report: AnalyzeReport) => void;
|
onAiAnalysis?: (report: AnalyzeReport) => void;
|
||||||
isAiAnalyzing?: boolean;
|
isAiAnalyzing?: boolean;
|
||||||
|
onAiCodeGen?: (report: AnalyzeReport) => void;
|
||||||
|
isGeneratingActions?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="text-sm text-destructive">Ошибка: {error}</div>;
|
return <div className="text-sm text-destructive">Ошибка: {error}</div>;
|
||||||
@ -948,6 +1019,17 @@ function ReportBlock({
|
|||||||
{isAiAnalyzing ? 'AI анализирует...' : 'AI Анализ'}
|
{isAiAnalyzing ? 'AI анализирует...' : 'AI Анализ'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isCurrentReport && onAiCodeGen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAiCodeGen(r)}
|
||||||
|
disabled={isGeneratingActions}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border bg-green-600 text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isGeneratingActions ? 'animate-spin' : ''}`} />
|
||||||
|
{isGeneratingActions ? 'Генерирую...' : 'AI Исправления'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDownload(r)}
|
onClick={() => onDownload(r)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user