Skip to content

Commit 245e4bf

Browse files
amnotyoungclaude
andauthored
feat: AI 답변을 Markdown 파일로 저장 (#39)
AI 문서 분석 결과를 .md 파일로 내보내는 버튼을 하단 액션 바에 추가. 질문·답변·참조 문서 목록을 한 문서로 묶어 저장하므로, 여러 파일에 흩어진 내용을 AI로 종합한 결과를 나중에 다시 찾아볼 수 있다. 미리보기 패널이 쓰던 `export_markdown` 커맨드를 그대로 재사용해 백엔드 변경이 없다. 경로 검증(시스템 폴더 차단·확장자 강제·덮어쓰기 방지)도 기존 로직을 그대로 따르며, `tauri-plugin-fs` 같은 추가 권한도 필요하지 않다. - 저장 다이얼로그는 질의에 사용한 검색 범위 폴더에서 열린다. 범위 없이 (전체 검색) 물었으면 파일명만 넘겨 OS 기본 위치를 쓴다 - 파일명 기본값은 질문에서 생성(경로 구분자·예약문자 제거, 40자 제한) - 참조 문서 경로는 Windows extended-length 접두사(\\?\)를 정리해 기록 - 본문의 [출처N] 표기는 그대로 두고 참조 문서 번호와 대응 - 마크다운 조립·경로 유틸은 utils/aiAnswerMarkdown.ts 로 분리 — 컴포넌트 파일에서 비컴포넌트를 export 하면 React Fast Refresh 가 깨진다 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 4e15430 commit 245e4bf

3 files changed

Lines changed: 131 additions & 5 deletions

File tree

src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,7 @@ function AppContent() {
798798
error={search.aiError}
799799
onReset={search.resetAi}
800800
currentQuestion={search.aiAskedQuery}
801+
searchScope={search.filters.searchScope}
801802
onCite={handleCitationJump}
802803
onExampleClick={(text) => {
803804
search.setQuery(text);

src/components/search/AiAnswerPanel.tsx

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { memo, useCallback, useMemo, useState } from "react";
22
import { invoke } from "@tauri-apps/api/core";
3+
import { save } from "@tauri-apps/plugin-dialog";
34
import ReactMarkdown from "react-markdown";
45
import remarkGfm from "remark-gfm";
56
import remarkMath from "remark-math";
@@ -8,6 +9,8 @@ import "katex/dist/katex.min.css";
89
import type { AiAnalysis, SourceRef } from "../../types/search";
910
import { FileIcon } from "../ui/FileIcon";
1011
import { ResultContextMenu, useContextMenu } from "./ResultContextMenu";
12+
import { useUIContext } from "../../contexts/UIContext";
13+
import { buildAiAnswerMarkdown, toDefaultSavePath, toSafeFileStem } from "../../utils/aiAnswerMarkdown";
1114

1215
interface Props {
1316
answer: string;
@@ -16,6 +19,8 @@ interface Props {
1619
error: string | null;
1720
onReset: () => void;
1821
currentQuestion?: string;
22+
/** 질의에 사용된 검색 범위 폴더 — Markdown 저장 시 기본 폴더로 쓴다 */
23+
searchScope?: string | null;
1924
onExampleClick?: (text: string) => void;
2025
/** AI 답변 [출처N] 또는 참조 문서 클릭 → 미리보기 인용 점프 */
2126
onCite?: (source: SourceRef) => void;
@@ -65,7 +70,8 @@ function linkifyCitations(text: string): string {
6570
return text.replace(/\[\s*:?\s*(\d+)\s*\]/g, (_m, n) => `[출처${n}](${CITE_SCHEME}${n})`);
6671
}
6772

68-
function AiAnswerPanel({ answer, isStreaming, analysis, error, onReset, currentQuestion, onCite }: Props) {
73+
74+
function AiAnswerPanel({ answer, isStreaming, analysis, error, onReset, currentQuestion, searchScope, onCite }: Props) {
6975
const handleOpenFile = useCallback((path: string) => {
7076
invoke("open_file", { path }).catch(() => {});
7177
}, []);
@@ -305,8 +311,13 @@ function AiAnswerPanel({ answer, isStreaming, analysis, error, onReset, currentQ
305311

306312
{/* 하단 액션 바 */}
307313
{(analysis || error) && (
308-
<CopyableActionBar answer={answer} analysis={analysis} onReset={onReset} />
309-
314+
<CopyableActionBar
315+
answer={answer}
316+
analysis={analysis}
317+
onReset={onReset}
318+
currentQuestion={currentQuestion}
319+
searchScope={searchScope}
320+
/>
310321
)}
311322
</div>
312323
);
@@ -393,9 +404,55 @@ function SourceFileItem({
393404
);
394405
}
395406

396-
/** 하단 액션 바 — 새 질문 + 복사 버튼 */
397-
function CopyableActionBar({ answer, analysis, onReset }: { answer: string; analysis: AiAnalysis | null; onReset: () => void }) {
407+
/** 하단 액션 바 — 새 질문 + 복사 + Markdown 저장 버튼 */
408+
function CopyableActionBar({
409+
answer,
410+
analysis,
411+
onReset,
412+
currentQuestion,
413+
searchScope,
414+
}: {
415+
answer: string;
416+
analysis: AiAnalysis | null;
417+
onReset: () => void;
418+
currentQuestion?: string;
419+
searchScope?: string | null;
420+
}) {
398421
const [copied, setCopied] = useState(false);
422+
const [isSaving, setIsSaving] = useState(false);
423+
const { showToast, updateToast } = useUIContext();
424+
425+
// 답변 + 참조 문서를 .md 파일로 저장 (미리보기 패널과 같은 export_markdown 커맨드 사용)
426+
const handleSaveMarkdown = useCallback(async () => {
427+
const timestamp = new Date().toISOString().slice(0, 10);
428+
let outputPath: string | null = null;
429+
try {
430+
outputPath = await save({
431+
defaultPath: toDefaultSavePath(
432+
searchScope,
433+
`${toSafeFileStem(currentQuestion)}_${timestamp}.md`,
434+
),
435+
filters: [{ name: "Markdown", extensions: ["md"] }],
436+
});
437+
} catch {
438+
showToast("파일 저장 창 열기 실패", "error");
439+
return;
440+
}
441+
if (!outputPath) return; // 사용자 취소
442+
443+
setIsSaving(true);
444+
const toastId = showToast("Markdown 저장 중...", "loading");
445+
try {
446+
const content = buildAiAnswerMarkdown(currentQuestion, answer, analysis);
447+
await invoke("export_markdown", { content, outputPath });
448+
updateToast(toastId, { message: "Markdown 파일로 저장했습니다", type: "success" });
449+
} catch (e) {
450+
const msg = typeof e === "string" ? e : ((e as { message?: string })?.message ?? "저장 실패");
451+
updateToast(toastId, { message: `저장 실패: ${msg}`, type: "error" });
452+
} finally {
453+
setIsSaving(false);
454+
}
455+
}, [answer, analysis, currentQuestion, searchScope, showToast, updateToast]);
399456

400457
const handleCopy = useCallback(async () => {
401458
try {
@@ -451,6 +508,23 @@ function CopyableActionBar({ answer, analysis, onReset }: { answer: string; anal
451508
{copied ? "복사됨" : "복사"}
452509
</button>
453510
)}
511+
{answer && (
512+
<button
513+
onClick={handleSaveMarkdown}
514+
disabled={isSaving}
515+
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium rounded-md transition-colors hover:bg-[var(--color-bg-tertiary)] disabled:opacity-50"
516+
style={{ color: "var(--color-text-muted)" }}
517+
aria-label="AI 답변을 Markdown 파일로 저장"
518+
title="질문·답변·참조 문서를 .md 파일로 저장"
519+
>
520+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
521+
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
522+
<polyline points="17 21 17 13 7 13 7 21" />
523+
<polyline points="7 3 7 8 15 8" />
524+
</svg>
525+
{isSaving ? "저장 중..." : "MD 저장"}
526+
</button>
527+
)}
454528
</div>
455529
{analysis && (
456530
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">

src/utils/aiAnswerMarkdown.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { cleanPath } from "./cleanPath";
2+
import type { AiAnalysis } from "../types/search";
3+
4+
/** 경로에서 파일명만 추출 */
5+
function basename(path: string): string {
6+
return path.replace(/\\/g, "/").split("/").pop() || path;
7+
}
8+
9+
/** AI 답변을 나중에 다시 찾아볼 수 있는 마크다운 문서로 조립.
10+
* 본문의 [출처N] 표기는 그대로 두고, 아래 참조 문서 번호와 대응시킨다. */
11+
export function buildAiAnswerMarkdown(
12+
question: string | undefined,
13+
answer: string,
14+
analysis: AiAnalysis | null,
15+
): string {
16+
const lines: string[] = [`# ${question?.trim() || "AI 문서 분석 결과"}`, ""];
17+
18+
const meta = [new Date().toLocaleString("ko-KR")];
19+
if (analysis?.model) meta.push(analysis.model);
20+
lines.push(`> Anything AI 문서 분석 · ${meta.join(" · ")}`, "", answer.trim(), "");
21+
22+
const files = analysis?.source_files ?? [];
23+
if (files.length > 0) {
24+
lines.push("## 참조 문서", "");
25+
files.forEach((path, i) => {
26+
const hint = analysis?.sources?.[i]?.location_hint;
27+
lines.push(`${i + 1}. **${basename(path)}**${hint ? ` — ${hint}` : ""}`);
28+
lines.push(` \`${cleanPath(path)}\``);
29+
});
30+
lines.push("");
31+
}
32+
33+
return lines.join("\n");
34+
}
35+
36+
/** 질문을 파일명으로 쓸 수 있게 정리 (경로 구분자·예약문자 제거) */
37+
export function toSafeFileStem(question: string | undefined): string {
38+
const stem = (question ?? "").replace(/[<>:"/\\|?*\n\r\t]+/g, "_").trim();
39+
return stem.slice(0, 40) || "AI_문서분석";
40+
}
41+
42+
/** 검색 범위 폴더를 저장 다이얼로그 기본 위치로 삼는다.
43+
* 범위 지정 없이(전체 검색) 질의했으면 파일명만 넘겨 OS 기본 위치를 쓴다. */
44+
export function toDefaultSavePath(
45+
scope: string | null | undefined,
46+
fileName: string,
47+
): string {
48+
const dir = scope ? cleanPath(scope).replace(/[/\\]+$/, "") : "";
49+
if (!dir) return fileName;
50+
return `${dir}${dir.includes("\\") ? "\\" : "/"}${fileName}`;
51+
}

0 commit comments

Comments
 (0)