idea-manager 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/next.config.ts +0 -1
  2. package/package.json +2 -2
  3. package/{src/app/icon.svg → public/favicon.svg} +2 -2
  4. package/src/app/api/filesystem/route.ts +49 -0
  5. package/src/app/api/projects/[id]/cleanup/route.ts +32 -0
  6. package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +36 -0
  7. package/src/app/api/projects/[id]/items/[itemId]/route.ts +23 -1
  8. package/src/app/api/projects/[id]/items/route.ts +51 -1
  9. package/src/app/api/projects/[id]/scan/route.ts +73 -0
  10. package/src/app/api/projects/[id]/scan/stream/route.ts +112 -0
  11. package/src/app/api/projects/[id]/structure/route.ts +34 -3
  12. package/src/app/api/projects/[id]/structure/stream/route.ts +157 -0
  13. package/src/app/api/projects/[id]/sub-projects/[subId]/route.ts +39 -0
  14. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +60 -0
  15. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.ts +26 -0
  16. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.ts +39 -0
  17. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/route.ts +33 -0
  18. package/src/app/api/projects/[id]/sub-projects/route.ts +31 -0
  19. package/src/app/api/projects/route.ts +1 -1
  20. package/src/app/globals.css +465 -5
  21. package/src/app/layout.tsx +3 -0
  22. package/src/app/page.tsx +260 -88
  23. package/src/app/projects/[id]/page.tsx +366 -183
  24. package/src/cli.ts +10 -10
  25. package/src/components/DirectoryPicker.tsx +137 -0
  26. package/src/components/ScanPanel.tsx +743 -0
  27. package/src/components/brainstorm/Editor.tsx +20 -4
  28. package/src/components/brainstorm/MemoPin.tsx +91 -5
  29. package/src/components/dashboard/SubProjectCard.tsx +76 -0
  30. package/src/components/dashboard/TabBar.tsx +42 -0
  31. package/src/components/task/ProjectTree.tsx +223 -0
  32. package/src/components/task/PromptEditor.tsx +107 -0
  33. package/src/components/task/StatusFlow.tsx +43 -0
  34. package/src/components/task/TaskChat.tsx +134 -0
  35. package/src/components/task/TaskDetail.tsx +205 -0
  36. package/src/components/task/TaskList.tsx +119 -0
  37. package/src/components/tree/CardView.tsx +206 -0
  38. package/src/components/tree/RefinePopover.tsx +157 -0
  39. package/src/components/tree/TreeNode.tsx +147 -38
  40. package/src/components/tree/TreeView.tsx +270 -26
  41. package/src/components/ui/ConfirmDialog.tsx +88 -0
  42. package/src/lib/ai/chat-responder.ts +4 -2
  43. package/src/lib/ai/cleanup.ts +87 -0
  44. package/src/lib/ai/client.ts +175 -58
  45. package/src/lib/ai/prompter.ts +19 -24
  46. package/src/lib/ai/refiner.ts +128 -0
  47. package/src/lib/ai/structurer.ts +340 -11
  48. package/src/lib/db/queries/context.ts +76 -0
  49. package/src/lib/db/queries/items.ts +133 -12
  50. package/src/lib/db/queries/projects.ts +12 -8
  51. package/src/lib/db/queries/sub-projects.ts +122 -0
  52. package/src/lib/db/queries/task-conversations.ts +27 -0
  53. package/src/lib/db/queries/task-prompts.ts +32 -0
  54. package/src/lib/db/queries/tasks.ts +133 -0
  55. package/src/lib/db/schema.ts +75 -0
  56. package/src/lib/mcp/server.ts +38 -39
  57. package/src/lib/mcp/tools.ts +47 -45
  58. package/src/lib/scanner.ts +573 -0
  59. package/src/lib/task-store.ts +97 -0
  60. package/src/types/index.ts +65 -0
package/next.config.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { NextConfig } from "next";
2
2
 
3
3
  const nextConfig: NextConfig = {
4
- output: 'standalone',
5
4
  serverExternalPackages: ['better-sqlite3'],
6
5
  };
7
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idea-manager",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "AI 기반 브레인스토밍 → 구조화 → 프롬프트 생성 도구. MCP Server 내장.",
5
5
  "keywords": [
6
6
  "brainstorm",
@@ -37,7 +37,7 @@
37
37
  "mcp": "tsx src/cli.ts mcp"
38
38
  },
39
39
  "dependencies": {
40
- "@anthropic-ai/claude-agent-sdk": "^0.2.66",
40
+ "@anthropic-ai/sdk": "^0.78.0",
41
41
  "@modelcontextprotocol/sdk": "^1.27.1",
42
42
  "better-sqlite3": "^12.6.2",
43
43
  "commander": "^14.0.3",
@@ -5,6 +5,6 @@
5
5
  <stop offset="100%" stop-color="#8b5cf6"/>
6
6
  </linearGradient>
7
7
  </defs>
8
- <rect width="512" height="512" rx="108" fill="url(#bg)"/>
9
- <text x="256" y="390" font-family="system-ui, -apple-system, sans-serif" font-size="420" font-weight="900" fill="white" text-anchor="middle">I</text>
8
+ <rect width="512" height="512" rx="96" fill="url(#bg)"/>
9
+ <text x="256" y="390" font-family="Georgia, 'Times New Roman', serif" font-size="420" font-weight="700" fill="white" text-anchor="middle">I</text>
10
10
  </svg>
@@ -0,0 +1,49 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const IGNORED = new Set([
7
+ 'node_modules', '.git', '.next', 'dist', 'build', '__pycache__',
8
+ '.cache', '.tmp', '.DS_Store', 'coverage', '.turbo', '.vercel',
9
+ ]);
10
+
11
+ export async function GET(request: NextRequest) {
12
+ const dirPath = request.nextUrl.searchParams.get('path') || os.homedir();
13
+
14
+ try {
15
+ const resolved = path.resolve(dirPath);
16
+
17
+ if (!fs.existsSync(resolved)) {
18
+ return NextResponse.json({ error: '경로가 존재하지 않습니다' }, { status: 404 });
19
+ }
20
+
21
+ const stat = fs.statSync(resolved);
22
+ if (!stat.isDirectory()) {
23
+ return NextResponse.json({ error: '디렉토리가 아닙니다' }, { status: 400 });
24
+ }
25
+
26
+ const entries = fs.readdirSync(resolved, { withFileTypes: true });
27
+ const dirs = entries
28
+ .filter(e => e.isDirectory() && !e.name.startsWith('.') && !IGNORED.has(e.name))
29
+ .map(e => ({
30
+ name: e.name,
31
+ path: path.join(resolved, e.name),
32
+ }))
33
+ .sort((a, b) => a.name.localeCompare(b.name));
34
+
35
+ // Check for project markers
36
+ const hasPackageJson = fs.existsSync(path.join(resolved, 'package.json'));
37
+ const hasReadme = fs.existsSync(path.join(resolved, 'README.md'));
38
+ const hasGit = fs.existsSync(path.join(resolved, '.git'));
39
+
40
+ return NextResponse.json({
41
+ current: resolved,
42
+ parent: path.dirname(resolved) !== resolved ? path.dirname(resolved) : null,
43
+ dirs,
44
+ isProject: hasPackageJson || hasReadme || hasGit,
45
+ });
46
+ } catch {
47
+ return NextResponse.json({ error: '디렉토리를 읽을 수 없습니다' }, { status: 500 });
48
+ }
49
+ }
@@ -0,0 +1,32 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getProject } from '@/lib/db/queries/projects';
3
+ import { getItemTree } from '@/lib/db/queries/items';
4
+ import { getBrainstorm } from '@/lib/db/queries/brainstorms';
5
+ import { cleanupItems } from '@/lib/ai/cleanup';
6
+
7
+ export async function POST(
8
+ _request: NextRequest,
9
+ { params }: { params: Promise<{ id: string }> },
10
+ ) {
11
+ const { id } = await params;
12
+ const project = getProject(id);
13
+ if (!project) {
14
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
15
+ }
16
+
17
+ const items = getItemTree(id);
18
+ if (items.length === 0) {
19
+ return NextResponse.json({ items: [], changed: false });
20
+ }
21
+
22
+ const brainstorm = getBrainstorm(id);
23
+ const brainstormContent = brainstorm?.content || '';
24
+
25
+ try {
26
+ const result = await cleanupItems(id, brainstorm?.id || '', items, brainstormContent);
27
+ return NextResponse.json(result);
28
+ } catch (error) {
29
+ const message = error instanceof Error ? error.message : 'Cleanup failed';
30
+ return NextResponse.json({ error: message }, { status: 500 });
31
+ }
32
+ }
@@ -0,0 +1,36 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getItem } from '@/lib/db/queries/items';
3
+ import { getProject } from '@/lib/db/queries/projects';
4
+ import { refineItem } from '@/lib/ai/refiner';
5
+
6
+ export async function POST(
7
+ request: NextRequest,
8
+ { params }: { params: Promise<{ id: string; itemId: string }> },
9
+ ) {
10
+ const { id: projectId, itemId } = await params;
11
+
12
+ const project = getProject(projectId);
13
+ if (!project) {
14
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
15
+ }
16
+
17
+ const item = getItem(itemId);
18
+ if (!item || item.project_id !== projectId) {
19
+ return NextResponse.json({ error: 'Item not found' }, { status: 404 });
20
+ }
21
+
22
+ const body = await request.json();
23
+ const { message } = body;
24
+
25
+ if (!message || typeof message !== 'string' || !message.trim()) {
26
+ return NextResponse.json({ error: 'Message is required' }, { status: 400 });
27
+ }
28
+
29
+ try {
30
+ const result = await refineItem(item, message.trim());
31
+ return NextResponse.json(result);
32
+ } catch (error) {
33
+ const msg = error instanceof Error ? error.message : 'Refine failed';
34
+ return NextResponse.json({ error: msg }, { status: 500 });
35
+ }
36
+ }
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { getDb } from '@/lib/db/index';
3
- import { updateItem } from '@/lib/db/queries/items';
3
+ import { updateItem, deleteItem } from '@/lib/db/queries/items';
4
4
  import type { IItem, ItemStatus } from '@/types';
5
5
 
6
6
  export async function GET(
@@ -48,6 +48,10 @@ export async function PUT(
48
48
  }
49
49
  }
50
50
 
51
+ if (body.is_pinned !== undefined) {
52
+ updates.is_pinned = Boolean(body.is_pinned);
53
+ }
54
+
51
55
  if (body.title !== undefined) updates.title = body.title;
52
56
  if (body.description !== undefined) updates.description = body.description;
53
57
  if (body.priority !== undefined) updates.priority = body.priority;
@@ -61,6 +65,24 @@ export async function PUT(
61
65
  return NextResponse.json(updated);
62
66
  }
63
67
 
68
+ export async function DELETE(
69
+ _request: NextRequest,
70
+ { params }: { params: Promise<{ id: string; itemId: string }> },
71
+ ) {
72
+ const { id: projectId, itemId } = await params;
73
+ const db = getDb();
74
+
75
+ const item = db.prepare('SELECT * FROM items WHERE id = ? AND project_id = ?')
76
+ .get(itemId, projectId) as IItem | undefined;
77
+
78
+ if (!item) {
79
+ return NextResponse.json({ error: 'Item not found' }, { status: 404 });
80
+ }
81
+
82
+ deleteItem(itemId);
83
+ return NextResponse.json({ success: true });
84
+ }
85
+
64
86
  function lockChildrenRecursive(db: ReturnType<typeof getDb>, parentId: string, locked: boolean) {
65
87
  const now = new Date().toISOString();
66
88
  const children = db.prepare('SELECT id FROM items WHERE parent_id = ?').all(parentId) as { id: string }[];
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { getProject } from '@/lib/db/queries/projects';
3
- import { getItemTree } from '@/lib/db/queries/items';
3
+ import { getItemTree, deleteItem, deleteItemsByProject, bulkUpdateStatus } from '@/lib/db/queries/items';
4
4
 
5
5
  export async function GET(
6
6
  _request: NextRequest,
@@ -15,3 +15,53 @@ export async function GET(
15
15
  const tree = getItemTree(id);
16
16
  return NextResponse.json(tree);
17
17
  }
18
+
19
+ // Bulk delete: DELETE /api/projects/[id]/items
20
+ // body: { itemIds: string[] } or { all: true }
21
+ export async function DELETE(
22
+ request: NextRequest,
23
+ { params }: { params: Promise<{ id: string }> },
24
+ ) {
25
+ const { id } = await params;
26
+ const project = getProject(id);
27
+ if (!project) {
28
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
29
+ }
30
+
31
+ const body = await request.json();
32
+
33
+ if (body.all) {
34
+ deleteItemsByProject(id);
35
+ } else if (Array.isArray(body.itemIds)) {
36
+ for (const itemId of body.itemIds) {
37
+ deleteItem(itemId);
38
+ }
39
+ } else {
40
+ return NextResponse.json({ error: 'itemIds or all required' }, { status: 400 });
41
+ }
42
+
43
+ const tree = getItemTree(id);
44
+ return NextResponse.json(tree);
45
+ }
46
+
47
+ // Bulk update status: PATCH /api/projects/[id]/items
48
+ // body: { status: 'done' | 'pending' | 'in_progress' }
49
+ export async function PATCH(
50
+ request: NextRequest,
51
+ { params }: { params: Promise<{ id: string }> },
52
+ ) {
53
+ const { id } = await params;
54
+ const project = getProject(id);
55
+ if (!project) {
56
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
57
+ }
58
+
59
+ const body = await request.json();
60
+ if (!body.status) {
61
+ return NextResponse.json({ error: 'status required' }, { status: 400 });
62
+ }
63
+
64
+ bulkUpdateStatus(id, body.status);
65
+ const tree = getItemTree(id);
66
+ return NextResponse.json(tree);
67
+ }
@@ -0,0 +1,73 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getProject } from '@/lib/db/queries/projects';
3
+ import { getProjectContexts, replaceProjectContexts } from '@/lib/db/queries/context';
4
+ import { scanProjectDirectory } from '@/lib/scanner';
5
+ import { getFileCategory } from '@/lib/scanner';
6
+
7
+ export async function GET(
8
+ _request: NextRequest,
9
+ { params }: { params: Promise<{ id: string }> },
10
+ ) {
11
+ const { id } = await params;
12
+ const project = getProject(id);
13
+ if (!project) {
14
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
15
+ }
16
+
17
+ const contexts = getProjectContexts(id);
18
+ if (contexts.length === 0) {
19
+ return NextResponse.json({ exists: false, files: [], total: 0, totalSize: 0, scannedAt: null });
20
+ }
21
+
22
+ const files = contexts.map(c => ({
23
+ file_path: c.file_path,
24
+ size: c.content.length,
25
+ category: getFileCategory(c.file_path),
26
+ folder: getFolder(c.file_path),
27
+ }));
28
+
29
+ return NextResponse.json({
30
+ exists: true,
31
+ files,
32
+ total: contexts.length,
33
+ totalSize: contexts.reduce((s, c) => s + c.content.length, 0),
34
+ scannedAt: contexts[0]?.scanned_at || null,
35
+ });
36
+ }
37
+
38
+ function getFolder(filePath: string): string {
39
+ const parts = filePath.split('/');
40
+ if (parts.length <= 1 || filePath.startsWith('__')) return '(root)';
41
+ return parts[0];
42
+ }
43
+
44
+ export async function POST(
45
+ _request: NextRequest,
46
+ { params }: { params: Promise<{ id: string }> },
47
+ ) {
48
+ const { id } = await params;
49
+ const project = getProject(id);
50
+ if (!project) {
51
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
52
+ }
53
+
54
+ if (!project.project_path) {
55
+ return NextResponse.json({ error: '프로젝트 경로가 설정되지 않았습니다' }, { status: 400 });
56
+ }
57
+
58
+ try {
59
+ const scanned = scanProjectDirectory(project.project_path);
60
+ const contexts = replaceProjectContexts(id, scanned);
61
+
62
+ return NextResponse.json({
63
+ files: contexts.map(c => ({
64
+ file_path: c.file_path,
65
+ size: c.content.length,
66
+ })),
67
+ total: contexts.length,
68
+ });
69
+ } catch (error) {
70
+ const msg = error instanceof Error ? error.message : 'Scan failed';
71
+ return NextResponse.json({ error: msg }, { status: 500 });
72
+ }
73
+ }
@@ -0,0 +1,112 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { getProject } from '@/lib/db/queries/projects';
3
+ import { replaceProjectContexts } from '@/lib/db/queries/context';
4
+ import { scanProjectDirectoryStream } from '@/lib/scanner';
5
+ import { runAnalysis } from '@/lib/ai/client';
6
+
7
+ export async function GET(
8
+ _request: NextRequest,
9
+ { params }: { params: Promise<{ id: string }> },
10
+ ) {
11
+ const { id } = await params;
12
+ const project = getProject(id);
13
+ if (!project) {
14
+ return new Response('Project not found', { status: 404 });
15
+ }
16
+
17
+ if (!project.project_path) {
18
+ return new Response('No project path', { status: 400 });
19
+ }
20
+
21
+ const projectPath = project.project_path;
22
+ const projectId = id;
23
+
24
+ const stream = new ReadableStream({
25
+ async start(controller) {
26
+ const encoder = new TextEncoder();
27
+
28
+ const send = (event: string, data: unknown) => {
29
+ try {
30
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
31
+ } catch { /* closed */ }
32
+ };
33
+ let directoryTree = '';
34
+ let readmeContent = '';
35
+ let packageJsonContent = '';
36
+
37
+ try {
38
+ const generator = scanProjectDirectoryStream(projectPath);
39
+
40
+ for (const event of generator) {
41
+ if (event.type === 'scanning_dir') {
42
+ send('scanning', { dir: event.dir });
43
+ } else if (event.type === 'file_found') {
44
+ send('file', {
45
+ file_path: event.file!.file_path,
46
+ size: event.file!.size,
47
+ category: event.file!.category,
48
+ folder: event.file!.folder,
49
+ summarized: event.file!.summarized,
50
+ });
51
+ } else if (event.type === 'done') {
52
+ replaceProjectContexts(projectId, event.results!);
53
+ directoryTree = event.results?.find(r => r.file_path === '__directory_tree.txt')?.content || '';
54
+ readmeContent = event.results?.find(r => r.file_path.match(/^README\.md$/i))?.content || '';
55
+ packageJsonContent = event.results?.find(r => r.file_path === 'package.json')?.content || '';
56
+ send('scan_complete', {
57
+ total: event.total,
58
+ totalSize: event.totalSize,
59
+ });
60
+ }
61
+ }
62
+ } catch (error) {
63
+ const msg = error instanceof Error ? error.message : 'Scan failed';
64
+ send('error', { error: msg });
65
+ controller.close();
66
+ return;
67
+ }
68
+
69
+ // Phase 2: Auto-analyze project with AI
70
+ send('analyzing', { message: '프로젝트를 분석하고 있습니다...' });
71
+
72
+ const analysisPrompt = `아래 프로젝트의 디렉토리 구조와 핵심 파일을 보고, 이 프로젝트에 대해 간결하게 설명해주세요.
73
+
74
+ 다음 형식으로 작성해주세요 (마크다운 없이 평문):
75
+ 1. 프로젝트 목적 (1줄)
76
+ 2. 기술 스택 (1줄)
77
+ 3. 주요 서브 프로젝트/모듈 구성 (2-3줄)
78
+ 4. 현재 개발 상태 추정 (1줄)
79
+
80
+ 간결하고 핵심만 담아주세요. 한국어로 작성하세요.
81
+
82
+ === 디렉토리 구조 ===
83
+ ${directoryTree.slice(0, 5000)}
84
+
85
+ ${readmeContent ? `=== README.md ===\n${readmeContent.slice(0, 3000)}` : ''}
86
+
87
+ ${packageJsonContent ? `=== package.json ===\n${packageJsonContent.slice(0, 2000)}` : ''}`;
88
+
89
+ try {
90
+ const analysisResult = await runAnalysis(analysisPrompt, (text) => {
91
+ send('analysis_text', { text });
92
+ });
93
+ send('analysis_done', { description: analysisResult });
94
+ } catch (err) {
95
+ const msg = err instanceof Error ? err.message : 'Analysis failed';
96
+ console.error('[scan] Auto-analysis failed:', msg);
97
+ send('analysis_done', { description: '', error: msg });
98
+ }
99
+
100
+ controller.close();
101
+ },
102
+ });
103
+
104
+ return new Response(stream, {
105
+ headers: {
106
+ 'Content-Type': 'text/event-stream',
107
+ 'Cache-Control': 'no-cache',
108
+ 'Connection': 'keep-alive',
109
+ 'X-Accel-Buffering': 'no',
110
+ },
111
+ });
112
+ }
@@ -1,7 +1,26 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { getProject } from '@/lib/db/queries/projects';
3
3
  import { getBrainstorm } from '@/lib/db/queries/brainstorms';
4
+ import { getProjectContextSummary } from '@/lib/db/queries/context';
4
5
  import { structureWithChat } from '@/lib/ai/structurer';
6
+ import { getTask } from '@/lib/task-store';
7
+
8
+ export async function GET(
9
+ _request: NextRequest,
10
+ { params }: { params: Promise<{ id: string }> },
11
+ ) {
12
+ const { id } = await params;
13
+ const task = getTask(id);
14
+ if (!task) {
15
+ return NextResponse.json({ active: false });
16
+ }
17
+ return NextResponse.json({
18
+ active: task.status === 'running',
19
+ status: task.status,
20
+ startedAt: task.startedAt,
21
+ eventCount: task.events.length,
22
+ });
23
+ }
5
24
 
6
25
  export async function POST(
7
26
  _request: NextRequest,
@@ -14,12 +33,24 @@ export async function POST(
14
33
  }
15
34
 
16
35
  const brainstorm = getBrainstorm(id);
17
- if (!brainstorm || !brainstorm.content.trim()) {
18
- return NextResponse.json({ error: 'No brainstorm content to structure' }, { status: 400 });
36
+ if (!brainstorm) {
37
+ return NextResponse.json({ error: 'Project not initialized' }, { status: 400 });
19
38
  }
20
39
 
40
+ const hasContent = brainstorm.content.trim();
41
+ const hasContext = !!getProjectContextSummary(id);
42
+
43
+ if (!hasContent && !hasContext) {
44
+ return NextResponse.json({ error: '브레인스토밍 내용이나 프로젝트 스캔 결과가 필요합니다' }, { status: 400 });
45
+ }
46
+
47
+ // If brainstorm is empty but project context exists, use a placeholder prompt
48
+ const content = hasContent
49
+ ? brainstorm.content
50
+ : '프로젝트 스캔 결과를 분석하여 현재 프로젝트의 구조, 진행 상황, TODO 항목을 파악해주세요.';
51
+
21
52
  try {
22
- const result = await structureWithChat(id, brainstorm.id, brainstorm.content);
53
+ const result = await structureWithChat(id, brainstorm.id, content);
23
54
  return NextResponse.json(result);
24
55
  } catch (error) {
25
56
  const message = error instanceof Error ? error.message : 'AI structuring failed';
@@ -0,0 +1,157 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { getProject } from '@/lib/db/queries/projects';
3
+ import { getBrainstorm } from '@/lib/db/queries/brainstorms';
4
+ import { getProjectContextSummary } from '@/lib/db/queries/context';
5
+ import { structureWithChatDirect } from '@/lib/ai/structurer';
6
+ import {
7
+ getTask, startTask, addTaskEvent, finishTask, failTask,
8
+ addTaskListener, cleanupTasks,
9
+ } from '@/lib/task-store';
10
+
11
+ export async function GET(
12
+ request: NextRequest,
13
+ { params }: { params: Promise<{ id: string }> },
14
+ ) {
15
+ const { id } = await params;
16
+ const project = getProject(id);
17
+ if (!project) {
18
+ return new Response('Project not found', { status: 404 });
19
+ }
20
+
21
+ cleanupTasks();
22
+
23
+ const existingTask = getTask(id);
24
+
25
+ // If there's an active task, attach to it (reconnect scenario)
26
+ if (existingTask && existingTask.status === 'running') {
27
+ return createReconnectStream(id, existingTask);
28
+ }
29
+
30
+ // If recently finished task exists, replay final result
31
+ if (existingTask && existingTask.status === 'done' && existingTask.result) {
32
+ return createReplayStream(existingTask);
33
+ }
34
+
35
+ // Start new task
36
+ const brainstorm = getBrainstorm(id);
37
+ if (!brainstorm) {
38
+ return new Response('Project not initialized', { status: 400 });
39
+ }
40
+
41
+ const hasContent = brainstorm.content.trim();
42
+ const hasContext = !!getProjectContextSummary(id);
43
+
44
+ if (!hasContent && !hasContext) {
45
+ return new Response('No content to structure', { status: 400 });
46
+ }
47
+
48
+ // User-provided project description from scan panel
49
+ const userDescription = request.nextUrl.searchParams.get('desc') || '';
50
+
51
+ let content = hasContent
52
+ ? brainstorm.content
53
+ : '프로젝트 스캔 결과를 분석하여 현재 프로젝트의 구조, 진행 상황, TODO 항목을 파악해주세요.';
54
+
55
+ if (userDescription) {
56
+ content = `[사용자가 제공한 프로젝트 설명]\n${userDescription}\n\n${content}`;
57
+ }
58
+
59
+ const brainstormId = brainstorm.id;
60
+
61
+ // Start background task
62
+ startTask(id);
63
+
64
+ const send = async (event: string, data: unknown) => {
65
+ addTaskEvent(id, event, data);
66
+ if (event === 'done') {
67
+ finishTask(id, data);
68
+ }
69
+ };
70
+
71
+ // Run structuring in background (detached from stream)
72
+ (async () => {
73
+ try {
74
+ await structureWithChatDirect(id, brainstormId, content, send);
75
+ } catch (error) {
76
+ const msg = error instanceof Error ? error.message : 'Structure failed';
77
+ addTaskEvent(id, 'error', { error: msg });
78
+ failTask(id, msg);
79
+ }
80
+ })();
81
+
82
+ // Stream events to this client
83
+ return createReconnectStream(id, getTask(id)!);
84
+ }
85
+
86
+ function createReconnectStream(projectId: string, task: ReturnType<typeof getTask>) {
87
+ const encoder = new TextEncoder();
88
+ let unsubscribe: (() => void) | null = null;
89
+
90
+ const stream = new ReadableStream({
91
+ start(controller) {
92
+ const send = (event: string, data: unknown) => {
93
+ try {
94
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
95
+ } catch {
96
+ // controller may be closed
97
+ }
98
+ };
99
+
100
+ // Replay past events
101
+ if (task) {
102
+ for (const ev of task.events) {
103
+ send(ev.event, ev.data);
104
+ }
105
+
106
+ // If already finished, close immediately
107
+ if (task.status !== 'running') {
108
+ controller.close();
109
+ return;
110
+ }
111
+ }
112
+
113
+ // Listen for new events
114
+ unsubscribe = addTaskListener(projectId, (event, data) => {
115
+ send(event, data);
116
+ if (event === 'done' || event === 'error') {
117
+ try { controller.close(); } catch { /* already closed */ }
118
+ unsubscribe?.();
119
+ }
120
+ });
121
+ },
122
+ cancel() {
123
+ unsubscribe?.();
124
+ },
125
+ });
126
+
127
+ return new Response(stream, {
128
+ headers: {
129
+ 'Content-Type': 'text/event-stream',
130
+ 'Cache-Control': 'no-cache',
131
+ 'Connection': 'keep-alive',
132
+ 'X-Accel-Buffering': 'no',
133
+ },
134
+ });
135
+ }
136
+
137
+ function createReplayStream(task: NonNullable<ReturnType<typeof getTask>>) {
138
+ const encoder = new TextEncoder();
139
+ const stream = new ReadableStream({
140
+ start(controller) {
141
+ for (const ev of task.events) {
142
+ try {
143
+ controller.enqueue(encoder.encode(`event: ${ev.event}\ndata: ${JSON.stringify(ev.data)}\n\n`));
144
+ } catch { break; }
145
+ }
146
+ controller.close();
147
+ },
148
+ });
149
+
150
+ return new Response(stream, {
151
+ headers: {
152
+ 'Content-Type': 'text/event-stream',
153
+ 'Cache-Control': 'no-cache',
154
+ 'Connection': 'keep-alive',
155
+ },
156
+ });
157
+ }