feat: switched to opencode

This commit is contained in:
2026-02-11 19:07:06 +01:00
parent 870bec4dcd
commit 49f2b620db
15 changed files with 1343 additions and 1351 deletions

View File

@@ -344,3 +344,54 @@
opacity: 0.5;
cursor: not-allowed;
}
/* API Key form */
.api-key-form {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
width: 100%;
max-width: 400px;
}
.api-key-input {
padding: 10px 14px;
font-size: 14px;
color: var(--vscode-input-foreground);
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 6px;
outline: none;
}
.api-key-input:focus {
border-color: var(--vscode-focusBorder);
}
.api-key-submit {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s;
}
.api-key-submit:hover:not(:disabled) {
background-color: var(--vscode-button-hoverBackground);
}
.api-key-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.api-key-error {
font-size: 13px;
color: var(--vscode-errorForeground);
margin-top: 4px;
}

View File

@@ -14,6 +14,10 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
const [streamingContent, setStreamingContent] = useState('');
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
const [showModelSelector, setShowModelSelector] = useState(false);
const [needsApiKey, setNeedsApiKey] = useState(false);
const [apiKeyInput, setApiKeyInput] = useState('');
const [apiKeyError, setApiKeyError] = useState('');
const [isValidating, setIsValidating] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const streamingRef = useRef('');
@@ -23,6 +27,20 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
// Check if service is ready
const checkReady = useCallback(async () => {
try {
const status = await window.electronAPI?.chat.checkReady();
if (!status?.ready) {
setNeedsApiKey(true);
} else {
setNeedsApiKey(false);
}
} catch {
setNeedsApiKey(true);
}
}, []);
// Load conversation and messages
const loadData = useCallback(async () => {
try {
@@ -31,7 +49,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
window.electronAPI?.chat.getHistory(conversationId),
window.electronAPI?.chat.getAvailableModels()
]);
if (conv) setConversation(conv);
if (msgs) setMessages(msgs);
if (models) setAvailableModels(models);
@@ -41,8 +59,9 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
}, [conversationId]);
useEffect(() => {
checkReady();
loadData();
// Subscribe to stream events
const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => {
if (data.conversationId === conversationId) {
@@ -62,13 +81,36 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
unsubDelta?.();
unsubTitle?.();
};
}, [conversationId, loadData, scrollToBottom]);
}, [conversationId, loadData, scrollToBottom, checkReady]);
// Scroll on new messages or streaming content
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
const handleApiKeySubmit = async () => {
if (!apiKeyInput.trim()) return;
setIsValidating(true);
setApiKeyError('');
try {
const result = await window.electronAPI?.chat.validateApiKey(apiKeyInput.trim());
if (result?.isValid) {
await window.electronAPI?.chat.setApiKey(apiKeyInput.trim());
setNeedsApiKey(false);
setApiKeyInput('');
loadData();
} else {
setApiKeyError('Invalid API key. Please check and try again.');
}
} catch {
setApiKeyError('Failed to validate API key.');
} finally {
setIsValidating(false);
}
};
const handleSend = async () => {
const message = inputValue.trim();
if (!message || isStreaming) return;
@@ -91,7 +133,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
try {
// Send message and wait for complete response
await window.electronAPI?.chat.sendMessage(conversationId, message);
// Reload messages to get the saved assistant response
const msgs = await window.electronAPI?.chat.getHistory(conversationId);
if (msgs) setMessages(msgs);
@@ -144,7 +186,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
return (
<div key={msg.id} className={`chat-message ${msg.role}`}>
<div className="chat-message-avatar">
{msg.role === 'user' ? '👤' : '🤖'}
{msg.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
</div>
<div className="chat-message-content">
<div className="chat-message-header">
@@ -158,6 +200,43 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
);
};
// API key setup screen
if (needsApiKey) {
return (
<div className="chat-panel">
<div className="chat-panel-header">
<div className="chat-panel-title">AI Chat Setup</div>
</div>
<div className="chat-messages">
<div className="chat-welcome">
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
<h2>OpenCode Zen API Key Required</h2>
<p>Enter your OpenCode API key to enable AI chat.</p>
<div className="api-key-form">
<input
type="password"
className="api-key-input"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()}
placeholder="Enter your API key..."
disabled={isValidating}
/>
<button
className="api-key-submit"
onClick={handleApiKeySubmit}
disabled={!apiKeyInput.trim() || isValidating}
>
{isValidating ? 'Validating...' : 'Save Key'}
</button>
{apiKeyError && <div className="api-key-error">{apiKeyError}</div>}
</div>
</div>
</div>
</div>
);
}
return (
<div className="chat-panel">
<div className="chat-panel-header">
@@ -165,12 +244,12 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
{conversation?.title || 'New Chat'}
</div>
<div className="chat-panel-model">
<button
<button
className="model-selector-button"
onClick={() => setShowModelSelector(!showModelSelector)}
>
{conversation?.model || 'gpt-4o'}
<span className="model-dropdown-icon"></span>
{conversation?.model || 'claude-sonnet-4'}
<span className="model-dropdown-icon">{'\u25BE'}</span>
</button>
{showModelSelector && (
<div className="model-dropdown">
@@ -191,36 +270,37 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
<div className="chat-messages">
{messages.length === 0 && !isStreaming && (
<div className="chat-welcome">
<div className="chat-welcome-icon">🤖</div>
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
<h2>Welcome to the AI Assistant</h2>
<p>I can help you manage your posts and media. Try asking me to:</p>
<ul>
<li>Search for posts about a specific topic</li>
<li>Get details about a specific post</li>
<li>List all tags or categories in your blog</li>
<li>Update metadata for posts or media</li>
<li>List all images in your media library</li>
</ul>
</div>
)}
{messages.map(renderMessage)}
{isStreaming && streamingContent && (
<div className="chat-message assistant streaming">
<div className="chat-message-avatar">🤖</div>
<div className="chat-message-avatar">{'\u{1F916}'}</div>
<div className="chat-message-content">
<div className="chat-message-header">
<span className="chat-message-role">Assistant</span>
<span className="streaming-indicator"></span>
<span className="streaming-indicator">{'\u25CF'}</span>
</div>
<div className="chat-message-text">{streamingContent}</div>
</div>
</div>
)}
{isStreaming && !streamingContent && (
<div className="chat-message assistant thinking">
<div className="chat-message-avatar">🤖</div>
<div className="chat-message-avatar">{'\u{1F916}'}</div>
<div className="chat-message-content">
<div className="chat-thinking-indicator">
<span></span><span></span><span></span>
@@ -228,14 +308,14 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="chat-input-container">
{isStreaming && (
<button className="chat-abort-button" onClick={handleAbort}>
Stop
{'\u25FC'} Stop
</button>
)}
<div className="chat-input-wrapper">
@@ -249,12 +329,12 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
rows={1}
disabled={isStreaming}
/>
<button
<button
className="chat-send-button"
onClick={handleSend}
disabled={!inputValue.trim() || isStreaming}
>
{'\u2191'}
</button>
</div>
</div>

View File

@@ -753,8 +753,7 @@ const ChatList: React.FC = () => {
const { openTab } = useAppStore();
const [conversations, setConversations] = useState<ChatConversation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [authStatus, setAuthStatus] = useState<{ authenticated: boolean; username?: string } | null>(null);
const [deviceCode, setDeviceCode] = useState<{ verificationUri: string; userCode: string } | null>(null);
const [isReady, setIsReady] = useState(false);
// Load conversations
const loadConversations = useCallback(async () => {
@@ -768,20 +767,20 @@ const ChatList: React.FC = () => {
}
}, []);
// Check auth status
const checkAuth = useCallback(async () => {
// Check if service is ready
const checkReady = useCallback(async () => {
try {
const status = await window.electronAPI?.chat.copilotAuthStatus();
setAuthStatus(status ?? null);
} catch (error) {
console.error('Failed to check auth:', error);
const status = await window.electronAPI?.chat.checkReady();
setIsReady(status?.ready ?? false);
} catch {
setIsReady(false);
}
}, []);
useEffect(() => {
const init = async () => {
setIsLoading(true);
await checkAuth();
await checkReady();
await loadConversations();
setIsLoading(false);
};
@@ -789,21 +788,15 @@ const ChatList: React.FC = () => {
// Subscribe to title updates
const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => {
setConversations(prev =>
setConversations(prev =>
prev.map(c => c.id === data.conversationId ? { ...c, title: data.title } : c)
);
});
// Subscribe to device code for login flow
const unsubDevice = window.electronAPI?.chat.onDeviceCode((data) => {
setDeviceCode(data);
});
return () => {
unsubTitle?.();
unsubDevice?.();
};
}, [loadConversations, checkAuth]);
}, [loadConversations, checkReady]);
const handleNewChat = async () => {
try {
@@ -832,22 +825,6 @@ const ChatList: React.FC = () => {
}
};
const handleLogin = async () => {
try {
const result = await window.electronAPI?.chat.copilotLogin();
if (result?.success) {
setDeviceCode(null);
await checkAuth();
} else if (result?.error) {
console.error('Login failed:', result.error);
showToast.error(result.error);
}
} catch (error) {
console.error('Login failed:', error);
showToast.error('Login failed');
}
};
const formatChatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
@@ -874,33 +851,6 @@ const ChatList: React.FC = () => {
);
}
// Show login prompt if not authenticated
if (!authStatus?.authenticated) {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>AI ASSISTANT</span>
</div>
<div className="chat-auth-prompt">
<p>Sign in to GitHub Copilot to start chatting</p>
{deviceCode ? (
<div className="device-code-prompt">
<p>Enter this code at:</p>
<a href={deviceCode.verificationUri} target="_blank" rel="noopener noreferrer">
{deviceCode.verificationUri}
</a>
<div className="device-code">{deviceCode.userCode}</div>
</div>
) : (
<button className="chat-login-button" onClick={handleLogin}>
Sign in with GitHub
</button>
)}
</div>
</div>
);
}
return (
<div className="chat-list">
<div className="chat-list-header">
@@ -909,10 +859,9 @@ const ChatList: React.FC = () => {
+
</button>
</div>
{authStatus.username && (
<div className="chat-user-info">
<span className="chat-user-icon">👤</span>
<span className="chat-username">{authStatus.username}</span>
{!isReady && (
<div className="chat-auth-prompt">
<p>API key needed. Open a chat to configure.</p>
</div>
)}
<div className="chat-list-items">

View File

@@ -173,10 +173,8 @@ export interface SyncTagsResult {
// Chat/AI types
export interface ChatConversation {
id: string;
projectId: string;
title: string;
model?: string;
copilotSessionId?: string;
createdAt: string;
updatedAt: string;
}
@@ -194,16 +192,18 @@ export interface ChatMessage {
export interface ChatModel {
id: string;
name: string;
}
export interface ChatAuthStatus {
authenticated: boolean;
username?: string;
provider?: string;
}
export interface ChatReadyStatus {
ready: boolean;
authenticated: boolean;
error?: string;
backend?: string;
}
export interface ChatApiKeyStatus {
hasKey: boolean;
maskedKey: string;
}
export interface ChatStreamDelta {
@@ -229,11 +229,6 @@ export interface ChatTitleUpdate {
title: string;
}
export interface ChatDeviceCode {
verificationUri: string;
userCode: string;
}
export interface ElectronAPI {
projects: {
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
@@ -339,38 +334,37 @@ export interface ElectronAPI {
syncFromPosts: () => Promise<SyncTagsResult>;
};
chat: {
// Authentication
// API Key Management
checkReady: () => Promise<ChatReadyStatus>;
copilotAuthStatus: () => Promise<ChatAuthStatus>;
copilotLogin: () => Promise<{ success: boolean; error?: string; login?: string }>;
copilotLogout: () => Promise<void>;
validateApiKey: (apiKey: string) => Promise<{ isValid: boolean; models: ChatModel[] }>;
setApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
getApiKey: () => Promise<ChatApiKeyStatus>;
// Settings
getAvailableModels: () => Promise<ChatModel[]>;
setDefaultModel: (modelId: string) => Promise<void>;
getSystemPrompt: () => Promise<string | null>;
setSystemPrompt: (prompt: string) => Promise<void>;
// Conversations
getConversations: () => Promise<ChatConversation[]>;
createConversation: (title?: string, model?: string) => Promise<ChatConversation>;
getConversation: (id: string) => Promise<ChatConversation | null>;
updateConversation: (id: string, updates: { title?: string; model?: string }) => Promise<ChatConversation | null>;
deleteConversation: (id: string) => Promise<boolean>;
// Messaging
sendMessage: (conversationId: string, message: string) => Promise<string>;
abortMessage: (conversationId: string) => Promise<void>;
getHistory: (conversationId: string) => Promise<ChatMessage[]>;
clearMessages: (conversationId: string) => Promise<void>;
setConversationModel: (conversationId: string, modelId: string) => Promise<void>;
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
onToolResult: (callback: (data: ChatToolResult) => void) => () => void;
onTitleUpdated: (callback: (data: ChatTitleUpdate) => void) => () => void;
onDeviceCode: (callback: (data: ChatDeviceCode) => void) => () => void;
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;