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.
- package/next.config.ts +0 -1
- package/package.json +2 -2
- package/{src/app/icon.svg → public/favicon.svg} +2 -2
- package/src/app/api/filesystem/route.ts +49 -0
- package/src/app/api/projects/[id]/cleanup/route.ts +32 -0
- package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +36 -0
- package/src/app/api/projects/[id]/items/[itemId]/route.ts +23 -1
- package/src/app/api/projects/[id]/items/route.ts +51 -1
- package/src/app/api/projects/[id]/scan/route.ts +73 -0
- package/src/app/api/projects/[id]/scan/stream/route.ts +112 -0
- package/src/app/api/projects/[id]/structure/route.ts +34 -3
- package/src/app/api/projects/[id]/structure/stream/route.ts +157 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/route.ts +39 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +60 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.ts +26 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.ts +39 -0
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/route.ts +33 -0
- package/src/app/api/projects/[id]/sub-projects/route.ts +31 -0
- package/src/app/api/projects/route.ts +1 -1
- package/src/app/globals.css +465 -5
- package/src/app/layout.tsx +3 -0
- package/src/app/page.tsx +260 -88
- package/src/app/projects/[id]/page.tsx +366 -183
- package/src/cli.ts +10 -10
- package/src/components/DirectoryPicker.tsx +137 -0
- package/src/components/ScanPanel.tsx +743 -0
- package/src/components/brainstorm/Editor.tsx +20 -4
- package/src/components/brainstorm/MemoPin.tsx +91 -5
- package/src/components/dashboard/SubProjectCard.tsx +76 -0
- package/src/components/dashboard/TabBar.tsx +42 -0
- package/src/components/task/ProjectTree.tsx +223 -0
- package/src/components/task/PromptEditor.tsx +107 -0
- package/src/components/task/StatusFlow.tsx +43 -0
- package/src/components/task/TaskChat.tsx +134 -0
- package/src/components/task/TaskDetail.tsx +205 -0
- package/src/components/task/TaskList.tsx +119 -0
- package/src/components/tree/CardView.tsx +206 -0
- package/src/components/tree/RefinePopover.tsx +157 -0
- package/src/components/tree/TreeNode.tsx +147 -38
- package/src/components/tree/TreeView.tsx +270 -26
- package/src/components/ui/ConfirmDialog.tsx +88 -0
- package/src/lib/ai/chat-responder.ts +4 -2
- package/src/lib/ai/cleanup.ts +87 -0
- package/src/lib/ai/client.ts +175 -58
- package/src/lib/ai/prompter.ts +19 -24
- package/src/lib/ai/refiner.ts +128 -0
- package/src/lib/ai/structurer.ts +340 -11
- package/src/lib/db/queries/context.ts +76 -0
- package/src/lib/db/queries/items.ts +133 -12
- package/src/lib/db/queries/projects.ts +12 -8
- package/src/lib/db/queries/sub-projects.ts +122 -0
- package/src/lib/db/queries/task-conversations.ts +27 -0
- package/src/lib/db/queries/task-prompts.ts +32 -0
- package/src/lib/db/queries/tasks.ts +133 -0
- package/src/lib/db/schema.ts +75 -0
- package/src/lib/mcp/server.ts +38 -39
- package/src/lib/mcp/tools.ts +47 -45
- package/src/lib/scanner.ts +573 -0
- package/src/lib/task-store.ts +97 -0
- package/src/types/index.ts +65 -0
package/src/lib/ai/structurer.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import { runStructure, runStructureWithQuestions, type IStructuredItem } from './client';
|
|
2
|
-
import { replaceItems } from '../db/queries/items';
|
|
1
|
+
import { runStructure, runStructureWithQuestions, runAnalysis, runClaude, extractJson, type IStructuredItem, type OnTextChunk, type OnRawEvent } from './client';
|
|
2
|
+
import { replaceItems, appendItems, getItemTree } from '../db/queries/items';
|
|
3
3
|
import { getRecentConversations, addMessage } from '../db/queries/conversations';
|
|
4
|
+
import {
|
|
5
|
+
getProjectContextSummary,
|
|
6
|
+
getProjectContextsBySubProject,
|
|
7
|
+
buildSubProjectSummary,
|
|
8
|
+
} from '../db/queries/context';
|
|
4
9
|
import { resolveMemos, createMemosFromQuestions } from '../db/queries/memos';
|
|
5
10
|
import type { IItemTree, IMemo, IConversation } from '@/types';
|
|
6
11
|
|
|
12
|
+
const AI_CONTEXT_LIMIT = 150_000; // 150KB - threshold for chunking
|
|
13
|
+
const PHASE3_CONTEXT_LIMIT = 300_000; // 300KB - Phase 3 final structuring (hub doc can be large)
|
|
14
|
+
const AI_CHUNK_LIMIT = 80_000; // 80KB - max context per AI call
|
|
15
|
+
|
|
7
16
|
export async function structureBrainstorm(
|
|
8
17
|
projectId: string,
|
|
9
18
|
brainstormId: string,
|
|
@@ -13,7 +22,8 @@ export async function structureBrainstorm(
|
|
|
13
22
|
return [];
|
|
14
23
|
}
|
|
15
24
|
|
|
16
|
-
const
|
|
25
|
+
const projectContext = getProjectContextSummary(projectId) || undefined;
|
|
26
|
+
const structured = await runStructure(content, projectContext);
|
|
17
27
|
|
|
18
28
|
const dbItems = mapToDbFormat(structured);
|
|
19
29
|
|
|
@@ -25,28 +35,316 @@ export async function structureWithChat(
|
|
|
25
35
|
brainstormId: string,
|
|
26
36
|
content: string,
|
|
27
37
|
): Promise<{ items: IItemTree[]; memos: IMemo[]; message: IConversation | null }> {
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
// Always use single mode for auto-structuring (triggered by brainstorming edits).
|
|
39
|
+
// Multi-agent analysis is only used via streaming endpoint (structureWithChatDirect).
|
|
40
|
+
const projectContext = getProjectContextSummary(projectId) || undefined;
|
|
41
|
+
const safeContext = projectContext ? truncateContext(projectContext, AI_CONTEXT_LIMIT) : undefined;
|
|
42
|
+
return structureSingle(projectId, brainstormId, content, safeContext);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Streaming structure with direct SSE callback.
|
|
47
|
+
* Instead of async generator, takes a `send` callback to emit SSE events directly.
|
|
48
|
+
* This avoids timing issues with generator yield + async queue.
|
|
49
|
+
*/
|
|
50
|
+
export async function structureWithChatDirect(
|
|
51
|
+
projectId: string,
|
|
52
|
+
brainstormId: string,
|
|
53
|
+
content: string,
|
|
54
|
+
send: (event: string, data: unknown) => void | Promise<void>,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const projectContext = getProjectContextSummary(projectId) || undefined;
|
|
57
|
+
const contextSize = projectContext?.length || 0;
|
|
58
|
+
|
|
59
|
+
await send('status', { message: '컨텍스트 크기 확인 중...' });
|
|
60
|
+
|
|
61
|
+
const onText: OnTextChunk = (text) => {
|
|
62
|
+
send('ai_text', { text });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const onRawEvent: OnRawEvent = (event) => {
|
|
66
|
+
send('ai_event', event);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (contextSize <= AI_CONTEXT_LIMIT) {
|
|
70
|
+
await send('status', { message: 'AI 구조화 중...', mode: 'single' });
|
|
71
|
+
|
|
72
|
+
const history = getRecentConversations(projectId, 20);
|
|
73
|
+
const historyForAi = history.map(h => ({ role: h.role, content: h.content }));
|
|
74
|
+
const safeContext = projectContext ? truncateContext(projectContext, AI_CONTEXT_LIMIT) : undefined;
|
|
75
|
+
|
|
76
|
+
const existingItems = getItemTree(projectId);
|
|
77
|
+
const existingContext = existingItems.length > 0
|
|
78
|
+
? serializeExistingItems(existingItems)
|
|
79
|
+
: undefined;
|
|
80
|
+
|
|
81
|
+
const result = await runStructureWithQuestions(content, historyForAi, safeContext, onText, onRawEvent, existingContext);
|
|
82
|
+
|
|
83
|
+
const dbItems = mapToDbFormat(result.items as IStructuredItem[]);
|
|
84
|
+
const tree = replaceItems(projectId, brainstormId, dbItems);
|
|
85
|
+
resolveMemos(projectId);
|
|
86
|
+
|
|
87
|
+
let aiMessage: IConversation | null = null;
|
|
88
|
+
let memos: IMemo[] = [];
|
|
89
|
+
if (result.questions.length > 0) {
|
|
90
|
+
const messageContent = result.questions
|
|
91
|
+
.map((q, i) => `${i + 1}. ${q.question}`)
|
|
92
|
+
.join('\n');
|
|
93
|
+
aiMessage = addMessage(projectId, 'assistant', messageContent);
|
|
94
|
+
memos = createMemosFromQuestions(projectId, aiMessage.id, result.questions);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await send('done', { items: tree, memos, message: aiMessage });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================
|
|
102
|
+
// 3-Phase Multi-Agent Analysis
|
|
103
|
+
// ============================================================
|
|
104
|
+
const subProjects = getProjectContextsBySubProject(projectId);
|
|
105
|
+
const brainstormContext = content.trim()
|
|
106
|
+
? `\n\n사용자의 브레인스토밍 메모:\n${content}`
|
|
107
|
+
: '';
|
|
108
|
+
|
|
109
|
+
// Send phase list to frontend
|
|
110
|
+
await send('phase_list', {
|
|
111
|
+
phases: [
|
|
112
|
+
{ name: '전체 아키텍처 분석', status: 'pending' },
|
|
113
|
+
{ name: `서브 프로젝트 병렬 분석 (${subProjects.length}개)`, status: 'pending' },
|
|
114
|
+
{ name: '최종 구조화', status: 'pending' },
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Send sub-project list
|
|
119
|
+
await send('chunk_list', {
|
|
120
|
+
chunks: subProjects.map((c, i) => ({
|
|
121
|
+
name: c.name,
|
|
122
|
+
index: i + 1,
|
|
123
|
+
fileCount: c.contexts.length,
|
|
124
|
+
})),
|
|
125
|
+
total: subProjects.length,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ----------------------------------------------------------
|
|
129
|
+
// Phase 1: Build hub document from docs/configs
|
|
130
|
+
// ----------------------------------------------------------
|
|
131
|
+
await send('phase_update', { index: 0, status: 'active' });
|
|
132
|
+
await send('status', { message: 'Phase 1: 문서/설정 기반 아키텍처 분석 중...' });
|
|
133
|
+
|
|
134
|
+
// Collect root files + all docs/configs for overview
|
|
135
|
+
const rootSub = subProjects.find(s => s.name === '(root)');
|
|
136
|
+
const rootContext = rootSub ? truncateContext(buildSubProjectSummary(rootSub), AI_CHUNK_LIMIT) : '';
|
|
137
|
+
|
|
138
|
+
// Also collect file listing for all sub-projects
|
|
139
|
+
const projectFileTree = subProjects
|
|
140
|
+
.map(s => `[${s.name}] (${s.contexts.length}개 파일)\n${s.contexts.map(c => ` ${c.file_path}`).join('\n')}`)
|
|
141
|
+
.join('\n\n');
|
|
142
|
+
|
|
143
|
+
const phase1Prompt = `당신은 소프트웨어 프로젝트 아키텍트입니다. 아래 프로젝트의 문서와 설정 파일을 분석하여 "프로젝트 개요 문서"를 작성하세요.
|
|
144
|
+
|
|
145
|
+
이 문서는 다른 AI 에이전트들이 각 서브 프로젝트를 분석할 때 참조하는 중추 문서가 됩니다.
|
|
146
|
+
|
|
147
|
+
다음 내용을 포함해주세요:
|
|
148
|
+
1. 프로젝트 전체 목적과 구조
|
|
149
|
+
2. 기술 스택 (프레임워크, 언어, 주요 라이브러리)
|
|
150
|
+
3. 서브 프로젝트 간의 관계와 의존성
|
|
151
|
+
4. 아키텍처 패턴 (모노레포, 마이크로서비스, 등)
|
|
152
|
+
5. 주요 컨벤션과 규칙
|
|
153
|
+
6. 배포/인프라 구조 (파악 가능한 경우)
|
|
154
|
+
|
|
155
|
+
한국어로 작성하세요. Markdown 형식으로 작성하세요.
|
|
156
|
+
${brainstormContext}
|
|
157
|
+
|
|
158
|
+
=== 프로젝트 파일 트리 ===
|
|
159
|
+
${projectFileTree}
|
|
160
|
+
|
|
161
|
+
=== 루트 문서/설정 파일 ===
|
|
162
|
+
${rootContext}`;
|
|
163
|
+
|
|
164
|
+
let hubDocument = '';
|
|
165
|
+
try {
|
|
166
|
+
hubDocument = await runAnalysis(phase1Prompt, onText, onRawEvent);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
169
|
+
hubDocument = `# 프로젝트 개요 (자동 생성 실패)\n오류: ${errMsg.slice(0, 300)}\n\n## 서브 프로젝트 목록\n${subProjects.map(s => `- ${s.name}`).join('\n')}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await send('phase_update', { index: 0, status: 'done' });
|
|
173
|
+
await send('hub_document', { content: hubDocument });
|
|
174
|
+
|
|
175
|
+
// ----------------------------------------------------------
|
|
176
|
+
// Phase 2: Parallel sub-project analysis
|
|
177
|
+
// ----------------------------------------------------------
|
|
178
|
+
await send('phase_update', { index: 1, status: 'active' });
|
|
179
|
+
await send('status', { message: 'Phase 2: 서브 프로젝트 병렬 분석 중...' });
|
|
180
|
+
|
|
181
|
+
const CONCURRENCY = 2;
|
|
182
|
+
const subAnalyses = new Map<string, string>();
|
|
183
|
+
const nonRootSubs = subProjects.filter(s => s.name !== '(root)');
|
|
184
|
+
|
|
185
|
+
// Process in batches of CONCURRENCY
|
|
186
|
+
for (let batch = 0; batch < nonRootSubs.length; batch += CONCURRENCY) {
|
|
187
|
+
const batchSubs = nonRootSubs.slice(batch, batch + CONCURRENCY);
|
|
188
|
+
|
|
189
|
+
const batchPromises = batchSubs.map(async (sub, batchIdx) => {
|
|
190
|
+
const globalIdx = batch + batchIdx;
|
|
191
|
+
const subIdx = subProjects.indexOf(sub);
|
|
192
|
+
|
|
193
|
+
await send('structuring_sub', {
|
|
194
|
+
subProject: sub.name,
|
|
195
|
+
current: globalIdx + 1,
|
|
196
|
+
total: nonRootSubs.length,
|
|
197
|
+
files: sub.contexts.map(c => c.file_path),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const subContext = truncateContext(buildSubProjectSummary(sub), AI_CHUNK_LIMIT);
|
|
201
|
+
const phase2Prompt = `당신은 소프트웨어 분석가입니다. 아래 "프로젝트 개요 문서"를 참조하여 서브 프로젝트 "${sub.name}"의 소스코드를 분석하세요.
|
|
202
|
+
|
|
203
|
+
분석 결과를 다음 형식으로 작성하세요:
|
|
204
|
+
1. **역할**: 이 서브 프로젝트가 전체 시스템에서 하는 역할
|
|
205
|
+
2. **주요 기능**: 구현된 핵심 기능 목록 (각 기능의 구현 상태 포함)
|
|
206
|
+
3. **기술 스택**: 사용 중인 기술/라이브러리
|
|
207
|
+
4. **구현 상태**: done/in_progress/pending 판단 근거
|
|
208
|
+
5. **TODO/개선점**: 코드에서 발견된 TODO, 미구현 부분, 개선 가능 사항
|
|
209
|
+
6. **다른 서브 프로젝트와의 관계**: 의존하거나 의존받는 프로젝트
|
|
210
|
+
|
|
211
|
+
한국어로 작성하세요. Markdown 형식으로 작성하세요.
|
|
212
|
+
|
|
213
|
+
=== 프로젝트 개요 문서 (중추) ===
|
|
214
|
+
${truncateContext(hubDocument, 30_000)}
|
|
215
|
+
|
|
216
|
+
=== ${sub.name} 소스코드 ===
|
|
217
|
+
${subContext}`;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
// Each sub gets its own text stream tagged with sub name
|
|
221
|
+
const subOnText: OnTextChunk = (text) => {
|
|
222
|
+
send('ai_text', { text, subProject: sub.name });
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const analysis = await runAnalysis(phase2Prompt, subOnText);
|
|
226
|
+
subAnalyses.set(sub.name, analysis);
|
|
227
|
+
|
|
228
|
+
await send('structuring_sub_done', {
|
|
229
|
+
subProject: sub.name,
|
|
230
|
+
current: globalIdx + 1,
|
|
231
|
+
total: nonRootSubs.length,
|
|
232
|
+
itemCount: 0,
|
|
233
|
+
});
|
|
234
|
+
} catch (err) {
|
|
235
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
236
|
+
console.error(`[structurer] Phase 2 "${sub.name}" failed:`, errMsg);
|
|
237
|
+
subAnalyses.set(sub.name, `# ${sub.name} (분석 실패)\n오류: ${errMsg.slice(0, 300)}`);
|
|
238
|
+
|
|
239
|
+
await send('structuring_sub_done', {
|
|
240
|
+
subProject: sub.name,
|
|
241
|
+
current: globalIdx + 1,
|
|
242
|
+
total: nonRootSubs.length,
|
|
243
|
+
error: errMsg.slice(0, 200),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await Promise.all(batchPromises);
|
|
30
249
|
}
|
|
31
250
|
|
|
32
|
-
//
|
|
251
|
+
// Build complete hub document with all analyses
|
|
252
|
+
const completeDocument = `${hubDocument}\n\n---\n\n# 서브 프로젝트 상세 분석\n\n${
|
|
253
|
+
Array.from(subAnalyses.entries())
|
|
254
|
+
.map(([name, analysis]) => `## ${name}\n\n${analysis}`)
|
|
255
|
+
.join('\n\n---\n\n')
|
|
256
|
+
}`;
|
|
257
|
+
|
|
258
|
+
await send('phase_update', { index: 1, status: 'done' });
|
|
259
|
+
await send('hub_document', { content: completeDocument });
|
|
260
|
+
|
|
261
|
+
// ----------------------------------------------------------
|
|
262
|
+
// Phase 3: Final structuring from complete hub document
|
|
263
|
+
// ----------------------------------------------------------
|
|
264
|
+
await send('phase_update', { index: 2, status: 'active' });
|
|
265
|
+
await send('status', { message: 'Phase 3: 중추 문서 기반 최종 구조화 중...' });
|
|
266
|
+
await send('ai_text_reset', {});
|
|
267
|
+
|
|
268
|
+
const phase3Prompt = `You are a JSON-only structuring machine. You NEVER respond with text, explanations, or conversation.
|
|
269
|
+
You ALWAYS output ONLY a raw JSON array, nothing else.
|
|
270
|
+
|
|
271
|
+
Your job: convert the comprehensive project analysis document below into a structured JSON tree.
|
|
272
|
+
|
|
273
|
+
Schema per item:
|
|
274
|
+
{ "title": string, "description": string, "item_type": "feature"|"task"|"bug"|"idea"|"note", "priority": "high"|"medium"|"low", "status": "pending"|"in_progress"|"done", "children": [same schema] }
|
|
275
|
+
|
|
276
|
+
Rules:
|
|
277
|
+
- Output MUST start with [ and end with ]
|
|
278
|
+
- No markdown fences, no explanation, no text before or after the JSON
|
|
279
|
+
- Top-level items should be sub-projects (one per analyzed project)
|
|
280
|
+
- Each top-level item should have children representing features/tasks/bugs found in that sub-project
|
|
281
|
+
- Keep titles concise (under 50 chars)
|
|
282
|
+
- Judge status based on the analysis:
|
|
283
|
+
- "done": fully implemented as described
|
|
284
|
+
- "in_progress": partially implemented or has TODOs
|
|
285
|
+
- "pending": not yet started or only planned
|
|
286
|
+
- Prioritize items that have TODOs or are in_progress as "high" priority
|
|
287
|
+
- Include bugs, improvements, and missing features mentioned in the analysis
|
|
288
|
+
${brainstormContext}
|
|
289
|
+
|
|
290
|
+
=== 프로젝트 분석 문서 ===
|
|
291
|
+
${truncateContext(completeDocument, PHASE3_CONTEXT_LIMIT)}`;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const resultText = await runClaude(phase3Prompt, onText, onRawEvent);
|
|
295
|
+
const json = extractJson(resultText, 'array');
|
|
296
|
+
const structured = JSON.parse(json) as IStructuredItem[];
|
|
297
|
+
|
|
298
|
+
const dbItems = mapToDbFormat(structured);
|
|
299
|
+
const tree = appendItems(projectId, brainstormId, dbItems);
|
|
300
|
+
resolveMemos(projectId);
|
|
301
|
+
|
|
302
|
+
const summaryMsg = addMessage(
|
|
303
|
+
projectId,
|
|
304
|
+
'assistant',
|
|
305
|
+
`3단계 분석 완료: ${nonRootSubs.length}개 서브 프로젝트를 병렬 분석 후 구조화했습니다.`,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
await send('phase_update', { index: 2, status: 'done' });
|
|
309
|
+
await send('done', { items: tree, memos: [], message: summaryMsg });
|
|
310
|
+
} catch (err) {
|
|
311
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
312
|
+
console.error('[structurer] Phase 3 failed:', errMsg);
|
|
313
|
+
|
|
314
|
+
await send('phase_update', { index: 2, status: 'error' });
|
|
315
|
+
await send('error', { error: `Phase 3 구조화 실패: ${errMsg.slice(0, 300)}` });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ============================================================
|
|
320
|
+
// Internal helpers
|
|
321
|
+
// ============================================================
|
|
322
|
+
|
|
323
|
+
async function structureSingle(
|
|
324
|
+
projectId: string,
|
|
325
|
+
brainstormId: string,
|
|
326
|
+
content: string,
|
|
327
|
+
projectContext?: string,
|
|
328
|
+
): Promise<{ items: IItemTree[]; memos: IMemo[]; message: IConversation | null }> {
|
|
33
329
|
const history = getRecentConversations(projectId, 20);
|
|
34
330
|
const historyForAi = history.map(h => ({
|
|
35
331
|
role: h.role,
|
|
36
332
|
content: h.content,
|
|
37
333
|
}));
|
|
38
334
|
|
|
39
|
-
|
|
40
|
-
const
|
|
335
|
+
const existingItems = getItemTree(projectId);
|
|
336
|
+
const existingContext = existingItems.length > 0
|
|
337
|
+
? serializeExistingItems(existingItems)
|
|
338
|
+
: undefined;
|
|
339
|
+
|
|
340
|
+
const safeContext = projectContext ? truncateContext(projectContext, AI_CONTEXT_LIMIT) : undefined;
|
|
341
|
+
const result = await runStructureWithQuestions(content, historyForAi, safeContext, undefined, undefined, existingContext);
|
|
41
342
|
|
|
42
|
-
// Replace items in DB
|
|
43
343
|
const dbItems = mapToDbFormat(result.items as IStructuredItem[]);
|
|
44
344
|
const tree = replaceItems(projectId, brainstormId, dbItems);
|
|
45
345
|
|
|
46
|
-
// Resolve old memos
|
|
47
346
|
resolveMemos(projectId);
|
|
48
347
|
|
|
49
|
-
// Build AI message from questions
|
|
50
348
|
let aiMessage: IConversation | null = null;
|
|
51
349
|
let memos: IMemo[] = [];
|
|
52
350
|
|
|
@@ -62,6 +360,36 @@ export async function structureWithChat(
|
|
|
62
360
|
return { items: tree, memos, message: aiMessage };
|
|
63
361
|
}
|
|
64
362
|
|
|
363
|
+
function serializeExistingItems(items: IItemTree[], depth = 0): string {
|
|
364
|
+
if (items.length === 0) return '';
|
|
365
|
+
const lines: string[] = [];
|
|
366
|
+
for (const item of items) {
|
|
367
|
+
const indent = ' '.repeat(depth);
|
|
368
|
+
lines.push(`${indent}- [${item.item_type}/${item.priority}] ${item.title}: ${item.description || ''}`);
|
|
369
|
+
if (item.children && item.children.length > 0) {
|
|
370
|
+
lines.push(serializeExistingItems(item.children, depth + 1));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return lines.join('\n');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function truncateContext(context: string, limit: number): string {
|
|
377
|
+
if (context.length <= limit) return context;
|
|
378
|
+
|
|
379
|
+
const fileSections = context.split(/(?=--- .+ ---\n)/);
|
|
380
|
+
let result = '';
|
|
381
|
+
|
|
382
|
+
for (const section of fileSections) {
|
|
383
|
+
if (result.length + section.length > limit) {
|
|
384
|
+
result += `\n\n--- (${fileSections.length - result.split('---').length / 2}개 파일 생략됨, 컨텍스트 크기 제한) ---\n`;
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
result += section;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
|
|
65
393
|
function mapToDbFormat(items: IStructuredItem[]): Parameters<typeof replaceItems>[2] {
|
|
66
394
|
return items.map((item) => ({
|
|
67
395
|
parent_id: null,
|
|
@@ -69,6 +397,7 @@ function mapToDbFormat(items: IStructuredItem[]): Parameters<typeof replaceItems
|
|
|
69
397
|
description: item.description,
|
|
70
398
|
item_type: item.item_type,
|
|
71
399
|
priority: item.priority,
|
|
400
|
+
status: item.status,
|
|
72
401
|
children: item.children ? mapToDbFormat(item.children) : undefined,
|
|
73
402
|
}));
|
|
74
403
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { getDb } from '../index';
|
|
2
|
+
import { generateId } from '../../utils/id';
|
|
3
|
+
import type { IProjectContext } from '@/types';
|
|
4
|
+
|
|
5
|
+
export function getProjectContexts(projectId: string): IProjectContext[] {
|
|
6
|
+
const db = getDb();
|
|
7
|
+
return db.prepare(
|
|
8
|
+
'SELECT * FROM project_context WHERE project_id = ? ORDER BY file_path ASC'
|
|
9
|
+
).all(projectId) as IProjectContext[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getProjectContextSummary(projectId: string): string {
|
|
13
|
+
const contexts = getProjectContexts(projectId);
|
|
14
|
+
if (contexts.length === 0) return '';
|
|
15
|
+
|
|
16
|
+
return contexts
|
|
17
|
+
.map(c => `--- ${c.file_path} ---\n${c.content}`)
|
|
18
|
+
.join('\n\n');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SubProjectContext {
|
|
22
|
+
name: string; // sub-project directory name (or "(root)")
|
|
23
|
+
contexts: IProjectContext[];
|
|
24
|
+
totalSize: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getProjectContextsBySubProject(projectId: string): SubProjectContext[] {
|
|
28
|
+
const contexts = getProjectContexts(projectId);
|
|
29
|
+
if (contexts.length === 0) return [];
|
|
30
|
+
|
|
31
|
+
// Simple grouping: first path segment = sub-project
|
|
32
|
+
// e.g. "jabis-template/apps/prototype/src/App.tsx" → "jabis-template"
|
|
33
|
+
// "package.json" or "__directory_tree.txt" → "(root)"
|
|
34
|
+
const groups = new Map<string, IProjectContext[]>();
|
|
35
|
+
|
|
36
|
+
for (const ctx of contexts) {
|
|
37
|
+
const parts = ctx.file_path.split('/');
|
|
38
|
+
const group = (parts.length <= 1 || ctx.file_path.startsWith('__'))
|
|
39
|
+
? '(root)'
|
|
40
|
+
: parts[0];
|
|
41
|
+
|
|
42
|
+
if (!groups.has(group)) groups.set(group, []);
|
|
43
|
+
groups.get(group)!.push(ctx);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return Array.from(groups.entries()).map(([name, ctxs]) => ({
|
|
47
|
+
name,
|
|
48
|
+
contexts: ctxs,
|
|
49
|
+
totalSize: ctxs.reduce((sum, c) => sum + c.content.length, 0),
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildSubProjectSummary(sub: SubProjectContext): string {
|
|
54
|
+
return sub.contexts
|
|
55
|
+
.map(c => `--- ${c.file_path} ---\n${c.content}`)
|
|
56
|
+
.join('\n\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function replaceProjectContexts(projectId: string, files: { file_path: string; content: string }[]): IProjectContext[] {
|
|
60
|
+
const db = getDb();
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
|
|
63
|
+
const replace = db.transaction(() => {
|
|
64
|
+
db.prepare('DELETE FROM project_context WHERE project_id = ?').run(projectId);
|
|
65
|
+
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
const id = generateId();
|
|
68
|
+
db.prepare(
|
|
69
|
+
'INSERT INTO project_context (id, project_id, file_path, content, scanned_at) VALUES (?, ?, ?, ?, ?)'
|
|
70
|
+
).run(id, projectId, file.file_path, file.content, now);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
replace();
|
|
75
|
+
return getProjectContexts(projectId);
|
|
76
|
+
}
|
|
@@ -9,6 +9,11 @@ export function getItems(projectId: string): IItem[] {
|
|
|
9
9
|
).all(projectId) as IItem[];
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export function getItem(id: string): IItem | undefined {
|
|
13
|
+
const db = getDb();
|
|
14
|
+
return db.prepare('SELECT * FROM items WHERE id = ?').get(id) as IItem | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
export function getItemTree(projectId: string): IItemTree[] {
|
|
13
18
|
const items = getItems(projectId);
|
|
14
19
|
return buildTree(items);
|
|
@@ -50,8 +55,8 @@ export function createItem(data: {
|
|
|
50
55
|
|
|
51
56
|
db.prepare(`
|
|
52
57
|
INSERT INTO items (id, project_id, brainstorm_id, parent_id, title, description,
|
|
53
|
-
item_type, priority, status, is_locked, sort_order, created_at, updated_at)
|
|
54
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1, ?, ?, ?)
|
|
58
|
+
item_type, priority, status, is_locked, is_pinned, sort_order, created_at, updated_at)
|
|
59
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1, 1, ?, ?, ?)
|
|
55
60
|
`).run(
|
|
56
61
|
id,
|
|
57
62
|
data.project_id,
|
|
@@ -74,6 +79,7 @@ export function updateItem(id: string, data: {
|
|
|
74
79
|
description?: string;
|
|
75
80
|
status?: ItemStatus;
|
|
76
81
|
is_locked?: boolean;
|
|
82
|
+
is_pinned?: boolean;
|
|
77
83
|
priority?: ItemPriority;
|
|
78
84
|
sort_order?: number;
|
|
79
85
|
}): IItem | undefined {
|
|
@@ -84,7 +90,7 @@ export function updateItem(id: string, data: {
|
|
|
84
90
|
const now = new Date().toISOString();
|
|
85
91
|
db.prepare(`
|
|
86
92
|
UPDATE items SET
|
|
87
|
-
title = ?, description = ?, status = ?, is_locked = ?,
|
|
93
|
+
title = ?, description = ?, status = ?, is_locked = ?, is_pinned = ?,
|
|
88
94
|
priority = ?, sort_order = ?, updated_at = ?
|
|
89
95
|
WHERE id = ?
|
|
90
96
|
`).run(
|
|
@@ -92,6 +98,7 @@ export function updateItem(id: string, data: {
|
|
|
92
98
|
data.description ?? item.description,
|
|
93
99
|
data.status ?? item.status,
|
|
94
100
|
data.is_locked !== undefined ? (data.is_locked ? 1 : 0) : (item.is_locked ? 1 : 0),
|
|
101
|
+
data.is_pinned !== undefined ? (data.is_pinned ? 1 : 0) : (item.is_pinned ? 1 : 0),
|
|
95
102
|
data.priority ?? item.priority,
|
|
96
103
|
data.sort_order ?? item.sort_order,
|
|
97
104
|
now,
|
|
@@ -101,37 +108,151 @@ export function updateItem(id: string, data: {
|
|
|
101
108
|
return db.prepare('SELECT * FROM items WHERE id = ?').get(id) as IItem;
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
export function deleteItem(id: string): boolean {
|
|
112
|
+
const db = getDb();
|
|
113
|
+
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(id) as IItem | undefined;
|
|
114
|
+
if (!item) return false;
|
|
115
|
+
|
|
116
|
+
// Delete children first (recursive via collecting all descendant IDs)
|
|
117
|
+
const deleteRecursive = db.transaction(() => {
|
|
118
|
+
const collectIds = (parentId: string): string[] => {
|
|
119
|
+
const children = db.prepare('SELECT id FROM items WHERE parent_id = ?').all(parentId) as { id: string }[];
|
|
120
|
+
const ids: string[] = [];
|
|
121
|
+
for (const child of children) {
|
|
122
|
+
ids.push(...collectIds(child.id));
|
|
123
|
+
ids.push(child.id);
|
|
124
|
+
}
|
|
125
|
+
return ids;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const idsToDelete = [...collectIds(id), id];
|
|
129
|
+
const placeholders = idsToDelete.map(() => '?').join(',');
|
|
130
|
+
db.prepare(`DELETE FROM prompts WHERE item_id IN (${placeholders})`).run(...idsToDelete);
|
|
131
|
+
db.prepare(`DELETE FROM items WHERE id IN (${placeholders})`).run(...idsToDelete);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
deleteRecursive();
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
104
138
|
export function deleteItemsByProject(projectId: string): void {
|
|
105
139
|
const db = getDb();
|
|
106
140
|
db.prepare('DELETE FROM items WHERE project_id = ?').run(projectId);
|
|
107
141
|
}
|
|
108
142
|
|
|
109
|
-
export function
|
|
143
|
+
export function bulkUpdateStatus(projectId: string, status: ItemStatus): void {
|
|
144
|
+
const db = getDb();
|
|
145
|
+
const now = new Date().toISOString();
|
|
146
|
+
db.prepare('UPDATE items SET status = ?, updated_at = ? WHERE project_id = ?').run(status, now, projectId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type NewItemInput = {
|
|
110
150
|
parent_id: string | null;
|
|
111
151
|
title: string;
|
|
112
152
|
description: string;
|
|
113
153
|
item_type: ItemType;
|
|
114
154
|
priority: ItemPriority;
|
|
115
|
-
|
|
116
|
-
|
|
155
|
+
status?: ItemStatus;
|
|
156
|
+
children?: NewItemInput[];
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Append new items to existing ones (additive).
|
|
161
|
+
* Existing items are preserved — only new items are inserted.
|
|
162
|
+
*/
|
|
163
|
+
export function appendItems(projectId: string, brainstormId: string, newItems: NewItemInput[]): IItemTree[] {
|
|
164
|
+
const db = getDb();
|
|
165
|
+
|
|
166
|
+
const insertItems = db.transaction(() => {
|
|
167
|
+
const maxOrder = db.prepare(
|
|
168
|
+
'SELECT MAX(sort_order) as max_order FROM items WHERE project_id = ?'
|
|
169
|
+
).get(projectId) as { max_order: number | null };
|
|
170
|
+
let sortOrder = (maxOrder?.max_order ?? -1) + 1;
|
|
171
|
+
|
|
172
|
+
const insertRecursive = (items: NewItemInput[], parentId: string | null) => {
|
|
173
|
+
for (const item of items) {
|
|
174
|
+
const id = generateId();
|
|
175
|
+
const now = new Date().toISOString();
|
|
176
|
+
db.prepare(`
|
|
177
|
+
INSERT INTO items (id, project_id, brainstorm_id, parent_id, title, description,
|
|
178
|
+
item_type, priority, status, is_locked, is_pinned, sort_order, created_at, updated_at)
|
|
179
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
180
|
+
`).run(id, projectId, brainstormId, parentId, item.title, item.description,
|
|
181
|
+
item.item_type, item.priority, item.status || 'pending', sortOrder++, now, now);
|
|
182
|
+
|
|
183
|
+
if (item.children?.length) {
|
|
184
|
+
insertRecursive(item.children, id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
insertRecursive(newItems, null);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
insertItems();
|
|
193
|
+
return getItemTree(projectId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Add children under a specific parent item.
|
|
198
|
+
*/
|
|
199
|
+
export function addChildItems(projectId: string, parentId: string, newChildren: NewItemInput[]): IItemTree[] {
|
|
117
200
|
const db = getDb();
|
|
118
201
|
|
|
119
202
|
const insertItems = db.transaction(() => {
|
|
120
|
-
|
|
203
|
+
const maxOrder = db.prepare(
|
|
204
|
+
'SELECT MAX(sort_order) as max_order FROM items WHERE project_id = ?'
|
|
205
|
+
).get(projectId) as { max_order: number | null };
|
|
206
|
+
let sortOrder = (maxOrder?.max_order ?? -1) + 1;
|
|
207
|
+
|
|
208
|
+
const insertRecursive = (items: NewItemInput[], pid: string | null) => {
|
|
209
|
+
for (const item of items) {
|
|
210
|
+
const id = generateId();
|
|
211
|
+
const now = new Date().toISOString();
|
|
212
|
+
db.prepare(`
|
|
213
|
+
INSERT INTO items (id, project_id, brainstorm_id, parent_id, title, description,
|
|
214
|
+
item_type, priority, status, is_locked, is_pinned, sort_order, created_at, updated_at)
|
|
215
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
216
|
+
`).run(id, projectId, null, pid, item.title, item.description,
|
|
217
|
+
item.item_type, item.priority, item.status || 'pending', sortOrder++, now, now);
|
|
218
|
+
|
|
219
|
+
if (item.children?.length) {
|
|
220
|
+
insertRecursive(item.children, id);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
insertRecursive(newChildren, parentId);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
insertItems();
|
|
229
|
+
return getItemTree(projectId);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Replace ALL items for the project with new ones.
|
|
234
|
+
* Deletes all existing items first, then inserts the new tree.
|
|
235
|
+
*/
|
|
236
|
+
export function replaceItems(projectId: string, brainstormId: string, newItems: NewItemInput[]): IItemTree[] {
|
|
237
|
+
const db = getDb();
|
|
238
|
+
|
|
239
|
+
const insertItems = db.transaction(() => {
|
|
240
|
+
// Delete ALL existing items and their prompts
|
|
241
|
+
db.prepare('DELETE FROM prompts WHERE project_id = ?').run(projectId);
|
|
121
242
|
db.prepare('DELETE FROM items WHERE project_id = ?').run(projectId);
|
|
122
243
|
|
|
123
|
-
// Insert new items recursively
|
|
124
244
|
let sortOrder = 0;
|
|
125
|
-
|
|
245
|
+
|
|
246
|
+
const insertRecursive = (items: NewItemInput[], parentId: string | null) => {
|
|
126
247
|
for (const item of items) {
|
|
127
248
|
const id = generateId();
|
|
128
249
|
const now = new Date().toISOString();
|
|
129
250
|
db.prepare(`
|
|
130
251
|
INSERT INTO items (id, project_id, brainstorm_id, parent_id, title, description,
|
|
131
|
-
item_type, priority, status, is_locked, sort_order, created_at, updated_at)
|
|
132
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
|
252
|
+
item_type, priority, status, is_locked, is_pinned, sort_order, created_at, updated_at)
|
|
253
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
133
254
|
`).run(id, projectId, brainstormId, parentId, item.title, item.description,
|
|
134
|
-
item.item_type, item.priority, sortOrder++, now, now);
|
|
255
|
+
item.item_type, item.priority, item.status || 'pending', sortOrder++, now, now);
|
|
135
256
|
|
|
136
257
|
if (item.children?.length) {
|
|
137
258
|
insertRecursive(item.children, id);
|