From 143275abc009a5db5a548e2dc17e20158ae1caa3 Mon Sep 17 00:00:00 2001 From: Federico Ramos Date: Tue, 12 May 2026 18:20:31 -0300 Subject: [PATCH 1/2] wip: local AI updates --- .claude/settings.local.json | 10 ++ .env.example | 22 ++++ README.es.md | 155 +++++++++++++++++++++++++++ src/App.jsx | 151 ++++++++++++++++++++++++-- src/config.js | 78 ++++++++++++++ src/logic/classifyWithAI.js | 207 ++++++++++++++++++++++++++++++++++++ src/style.css | 117 ++++++++++++++++++++ 7 files changed, 733 insertions(+), 7 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .env.example create mode 100644 README.es.md create mode 100644 src/config.js create mode 100644 src/logic/classifyWithAI.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b18b583 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(Get-ChildItem -Path \"C:\\\\Users\\\\feder\\\\Documentos\\\\proyectos-ia\\\\contextforge\" -Recurse -ErrorAction SilentlyContinue)", + "Bash(Select-Object FullName)", + "Bash(Sort-Object FullName)", + "Bash(npm run *)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9d38c68 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# ContextForge — configuración de proveedores de IA +# Copiá este archivo como .env y completá las keys que tengas +# Solo necesitás UNA key para activar el Modo IA + +# Mistral (gratuito) — https://console.mistral.ai +VITE_MISTRAL_KEY= + +# Groq (muy generoso) — https://console.groq.com +VITE_GROQ_KEY= + +# Google Gemini (generoso) — https://aistudio.google.com +VITE_GEMINI_KEY= + +# Anthropic — https://console.anthropic.com +VITE_ANTHROPIC_KEY= + +# OpenAI — https://platform.openai.com +VITE_OPENAI_KEY= + +# Ollama local (sin key, solo URL) +VITE_OLLAMA_URL=http://localhost:11434 +VITE_OLLAMA_MODEL=llama3 diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000..f4995ba --- /dev/null +++ b/README.es.md @@ -0,0 +1,155 @@ +# ContextForge + +ContextForge es una herramienta web local que recibe un prompt escrito en lenguaje natural y recomienda qué tipo de contexto conviene compartir con una IA para obtener mejores respuestas. + +Ejemplo: + +> "Quiero que una IA revise mi landing page y me diga por qué no convierte." + +ContextForge responde: + +- categoría detectada; +- formatos principales recomendados; +- formatos complementarios; +- qué evitar; +- checklist de archivos/contexto; +- puntuación de calidad del prompt; +- prompt refinado listo para copiar. + +## Estado del proyecto + +Versión actual: **v0.2** + +- Sin backend. +- Sin base de datos. +- Funciona con reglas locales en JSON (modo heurístico). +- Soporte opcional para clasificación por IA real (modo IA). +- Pensado para aprender React, Vite y flujo de trabajo con VS Code. + +## Requisitos + +Instalar: + +1. Node.js LTS. +2. Git. +3. VS Code. + +## Ejecutar localmente + +Dentro de la carpeta del proyecto: + +```bash +npm install +npm run dev +``` + +Luego abrir la URL que muestre la terminal, normalmente: + +```text +http://localhost:5173/ +``` + +## Estructura + +```text +contextforge/ +├─ src/ +│ ├─ components/ +│ │ ├─ Checklist.jsx +│ │ ├─ PromptInput.jsx +│ │ ├─ PromptSuggestion.jsx +│ │ ├─ ResultCard.jsx +│ │ └─ ScorePanel.jsx +│ ├─ data/ +│ │ └─ contextRules.json +│ ├─ logic/ +│ │ ├─ classifyPrompt.js +│ │ ├─ classifyWithAI.js +│ │ ├─ exportMarkdown.js +│ │ ├─ generateAdvice.js +│ │ ├─ generateRefinedPrompt.js +│ │ ├─ scoreContext.js +│ │ └─ textUtils.js +│ ├─ config.js +│ ├─ App.jsx +│ ├─ main.jsx +│ └─ style.css +├─ docs/ +│ ├─ CODEX_WORKFLOW.md +│ └─ ROADMAP.md +├─ .env.example +├─ package.json +├─ index.html +└─ vite.config.js +``` + +## Cómo funciona + +1. El usuario escribe una necesidad. +2. `classifyPrompt.js` (o `classifyWithAI.js` en Modo IA) categoriza el texto. +3. `scoreContext.js` evalúa si el prompt trae objetivo, tipo de contenido, problema, resultado esperado y restricciones. +4. `generateAdvice.js` arma la recomendación. +5. `generateRefinedPrompt.js` crea un prompt mejorado para copiar. +6. `exportMarkdown.js` genera un reporte exportable. + +## Modo IA (opcional) + +Por defecto, ContextForge usa un clasificador basado en **reglas locales** (sin conexión ni API). +El **Modo IA** reemplaza ese clasificador con una llamada real a un modelo de lenguaje, +lo que permite un análisis más contextual y razonado. + +### Diferencia entre los modos + +| | Modo reglas | Modo IA | +|---|---|---| +| Requiere conexión | No | Sí | +| Requiere API key | No | Una key basta | +| Velocidad | Instantáneo | Depende del proveedor | +| Análisis | Heurístico por keywords | Razonamiento en lenguaje natural | +| Funciona offline | Sí | Solo con Ollama local | + +### Cómo activarlo + +Copiá el archivo `.env.example` como `.env` en la raíz del proyecto: + +```bash +cp .env.example .env +``` + +Completá al menos una de las keys de proveedor y reiniciá el servidor con `npm run dev`. + +### Proveedores soportados + +| Proveedor | Variable de entorno | Consola | +|-----------|---------------------|---------| +| Ollama (local, sin key) | `VITE_OLLAMA_URL` | Instalación local — sin cuenta | +| Groq | `VITE_GROQ_KEY` | https://console.groq.com | +| Mistral | `VITE_MISTRAL_KEY` | https://console.mistral.ai | +| Google Gemini | `VITE_GEMINI_KEY` | https://aistudio.google.com | +| Anthropic | `VITE_ANTHROPIC_KEY` | https://console.anthropic.com | +| OpenAI | `VITE_OPENAI_KEY` | https://platform.openai.com | + +El orden de prioridad si hay varias keys configuradas: +**Ollama → Groq → Mistral → Gemini → Anthropic → OpenAI** + +### Modo heurístico siempre disponible + +Si no configurás ninguna key, la herramienta sigue funcionando exactamente igual que antes +con el clasificador de reglas locales. El toggle de Modo IA aparece deshabilitado hasta que +haya al menos un proveedor configurado. + +Si configurás una key pero la llamada falla (red, key inválida, timeout), ContextForge +hace fallback automático al modo heurístico y muestra un aviso. + +## Próximas mejoras + +- Historial local en `localStorage`. +- Selector de IA destino: ChatGPT, Claude, Gemini, Manus. +- Modo principiante / profesional. +- Editor de reglas desde la interfaz. +- Exportación JSON. +- Tests unitarios para la lógica. + +## Filosofía del proyecto + +No intenta reemplazar a una IA avanzada. Sirve como capa previa: ayuda al usuario a preparar mejor su contexto antes de consultar una IA. diff --git a/src/App.jsx b/src/App.jsx index 20dceeb..34a345c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,8 @@ import Checklist from './components/Checklist'; import ScorePanel from './components/ScorePanel'; import PromptSuggestion from './components/PromptSuggestion'; import { classifyPrompt } from './logic/classifyPrompt'; +import { classifyWithAI } from './logic/classifyWithAI'; +import { getActiveProvider, getStoredKeys, STORAGE_KEY } from './config'; import { scoreContext } from './logic/scoreContext'; import { generateAdvice } from './logic/generateAdvice'; import { generateRefinedPrompt } from './logic/generateRefinedPrompt'; @@ -30,10 +32,44 @@ const examples = [ }, ]; +// Definición de campos del panel de configuración +const CONFIG_PROVIDERS = [ + { id: 'mistral', label: 'Mistral', type: 'password', placeholder: 'VITE_MISTRAL_KEY' }, + { id: 'groq', label: 'Groq', type: 'password', placeholder: 'VITE_GROQ_KEY' }, + { id: 'gemini', label: 'Google Gemini', type: 'password', placeholder: 'VITE_GEMINI_KEY' }, + { id: 'anthropic', label: 'Anthropic', type: 'password', placeholder: 'VITE_ANTHROPIC_KEY' }, + { id: 'openai', label: 'OpenAI', type: 'password', placeholder: 'VITE_OPENAI_KEY' }, + { id: 'ollama', label: 'Ollama (URL local)',type: 'text', placeholder: 'http://localhost:11434' }, +]; + +const EMPTY_FORM = { mistral: '', groq: '', gemini: '', anthropic: '', openai: '', ollama: '' }; + +// Enmascara una key mostrando solo los últimos 4 caracteres +function maskKey(value) { + if (!value || value.length <= 4) return value; + return '••••••••' + value.slice(-4); +} + +// Para Ollama muestra la URL completa (no es sensible); para el resto aplica máscara +function maskValue(id, value) { + return id === 'ollama' ? value : maskKey(value); +} + export default function App() { const [userText, setUserText] = useState(''); const [analysis, setAnalysis] = useState(null); const [copiedMessage, setCopiedMessage] = useState(''); + const [useAI, setUseAI] = useState(false); + + // Estado del panel de configuración + const [showConfig, setShowConfig] = useState(false); + const [formValues, setFormValues] = useState(EMPTY_FORM); + // savedKeys se inicializa leyendo localStorage para mostrar indicadores de "Guardada" + const [savedKeys, setSavedKeys] = useState(() => getStoredKeys()); + + // getActiveProvider() lee localStorage en cada render, por lo que se actualiza + // automáticamente tras guardar o borrar keys sin necesidad de estado extra + const activeProvider = getActiveProvider(); const markdownReport = useMemo(() => { if (!analysis) return ''; @@ -45,7 +81,7 @@ export default function App() { }); }, [analysis, userText]); - function analyze() { + async function analyze() { const cleanText = userText.trim(); if (!cleanText) { @@ -54,20 +90,73 @@ export default function App() { return; } - const category = classifyPrompt(cleanText); + let category; + + if (useAI && activeProvider) { + setCopiedMessage(`Analizando con ${activeProvider.name}...`); + category = await classifyWithAI(cleanText); + if (category._fallback) { + setCopiedMessage('No se pudo conectar con la IA. Usando modo reglas.'); + } + } else { + category = classifyPrompt(cleanText); + } + const advice = generateAdvice(category); const scoreData = scoreContext(cleanText); const refinedPrompt = generateRefinedPrompt(cleanText, category); setAnalysis({ category, advice, scoreData, refinedPrompt }); - setCopiedMessage('Análisis generado.'); + + if (!category._fallback) { + setCopiedMessage('Análisis generado.'); + } + } + + // Guarda las keys ingresadas en localStorage. + // Los campos vacíos conservan el valor previo (no borran la key existente). + function saveConfig() { + const toSave = { ...savedKeys }; + + for (const { id } of CONFIG_PROVIDERS) { + if (formValues[id].trim()) { + toSave[id] = formValues[id].trim(); + } + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); + setSavedKeys(toSave); + setFormValues(EMPTY_FORM); + setShowConfig(false); + + // getActiveProvider() ya lee el localStorage actualizado en el próximo render + const active = getActiveProvider(); + setCopiedMessage( + active + ? `Proveedor activo: ${active.name}` + : 'Sin proveedor activo — usando modo heurístico', + ); + } + + // Elimina todas las keys guardadas en localStorage + function clearConfig() { + localStorage.removeItem(STORAGE_KEY); + setSavedKeys({}); + setFormValues(EMPTY_FORM); + + const active = getActiveProvider(); + setCopiedMessage( + active + ? `Keys de UI borradas. Proveedor activo por .env: ${active.name}` + : 'Keys borradas — sin proveedor activo. Usando modo heurístico.', + ); } async function copyToClipboard(text, successMessage) { try { await navigator.clipboard.writeText(text); setCopiedMessage(successMessage); - } catch (error) { + } catch { setCopiedMessage('No pude copiar automáticamente. Seleccioná el texto y copialo manualmente.'); } } @@ -97,11 +186,59 @@ export default function App() {

- v0.1 - Reglas locales + v0.2 + {useAI && activeProvider ? `IA — ${activeProvider.name}` : 'Reglas locales'} + +
+ {showConfig && ( +
+

Proveedores de IA

+
+ {CONFIG_PROVIDERS.map(({ id, label, type, placeholder }) => ( +
+ + setFormValues((v) => ({ ...v, [id]: e.target.value }))} + placeholder={placeholder} + autoComplete="off" + /> + {savedKeys[id] && ( + + Guardada: {maskValue(id, savedKeys[id])} + + )} +
+ ))} +
+
+ + +
+
+ )} + {copiedMessage &&
{copiedMessage}
}
@@ -112,7 +249,7 @@ export default function App() { examples={examples} onExample={(text) => { setUserText(text); - setCopiedMessage('Ejemplo cargado. Presioná “Analizar contexto”.'); + setCopiedMessage('Ejemplo cargado. Presioná "Analizar contexto".'); }} /> diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..6d6ed27 --- /dev/null +++ b/src/config.js @@ -0,0 +1,78 @@ +// Configuración de proveedores de IA para ContextForge +// Los valores se leen desde import.meta.env.VITE_* (build time) y localStorage (runtime). +// localStorage tiene precedencia sobre las variables de entorno. + +export const STORAGE_KEY = 'contextforge_api_keys'; + +// Lee las keys guardadas desde la UI (localStorage). Devuelve {} si no hay nada. +export function getStoredKeys() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); + } catch { + return {}; + } +} + +const CONFIG = { + ollama: { + id: 'ollama', + name: 'Ollama', + url: import.meta.env.VITE_OLLAMA_URL || 'http://localhost:11434', + model: import.meta.env.VITE_OLLAMA_MODEL || 'llama3', + }, + groq: { + id: 'groq', + name: 'Groq', + key: import.meta.env.VITE_GROQ_KEY, + model: 'llama3-8b-8192', + }, + mistral: { + id: 'mistral', + name: 'Mistral', + key: import.meta.env.VITE_MISTRAL_KEY, + model: 'mistral-small-latest', + }, + gemini: { + id: 'gemini', + name: 'Gemini', + key: import.meta.env.VITE_GEMINI_KEY, + model: 'gemini-1.5-flash', + }, + anthropic: { + id: 'anthropic', + name: 'Anthropic', + key: import.meta.env.VITE_ANTHROPIC_KEY, + model: 'claude-haiku-3-5-20251001', + }, + openai: { + id: 'openai', + name: 'OpenAI', + key: import.meta.env.VITE_OPENAI_KEY, + model: 'gpt-4o-mini', + }, +}; + +// Orden de prioridad: Ollama → Groq → Mistral → Gemini → Anthropic → OpenAI +const PRIORITY = ['ollama', 'groq', 'mistral', 'gemini', 'anthropic', 'openai']; + +// Devuelve el primer proveedor activo con su key/URL efectiva resuelta. +// Para cada proveedor busca primero en localStorage y luego en import.meta.env. +export function getActiveProvider() { + const stored = getStoredKeys(); + + for (const id of PRIORITY) { + const p = CONFIG[id]; + + if (id === 'ollama') { + const url = stored.ollama || import.meta.env.VITE_OLLAMA_URL; + if (url) return { ...p, url }; + } else { + const key = stored[id] || p.key; + if (key) return { ...p, key }; + } + } + + return null; +} + +export default CONFIG; diff --git a/src/logic/classifyWithAI.js b/src/logic/classifyWithAI.js new file mode 100644 index 0000000..ae488d1 --- /dev/null +++ b/src/logic/classifyWithAI.js @@ -0,0 +1,207 @@ +// Clasificador con IA real — reemplaza la lógica heurística cuando hay un proveedor activo. +// Devuelve el mismo shape que classifyPrompt.js con fallback automático si la llamada falla. +import rules from '../data/contextRules.json'; +import { classifyPrompt } from './classifyPrompt'; +import { getActiveProvider } from '../config'; + +function buildSystemPrompt() { + const categoryList = rules.map((r) => `- ${r.id}: ${r.label}`).join('\n'); + + return `You are a context strategy advisor for AI tools. Your job is to analyze the user's need written in natural language and determine what type of information and file formats would help an AI understand and respond better. + +You must respond ONLY with a valid JSON object. No explanation, no markdown, no preamble. Just the raw JSON. + +Available categories (use the id field to select one): +${categoryList} + +Respond with this exact shape: +{ + "id": "string (category id from the list above)", + "label": "string (category label)", + "confidence": 0, + "matchedKeywords": [], + "description": "string (why this category fits)", + "primaryFormats": [], + "secondaryFormats": [], + "avoid": [], + "checklist": [], + "reason": "string (explanation of why this context helps the AI)", + "diagnosticExplanation": "string (brief explanation of category detection)" +} + +If no category fits clearly, use general_context. +Never invent categories outside the list.`; +} + +// Intenta extraer un objeto JSON de texto que puede incluir markdown o texto extra +function extractJSON(text) { + try { + return JSON.parse(text.trim()); + } catch {} + + const block = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (block) { + try { return JSON.parse(block[1].trim()); } catch {} + } + + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start !== -1 && end > start) { + try { return JSON.parse(text.slice(start, end + 1)); } catch {} + } + + throw new Error('No se pudo extraer JSON válido de la respuesta de la IA'); +} + +// --- Adaptadores por proveedor --- + +async function callOllama(provider, systemPrompt, userText) { + const res = await fetch(`${provider.url}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: provider.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userText }, + ], + stream: false, + }), + }); + if (!res.ok) throw new Error(`Ollama ${res.status}`); + const data = await res.json(); + return data.message.content; +} + +// Groq, Mistral y OpenAI comparten el formato de la API de OpenAI +async function callOpenAICompat(endpoint, key, model, systemPrompt, userText) { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${key}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userText }, + ], + }), + }); + if (!res.ok) throw new Error(`${endpoint} ${res.status}`); + const data = await res.json(); + return data.choices[0].message.content; +} + +async function callAnthropic(provider, systemPrompt, userText) { + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'x-api-key': provider.key, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: provider.model, + max_tokens: 1024, + system: systemPrompt, + messages: [{ role: 'user', content: userText }], + }), + }); + if (!res.ok) throw new Error(`Anthropic ${res.status}`); + const data = await res.json(); + return data.content[0].text; +} + +async function callGemini(provider, systemPrompt, userText) { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${provider.model}:generateContent?key=${provider.key}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + system_instruction: { parts: [{ text: systemPrompt }] }, + contents: [{ parts: [{ text: userText }] }], + }), + }); + if (!res.ok) throw new Error(`Gemini ${res.status}`); + const data = await res.json(); + return data.candidates[0].content.parts[0].text; +} + +async function callProvider(provider, systemPrompt, userText) { + switch (provider.id) { + case 'ollama': + return callOllama(provider, systemPrompt, userText); + case 'groq': + return callOpenAICompat( + 'https://api.groq.com/openai/v1/chat/completions', + provider.key, provider.model, systemPrompt, userText, + ); + case 'mistral': + return callOpenAICompat( + 'https://api.mistral.ai/v1/chat/completions', + provider.key, provider.model, systemPrompt, userText, + ); + case 'openai': + return callOpenAICompat( + 'https://api.openai.com/v1/chat/completions', + provider.key, provider.model, systemPrompt, userText, + ); + case 'anthropic': + return callAnthropic(provider, systemPrompt, userText); + case 'gemini': + return callGemini(provider, systemPrompt, userText); + default: + throw new Error(`Proveedor desconocido: ${provider.id}`); + } +} + +// --- Función principal exportada --- + +export async function classifyWithAI(text) { + const provider = getActiveProvider(); + + if (!provider) { + return { ...classifyPrompt(text), _fallback: true, _fallbackReason: 'Sin proveedor configurado' }; + } + + try { + const systemPrompt = buildSystemPrompt(); + const raw = await callProvider(provider, systemPrompt, text); + const aiResult = extractJSON(raw); + + // Normalizar confidence: algunos modelos devuelven escala 0-1 en lugar de 0-100 + if (typeof aiResult.confidence === 'number' && aiResult.confidence <= 1) { + aiResult.confidence = Math.round(aiResult.confidence * 100); + } + + // Usar la regla base del JSON para completar campos que la IA pudiera omitir + const rule = rules.find((r) => r.id === aiResult.id) + || rules.find((r) => r.id === 'general_context'); + + return { + ...rule, + label: aiResult.label || rule.label, + confidence: typeof aiResult.confidence === 'number' ? aiResult.confidence : 50, + matchedKeywords: Array.isArray(aiResult.matchedKeywords) ? aiResult.matchedKeywords : [], + description: aiResult.description || rule.description, + primaryFormats: Array.isArray(aiResult.primaryFormats) && aiResult.primaryFormats.length + ? aiResult.primaryFormats : rule.primaryFormats, + secondaryFormats: Array.isArray(aiResult.secondaryFormats) && aiResult.secondaryFormats.length + ? aiResult.secondaryFormats : rule.secondaryFormats, + avoid: Array.isArray(aiResult.avoid) && aiResult.avoid.length + ? aiResult.avoid : rule.avoid, + checklist: Array.isArray(aiResult.checklist) && aiResult.checklist.length + ? aiResult.checklist : rule.checklist, + reason: aiResult.reason || rule.reason, + diagnosticExplanation: aiResult.diagnosticExplanation || '', + }; + } catch (err) { + return { + ...classifyPrompt(text), + _fallback: true, + _fallbackReason: err.message || 'Error desconocido', + }; + } +} diff --git a/src/style.css b/src/style.css index 2ea4275..103a2b4 100644 --- a/src/style.css +++ b/src/style.css @@ -390,6 +390,123 @@ li + li { color: #dbe7ff; } +/* Botón discreto para abrir el panel de configuración de API */ +.config-open-btn { + display: block; + width: 100%; + margin-top: 6px; + padding: 5px 12px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.07); + background: transparent; + color: #5a6e94; + font-size: 0.74rem; + font-weight: 600; + letter-spacing: 0.04em; + cursor: pointer; + transition: color 0.15s; +} + +.config-open-btn:hover { + color: #93a4c7; +} + +.config-open-btn.open { + color: #b8cbff; +} + +/* Panel expandible de configuración de keys */ +.config-panel { + margin-bottom: 18px; +} + +.config-panel h3 { + margin: 0 0 16px; + font-size: 0.78rem; + color: #93a4c7; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 800; +} + +.config-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.config-field { + display: flex; + flex-direction: column; + gap: 5px; +} + +.config-field label { + font-size: 0.78rem; + font-weight: 700; + color: #b9c4dc; + letter-spacing: 0.04em; +} + +.config-field input { + padding: 9px 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + background: rgba(3, 7, 18, 0.6); + color: #f8fbff; + font-size: 0.88rem; + outline: none; +} + +.config-field input:focus { + border-color: rgba(108, 142, 255, 0.6); + box-shadow: 0 0 0 3px rgba(108, 142, 255, 0.08); +} + +.config-field input::placeholder { + color: #3f516e; +} + +/* Indicador de key ya guardada bajo el campo */ +.config-saved { + font-size: 0.72rem; + color: #5a6e94; + font-family: ui-monospace, monospace; + letter-spacing: 0.04em; +} + +.mode-toggle-btn { + display: block; + width: 100%; + margin-top: 10px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: #93a4c7; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.04em; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.mode-toggle-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); +} + +.mode-toggle-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.mode-toggle-btn.active { + background: rgba(125, 161, 255, 0.15); + border-color: rgba(125, 161, 255, 0.3); + color: #b8cbff; +} + @media (max-width: 860px) { .hero, .layout { From 0f16552a6d561be5be6fe41495bc1bbb38ffccdd Mon Sep 17 00:00:00 2001 From: Federico Ramos Date: Tue, 12 May 2026 18:23:00 -0300 Subject: [PATCH 2/2] chore: remove local Claude settings --- .claude/settings.local.json | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index b18b583..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(Get-ChildItem -Path \"C:\\\\Users\\\\feder\\\\Documentos\\\\proyectos-ia\\\\contextforge\" -Recurse -ErrorAction SilentlyContinue)", - "Bash(Select-Object FullName)", - "Bash(Sort-Object FullName)", - "Bash(npm run *)" - ] - } -}