Added the AI config menu, options to select an API endpoint, or configure a local model.

This commit is contained in:
aomukai 2025-11-15 03:38:29 +09:00
parent b9347670c6
commit 24ecd96596
5 changed files with 486 additions and 57 deletions

170
main.html
View file

@ -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">

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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

View file

@ -147,6 +147,11 @@ body {
background: var(--success);
}
.status-dot.not-configured {
background: var(--accent);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,