Configure AI providers and API keys here (placeholder).
-
-
+
+
+
+
+
+
+
đ Quick Start
+
New to AI writing? Start
+ here! Choose a free API option or use a local model if you have one.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
API Provider Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Local Model Settings
+
+
+
đ Place your GGUF model
+ files in the /models
+ folder, then click "Scan for Models" below.
+
+
+
+
+
+
+
+
+
+
+
â ī¸ No models found. Please add
+ GGUF files to the /models folder.
+
+
+
+
+
+
The llama-server
+ endpoint (usually localhost:8080)
+
+
+
+
+
+
+
+
-
+
diff --git a/src/ai.js b/src/ai.js
index 1b2be30..de315a7 100644
--- a/src/ai.js
+++ b/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;
}
}
diff --git a/src/app.js b/src/app.js
index 6b51172..f10b80f 100644
--- a/src/app.js
+++ b/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;
diff --git a/src/generation.js b/src/generation.js
index 7640345..9cbd379 100644
--- a/src/generation.js
+++ b/src/generation.js
@@ -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
diff --git a/src/styles.css b/src/styles.css
index 6524333..6fd7933 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -147,6 +147,11 @@ body {
background: var(--success);
}
+.status-dot.not-configured {
+ background: var(--accent);
+ animation: pulse 2s infinite;
+}
+
@keyframes pulse {
0%,