E5K7 commited on
Commit
529b9cc
·
1 Parent(s): 5e4b249

feat(ai): Auto-apply code blocks from chat direct to IDE workspace + inject file context

Browse files
packages/client/src/components/ai/AIChatPanel.tsx CHANGED
@@ -6,6 +6,8 @@ import { Textarea } from '@/components/ui/textarea';
6
  import { Button } from '@/components/ui/button';
7
  import { cn } from '@/lib/utils';
8
 
 
 
9
  export default function AIChatPanel() {
10
  const { sendMessage, stopGeneration, isStreaming } = useASI1Chat();
11
  const messages = useAIStore((s) => s.messages);
@@ -65,28 +67,28 @@ export default function AIChatPanel() {
65
  <div
66
  key={msg.id}
67
  className={cn(
68
- 'max-w-[90%] text-sm',
69
- msg.role === 'user' ? 'ml-auto' : 'mr-auto'
70
  )}
71
  >
72
  <div
73
  className={cn(
74
- 'px-4 py-3 rounded-2xl whitespace-pre-wrap break-words',
75
  msg.role === 'user'
76
- ? 'bg-primary/10 rounded-br-md'
77
- : 'liquid-glass rounded-bl-md'
78
  )}
79
  >
80
- {msg.content}
81
  </div>
 
82
  </div>
83
  ))}
84
 
85
  {streamingMessage && (
86
- <div className="mr-auto max-w-[90%] text-sm">
87
- <div className="liquid-glass px-4 py-3 rounded-2xl rounded-bl-md whitespace-pre-wrap">
88
- {streamingMessage}
89
- <span className="animate-pulse">▌</span>
90
  </div>
91
  </div>
92
  )}
 
6
  import { Button } from '@/components/ui/button';
7
  import { cn } from '@/lib/utils';
8
 
9
+ import { MarkdownMessage } from './MarkdownMessage';
10
+
11
  export default function AIChatPanel() {
12
  const { sendMessage, stopGeneration, isStreaming } = useASI1Chat();
13
  const messages = useAIStore((s) => s.messages);
 
67
  <div
68
  key={msg.id}
69
  className={cn(
70
+ 'max-w-[100%] text-sm',
71
+ msg.role === 'user' ? 'ml-auto' : 'mr-auto w-full'
72
  )}
73
  >
74
  <div
75
  className={cn(
76
+ 'px-4 py-3 rounded-2xl',
77
  msg.role === 'user'
78
+ ? 'bg-primary/10 rounded-br-md whitespace-pre-wrap break-words max-w-[90%] float-right'
79
+ : 'liquid-glass rounded-bl-md w-full'
80
  )}
81
  >
82
+ {msg.role === 'user' ? msg.content : <MarkdownMessage content={msg.content} />}
83
  </div>
84
+ <div className="clear-both"></div>
85
  </div>
86
  ))}
87
 
88
  {streamingMessage && (
89
+ <div className="mr-auto w-full text-sm">
90
+ <div className="liquid-glass px-4 py-3 rounded-2xl rounded-bl-md">
91
+ <MarkdownMessage content={streamingMessage + '▌'} />
 
92
  </div>
93
  </div>
94
  )}
packages/client/src/components/ai/MarkdownMessage.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Check } from 'lucide-react';
2
+
3
+ interface MarkdownMessageProps {
4
+ content: string;
5
+ }
6
+
7
+ export function MarkdownMessage({ content }: MarkdownMessageProps) {
8
+ // Parse the message into text segments and code blocks
9
+ // Parse the message into text segments and code blocks
10
+ // Looking for: **`filename`** (or similar) followed by ```lang ... ```
11
+ const segments: Array<{ type: 'text' | 'code'; content?: string; filename?: string | null; language?: string; code?: string }> = [];
12
+ const regex = /(?:\*\*\`?([^\`\n]+)\`?\*\*\s*\n)?```(\w*)\n([\s\S]*?)```/g;
13
+
14
+ let lastIndex = 0;
15
+ let match;
16
+
17
+ while ((match = regex.exec(content)) !== null) {
18
+ // Add preceding text
19
+ if (match.index > lastIndex) {
20
+ segments.push({
21
+ type: 'text',
22
+ content: content.slice(lastIndex, match.index),
23
+ });
24
+ }
25
+
26
+ segments.push({
27
+ type: 'code',
28
+ filename: match[1] || null,
29
+ language: match[2] || 'text',
30
+ code: match[3].trim(),
31
+ });
32
+
33
+ lastIndex = regex.lastIndex;
34
+ }
35
+
36
+ // Add remaining text
37
+ if (lastIndex < content.length) {
38
+ segments.push({
39
+ type: 'text',
40
+ content: content.slice(lastIndex),
41
+ });
42
+ }
43
+
44
+ return (
45
+ <div className="space-y-3">
46
+ {segments.map((segment, i) => {
47
+ if (segment.type === 'text') {
48
+ return (
49
+ <div key={i} className="whitespace-pre-wrap break-words text-sm">
50
+ {segment.content}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ return (
56
+ <div key={i} className="flex items-center gap-1.5 py-1.5 px-3 bg-primary/10 text-primary border border-primary/20 rounded-md w-fit text-xs font-medium my-2">
57
+ <Check className="w-3.5 h-3.5" />
58
+ Applied changes to {segment.filename || 'active file'}
59
+ </div>
60
+ );
61
+ })}
62
+ </div>
63
+ );
64
+ }
packages/client/src/hooks/useASI1Chat.ts CHANGED
@@ -1,74 +1,154 @@
1
  import { useCallback, useRef } from 'react';
2
  import { useAIStore } from '@/stores/aiStore';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  export function useASI1Chat() {
5
- const { addMessage, updateStreamingMessage, finishStreaming, setIsStreaming, isStreaming } = useAIStore();
 
6
  const abortRef = useRef<AbortController | null>(null);
7
 
8
- const sendMessage = useCallback(async (message: string, history: Array<{ role: string; content: string }> = []) => {
9
- // Add user message
10
- addMessage({
11
- id: `msg-${Date.now()}`,
12
- role: 'user',
13
- content: message,
14
- timestamp: Date.now(),
15
- });
16
-
17
- setIsStreaming(true);
18
- const controller = new AbortController();
19
- abortRef.current = controller;
20
-
21
- try {
22
- const apiBase = import.meta.env.VITE_API_URL || '';
23
- const response = await fetch(`${apiBase}/api/v1/chat`, {
24
- method: 'POST',
25
- headers: { 'Content-Type': 'application/json' },
26
- body: JSON.stringify({ message, history, stream: true }),
27
- signal: controller.signal,
28
  });
29
 
30
- if (!response.ok) throw new Error('Chat request failed');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- const reader = response.body?.getReader();
33
- if (!reader) throw new Error('No response body');
 
 
 
 
 
34
 
35
- const decoder = new TextDecoder();
36
- let fullContent = '';
37
- let buffer = '';
38
 
39
- while (true) {
40
- const { done, value } = await reader.read();
41
- if (done) break;
42
 
43
- buffer += decoder.decode(value, { stream: true });
44
- const lines = buffer.split('\n');
45
- buffer = lines.pop() || '';
46
 
47
- for (const line of lines) {
48
- const trimmed = line.trim();
49
- if (!trimmed.startsWith('data: ')) continue;
50
- const data = trimmed.slice(6);
51
- if (data === '[DONE]') break;
52
 
53
- try {
54
- const parsed = JSON.parse(data);
55
- if (parsed.token) {
56
- fullContent += parsed.token;
57
- updateStreamingMessage(parsed.token);
 
 
 
 
58
  }
59
- } catch { /* skip */ }
60
  }
61
- }
62
 
63
- finishStreaming(fullContent);
64
- } catch (error) {
65
- if ((error as Error).name !== 'AbortError') {
66
- finishStreaming('Sorry, an error occurred. Please try again.');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  }
68
- } finally {
69
- abortRef.current = null;
70
- }
71
- }, [addMessage, updateStreamingMessage, finishStreaming, setIsStreaming]);
72
 
73
  const stopGeneration = useCallback(() => {
74
  abortRef.current?.abort();
 
1
  import { useCallback, useRef } from 'react';
2
  import { useAIStore } from '@/stores/aiStore';
3
+ import { useEditorStore } from '@/stores/editorStore';
4
+ import { useFileStore } from '@/stores/fileStore';
5
+
6
+ const ASI1_BASE_URL = import.meta.env.VITE_ASI1_BASE_URL || 'https://api.asi1.ai/v1';
7
+ const ASI1_API_KEY = import.meta.env.VITE_ASI1_API_KEY || '';
8
+ const ASI1_MODEL = import.meta.env.VITE_ASI1_MODEL || 'asi1-mini';
9
+
10
+ const SYSTEM_PROMPT =
11
+ 'You are FrontendForge AI, an expert frontend development assistant. You help with HTML, CSS, JavaScript, TypeScript, React, Vue, Svelte, Next.js, and more.\n' +
12
+ 'CRITICAL INSTRUCTION: When you provide code that should be applied to a file, you MUST start the code block with a markdown bolded filename, followed immediately by the fenced code block. Example:\n' +
13
+ '**`src/App.tsx`**\n' +
14
+ '```tsx\n' +
15
+ 'export default function App() { ... }\n' +
16
+ '```\n' +
17
+ 'Do not omit the bolded filename. Provide exact, complete code drop-ins when requested to fix or change code.';
18
 
19
  export function useASI1Chat() {
20
+ const { addMessage, updateStreamingMessage, finishStreaming, setIsStreaming, isStreaming } =
21
+ useAIStore();
22
  const abortRef = useRef<AbortController | null>(null);
23
 
24
+ const sendMessage = useCallback(
25
+ async (message: string, history: Array<{ role: string; content: string }> = []) => {
26
+ addMessage({
27
+ id: `msg-${Date.now()}`,
28
+ role: 'user',
29
+ content: message,
30
+ timestamp: Date.now(),
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  });
32
 
33
+ setIsStreaming(true);
34
+ const controller = new AbortController();
35
+ abortRef.current = controller;
36
+
37
+ try {
38
+ // Build the dynamic workspace context from the stores
39
+ const editorStore = useEditorStore.getState();
40
+ const fileStore = useFileStore.getState();
41
+
42
+ // Merge the base files with the unsaved editor changes
43
+ const currentFiles = { ...fileStore.files };
44
+ for (const [path, file] of Object.entries(editorStore.openFiles)) {
45
+ currentFiles[path] = typeof file === 'string' ? file : file.content;
46
+ }
47
+
48
+ let dynamicSystemPrompt = SYSTEM_PROMPT + '\n\n--- CURRENT WORKSPACE FILES ---\n';
49
+ for (const [path, content] of Object.entries(currentFiles)) {
50
+ // Guess language for markdown
51
+ const ext = path.split('.').pop() || 'text';
52
+ const langMap: Record<string, string> = { js: 'javascript', ts: 'typescript', jsx: 'jsx', tsx: 'tsx', html: 'html', css: 'css' };
53
+ const lang = langMap[ext] || ext;
54
+
55
+ dynamicSystemPrompt += `**\`${path}\`**\n\`\`\`${lang}\n${content}\n\`\`\`\n\n`;
56
+ }
57
+
58
+ const response = await fetch(`${ASI1_BASE_URL}/chat/completions`, {
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ Authorization: `Bearer ${ASI1_API_KEY}`,
63
+ },
64
+ body: JSON.stringify({
65
+ model: ASI1_MODEL,
66
+ messages: [
67
+ { role: 'system', content: dynamicSystemPrompt },
68
+ ...history,
69
+ { role: 'user', content: message },
70
+ ],
71
+ stream: true,
72
+ temperature: 0.7,
73
+ max_tokens: 4096,
74
+ }),
75
+ signal: controller.signal,
76
+ });
77
 
78
+ if (!response.ok) {
79
+ const errText = await response.text();
80
+ throw new Error(`ASI-1 API error ${response.status}: ${errText}`);
81
+ }
82
+
83
+ const reader = response.body?.getReader();
84
+ if (!reader) throw new Error('No response body');
85
 
86
+ const decoder = new TextDecoder();
87
+ let fullContent = '';
88
+ let buffer = '';
89
 
90
+ while (true) {
91
+ const { done, value } = await reader.read();
92
+ if (done) break;
93
 
94
+ buffer += decoder.decode(value, { stream: true });
95
+ const lines = buffer.split('\n');
96
+ buffer = lines.pop() || '';
97
 
98
+ for (const line of lines) {
99
+ const trimmed = line.trim();
100
+ if (!trimmed.startsWith('data: ')) continue;
101
+ const data = trimmed.slice(6);
102
+ if (data === '[DONE]') break;
103
 
104
+ try {
105
+ const parsed = JSON.parse(data);
106
+ const token = parsed.choices?.[0]?.delta?.content;
107
+ if (token) {
108
+ fullContent += token;
109
+ updateStreamingMessage(token);
110
+ }
111
+ } catch {
112
+ /* skip malformed chunks */
113
  }
114
+ }
115
  }
 
116
 
117
+ // Lovable-style Auto-Apply logic:
118
+ // Parse the full completion string for markdown code blocks + filenames
119
+ const regex = /(?:\*\*\`?([^\`\n]+)\`?\*\*\s*\n)?```(\w*)\n([\s\S]*?)```/g;
120
+ let match;
121
+
122
+ while ((match = regex.exec(fullContent)) !== null) {
123
+ const filename = match[1];
124
+ const code = match[3].trim();
125
+
126
+ if (filename) {
127
+ // Apply it!
128
+ if (editorStore.openFiles[filename]) {
129
+ editorStore.updateContent(filename, code);
130
+ } else if (fileStore.files[filename] !== undefined) {
131
+ fileStore.updateFile(filename, code);
132
+ }
133
+ }
134
+ }
135
+
136
+ finishStreaming(fullContent);
137
+ } catch (error) {
138
+ if ((error as Error).name !== 'AbortError') {
139
+ finishStreaming('Sorry, an error occurred. Please try again.');
140
+ console.error('[useASI1Chat]', error);
141
+ } else {
142
+ // User stopped — commit whatever was streamed so far
143
+ const partial = useAIStore.getState().streamingMessage;
144
+ if (partial) finishStreaming(partial);
145
+ }
146
+ } finally {
147
+ abortRef.current = null;
148
  }
149
+ },
150
+ [addMessage, updateStreamingMessage, finishStreaming, setIsStreaming]
151
+ );
 
152
 
153
  const stopGeneration = useCallback(() => {
154
  abortRef.current?.abort();
packages/client/src/hooks/useLivePreview.ts CHANGED
@@ -10,7 +10,7 @@ export function useLivePreview() {
10
  const files = useMemo(() => {
11
  const merged = { ...baseFiles };
12
  for (const [path, file] of Object.entries(openFiles)) {
13
- merged[path] = file.content;
14
  }
15
  return merged;
16
  }, [baseFiles, openFiles]);
 
10
  const files = useMemo(() => {
11
  const merged = { ...baseFiles };
12
  for (const [path, file] of Object.entries(openFiles)) {
13
+ merged[path] = typeof file === 'string' ? file : file.content;
14
  }
15
  return merged;
16
  }, [baseFiles, openFiles]);
packages/client/src/stores/fileStore.ts CHANGED
@@ -113,8 +113,10 @@ export const useFileStore = create<FileStore>((set, get) => ({
113
  if (saved) {
114
  try {
115
  const parsed = JSON.parse(saved);
116
- set({ files: parsed.files, fileTree: buildTreeFromPaths(Object.keys(parsed.files)), initialized: true });
117
- return;
 
 
118
  } catch { /* fallback */ }
119
  }
120
  // Use default files
 
113
  if (saved) {
114
  try {
115
  const parsed = JSON.parse(saved);
116
+ if (parsed.files && Object.keys(parsed.files).length > 0) {
117
+ set({ files: parsed.files, fileTree: buildTreeFromPaths(Object.keys(parsed.files)), initialized: true });
118
+ return;
119
+ }
120
  } catch { /* fallback */ }
121
  }
122
  // Use default files