SLM
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
# Use an official Python runtime as a parent image
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set the working directory in the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the requirements file into the container at /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install any needed packages specified in requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy the rest of the application's code into the container
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose the port the app runs on
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Define the command to run the application
|
||||||
|
# We use the PORT environment variable, defaulting to 8000
|
||||||
|
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import os
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, Body
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Import core LLM logic
|
||||||
|
from llm import load_or_train_model, generate_text, SOURCES_DIR
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
# Models to pre-load on startup
|
||||||
|
PRELOAD_N_GRAMS = [2, 3, 4, 5]
|
||||||
|
UI_DIR = "ui"
|
||||||
|
|
||||||
|
# --- Globals ---
|
||||||
|
# Cache for loaded models: {n: model}
|
||||||
|
MODEL_CACHE = {}
|
||||||
|
|
||||||
|
# --- Pydantic Models ---
|
||||||
|
class PredictRequest(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
temperature: float = 0.7
|
||||||
|
n: int = 3
|
||||||
|
length: int = 5
|
||||||
|
|
||||||
|
class PredictResponse(BaseModel):
|
||||||
|
prediction: str
|
||||||
|
|
||||||
|
# --- FastAPI App ---
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
def get_model_for_n(n: int):
|
||||||
|
"""
|
||||||
|
Retrieves the model for a specific N from cache, or loads/trains it.
|
||||||
|
"""
|
||||||
|
global MODEL_CACHE
|
||||||
|
if n in MODEL_CACHE:
|
||||||
|
return MODEL_CACHE[n]
|
||||||
|
|
||||||
|
print(f"Loading/Training model for N={n}...")
|
||||||
|
model = load_or_train_model(SOURCES_DIR, n)
|
||||||
|
MODEL_CACHE[n] = model
|
||||||
|
return model
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup_event():
|
||||||
|
"""
|
||||||
|
On server startup, pre-load models for all specified N-grams.
|
||||||
|
"""
|
||||||
|
print("Server starting up. Pre-loading models...")
|
||||||
|
for n in PRELOAD_N_GRAMS:
|
||||||
|
get_model_for_n(n)
|
||||||
|
print(f"Models for N={PRELOAD_N_GRAMS} loaded. Server is ready.")
|
||||||
|
|
||||||
|
@app.post("/api/predict", response_model=PredictResponse)
|
||||||
|
async def predict(request: PredictRequest):
|
||||||
|
"""
|
||||||
|
API endpoint to get the next word prediction.
|
||||||
|
"""
|
||||||
|
n = max(2, min(request.n, 5))
|
||||||
|
model = get_model_for_n(n)
|
||||||
|
|
||||||
|
if not model:
|
||||||
|
return {"prediction": ""}
|
||||||
|
|
||||||
|
length = max(1, min(request.length, 500))
|
||||||
|
|
||||||
|
prediction = generate_text(
|
||||||
|
model,
|
||||||
|
start_prompt=request.prompt,
|
||||||
|
length=length,
|
||||||
|
temperature=request.temperature
|
||||||
|
)
|
||||||
|
|
||||||
|
return PredictResponse(prediction=prediction)
|
||||||
|
|
||||||
|
# --- Static Files and Root ---
|
||||||
|
app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui")
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def read_root():
|
||||||
|
return FileResponse(os.path.join(UI_DIR, "index.html"))
|
||||||
|
|
||||||
|
def run():
|
||||||
|
# Read port from environment variable, default to 8000
|
||||||
|
port = int(os.environ.get("PORT", 8000))
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
stupid-llm-editor:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
# Map host port 5432 to container port 8000
|
||||||
|
- "${PORT:-5432}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./sources:/app/sources:ro
|
||||||
|
- ./models:/app/models
|
||||||
|
environment:
|
||||||
|
- PORT=${PORT:-8000}
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import hashlib
|
||||||
|
import pickle
|
||||||
|
from collections import defaultdict, Counter
|
||||||
|
|
||||||
|
SOURCES_DIR = "sources"
|
||||||
|
CACHE_DIR = "models"
|
||||||
|
N_GRAM = 3 # Default N-gram for standalone script use
|
||||||
|
|
||||||
|
def get_dir_checksum(directory):
|
||||||
|
"""
|
||||||
|
Calculates MD5 checksum of all .txt files in the directory to detect changes.
|
||||||
|
"""
|
||||||
|
hash_md5 = hashlib.md5()
|
||||||
|
if not os.path.exists(directory):
|
||||||
|
return None
|
||||||
|
|
||||||
|
files = sorted([f for f in os.listdir(directory) if f.endswith('.txt')])
|
||||||
|
|
||||||
|
for filename in files:
|
||||||
|
filepath = os.path.join(directory, filename)
|
||||||
|
hash_md5.update(filename.encode('utf-8'))
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(4096), b""):
|
||||||
|
hash_md5.update(chunk)
|
||||||
|
|
||||||
|
return hash_md5.hexdigest()
|
||||||
|
|
||||||
|
def train_model(sources_dir, n):
|
||||||
|
"""
|
||||||
|
Trains the N-gram model from scratch.
|
||||||
|
Returns: model object
|
||||||
|
"""
|
||||||
|
print(f"Training new {n}-gram model from sources...")
|
||||||
|
model = defaultdict(Counter)
|
||||||
|
|
||||||
|
files = [f for f in os.listdir(sources_dir) if f.endswith(".txt")]
|
||||||
|
if not files:
|
||||||
|
print("No source files found!")
|
||||||
|
return model
|
||||||
|
|
||||||
|
for filename in files:
|
||||||
|
filepath = os.path.join(sources_dir, filename)
|
||||||
|
try:
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
text = f.read()
|
||||||
|
text = re.sub(r'[[.*?]]', '', text)
|
||||||
|
words = text.split()
|
||||||
|
|
||||||
|
if len(words) < n:
|
||||||
|
continue
|
||||||
|
|
||||||
|
context_size = n - 1
|
||||||
|
for i in range(len(words) - context_size):
|
||||||
|
context = tuple(words[i : i + context_size])
|
||||||
|
next_word = words[i + context_size]
|
||||||
|
model[context][next_word] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing {filename}: {e}")
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
|
def load_or_train_model(sources_dir, n):
|
||||||
|
"""
|
||||||
|
Loads model from its dedicated cache file if checksum matches, otherwise retrains.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(CACHE_DIR):
|
||||||
|
os.makedirs(CACHE_DIR)
|
||||||
|
|
||||||
|
cache_file = os.path.join(CACHE_DIR, f"model_n{n}.pkl")
|
||||||
|
checksum_file = os.path.join(CACHE_DIR, f"checksum.txt") # One checksum for all
|
||||||
|
|
||||||
|
current_checksum = get_dir_checksum(sources_dir)
|
||||||
|
|
||||||
|
# Check if a model for this N exists and if the checksum matches
|
||||||
|
if os.path.exists(cache_file) and os.path.exists(checksum_file):
|
||||||
|
with open(checksum_file, 'r') as f:
|
||||||
|
saved_checksum = f.read()
|
||||||
|
|
||||||
|
if saved_checksum == current_checksum:
|
||||||
|
print(f"Sources unchanged. Loading model N={n} from {cache_file}...")
|
||||||
|
with open(cache_file, 'rb') as f:
|
||||||
|
return pickle.load(f)
|
||||||
|
else:
|
||||||
|
print(f"Sources changed. Global retrain needed. Deleting old models.")
|
||||||
|
for item in os.listdir(CACHE_DIR):
|
||||||
|
os.remove(os.path.join(CACHE_DIR, item))
|
||||||
|
|
||||||
|
print(f"No valid cache found for N={n}. Training...")
|
||||||
|
model = train_model(sources_dir, n)
|
||||||
|
|
||||||
|
print(f"Saving model to {cache_file}...")
|
||||||
|
with open(cache_file, 'wb') as f:
|
||||||
|
pickle.dump(model, f)
|
||||||
|
# Update the global checksum file after a successful train
|
||||||
|
with open(checksum_file, 'w') as f:
|
||||||
|
f.write(current_checksum or "")
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
|
def generate_text(model, start_prompt, length=100, temperature=1.0):
|
||||||
|
"""
|
||||||
|
Generates text using the N-gram model.
|
||||||
|
"""
|
||||||
|
if not model:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
context_size = next(iter(model.keys())).__len__() # Get context size from model keys
|
||||||
|
except StopIteration:
|
||||||
|
return "" # Model is empty
|
||||||
|
|
||||||
|
start_words = start_prompt.split()
|
||||||
|
current_context = None
|
||||||
|
|
||||||
|
if len(start_words) >= context_size:
|
||||||
|
potential_context = tuple(start_words[-context_size:])
|
||||||
|
if potential_context in model:
|
||||||
|
current_context = potential_context
|
||||||
|
|
||||||
|
if current_context is None and start_words:
|
||||||
|
last_word = start_words[-1]
|
||||||
|
candidates = [k for k in model.keys() if k[0] == last_word]
|
||||||
|
if candidates:
|
||||||
|
current_context = random.choice(candidates)
|
||||||
|
|
||||||
|
if current_context is None:
|
||||||
|
current_context = random.choice(list(model.keys()))
|
||||||
|
if not start_prompt:
|
||||||
|
start_prompt = ' '.join(current_context)
|
||||||
|
|
||||||
|
generated_words = []
|
||||||
|
|
||||||
|
for _ in range(length):
|
||||||
|
if current_context not in model or not model[current_context]:
|
||||||
|
current_context = random.choice(list(model.keys()))
|
||||||
|
|
||||||
|
possible_next = list(model[current_context].keys())
|
||||||
|
counts = list(model[current_context].values())
|
||||||
|
|
||||||
|
try:
|
||||||
|
if temperature == 1.0:
|
||||||
|
weights = counts
|
||||||
|
else:
|
||||||
|
weights = [c ** (1.0 / temperature) for c in counts]
|
||||||
|
next_word = random.choices(possible_next, weights=weights, k=1)[0]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
# Fallback if weights are invalid or no words are possible
|
||||||
|
current_context = random.choice(list(model.keys()))
|
||||||
|
next_word = current_context[0]
|
||||||
|
|
||||||
|
generated_words.append(next_word)
|
||||||
|
current_context = current_context[1:] + (next_word,)
|
||||||
|
|
||||||
|
return " ".join(generated_words)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.isdir(SOURCES_DIR):
|
||||||
|
print(f"Error: Directory '{SOURCES_DIR}' not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
model = load_or_train_model(SOURCES_DIR, N_GRAM)
|
||||||
|
print(f"Model ready. (N={N_GRAM}, Keys={len(model)})")
|
||||||
|
|
||||||
|
start_prompt = ""
|
||||||
|
length = 100
|
||||||
|
temperature = 1.0
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if not args:
|
||||||
|
start_ctx = random.choice(list(model.keys()))
|
||||||
|
start_prompt = " ".join(start_ctx)
|
||||||
|
else:
|
||||||
|
start_prompt = args[0]
|
||||||
|
if len(args) >= 2: length = int(args[1])
|
||||||
|
if len(args) >= 3: temperature = float(args[2])
|
||||||
|
|
||||||
|
print(f"\n--- Generating (Start: '{start_prompt}', Temp: {temperature}) ---\n")
|
||||||
|
output = start_prompt + " " + generate_text(model, start_prompt, length, temperature)
|
||||||
|
print(output)
|
||||||
|
print("\n-------------------------------------------------------------")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
python-multipart
|
||||||
File diff suppressed because it is too large
Load Diff
+10139
File diff suppressed because it is too large
Load Diff
+17097
File diff suppressed because it is too large
Load Diff
+9231
File diff suppressed because it is too large
Load Diff
+5528
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+10851
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+6545
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Stupid LLM Editor</title>
|
||||||
|
<link rel="stylesheet" href="/ui/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header class="header">
|
||||||
|
<h1 class="header-title">Stupid LLM Editor</h1>
|
||||||
|
<p class="header-subtitle">AI Pair-Programmer for Polish Literature</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card controls">
|
||||||
|
<div class="control-grid">
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="n-gram">Complexity (N)</label>
|
||||||
|
<select id="n-gram" class="input-base">
|
||||||
|
<option value="2">2 (Bigram)</option>
|
||||||
|
<option value="3" selected>3 (Trigram)</option>
|
||||||
|
<option value="4">4 (Tetragram)</option>
|
||||||
|
<option value="5">5 (Pentagram)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="temperature">Creativity (Temp): <span id="temp-val">0.7</span></label>
|
||||||
|
<input type="range" id="temperature" min="0.1" max="2.0" step="0.1" value="0.7">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="length">Length (Words): <span id="length-val">5</span></label>
|
||||||
|
<input type="range" id="length" min="1" max="20" step="1" value="5">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="generate-action">
|
||||||
|
<button id="generate-more-btn" class="btn btn-primary">Generate Paragraph</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card editor-wrapper">
|
||||||
|
<div id="suggestion-overlay"></div>
|
||||||
|
<textarea id="editor" rows="1" spellcheck="false" autofocus placeholder="Start typing... Press Tab to autocomplete."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="status-bar">
|
||||||
|
<span>Status:</span>
|
||||||
|
<span id="status">Idle</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script src="/ui/script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const editor = document.getElementById('editor');
|
||||||
|
const suggestionOverlay = document.getElementById('suggestion-overlay');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
const nGramSelect = document.getElementById('n-gram');
|
||||||
|
const tempInput = document.getElementById('temperature');
|
||||||
|
const tempValDisplay = document.getElementById('temp-val');
|
||||||
|
const lengthInput = document.getElementById('length');
|
||||||
|
const lengthValDisplay = document.getElementById('length-val');
|
||||||
|
const generateBtn = document.getElementById('generate-more-btn');
|
||||||
|
|
||||||
|
let currentSuggestion = '';
|
||||||
|
let isFetching = false;
|
||||||
|
let debounceTimer;
|
||||||
|
|
||||||
|
const autoResize = () => {
|
||||||
|
editor.style.height = 'auto';
|
||||||
|
suggestionOverlay.style.height = 'auto';
|
||||||
|
const newHeight = Math.max(360, editor.scrollHeight);
|
||||||
|
editor.style.height = newHeight + 'px';
|
||||||
|
suggestionOverlay.style.height = newHeight + 'px';
|
||||||
|
};
|
||||||
|
|
||||||
|
tempInput.addEventListener('input', () => { tempValDisplay.textContent = tempInput.value; });
|
||||||
|
lengthInput.addEventListener('input', () => { lengthValDisplay.textContent = lengthInput.value; });
|
||||||
|
|
||||||
|
const triggerUpdate = () => {
|
||||||
|
currentSuggestion = '';
|
||||||
|
updateSuggestion();
|
||||||
|
const prompt = editor.value;
|
||||||
|
if (prompt.trim().length > 0) fetchPrediction(prompt);
|
||||||
|
};
|
||||||
|
|
||||||
|
nGramSelect.addEventListener('change', triggerUpdate);
|
||||||
|
tempInput.addEventListener('change', triggerUpdate);
|
||||||
|
lengthInput.addEventListener('change', triggerUpdate);
|
||||||
|
|
||||||
|
const fetchPrediction = async (prompt, customLength = null) => {
|
||||||
|
if (isFetching) return;
|
||||||
|
|
||||||
|
isFetching = true;
|
||||||
|
status.textContent = 'Thinking...';
|
||||||
|
status.classList.add('fetching');
|
||||||
|
|
||||||
|
const n = parseInt(nGramSelect.value);
|
||||||
|
const temperature = parseFloat(tempInput.value);
|
||||||
|
const length = customLength || parseInt(lengthInput.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/predict', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt, n, temperature, length }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Network response failed');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (customLength) {
|
||||||
|
insertText(data.prediction || '');
|
||||||
|
} else {
|
||||||
|
currentSuggestion = data.prediction || '';
|
||||||
|
updateSuggestion();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Prediction failed:', error);
|
||||||
|
status.textContent = 'Error';
|
||||||
|
} finally {
|
||||||
|
isFetching = false;
|
||||||
|
status.textContent = 'Idle';
|
||||||
|
status.classList.remove('fetching');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSuggestion = () => {
|
||||||
|
const editorText = editor.value;
|
||||||
|
const space = (editorText.length > 0 && !/\s$/.test(editorText)) ? ' ' : '';
|
||||||
|
suggestionOverlay.textContent = editorText + space + currentSuggestion;
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertText = (text) => {
|
||||||
|
if (!text) return;
|
||||||
|
const space = (editor.value.length > 0 && !/\s$/.test(editor.value)) ? ' ' : '';
|
||||||
|
editor.value += space + text;
|
||||||
|
currentSuggestion = '';
|
||||||
|
updateSuggestion();
|
||||||
|
autoResize();
|
||||||
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.addEventListener('input', () => {
|
||||||
|
autoResize();
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
currentSuggestion = '';
|
||||||
|
updateSuggestion();
|
||||||
|
const prompt = editor.value;
|
||||||
|
if (prompt.trim().length === 0) return;
|
||||||
|
debounceTimer = setTimeout(() => fetchPrediction(prompt), 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Tab' && currentSuggestion) {
|
||||||
|
e.preventDefault();
|
||||||
|
insertText(currentSuggestion);
|
||||||
|
fetchPrediction(editor.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
generateBtn.addEventListener('click', () => {
|
||||||
|
fetchPrediction(editor.value, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
autoResize();
|
||||||
|
});
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
|
||||||
|
:root {
|
||||||
|
--background: hsl(0 0% 3.9%);
|
||||||
|
--foreground: hsl(0 0% 98%);
|
||||||
|
--card: hsl(0 0% 12%);
|
||||||
|
--card-foreground: hsl(0 0% 98%);
|
||||||
|
--popover: hsl(0 0% 3.9%);
|
||||||
|
--popover-foreground: hsl(0 0% 98%);
|
||||||
|
--primary: hsl(0 0% 98%);
|
||||||
|
--primary-foreground: hsl(0 0% 9%);
|
||||||
|
--secondary: hsl(0 0% 14.9%);
|
||||||
|
--secondary-foreground: hsl(0 0% 98%);
|
||||||
|
--muted: hsl(0 0% 14.9%);
|
||||||
|
--muted-foreground: hsl(0 0% 63.9%);
|
||||||
|
--accent: hsl(0 0% 14.9%);
|
||||||
|
--accent-foreground: hsl(0 0% 98%);
|
||||||
|
--border: hsl(0 0% 14.9%);
|
||||||
|
--input: hsl(0 0% 14.9%);
|
||||||
|
--ring: hsl(0 0% 83.1%);
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.header-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.header-subtitle {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.control-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.input-base {
|
||||||
|
background-color: var(--background);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 2px);
|
||||||
|
color: var(--foreground);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
select.input-base {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
padding-right: 2rem;
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='hsl(0 0% 63.9%)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3e%3cpath d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.5rem center;
|
||||||
|
background-size: 1em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-action {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: calc(var(--radius) - 2px);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: hsl(0 0% 98% / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
#editor, #suggestion-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'SF Mono', 'Fira Code', 'Courier New', monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
#editor {
|
||||||
|
z-index: 2;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
#editor:focus { outline: none; }
|
||||||
|
#editor::placeholder { color: var(--muted-foreground); }
|
||||||
|
|
||||||
|
#suggestion-overlay {
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#status.fetching {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user