diff --git a/src/components/AI/AIPanel.tsx b/src/components/AI/AIPanel.tsx index 7ad1a89c..63e1f252 100644 --- a/src/components/AI/AIPanel.tsx +++ b/src/components/AI/AIPanel.tsx @@ -94,6 +94,7 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId updateFileContexts, toggleFileSelection, generatePromptText, + streamingContent, } = useAI({ onAddMessage: async (content, type, mode, fileContext, editResponse) => { return await addSpaceMessage(content, type, mode, fileContext, editResponse); @@ -498,6 +499,7 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId messages={messages} isProcessing={isProcessing} emptyMessage={mode === 'ask' ? t('AI.ask') : t('AI.edit')} + streamingContent={streamingContent} onRevert={async (message: ChatSpaceMessage) => { // Show confirmation dialog instead of executing immediately setRevertConfirmation({ open: true, message }); diff --git a/src/components/AI/chat/ChatContainer.tsx b/src/components/AI/chat/ChatContainer.tsx index 982747b4..862d4a9f 100644 --- a/src/components/AI/chat/ChatContainer.tsx +++ b/src/components/AI/chat/ChatContainer.tsx @@ -4,9 +4,12 @@ import { Loader2, MessageSquare, Bot } from 'lucide-react'; import React, { useEffect, useRef } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import ChatMessage from './ChatMessage'; +import InlineHighlightedCode from '@/components/Tab/InlineHighlightedCode'; import { useTranslation } from '@/context/I18nContext'; import { useTheme } from '@/context/ThemeContext'; import type { ChatSpaceMessage } from '@/types'; @@ -15,6 +18,7 @@ interface ChatContainerProps { messages: ChatSpaceMessage[]; isProcessing: boolean; emptyMessage?: string; + streamingContent?: string; onRevert?: (message: ChatSpaceMessage) => Promise; } @@ -22,18 +26,19 @@ export default function ChatContainer({ messages, isProcessing, emptyMessage = 'AIとチャットを開始してください', + streamingContent = '', onRevert, }: ChatContainerProps) { const { colors } = useTheme(); const { t } = useTranslation(); const scrollRef = useRef(null); - // Auto scroll to bottom when new messages arrive + // Auto scroll to bottom when new messages arrive or streaming content updates useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }, [messages.length, isProcessing]); + }, [messages.length, isProcessing, streamingContent]); return (
))} - {/* Processing indicator */} - {isProcessing && ( + {/* Streaming message display */} + {isProcessing && streamingContent && ( +
+ {/* Avatar */} +
+ +
+ + {/* Streaming content */} +
+
+
+ + ); + } + + return ( + + {children} + + ); + }, + p: ({ children }) =>

{children}

, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + }} + > + {streamingContent} +
    +
    + {/* Streaming indicator */} +
    + + {t('ai.chatContainer.generating')} +
    +
    +
    +
    + )} + + {/* Processing indicator (shown when no streaming content yet) */} + {isProcessing && !streamingContent && (
    { +/** + * Stream chat response from Gemini API + * @param message - User message + * @param context - Context strings + * @param apiKey - Gemini API key + * @param onChunk - Callback for each chunk of text + */ +export async function streamChatResponse( + message: string, + context: string[], + apiKey: string, + onChunk: (chunk: string) => void +): Promise { if (!apiKey) throw new Error('Gemini API key is missing'); + const contextText = context.length > 0 ? `\n\n参考コンテキスト:\n${context.join('\n---\n')}` : ''; + const prompt = `${message}${contextText}`; + try { - const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, { + const response = await fetch(`${GEMINI_STREAM_API_URL}?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { - temperature: 0.1, // より確実な回答のため温度を下げる - maxOutputTokens: 4096, + temperature: 0.7, + maxOutputTokens: 2048, }, }), }); @@ -22,41 +37,82 @@ export async function generateCodeEdit(prompt: string, apiKey: string): Promise< throw new Error(`HTTP error! status: ${response.status}`); } - const data = await response.json(); - const result = data?.candidates?.[0]?.content?.parts?.[0]?.text; + if (!response.body) { + throw new Error('Response body is null'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; - console.log('[original response]', result); + buffer += decoder.decode(value, { stream: true }); + + // Split by lines and process complete JSON objects + const lines = buffer.split('\n'); + + // Keep the last incomplete line in the buffer + buffer = lines.pop() || ''; - if (!result) { - throw new Error('No response from Gemini API'); + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + try { + const parsed = JSON.parse(trimmedLine); + const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text; + if (text) { + onChunk(text); + } + } catch (e) { + // Skip invalid JSON lines + console.warn('[streamChatResponse] Failed to parse chunk:', trimmedLine.substring(0, 100)); + } + } } - return result; + // Process any remaining buffer + if (buffer.trim()) { + try { + const parsed = JSON.parse(buffer.trim()); + const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text; + if (text) { + onChunk(text); + } + } catch (e) { + console.warn('[streamChatResponse] Failed to parse final chunk'); + } + } } catch (error) { - throw new Error('Gemini API error: ' + (error as Error).message); + throw new Error('Gemini API streaming error: ' + (error as Error).message); } } -export async function generateChatResponse( - message: string, - context: string[], - apiKey: string -): Promise { +/** + * Stream code edit response from Gemini API + * @param prompt - Edit prompt + * @param apiKey - Gemini API key + * @param onChunk - Callback for each chunk of text + */ +export async function streamCodeEdit( + prompt: string, + apiKey: string, + onChunk: (chunk: string) => void +): Promise { if (!apiKey) throw new Error('Gemini API key is missing'); - const contextText = context.length > 0 ? `\n\n参考コンテキスト:\n${context.join('\n---\n')}` : ''; - - const prompt = `${message}${contextText}`; - try { - const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, { + const response = await fetch(`${GEMINI_STREAM_API_URL}?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { - temperature: 0.7, - maxOutputTokens: 2048, + temperature: 0.1, + maxOutputTokens: 4096, }, }), }); @@ -65,16 +121,56 @@ export async function generateChatResponse( throw new Error(`HTTP error! status: ${response.status}`); } - const data = await response.json(); - const result = data?.candidates?.[0]?.content?.parts?.[0]?.text; + if (!response.body) { + throw new Error('Response body is null'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; - if (!result) { - throw new Error('No response from Gemini API'); + buffer += decoder.decode(value, { stream: true }); + + // Split by lines and process complete JSON objects + const lines = buffer.split('\n'); + + // Keep the last incomplete line in the buffer + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + try { + const parsed = JSON.parse(trimmedLine); + const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text; + if (text) { + onChunk(text); + } + } catch (e) { + // Skip invalid JSON lines + console.warn('[streamCodeEdit] Failed to parse chunk:', trimmedLine.substring(0, 100)); + } + } } - console.log('[original response]', result); - return result; + // Process any remaining buffer + if (buffer.trim()) { + try { + const parsed = JSON.parse(buffer.trim()); + const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text; + if (text) { + onChunk(text); + } + } catch (e) { + console.warn('[streamCodeEdit] Failed to parse final chunk'); + } + } } catch (error) { - throw new Error('Gemini API error: ' + (error as Error).message); + throw new Error('Gemini API streaming error: ' + (error as Error).message); } } diff --git a/src/hooks/ai/useAI.ts b/src/hooks/ai/useAI.ts index 49735970..96f1ec61 100644 --- a/src/hooks/ai/useAI.ts +++ b/src/hooks/ai/useAI.ts @@ -7,7 +7,7 @@ import { useState, useCallback, useEffect } from 'react'; import { pushMsgOutPanel } from '@/components/Bottom/BottomPanel'; import { LOCALSTORAGE_KEY } from '@/context/config'; import { getSelectedFileContexts, getCustomInstructions } from '@/engine/ai/contextBuilder'; -import { generateCodeEdit, generateChatResponse } from '@/engine/ai/fetchAI'; +import { streamCodeEdit, streamChatResponse } from '@/engine/ai/fetchAI'; import { EDIT_PROMPT_TEMPLATE, ASK_PROMPT_TEMPLATE } from '@/engine/ai/prompts'; import { parseEditResponse, @@ -34,6 +34,7 @@ interface UseAIProps { export function useAI(props?: UseAIProps) { const [isProcessing, setIsProcessing] = useState(false); const [fileContexts, setFileContexts] = useState([]); + const [streamingContent, setStreamingContent] = useState(''); // storage adapter for AI review metadata // import dynamically to avoid circular deps in some build setups @@ -90,7 +91,7 @@ export function useAI(props?: UseAIProps) { [props?.onAddMessage] ); - // メッセージを送信(Ask/Edit統合) + // メッセージを送信(Ask/Edit統合)- ストリーミング対応 const sendMessage = useCallback( async (content: string, mode: 'ask' | 'edit'): Promise => { const apiKey = localStorage.getItem(LOCALSTORAGE_KEY.GEMINI_API_KEY); @@ -119,24 +120,41 @@ export function useAI(props?: UseAIProps) { })); setIsProcessing(true); + setStreamingContent(''); + try { // Get custom instructions if available const customInstructions = getCustomInstructions(fileContexts); if (mode === 'ask') { - // Ask モード + // Ask モード - ストリーミング const prompt = ASK_PROMPT_TEMPLATE(selectedFiles, content, previousMessages, customInstructions); - const response = await generateChatResponse(prompt, [], apiKey); + let fullResponse = ''; - await addMessage(response, 'assistant', 'ask'); + await streamChatResponse(prompt, [], apiKey, (chunk) => { + fullResponse += chunk; + setStreamingContent(fullResponse); + }); + + // ストリーミング完了後、最終メッセージを追加 + await addMessage(fullResponse, 'assistant', 'ask'); + setStreamingContent(''); return null; } else { - // Edit モード + // Edit モード - ストリーミング const prompt = EDIT_PROMPT_TEMPLATE(selectedFiles, content, previousMessages, customInstructions); - const response = await generateCodeEdit(prompt, apiKey); + let fullResponse = ''; + + await streamCodeEdit(prompt, apiKey, (chunk) => { + fullResponse += chunk; + setStreamingContent(fullResponse); + }); + + // ストリーミング完了後、レスポンスをパース + console.log('[useAI] Full streamed response:', fullResponse); // レスポンスのバリデーション - const validation = validateResponse(response); + const validation = validateResponse(fullResponse); if (!validation.isValid) { console.warn('[useAI] Response validation errors:', validation.errors); } @@ -145,7 +163,7 @@ export function useAI(props?: UseAIProps) { } // レスポンスをパース - const responsePaths = extractFilePathsFromResponse(response); + const responsePaths = extractFilePathsFromResponse(fullResponse); console.log( '[useAI] Selected files:', selectedFiles.map(f => ({ path: f.path, contentLength: f.content.length })) @@ -198,7 +216,7 @@ export function useAI(props?: UseAIProps) { allOriginalFiles.map(f => ({ path: f.path, contentLength: f.content.length, isNewFile: f.isNewFile })) ); - const parseResult = parseEditResponse(response, allOriginalFiles); + const parseResult = parseEditResponse(fullResponse, allOriginalFiles); console.log( '[useAI] Parse result:', @@ -237,6 +255,7 @@ export function useAI(props?: UseAIProps) { // Append assistant edit message and capture returned message (so we know its id) const assistantMsg = await addMessage(detailedMessage, 'assistant', 'edit', [], editResponse); + setStreamingContent(''); // Persist AI review metadata / snapshots using storage adapter when projectId provided try { @@ -259,12 +278,13 @@ export function useAI(props?: UseAIProps) { } catch (error) { const errorMessage = `Error: ${(error as Error).message}`; await addMessage(errorMessage, 'assistant', mode); + setStreamingContent(''); throw error; } finally { setIsProcessing(false); } }, - [fileContexts, addMessage, props?.messages] + [fileContexts, addMessage, props?.messages, props?.projectId, aiStorage] ); // ファイルコンテキストを更新 @@ -337,5 +357,6 @@ export function useAI(props?: UseAIProps) { updateFileContexts, toggleFileSelection, generatePromptText, + streamingContent, }; }