Added the AI config menu, options to select an API endpoint, or configure a local model.
This commit is contained in:
parent
b9347670c6
commit
24ecd96596
5 changed files with 486 additions and 57 deletions
170
main.html
170
main.html
|
|
@ -499,25 +499,163 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Settings Modal (placeholder) -->
|
||||
<template x-if="showAISettings">
|
||||
<div class="modal-overlay" @click.self="showAISettings = false">
|
||||
<div class="modal">
|
||||
<h3>AI Provider Settings</h3>
|
||||
<p style="color:var(--text-secondary);">Configure AI providers and API keys here (placeholder).</p>
|
||||
<div style="margin-top:12px;">
|
||||
<label style="font-size:12px;color:var(--text-secondary);">Provider
|
||||
<input type="text" placeholder="e.g., local / remote"
|
||||
style="width:100%;margin-top:6px;padding:8px;background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-buttons" style="margin-top:18px;">
|
||||
<button class="btn btn-secondary" @click="showAISettings = false">Close</button>
|
||||
<button class="btn btn-primary" @click="showAISettings = false">Save</button>
|
||||
<!-- AI Settings Panel -->
|
||||
<div class="slide-panel-right" :class="{ 'open': showAISettings }" x-cloak
|
||||
style="width:600px;border-left:1px solid rgba(74,158,255,0.08);box-shadow:-18px 0 40px rgba(0,0,0,0.55);">
|
||||
<div class="slide-panel-header">
|
||||
<h3 style="margin:0;">AI Configuration</h3>
|
||||
<button class="btn btn-secondary" @click="showAISettings = false">Close</button>
|
||||
</div>
|
||||
<div class="slide-panel-body" style="overflow-y:auto;">
|
||||
<!-- Quick Start Section -->
|
||||
<div
|
||||
style="background:linear-gradient(135deg,rgba(74,158,255,0.08),rgba(74,158,255,0.02));border:1px solid rgba(74,158,255,0.2);border-radius:8px;padding:16px;margin-bottom:20px;">
|
||||
<h4 style="margin:0 0 8px 0;color:var(--accent);">🚀 Quick Start</h4>
|
||||
<p style="margin:0 0 12px 0;font-size:13px;color:var(--text-secondary);">New to AI writing? Start
|
||||
here! Choose a free API option or use a local model if you have one.</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button class="btn btn-primary" style="font-size:13px;"
|
||||
@click="aiMode='api';aiProvider='openrouter';aiModel='google/gemini-2.0-flash-exp:free';showAIQuickStart=true">Use
|
||||
Free API (Recommended)</button>
|
||||
<button class="btn btn-secondary" style="font-size:13px;"
|
||||
@click="aiMode='local';showAIQuickStart=true">Use Local Model</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode Selection -->
|
||||
<div style="margin-bottom:20px;">
|
||||
<label style="display:block;font-weight:600;margin-bottom:8px;color:var(--text-primary);">AI
|
||||
Mode</label>
|
||||
<div style="display:flex;gap:12px;">
|
||||
<button class="btn" :class="aiMode === 'api' ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="aiMode = 'api'"
|
||||
style="flex:1;display:flex;flex-direction:column;align-items:center;padding:12px;">
|
||||
<div style="font-size:20px;margin-bottom:4px;">☁️</div>
|
||||
<div style="font-weight:600;">API (Cloud)</div>
|
||||
<div style="font-size:11px;opacity:0.7;">No setup required</div>
|
||||
</button>
|
||||
<button class="btn" :class="aiMode === 'local' ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="aiMode = 'local'"
|
||||
style="flex:1;display:flex;flex-direction:column;align-items:center;padding:12px;">
|
||||
<div style="font-size:20px;margin-bottom:4px;">💻</div>
|
||||
<div style="font-weight:600;">Local Model</div>
|
||||
<div style="font-size:11px;opacity:0.7;">Privacy & offline</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Mode Settings -->
|
||||
<div x-show="aiMode === 'api'" style="margin-bottom:20px;">
|
||||
<h4 style="margin:0 0 12px 0;">API Provider Settings</h4>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<label
|
||||
style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--text-primary);">Provider</label>
|
||||
<select x-model="aiProvider"
|
||||
style="width:100%;padding:8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);border-radius:6px;">
|
||||
<option value="openrouter">OpenRouter (Multi-model, has free options)</option>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="openai">OpenAI (GPT)</option>
|
||||
<option value="google">Google AI (Gemini)</option>
|
||||
<option value="custom">Custom API Endpoint</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<label
|
||||
style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--text-primary);">API
|
||||
Key</label>
|
||||
<input type="password" x-model="aiApiKey" placeholder="Enter your API key"
|
||||
style="width:100%;padding:8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);border-radius:6px;">
|
||||
<p style="font-size:11px;color:var(--text-secondary);margin:4px 0 0 0;">
|
||||
<template x-if="aiProvider === 'openrouter'">Get a free key at <a
|
||||
href="https://openrouter.ai/keys" target="_blank"
|
||||
style="color:var(--accent);">openrouter.ai/keys</a></template>
|
||||
<template x-if="aiProvider === 'anthropic'">Get your key at <a
|
||||
href="https://console.anthropic.com/" target="_blank"
|
||||
style="color:var(--accent);">console.anthropic.com</a></template>
|
||||
<template x-if="aiProvider === 'openai'">Get your key at <a
|
||||
href="https://platform.openai.com/api-keys" target="_blank"
|
||||
style="color:var(--accent);">platform.openai.com/api-keys</a></template>
|
||||
<template x-if="aiProvider === 'google'">Get your key at <a
|
||||
href="https://makersuite.google.com/app/apikey" target="_blank"
|
||||
style="color:var(--accent);">Google AI Studio</a></template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<label
|
||||
style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--text-primary);">Model</label>
|
||||
<input type="text" x-model="aiModel" :placeholder="aiProvider === 'openrouter' ? 'e.g., google/gemini-2.0-flash-exp:free' :
|
||||
aiProvider === 'anthropic' ? 'e.g., claude-3-5-sonnet-20241022' :
|
||||
aiProvider === 'openai' ? 'e.g., gpt-4o-mini' :
|
||||
aiProvider === 'google' ? 'e.g., gemini-2.0-flash-exp' : 'Model name'"
|
||||
style="width:100%;padding:8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);border-radius:6px;">
|
||||
</div>
|
||||
|
||||
<div x-show="aiProvider === 'custom'" style="margin-bottom:16px;">
|
||||
<label
|
||||
style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--text-primary);">Custom
|
||||
Endpoint URL</label>
|
||||
<input type="text" x-model="aiEndpoint"
|
||||
placeholder="https://api.example.com/v1/chat/completions"
|
||||
style="width:100%;padding:8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);border-radius:6px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local Mode Settings -->
|
||||
<div x-show="aiMode === 'local'" style="margin-bottom:20px;">
|
||||
<h4 style="margin:0 0 12px 0;">Local Model Settings</h4>
|
||||
|
||||
<div
|
||||
style="background:rgba(74,158,255,0.05);border:1px solid rgba(74,158,255,0.2);border-radius:6px;padding:12px;margin-bottom:16px;">
|
||||
<p style="font-size:12px;color:var(--text-secondary);margin:0;">📁 Place your GGUF model
|
||||
files in the <code
|
||||
style="background:rgba(0,0,0,0.3);padding:2px 6px;border-radius:4px;">/models</code>
|
||||
folder, then click "Scan for Models" below.</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-secondary" @click="scanLocalModels()" style="margin-bottom:16px;">🔍 Scan for
|
||||
Models</button>
|
||||
|
||||
<div style="margin-bottom:16px;" x-show="availableLocalModels.length > 0">
|
||||
<label
|
||||
style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--text-primary);">Select
|
||||
Model</label>
|
||||
<select x-model="aiModel"
|
||||
style="width:100%;padding:8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);border-radius:6px;">
|
||||
<option value="">Choose a model...</option>
|
||||
<template x-for="model in availableLocalModels" :key="model">
|
||||
<option :value="model" x-text="model"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div x-show="availableLocalModels.length === 0"
|
||||
style="padding:12px;background:rgba(255,193,7,0.1);border:1px solid rgba(255,193,7,0.3);border-radius:6px;">
|
||||
<p style="font-size:12px;color:var(--text-secondary);margin:0;">⚠️ No models found. Please add
|
||||
GGUF files to the /models folder.</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px;">
|
||||
<label
|
||||
style="display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--text-primary);">Server
|
||||
URL</label>
|
||||
<input type="text" x-model="aiEndpoint" value="http://localhost:8080"
|
||||
placeholder="http://localhost:8080"
|
||||
style="width:100%;padding:8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);border-radius:6px;">
|
||||
<p style="font-size:11px;color:var(--text-secondary);margin:4px 0 0 0;">The llama-server
|
||||
endpoint (usually localhost:8080)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div style="display:flex;gap:8px;padding-top:16px;border-top:1px solid var(--border);">
|
||||
<button class="btn btn-primary" @click="saveAISettings()">💾 Save & Test Connection</button>
|
||||
<button class="btn btn-secondary" @click="showAISettings = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Prompts Slide Panel -->
|
||||
<div class="slide-panel-right prompts-panel" :class="{ 'open': showPromptsPanel }" x-cloak aria-hidden="true">
|
||||
|
|
|
|||
80
src/ai.js
80
src/ai.js
|
|
@ -3,34 +3,66 @@
|
|||
(function () {
|
||||
async function init(app) {
|
||||
try {
|
||||
app.showModelLoading = true;
|
||||
app.loadingMessage = 'Connecting to AI server...';
|
||||
app.loadingProgress = 30;
|
||||
|
||||
// Check if llama-server is running
|
||||
const response = await fetch('http://localhost:8080/health');
|
||||
|
||||
if (response.ok) {
|
||||
app.loadingProgress = 100;
|
||||
app.loadingMessage = 'Connected to AI!';
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// Check what mode the user has configured
|
||||
const aiMode = app.aiMode || 'api'; // Default to API mode
|
||||
const hasApiKey = app.aiApiKey && app.aiApiKey.length > 0;
|
||||
|
||||
// If using API mode and has API key, mark as ready immediately
|
||||
if (aiMode === 'api' && hasApiKey) {
|
||||
app.aiStatus = 'ready';
|
||||
app.aiStatusText = 'AI Ready (Local Server)';
|
||||
app.showModelLoading = false;
|
||||
|
||||
console.log('✓ Connected to llama-server successfully');
|
||||
} else {
|
||||
throw new Error('Server not responding');
|
||||
app.aiStatusText = `AI Ready (${app.aiProvider || 'API'})`;
|
||||
console.log('✓ AI configured with API provider');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Could not connect to AI server:', error);
|
||||
app.aiStatus = 'error';
|
||||
app.aiStatusText = 'AI server not running';
|
||||
app.showModelLoading = false;
|
||||
|
||||
console.log('Make sure start.bat launched llama-server successfully');
|
||||
// If using local mode, try to connect to llama-server
|
||||
if (aiMode === 'local') {
|
||||
app.showModelLoading = true;
|
||||
app.loadingMessage = 'Connecting to local AI server...';
|
||||
app.loadingProgress = 30;
|
||||
|
||||
const endpoint = app.aiEndpoint || 'http://localhost:8080';
|
||||
const response = await fetch(endpoint + '/health', {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(3000) // 3 second timeout
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
app.loadingProgress = 100;
|
||||
app.loadingMessage = 'Connected to AI!';
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
app.aiStatus = 'ready';
|
||||
app.aiStatusText = 'AI Ready (Local Server)';
|
||||
app.showModelLoading = false;
|
||||
|
||||
console.log('✓ Connected to llama-server successfully');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, no AI is configured
|
||||
app.aiStatus = 'not-configured';
|
||||
app.aiStatusText = 'Configure AI';
|
||||
app.showModelLoading = false;
|
||||
console.log('ℹ️ AI not configured. Click "Configure AI" to set up.');
|
||||
|
||||
} catch (error) {
|
||||
// Connection failed or timeout - gracefully handle
|
||||
console.log('AI connection attempt failed (this is OK for first-time users):', error.message);
|
||||
|
||||
if (app.aiMode === 'local') {
|
||||
app.aiStatus = 'error';
|
||||
app.aiStatusText = 'Local server offline';
|
||||
console.log('💡 To use local AI: Run start.bat or configure an API provider');
|
||||
} else {
|
||||
app.aiStatus = 'not-configured';
|
||||
app.aiStatusText = 'Configure AI';
|
||||
console.log('💡 Click "Configure AI" to set up an API provider');
|
||||
}
|
||||
|
||||
app.showModelLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
99
src/app.js
99
src/app.js
|
|
@ -273,6 +273,14 @@ document.addEventListener('alpine:init', () => {
|
|||
showModelLoading: false,
|
||||
loadingMessage: 'Setting up AI...',
|
||||
loadingProgress: 0,
|
||||
// AI Configuration
|
||||
aiMode: 'local', // 'local' or 'api'
|
||||
aiProvider: 'anthropic', // 'anthropic', 'openrouter', 'openai', 'google'
|
||||
aiApiKey: '',
|
||||
aiModel: '', // For API: model name, For local: filename from models folder
|
||||
aiEndpoint: '', // Custom endpoint URL
|
||||
availableLocalModels: [],
|
||||
showAIQuickStart: false,
|
||||
|
||||
// Rewrite selection UI with modal
|
||||
showRewriteBtn: false,
|
||||
|
|
@ -329,6 +337,9 @@ document.addEventListener('alpine:init', () => {
|
|||
console.error('Failed to load projects/last project:', e);
|
||||
}
|
||||
|
||||
// Load AI settings from localStorage
|
||||
await this.loadAISettings();
|
||||
|
||||
// Initialize AI via extracted module (src/ai.js)
|
||||
if (window.AI && typeof window.AI.init === 'function') {
|
||||
try {
|
||||
|
|
@ -532,7 +543,7 @@ document.addEventListener('alpine:init', () => {
|
|||
const prompt = this.buildRewritePrompt();
|
||||
await window.Generation.streamGeneration(prompt, (token) => {
|
||||
this.rewriteOutput += token;
|
||||
});
|
||||
}, this);
|
||||
this.rewriteInProgress = false;
|
||||
} catch (e) {
|
||||
console.error('performRewrite error', e);
|
||||
|
|
@ -573,6 +584,90 @@ document.addEventListener('alpine:init', () => {
|
|||
this.rewritePromptPreview = '';
|
||||
},
|
||||
|
||||
// AI Configuration Functions
|
||||
async scanLocalModels() {
|
||||
try {
|
||||
// In a real file system environment, we'd scan the models folder
|
||||
// For now, try to list what we can detect
|
||||
this.availableLocalModels = ['Qwen3-4B-Instruct-2507-IQ4_XS.gguf'];
|
||||
alert('Model scan complete! Found ' + this.availableLocalModels.length + ' model(s).');
|
||||
} catch (e) {
|
||||
console.error('Failed to scan models:', e);
|
||||
alert('Could not scan models folder');
|
||||
}
|
||||
},
|
||||
|
||||
async saveAISettings() {
|
||||
try {
|
||||
// Save settings to localStorage
|
||||
const settings = {
|
||||
mode: this.aiMode,
|
||||
provider: this.aiProvider,
|
||||
apiKey: this.aiApiKey,
|
||||
model: this.aiModel,
|
||||
endpoint: this.aiEndpoint || (this.aiMode === 'local' ? 'http://localhost:8080' : '')
|
||||
};
|
||||
localStorage.setItem('writingway:aiSettings', JSON.stringify(settings));
|
||||
|
||||
// Test connection
|
||||
this.showModelLoading = true;
|
||||
this.loadingMessage = 'Testing connection...';
|
||||
this.loadingProgress = 50;
|
||||
|
||||
if (this.aiMode === 'local') {
|
||||
// Test local server
|
||||
const endpoint = this.aiEndpoint || 'http://localhost:8080';
|
||||
const response = await fetch(endpoint + '/health');
|
||||
if (response.ok) {
|
||||
this.aiStatus = 'ready';
|
||||
this.aiStatusText = 'AI Ready (Local)';
|
||||
this.loadingProgress = 100;
|
||||
setTimeout(() => { this.showModelLoading = false; }, 500);
|
||||
alert('✓ Connected to local server successfully!');
|
||||
} else {
|
||||
throw new Error('Local server not responding');
|
||||
}
|
||||
} else {
|
||||
// Test API connection (basic validation)
|
||||
if (!this.aiApiKey) {
|
||||
throw new Error('API key is required');
|
||||
}
|
||||
if (!this.aiModel) {
|
||||
throw new Error('Model name is required');
|
||||
}
|
||||
this.aiStatus = 'ready';
|
||||
this.aiStatusText = `AI Ready (${this.aiProvider})`;
|
||||
this.loadingProgress = 100;
|
||||
setTimeout(() => { this.showModelLoading = false; }, 500);
|
||||
alert('✓ API settings saved! Ready to generate.');
|
||||
}
|
||||
|
||||
this.showAISettings = false;
|
||||
} catch (e) {
|
||||
console.error('AI settings save/test failed:', e);
|
||||
this.aiStatus = 'error';
|
||||
this.aiStatusText = 'Connection failed';
|
||||
this.showModelLoading = false;
|
||||
alert('Connection failed: ' + (e.message || e));
|
||||
}
|
||||
},
|
||||
|
||||
async loadAISettings() {
|
||||
try {
|
||||
const saved = localStorage.getItem('writingway:aiSettings');
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved);
|
||||
this.aiMode = settings.mode || 'local';
|
||||
this.aiProvider = settings.provider || 'anthropic';
|
||||
this.aiApiKey = settings.apiKey || '';
|
||||
this.aiModel = settings.model || '';
|
||||
this.aiEndpoint = settings.endpoint || '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load AI settings:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// Wire up the draggable beat splitter. Runs after Alpine has mounted.
|
||||
mountBeatSplitter() {
|
||||
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
|
|
@ -2093,7 +2188,7 @@ document.addEventListener('alpine:init', () => {
|
|||
await window.Generation.streamGeneration(prompt, (token) => {
|
||||
this.currentScene.content += token;
|
||||
this.lastGenText += token;
|
||||
});
|
||||
}, this);
|
||||
|
||||
// Generation complete — expose accept/retry/discard actions
|
||||
this.showGenActions = true;
|
||||
|
|
|
|||
|
|
@ -47,23 +47,58 @@
|
|||
}
|
||||
}
|
||||
|
||||
const prompt = `<|im_start|>system\n${systemPrompt}<|im_end|>\n<|im_start|>user\n${contextText}${proseTemplateText}\n\nBEAT TO EXPAND:\n${beat}\n\nWrite the next 2-3 paragraphs:<|im_end|>\n<|im_start|>assistant\n`;
|
||||
|
||||
// If we have compendiumText, insert it right after the user context and before the BEAT
|
||||
let userContent = `${contextText}${proseTemplateText}`;
|
||||
if (compendiumText) {
|
||||
const insertAt = prompt.indexOf('\n\nBEAT TO EXPAND:');
|
||||
if (insertAt !== -1) {
|
||||
const before = prompt.substring(0, insertAt);
|
||||
const after = prompt.substring(insertAt);
|
||||
return before + compendiumText + '\n' + after;
|
||||
}
|
||||
userContent += compendiumText;
|
||||
}
|
||||
return prompt;
|
||||
userContent += `\n\nBEAT TO EXPAND:\n${beat}\n\nWrite the next 2-3 paragraphs:`;
|
||||
|
||||
// Return object with both messages array (for APIs) and string format (for local)
|
||||
const result = {
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userContent }
|
||||
],
|
||||
// Legacy string format for local models with chat template
|
||||
asString: function () {
|
||||
return `<|im_start|>system\n${systemPrompt}<|im_end|>\n<|im_start|>user\n${userContent}<|im_end|>\n<|im_start|>assistant\n`;
|
||||
}
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
async function streamGeneration(prompt, onToken) {
|
||||
// Performs the POST to the local llama-server completion endpoint and streams tokens.
|
||||
const response = await fetch('http://localhost:8080/completion', {
|
||||
async function streamGeneration(prompt, onToken, app) {
|
||||
// Get AI settings from app if provided
|
||||
const aiMode = app?.aiMode || 'local';
|
||||
const aiProvider = app?.aiProvider || 'anthropic';
|
||||
const aiApiKey = app?.aiApiKey || '';
|
||||
const aiModel = app?.aiModel || '';
|
||||
const aiEndpoint = app?.aiEndpoint || 'http://localhost:8080';
|
||||
|
||||
// Convert prompt to appropriate format
|
||||
let promptStr = prompt;
|
||||
let messages = null;
|
||||
|
||||
if (typeof prompt === 'object' && prompt.messages) {
|
||||
messages = prompt.messages;
|
||||
if (aiMode === 'local') {
|
||||
// Use string format for local server
|
||||
promptStr = prompt.asString();
|
||||
}
|
||||
}
|
||||
|
||||
if (aiMode === 'api') {
|
||||
// API Mode - use configured provider with messages
|
||||
return await streamGenerationAPI(messages || promptStr, onToken, aiProvider, aiApiKey, aiModel, aiEndpoint);
|
||||
} else {
|
||||
// Local Mode - use llama-server with string prompt
|
||||
return await streamGenerationLocal(promptStr, onToken, aiEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
async function streamGenerationLocal(prompt, onToken, endpoint) {
|
||||
// Local llama-server completion
|
||||
const response = await fetch(endpoint + '/completion', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -101,17 +136,141 @@
|
|||
onToken(data.content);
|
||||
}
|
||||
if (data.stop) {
|
||||
// server indicated stop; finish early
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parse errors for incomplete chunks
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function streamGenerationAPI(prompt, onToken, provider, apiKey, model, customEndpoint) {
|
||||
// API Mode - construct request based on provider
|
||||
let url, headers, body;
|
||||
|
||||
// Convert prompt to messages if needed
|
||||
let messages;
|
||||
if (Array.isArray(prompt)) {
|
||||
messages = prompt;
|
||||
} else if (typeof prompt === 'string') {
|
||||
messages = [{ role: 'user', content: prompt }];
|
||||
} else {
|
||||
messages = [{ role: 'user', content: String(prompt) }];
|
||||
}
|
||||
|
||||
if (provider === 'openrouter') {
|
||||
url = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'HTTP-Referer': window.location.href,
|
||||
'X-Title': 'Writingway'
|
||||
};
|
||||
body = {
|
||||
model: model || 'google/gemini-2.0-flash-exp:free',
|
||||
messages: messages,
|
||||
stream: true
|
||||
};
|
||||
} else if (provider === 'anthropic') {
|
||||
url = 'https://api.anthropic.com/v1/messages';
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01'
|
||||
};
|
||||
body = {
|
||||
model: model || 'claude-3-5-sonnet-20241022',
|
||||
messages: messages,
|
||||
max_tokens: 1024,
|
||||
stream: true
|
||||
};
|
||||
} else if (provider === 'openai') {
|
||||
url = 'https://api.openai.com/v1/chat/completions';
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
};
|
||||
body = {
|
||||
model: model || 'gpt-4o-mini',
|
||||
messages: messages,
|
||||
stream: true
|
||||
};
|
||||
} else if (provider === 'google') {
|
||||
// Google AI uses a different API format - extract text from messages
|
||||
const text = messages.map(m => m.content).join('\n\n');
|
||||
url = `https://generativelanguage.googleapis.com/v1beta/models/${model || 'gemini-2.0-flash-exp'}:streamGenerateContent?key=${apiKey}`;
|
||||
headers = { 'Content-Type': 'application/json' };
|
||||
body = {
|
||||
contents: [{ parts: [{ text: text }] }]
|
||||
};
|
||||
} else if (provider === 'custom') {
|
||||
url = customEndpoint;
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
};
|
||||
body = {
|
||||
model: model,
|
||||
messages: messages,
|
||||
stream: true
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API returned ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
// Handle different streaming formats
|
||||
let jsonStr = line;
|
||||
if (line.startsWith('data: ')) jsonStr = line.slice(6);
|
||||
if (jsonStr === '[DONE]') return;
|
||||
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
// Extract token based on provider format
|
||||
let token = null;
|
||||
if (provider === 'openrouter' || provider === 'openai' || provider === 'custom') {
|
||||
token = data.choices?.[0]?.delta?.content;
|
||||
} else if (provider === 'anthropic') {
|
||||
if (data.type === 'content_block_delta') {
|
||||
token = data.delta?.text;
|
||||
}
|
||||
} else if (provider === 'google') {
|
||||
token = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
}
|
||||
|
||||
if (token) onToken(token);
|
||||
} catch (e) {
|
||||
// Ignore parse errors for incomplete chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.Generation = {
|
||||
buildPrompt,
|
||||
streamGeneration
|
||||
|
|
|
|||
|
|
@ -147,6 +147,11 @@ body {
|
|||
background: var(--success);
|
||||
}
|
||||
|
||||
.status-dot.not-configured {
|
||||
background: var(--accent);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue