Merge pull request #13 from KGAudioLab/feat/2025-08-24-claude-support
Feat/2025 08 24 claude support
This commit is contained in:
@@ -15,6 +15,11 @@
|
|||||||
"api_key": "",
|
"api_key": "",
|
||||||
"model": "claude-sonnet-4-0"
|
"model": "claude-sonnet-4-0"
|
||||||
},
|
},
|
||||||
|
"claude_openrouter": {
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
"model": "anthropic/claude-sonnet-4"
|
||||||
|
},
|
||||||
"openai_compatible": {
|
"openai_compatible": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"base_url": "",
|
"base_url": "",
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { LLMProvider } from './LLMProvider';
|
||||||
|
import type { StreamChunk } from './StreamingTypes';
|
||||||
|
import type { Message } from '../core/AgentState';
|
||||||
|
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||||
|
import { LLM_PROTOCOL } from '../../constants/llmConstants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude (via OpenRouter) provider using the OpenAI-compatible Chat Completions API.
|
||||||
|
* Difference from the generic OpenAI provider: message content is an array of parts
|
||||||
|
* with a single text item per message (future-ready for images, tools, etc.).
|
||||||
|
*/
|
||||||
|
export class ClaudeOpenRouterProvider extends LLMProvider {
|
||||||
|
readonly name = 'Claude (OpenRouter)';
|
||||||
|
|
||||||
|
private isOllamaFormat: boolean | null = null; // Detected at runtime
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build OpenAI-compatible messages array where each message content is an array of parts.
|
||||||
|
*/
|
||||||
|
private buildRequestMessages(messages: Message[], systemPrompt?: string): Array<{ role: string; content: Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> }> {
|
||||||
|
type ORPart = { type: 'text'; text: string; cache_control?: { type: 'ephemeral' } };
|
||||||
|
const openAIMessages: Array<{ role: string; content: Array<ORPart> }> = [];
|
||||||
|
|
||||||
|
// Add system prompt if provided
|
||||||
|
if (systemPrompt) {
|
||||||
|
openAIMessages.push({ role: 'system', content: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add conversation history with preserved roles
|
||||||
|
let lastUserIndex = -1;
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
if (messages[i].role === 'user') lastUserIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
openAIMessages.push(
|
||||||
|
...messages.map((msg, i) => {
|
||||||
|
const part: ORPart = { type: 'text', text: msg.content };
|
||||||
|
if (msg.role === 'user' && i === lastUserIndex) {
|
||||||
|
part.cache_control = { type: 'ephemeral' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
role: msg.role,
|
||||||
|
content: [part]
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return openAIMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create API request with proper headers and body
|
||||||
|
*/
|
||||||
|
private async createApiRequest(messages: Array<{ role: string; content: Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> }>, config: ReturnType<typeof this.getCurrentConfig>, streaming: boolean): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': `Bearer ${config.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional, but recommended by OpenRouter docs to set referer/title for attribution
|
||||||
|
// if (typeof window !== 'undefined') {
|
||||||
|
// headers['HTTP-Referer'] = window.location.origin;
|
||||||
|
// headers['X-Title'] = 'K.G.Studio';
|
||||||
|
// }
|
||||||
|
|
||||||
|
const response = await fetch(config.apiEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: config.model,
|
||||||
|
messages,
|
||||||
|
stream: streaming,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => 'Unknown error');
|
||||||
|
throw new Error(`${this.name} API request failed (${response.status}): ${response.statusText}. ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current configuration values from ConfigManager
|
||||||
|
*/
|
||||||
|
private getCurrentConfig() {
|
||||||
|
const configManager = ConfigManager.instance();
|
||||||
|
const apiKey = configManager.get('general.claude_openrouter.api_key') as string;
|
||||||
|
const model = configManager.get('general.claude_openrouter.model') as string;
|
||||||
|
const baseURL = configManager.get('general.claude_openrouter.base_url') as string;
|
||||||
|
// baseURL is the full API endpoint for OpenRouter (e.g., https://openrouter.ai/api/v1/chat/completions)
|
||||||
|
const apiEndpoint = baseURL;
|
||||||
|
return { apiKey, model, baseURL, apiEndpoint };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if the response uses Ollama's raw JSON format or OpenAI's SSE format
|
||||||
|
*/
|
||||||
|
private detectStreamFormat(firstChunk: string): boolean {
|
||||||
|
// If it starts with "data: ", it's OpenAI SSE format
|
||||||
|
if (firstChunk.trim().startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) {
|
||||||
|
return false; // Not Ollama format
|
||||||
|
}
|
||||||
|
// Try to parse as JSON - if successful and has 'done' field, it's Ollama format
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(firstChunk.trim());
|
||||||
|
return typeof json.done === 'boolean';
|
||||||
|
} catch {
|
||||||
|
return false; // Not valid JSON, assume OpenAI format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Ollama's raw JSON chunk format
|
||||||
|
*/
|
||||||
|
private parseOllamaChunk(chunk: string): { thinking?: string; content?: string; isDone?: boolean } {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(chunk.trim());
|
||||||
|
const thinking: string | undefined = json.message?.thinking;
|
||||||
|
const content: string | undefined = json.message?.content || json.response; // Handle both chat and completion formats
|
||||||
|
return {
|
||||||
|
thinking,
|
||||||
|
content,
|
||||||
|
isDone: json.done === true
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {}; // Invalid JSON, return empty object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse OpenAI's SSE format chunk
|
||||||
|
*/
|
||||||
|
private parseOpenAIChunk(line: string): { thinking?: string; content?: string; isDone?: boolean } {
|
||||||
|
if (!line.startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = line.slice(LLM_PROTOCOL.SSE_DATA_PREFIX.length);
|
||||||
|
if (data === LLM_PROTOCOL.SSE_DONE_MARKER) {
|
||||||
|
return { isDone: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
const delta = json.choices?.[0]?.delta;
|
||||||
|
const thinking: string | undefined = delta?.thinking; // Some providers may stream "thinking"
|
||||||
|
const content: string | undefined = delta?.content;
|
||||||
|
return { thinking, content, isDone: false };
|
||||||
|
} catch {
|
||||||
|
return {}; // Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *processContentChunk(
|
||||||
|
thinking: string | undefined,
|
||||||
|
content: string | undefined,
|
||||||
|
lastSegmentType: { current: 'thinking' | 'content' | null }
|
||||||
|
): AsyncIterableIterator<StreamChunk> {
|
||||||
|
if (typeof thinking === 'string' && thinking.length > 0) {
|
||||||
|
if (lastSegmentType.current && lastSegmentType.current !== 'thinking') {
|
||||||
|
yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR };
|
||||||
|
}
|
||||||
|
yield { type: 'text', content: thinking };
|
||||||
|
lastSegmentType.current = 'thinking';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof content === 'string' && content.length > 0) {
|
||||||
|
if (lastSegmentType.current && lastSegmentType.current !== 'content') {
|
||||||
|
yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR };
|
||||||
|
}
|
||||||
|
yield { type: 'text', content: content };
|
||||||
|
lastSegmentType.current = 'content';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async *generateStream(
|
||||||
|
messages: Message[],
|
||||||
|
systemPrompt?: string
|
||||||
|
): AsyncIterableIterator<StreamChunk> {
|
||||||
|
const config = this.getCurrentConfig();
|
||||||
|
const requestMessages = this.buildRequestMessages(messages, systemPrompt);
|
||||||
|
const response = await this.createApiRequest(requestMessages, config, true);
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error(`${this.name} streaming: Failed to get response reader from API response`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let firstChunkProcessed = false;
|
||||||
|
const lastSegmentType = { current: null as 'thinking' | 'content' | null };
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Split by newlines for SSE (and also works for line-delimited JSON)
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (!trimmedLine) continue;
|
||||||
|
|
||||||
|
// Detect format on first non-empty chunk
|
||||||
|
if (!firstChunkProcessed) {
|
||||||
|
this.isOllamaFormat = this.detectStreamFormat(trimmedLine);
|
||||||
|
firstChunkProcessed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { thinking, content, isDone } = this.isOllamaFormat
|
||||||
|
? this.parseOllamaChunk(trimmedLine)
|
||||||
|
: this.parseOpenAIChunk(trimmedLine);
|
||||||
|
|
||||||
|
if (isDone) {
|
||||||
|
yield { type: 'done', content: '' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* this.processContentChunk(thinking, content, lastSegmentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import { UserMessage, AssistantMessage } from './chat';
|
|||||||
import { AgentCore } from '../agent/core/AgentCore';
|
import { AgentCore } from '../agent/core/AgentCore';
|
||||||
import { OpenAIProvider } from '../agent/llm/OpenAIProvider';
|
import { OpenAIProvider } from '../agent/llm/OpenAIProvider';
|
||||||
import { ClaudeProvider } from '../agent/llm/ClaudeProvider';
|
import { ClaudeProvider } from '../agent/llm/ClaudeProvider';
|
||||||
|
import { ClaudeOpenRouterProvider } from '../agent/llm/ClaudeOpenRouterProvider';
|
||||||
import { GeminiProvider } from '../agent/llm/GeminiProvider';
|
import { GeminiProvider } from '../agent/llm/GeminiProvider';
|
||||||
import { LLMProvider } from '../agent/llm/LLMProvider';
|
import { LLMProvider } from '../agent/llm/LLMProvider';
|
||||||
import { ConfigManager } from '../core/config/ConfigManager';
|
import { ConfigManager } from '../core/config/ConfigManager';
|
||||||
@@ -36,6 +37,8 @@ const createLLMProvider = (): LLMProvider => {
|
|||||||
return new ClaudeProvider();
|
return new ClaudeProvider();
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
return new GeminiProvider();
|
return new GeminiProvider();
|
||||||
|
case 'claude_openrouter':
|
||||||
|
return new ClaudeOpenRouterProvider();
|
||||||
case 'openai_compatible':
|
case 'openai_compatible':
|
||||||
case 'openai':
|
case 'openai':
|
||||||
default:
|
default:
|
||||||
@@ -167,17 +170,39 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
await configManager.initialize();
|
await configManager.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyProviderFromConfig = () => {
|
||||||
const provider = createLLMProvider();
|
const provider = createLLMProvider();
|
||||||
const agentCore = AgentCore.instance();
|
const agentCore = AgentCore.instance();
|
||||||
agentCore.setLLMProvider(provider);
|
agentCore.setLLMProvider(provider);
|
||||||
|
|
||||||
console.log(`Switched to ${provider.name} provider`);
|
console.log(`Switched to ${provider.name} provider`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initial apply
|
||||||
|
applyProviderFromConfig();
|
||||||
|
|
||||||
|
// Subscribe to config changes to hot-swap providers
|
||||||
|
const unsubscribe = configManager.addChangeListener((changedKeys) => {
|
||||||
|
// Hot-swap on provider change or when relevant provider config changes
|
||||||
|
if (
|
||||||
|
changedKeys.includes('general.llm_provider') ||
|
||||||
|
changedKeys.some(k => k.startsWith('general.openai.')) ||
|
||||||
|
changedKeys.some(k => k.startsWith('general.openai_compatible.')) ||
|
||||||
|
changedKeys.some(k => k.startsWith('general.claude_openrouter.')) ||
|
||||||
|
changedKeys.some(k => k.startsWith('general.gemini.')) ||
|
||||||
|
changedKeys.some(k => k.startsWith('general.claude.'))
|
||||||
|
) {
|
||||||
|
applyProviderFromConfig();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup subscription on unmount
|
||||||
|
return unsubscribe;
|
||||||
|
};
|
||||||
|
|
||||||
// Register the UI clear callback for external components to use
|
// Register the UI clear callback for external components to use
|
||||||
registerClearChatUICallback(clearChatUI);
|
registerClearChatUICallback(clearChatUI);
|
||||||
|
|
||||||
initializeProvider();
|
const maybeUnsubscribePromise = initializeProvider();
|
||||||
|
|
||||||
// Auto-trigger welcome on first launch (guard against React StrictMode double-invoke only)
|
// Auto-trigger welcome on first launch (guard against React StrictMode double-invoke only)
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -189,6 +214,12 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
setMessages([welcomeMessage]);
|
setMessages([welcomeMessage]);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
// In case initializeProvider returned a cleanup, ensure we call it
|
||||||
|
return () => {
|
||||||
|
Promise.resolve(maybeUnsubscribePromise).then((cleanup) => {
|
||||||
|
if (typeof cleanup === 'function') cleanup();
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
}, [clearChatUI]);
|
}, [clearChatUI]);
|
||||||
|
|
||||||
const handleAbort = () => {
|
const handleAbort = () => {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const GeneralSettings: React.FC = () => {
|
|||||||
const [geminiModel, setGeminiModel] = useState<string>('');
|
const [geminiModel, setGeminiModel] = useState<string>('');
|
||||||
const [claudeKey, setClaudeKey] = useState<string>('');
|
const [claudeKey, setClaudeKey] = useState<string>('');
|
||||||
const [claudeModel, setClaudeModel] = useState<string>('');
|
const [claudeModel, setClaudeModel] = useState<string>('');
|
||||||
|
const [claudeOpenRouterKey, setClaudeOpenRouterKey] = useState<string>('');
|
||||||
|
const [claudeOpenRouterBaseUrl, setClaudeOpenRouterBaseUrl] = useState<string>('');
|
||||||
|
const [claudeOpenRouterModel, setClaudeOpenRouterModel] = useState<string>('');
|
||||||
const [openaiFlex, setOpenaiFlex] = useState<boolean>(false);
|
const [openaiFlex, setOpenaiFlex] = useState<boolean>(false);
|
||||||
const [compatibleKey, setCompatibleKey] = useState<string>('');
|
const [compatibleKey, setCompatibleKey] = useState<string>('');
|
||||||
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState<string>('');
|
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState<string>('');
|
||||||
@@ -46,6 +49,9 @@ const GeneralSettings: React.FC = () => {
|
|||||||
setGeminiModel((configManager.get('general.gemini.model') as string) || '');
|
setGeminiModel((configManager.get('general.gemini.model') as string) || '');
|
||||||
setClaudeKey((configManager.get('general.claude.api_key') as string) || '');
|
setClaudeKey((configManager.get('general.claude.api_key') as string) || '');
|
||||||
setClaudeModel((configManager.get('general.claude.model') as string) || '');
|
setClaudeModel((configManager.get('general.claude.model') as string) || '');
|
||||||
|
setClaudeOpenRouterKey((configManager.get('general.claude_openrouter.api_key') as string) || '');
|
||||||
|
setClaudeOpenRouterBaseUrl((configManager.get('general.claude_openrouter.base_url') as string) || '');
|
||||||
|
setClaudeOpenRouterModel((configManager.get('general.claude_openrouter.model') as string) || '');
|
||||||
setCompatibleKey((configManager.get('general.openai_compatible.api_key') as string) || '');
|
setCompatibleKey((configManager.get('general.openai_compatible.api_key') as string) || '');
|
||||||
setCompatibleBaseUrl((configManager.get('general.openai_compatible.base_url') as string) || '');
|
setCompatibleBaseUrl((configManager.get('general.openai_compatible.base_url') as string) || '');
|
||||||
setCompatibleModel((configManager.get('general.openai_compatible.model') as string) || '');
|
setCompatibleModel((configManager.get('general.openai_compatible.model') as string) || '');
|
||||||
@@ -121,6 +127,21 @@ const GeneralSettings: React.FC = () => {
|
|||||||
debouncedSave('general.claude.model', value);
|
debouncedSave('general.claude.model', value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClaudeOpenRouterKeyChange = (value: string) => {
|
||||||
|
setClaudeOpenRouterKey(value);
|
||||||
|
debouncedSave('general.claude_openrouter.api_key', value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClaudeOpenRouterModelChange = (value: string) => {
|
||||||
|
setClaudeOpenRouterModel(value);
|
||||||
|
debouncedSave('general.claude_openrouter.model', value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClaudeOpenRouterBaseUrlChange = (value: string) => {
|
||||||
|
setClaudeOpenRouterBaseUrl(value);
|
||||||
|
debouncedSave('general.claude_openrouter.base_url', value);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCompatibleKeyChange = (value: string) => {
|
const handleCompatibleKeyChange = (value: string) => {
|
||||||
setCompatibleKey(value);
|
setCompatibleKey(value);
|
||||||
debouncedSave('general.openai_compatible.api_key', value);
|
debouncedSave('general.openai_compatible.api_key', value);
|
||||||
@@ -164,6 +185,7 @@ const GeneralSettings: React.FC = () => {
|
|||||||
<option value="openai">OpenAI</option>
|
<option value="openai">OpenAI</option>
|
||||||
{/* <option value="gemini">Gemini</option>
|
{/* <option value="gemini">Gemini</option>
|
||||||
<option value="claude">Claude</option> */}
|
<option value="claude">Claude</option> */}
|
||||||
|
<option value="claude_openrouter">Claude (via OpenRouter)</option>
|
||||||
<option value="openai_compatible">OpenAI Compatible (e.g. OpenRouter, Ollama)</option>
|
<option value="openai_compatible">OpenAI Compatible (e.g. OpenRouter, Ollama)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,6 +306,58 @@ const GeneralSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|
||||||
|
<div className="settings-group">
|
||||||
|
<h4>Anthropic Claude (via OpenRouter)</h4>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label">
|
||||||
|
Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="settings-input"
|
||||||
|
placeholder="Enter your Claude API key"
|
||||||
|
value={claudeOpenRouterKey}
|
||||||
|
onChange={(e) => handleClaudeOpenRouterKeyChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
{isLocalEnvironment
|
||||||
|
? 'Keys are persisted locally (the IndexedDB in your browser).'
|
||||||
|
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label">
|
||||||
|
Base URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input"
|
||||||
|
placeholder="e.g. https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
value={claudeOpenRouterBaseUrl}
|
||||||
|
onChange={(e) => handleClaudeOpenRouterBaseUrlChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
This is the base URL for the OpenRouter API. Please do not change this unless you know what you are doing.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label">
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="settings-select"
|
||||||
|
value={claudeOpenRouterModel}
|
||||||
|
onChange={(e) => handleClaudeOpenRouterModelChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="anthropic/claude-sonnet-4">claude-sonnet-4</option>
|
||||||
|
<option value="anthropic/claude-opus-4.1">claude-opus-4.1</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-group">
|
<div className="settings-group">
|
||||||
<h4>OpenAI Compatible Server</h4>
|
<h4>OpenAI Compatible Server</h4>
|
||||||
|
|
||||||
@@ -312,7 +386,7 @@ const GeneralSettings: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="settings-input"
|
className="settings-input"
|
||||||
placeholder="e.g. https://api.openrouter.ai/v1"
|
placeholder="e.g. https://openrouter.ai/api/v1/chat/completions"
|
||||||
value={compatibleBaseUrl}
|
value={compatibleBaseUrl}
|
||||||
onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)}
|
onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { DB_CONSTANTS } from '../../constants/coreConstants';
|
|||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
general: {
|
general: {
|
||||||
language: string;
|
language: string;
|
||||||
llm_provider: 'openai' | 'gemini' | 'claude' | 'openai_compatible';
|
llm_provider: 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible';
|
||||||
openai: {
|
openai: {
|
||||||
api_key: string;
|
api_key: string;
|
||||||
flex: boolean;
|
flex: boolean;
|
||||||
@@ -21,6 +21,11 @@ interface AppConfig {
|
|||||||
api_key: string;
|
api_key: string;
|
||||||
model: string;
|
model: string;
|
||||||
};
|
};
|
||||||
|
claude_openrouter: {
|
||||||
|
api_key: string;
|
||||||
|
base_url: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
openai_compatible: {
|
openai_compatible: {
|
||||||
api_key: string;
|
api_key: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
@@ -85,6 +90,7 @@ export class ConfigManager {
|
|||||||
private storage: KGStorage;
|
private storage: KGStorage;
|
||||||
private isInitialized: boolean = false;
|
private isInitialized: boolean = false;
|
||||||
private defaultConfig: AppConfig | null = null;
|
private defaultConfig: AppConfig | null = null;
|
||||||
|
private changeListeners: Set<(changedKeys: string[]) => void> = new Set();
|
||||||
|
|
||||||
// Private constructor to prevent direct instantiation
|
// Private constructor to prevent direct instantiation
|
||||||
private constructor() {
|
private constructor() {
|
||||||
@@ -169,6 +175,11 @@ export class ConfigManager {
|
|||||||
api_key: '',
|
api_key: '',
|
||||||
model: 'claude-sonnet-4-0'
|
model: 'claude-sonnet-4-0'
|
||||||
},
|
},
|
||||||
|
claude_openrouter: {
|
||||||
|
api_key: '',
|
||||||
|
base_url: 'https://openrouter.ai/api/v1/chat/completions',
|
||||||
|
model: 'anthropic/claude-sonnet-4'
|
||||||
|
},
|
||||||
openai_compatible: {
|
openai_compatible: {
|
||||||
api_key: '',
|
api_key: '',
|
||||||
base_url: '',
|
base_url: '',
|
||||||
@@ -351,6 +362,8 @@ export class ConfigManager {
|
|||||||
await this.saveToStorage();
|
await this.saveToStorage();
|
||||||
|
|
||||||
console.log(`Config updated: ${key} = ${value}`);
|
console.log(`Config updated: ${key} = ${value}`);
|
||||||
|
// Notify listeners of the specific key change
|
||||||
|
this.notifyChangeListeners([key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -368,6 +381,11 @@ export class ConfigManager {
|
|||||||
await this.saveToStorage();
|
await this.saveToStorage();
|
||||||
|
|
||||||
console.log('Config updated with multiple values:', updates);
|
console.log('Config updated with multiple values:', updates);
|
||||||
|
// Notify listeners of changed keys (dot notation)
|
||||||
|
const changedKeys = this.collectDotKeys(updates as Record<string, unknown>);
|
||||||
|
if (changedKeys.length > 0) {
|
||||||
|
this.notifyChangeListeners(changedKeys);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -386,6 +404,7 @@ export class ConfigManager {
|
|||||||
await this.saveToStorage();
|
await this.saveToStorage();
|
||||||
|
|
||||||
console.log('Config reset to defaults');
|
console.log('Config reset to defaults');
|
||||||
|
this.notifyChangeListeners(['__all__']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -433,6 +452,44 @@ export class ConfigManager {
|
|||||||
current[keys[keys.length - 1]] = value;
|
current[keys[keys.length - 1]] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to config changes. Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
public addChangeListener(listener: (changedKeys: string[]) => void): () => void {
|
||||||
|
this.changeListeners.add(listener);
|
||||||
|
return () => this.changeListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeChangeListener(listener: (changedKeys: string[]) => void): void {
|
||||||
|
this.changeListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyChangeListeners(changedKeys: string[]): void {
|
||||||
|
for (const listener of this.changeListeners) {
|
||||||
|
try {
|
||||||
|
listener(changedKeys);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Config change listener error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect dot-notation keys for all leaf values in a partial config object
|
||||||
|
*/
|
||||||
|
private collectDotKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const path = prefix ? `${prefix}.${k}` : k;
|
||||||
|
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||||
|
keys.push(...this.collectDotKeys(v as Record<string, unknown>, path));
|
||||||
|
} else {
|
||||||
|
keys.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if ConfigManager is initialized
|
* Check if ConfigManager is initialized
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user