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.
Files changed (54) hide show
  1. package/README.md +36 -0
  2. package/bin/im.js +4 -0
  3. package/next.config.ts +8 -0
  4. package/package.json +55 -0
  5. package/postcss.config.mjs +7 -0
  6. package/public/file.svg +1 -0
  7. package/public/globe.svg +1 -0
  8. package/public/next.svg +1 -0
  9. package/public/vercel.svg +1 -0
  10. package/public/window.svg +1 -0
  11. package/src/app/api/health/route.ts +5 -0
  12. package/src/app/api/projects/[id]/brainstorm/route.ts +37 -0
  13. package/src/app/api/projects/[id]/conversations/route.ts +50 -0
  14. package/src/app/api/projects/[id]/items/[itemId]/prompt/route.ts +51 -0
  15. package/src/app/api/projects/[id]/items/[itemId]/route.ts +73 -0
  16. package/src/app/api/projects/[id]/items/route.ts +17 -0
  17. package/src/app/api/projects/[id]/memos/route.ts +18 -0
  18. package/src/app/api/projects/[id]/route.ts +39 -0
  19. package/src/app/api/projects/[id]/structure/route.ts +28 -0
  20. package/src/app/api/projects/route.ts +19 -0
  21. package/src/app/favicon.ico +0 -0
  22. package/src/app/globals.css +437 -0
  23. package/src/app/layout.tsx +42 -0
  24. package/src/app/page.tsx +175 -0
  25. package/src/app/projects/[id]/page.tsx +249 -0
  26. package/src/cli.ts +41 -0
  27. package/src/components/brainstorm/Editor.tsx +163 -0
  28. package/src/components/brainstorm/MemoPin.tsx +31 -0
  29. package/src/components/brainstorm/ResizeHandle.tsx +45 -0
  30. package/src/components/chat/ChatMessage.tsx +28 -0
  31. package/src/components/chat/ChatPanel.tsx +100 -0
  32. package/src/components/tree/ItemDetail.tsx +196 -0
  33. package/src/components/tree/LockToggle.tsx +23 -0
  34. package/src/components/tree/StatusBadge.tsx +32 -0
  35. package/src/components/tree/TreeNode.tsx +118 -0
  36. package/src/components/tree/TreeView.tsx +60 -0
  37. package/src/lib/ai/chat-responder.ts +69 -0
  38. package/src/lib/ai/client.ts +124 -0
  39. package/src/lib/ai/prompter.ts +83 -0
  40. package/src/lib/ai/structurer.ts +74 -0
  41. package/src/lib/db/index.ts +16 -0
  42. package/src/lib/db/queries/brainstorms.ts +26 -0
  43. package/src/lib/db/queries/conversations.ts +46 -0
  44. package/src/lib/db/queries/items.ts +147 -0
  45. package/src/lib/db/queries/memos.ts +66 -0
  46. package/src/lib/db/queries/projects.ts +53 -0
  47. package/src/lib/db/queries/prompts.ts +68 -0
  48. package/src/lib/db/schema.ts +78 -0
  49. package/src/lib/mcp/server.ts +117 -0
  50. package/src/lib/mcp/tools.ts +83 -0
  51. package/src/lib/utils/id.ts +5 -0
  52. package/src/lib/utils/paths.ts +16 -0
  53. package/src/types/index.ts +97 -0
  54. 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
+ &larr; 뒤로
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">&#x1F4CC;</span>
23
+ {showTooltip && (
24
+ <div className="memo-tooltip">
25
+ <div className="memo-tooltip-anchor">&ldquo;{anchorText}&rdquo;</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
+ }