idea-manager 0.1.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/README.md +36 -0
- package/bin/im.js +4 -0
- package/next.config.ts +8 -0
- package/package.json +55 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/health/route.ts +5 -0
- package/src/app/api/projects/[id]/brainstorm/route.ts +37 -0
- package/src/app/api/projects/[id]/conversations/route.ts +50 -0
- package/src/app/api/projects/[id]/items/[itemId]/prompt/route.ts +51 -0
- package/src/app/api/projects/[id]/items/[itemId]/route.ts +73 -0
- package/src/app/api/projects/[id]/items/route.ts +17 -0
- package/src/app/api/projects/[id]/memos/route.ts +18 -0
- package/src/app/api/projects/[id]/route.ts +39 -0
- package/src/app/api/projects/[id]/structure/route.ts +28 -0
- package/src/app/api/projects/route.ts +19 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +437 -0
- package/src/app/layout.tsx +42 -0
- package/src/app/page.tsx +175 -0
- package/src/app/projects/[id]/page.tsx +249 -0
- package/src/cli.ts +41 -0
- package/src/components/brainstorm/Editor.tsx +163 -0
- package/src/components/brainstorm/MemoPin.tsx +31 -0
- package/src/components/brainstorm/ResizeHandle.tsx +45 -0
- package/src/components/chat/ChatMessage.tsx +28 -0
- package/src/components/chat/ChatPanel.tsx +100 -0
- package/src/components/tree/ItemDetail.tsx +196 -0
- package/src/components/tree/LockToggle.tsx +23 -0
- package/src/components/tree/StatusBadge.tsx +32 -0
- package/src/components/tree/TreeNode.tsx +118 -0
- package/src/components/tree/TreeView.tsx +60 -0
- package/src/lib/ai/chat-responder.ts +69 -0
- package/src/lib/ai/client.ts +124 -0
- package/src/lib/ai/prompter.ts +83 -0
- package/src/lib/ai/structurer.ts +74 -0
- package/src/lib/db/index.ts +16 -0
- package/src/lib/db/queries/brainstorms.ts +26 -0
- package/src/lib/db/queries/conversations.ts +46 -0
- package/src/lib/db/queries/items.ts +147 -0
- package/src/lib/db/queries/memos.ts +66 -0
- package/src/lib/db/queries/projects.ts +53 -0
- package/src/lib/db/queries/prompts.ts +68 -0
- package/src/lib/db/schema.ts +78 -0
- package/src/lib/mcp/server.ts +117 -0
- package/src/lib/mcp/tools.ts +83 -0
- package/src/lib/utils/id.ts +5 -0
- package/src/lib/utils/paths.ts +16 -0
- package/src/types/index.ts +97 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ItemDetailProps {
|
|
6
|
+
itemId: string;
|
|
7
|
+
projectId: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
itemType: string;
|
|
11
|
+
priority: string;
|
|
12
|
+
status: string;
|
|
13
|
+
isLocked: boolean;
|
|
14
|
+
depth: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface IPrompt {
|
|
18
|
+
id: string;
|
|
19
|
+
content: string;
|
|
20
|
+
prompt_type: string;
|
|
21
|
+
version: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const typeLabels: Record<string, string> = {
|
|
25
|
+
feature: '기능',
|
|
26
|
+
task: '작업',
|
|
27
|
+
bug: '버그',
|
|
28
|
+
idea: '아이디어',
|
|
29
|
+
note: '메모',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const priorityLabels: Record<string, string> = {
|
|
33
|
+
high: '높음',
|
|
34
|
+
medium: '보통',
|
|
35
|
+
low: '낮음',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const statusLabels: Record<string, string> = {
|
|
39
|
+
pending: '대기',
|
|
40
|
+
in_progress: '진행 중',
|
|
41
|
+
done: '완료',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default function ItemDetail({
|
|
45
|
+
itemId,
|
|
46
|
+
projectId,
|
|
47
|
+
title,
|
|
48
|
+
description,
|
|
49
|
+
itemType,
|
|
50
|
+
priority,
|
|
51
|
+
status,
|
|
52
|
+
isLocked,
|
|
53
|
+
depth,
|
|
54
|
+
}: ItemDetailProps) {
|
|
55
|
+
const [prompt, setPrompt] = useState<IPrompt | null>(null);
|
|
56
|
+
const [loadingPrompt, setLoadingPrompt] = useState(false);
|
|
57
|
+
const [generating, setGenerating] = useState(false);
|
|
58
|
+
const [copied, setCopied] = useState(false);
|
|
59
|
+
const [editing, setEditing] = useState(false);
|
|
60
|
+
const [editContent, setEditContent] = useState('');
|
|
61
|
+
|
|
62
|
+
// Load existing prompt
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const loadPrompt = async () => {
|
|
65
|
+
setLoadingPrompt(true);
|
|
66
|
+
const res = await fetch(`/api/projects/${projectId}/items/${itemId}/prompt`);
|
|
67
|
+
if (res.ok) {
|
|
68
|
+
setPrompt(await res.json());
|
|
69
|
+
}
|
|
70
|
+
setLoadingPrompt(false);
|
|
71
|
+
};
|
|
72
|
+
loadPrompt();
|
|
73
|
+
}, [itemId, projectId]);
|
|
74
|
+
|
|
75
|
+
const handleGenerate = async () => {
|
|
76
|
+
setGenerating(true);
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(`/api/projects/${projectId}/items/${itemId}/prompt`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({}),
|
|
82
|
+
});
|
|
83
|
+
if (res.ok) {
|
|
84
|
+
setPrompt(await res.json());
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
setGenerating(false);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const handleCopy = async () => {
|
|
92
|
+
if (!prompt) return;
|
|
93
|
+
await navigator.clipboard.writeText(prompt.content);
|
|
94
|
+
setCopied(true);
|
|
95
|
+
setTimeout(() => setCopied(false), 2000);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const handleSaveEdit = async () => {
|
|
99
|
+
if (!editContent.trim()) return;
|
|
100
|
+
setGenerating(true);
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(`/api/projects/${projectId}/items/${itemId}/prompt`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ content: editContent }),
|
|
106
|
+
});
|
|
107
|
+
if (res.ok) {
|
|
108
|
+
setPrompt(await res.json());
|
|
109
|
+
setEditing(false);
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
setGenerating(false);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
className="item-detail"
|
|
119
|
+
style={{ marginLeft: `${depth * 20 + 8}px` }}
|
|
120
|
+
>
|
|
121
|
+
<p className="text-muted-foreground mb-2 text-xs">{description || '설명 없음'}</p>
|
|
122
|
+
|
|
123
|
+
<div className="flex gap-3 text-xs text-muted-foreground mb-3">
|
|
124
|
+
<span>유형: {typeLabels[itemType] || itemType}</span>
|
|
125
|
+
<span>우선순위: {priorityLabels[priority] || priority}</span>
|
|
126
|
+
<span>상태: {statusLabels[status] || status}</span>
|
|
127
|
+
<span>잠금: {isLocked ? '잠금' : '해제'}</span>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Prompt section */}
|
|
131
|
+
<div className="prompt-section">
|
|
132
|
+
<div className="prompt-header">
|
|
133
|
+
<span className="text-xs font-medium text-muted-foreground">프롬프트</span>
|
|
134
|
+
<div className="flex gap-1">
|
|
135
|
+
{prompt && (
|
|
136
|
+
<>
|
|
137
|
+
<button onClick={handleCopy} className="prompt-action-btn" title="복사">
|
|
138
|
+
{copied ? '복사됨' : '복사'}
|
|
139
|
+
</button>
|
|
140
|
+
<button
|
|
141
|
+
onClick={() => { setEditing(!editing); setEditContent(prompt.content); }}
|
|
142
|
+
className="prompt-action-btn"
|
|
143
|
+
title="수정"
|
|
144
|
+
>
|
|
145
|
+
{editing ? '취소' : '수정'}
|
|
146
|
+
</button>
|
|
147
|
+
</>
|
|
148
|
+
)}
|
|
149
|
+
<button
|
|
150
|
+
onClick={handleGenerate}
|
|
151
|
+
disabled={generating}
|
|
152
|
+
className="prompt-action-btn prompt-generate-btn"
|
|
153
|
+
title={prompt ? '재생성' : '생성'}
|
|
154
|
+
>
|
|
155
|
+
{generating ? '생성 중...' : prompt ? '재생성' : '생성'}
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{loadingPrompt ? (
|
|
161
|
+
<p className="text-xs text-muted-foreground">로딩 중...</p>
|
|
162
|
+
) : editing ? (
|
|
163
|
+
<div className="prompt-edit">
|
|
164
|
+
<textarea
|
|
165
|
+
value={editContent}
|
|
166
|
+
onChange={(e) => setEditContent(e.target.value)}
|
|
167
|
+
className="prompt-edit-textarea"
|
|
168
|
+
rows={4}
|
|
169
|
+
/>
|
|
170
|
+
<button
|
|
171
|
+
onClick={handleSaveEdit}
|
|
172
|
+
disabled={generating}
|
|
173
|
+
className="prompt-action-btn prompt-generate-btn mt-1"
|
|
174
|
+
>
|
|
175
|
+
저장
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
) : prompt ? (
|
|
179
|
+
<div className="prompt-content">
|
|
180
|
+
{prompt.content}
|
|
181
|
+
</div>
|
|
182
|
+
) : (
|
|
183
|
+
<p className="text-xs text-muted-foreground/60">
|
|
184
|
+
아직 프롬프트가 없습니다. "생성" 버튼을 클릭하세요.
|
|
185
|
+
</p>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{prompt && (
|
|
189
|
+
<div className="text-[10px] text-muted-foreground/40 mt-1">
|
|
190
|
+
v{prompt.version} · {prompt.prompt_type === 'manual' ? '수동' : '자동'}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
interface LockToggleProps {
|
|
4
|
+
isLocked: boolean;
|
|
5
|
+
onToggle: (locked: boolean) => void;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function LockToggle({ isLocked, onToggle, disabled }: LockToggleProps) {
|
|
10
|
+
return (
|
|
11
|
+
<button
|
|
12
|
+
onClick={(e) => {
|
|
13
|
+
e.stopPropagation();
|
|
14
|
+
onToggle(!isLocked);
|
|
15
|
+
}}
|
|
16
|
+
disabled={disabled}
|
|
17
|
+
className="lock-toggle"
|
|
18
|
+
title={isLocked ? '잠금 해제' : '잠금'}
|
|
19
|
+
>
|
|
20
|
+
{isLocked ? '\u{1F510}' : '\u{1F513}'}
|
|
21
|
+
</button>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
interface StatusBadgeProps {
|
|
4
|
+
status: string;
|
|
5
|
+
onStatusChange: (status: string) => void;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const statusConfig: Record<string, { icon: string; label: string; next: string }> = {
|
|
10
|
+
pending: { icon: '\u{23F3}', label: '대기', next: 'in_progress' },
|
|
11
|
+
in_progress: { icon: '\u{1F504}', label: '진행 중', next: 'done' },
|
|
12
|
+
done: { icon: '\u{2705}', label: '완료', next: 'pending' },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default function StatusBadge({ status, onStatusChange, disabled }: StatusBadgeProps) {
|
|
16
|
+
const config = statusConfig[status] || statusConfig.pending;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<button
|
|
20
|
+
onClick={(e) => {
|
|
21
|
+
e.stopPropagation();
|
|
22
|
+
onStatusChange(config.next);
|
|
23
|
+
}}
|
|
24
|
+
disabled={disabled}
|
|
25
|
+
className="status-badge"
|
|
26
|
+
title={`${config.label} → 클릭하여 변경`}
|
|
27
|
+
>
|
|
28
|
+
<span>{config.icon}</span>
|
|
29
|
+
<span className="status-badge-label">{config.label}</span>
|
|
30
|
+
</button>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import LockToggle from './LockToggle';
|
|
5
|
+
import StatusBadge from './StatusBadge';
|
|
6
|
+
import ItemDetail from './ItemDetail';
|
|
7
|
+
|
|
8
|
+
interface IItemTree {
|
|
9
|
+
id: string;
|
|
10
|
+
project_id?: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
item_type: string;
|
|
14
|
+
priority: string;
|
|
15
|
+
status: string;
|
|
16
|
+
is_locked: boolean;
|
|
17
|
+
children: IItemTree[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface TreeNodeProps {
|
|
21
|
+
item: IItemTree;
|
|
22
|
+
depth: number;
|
|
23
|
+
projectId: string;
|
|
24
|
+
onItemUpdate: (itemId: string, data: Record<string, unknown>) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const typeIcons: Record<string, string> = {
|
|
28
|
+
feature: '\u{1F4E6}',
|
|
29
|
+
task: '\u{2705}',
|
|
30
|
+
bug: '\u{1F41B}',
|
|
31
|
+
idea: '\u{1F4A1}',
|
|
32
|
+
note: '\u{1F4DD}',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const priorityColors: Record<string, string> = {
|
|
36
|
+
high: 'text-destructive',
|
|
37
|
+
medium: 'text-warning',
|
|
38
|
+
low: 'text-success',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default function TreeNode({ item, depth, projectId, onItemUpdate }: TreeNodeProps) {
|
|
42
|
+
const [expanded, setExpanded] = useState(true);
|
|
43
|
+
const [showDetail, setShowDetail] = useState(false);
|
|
44
|
+
const hasChildren = item.children.length > 0;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="select-none">
|
|
48
|
+
<div
|
|
49
|
+
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-hover
|
|
50
|
+
cursor-pointer transition-colors group"
|
|
51
|
+
style={{ paddingLeft: `${depth * 20 + 8}px` }}
|
|
52
|
+
onClick={() => setShowDetail(!showDetail)}
|
|
53
|
+
>
|
|
54
|
+
{hasChildren ? (
|
|
55
|
+
<button
|
|
56
|
+
onClick={(e) => {
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
setExpanded(!expanded);
|
|
59
|
+
}}
|
|
60
|
+
className="text-muted-foreground hover:text-foreground text-xs w-4 flex-shrink-0"
|
|
61
|
+
>
|
|
62
|
+
{expanded ? '\u25BC' : '\u25B6'}
|
|
63
|
+
</button>
|
|
64
|
+
) : (
|
|
65
|
+
<span className="w-4 flex-shrink-0" />
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
<LockToggle
|
|
69
|
+
isLocked={item.is_locked}
|
|
70
|
+
onToggle={(locked) => onItemUpdate(item.id, { is_locked: locked })}
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
<span className="text-sm flex-shrink-0">
|
|
74
|
+
{typeIcons[item.item_type] || '\u{1F4CB}'}
|
|
75
|
+
</span>
|
|
76
|
+
|
|
77
|
+
<span className="text-sm flex-1 truncate">{item.title}</span>
|
|
78
|
+
|
|
79
|
+
<span className={`text-xs flex-shrink-0 ${priorityColors[item.priority] || ''}`}>
|
|
80
|
+
{item.priority === 'high' ? '\u{1F534}' : item.priority === 'medium' ? '\u{1F7E1}' : '\u{1F7E2}'}
|
|
81
|
+
</span>
|
|
82
|
+
|
|
83
|
+
<StatusBadge
|
|
84
|
+
status={item.status}
|
|
85
|
+
onStatusChange={(status) => onItemUpdate(item.id, { status })}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{showDetail && (
|
|
90
|
+
<ItemDetail
|
|
91
|
+
itemId={item.id}
|
|
92
|
+
projectId={projectId}
|
|
93
|
+
title={item.title}
|
|
94
|
+
description={item.description}
|
|
95
|
+
itemType={item.item_type}
|
|
96
|
+
priority={item.priority}
|
|
97
|
+
status={item.status}
|
|
98
|
+
isLocked={item.is_locked}
|
|
99
|
+
depth={depth}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{expanded && hasChildren && (
|
|
104
|
+
<div>
|
|
105
|
+
{item.children.map((child) => (
|
|
106
|
+
<TreeNode
|
|
107
|
+
key={child.id}
|
|
108
|
+
item={child}
|
|
109
|
+
depth={depth + 1}
|
|
110
|
+
projectId={projectId}
|
|
111
|
+
onItemUpdate={onItemUpdate}
|
|
112
|
+
/>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import TreeNode from './TreeNode';
|
|
4
|
+
|
|
5
|
+
interface IItemTree {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
description: string;
|
|
9
|
+
item_type: string;
|
|
10
|
+
priority: string;
|
|
11
|
+
status: string;
|
|
12
|
+
is_locked: boolean;
|
|
13
|
+
children: IItemTree[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TreeViewProps {
|
|
17
|
+
items: IItemTree[];
|
|
18
|
+
loading: boolean;
|
|
19
|
+
projectId: string;
|
|
20
|
+
onItemUpdate: (itemId: string, data: Record<string, unknown>) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function TreeView({ items, loading, projectId, onItemUpdate }: TreeViewProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex flex-col h-full">
|
|
26
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
|
27
|
+
<h2 className="text-sm font-medium text-muted-foreground">구조화 뷰</h2>
|
|
28
|
+
{loading && (
|
|
29
|
+
<span className="text-xs text-accent animate-pulse">
|
|
30
|
+
AI 분석 중...
|
|
31
|
+
</span>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div className="flex-1 overflow-auto p-2">
|
|
36
|
+
{items.length === 0 && !loading ? (
|
|
37
|
+
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
|
|
38
|
+
<div className="text-4xl mb-3">🗂</div>
|
|
39
|
+
<p className="mb-2">아직 구조화된 항목이 없습니다</p>
|
|
40
|
+
<p className="text-xs text-center">
|
|
41
|
+
왼쪽 패널에서 아이디어를 입력해보세요.
|
|
42
|
+
<br />
|
|
43
|
+
입력을 멈추면 3초 후 AI가 자동으로 구조화합니다.
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
) : (
|
|
47
|
+
items.map((item) => (
|
|
48
|
+
<TreeNode
|
|
49
|
+
key={item.id}
|
|
50
|
+
item={item}
|
|
51
|
+
depth={0}
|
|
52
|
+
projectId={projectId}
|
|
53
|
+
onItemUpdate={onItemUpdate}
|
|
54
|
+
/>
|
|
55
|
+
))
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { runStructureWithQuestions, type IStructuredItem } from './client';
|
|
2
|
+
import { replaceItems } from '../db/queries/items';
|
|
3
|
+
import { getRecentConversations, addMessage } from '../db/queries/conversations';
|
|
4
|
+
import { getBrainstorm } from '../db/queries/brainstorms';
|
|
5
|
+
import { resolveMemos, createMemosFromQuestions } from '../db/queries/memos';
|
|
6
|
+
import type { IItemTree, IMemo, IConversation } from '@/types';
|
|
7
|
+
|
|
8
|
+
export async function handleChatResponse(
|
|
9
|
+
projectId: string,
|
|
10
|
+
brainstormId: string,
|
|
11
|
+
userMessage: string,
|
|
12
|
+
): Promise<{ items: IItemTree[]; memos: IMemo[]; messages: IConversation[] }> {
|
|
13
|
+
// Save user message
|
|
14
|
+
const userMsg = addMessage(projectId, 'user', userMessage);
|
|
15
|
+
|
|
16
|
+
// Load brainstorm content
|
|
17
|
+
const brainstorm = getBrainstorm(projectId);
|
|
18
|
+
if (!brainstorm || !brainstorm.content.trim()) {
|
|
19
|
+
return { items: [], memos: [], messages: [userMsg] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Resolve old memos before generating new ones
|
|
23
|
+
resolveMemos(projectId);
|
|
24
|
+
|
|
25
|
+
// Load full conversation history (limited to 20)
|
|
26
|
+
const history = getRecentConversations(projectId, 20);
|
|
27
|
+
const historyForAi = history.map(h => ({
|
|
28
|
+
role: h.role,
|
|
29
|
+
content: h.content,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// AI call with updated conversation context
|
|
33
|
+
const result = await runStructureWithQuestions(brainstorm.content, historyForAi);
|
|
34
|
+
|
|
35
|
+
// Replace items in DB
|
|
36
|
+
const dbItems = mapToDbFormat(result.items as IStructuredItem[]);
|
|
37
|
+
const tree = replaceItems(projectId, brainstormId, dbItems);
|
|
38
|
+
|
|
39
|
+
// Build AI response + new memos
|
|
40
|
+
const newMessages: IConversation[] = [userMsg];
|
|
41
|
+
let memos: IMemo[] = [];
|
|
42
|
+
|
|
43
|
+
if (result.questions.length > 0) {
|
|
44
|
+
const messageContent = result.questions
|
|
45
|
+
.map((q, i) => `${i + 1}. ${q.question}`)
|
|
46
|
+
.join('\n');
|
|
47
|
+
|
|
48
|
+
const aiMsg = addMessage(projectId, 'assistant', messageContent);
|
|
49
|
+
newMessages.push(aiMsg);
|
|
50
|
+
memos = createMemosFromQuestions(projectId, aiMsg.id, result.questions);
|
|
51
|
+
} else {
|
|
52
|
+
// Even without questions, acknowledge the refinement
|
|
53
|
+
const aiMsg = addMessage(projectId, 'assistant', '답변을 반영하여 구조를 업데이트했습니다.');
|
|
54
|
+
newMessages.push(aiMsg);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { items: tree, memos, messages: newMessages };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function mapToDbFormat(items: IStructuredItem[]): Parameters<typeof replaceItems>[2] {
|
|
61
|
+
return items.map((item) => ({
|
|
62
|
+
parent_id: null,
|
|
63
|
+
title: item.title,
|
|
64
|
+
description: item.description,
|
|
65
|
+
item_type: item.item_type,
|
|
66
|
+
priority: item.priority,
|
|
67
|
+
children: item.children ? mapToDbFormat(item.children) : undefined,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import type { IStructureWithQuestions } from '@/types';
|
|
3
|
+
|
|
4
|
+
export interface IStructuredItem {
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
item_type: 'feature' | 'task' | 'bug' | 'idea' | 'note';
|
|
8
|
+
priority: 'high' | 'medium' | 'low';
|
|
9
|
+
children?: IStructuredItem[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function runStructure(brainstormContent: string): Promise<IStructuredItem[]> {
|
|
13
|
+
const systemPrompt = `You are a JSON-only structuring machine. You NEVER respond with text, explanations, or conversation.
|
|
14
|
+
You ALWAYS output ONLY a raw JSON array, nothing else.
|
|
15
|
+
|
|
16
|
+
Your job: convert ANY input text into a structured JSON array of items.
|
|
17
|
+
Even if the input seems like a greeting or conversation, extract the implicit intent and structure it.
|
|
18
|
+
|
|
19
|
+
Schema per item:
|
|
20
|
+
{ "title": string, "description": string, "item_type": "feature"|"task"|"bug"|"idea"|"note", "priority": "high"|"medium"|"low", "children": [same schema] }
|
|
21
|
+
|
|
22
|
+
Rules:
|
|
23
|
+
- Output MUST start with [ and end with ]
|
|
24
|
+
- No markdown fences, no explanation, no text before or after the JSON
|
|
25
|
+
- Keep titles concise (under 50 chars)
|
|
26
|
+
- Group related ideas under parent items
|
|
27
|
+
- If input is vague, interpret it as best you can and create at least 1 item`;
|
|
28
|
+
|
|
29
|
+
const prompt = `Analyze this brainstorming content and structure it into a JSON tree:\n\n${brainstormContent}`;
|
|
30
|
+
|
|
31
|
+
let resultText = '';
|
|
32
|
+
|
|
33
|
+
for await (const message of query({
|
|
34
|
+
prompt: `${systemPrompt}\n\n${prompt}`,
|
|
35
|
+
options: {
|
|
36
|
+
allowedTools: [],
|
|
37
|
+
maxTurns: 1,
|
|
38
|
+
},
|
|
39
|
+
})) {
|
|
40
|
+
if (message.type === 'result') {
|
|
41
|
+
resultText = (message as { type: string; result: string }).result || '';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Strip markdown fences if present
|
|
46
|
+
resultText = resultText.replace(/```(?:json)?\s*/g, '').replace(/```\s*/g, '').trim();
|
|
47
|
+
|
|
48
|
+
// Extract JSON from the response
|
|
49
|
+
const jsonMatch = resultText.match(/\[[\s\S]*\]/);
|
|
50
|
+
if (!jsonMatch) {
|
|
51
|
+
throw new Error('AI did not return valid JSON');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return JSON.parse(jsonMatch[0]) as IStructuredItem[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function runStructureWithQuestions(
|
|
58
|
+
brainstormContent: string,
|
|
59
|
+
conversationHistory: { role: 'assistant' | 'user'; content: string }[],
|
|
60
|
+
): Promise<IStructureWithQuestions> {
|
|
61
|
+
const systemPrompt = `You are an AI assistant that structures brainstorming content AND identifies ambiguous areas.
|
|
62
|
+
You ALWAYS output ONLY a raw JSON object (not an array), nothing else.
|
|
63
|
+
|
|
64
|
+
Your job:
|
|
65
|
+
1. Convert the brainstorming text into a structured JSON tree of items
|
|
66
|
+
2. Identify 0-5 areas where the brainstorming is ambiguous or could benefit from clarification
|
|
67
|
+
3. Consider the conversation history to avoid repeating questions already answered
|
|
68
|
+
|
|
69
|
+
Output schema:
|
|
70
|
+
{
|
|
71
|
+
"items": [{ "title": string, "description": string, "item_type": "feature"|"task"|"bug"|"idea"|"note", "priority": "high"|"medium"|"low", "children": [same] }],
|
|
72
|
+
"questions": [{ "anchor_text": string, "question": string }]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
Rules:
|
|
76
|
+
- Output MUST be a JSON object with "items" and "questions" keys
|
|
77
|
+
- No markdown fences, no explanation, no text before or after the JSON
|
|
78
|
+
- Keep titles concise (under 50 chars)
|
|
79
|
+
- Group related ideas under parent items
|
|
80
|
+
- questions[].anchor_text MUST be an exact substring from the brainstorming content (5-20 chars)
|
|
81
|
+
- questions[].question should be a helpful Korean question asking for clarification
|
|
82
|
+
- Generate 0-5 questions. Skip questions already answered in conversation history.
|
|
83
|
+
- If the brainstorming is clear enough, return an empty questions array
|
|
84
|
+
- All questions MUST be in Korean`;
|
|
85
|
+
|
|
86
|
+
let historyContext = '';
|
|
87
|
+
if (conversationHistory.length > 0) {
|
|
88
|
+
historyContext = '\n\n이전 대화:\n' + conversationHistory
|
|
89
|
+
.map(m => `${m.role === 'user' ? '사용자' : 'AI'}: ${m.content}`)
|
|
90
|
+
.join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const prompt = `다음 브레인스토밍 내용을 분석하고 구조화하세요:\n\n${brainstormContent}${historyContext}`;
|
|
94
|
+
|
|
95
|
+
let resultText = '';
|
|
96
|
+
|
|
97
|
+
for await (const message of query({
|
|
98
|
+
prompt: `${systemPrompt}\n\n${prompt}`,
|
|
99
|
+
options: {
|
|
100
|
+
allowedTools: [],
|
|
101
|
+
maxTurns: 1,
|
|
102
|
+
},
|
|
103
|
+
})) {
|
|
104
|
+
if (message.type === 'result') {
|
|
105
|
+
resultText = (message as { type: string; result: string }).result || '';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Strip markdown fences if present
|
|
110
|
+
resultText = resultText.replace(/```(?:json)?\s*/g, '').replace(/```\s*/g, '').trim();
|
|
111
|
+
|
|
112
|
+
// Extract JSON object from the response
|
|
113
|
+
const jsonMatch = resultText.match(/\{[\s\S]*\}/);
|
|
114
|
+
if (!jsonMatch) {
|
|
115
|
+
throw new Error('AI did not return valid JSON');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
items: parsed.items || [],
|
|
122
|
+
questions: parsed.questions || [],
|
|
123
|
+
} as IStructureWithQuestions;
|
|
124
|
+
}
|