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,249 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef, use } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import Editor from '@/components/brainstorm/Editor';
|
|
6
|
+
import TreeView from '@/components/tree/TreeView';
|
|
7
|
+
import ChatPanel from '@/components/chat/ChatPanel';
|
|
8
|
+
import ResizeHandle from '@/components/brainstorm/ResizeHandle';
|
|
9
|
+
|
|
10
|
+
interface IProject {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface IItemTree {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
item_type: string;
|
|
21
|
+
priority: string;
|
|
22
|
+
status: string;
|
|
23
|
+
is_locked: boolean;
|
|
24
|
+
children: IItemTree[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface IMessage {
|
|
28
|
+
id: string;
|
|
29
|
+
role: 'assistant' | 'user';
|
|
30
|
+
content: string;
|
|
31
|
+
created_at: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface IMemo {
|
|
35
|
+
id: string;
|
|
36
|
+
anchor_text: string;
|
|
37
|
+
question: string;
|
|
38
|
+
is_resolved: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function ProjectWorkspace({ params }: { params: Promise<{ id: string }> }) {
|
|
42
|
+
const { id } = use(params);
|
|
43
|
+
const router = useRouter();
|
|
44
|
+
const [project, setProject] = useState<IProject | null>(null);
|
|
45
|
+
const [items, setItems] = useState<IItemTree[]>([]);
|
|
46
|
+
const [messages, setMessages] = useState<IMessage[]>([]);
|
|
47
|
+
const [memos, setMemos] = useState<IMemo[]>([]);
|
|
48
|
+
const [structuring, setStructuring] = useState(false);
|
|
49
|
+
const [chatLoading, setChatLoading] = useState(false);
|
|
50
|
+
const [error, setError] = useState<string | null>(null);
|
|
51
|
+
const [editorPercent, setEditorPercent] = useState(60);
|
|
52
|
+
const leftPanelRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const loadProject = async () => {
|
|
56
|
+
const res = await fetch(`/api/projects/${id}`);
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
router.push('/');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
setProject(await res.json());
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const loadItems = async () => {
|
|
65
|
+
const res = await fetch(`/api/projects/${id}/items`);
|
|
66
|
+
if (res.ok) {
|
|
67
|
+
setItems(await res.json());
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const loadConversations = async () => {
|
|
72
|
+
const res = await fetch(`/api/projects/${id}/conversations`);
|
|
73
|
+
if (res.ok) {
|
|
74
|
+
setMessages(await res.json());
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const loadMemos = async () => {
|
|
79
|
+
const res = await fetch(`/api/projects/${id}/memos?unresolved=true`);
|
|
80
|
+
if (res.ok) {
|
|
81
|
+
setMemos(await res.json());
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
loadProject();
|
|
86
|
+
loadItems();
|
|
87
|
+
loadConversations();
|
|
88
|
+
loadMemos();
|
|
89
|
+
}, [id, router]);
|
|
90
|
+
|
|
91
|
+
const handleStructure = useCallback(async (_content: string) => {
|
|
92
|
+
setStructuring(true);
|
|
93
|
+
setError(null);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(`/api/projects/${id}/structure`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (res.ok) {
|
|
101
|
+
const data = await res.json();
|
|
102
|
+
setItems(data.items);
|
|
103
|
+
if (data.message) {
|
|
104
|
+
setMessages(prev => [...prev, data.message]);
|
|
105
|
+
}
|
|
106
|
+
if (data.memos) {
|
|
107
|
+
setMemos(data.memos);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
const data = await res.json();
|
|
111
|
+
setError(data.error || '구조화에 실패했습니다');
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
setError('AI 연결에 실패했습니다');
|
|
115
|
+
} finally {
|
|
116
|
+
setStructuring(false);
|
|
117
|
+
}
|
|
118
|
+
}, [id]);
|
|
119
|
+
|
|
120
|
+
const handleItemUpdate = useCallback(async (itemId: string, data: Record<string, unknown>) => {
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch(`/api/projects/${id}/items/${itemId}`, {
|
|
123
|
+
method: 'PUT',
|
|
124
|
+
headers: { 'Content-Type': 'application/json' },
|
|
125
|
+
body: JSON.stringify(data),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (res.ok) {
|
|
129
|
+
// Reload items tree to reflect changes (including cascaded lock)
|
|
130
|
+
const itemsRes = await fetch(`/api/projects/${id}/items`);
|
|
131
|
+
if (itemsRes.ok) {
|
|
132
|
+
setItems(await itemsRes.json());
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
setError('항목 업데이트에 실패했습니다');
|
|
137
|
+
}
|
|
138
|
+
}, [id]);
|
|
139
|
+
|
|
140
|
+
const handleSendMessage = useCallback(async (message: string) => {
|
|
141
|
+
setChatLoading(true);
|
|
142
|
+
setError(null);
|
|
143
|
+
|
|
144
|
+
// Optimistically add user message
|
|
145
|
+
const tempUserMsg: IMessage = {
|
|
146
|
+
id: `temp-${Date.now()}`,
|
|
147
|
+
role: 'user',
|
|
148
|
+
content: message,
|
|
149
|
+
created_at: new Date().toISOString(),
|
|
150
|
+
};
|
|
151
|
+
setMessages(prev => [...prev, tempUserMsg]);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetch(`/api/projects/${id}/conversations`, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'Content-Type': 'application/json' },
|
|
157
|
+
body: JSON.stringify({ message }),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (res.ok) {
|
|
161
|
+
const data = await res.json();
|
|
162
|
+
setItems(data.items);
|
|
163
|
+
// Replace the temp message with real messages
|
|
164
|
+
setMessages(prev => {
|
|
165
|
+
const withoutTemp = prev.filter(m => m.id !== tempUserMsg.id);
|
|
166
|
+
return [...withoutTemp, ...data.messages];
|
|
167
|
+
});
|
|
168
|
+
if (data.memos) {
|
|
169
|
+
setMemos(data.memos);
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
const data = await res.json();
|
|
173
|
+
setError(data.error || '응답에 실패했습니다');
|
|
174
|
+
// Remove temp message on error
|
|
175
|
+
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
setError('AI 연결에 실패했습니다');
|
|
179
|
+
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
|
|
180
|
+
} finally {
|
|
181
|
+
setChatLoading(false);
|
|
182
|
+
}
|
|
183
|
+
}, [id]);
|
|
184
|
+
|
|
185
|
+
if (!project) {
|
|
186
|
+
return (
|
|
187
|
+
<div className="min-h-screen flex items-center justify-center text-muted-foreground">
|
|
188
|
+
로딩 중...
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="h-screen flex flex-col">
|
|
195
|
+
{/* Header */}
|
|
196
|
+
<header className="flex items-center justify-between px-4 py-2 border-b border-border bg-card">
|
|
197
|
+
<div className="flex items-center gap-3">
|
|
198
|
+
<button
|
|
199
|
+
onClick={() => router.push('/')}
|
|
200
|
+
className="text-muted-foreground hover:text-foreground hover:bg-muted transition-colors text-sm px-2 py-1 rounded-md"
|
|
201
|
+
>
|
|
202
|
+
← 뒤로
|
|
203
|
+
</button>
|
|
204
|
+
<span className="text-border">|</span>
|
|
205
|
+
<h1 className="text-sm font-semibold">{project.name}</h1>
|
|
206
|
+
{project.description && (
|
|
207
|
+
<span className="text-xs text-muted-foreground">{project.description}</span>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
<div className="flex items-center gap-2">
|
|
211
|
+
{error && (
|
|
212
|
+
<span className="text-xs text-destructive">{error}</span>
|
|
213
|
+
)}
|
|
214
|
+
<button
|
|
215
|
+
onClick={() => handleStructure('')}
|
|
216
|
+
disabled={structuring}
|
|
217
|
+
className="px-3 py-1.5 text-xs bg-accent hover:bg-accent/80 text-white
|
|
218
|
+
rounded-md transition-colors disabled:opacity-50"
|
|
219
|
+
>
|
|
220
|
+
{structuring ? '구조화 중...' : '지금 구조화'}
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
</header>
|
|
224
|
+
|
|
225
|
+
{/* 2-Panel Layout */}
|
|
226
|
+
<div className="flex-1 flex overflow-hidden">
|
|
227
|
+
{/* Left: Editor (top) + Chat (bottom) */}
|
|
228
|
+
<div ref={leftPanelRef} className="w-1/2 border-r border-border flex flex-col">
|
|
229
|
+
<div style={{ height: `${editorPercent}%` }} className="flex flex-col min-h-0">
|
|
230
|
+
<Editor projectId={id} onContentChange={handleStructure} memos={memos} />
|
|
231
|
+
</div>
|
|
232
|
+
<ResizeHandle onResize={setEditorPercent} containerRef={leftPanelRef} />
|
|
233
|
+
<div style={{ height: `${100 - editorPercent}%` }} className="flex flex-col min-h-0">
|
|
234
|
+
<ChatPanel
|
|
235
|
+
messages={messages}
|
|
236
|
+
loading={chatLoading || structuring}
|
|
237
|
+
onSendMessage={handleSendMessage}
|
|
238
|
+
/>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{/* Right: Tree View */}
|
|
243
|
+
<div className="w-1/2 flex flex-col">
|
|
244
|
+
<TreeView items={items} loading={structuring} projectId={id} onItemUpdate={handleItemUpdate} />
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { startMcpServer } from '@/lib/mcp/server';
|
|
5
|
+
import { listProjects, getProject } from '@/lib/db/queries/projects';
|
|
6
|
+
import { getItemTree, getItems, updateItem } from '@/lib/db/queries/items';
|
|
7
|
+
import { getPrompt } from '@/lib/db/queries/prompts';
|
|
8
|
+
import type { McpToolContext } from '@/lib/mcp/tools';
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('im')
|
|
14
|
+
.description('Idea Manager CLI')
|
|
15
|
+
.version('1.0.0');
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.command('mcp')
|
|
19
|
+
.description('Start MCP server (stdio mode)')
|
|
20
|
+
.action(async () => {
|
|
21
|
+
const ctx: McpToolContext = {
|
|
22
|
+
listProjects,
|
|
23
|
+
getProject,
|
|
24
|
+
getItemTree,
|
|
25
|
+
getItems,
|
|
26
|
+
getPrompt,
|
|
27
|
+
updateItem: (id, data) => updateItem(id, data as Parameters<typeof updateItem>[1]),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
await startMcpServer(ctx);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.command('start')
|
|
35
|
+
.description('Start the web UI')
|
|
36
|
+
.action(async () => {
|
|
37
|
+
const open = (await import('open')).default;
|
|
38
|
+
await open('http://localhost:3456');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
program.parse();
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import MemoPin from './MemoPin';
|
|
5
|
+
|
|
6
|
+
interface Memo {
|
|
7
|
+
id: string;
|
|
8
|
+
anchor_text: string;
|
|
9
|
+
question: string;
|
|
10
|
+
is_resolved: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface EditorProps {
|
|
14
|
+
projectId: string;
|
|
15
|
+
onContentChange: (content: string) => void;
|
|
16
|
+
memos?: Memo[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PinPosition {
|
|
20
|
+
memo: Memo;
|
|
21
|
+
top: number;
|
|
22
|
+
left: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function Editor({ projectId, onContentChange, memos = [] }: EditorProps) {
|
|
26
|
+
const [content, setContent] = useState('');
|
|
27
|
+
const [saving, setSaving] = useState(false);
|
|
28
|
+
const [loaded, setLoaded] = useState(false);
|
|
29
|
+
const [pinPositions, setPinPositions] = useState<PinPosition[]>([]);
|
|
30
|
+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
31
|
+
const structureTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
32
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
33
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
|
|
35
|
+
// Load brainstorm content
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const load = async () => {
|
|
38
|
+
const res = await fetch(`/api/projects/${projectId}/brainstorm`);
|
|
39
|
+
const data = await res.json();
|
|
40
|
+
setContent(data.content || '');
|
|
41
|
+
setLoaded(true);
|
|
42
|
+
};
|
|
43
|
+
load();
|
|
44
|
+
}, [projectId]);
|
|
45
|
+
|
|
46
|
+
// Calculate pin positions when memos or content change
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!textareaRef.current || !content) {
|
|
49
|
+
setPinPositions([]);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const textarea = textareaRef.current;
|
|
54
|
+
const unresolvedMemos = memos.filter(m => !m.is_resolved);
|
|
55
|
+
const positions: PinPosition[] = [];
|
|
56
|
+
|
|
57
|
+
for (const memo of unresolvedMemos) {
|
|
58
|
+
const idx = content.indexOf(memo.anchor_text);
|
|
59
|
+
if (idx === -1) continue;
|
|
60
|
+
|
|
61
|
+
// Calculate approximate position based on character index
|
|
62
|
+
const textBefore = content.substring(0, idx);
|
|
63
|
+
const lines = textBefore.split('\n');
|
|
64
|
+
const lineNumber = lines.length - 1;
|
|
65
|
+
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 22;
|
|
66
|
+
const paddingTop = parseFloat(getComputedStyle(textarea).paddingTop) || 16;
|
|
67
|
+
|
|
68
|
+
const top = paddingTop + lineNumber * lineHeight;
|
|
69
|
+
const left = textarea.clientWidth - 28;
|
|
70
|
+
|
|
71
|
+
positions.push({ memo, top, left });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setPinPositions(positions);
|
|
75
|
+
}, [memos, content]);
|
|
76
|
+
|
|
77
|
+
const saveContent = useCallback(async (text: string) => {
|
|
78
|
+
setSaving(true);
|
|
79
|
+
await fetch(`/api/projects/${projectId}/brainstorm`, {
|
|
80
|
+
method: 'PUT',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ content: text }),
|
|
83
|
+
});
|
|
84
|
+
setSaving(false);
|
|
85
|
+
}, [projectId]);
|
|
86
|
+
|
|
87
|
+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
88
|
+
const newContent = e.target.value;
|
|
89
|
+
setContent(newContent);
|
|
90
|
+
|
|
91
|
+
// Auto-save with 1s debounce
|
|
92
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
93
|
+
saveTimerRef.current = setTimeout(() => {
|
|
94
|
+
saveContent(newContent);
|
|
95
|
+
}, 1000);
|
|
96
|
+
|
|
97
|
+
// Trigger AI structuring with 3s debounce
|
|
98
|
+
if (structureTimerRef.current) clearTimeout(structureTimerRef.current);
|
|
99
|
+
if (newContent.trim()) {
|
|
100
|
+
structureTimerRef.current = setTimeout(() => {
|
|
101
|
+
onContentChange(newContent);
|
|
102
|
+
}, 3000);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Sync scroll between textarea and overlay
|
|
107
|
+
const handleScroll = () => {
|
|
108
|
+
if (textareaRef.current && overlayRef.current) {
|
|
109
|
+
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (!loaded) {
|
|
114
|
+
return (
|
|
115
|
+
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
116
|
+
로딩 중...
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex flex-col h-full">
|
|
123
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
|
124
|
+
<h2 className="text-sm font-medium text-muted-foreground">브레인스토밍</h2>
|
|
125
|
+
<span className="text-xs text-muted-foreground">
|
|
126
|
+
{saving ? '저장 중...' : content ? '저장됨' : ''}
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
<div className="editor-container">
|
|
130
|
+
<textarea
|
|
131
|
+
ref={textareaRef}
|
|
132
|
+
value={content}
|
|
133
|
+
onChange={handleChange}
|
|
134
|
+
onScroll={handleScroll}
|
|
135
|
+
placeholder={`자유롭게 아이디어를 적어보세요...
|
|
136
|
+
|
|
137
|
+
예시:
|
|
138
|
+
- 소셜 로그인을 활용한 사용자 인증
|
|
139
|
+
- 분석 차트가 포함된 대시보드
|
|
140
|
+
- 알림 시스템 필요 (푸시 + 이메일)
|
|
141
|
+
- 다크 모드 지원
|
|
142
|
+
- 모바일 반응형 디자인 중요`}
|
|
143
|
+
className="flex-1 w-full p-4 bg-transparent resize-none text-foreground
|
|
144
|
+
placeholder:text-muted-foreground/40 font-mono text-sm leading-relaxed"
|
|
145
|
+
spellCheck={false}
|
|
146
|
+
/>
|
|
147
|
+
{pinPositions.length > 0 && (
|
|
148
|
+
<div ref={overlayRef} className="memo-overlay">
|
|
149
|
+
{pinPositions.map((pin) => (
|
|
150
|
+
<MemoPin
|
|
151
|
+
key={pin.memo.id}
|
|
152
|
+
question={pin.memo.question}
|
|
153
|
+
anchorText={pin.memo.anchor_text}
|
|
154
|
+
top={pin.top}
|
|
155
|
+
left={pin.left}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface MemoPinProps {
|
|
6
|
+
question: string;
|
|
7
|
+
anchorText: string;
|
|
8
|
+
top: number;
|
|
9
|
+
left: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function MemoPin({ question, anchorText, top, left }: MemoPinProps) {
|
|
13
|
+
const [showTooltip, setShowTooltip] = useState(false);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
className="memo-pin"
|
|
18
|
+
style={{ top: `${top}px`, left: `${left}px` }}
|
|
19
|
+
onMouseEnter={() => setShowTooltip(true)}
|
|
20
|
+
onMouseLeave={() => setShowTooltip(false)}
|
|
21
|
+
>
|
|
22
|
+
<span className="memo-pin-icon">📌</span>
|
|
23
|
+
{showTooltip && (
|
|
24
|
+
<div className="memo-tooltip">
|
|
25
|
+
<div className="memo-tooltip-anchor">“{anchorText}”</div>
|
|
26
|
+
<div className="memo-tooltip-question">{question}</div>
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ResizeHandleProps {
|
|
6
|
+
onResize: (topPercent: number) => void;
|
|
7
|
+
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function ResizeHandle({ onResize, containerRef }: ResizeHandleProps) {
|
|
11
|
+
const isDragging = useRef(false);
|
|
12
|
+
|
|
13
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
isDragging.current = true;
|
|
16
|
+
|
|
17
|
+
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
18
|
+
if (!isDragging.current || !containerRef.current) return;
|
|
19
|
+
const container = containerRef.current;
|
|
20
|
+
const rect = container.getBoundingClientRect();
|
|
21
|
+
const y = moveEvent.clientY - rect.top;
|
|
22
|
+
const percent = Math.max(20, Math.min(80, (y / rect.height) * 100));
|
|
23
|
+
onResize(percent);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const handleMouseUp = () => {
|
|
27
|
+
isDragging.current = false;
|
|
28
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
29
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
30
|
+
document.body.style.cursor = '';
|
|
31
|
+
document.body.style.userSelect = '';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
35
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
36
|
+
document.body.style.cursor = 'row-resize';
|
|
37
|
+
document.body.style.userSelect = 'none';
|
|
38
|
+
}, [onResize, containerRef]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="resize-handle" onMouseDown={handleMouseDown}>
|
|
42
|
+
<div className="resize-handle-bar" />
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
interface ChatMessageProps {
|
|
4
|
+
role: 'assistant' | 'user';
|
|
5
|
+
content: string;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function ChatMessage({ role, content, createdAt }: ChatMessageProps) {
|
|
10
|
+
const isAi = role === 'assistant';
|
|
11
|
+
const time = new Date(createdAt).toLocaleTimeString('ko-KR', {
|
|
12
|
+
hour: '2-digit',
|
|
13
|
+
minute: '2-digit',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className={`chat-message ${isAi ? 'chat-message-ai' : 'chat-message-user'}`}>
|
|
18
|
+
<div className={`chat-bubble ${isAi ? 'chat-bubble-ai' : 'chat-bubble-user'}`}>
|
|
19
|
+
{content.split('\n').map((line, i) => (
|
|
20
|
+
<p key={i} className={i > 0 ? 'mt-1' : ''}>
|
|
21
|
+
{line}
|
|
22
|
+
</p>
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
<span className="chat-time">{time}</span>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from 'react';
|
|
4
|
+
import ChatMessage from './ChatMessage';
|
|
5
|
+
|
|
6
|
+
interface Message {
|
|
7
|
+
id: string;
|
|
8
|
+
role: 'assistant' | 'user';
|
|
9
|
+
content: string;
|
|
10
|
+
created_at: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ChatPanelProps {
|
|
14
|
+
messages: Message[];
|
|
15
|
+
loading: boolean;
|
|
16
|
+
onSendMessage: (message: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function ChatPanel({ messages, loading, onSendMessage }: ChatPanelProps) {
|
|
20
|
+
const [input, setInput] = useState('');
|
|
21
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
23
|
+
|
|
24
|
+
// Auto-scroll to bottom when messages change
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
27
|
+
}, [messages]);
|
|
28
|
+
|
|
29
|
+
const handleSubmit = () => {
|
|
30
|
+
const trimmed = input.trim();
|
|
31
|
+
if (!trimmed || loading) return;
|
|
32
|
+
onSendMessage(trimmed);
|
|
33
|
+
setInput('');
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
37
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
handleSubmit();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="chat-panel">
|
|
45
|
+
<div className="chat-header">
|
|
46
|
+
<h2 className="text-sm font-medium text-muted-foreground">AI 대화</h2>
|
|
47
|
+
{messages.length > 0 && (
|
|
48
|
+
<span className="text-xs text-muted-foreground">{messages.length}개 메시지</span>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div className="chat-messages">
|
|
53
|
+
{messages.length === 0 ? (
|
|
54
|
+
<div className="chat-empty">
|
|
55
|
+
<p>구조화를 실행하면 AI가 질문을 시작합니다</p>
|
|
56
|
+
</div>
|
|
57
|
+
) : (
|
|
58
|
+
messages.map((msg) => (
|
|
59
|
+
<ChatMessage
|
|
60
|
+
key={msg.id}
|
|
61
|
+
role={msg.role}
|
|
62
|
+
content={msg.content}
|
|
63
|
+
createdAt={msg.created_at}
|
|
64
|
+
/>
|
|
65
|
+
))
|
|
66
|
+
)}
|
|
67
|
+
{loading && (
|
|
68
|
+
<div className="chat-message chat-message-ai">
|
|
69
|
+
<div className="chat-bubble chat-bubble-ai chat-loading">
|
|
70
|
+
<span className="dot" />
|
|
71
|
+
<span className="dot" />
|
|
72
|
+
<span className="dot" />
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
<div ref={messagesEndRef} />
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div className="chat-input-area">
|
|
80
|
+
<textarea
|
|
81
|
+
ref={inputRef}
|
|
82
|
+
value={input}
|
|
83
|
+
onChange={(e) => setInput(e.target.value)}
|
|
84
|
+
onKeyDown={handleKeyDown}
|
|
85
|
+
placeholder="답변을 입력하세요..."
|
|
86
|
+
rows={1}
|
|
87
|
+
disabled={loading}
|
|
88
|
+
className="chat-input"
|
|
89
|
+
/>
|
|
90
|
+
<button
|
|
91
|
+
onClick={handleSubmit}
|
|
92
|
+
disabled={!input.trim() || loading}
|
|
93
|
+
className="chat-send-btn"
|
|
94
|
+
>
|
|
95
|
+
전송
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|