diff --git a/.env.example b/.env.example index 2516f76..86c7142 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ -VITE_API_URL=/v1/completions -VITE_OCR_URL=/v1/ocr +VITE_API_BASE_URL=http://149.104.29.239:8001 +VITE_API_URL=http://149.104.29.239:8001/v1/completions +VITE_OCR_URL=http://149.104.29.239:8001/v1/ocr diff --git a/backend/.env.example b/backend/.env.example index c74dfba..aec1400 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,4 @@ OPENAI_API_KEY=ollama OLLAMA_BASE_URL=http://192.168.0.120:11434/v1/ -OLLAMA_MODEL=gpt-oss:120b +OLLAMA_MODEL=gpt-oss:20b VLM_MODEL=qwen3-vl:30b diff --git a/backend/llm.py b/backend/llm.py index 38b8e5d..4f88721 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -7,7 +7,7 @@ from dotenv import load_dotenv load_dotenv() -OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:120b') +OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b') OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434') VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b') diff --git a/backend/main.py b/backend/main.py index 67a8433..fc6f399 100644 --- a/backend/main.py +++ b/backend/main.py @@ -27,10 +27,20 @@ app.add_middleware( allow_headers=["*"], ) +from typing import Optional + +class UserPreferences(BaseModel): + language: str = 'auto' + currency: str = 'auto' + timezone: str = 'auto' + class CompletionRequest(BaseModel): prefix: str suffix: str languageId: str = 'markdown' + model_thinking: str = 'low' + privacy_mode: bool = False + user_preferences: Optional[UserPreferences] = None class OCRRequest(BaseModel): image: str @@ -50,26 +60,40 @@ def get_client_ip(request: Request) -> str: @app.post("/v1/completions") async def create_completion(request: Request, req: CompletionRequest): request_id = str(uuid.uuid4())[:8] - client_ip = get_client_ip(request) - # 查询 IP 归属地 - location = get_ip_location_text(client_ip) - if location: - logger.info("[%s] client_location=%s", request_id, location) + + client_ip = "hidden" + location = "" + + if not req.privacy_mode: + client_ip = get_client_ip(request) + # 查询 IP 归属地 + location = get_ip_location_text(client_ip) + if location: + logger.info("[%s] client_location=%s", request_id, location) + try: logger.info( - "[%s] /v1/completions client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s prefix_tail='%s' suffix_head='%s'", + "[%s] /v1/completions client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s", request_id, client_ip, len(req.prefix or ""), len(req.suffix or ""), req.languageId, - _preview((req.prefix or "")[-120:]), - _preview((req.suffix or "")[:120]), + req.model_thinking, + req.privacy_mode ) llm_prefix, llm_suffix = prepare_prompt_context(req.prefix or "", req.suffix or "") logger.info("[%s] llm_input_prefix=%r", request_id, llm_prefix) logger.info("[%s] llm_input_suffix=%r", request_id, llm_suffix) - prompt = build_prompt(req.prefix, req.suffix, req.languageId, location=location) + + prompt = build_prompt( + req.prefix, + req.suffix, + req.languageId, + location=location, + thinking_level=req.model_thinking, + preferences=req.user_preferences + ) result = await call_ollama(prompt, tag=f"{request_id}-primary", temperature=0.7) content = result["content"] or "" diff --git a/backend/prompt.py b/backend/prompt.py index 12ec50a..4acfeff 100644 --- a/backend/prompt.py +++ b/backend/prompt.py @@ -29,20 +29,46 @@ def prepare_prompt_context(prefix: str, suffix: str) -> Tuple[str, str]: return _prepare_context(prefix, suffix) -def build_prompt(prefix: str, suffix: str, language_id: str = "markdown", location: str = "") -> str: +def build_prompt( + prefix: str, + suffix: str, + language_id: str = "markdown", + location: str = "", + thinking_level: str = "low", + preferences: object = None +) -> str: safe_language_id = _sanitize_language_id(language_id) recent_prefix, recent_suffix = _prepare_context(prefix, suffix) current_time = _get_current_datetime() location_info = f"\nUser location: {location}" if location else "" + + thinking_instruction = "" + if thinking_level == "medium": + thinking_instruction = "\n- Briefly analyze the context before suggesting." + elif thinking_level == "high": + thinking_instruction = "\n- Deeply analyze the context, structure, and intent before suggesting. Think step-by-step." - prompt = f"""Current time: {current_time}{location_info} + pref_info = [] + if preferences: + if preferences.language and preferences.language != 'auto': + pref_info.append(f"Preferred language: {preferences.language}") + if preferences.currency and preferences.currency != 'auto': + pref_info.append(f"Preferred currency: {preferences.currency}") + if preferences.timezone and preferences.timezone != 'auto': + pref_info.append(f"User timezone: {preferences.timezone}") + + preferences_instruction = "\n".join(pref_info) + if preferences_instruction: + preferences_instruction = f"\nUser Preferences:\n{preferences_instruction}" + + prompt = f"""Current time: {current_time}{location_info}{preferences_instruction} You are an inline completion engine for a {safe_language_id} editor with ghost-text suggestions. Your job: - Return ONLY the text that should be inserted at the cursor between PREFIX and SUFFIX. - Prefer a meaningful, non-empty insertion with moderate length. -- Avoid overly short outputs with little information value. +- Avoid overly short outputs with little information value.{thinking_instruction} Important context: - PREFIX may contain OCR metadata inline after images, e.g. ![alt](url) . diff --git a/index.html b/index.html index cba95be..22f5d36 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,14 @@ - + + + + + + + + llm-in-text diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 0000000..8fd6bd8 Binary files /dev/null and b/public/icons/icon-180.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..c8deb56 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-192.svg b/public/icons/icon-192.svg new file mode 100644 index 0000000..592200d --- /dev/null +++ b/public/icons/icon-192.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..183e65e Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon-512.svg b/public/icons/icon-512.svg new file mode 100644 index 0000000..081c7ad --- /dev/null +++ b/public/icons/icon-512.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/icons/icon-maskable.svg b/public/icons/icon-maskable.svg new file mode 100644 index 0000000..c66103b --- /dev/null +++ b/public/icons/icon-maskable.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..877ee94 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,50 @@ +{ + "id": "/?source=pwa", + "name": "LLM in Text", + "short_name": "LLMText", + "description": "AI-assisted Markdown editor with real-time completion.", + "lang": "zh-CN", + "start_url": "/", + "scope": "/", + "display": "standalone", + "display_override": [ + "window-controls-overlay", + "standalone", + "minimal-ui" + ], + "orientation": "any", + "background_color": "#f8fafc", + "theme_color": "#0f172a", + "categories": [ + "productivity", + "utilities" + ], + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "shortcuts": [ + { + "name": "New Draft", + "short_name": "Draft", + "description": "Open the editor to start writing immediately.", + "url": "/" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..d120bb1 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,79 @@ +const CACHE_NAME = 'llm-in-text-v1'; +const APP_SHELL_ASSETS = [ + '/', + '/index.html', + '/manifest.webmanifest', + '/icons/icon-180.png', + '/icons/icon-192.png', + '/icons/icon-512.png' +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)) + ) + ) + ); + self.clients.claim(); +}); + +async function staleWhileRevalidate(request) { + const cache = await caches.open(CACHE_NAME); + const cached = await cache.match(request); + const networkPromise = fetch(request) + .then((response) => { + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + }) + .catch(() => cached); + + return cached || networkPromise; +} + +self.addEventListener('fetch', (event) => { + const { request } = event; + + if (request.method !== 'GET') return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + if (url.pathname.startsWith('/v1/')) return; + + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); + } + return response; + }) + .catch(async () => { + const cachedPage = await caches.match(request); + if (cachedPage) return cachedPage; + return caches.match('/index.html'); + }) + ); + return; + } + + const cacheableDestinations = ['script', 'style', 'font', 'image', 'worker']; + if (cacheableDestinations.includes(request.destination)) { + event.respondWith(staleWhileRevalidate(request)); + } +}); diff --git a/src/App.vue b/src/App.vue index 06e644b..fbaaf0b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,54 +1,63 @@ @@ -59,128 +68,14 @@ function onChange(markdownValue) { height: 100%; background: var(--app-bg); color: var(--app-text); + transition: background 0.3s, color 0.3s; + isolation: isolate; /* Create new stacking context */ } -.theme-toggle { - position: fixed; - top: 16px; - right: 20px; - z-index: 11000; - border: none; - padding: 0; - background: none; - cursor: pointer; -} - -.theme-toggle__track { +.editor-container { position: relative; - width: 72px; - height: 36px; - display: block; - border-radius: 999px; - border: 2px solid var(--panel-border); - background: linear-gradient(135deg, var(--toggle-bg-start), var(--toggle-bg-end)); - box-shadow: var(--panel-shadow), inset 0 2px 4px rgba(255, 255, 255, 0.1), inset 0 -2px 4px rgba(0, 0, 0, 0.1); - overflow: hidden; - transition: background 400ms ease, border-color 400ms ease, box-shadow 400ms ease; + z-index: 1; + height: 100%; } -.theme-toggle__track::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(180deg, rgba(255,255,255,0.15) 0%, transparent 50%, rgba(0,0,0,0.1) 100%); - border-radius: 999px; - pointer-events: none; - transition: opacity 400ms ease; -} - -.theme-toggle__thumb { - position: absolute; - top: 2px; - left: 2px; - width: 28px; - height: 28px; - border-radius: 50%; - background: linear-gradient(135deg, var(--toggle-thumb-bg) 0%, var(--toggle-thumb-bg) 100%); - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.4); - transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1); - z-index: 2; -} - -.theme-toggle__sun, -.theme-toggle__moon { - position: absolute; - top: 50%; - width: 18px; - height: 18px; - transform: translateY(-50%); - transition: opacity 300ms ease, transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1); -} - -.theme-toggle__sun { - left: 10px; - color: var(--toggle-sun); - opacity: 1; - transform: translateY(-50%) scale(1); -} - -.theme-toggle__sun svg { - width: 18px; - height: 18px; - filter: drop-shadow(0 0 3px var(--toggle-sun)); -} - -.theme-toggle__moon { - right: 10px; - color: var(--toggle-moon); - opacity: 0.5; - transform: translateY(-50%) scale(0.85); -} - -.theme-toggle__moon svg { - width: 18px; - height: 18px; -} - -.theme-toggle.is-dark .theme-toggle__thumb { - transform: translateX(36px); - background: linear-gradient(135deg, var(--toggle-thumb-bg) 0%, #c8d4ec 100%); -} - -.theme-toggle.is-dark .theme-toggle__sun { - opacity: 0.5; - transform: translateY(-50%) scale(0.85); -} - -.theme-toggle.is-dark .theme-toggle__moon { - opacity: 1; - transform: translateY(-50%) scale(1); - color: #c8d4ec; -} - -.theme-toggle.is-dark .theme-toggle__moon svg { - filter: drop-shadow(0 0 4px rgba(200, 212, 236, 0.6)); -} - -.theme-toggle:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 3px; - border-radius: 999px; -} - -@media (max-width: 640px) { - .theme-toggle { - top: 12px; - right: 12px; - } -} - -@media (prefers-reduced-motion: reduce) { - .theme-toggle__thumb, - .theme-toggle__sun, - .theme-toggle__moon { - transition: none; - } -} diff --git a/src/components/MilkdownEditor.vue b/src/components/MilkdownEditor.vue index 567e516..cf8fa37 100644 --- a/src/components/MilkdownEditor.vue +++ b/src/components/MilkdownEditor.vue @@ -6,8 +6,8 @@
- - + +
@@ -84,16 +84,16 @@
-

通过 URL 插入图片

+

{{ t('insertUrl') }}

- - + +
@@ -101,17 +101,20 @@ + + + + diff --git a/src/main.js b/src/main.js index 8a0a19c..9cf6c02 100644 --- a/src/main.js +++ b/src/main.js @@ -2,7 +2,18 @@ import { createApp } from 'vue' import App from './App.vue' import './style.css' +import { createPinia } from 'pinia' import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/frame.css' -createApp(App).mount('#app') +const app = createApp(App) +app.use(createPinia()) +app.mount('#app') + +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch((error) => { + console.error('Service worker registration failed:', error) + }) + }) +} diff --git a/src/plugins/copilotPlugin.ts b/src/plugins/copilotPlugin.ts index 419ac49..a0ee43b 100644 --- a/src/plugins/copilotPlugin.ts +++ b/src/plugins/copilotPlugin.ts @@ -51,11 +51,11 @@ export const copilotGhostMark = $markSchema('copilot_ghost', () => ({ toDOM: () => ['span', { 'data-copilot-ghost': '', class: 'copilot-ghost-text' }, 0], parseMarkdown: { match: () => false, - runner: () => {} + runner: () => { } }, toMarkdown: { match: (mark) => mark.type.name === 'copilot_ghost', - runner: () => {} + runner: () => { } } })) @@ -331,7 +331,7 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) { const doc = view.state.doc const schema = view.state.schema const baseSize = doc.content.size - + const serializer = runtime.ctx.get(serializerCtx) let prefixMarkdown = '' let suffixMarkdown = '' @@ -356,7 +356,7 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) { const totalTextLen = (prefixMarkdown + suffixMarkdown).length const ocrContextLen = requestPrefix.length - prefixMarkdown.length const totalWithOcr = totalTextLen + ocrContextLen - + const overLimit = totalWithOcr > SIZE_LIMIT if (overLimit) { diff --git a/src/stores/settings.js b/src/stores/settings.js new file mode 100644 index 0000000..727609e --- /dev/null +++ b/src/stores/settings.js @@ -0,0 +1,156 @@ +import { defineStore } from 'pinia' +import { ref, watch, computed } from 'vue' +import { translations } from '../utils/i18n' + +export const useSettingsStore = defineStore('settings', () => { + // --- State --- + + // 1. Theme (handled partly by useTheme, but we keep a ref here for the UI) + const theme = ref('system') // 'light' | 'dark' | 'system' + + // 2. Model Behavior + const modelThinking = ref('low') // 'low' | 'medium' | 'high' + const debounceMs = ref(1000) // 1000 - 5000 + + // 3. Privacy + const privacyMode = ref(false) + + // 4. Preferences + const language = ref('auto') + const currency = ref('auto') + // const timezone = ref('auto') // removed + + // 5. Background + const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image' + // const backgroundColor = ref('#ffffff') // removed + const backgroundImage = ref('') + const backgroundOpacity = ref(0.2) // 0.05 - 0.50 + + // --- Getters --- + const uiLanguage = computed(() => { + if (language.value !== 'auto') { + return language.value + } + const sysLang = (navigator.language || navigator.userLanguage || 'en').split('-')[0] + const supported = ['zh', 'en', 'ja', 'ko', 'de', 'fr'] + return supported.includes(sysLang) ? sysLang : 'en' + }) + + const detectedTimezone = computed(() => { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' + }) + + // We can't easily detect currency by IP on the frontend without an external API. + // We will let the backend handle 'auto' currency if needed, or stick to auto label. + + const t = computed(() => { + return translations[uiLanguage.value] || translations['en'] + }) + + // --- Actions/Logic --- + + // Load from localStorage + const loadSettings = () => { + try { + const stored = localStorage.getItem('llm-in-text-settings') + if (stored) { + const data = JSON.parse(stored) + if (data.theme) theme.value = data.theme + if (data.modelThinking) modelThinking.value = data.modelThinking + if (data.debounceMs) debounceMs.value = data.debounceMs + if (typeof data.privacyMode === 'boolean') privacyMode.value = data.privacyMode + if (data.language) language.value = data.language + if (data.currency) currency.value = data.currency + // if (data.timezone) timezone.value = data.timezone // timezone legacy ignore + if (data.backgroundType) { + // migrate color to default if needed, or mapped + if (data.backgroundType === 'color') backgroundType.value = 'default' + else backgroundType.value = data.backgroundType + } + // if (data.backgroundColor) backgroundColor.value = data.backgroundColor // removed + if (data.backgroundImage) backgroundImage.value = data.backgroundImage + if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity + } + } catch (e) { + console.error('Failed to load settings', e) + } + } + + // Save to localStorage + const saveSettings = () => { + try { + const data = { + theme: theme.value, + modelThinking: modelThinking.value, + debounceMs: debounceMs.value, + privacyMode: privacyMode.value, + language: language.value, + currency: currency.value, + // timezone: timezone.value, // removed + backgroundType: backgroundType.value, + // backgroundColor: backgroundColor.value, // removed + backgroundImage: backgroundImage.value, + backgroundOpacity: backgroundOpacity.value, + } + localStorage.setItem('llm-in-text-settings', JSON.stringify(data)) + } catch (e) { + console.error('Failed to save settings', e) + } + } + + // Reset to defaults + const resetSettings = () => { + theme.value = 'system' + modelThinking.value = 'low' + debounceMs.value = 1000 + privacyMode.value = false + language.value = 'auto' + currency.value = 'auto' + // timezone.value = 'auto' // removed + backgroundType.value = 'default' + // backgroundColor.value = '#ffffff' // removed + backgroundImage.value = '' + backgroundOpacity.value = 0.2 + saveSettings() + } + + // Auto-save watchers + watch( + [ + theme, + modelThinking, + debounceMs, + privacyMode, + language, + currency, + // timezone, // removed + backgroundType, + // backgroundColor, // removed + backgroundImage, + backgroundOpacity, + ], + () => { + saveSettings() + } + ) + + // Initialize + loadSettings() + + return { + theme, + modelThinking, + debounceMs, + privacyMode, + language, + currency, + // timezone, // removed + backgroundType, + // backgroundColor, // removed + backgroundImage, + backgroundOpacity, + uiLanguage, + t, + resetSettings + } +}) diff --git a/src/style.css b/src/style.css index 196f292..90864c1 100644 --- a/src/style.css +++ b/src/style.css @@ -13,14 +13,14 @@ color-scheme: light; --app-bg: #f4f6fb; --app-text: #1f2937; - --panel-bg: #ffffff; + --panel-bg: rgba(255, 255, 255, 0.5); --panel-border: #d7deea; --panel-shadow: 0 8px 24px rgba(16, 24, 40, 0.12); - --btn-bg: #ffffff; + --btn-bg: rgba(255, 255, 255, 0.5); --btn-fg: #5b6470; --btn-hover-bg: #4a90d9; --btn-hover-fg: #ffffff; - --btn-disabled-bg: #cfd5df; + --btn-disabled-bg: rgba(207, 213, 223, 0.5); --btn-disabled-fg: #8a92a0; --overlay-bg: rgba(15, 23, 42, 0.3); --tooltip-bg: #111827; @@ -30,8 +30,8 @@ --scrollbar-thumb: #d4dae4; --scrollbar-thumb-hover: #bbc4d2; --focus-ring: #3b82f6; - --toggle-bg-start: #fff8dd; - --toggle-bg-end: #f2f4ff; + --toggle-bg-start: rgba(255, 248, 221, 0.5); + --toggle-bg-end: rgba(242, 244, 255, 0.5); --toggle-thumb-bg: #ffffff; --toggle-sun: #f59e0b; --toggle-moon: #475569; @@ -61,14 +61,14 @@ color-scheme: dark; --app-bg: #0f1117; --app-text: #e5e7eb; - --panel-bg: #1a1e27; + --panel-bg: rgba(26, 30, 39, 0.5); --panel-border: #2f3644; --panel-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); - --btn-bg: #222834; + --btn-bg: rgba(34, 40, 52, 0.5); --btn-fg: #d2d8e4; --btn-hover-bg: #6ea8ff; --btn-hover-fg: #0d1117; - --btn-disabled-bg: #2e3441; + --btn-disabled-bg: rgba(46, 52, 65, 0.5); --btn-disabled-fg: #7a8498; --overlay-bg: rgba(2, 6, 23, 0.65); --tooltip-bg: #f8fafc; @@ -78,8 +78,8 @@ --scrollbar-thumb: #40485a; --scrollbar-thumb-hover: #5f6980; --focus-ring: #60a5fa; - --toggle-bg-start: #2d3140; - --toggle-bg-end: #1f2430; + --toggle-bg-start: rgba(45, 49, 64, 0.5); + --toggle-bg-end: rgba(31, 36, 48, 0.5); --toggle-thumb-bg: #dbe3f2; --toggle-sun: #fbbf24; --toggle-moon: #e2e8f0; @@ -106,10 +106,10 @@ } :root[data-theme='light'] .milkdown { - --crepe-color-background: #ffffff; + --crepe-color-background: transparent; --crepe-color-on-background: #000000; - --crepe-color-surface: #f7f7f7; - --crepe-color-surface-low: #ededed; + --crepe-color-surface: rgba(247, 247, 247, 0.5); + --crepe-color-surface-low: rgba(237, 237, 237, 0.5); --crepe-color-on-surface: #1c1c1c; --crepe-color-on-surface-variant: #4d4d4d; --crepe-color-outline: #a8a8a8; @@ -126,10 +126,10 @@ } :root[data-theme='dark'] .milkdown { - --crepe-color-background: #1a1a1a; + --crepe-color-background: transparent; --crepe-color-on-background: #e6e6e6; - --crepe-color-surface: #121212; - --crepe-color-surface-low: #1c1c1c; + --crepe-color-surface: rgba(18, 18, 18, 0.5); + --crepe-color-surface-low: rgba(28, 28, 28, 0.5); --crepe-color-on-surface: #d1d1d1; --crepe-color-on-surface-variant: #a9a9a9; --crepe-color-outline: #757575; diff --git a/src/utils/api.js b/src/utils/api.js index 29712c6..ecf62c6 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -16,16 +16,36 @@ async function getClientIP() { } } +import { useSettingsStore } from '../stores/settings' + export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) { try { + const settings = useSettingsStore() const clientIP = await getClientIP() const headers = { 'Content-Type': 'application/json' } - if (clientIP) headers['X-Client-IP'] = clientIP - + + // Only send IP if privacy mode is OFF + if (clientIP && !settings.privacyMode) { + headers['X-Client-IP'] = clientIP + } + + const body = { + prefix, + suffix, + languageId: 'markdown', + model_thinking: settings.modelThinking, + privacy_mode: settings.privacyMode, + user_preferences: { + language: settings.language, + currency: settings.currency, + timezone: settings.detectedTimezone + } + } + const res = await fetch(apiUrl, { method: 'POST', headers, - body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }), + body: JSON.stringify(body), signal }) @@ -45,10 +65,10 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) const { done, value } = await reader.read() if (done) break buffer += new TextDecoder().decode(value) - + const lines = buffer.split('\n') buffer = lines.pop() || '' - + for (const line of lines) { if (!line.startsWith('data: ')) continue const jsonStr = line.slice(6).trim() @@ -64,7 +84,7 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) } } } - + return text } catch (e) { if (e.name === 'AbortError') { diff --git a/src/utils/config.js b/src/utils/config.js index cc817e5..0e7612a 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -1,3 +1,6 @@ -export const DEBUG = import.meta.env.DEV -export const API_URL = import.meta.env.VITE_API_URL || '/v1/completions' -export const OCR_URL = import.meta.env.VITE_OCR_URL || '/v1/ocr' +export const DEBUG = import.meta.env.DEV + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://149.104.29.239:8001' + +export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions` +export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr` diff --git a/src/utils/i18n.js b/src/utils/i18n.js new file mode 100644 index 0000000..f350efe --- /dev/null +++ b/src/utils/i18n.js @@ -0,0 +1,248 @@ +export const translations = { + en: { + settings: 'Settings', + close: 'Close', + appearance: 'Appearance', + theme: 'Theme', + light: 'Light', + dark: 'Dark', + system: 'System', + background: 'Background', + default: 'Default', + warm: 'Warm', + reading: 'Reading Light', + image: 'Custom Image', + opacity: 'Opacity', + modelIntelligence: 'Model Intelligence', + thinkingLevel: 'Thinking Level', + low: 'Low', + medium: 'Medium', + high: 'High', + lowDesc: 'Direct completion (Fastest)', + mediumDesc: 'Brief analysis before suggesting', + highDesc: 'Deep, step-by-step analysis (Slowest)', + debounceTime: 'Debounce Time', + privacyPreferences: 'Privacy & Preferences', + privacyMode: 'Privacy Mode', + privacyDesc: 'Prevent sending IP and preferences to the AI', + language: 'Language', + auto: 'Auto Detect', + currency: 'Currency', + about: 'About Us', + importMd: 'Import Markdown', + exportMd: 'Export Markdown', + uploadImg: 'Upload Image', + enableAI: 'Enable AI', + disableAI: 'Disable AI', + insertUrl: 'Insert Image from URL', + insert: 'Insert', + cancel: 'Cancel', + imgTooLarge: 'Image too large', + docTooLarge: 'Document too large, AI disabled' + }, + zh: { + settings: '设置', + close: '关闭', + appearance: '外观', + theme: '主题', + light: '浅色', + dark: '深色', + system: '跟随系统', + background: '背景', + default: '默认', + warm: '暖色调', + reading: '读书灯', + image: '自定义图片', + opacity: '透明度', + modelIntelligence: '模型智能', + thinkingLevel: '思考程度', + low: '低', + medium: '中', + high: '高', + lowDesc: '直接补全(最快)', + mediumDesc: '简要分析上下文后建议', + highDesc: '深度逐步分析(最慢但质量最高)', + debounceTime: '防抖时间', + privacyPreferences: '隐私与偏好', + privacyMode: '隐私模式', + privacyDesc: '不向 AI 发送 IP 地址和偏好设置', + language: '语言', + auto: '自动检测', + currency: '货币', + about: '关于我们', + importMd: '导入 Markdown', + exportMd: '导出 Markdown', + uploadImg: '上传图片', + enableAI: '启用 AI', + disableAI: '禁用 AI', + insertUrl: '通过 URL 插入图片', + insert: '插入', + cancel: '取消', + imgTooLarge: '图片过大', + docTooLarge: '文档过大,AI已禁用' + }, + ja: { + settings: '設定', + close: '閉じる', + appearance: '外観', + theme: 'テーマ', + light: 'ライト', + dark: 'ダーク', + system: 'システム', + background: '背景', + default: 'デフォルト', + warm: '暖色', + reading: '読書灯', + image: 'カスタム画像', + opacity: '不透明度', + modelIntelligence: 'モデル知能', + thinkingLevel: '思考レベル', + low: '低', + medium: '中', + high: '高', + lowDesc: '直接補完(最速)', + mediumDesc: '提案前に文脈を簡単に分析', + highDesc: '深く段階的に分析(遅いが最高品質)', + debounceTime: 'デバウンス時間', + privacyPreferences: 'プライバシーと設定', + privacyMode: 'プライバシーモード', + privacyDesc: 'AIにIPアドレスと設定を送信しない', + language: '言語', + auto: '自動検出', + currency: '通貨', + about: '私たちについて', + importMd: 'Markdownをインポート', + exportMd: 'Markdownをエクスポート', + uploadImg: '画像をアップロード', + enableAI: 'AIを有効化', + disableAI: 'AIを無効化', + insertUrl: 'URLから画像を挿入', + insert: '挿入', + cancel: 'キャンセル', + imgTooLarge: '画像が大きすぎます', + docTooLarge: 'ドキュメントが大きすぎます、AI無効' + }, + ko: { + settings: '설정', + close: '닫기', + appearance: '외관', + theme: '테마', + light: '라이트', + dark: '다크', + system: '시스템', + background: '배경', + default: '기본', + warm: '따뜻한 색', + reading: '독서등', + image: '사용자 지정 이미지', + opacity: '불투명도', + modelIntelligence: '모델 지능', + thinkingLevel: '사고 수준', + low: '낮음', + medium: '중간', + high: '높음', + lowDesc: '직접 완성 (가장 빠름)', + mediumDesc: '제안 전 문맥 간단 분석', + highDesc: '심층 단계별 분석 (가장 느리지만 최고 품질)', + debounceTime: '디바운스 시간', + privacyPreferences: '개인정보 및 환경설정', + privacyMode: '개인정보 모드', + privacyDesc: 'AI에 IP 주소 및 설정 전송 안 함', + language: '언어', + auto: '자동 감지', + currency: '통화', + about: '회사 소개', + importMd: 'Markdown 가져오기', + exportMd: 'Markdown 내보내기', + uploadImg: '이미지 업로드', + enableAI: 'AI 활성화', + disableAI: 'AI 비활성화', + insertUrl: 'URL로 이미지 삽입', + insert: '삽입', + cancel: '취소', + imgTooLarge: '이미지가 너무 큽니다', + docTooLarge: '문서가 너무 큽니다, AI 비활성화됨' + }, + de: { + settings: 'Einstellungen', + close: 'Schließen', + appearance: 'Aussehen', + theme: 'Thema', + light: 'Hell', + dark: 'Dunkel', + system: 'System', + background: 'Hintergrund', + default: 'Standard', + warm: 'Warm', + reading: 'Leselicht', + image: 'Eigenes Bild', + opacity: 'Deckkraft', + modelIntelligence: 'Modell-Intelligenz', + thinkingLevel: 'Denkniveau', + low: 'Niedrig', + medium: 'Mittel', + high: 'Hoch', + lowDesc: 'Direkte Vervollständigung (Am schnellsten)', + mediumDesc: 'Kurze Analyse vor Vorschlag', + highDesc: 'Tiefe schrittweise Analyse (Langsam, aber höchste Qualität)', + debounceTime: 'Entprellzeit', + privacyPreferences: 'Datenschutz & Einstellungen', + privacyMode: 'Datenschutzmodus', + privacyDesc: 'Sende keine IP und Einstellungen an KI', + language: 'Sprache', + auto: 'Automatisch', + currency: 'Währung', + about: 'Über uns', + importMd: 'Markdown importieren', + exportMd: 'Markdown exportieren', + uploadImg: 'Bild hochladen', + enableAI: 'KI aktivieren', + disableAI: 'KI deaktivieren', + insertUrl: 'Bild per URL einfügen', + insert: 'Einfügen', + cancel: 'Abbrechen', + imgTooLarge: 'Bild zu groß', + docTooLarge: 'Dokument zu groß, KI deaktiviert' + }, + fr: { + settings: 'Paramètres', + close: 'Fermer', + appearance: 'Apparence', + theme: 'Thème', + light: 'Clair', + dark: 'Sombre', + system: 'Système', + background: 'Arrière-plan', + default: 'Défaut', + warm: 'Chaud', + reading: 'Lampe de lecture', + image: 'Image personnalisée', + opacity: 'Opacité', + modelIntelligence: 'Intelligence du modèle', + thinkingLevel: 'Niveau de réflexion', + low: 'Bas', + medium: 'Moyen', + high: 'Haut', + lowDesc: 'Complétion directe (Le plus rapide)', + mediumDesc: 'Analyse brève avant suggestion', + highDesc: 'Analyse approfondie étape par étape (Le plus lent)', + debounceTime: 'Temps de rebond', + privacyPreferences: 'Confidentialité et préférences', + privacyMode: 'Mode confidentialité', + privacyDesc: 'Ne pas envoyer IP et préférences à l\'IA', + language: 'Langue', + auto: 'Détection auto', + currency: 'Devise', + about: 'À propos de nous', + importMd: 'Importer Markdown', + exportMd: 'Exporter Markdown', + uploadImg: 'Télécharger image', + enableAI: 'Activer IA', + disableAI: 'Désactiver IA', + insertUrl: 'Insérer image via URL', + insert: 'Insérer', + cancel: 'Annuler', + imgTooLarge: 'Image trop grande', + docTooLarge: 'Document trop grand, IA désactivée' + } +}