idea-manager 0.3.2 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idea-manager",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "AI 기반 브레인스토밍 → 구조화 → 프롬프트 생성 도구. MCP Server 내장.",
5
5
  "keywords": [
6
6
  "brainstorm",
@@ -46,6 +46,7 @@
46
46
  "open": "^11.0.0",
47
47
  "react": "19.2.3",
48
48
  "react-dom": "19.2.3",
49
+ "react-markdown": "^10.1.0",
49
50
  "tsx": "^4.21.0"
50
51
  },
51
52
  "devDependencies": {
@@ -3,6 +3,7 @@ import { getTaskConversations, addTaskConversation } from '@/lib/db/queries/task
3
3
  import { getTask } from '@/lib/db/queries/tasks';
4
4
  import { getTaskPrompt } from '@/lib/db/queries/task-prompts';
5
5
  import { getBrainstorm } from '@/lib/db/queries/brainstorms';
6
+ import { getProject } from '@/lib/db/queries/projects';
6
7
  import { runClaude } from '@/lib/ai/client';
7
8
 
8
9
  export async function GET(
@@ -37,9 +38,12 @@ export async function POST(
37
38
  const history = getTaskConversations(taskId);
38
39
  const prompt = getTaskPrompt(taskId);
39
40
  const brainstorm = getBrainstorm(projectId);
41
+ const project = getProject(projectId);
40
42
 
41
- const systemPrompt = `You are a helpful assistant helping refine a development task. Respond in Korean. Be concise.
43
+ const aiPolicy = project?.ai_context ? `\n\nProject AI Policy:\n${project.ai_context}` : '';
42
44
 
45
+ const systemPrompt = `You are a helpful assistant helping refine a development task. Respond in Korean. Be concise.
46
+ ${aiPolicy}
43
47
  Task: ${task.title}
44
48
  Description: ${task.description}
45
49
  Status: ${task.status}
@@ -52,9 +56,15 @@ ${brainstorm?.content ? `\nBrainstorming context:\n${brainstorm.content.slice(0,
52
56
 
53
57
  try {
54
58
  const aiResponse = await runClaude(`${systemPrompt}\n\nConversation:\n${conversationText}`);
55
- const aiMsg = addTaskConversation(taskId, 'assistant', aiResponse.trim());
59
+ const trimmed = aiResponse.trim();
60
+ if (!trimmed) {
61
+ const fallbackMsg = addTaskConversation(taskId, 'assistant', '(AI 응답을 생성하지 못했습니다. 다시 시도해주세요.)');
62
+ return NextResponse.json({ userMessage: userMsg, aiMessage: fallbackMsg });
63
+ }
64
+ const aiMsg = addTaskConversation(taskId, 'assistant', trimmed);
56
65
  return NextResponse.json({ userMessage: userMsg, aiMessage: aiMsg });
57
66
  } catch {
58
- return NextResponse.json({ error: 'AI response failed' }, { status: 500 });
67
+ const errorMsg = addTaskConversation(taskId, 'assistant', '(AI 호출에 실패했습니다. Claude CLI가 설치되어 있는지 확인해주세요.)');
68
+ return NextResponse.json({ userMessage: userMsg, aiMessage: errorMsg });
59
69
  }
60
70
  }
@@ -886,6 +886,78 @@ textarea:focus {
886
886
  background: hsl(var(--primary) / 0.6);
887
887
  }
888
888
 
889
+ /* Chat markdown styling */
890
+ .chat-markdown p {
891
+ margin: 0.25em 0;
892
+ }
893
+
894
+ .chat-markdown p:first-child {
895
+ margin-top: 0;
896
+ }
897
+
898
+ .chat-markdown p:last-child {
899
+ margin-bottom: 0;
900
+ }
901
+
902
+ .chat-markdown strong {
903
+ font-weight: 700;
904
+ color: hsl(var(--foreground));
905
+ }
906
+
907
+ .chat-markdown code {
908
+ background: hsl(var(--background));
909
+ padding: 1px 5px;
910
+ border-radius: 4px;
911
+ font-size: 0.9em;
912
+ font-family: var(--font-mono);
913
+ }
914
+
915
+ .chat-markdown pre {
916
+ background: hsl(var(--background));
917
+ padding: 8px 12px;
918
+ border-radius: 6px;
919
+ overflow-x: auto;
920
+ margin: 0.5em 0;
921
+ }
922
+
923
+ .chat-markdown pre code {
924
+ background: none;
925
+ padding: 0;
926
+ }
927
+
928
+ .chat-markdown ul, .chat-markdown ol {
929
+ padding-left: 1.4em;
930
+ margin: 0.3em 0;
931
+ }
932
+
933
+ .chat-markdown li {
934
+ margin: 0.15em 0;
935
+ }
936
+
937
+ .chat-markdown h1, .chat-markdown h2, .chat-markdown h3,
938
+ .chat-markdown h4, .chat-markdown h5, .chat-markdown h6 {
939
+ font-weight: 700;
940
+ margin: 0.5em 0 0.25em;
941
+ line-height: 1.3;
942
+ }
943
+
944
+ .chat-markdown h1 { font-size: 1.2em; }
945
+ .chat-markdown h2 { font-size: 1.1em; }
946
+ .chat-markdown h3 { font-size: 1.05em; }
947
+
948
+ .chat-markdown blockquote {
949
+ border-left: 3px solid hsl(var(--border));
950
+ padding-left: 10px;
951
+ margin: 0.4em 0;
952
+ color: hsl(var(--muted-foreground));
953
+ }
954
+
955
+ .chat-markdown hr {
956
+ border: none;
957
+ border-top: 1px solid hsl(var(--border));
958
+ margin: 0.5em 0;
959
+ }
960
+
889
961
  /* Dialog animation */
890
962
  @keyframes dialogIn {
891
963
  from { opacity: 0; transform: scale(0.95) translateY(4px); }
@@ -7,6 +7,7 @@ import ProjectTree from '@/components/task/ProjectTree';
7
7
  import TaskDetail from '@/components/task/TaskDetail';
8
8
  import DirectoryPicker from '@/components/DirectoryPicker';
9
9
  import ConfirmDialog from '@/components/ui/ConfirmDialog';
10
+ import AiPolicyModal from '@/components/ui/AiPolicyModal';
10
11
  import type { ISubProject, ITask, TaskStatus, ISubProjectWithStats } from '@/types';
11
12
 
12
13
  interface IProject {
@@ -14,6 +15,7 @@ interface IProject {
14
15
  name: string;
15
16
  description: string;
16
17
  project_path: string | null;
18
+ ai_context: string;
17
19
  }
18
20
 
19
21
  function WorkspaceInner({ id }: { id: string }) {
@@ -32,10 +34,11 @@ function WorkspaceInner({ id }: { id: string }) {
32
34
  const [showAddSub, setShowAddSub] = useState(false);
33
35
  const [showBrainstorm, setShowBrainstorm] = useState(true);
34
36
  const [newSubName, setNewSubName] = useState('');
37
+ const [showAiPolicy, setShowAiPolicy] = useState(false);
35
38
 
36
39
  // Resizable panel widths
37
- const [leftWidth, setLeftWidth] = useState(280);
38
- const [centerWidth, setCenterWidth] = useState(280);
40
+ const [leftWidth, setLeftWidth] = useState(500);
41
+ const [centerWidth, setCenterWidth] = useState(500);
39
42
  const containerRef = useRef<HTMLDivElement>(null);
40
43
  const draggingRef = useRef<'left' | 'center' | null>(null);
41
44
  const startXRef = useRef(0);
@@ -221,6 +224,18 @@ function WorkspaceInner({ id }: { id: string }) {
221
224
  }
222
225
  };
223
226
 
227
+ const handleSaveAiPolicy = async (aiContext: string) => {
228
+ const res = await fetch(`/api/projects/${id}`, {
229
+ method: 'PUT',
230
+ headers: { 'Content-Type': 'application/json' },
231
+ body: JSON.stringify({ ai_context: aiContext }),
232
+ });
233
+ if (res.ok) {
234
+ setProject(await res.json());
235
+ setShowAiPolicy(false);
236
+ }
237
+ };
238
+
224
239
  // Keyboard shortcuts (use e.code for Korean IME compatibility)
225
240
  useEffect(() => {
226
241
  const handler = (e: KeyboardEvent) => {
@@ -289,6 +304,16 @@ function WorkspaceInner({ id }: { id: string }) {
289
304
  )}
290
305
  </div>
291
306
  <div className="flex items-center gap-2">
307
+ <button
308
+ onClick={() => setShowAiPolicy(true)}
309
+ className={`px-3 py-1.5 text-xs border rounded-md transition-colors ${
310
+ project.ai_context
311
+ ? 'bg-accent/15 text-accent border-accent/30 hover:bg-accent/25'
312
+ : 'bg-muted hover:bg-card-hover text-muted-foreground border-border'
313
+ }`}
314
+ >
315
+ AI Policy{project.ai_context ? ' *' : ''}
316
+ </button>
292
317
  {!project.project_path && (
293
318
  <button
294
319
  onClick={() => setShowDirPicker(true)}
@@ -418,6 +443,13 @@ function WorkspaceInner({ id }: { id: string }) {
418
443
  onConfirm={handleConfirmAction}
419
444
  onCancel={() => setConfirmAction(null)}
420
445
  />
446
+
447
+ <AiPolicyModal
448
+ open={showAiPolicy}
449
+ content={project.ai_context || ''}
450
+ onSave={handleSaveAiPolicy}
451
+ onClose={() => setShowAiPolicy(false)}
452
+ />
421
453
  </div>
422
454
  );
423
455
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback } from 'react';
4
4
  import type { ITaskConversation } from '@/types';
5
+ import ReactMarkdown from 'react-markdown';
5
6
 
6
7
  export default function TaskChat({
7
8
  basePath,
@@ -79,14 +80,16 @@ export default function TaskChat({
79
80
  Ask AI to help refine your task or prompt
80
81
  </div>
81
82
  )}
82
- {messages.map((msg) => (
83
+ {messages.filter(msg => msg.content).map((msg) => (
83
84
  <div key={msg.id} className={`flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
84
- <div className={`max-w-[90%] px-3 py-2 rounded-lg text-sm leading-relaxed whitespace-pre-wrap ${
85
+ <div className={`max-w-[90%] px-3 py-2 rounded-lg text-sm leading-relaxed ${
85
86
  msg.role === 'user'
86
- ? 'bg-accent text-white rounded-br-sm'
87
- : 'bg-muted text-foreground rounded-bl-sm'
87
+ ? 'bg-accent text-white rounded-br-sm whitespace-pre-wrap'
88
+ : 'bg-muted text-foreground rounded-bl-sm chat-markdown'
88
89
  }`}>
89
- {msg.content}
90
+ {msg.role === 'assistant'
91
+ ? <ReactMarkdown>{msg.content}</ReactMarkdown>
92
+ : msg.content}
90
93
  </div>
91
94
  {msg.role === 'assistant' && (
92
95
  <button
@@ -0,0 +1,98 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+
5
+ export default function AiPolicyModal({
6
+ open,
7
+ content,
8
+ onSave,
9
+ onClose,
10
+ }: {
11
+ open: boolean;
12
+ content: string;
13
+ onSave: (content: string) => void;
14
+ onClose: () => void;
15
+ }) {
16
+ const [draft, setDraft] = useState(content);
17
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
18
+
19
+ useEffect(() => {
20
+ if (open) {
21
+ setDraft(content);
22
+ setTimeout(() => textareaRef.current?.focus(), 50);
23
+ }
24
+ }, [open, content]);
25
+
26
+ useEffect(() => {
27
+ if (!open) return;
28
+ const handler = (e: KeyboardEvent) => {
29
+ if (e.key === 'Escape') onClose();
30
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
31
+ e.preventDefault();
32
+ onSave(draft);
33
+ }
34
+ };
35
+ window.addEventListener('keydown', handler);
36
+ return () => window.removeEventListener('keydown', handler);
37
+ }, [open, draft, onSave, onClose]);
38
+
39
+ if (!open) return null;
40
+
41
+ return (
42
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
43
+ <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
44
+ <div className="relative bg-card border border-border rounded-xl shadow-2xl w-[640px] max-h-[80vh] flex flex-col animate-dialog-in">
45
+ <div className="flex items-center justify-between px-5 py-3 border-b border-border">
46
+ <div>
47
+ <h3 className="text-sm font-semibold">AI Policy</h3>
48
+ <p className="text-xs text-muted-foreground mt-0.5">
49
+ AI 채팅과 프롬프트 다듬기에 항상 포함되는 프로젝트 컨텍스트
50
+ </p>
51
+ </div>
52
+ <button onClick={onClose} className="text-muted-foreground hover:text-foreground text-lg px-1">
53
+ x
54
+ </button>
55
+ </div>
56
+
57
+ <div className="flex-1 p-4 overflow-y-auto">
58
+ <textarea
59
+ ref={textareaRef}
60
+ value={draft}
61
+ onChange={(e) => setDraft(e.target.value)}
62
+ placeholder={`프로젝트 컨텍스트와 AI 지침을 작성하세요.
63
+
64
+ 예시:
65
+ - 이 프로젝트는 JABIS 스마트워크 시스템입니다
66
+ - 기술 스택: React + TypeScript + Vite (monorepo)
67
+ - DB: PostgreSQL (jabis 스키마)
68
+ - 한국어로 응답할 것
69
+ - 코드 제안 시 기존 컨벤션을 따를 것`}
70
+ className="w-full bg-input border border-border rounded-lg px-4 py-3 text-sm
71
+ text-foreground resize-none focus:border-primary focus:outline-none
72
+ leading-relaxed font-mono min-h-[300px]"
73
+ />
74
+ </div>
75
+
76
+ <div className="flex items-center justify-between px-5 py-3 border-t border-border">
77
+ <span className="text-xs text-muted-foreground">Cmd+Enter to save</span>
78
+ <div className="flex items-center gap-2">
79
+ <button
80
+ onClick={onClose}
81
+ className="px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground
82
+ border border-border rounded-md transition-colors"
83
+ >
84
+ Cancel
85
+ </button>
86
+ <button
87
+ onClick={() => onSave(draft)}
88
+ className="px-3 py-1.5 text-xs bg-primary text-white rounded-md
89
+ hover:bg-primary-hover transition-colors"
90
+ >
91
+ Save
92
+ </button>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ );
98
+ }
@@ -13,7 +13,7 @@ export interface IStructuredItem {
13
13
  const CLI_PATH = 'claude';
14
14
  const DEFAULT_ARGS = ['--dangerously-skip-permissions'];
15
15
  const MODEL = 'sonnet';
16
- const MAX_TURNS = 1;
16
+ const MAX_TURNS = 80;
17
17
 
18
18
  export type OnTextChunk = (text: string) => void;
19
19
  export type OnRawEvent = (event: Record<string, unknown>) => void;
@@ -24,10 +24,11 @@ export type OnRawEvent = (event: Record<string, unknown>) => void;
24
24
  */
25
25
  export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnRawEvent): Promise<string> {
26
26
  return new Promise((resolve, reject) => {
27
+ const useStreamJson = !!(onText || onRawEvent);
27
28
  const args = [
28
29
  ...DEFAULT_ARGS,
29
30
  '--model', MODEL,
30
- '--output-format', 'stream-json',
31
+ ...(useStreamJson ? ['--output-format', 'stream-json', '--verbose'] : ['--output-format', 'text']),
31
32
  '--max-turns', String(MAX_TURNS),
32
33
  '-p', '-', // read prompt from stdin
33
34
  ];
@@ -57,56 +58,47 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
57
58
  let resultText = '';
58
59
  let stderrText = '';
59
60
 
60
- proc.stdout?.on('data', (chunk: Buffer) => {
61
- buffer += chunk.toString();
62
- const lines = buffer.split('\n');
63
- buffer = lines.pop() ?? '';
64
-
65
- for (const line of lines) {
66
- const trimmed = line.trim();
67
- if (!trimmed) continue;
68
- try {
69
- const parsed = JSON.parse(trimmed);
70
-
71
- // Emit raw event for live streaming to frontend
72
- onRawEvent?.(parsed);
73
-
74
- // content_block_delta real-time streaming tokens (API-style)
75
- if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
76
- resultText += parsed.delta.text;
77
- onText?.(parsed.delta.text);
78
- }
79
-
80
- // assistant complete or incremental message
81
- else if (parsed.type === 'assistant' && parsed.message?.content) {
82
- let fullText = '';
83
- for (const block of parsed.message.content) {
84
- if (block.type === 'text') {
85
- fullText += block.text;
61
+ if (useStreamJson) {
62
+ // stream-json mode: parse NDJSON events
63
+ proc.stdout?.on('data', (chunk: Buffer) => {
64
+ buffer += chunk.toString();
65
+ const lines = buffer.split('\n');
66
+ buffer = lines.pop() ?? '';
67
+
68
+ for (const line of lines) {
69
+ const trimmed = line.trim();
70
+ if (!trimmed) continue;
71
+ try {
72
+ const parsed = JSON.parse(trimmed);
73
+ onRawEvent?.(parsed);
74
+
75
+ if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
76
+ resultText += parsed.delta.text;
77
+ onText?.(parsed.delta.text);
78
+ } else if (parsed.type === 'assistant' && parsed.message?.content) {
79
+ let fullText = '';
80
+ for (const block of parsed.message.content) {
81
+ if (block.type === 'text') fullText += block.text;
86
82
  }
83
+ if (fullText.length > resultText.length) {
84
+ onText?.(fullText.slice(resultText.length));
85
+ }
86
+ resultText = fullText;
87
+ } else if (parsed.type === 'result' && parsed.result) {
88
+ if (parsed.result.length > resultText.length) {
89
+ onText?.(parsed.result.slice(resultText.length));
90
+ }
91
+ resultText = parsed.result;
87
92
  }
88
- // Only emit the NEW part (handles incremental assistant messages)
89
- if (fullText.length > resultText.length) {
90
- const newPart = fullText.slice(resultText.length);
91
- onText?.(newPart);
92
- }
93
- resultText = fullText;
94
- }
95
-
96
- // result — final output
97
- else if (parsed.type === 'result' && parsed.result) {
98
- // Emit any remaining new text from result
99
- if (parsed.result.length > resultText.length) {
100
- const newPart = parsed.result.slice(resultText.length);
101
- onText?.(newPart);
102
- }
103
- resultText = parsed.result;
104
- }
105
- } catch {
106
- // ignore non-JSON lines
93
+ } catch { /* ignore non-JSON */ }
107
94
  }
108
- }
109
- });
95
+ });
96
+ } else {
97
+ // text mode: stdout is the raw result
98
+ proc.stdout?.on('data', (chunk: Buffer) => {
99
+ resultText += chunk.toString();
100
+ });
101
+ }
110
102
 
111
103
  proc.stderr?.on('data', (chunk: Buffer) => {
112
104
  stderrText += chunk.toString();
@@ -117,6 +109,10 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
117
109
  });
118
110
 
119
111
  proc.on('exit', (code, signal) => {
112
+ // Clean up known CLI noise from text output
113
+ if (!useStreamJson) {
114
+ resultText = resultText.replace(/Error: Reached max turns \(\d+\)\s*/g, '').trim();
115
+ }
120
116
  if (code !== 0 && !resultText) {
121
117
  const detail = stderrText.slice(0, 500) || (signal ? `killed by signal ${signal}` : 'no output');
122
118
  reject(new Error(`Claude CLI exited with code ${code}: ${detail}`));
@@ -30,7 +30,7 @@ export function createProject(name: string, description: string = '', projectPat
30
30
  return getProject(id)!;
31
31
  }
32
32
 
33
- export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null }): IProject | undefined {
33
+ export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null; ai_context?: string }): IProject | undefined {
34
34
  const db = getDb();
35
35
  const project = getProject(id);
36
36
  if (!project) return undefined;
@@ -38,11 +38,12 @@ export function updateProject(id: string, data: { name?: string; description?: s
38
38
  const now = new Date().toISOString();
39
39
 
40
40
  db.prepare(
41
- 'UPDATE projects SET name = ?, description = ?, project_path = ?, updated_at = ? WHERE id = ?'
41
+ 'UPDATE projects SET name = ?, description = ?, project_path = ?, ai_context = ?, updated_at = ? WHERE id = ?'
42
42
  ).run(
43
43
  data.name ?? project.name,
44
44
  data.description ?? project.description,
45
45
  data.project_path !== undefined ? data.project_path : project.project_path,
46
+ data.ai_context !== undefined ? data.ai_context : (project.ai_context ?? ''),
46
47
  now,
47
48
  id,
48
49
  );
@@ -97,6 +97,9 @@ export function initSchema(db: Database.Database): void {
97
97
  if (!projCols.some(c => c.name === 'project_path')) {
98
98
  db.exec("ALTER TABLE projects ADD COLUMN project_path TEXT");
99
99
  }
100
+ if (!projCols.some(c => c.name === 'ai_context')) {
101
+ db.exec("ALTER TABLE projects ADD COLUMN ai_context TEXT NOT NULL DEFAULT ''");
102
+ }
100
103
 
101
104
  // v2 tables
102
105
  db.exec(`
@@ -3,6 +3,7 @@ export interface IProject {
3
3
  name: string;
4
4
  description: string;
5
5
  project_path: string | null;
6
+ ai_context: string;
6
7
  created_at: string;
7
8
  updated_at: string;
8
9
  }