desktop: Fix with AI on scan findings that need a code change (aiFix: true, AI_FIX_RULES). The host (src-tauri/src/ai_fix.rs) runs the owner's local Claude Code headless in the app folder with Read / Edit / Write / Grep / Glob only (Bash, web tools, sub-agents disallowed) and a prompt it builds from the finding's fields; it refuses while the folder has uncommitted files, streams progress, times out after 10 min with a heartbeat, kills the process group on Cancel or exit, and records the run's changed files so Undo restores exactly those; the window shows the diff with Keep / Undo and rescans. Without claude the button copies the prompt (Copy for AI). Only the claude binary, git and kill / taskkill are started there (tests/security.rs); tests/ai_fix.rs covers the prompt, dirty-tree refusal, undo scope, timeout and cancel

This commit is contained in:
CRTkafa 2026-10-15 06:26:29 +08:00
parent 992f5bcc0e
commit c95f2af8ff
24 changed files with 1023 additions and 10 deletions

View file

@ -0,0 +1,115 @@
// "Fix with AI" for one scan finding: the owner's local Claude Code CLI, run by the host (src-tauri/src/ai_fix.rs)
// in the app folder with the file tools only. Without claude on PATH the button copies the same prompt ("Copy for
// AI"). A dirty git tree is refused with the list of files; a run streams its lines, can be cancelled, and ends with
// the diff of what it changed, Keep (nothing more) and Undo (the host restores exactly those files), and a fresh scan
// that says whether the finding is gone.
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { type AiChange, type AiFinding, type AiFixRun, api, errorText, newJob, onAiFix } from '../api';
import { t } from '../i18n';
import type { ScanFinding } from '../lib/model';
import { useData } from '../store';
import { Icon } from './icons';
export const aiFinding = (f: ScanFinding): AiFinding => ({
rule: f.rule, guideline: f.guideline, store: f.store, title: f.title, fix: f.fix, skill: f.skill,
evidence: (Array.isArray(f.evidence) ? f.evidence : []).slice(0, 5).map(e => ({ file: e.file, line: e.line, ...(e.text ? { text: e.text } : {}) })),
});
type Phase =
| { kind: 'idle' }
| { kind: 'dirty'; files: AiChange[] }
| { kind: 'running'; job: string; lines: string[] }
| { kind: 'done'; run: Extract<AiFixRun, { runId: string }>; lines: string[]; undone?: string[] }
| { kind: 'error'; msg: string };
/** One finding's AI fix, under the finding's text: the button (Fix with AI / Copy for AI), then its panel. */
export function AiFix({ projectId, f, claude, busy, setBusy, rescan, stillThere, onActive }: {
projectId: string; f: ScanFinding; claude: boolean | null; busy: boolean; setBusy: (b: boolean) => void;
rescan: () => void; stillThere: boolean; onActive: (on: boolean) => void;
}) {
const d = useData();
const [p, setP] = useState<Phase>({ kind: 'idle' });
const logRef = useRef<HTMLPreElement>(null);
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; });
const active = p.kind !== 'idle';
useEffect(() => { onActive(active); }, [active]); // eslint-disable-line react-hooks/exhaustive-deps
const copy = async () => {
try {
const info = await api.aiFixInfo(projectId, aiFinding(f));
await navigator.clipboard.writeText(info.prompt);
d.toast({ kind: 'ok', title: t('health.copied') });
} catch (e) { d.toast({ kind: 'bad', title: t('health.copyFailed'), detail: errorText(e) }); }
};
const start = async () => {
const job = newJob('ai-fix');
setBusy(true);
const lines: string[] = [];
const un = await onAiFix(e => { if (e.job === job) { lines.push(e.line); setP({ kind: 'running', job, lines: lines.slice(-200) }); } });
try {
const info = await api.aiFixInfo(projectId, aiFinding(f));
if (!info.git) { setP({ kind: 'error', msg: info.gitError ?? t('ai.noGit') }); return; }
if (info.dirty.length) { setP({ kind: 'dirty', files: info.dirty }); return; }
setP({ kind: 'running', job, lines: [] });
const r = await api.aiFixRun(projectId, aiFinding(f), job);
if (r.refused) { setP({ kind: 'dirty', files: r.dirty }); return; }
setP({ kind: 'done', run: r, lines: lines.slice(-200) });
if (r.changes.length) rescan();
} catch (e) { setP({ kind: 'error', msg: errorText(e) }); }
finally { un(); setBusy(false); }
};
const undo = async (run: Extract<AiFixRun, { runId: string }>) => {
try {
const files = await api.aiFixUndo(run.runId);
setP(x => (x.kind === 'done' ? { ...x, undone: files } : x));
rescan();
} catch (e) { setP({ kind: 'error', msg: errorText(e) }); }
};
const button = claude === false
? <button className="btn btn-sm" onClick={copy} title={t('ai.copyHint')}><Icon name="copy" />{t('ai.copy')}</button>
: <button className="btn btn-sm" onClick={start} disabled={busy || claude === null} title={t('ai.runHint')} aria-busy={p.kind === 'running' || undefined}>
<Icon name={p.kind === 'running' ? 'refresh' : 'sparkle'} className={p.kind === 'running' ? 'spin' : undefined} />{t('ai.run')}</button>;
let panel: ReactNode = null;
if (p.kind === 'dirty') panel = (
<div className="ai-panel" role="status">
<span className="li-sub">{t('ai.dirty', { count: p.files.length })}</span>
<pre className="log selectable">{p.files.slice(0, 30).map(c => `${c.status.padEnd(2)} ${c.path}`).join('\n')}</pre>
<div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div>
</div>
);
else if (p.kind === 'running') panel = (
<div className="ai-panel" role="status">
<span className="li-sub">{t('ai.running')}</span>
<pre className="log" ref={logRef} aria-live="polite">{p.lines.join('\n') || t('ai.starting')}</pre>
<div className="row"><button className="btn btn-sm" onClick={() => { api.cancel(p.job).catch(() => {}); }}><Icon name="x" />{t('common.cancel')}</button></div>
</div>
);
else if (p.kind === 'done') {
const o = p.run.outcome;
const head = o.cancelled ? t('ai.cancelled') : o.timed_out ? t('ai.timedOut') : !p.run.changes.length ? t('ai.noChanges') : t('ai.changed', { count: p.run.changes.length });
panel = (
<div className="ai-panel" role="status">
<span className="li-sub">{head}{p.run.changes.length > 0 && !p.undone ? ` · ${stillThere ? t('ai.stillThere') : t('ai.gone')}` : ''}</span>
{o.summary && <span className="li-sub selectable">{o.summary}</span>}
{p.run.diff && !p.undone && <pre className="log selectable ai-diff" aria-label={t('ai.diff')}>{p.run.diff}</pre>}
{p.undone ? <><span className="li-sub">{t('ai.undone', { count: p.undone.length })}</span><div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div></> : (
<div className="row">
{p.run.changes.length > 0 && <button className="btn btn-sm btn-primary" onClick={() => setP({ kind: 'idle' })}><Icon name="check" />{t('ai.keep')}</button>}
{p.run.changes.length > 0 && <button className="btn btn-sm" onClick={() => undo(p.run)}><Icon name="rotate" />{t('ai.undo')}</button>}
{!p.run.changes.length && <button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button>}
</div>
)}
</div>
);
} else if (p.kind === 'error') panel = (
<div className="ai-panel" role="alert">
<span className="li-sub">{t('ai.failed', { msg: p.msg })}</span>
<div className="row"><button className="btn btn-sm" onClick={() => setP({ kind: 'idle' })}>{t('common.close')}</button></div>
</div>
);
return <>{p.kind === 'idle' || p.kind === 'running' ? <div className="ai-actions">{button}</div> : null}{panel}</>;
}