idea-manager 0.2.0 → 0.3.1
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/README.md +33 -41
- 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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
3
4
|
import TreeNode from './TreeNode';
|
|
5
|
+
import CardView from './CardView';
|
|
4
6
|
|
|
5
7
|
interface IItemTree {
|
|
6
8
|
id: string;
|
|
@@ -10,6 +12,7 @@ interface IItemTree {
|
|
|
10
12
|
priority: string;
|
|
11
13
|
status: string;
|
|
12
14
|
is_locked: boolean;
|
|
15
|
+
is_pinned: boolean;
|
|
13
16
|
children: IItemTree[];
|
|
14
17
|
}
|
|
15
18
|
|
|
@@ -18,43 +21,284 @@ interface TreeViewProps {
|
|
|
18
21
|
loading: boolean;
|
|
19
22
|
projectId: string;
|
|
20
23
|
onItemUpdate: (itemId: string, data: Record<string, unknown>) => void;
|
|
24
|
+
onItemDelete: (itemId: string) => void;
|
|
25
|
+
onBulkDelete: (itemIds: string[] | 'all') => void;
|
|
26
|
+
onBulkStatus: (status: string) => void;
|
|
27
|
+
onTreeRefresh: (tree: IItemTree[]) => void;
|
|
28
|
+
onCleanup?: () => void;
|
|
29
|
+
cleaning?: boolean;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
type ViewMode = 'tree' | 'card';
|
|
33
|
+
|
|
34
|
+
function collectIds(item: IItemTree): string[] {
|
|
35
|
+
return [item.id, ...item.children.flatMap(collectIds)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function filterDone(items: IItemTree[]): IItemTree[] {
|
|
39
|
+
return items
|
|
40
|
+
.filter(item => item.status !== 'done')
|
|
41
|
+
.map(item => ({
|
|
42
|
+
...item,
|
|
43
|
+
children: filterDone(item.children),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function TreeView({ items, loading, projectId, onItemUpdate, onItemDelete, onBulkDelete, onBulkStatus, onTreeRefresh, onCleanup, cleaning }: TreeViewProps) {
|
|
48
|
+
const [selectMode, setSelectMode] = useState(false);
|
|
49
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
50
|
+
const [hideDone, setHideDone] = useState(() => {
|
|
51
|
+
if (typeof window !== 'undefined') {
|
|
52
|
+
return localStorage.getItem('im-hide-done') !== 'false';
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
localStorage.setItem('im-hide-done', String(hideDone));
|
|
59
|
+
}, [hideDone]);
|
|
60
|
+
const [viewMode, setViewMode] = useState<ViewMode>('card');
|
|
61
|
+
const [collapseAll, setCollapseAll] = useState(false);
|
|
62
|
+
const [collapseKey, setCollapseKey] = useState(0);
|
|
63
|
+
|
|
64
|
+
const toggleSelect = (id: string) => {
|
|
65
|
+
setSelected(prev => {
|
|
66
|
+
const next = new Set(prev);
|
|
67
|
+
if (next.has(id)) {
|
|
68
|
+
next.delete(id);
|
|
69
|
+
} else {
|
|
70
|
+
next.add(id);
|
|
71
|
+
}
|
|
72
|
+
return next;
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const handleDeleteSelected = () => {
|
|
77
|
+
if (selected.size === 0) return;
|
|
78
|
+
const ids = Array.from(selected);
|
|
79
|
+
onBulkDelete(ids);
|
|
80
|
+
setSelected(new Set());
|
|
81
|
+
setSelectMode(false);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleDeleteAll = () => {
|
|
85
|
+
onBulkDelete('all');
|
|
86
|
+
setSelected(new Set());
|
|
87
|
+
setSelectMode(false);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleSelectAll = () => {
|
|
91
|
+
const allIds = items.flatMap(collectIds);
|
|
92
|
+
setSelected(new Set(allIds));
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const totalCount = items.reduce((sum, item) => sum + 1 + countChildren(item), 0);
|
|
96
|
+
const doneCount = countByStatus(items, 'done');
|
|
97
|
+
const displayItems = hideDone ? filterDone(items) : items;
|
|
98
|
+
|
|
24
99
|
return (
|
|
25
100
|
<div className="flex flex-col h-full">
|
|
26
101
|
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
102
|
+
<div className="flex items-center gap-2">
|
|
103
|
+
<h2 className="text-sm font-medium text-muted-foreground">구조화 뷰</h2>
|
|
104
|
+
{totalCount > 0 && (
|
|
105
|
+
<span className="text-xs text-muted-foreground/60">{totalCount}</span>
|
|
106
|
+
)}
|
|
107
|
+
{doneCount > 0 && (
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => setHideDone(!hideDone)}
|
|
110
|
+
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
|
|
111
|
+
hideDone
|
|
112
|
+
? 'bg-accent/15 text-accent'
|
|
113
|
+
: 'text-muted-foreground/50 hover:text-muted-foreground'
|
|
114
|
+
}`}
|
|
115
|
+
title={hideDone ? '완료 항목 표시' : '완료 항목 숨기기'}
|
|
116
|
+
>
|
|
117
|
+
{hideDone ? `+${doneCount} 숨김` : `${doneCount} 완료`}
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
120
|
+
{loading && (
|
|
121
|
+
<span className="text-xs text-accent animate-pulse">AI 분석 중...</span>
|
|
122
|
+
)}
|
|
123
|
+
{cleaning && !loading && (
|
|
124
|
+
<span className="text-xs text-muted-foreground animate-pulse">정리 중...</span>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
<div className="flex items-center gap-2">
|
|
128
|
+
{/* View mode toggle */}
|
|
129
|
+
<div className="view-toggle">
|
|
130
|
+
<button
|
|
131
|
+
onClick={() => setViewMode('card')}
|
|
132
|
+
className={`view-toggle-btn ${viewMode === 'card' ? 'view-toggle-btn-active' : ''}`}
|
|
133
|
+
>
|
|
134
|
+
카드
|
|
135
|
+
</button>
|
|
136
|
+
<button
|
|
137
|
+
onClick={() => setViewMode('tree')}
|
|
138
|
+
className={`view-toggle-btn ${viewMode === 'tree' ? 'view-toggle-btn-active' : ''}`}
|
|
139
|
+
>
|
|
140
|
+
트리
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{items.length > 0 && viewMode === 'tree' && (
|
|
145
|
+
<div className="flex items-center gap-1">
|
|
146
|
+
{selectMode ? (
|
|
147
|
+
<>
|
|
148
|
+
<button
|
|
149
|
+
onClick={handleSelectAll}
|
|
150
|
+
className="text-xs px-2 py-1 text-muted-foreground hover:text-foreground rounded transition-colors"
|
|
151
|
+
>
|
|
152
|
+
전체선택
|
|
153
|
+
</button>
|
|
154
|
+
<button
|
|
155
|
+
onClick={handleDeleteSelected}
|
|
156
|
+
disabled={selected.size === 0}
|
|
157
|
+
className="text-xs px-2 py-1 text-destructive hover:bg-destructive/10 rounded transition-colors disabled:opacity-30"
|
|
158
|
+
>
|
|
159
|
+
선택삭제 ({selected.size})
|
|
160
|
+
</button>
|
|
161
|
+
<button
|
|
162
|
+
onClick={() => { setSelectMode(false); setSelected(new Set()); }}
|
|
163
|
+
className="text-xs px-2 py-1 text-muted-foreground hover:text-foreground rounded transition-colors"
|
|
164
|
+
>
|
|
165
|
+
취소
|
|
166
|
+
</button>
|
|
167
|
+
</>
|
|
168
|
+
) : (
|
|
169
|
+
<>
|
|
170
|
+
{onCleanup && (
|
|
171
|
+
<button
|
|
172
|
+
onClick={onCleanup}
|
|
173
|
+
disabled={cleaning || loading}
|
|
174
|
+
className="text-xs px-2 py-1 text-accent hover:bg-accent/10 rounded transition-colors disabled:opacity-30"
|
|
175
|
+
>
|
|
176
|
+
정리
|
|
177
|
+
</button>
|
|
178
|
+
)}
|
|
179
|
+
<button
|
|
180
|
+
onClick={() => { setCollapseAll(!collapseAll); setCollapseKey(k => k + 1); }}
|
|
181
|
+
className="text-xs px-2 py-1 text-muted-foreground hover:text-foreground rounded transition-colors"
|
|
182
|
+
title={collapseAll ? '전체 펼치기' : '전체 접기'}
|
|
183
|
+
>
|
|
184
|
+
{collapseAll ? '펼치기' : '접기'}
|
|
185
|
+
</button>
|
|
186
|
+
<button
|
|
187
|
+
onClick={() => onBulkStatus('done')}
|
|
188
|
+
className="text-xs px-2 py-1 text-success hover:bg-success/10 rounded transition-colors"
|
|
189
|
+
>
|
|
190
|
+
전체완료
|
|
191
|
+
</button>
|
|
192
|
+
<button
|
|
193
|
+
onClick={() => setSelectMode(true)}
|
|
194
|
+
className="text-xs px-2 py-1 text-muted-foreground hover:text-foreground rounded transition-colors"
|
|
195
|
+
>
|
|
196
|
+
선택
|
|
197
|
+
</button>
|
|
198
|
+
<button
|
|
199
|
+
onClick={handleDeleteAll}
|
|
200
|
+
className="text-xs px-2 py-1 text-destructive hover:bg-destructive/10 rounded transition-colors"
|
|
201
|
+
>
|
|
202
|
+
전체삭제
|
|
203
|
+
</button>
|
|
204
|
+
</>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{items.length > 0 && viewMode === 'card' && (
|
|
210
|
+
<div className="flex items-center gap-1">
|
|
211
|
+
{onCleanup && (
|
|
212
|
+
<button
|
|
213
|
+
onClick={onCleanup}
|
|
214
|
+
disabled={cleaning || loading}
|
|
215
|
+
className="text-xs px-2 py-1 text-accent hover:bg-accent/10 rounded transition-colors disabled:opacity-30"
|
|
216
|
+
>
|
|
217
|
+
정리
|
|
218
|
+
</button>
|
|
219
|
+
)}
|
|
220
|
+
<button
|
|
221
|
+
onClick={() => onBulkStatus('done')}
|
|
222
|
+
className="text-xs px-2 py-1 text-success hover:bg-success/10 rounded transition-colors"
|
|
223
|
+
>
|
|
224
|
+
전체완료
|
|
225
|
+
</button>
|
|
226
|
+
<button
|
|
227
|
+
onClick={handleDeleteAll}
|
|
228
|
+
className="text-xs px-2 py-1 text-destructive hover:bg-destructive/10 rounded transition-colors"
|
|
229
|
+
>
|
|
230
|
+
전체삭제
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
33
235
|
</div>
|
|
34
236
|
|
|
35
|
-
<div className="flex-1 overflow-auto
|
|
36
|
-
{
|
|
37
|
-
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
237
|
+
<div className="flex-1 overflow-auto">
|
|
238
|
+
{displayItems.length === 0 && !loading ? (
|
|
239
|
+
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm p-4">
|
|
240
|
+
{items.length > 0 && hideDone ? (
|
|
241
|
+
<>
|
|
242
|
+
<div className="text-4xl mb-3">✅</div>
|
|
243
|
+
<p className="mb-2">모든 항목이 완료되었습니다</p>
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => setHideDone(false)}
|
|
246
|
+
className="text-xs text-accent hover:underline"
|
|
247
|
+
>
|
|
248
|
+
완료 항목 보기
|
|
249
|
+
</button>
|
|
250
|
+
</>
|
|
251
|
+
) : (
|
|
252
|
+
<>
|
|
253
|
+
<div className="text-4xl mb-3">🗂</div>
|
|
254
|
+
<p className="mb-2">아직 구조화된 항목이 없습니다</p>
|
|
255
|
+
<p className="text-xs text-center">
|
|
256
|
+
왼쪽 패널에서 아이디어를 입력해보세요.
|
|
257
|
+
<br />
|
|
258
|
+
입력을 멈추면 3초 후 AI가 자동으로 구조화합니다.
|
|
259
|
+
</p>
|
|
260
|
+
</>
|
|
261
|
+
)}
|
|
45
262
|
</div>
|
|
263
|
+
) : viewMode === 'card' ? (
|
|
264
|
+
<CardView
|
|
265
|
+
items={displayItems}
|
|
266
|
+
onItemUpdate={onItemUpdate}
|
|
267
|
+
onItemDelete={onItemDelete}
|
|
268
|
+
/>
|
|
46
269
|
) : (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
270
|
+
<div className="p-2">
|
|
271
|
+
{displayItems.map((item) => (
|
|
272
|
+
<TreeNode
|
|
273
|
+
key={`${item.id}-${collapseKey}`}
|
|
274
|
+
item={item}
|
|
275
|
+
depth={0}
|
|
276
|
+
projectId={projectId}
|
|
277
|
+
onItemUpdate={onItemUpdate}
|
|
278
|
+
onItemDelete={onItemDelete}
|
|
279
|
+
onTreeRefresh={onTreeRefresh}
|
|
280
|
+
selectMode={selectMode}
|
|
281
|
+
selected={selected}
|
|
282
|
+
onToggleSelect={toggleSelect}
|
|
283
|
+
defaultExpanded={!collapseAll}
|
|
284
|
+
/>
|
|
285
|
+
))}
|
|
286
|
+
</div>
|
|
56
287
|
)}
|
|
57
288
|
</div>
|
|
58
289
|
</div>
|
|
59
290
|
);
|
|
60
291
|
}
|
|
292
|
+
|
|
293
|
+
function countChildren(item: IItemTree): number {
|
|
294
|
+
return item.children.reduce((sum, child) => sum + 1 + countChildren(child), 0);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function countByStatus(items: IItemTree[], status: string): number {
|
|
298
|
+
let count = 0;
|
|
299
|
+
for (const item of items) {
|
|
300
|
+
if (item.status === status) count++;
|
|
301
|
+
count += countByStatus(item.children, status);
|
|
302
|
+
}
|
|
303
|
+
return count;
|
|
304
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ConfirmDialogProps {
|
|
6
|
+
open: boolean;
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
confirmLabel?: string;
|
|
10
|
+
cancelLabel?: string;
|
|
11
|
+
variant?: 'danger' | 'default';
|
|
12
|
+
onConfirm: () => void;
|
|
13
|
+
onCancel: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function ConfirmDialog({
|
|
17
|
+
open,
|
|
18
|
+
title,
|
|
19
|
+
description,
|
|
20
|
+
confirmLabel = 'Confirm',
|
|
21
|
+
cancelLabel = 'Cancel',
|
|
22
|
+
variant = 'default',
|
|
23
|
+
onConfirm,
|
|
24
|
+
onCancel,
|
|
25
|
+
}: ConfirmDialogProps) {
|
|
26
|
+
const confirmRef = useRef<HTMLButtonElement>(null);
|
|
27
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (open) {
|
|
31
|
+
confirmRef.current?.focus();
|
|
32
|
+
}
|
|
33
|
+
}, [open]);
|
|
34
|
+
|
|
35
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
36
|
+
if (!open) return;
|
|
37
|
+
if (e.key === 'Escape') onCancel();
|
|
38
|
+
if (e.key === 'Enter') onConfirm();
|
|
39
|
+
}, [open, onCancel, onConfirm]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
43
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
44
|
+
}, [handleKeyDown]);
|
|
45
|
+
|
|
46
|
+
if (!open) return null;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
ref={overlayRef}
|
|
51
|
+
onClick={(e) => { if (e.target === overlayRef.current) onCancel(); }}
|
|
52
|
+
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
53
|
+
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(2px)' }}
|
|
54
|
+
>
|
|
55
|
+
<div
|
|
56
|
+
className="bg-card border border-border rounded-xl shadow-2xl shadow-black/40
|
|
57
|
+
w-full max-w-sm mx-4 animate-dialog-in"
|
|
58
|
+
>
|
|
59
|
+
<div className="p-5">
|
|
60
|
+
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
|
61
|
+
{description && (
|
|
62
|
+
<p className="text-xs text-muted-foreground mt-1.5 leading-relaxed">{description}</p>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
<div className="flex justify-end gap-2 px-5 pb-4">
|
|
66
|
+
<button
|
|
67
|
+
onClick={onCancel}
|
|
68
|
+
className="px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground
|
|
69
|
+
bg-muted hover:bg-card-hover border border-border rounded-md transition-colors"
|
|
70
|
+
>
|
|
71
|
+
{cancelLabel}
|
|
72
|
+
</button>
|
|
73
|
+
<button
|
|
74
|
+
ref={confirmRef}
|
|
75
|
+
onClick={onConfirm}
|
|
76
|
+
className={`px-3 py-1.5 text-xs text-white rounded-md transition-colors ${
|
|
77
|
+
variant === 'danger'
|
|
78
|
+
? 'bg-destructive hover:bg-destructive/80'
|
|
79
|
+
: 'bg-primary hover:bg-primary-hover'
|
|
80
|
+
}`}
|
|
81
|
+
>
|
|
82
|
+
{confirmLabel}
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -2,6 +2,7 @@ import { runStructureWithQuestions, type IStructuredItem } from './client';
|
|
|
2
2
|
import { replaceItems } from '../db/queries/items';
|
|
3
3
|
import { getRecentConversations, addMessage } from '../db/queries/conversations';
|
|
4
4
|
import { getBrainstorm } from '../db/queries/brainstorms';
|
|
5
|
+
import { getProjectContextSummary } from '../db/queries/context';
|
|
5
6
|
import { resolveMemos, createMemosFromQuestions } from '../db/queries/memos';
|
|
6
7
|
import type { IItemTree, IMemo, IConversation } from '@/types';
|
|
7
8
|
|
|
@@ -29,8 +30,9 @@ export async function handleChatResponse(
|
|
|
29
30
|
content: h.content,
|
|
30
31
|
}));
|
|
31
32
|
|
|
32
|
-
// AI call with updated conversation context
|
|
33
|
-
const
|
|
33
|
+
// AI call with updated conversation context + project docs
|
|
34
|
+
const projectContext = getProjectContextSummary(projectId) || undefined;
|
|
35
|
+
const result = await runStructureWithQuestions(brainstorm.content, historyForAi, projectContext);
|
|
34
36
|
|
|
35
37
|
// Replace items in DB
|
|
36
38
|
const dbItems = mapToDbFormat(result.items as IStructuredItem[]);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { runClaude, extractJson, type IStructuredItem } from './client';
|
|
2
|
+
import { replaceItems, getItemTree } from '../db/queries/items';
|
|
3
|
+
import type { IItemTree } from '@/types';
|
|
4
|
+
|
|
5
|
+
function serializeItems(items: IItemTree[], depth = 0): string {
|
|
6
|
+
const lines: string[] = [];
|
|
7
|
+
for (const item of items) {
|
|
8
|
+
const indent = ' '.repeat(depth);
|
|
9
|
+
const status = item.status || 'pending';
|
|
10
|
+
lines.push(`${indent}- [${item.item_type}/${item.priority}/${status}] ${item.title}: ${item.description || ''}`);
|
|
11
|
+
if (item.children && item.children.length > 0) {
|
|
12
|
+
lines.push(serializeItems(item.children, depth + 1));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return lines.join('\n');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function countItems(items: IItemTree[]): number {
|
|
19
|
+
let count = 0;
|
|
20
|
+
for (const item of items) {
|
|
21
|
+
count++;
|
|
22
|
+
if (item.children) count += countItems(item.children);
|
|
23
|
+
}
|
|
24
|
+
return count;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mapToDbFormat(items: IStructuredItem[]): Parameters<typeof replaceItems>[2] {
|
|
28
|
+
return items.map((item) => ({
|
|
29
|
+
parent_id: null,
|
|
30
|
+
title: item.title,
|
|
31
|
+
description: item.description,
|
|
32
|
+
item_type: item.item_type,
|
|
33
|
+
priority: item.priority,
|
|
34
|
+
status: item.status,
|
|
35
|
+
children: item.children ? mapToDbFormat(item.children) : undefined,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function cleanupItems(
|
|
40
|
+
projectId: string,
|
|
41
|
+
brainstormId: string,
|
|
42
|
+
items: IItemTree[],
|
|
43
|
+
brainstormContent: string,
|
|
44
|
+
): Promise<{ items: IItemTree[]; changed: boolean }> {
|
|
45
|
+
const serialized = serializeItems(items);
|
|
46
|
+
const beforeCount = countItems(items);
|
|
47
|
+
|
|
48
|
+
const prompt = `You are a JSON-only deduplication machine. You NEVER respond with text, explanations, or conversation.
|
|
49
|
+
You ALWAYS output ONLY a raw JSON array, nothing else.
|
|
50
|
+
|
|
51
|
+
Your job: clean up the structured item tree below by removing duplicates and merging similar items.
|
|
52
|
+
|
|
53
|
+
Schema per item:
|
|
54
|
+
{ "title": string, "description": string, "item_type": "feature"|"task"|"bug"|"idea"|"note", "priority": "high"|"medium"|"low", "status": "pending"|"in_progress"|"done", "children": [same schema] }
|
|
55
|
+
|
|
56
|
+
Rules:
|
|
57
|
+
- Output MUST start with [ and end with ]
|
|
58
|
+
- No markdown fences, no explanation, no text before or after the JSON
|
|
59
|
+
- MERGE items that describe the same concept (combine their descriptions, keep the more specific title)
|
|
60
|
+
- REMOVE exact or near-exact duplicates (keep the one with more detail)
|
|
61
|
+
- PRESERVE the status of items — if one copy is "done" and another is "pending", keep "done"
|
|
62
|
+
- PRESERVE the hierarchy — keep parent-child relationships logical
|
|
63
|
+
- Keep titles concise (under 50 chars)
|
|
64
|
+
- Do NOT add new items that weren't in the original
|
|
65
|
+
- Do NOT remove items just because they seem unimportant — only remove TRUE duplicates
|
|
66
|
+
- If the brainstorming context is provided, use it to understand which items are actually the same concept
|
|
67
|
+
|
|
68
|
+
${brainstormContent ? `사용자의 브레인스토밍 메모:\n${brainstormContent}\n\n` : ''}현재 구조화된 항목 (중복 제거 및 병합하세요):
|
|
69
|
+
${serialized}`;
|
|
70
|
+
|
|
71
|
+
const resultText = await runClaude(prompt);
|
|
72
|
+
const json = extractJson(resultText, 'array');
|
|
73
|
+
const cleaned = JSON.parse(json) as IStructuredItem[];
|
|
74
|
+
|
|
75
|
+
const afterCount = cleaned.reduce((sum, item) => sum + 1 + countStructuredChildren(item), 0);
|
|
76
|
+
const changed = afterCount !== beforeCount;
|
|
77
|
+
|
|
78
|
+
const dbItems = mapToDbFormat(cleaned);
|
|
79
|
+
const tree = replaceItems(projectId, brainstormId, dbItems);
|
|
80
|
+
|
|
81
|
+
return { items: tree, changed };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function countStructuredChildren(item: IStructuredItem): number {
|
|
85
|
+
if (!item.children) return 0;
|
|
86
|
+
return item.children.reduce((sum, child) => sum + 1 + countStructuredChildren(child), 0);
|
|
87
|
+
}
|