diff --git a/api/modules/index.ts b/api/modules/index.ts index f3b2fb3..19641c1 100644 --- a/api/modules/index.ts +++ b/api/modules/index.ts @@ -56,6 +56,13 @@ async function getStaticModules(): Promise { console.warn('[ModuleLoader] Failed to load remote module:', e) } + try { + const { createOpencodeModule } = await import('./opencode/index.js') + modules.push(createOpencodeModule()) + } catch (e) { + console.warn('[ModuleLoader] Failed to load opencode module:', e) + } + modules.sort((a, b) => { const orderA = a.metadata.order ?? 0 const orderB = b.metadata.order ?? 0 @@ -74,3 +81,4 @@ export * from './pydemos/index.js' export * from './document-parser/index.js' export * from './ai/index.js' export * from './remote/index.js' +export * from './opencode/index.js' diff --git a/api/modules/opencode/index.ts b/api/modules/opencode/index.ts new file mode 100644 index 0000000..e091bb1 --- /dev/null +++ b/api/modules/opencode/index.ts @@ -0,0 +1,20 @@ +import type { Router } from 'express' +import type { ServiceContainer } from '../../infra/container.js' +import { createApiModule } from '../../infra/createModule.js' +import { OPENCODE_MODULE } from '../../../shared/modules/opencode/index.js' +import { createOpencodeRoutes } from './routes.js' + +export * from './routes.js' + +export const createOpencodeModule = () => { + return createApiModule({ + ...OPENCODE_MODULE, + version: OPENCODE_MODULE.version || '1.0.0', + }, { + routes: (_container: ServiceContainer): Router => { + return createOpencodeRoutes() + }, + }) +} + +export default createOpencodeModule \ No newline at end of file diff --git a/api/modules/opencode/routes.ts b/api/modules/opencode/routes.ts new file mode 100644 index 0000000..f351d18 --- /dev/null +++ b/api/modules/opencode/routes.ts @@ -0,0 +1,223 @@ +import { Router } from 'express' +import { createOpencodeClient } from '@opencode-ai/sdk' + +const OPENCODE_URL = 'http://localhost:4096' + +async function getClient() { + console.log('[OpenCode] Creating client for URL:', OPENCODE_URL) + return createOpencodeClient({ + baseUrl: OPENCODE_URL, + }) +} + +export function createOpencodeRoutes(): Router { + const router = Router() + + router.get('/', async (req, res) => { + const action = req.query.action as string | undefined + + try { + const client = await getClient() + + if (action === 'list-sessions') { + const response = await client.session.list() + const sessions = (response.data || []).map((session: { id: string; title?: string; createdAt?: string }) => ({ + id: session.id, + title: session.title || 'New Chat', + createdAt: session.createdAt ? new Date(session.createdAt).getTime() : Date.now(), + })) + res.json({ sessions }) + return + } + + res.status(400).json({ error: 'Unknown action' }) + } catch (error: unknown) { + console.error('API error:', error) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + res.status(500).json({ error: errorMessage }) + } + }) + + router.post('/', async (req, res) => { + try { + const body = req.body + const { action, sessionId, title, text, attachments } = body + console.log('[OpenCode] Received action:', action, 'sessionId:', sessionId) + + const client = await getClient() + console.log('[OpenCode] Client created successfully') + + if (action === 'create-session') { + try { + const response = await client.session.create({ + body: { title: title || 'New Chat' }, + }) + + if (response.error) { + res.status(500).json({ error: 'OpenCode error', details: response.error }) + return + } + + const sessionData = response.data as { id: string; title?: string; createdAt?: string } | undefined + if (sessionData) { + res.json({ + session: { + id: sessionData.id, + title: sessionData.title, + createdAt: sessionData.createdAt ? new Date(sessionData.createdAt).getTime() : Date.now(), + }, + }) + return + } + res.status(500).json({ error: 'Failed to create session - no data' }) + return + } catch (e: unknown) { + console.error('Create session exception:', e) + const errorMessage = e instanceof Error ? e.message : 'Unknown error' + res.status(500).json({ error: errorMessage, exception: true }) + return + } + } + + if (action === 'delete-session') { + const response = await client.session.delete({ + path: { id: sessionId }, + }) + res.json({ success: response.data }) + return + } + + if (action === 'get-session') { + const response = await client.session.get({ + path: { id: sessionId }, + }) + + if (response.data) { + res.json({ + title: response.data.title || 'New Chat', + }) + return + } + res.status(404).json({ error: 'Session not found' }) + return + } + + if (action === 'get-messages') { + const limit = body.limit || 20 + + const response = await client.session.messages({ + path: { id: sessionId }, + query: { limit } + }) + + const messages = (response.data || []).map((item: { info?: { id?: string; role?: string; content?: string; createdAt?: string }; content?: string; parts?: unknown[]; id?: string; role?: string }) => { + let content = item.info?.content || item.content || '' + + const parts = item.parts as Array<{ type: string; text?: string }> | undefined + if (parts && parts.length > 0) { + const textParts = parts + .filter((p) => p.type === 'text') + .map((p) => p.text) + .join('') + if (textParts) { + content = textParts + } + } + + return { + id: item.info?.id || item.id, + role: item.info?.role || item.role, + content: content, + parts: item.parts || [], + createdAt: item.info?.createdAt, + } + }) + + const hasMore = messages.length >= limit + + res.json({ messages, hasMore }) + return + } + + if (action === 'prompt') { + const parts: Array<{ type: 'text'; text: string } | { type: 'file'; url: string; name: string; mime: string }> = [] + + if (attachments && attachments.length > 0) { + for (const att of attachments as Array<{ url: string; name: string; mediaType: string }>) { + parts.push({ + type: 'file', + url: att.url, + name: att.name, + mime: att.mediaType, + }) + } + } + + parts.push({ type: 'text', text }) + + try { + const response = await client.session.prompt({ + path: { id: sessionId }, + body: { parts }, + }) + + if (response.data) { + res.json({ + message: { + id: (response.data.info as { id?: string })?.id || crypto.randomUUID(), + role: (response.data.info as { role?: string })?.role || 'assistant', + content: '', + parts: response.data.parts || [], + createdAt: new Date().toISOString(), + }, + }) + return + } + + res.status(500).json({ error: 'Failed to get response', details: response.error }) + return + } catch (e: unknown) { + console.error('Prompt exception:', e) + const errorMessage = e instanceof Error ? e.message : 'Unknown error' + res.status(500).json({ error: errorMessage }) + return + } + } + + if (action === 'summarize-session') { + try { + await client.session.summarize({ + path: { id: sessionId }, + }) + + const response = await client.session.get({ + path: { id: sessionId }, + }) + + if (response.data) { + res.json({ + title: response.data.title || 'New Chat', + }) + return + } + + res.status(500).json({ error: 'Failed to get session' }) + return + } catch (e: unknown) { + console.error('Summarize error:', e) + const errorMessage = e instanceof Error ? e.message : 'Unknown error' + res.status(500).json({ error: errorMessage }) + return + } + } + + res.status(400).json({ error: 'Unknown action' }) + } catch (error: unknown) { + console.error('API error:', error) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + res.status(500).json({ error: errorMessage }) + } + }) + + return router +} \ No newline at end of file diff --git a/shared/modules/opencode/index.ts b/shared/modules/opencode/index.ts index d91de61..66267b6 100644 --- a/shared/modules/opencode/index.ts +++ b/shared/modules/opencode/index.ts @@ -7,6 +7,6 @@ export const OPENCODE_MODULE = defineModule({ order: 15, version: '1.0.0', backend: { - enabled: false, + enabled: true, }, }) diff --git a/src/components/chat/Chat.tsx b/src/components/chat/Chat.tsx new file mode 100644 index 0000000..e5880d7 --- /dev/null +++ b/src/components/chat/Chat.tsx @@ -0,0 +1,243 @@ +import { useRef, useState, useCallback, useEffect } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from './ui/AlertDialog' +import { Messages } from './Messages' +import { MultimodalInput } from './MultimodalInput' +import { generateUUID } from '@/lib/utils' +import { Eraser } from 'lucide-react' + +type Status = 'submitted' | 'streaming' | 'ready' | 'error' + +interface Attachment { + url: string + name: string + contentType: string +} + +interface ChatMessage { + id: string + role: 'user' | 'assistant' | 'system' + content: string + parts: Array<{ type: string; text?: string }> + createdAt?: Date +} + +const OPENCODE_API = '/api/opencode' +const INITIAL_MESSAGE_LIMIT = 20 + +export function Chat({ + id, + initialMessages, + onClear, +}: { + id: string + initialMessages: ChatMessage[] + onClear?: () => void +}) { + const messagesEndRef = useRef(null) + + const [messages, setMessages] = useState(initialMessages) + const [hasMoreMessages, setHasMoreMessages] = useState(false) + const [input, setInput] = useState('') + const [showCreditCardAlert, setShowCreditCardAlert] = useState(false) + const [status, setStatus] = useState('ready') + const [attachments, setAttachments] = useState([]) + const [showInputAtBottom, setShowInputAtBottom] = useState(false) + + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }) + }, []) + + const loadMessages = useCallback(async () => { + try { + const res = await fetch(OPENCODE_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'get-messages', + sessionId: id, + limit: INITIAL_MESSAGE_LIMIT + }), + }) + if (res.ok) { + const data = await res.json() + const newMessages = data.messages || [] + setMessages(newMessages) + setHasMoreMessages(data.hasMore ?? newMessages.length >= INITIAL_MESSAGE_LIMIT) + setTimeout(scrollToBottom, 100) + } + } catch (error) { + console.error('Failed to load messages:', error) + } + }, [id, scrollToBottom]) + + const loadMoreMessages = useCallback(async () => { + if (messages.length === 0) return + + const currentCount = messages.length + const newLimit = currentCount + INITIAL_MESSAGE_LIMIT + + try { + const res = await fetch(OPENCODE_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'get-messages', + sessionId: id, + limit: newLimit + }), + }) + if (res.ok) { + const data = await res.json() + const newMessages = data.messages || [] + + setMessages(prev => { + const existingIds = new Set(prev.map(m => m.id)) + const uniqueNew = newMessages.filter((m: ChatMessage) => !existingIds.has(m.id)) + return [...uniqueNew, ...prev] + }) + + setHasMoreMessages(data.hasMore ?? newMessages.length >= newLimit) + } + } catch (error) { + console.error('Failed to load more messages:', error) + } + }, [id, messages]) + + useEffect(() => { + if (id) { + loadMessages() + } + }, [id, loadMessages]) + + const handleSendMessage = useCallback(async (text: string, atts: Attachment[]) => { + const userMessage: ChatMessage = { + id: generateUUID(), + role: 'user', + content: text, + parts: [], + createdAt: new Date(), + } + + setMessages(prev => [...prev, userMessage]) + setStatus('submitted') + + try { + const res = await fetch(OPENCODE_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'prompt', + sessionId: id, + text, + attachments: atts.map(a => ({ + url: a.url, + name: a.name, + mediaType: a.contentType, + })), + }), + }) + + if (!res.ok) { + throw new Error(await res.text()) + } + + const data = await res.json() + + if (data.message) { + setMessages(prev => [...prev, data.message]) + setShowInputAtBottom(true) + } + + setStatus('ready') + } catch (error: unknown) { + console.error('Send message error:', error) + setStatus('error') + + const errorMessage = error instanceof Error ? error.message : 'Failed to send message' + if (errorMessage.includes('credit card')) { + setShowCreditCardAlert(true) + } + } + }, [id]) + + const handleStop = useCallback(() => { + setStatus('ready') + }, []) + + return ( + <> +
+ {onClear && messages.length > 0 && ( +
+ +
+ )} + + +
+
0 + ? 'sticky bottom-0' + : 'absolute bottom-[40%] left-0 right-0' + }`} + > + +
+
+ + + + + API 配置需要 + + 请在环境变量中配置您的 AI API 密钥。 + + + + 取消 + { + setShowCreditCardAlert(false) + }} + > + 确定 + + + + + + ) +} \ No newline at end of file diff --git a/src/components/chat/Greeting.tsx b/src/components/chat/Greeting.tsx new file mode 100644 index 0000000..2442c3a --- /dev/null +++ b/src/components/chat/Greeting.tsx @@ -0,0 +1,9 @@ +export function Greeting() { + return ( +
+

+ 开始今天的工作吧! +

+
+ ) +} \ No newline at end of file diff --git a/src/components/chat/Message.tsx b/src/components/chat/Message.tsx new file mode 100644 index 0000000..40212bc --- /dev/null +++ b/src/components/chat/Message.tsx @@ -0,0 +1,88 @@ +import { memo } from 'react' +import { cn } from '@/lib/utils' +import { ArrowDownIcon, SparklesIcon } from 'lucide-react' + +interface MessageProps { + message: { + id: string + role: 'user' | 'assistant' | 'system' + content: string + parts?: Array<{ type: string; text?: string }> + } + isLoading?: boolean +} + +function PureMessage({ message, isLoading }: MessageProps) { + const isUser = message.role === 'user' + + return ( +
+
+ {message.parts?.map((part: { type: string; text?: string }, i: number) => { + const safeString = (val: unknown): string => { + if (val === null || val === undefined) return '' + if (typeof val === 'string') return val + if (typeof val === 'object') return JSON.stringify(val, null, 2) + return String(val) + } + + if (part.type === 'text') { + return ( +
+ {safeString(part.text)} +
+ ) + } + + if (part.type === 'reasoning') { + return ( +
+
+ + Thinking +
+
{safeString(part.text)}
+
+ ) + } + + return null + })} + {(!message.parts || message.parts.length === 0) && message.content && ( +
{message.content}
+ )} + {isLoading && ( +
+ Thinking + +
+ )} +
+
+ ) +} + +export const PreviewMessage = memo(PureMessage) + +export function ThinkingMessage() { + return ( +
+
+
+ Thinking + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/chat/Messages.tsx b/src/components/chat/Messages.tsx new file mode 100644 index 0000000..d8e04a6 --- /dev/null +++ b/src/components/chat/Messages.tsx @@ -0,0 +1,103 @@ +import { ArrowDownIcon, Loader2Icon } from 'lucide-react' +import { useState, useEffect } from 'react' +import { useMessages } from '@/hooks/useMessages' +import { Greeting } from './Greeting' +import { PreviewMessage, ThinkingMessage } from './Message' + +type Status = 'submitted' | 'streaming' | 'ready' | 'error' + +interface ChatMessage { + id: string + role: 'user' | 'assistant' | 'system' + content: string + parts: Array<{ type: string; text?: string }> + createdAt?: Date +} + +type MessagesProps = { + messages: ChatMessage[] + status: Status + hasMoreMessages?: boolean + loadMoreMessages?: () => void +} + +export function Messages({ messages, status, hasMoreMessages, loadMoreMessages }: MessagesProps) { + const [isLoadingMore, setIsLoadingMore] = useState(false) + + const { + containerRef: messagesContainerRef, + endRef: messagesEndRef, + isAtBottom, + scrollToBottom, + } = useMessages({ status }) + + useEffect(() => { + if (messages.length > 0) { + const container = messagesContainerRef.current + if (container) { + container.scrollTop = container.scrollHeight + } + } + }, [messages, messagesContainerRef]) + + const handleLoadMore = async () => { + if (!loadMoreMessages || isLoadingMore) return + setIsLoadingMore(true) + await loadMoreMessages() + setIsLoadingMore(false) + } + + return ( +
+
+
+ {hasMoreMessages && ( +
+ +
+ )} + + {messages.length === 0 && } + + {messages.map((message, index) => ( + + ))} + + {status === 'submitted' && } + +
+
+
+ + +
+ ) +} \ No newline at end of file diff --git a/src/components/chat/MultimodalInput.tsx b/src/components/chat/MultimodalInput.tsx new file mode 100644 index 0000000..c37839a --- /dev/null +++ b/src/components/chat/MultimodalInput.tsx @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useRef } from 'react' +import { cn } from '@/lib/utils' +import { ArrowUpIcon, StopIcon } from './icons' +import { Button } from './ui/Button' + +type Status = 'submitted' | 'streaming' | 'ready' | 'error' + +interface Attachment { + url: string + name: string + contentType: string +} + +interface ChatMessage { + id: string + role: 'user' | 'assistant' | 'system' + content: string + parts: unknown[] + createdAt?: Date +} + +interface MultimodalInputProps { + chatId: string + input: string + setInput: (value: string) => void + status: Status + stop: () => void + attachments: Attachment[] + setAttachments: (attachments: Attachment[]) => void + messages: ChatMessage[] + sendMessage: (text: string, attachments: Attachment[]) => Promise + className?: string +} + +export function MultimodalInput({ + chatId, + input, + setInput, + status, + stop, + attachments, + setAttachments, + sendMessage, + className, +}: MultimodalInputProps) { + const textareaRef = useRef(null) + + const adjustHeight = useCallback(() => { + if (textareaRef.current) { + textareaRef.current.style.height = '44px' + } + }, []) + + useEffect(() => { + if (textareaRef.current) { + adjustHeight() + } + }, [adjustHeight]) + + const resetHeight = useCallback(() => { + if (textareaRef.current) { + textareaRef.current.style.height = '44px' + } + }, []) + + const handleInput = (event: React.ChangeEvent) => { + setInput(event.target.value) + } + + const submitForm = useCallback(() => { + if (!input.trim() && attachments.length === 0) return + + sendMessage(input, attachments) + + setAttachments([]) + resetHeight() + setInput('') + + textareaRef.current?.focus() + }, [input, setInput, attachments, sendMessage, setAttachments, resetHeight]) + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + if (!input.trim() && attachments.length === 0) { + return + } + if (status !== 'ready') { + console.warn('Please wait for the model to finish its response!') + } else { + submitForm() + } + } + } + + useEffect(() => { + if (textareaRef.current) { + const target = textareaRef.current + target.style.height = '44px' + const scrollHeight = target.scrollHeight + if (scrollHeight > 44) { + target.style.height = `${Math.min(scrollHeight, 200)}px` + } + } + }, [input]) + + return ( +
+
+ {attachments.length > 0 && ( +
+ {attachments.map((attachment) => ( +
+ {attachment.name} + +
+ ))} +
+ )} + +