feat: add privacy mode, thinking levels, PWA support, and i18n

- Add privacy mode to hide IP and user preferences from AI requests
- Add model thinking levels (low/medium/high) for context analysis depth
- Add PWA support with service worker, manifest, and app icons
- Add SettingsPanel for user preferences (theme, background, language)
- Add i18n translations for en/zh/ja/ko/de/fr
- Add Pinia store for centralized settings management
- Update backend to support user preferences and thinking levels
- Update config to use absolute API URLs
This commit is contained in:
2026-02-19 10:22:27 +08:00
parent d2b64ad5d6
commit aa6133e3ed
24 changed files with 1291 additions and 222 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) <OCR:description>.

View File

@@ -2,7 +2,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="LLM in Text" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>llm-in-text</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">

BIN
public/icons/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

14
public/icons/icon-192.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f172a" />
<stop offset="100%" stop-color="#1d4ed8" />
</linearGradient>
</defs>
<rect width="192" height="192" rx="42" fill="url(#bg)" />
<rect x="42" y="44" width="108" height="104" rx="14" fill="#f8fafc" />
<rect x="57" y="66" width="78" height="8" rx="4" fill="#0f172a" />
<rect x="57" y="86" width="64" height="8" rx="4" fill="#334155" />
<rect x="57" y="106" width="48" height="8" rx="4" fill="#64748b" />
<rect x="57" y="126" width="60" height="8" rx="4" fill="#94a3b8" />
</svg>

After

Width:  |  Height:  |  Size: 702 B

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

14
public/icons/icon-512.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f172a" />
<stop offset="100%" stop-color="#1d4ed8" />
</linearGradient>
</defs>
<rect width="512" height="512" rx="110" fill="url(#bg)" />
<rect x="112" y="116" width="288" height="280" rx="36" fill="#f8fafc" />
<rect x="152" y="174" width="208" height="20" rx="10" fill="#0f172a" />
<rect x="152" y="224" width="172" height="20" rx="10" fill="#334155" />
<rect x="152" y="274" width="128" height="20" rx="10" fill="#64748b" />
<rect x="152" y="324" width="160" height="20" rx="10" fill="#94a3b8" />
</svg>

After

Width:  |  Height:  |  Size: 723 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f172a" />
<stop offset="100%" stop-color="#1d4ed8" />
</linearGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)" />
<rect x="126" y="132" width="260" height="248" rx="32" fill="#f8fafc" />
<rect x="162" y="184" width="188" height="18" rx="9" fill="#0f172a" />
<rect x="162" y="228" width="156" height="18" rx="9" fill="#334155" />
<rect x="162" y="272" width="116" height="18" rx="9" fill="#64748b" />
<rect x="162" y="316" width="144" height="18" rx="9" fill="#94a3b8" />
</svg>

After

Width:  |  Height:  |  Size: 710 B

View File

@@ -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": "/"
}
]
}

79
public/sw.js Normal file
View File

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

View File

@@ -1,54 +1,63 @@
<script setup>
import { computed, defineAsyncComponent, ref } from 'vue'
import { useTheme } from './composables/useTheme'
import { useSettingsStore } from './stores/settings'
import SettingsPanel from './components/SettingsPanel.vue'
const MilkdownEditor = defineAsyncComponent(() => import('./components/MilkdownEditor.vue'))
const markdown = ref('')
const emit = defineEmits(['update:markdown'])
const { isDark, toggleTheme } = useTheme()
const themeToggleLabel = computed(() => (isDark.value ? '切换到浅色模式' : '切换到深色模式'))
const settings = useSettingsStore()
function onChange(markdownValue) {
markdown.value = markdownValue
emit('update:markdown', markdownValue)
}
const appStyle = computed(() => {
const style = {}
if (settings.backgroundType === 'warm') {
style.background = '#fdfaf3ff' // Very light warm yellow
style.color = '#5d4037'
} else if (settings.backgroundType === 'reading') {
style.background = '#f5f0e1' // Slightly deeper warm paper color
style.color = '#333333'
} else if (settings.backgroundType === 'default') {
style.background = 'var(--app-bg)'
style.color = 'var(--app-text)'
} else if (settings.backgroundType === 'image') {
// For image background, we might want a solid base color underlying it
style.background = settings.theme === 'dark' ? '#000000' : '#ffffff'
}
return style
})
const backgroundStyle = computed(() => {
if (settings.backgroundType === 'image' && settings.backgroundImage) {
return {
backgroundImage: `url(${settings.backgroundImage})`,
opacity: settings.backgroundOpacity,
backgroundSize: 'cover',
backgroundPosition: 'center',
position: 'absolute',
inset: 0,
zIndex: 0,
pointerEvents: 'none'
}
}
return {}
})
</script>
<template>
<div class="app-shell">
<button
type="button"
class="theme-toggle"
:class="{ 'is-dark': isDark }"
:aria-label="themeToggleLabel"
:title="themeToggleLabel"
@click="toggleTheme"
>
<span class="theme-toggle__track">
<span class="theme-toggle__sun" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
</span>
<span class="theme-toggle__moon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3c0 .26-.01.51-.01.77a7 7 0 0 0 9.03 6.7c.26-.08.52-.16.77-.24z" />
</svg>
</span>
<span class="theme-toggle__thumb" aria-hidden="true"></span>
</span>
</button>
<MilkdownEditor @update:markdown="onChange" />
<div class="app-shell" :style="appStyle">
<div v-if="settings.backgroundType === 'image'" class="app-bg-layer" :style="backgroundStyle"></div>
<div class="editor-container">
<MilkdownEditor @update:markdown="onChange" />
</div>
<SettingsPanel />
</div>
</template>
@@ -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;
}
}
</style>

View File

@@ -6,8 +6,8 @@
<button
type="button"
class="action-btn"
aria-label="导入 Markdown 文件"
title="导入 Markdown"
:aria-label="t('importMd')"
:title="t('importMd')"
@click="triggerUpload"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -15,15 +15,15 @@
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span class="btn-tooltip">导入 Markdown</span>
<span class="btn-tooltip">{{ t('importMd') }}</span>
</button>
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md" style="display:none">
<button
type="button"
class="action-btn"
aria-label="导出 Markdown 文件"
title="导出 Markdown"
:aria-label="t('exportMd')"
:title="t('exportMd')"
@click="exportMarkdown"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -31,15 +31,15 @@
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span class="btn-tooltip">导出 Markdown</span>
<span class="btn-tooltip">{{ t('exportMd') }}</span>
</button>
<div class="image-btn-wrapper">
<button
type="button"
class="action-btn"
aria-label="上传图片"
title="上传图片"
:aria-label="t('uploadImg')"
:title="t('uploadImg')"
@click="toggleImageDropdown"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -47,11 +47,11 @@
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<span class="btn-tooltip">上传图片</span>
<span class="btn-tooltip">{{ t('uploadImg') }}</span>
</button>
<div v-if="showImageDropdown" class="image-dropdown">
<button type="button" @click="triggerImageUpload">上传本地图片</button>
<button type="button" @click="showUrlDialog = true; showImageDropdown = false">通过 URL 插入</button>
<button type="button" @click="triggerImageUpload">{{ t('uploadImg') }}</button>
<button type="button" @click="showUrlDialog = true; showImageDropdown = false">{{ t('insertUrl') }}</button>
</div>
</div>
<input type="file" ref="imageInputRef" @change="handleImageUpload" accept="image/*" style="display:none">
@@ -84,16 +84,16 @@
<div v-if="showUrlDialog" class="url-dialog-overlay" @click.self="showUrlDialog = false">
<div class="url-dialog">
<h3>通过 URL 插入图片</h3>
<h3>{{ t('insertUrl') }}</h3>
<input
v-model="imageUrl"
type="url"
placeholder="请输入图片 URL"
placeholder="https://..."
@keyup.enter="insertImageFromUrl"
/>
<div class="url-dialog-buttons">
<button type="button" class="dialog-btn primary" @click="insertImageFromUrl">插入</button>
<button type="button" class="dialog-btn" @click="showUrlDialog = false; imageUrl = ''">取消</button>
<button type="button" class="dialog-btn primary" @click="insertImageFromUrl">{{ t('insert') }}</button>
<button type="button" class="dialog-btn" @click="showUrlDialog = false; imageUrl = ''">{{ t('cancel') }}</button>
</div>
</div>
</div>
@@ -101,17 +101,20 @@
</template>
<script setup>
import { onMounted, onUnmounted, ref, computed } from 'vue'
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
import { replaceAll } from '@milkdown/kit/utils'
import { Crepe } from '@milkdown/crepe'
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { Selection } from '@milkdown/prose/state'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { fetchSuggestion } from '../utils/api.js'
import { useSettingsStore } from '../stores/settings'
import { OCR_URL } from '../utils/config.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
const emit = defineEmits(['update:markdown'])
const settings = useSettingsStore()
const t = (key) => settings.t[key]
const root = ref(null)
const fileInputRef = ref(null)
@@ -124,8 +127,8 @@ const imageUrl = ref('')
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const aiButtonLabel = computed(() => {
if (isOverLimit.value) return '文档过大AI已禁用'
return aiEnabled.value ? '禁用 AI' : '启用 AI'
if (isOverLimit.value) return t('docTooLarge')
return aiEnabled.value ? t('disableAI') : t('enableAI')
})
let crepe = null
@@ -302,10 +305,24 @@ onMounted(async () => {
}
})
const settings = useSettingsStore()
crepe.editor.config((ctx) => {
ctx.set(copilotConfigCtx.key, {
fetchSuggestion,
debounceMs: 1000
debounceMs: settings.debounceMs
})
})
// Watch for debounce changes
watch(() => settings.debounceMs, (newVal) => {
if (!crepe) return
crepe.editor.action((ctx) => {
const config = ctx.get(copilotConfigCtx.key)
ctx.set(copilotConfigCtx.key, {
...config,
debounceMs: newVal
})
})
})
@@ -691,7 +708,7 @@ onUnmounted(() => {
.milkdown-editor {
width: 100%;
height: 100%;
background-color: var(--crepe-color-background);
background-color: transparent !important;
overflow-y: auto;
}

View File

@@ -0,0 +1,490 @@
<script setup>
import { ref, watch, computed } from 'vue'
import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme'
const store = useSettingsStore()
const { setTheme } = useTheme()
const isOpen = ref(false)
const togglePanel = () => {
isOpen.value = !isOpen.value
}
const closePanel = () => {
isOpen.value = false
}
// Theme Handling
watch(() => store.theme, (newVal) => {
if (newVal === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setTheme(isDark ? 'dark' : 'light')
} else {
setTheme(newVal)
}
}, { immediate: true })
// Background Image Handling
const handleImageUpload = (event) => {
const file = event.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
store.backgroundImage = e.target.result
store.backgroundType = 'image'
}
reader.readAsDataURL(file)
}
}
// Helper to translate
const t = (key) => store.t[key]
// Background Style for App (This will be used in App.vue, but we preview it here or just logical check)
// UI Helpers
const tabs = ['General', 'Model', 'Appearance', 'About']
const currentTab = ref('General')
</script>
<template>
<div>
<!-- Trigger Button -->
<button
class="settings-trigger"
@click="togglePanel"
:title="t('settings')"
>
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
<!-- Overlay (Mobile) -->
<div
class="settings-overlay"
:class="{ 'is-open': isOpen }"
@click="closePanel"
></div>
<!-- Panel -->
<div class="settings-panel" :class="{ 'is-open': isOpen }">
<div class="panel-header">
<h2>{{ t('settings') }}</h2>
<button class="close-btn" @click="closePanel" :title="t('close')">
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="panel-content">
<!-- Appearance Section -->
<section class="settings-section">
<h3>{{ t('appearance') }}</h3>
<div class="form-group">
<label>{{ t('theme') }}</label>
<div class="segment-control">
<button :class="{ active: store.theme === 'light' }" @click="store.theme = 'light'">{{ t('light') }}</button>
<button :class="{ active: store.theme === 'dark' }" @click="store.theme = 'dark'">{{ t('dark') }}</button>
<button :class="{ active: store.theme === 'system' }" @click="store.theme = 'system'">{{ t('system') }}</button>
</div>
</div>
<div class="form-group">
<label>{{ t('background') }}</label>
<select v-model="store.backgroundType" class="select-input">
<option value="default">{{ t('default') }}</option>
<option value="warm">{{ t('warm') }}</option>
<option value="reading">{{ t('reading') }}</option>
<option value="image">{{ t('image') }}</option>
</select>
</div>
<div v-if="store.backgroundType === 'image'" class="form-group">
<label>{{ t('image') }}</label>
<input type="file" accept="image/*" @change="handleImageUpload" class="file-input" />
<label class="sub-label">{{ t('opacity') }}: {{ Math.round(store.backgroundOpacity * 100) }}%</label>
<input
type="range"
min="0.05"
max="0.5"
step="0.05"
v-model.number="store.backgroundOpacity"
class="range-slider"
/>
</div>
</section>
<!-- Model Section -->
<section class="settings-section">
<h3>{{ t('modelIntelligence') }}</h3>
<div class="form-group">
<label>{{ t('thinkingLevel') }}</label>
<div class="segment-control">
<button :class="{ active: store.modelThinking === 'low' }" @click="store.modelThinking = 'low'">{{ t('low') }}</button>
<button :class="{ active: store.modelThinking === 'medium' }" @click="store.modelThinking = 'medium'">{{ t('medium') }}</button>
<button :class="{ active: store.modelThinking === 'high' }" @click="store.modelThinking = 'high'">{{ t('high') }}</button>
</div>
<p class="help-text">{{ store.modelThinking === 'low' ? t('lowDesc') : store.modelThinking === 'medium' ? t('mediumDesc') : t('highDesc') }}</p>
</div>
<div class="form-group">
<label>{{ t('debounceTime') }}: {{ store.debounceMs }}ms</label>
<input
type="range"
min="1000"
max="5000"
step="500"
v-model.number="store.debounceMs"
class="range-slider"
/>
</div>
</section>
<!-- Privacy & Preferences -->
<section class="settings-section">
<h3>{{ t('privacyPreferences') }}</h3>
<div class="form-group toggle-group">
<label>{{ t('privacyMode') }}</label>
<button
class="toggle-switch"
:class="{ active: store.privacyMode }"
@click="store.privacyMode = !store.privacyMode"
>
<span class="toggle-thumb"></span>
</button>
</div>
<p class="help-text">{{ t('privacyDesc') }}</p>
<div class="form-group">
<label>{{ t('language') }}</label>
<select v-model="store.language" class="select-input" :disabled="store.privacyMode">
<option value="auto">{{ t('auto') }}</option>
<option value="zh">Chinese</option>
<option value="en">English</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
<option value="de">German</option>
<option value="fr">French</option>
</select>
</div>
<div class="form-group">
<label>{{ t('currency') }}</label>
<select v-model="store.currency" class="select-input" :disabled="store.privacyMode">
<option value="auto">{{ t('auto') }}</option>
<option value="CNY">CNY (¥)</option>
<option value="USD">USD ($)</option>
<option value="EUR">EUR ()</option>
<option value="JPY">JPY (¥)</option>
<option value="KRW">KRW ()</option>
<option value="GBP">GBP (£)</option>
<option value="AUD">AUD ($)</option>
<option value="CAD">CAD ($)</option>
</select>
</div>
</section>
<!-- About -->
<section class="settings-section">
<h3>{{ t('about') }}</h3>
<div class="about-card">
<h4>llm-in-text</h4>
<p>A smart Markdown editor with local LLM intelligence.</p>
<p class="version">v0.1.0-beta</p>
</div>
</section>
</div>
</div>
</div>
</template>
<style scoped>
.settings-trigger {
position: fixed;
bottom: 20px;
left: 20px;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--btn-bg);
color: var(--btn-fg);
border: 1px solid var(--panel-border);
box-shadow: var(--panel-shadow);
cursor: pointer;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.settings-trigger:hover {
background: var(--btn-hover-bg);
color: var(--btn-hover-fg);
transform: scale(1.05);
}
.settings-panel {
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: 350px;
background: var(--panel-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--panel-border);
box-shadow: var(--panel-shadow);
z-index: 10000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
}
.settings-panel.is-open {
transform: translateX(0);
}
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.2);
z-index: 9999;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.settings-overlay.is-open {
opacity: 1;
pointer-events: auto;
}
/* Mobile Fullscreen */
@media (max-width: 640px) {
.settings-panel {
width: 100%;
border-right: none;
}
}
.panel-header {
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--panel-border);
}
.panel-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: var(--muted-text);
cursor: pointer;
padding: 4px;
}
.close-btn:hover {
color: var(--app-text);
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.settings-section {
margin-bottom: 32px;
}
.settings-section h3 {
font-size: 0.875rem;
text-transform: uppercase;
color: var(--muted-text);
margin-bottom: 16px;
letter-spacing: 0.05em;
font-weight: 600;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 0.9rem;
font-weight: 500;
margin-bottom: 8px;
color: var(--app-text);
}
.sub-label {
font-size: 0.8rem;
color: var(--muted-text);
margin-top: 8px;
margin-bottom: 4px;
}
.help-text {
font-size: 0.8rem;
color: var(--muted-text);
margin-top: 6px;
}
/* Segment Control */
.segment-control {
display: flex;
background: var(--ghost-code-bg);
padding: 4px;
border-radius: 8px;
}
.segment-control button {
flex: 1;
background: none;
border: none;
padding: 6px 12px;
font-size: 0.9rem;
cursor: pointer;
border-radius: 6px;
color: var(--muted-text);
transition: all 0.2s;
}
.segment-control button.active {
background: var(--app-bg);
color: var(--app-text);
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
font-weight: 600;
}
/* Range Slider */
.range-slider {
width: 100%;
-webkit-appearance: none;
height: 4px;
background: var(--panel-border);
border-radius: 2px;
outline: none;
margin: 10px 0;
}
.range-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--focus-ring);
cursor: pointer;
transition: transform 0.1s;
}
.range-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
/* Inputs */
.select-input, .file-input {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--panel-border);
background: var(--app-bg);
color: var(--app-text);
font-size: 0.9rem;
outline: none;
}
.select-input:focus {
border-color: var(--focus-ring);
}
.color-picker-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
/* Toggle Switch */
.toggle-group {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-group label {
margin-bottom: 0;
}
.toggle-switch {
width: 44px;
height: 24px;
background: var(--panel-border);
border-radius: 999px;
border: none;
position: relative;
cursor: pointer;
transition: background 0.3s;
}
.toggle-switch.active {
background: var(--focus-ring);
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggle-switch.active .toggle-thumb {
transform: translateX(20px);
}
/* About Card */
.about-card {
background: var(--ghost-code-bg);
padding: 16px;
border-radius: 8px;
text-align: center;
}
.about-card h4 {
margin: 0 0 8px;
}
.about-card p {
margin: 0;
font-size: 0.9rem;
color: var(--muted-text);
}
.about-card .version {
margin-top: 8px;
font-size: 0.8rem;
opacity: 0.7;
}
</style>

View File

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

View File

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

156
src/stores/settings.js Normal file
View File

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

View File

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

View File

@@ -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') {

View File

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

248
src/utils/i18n.js Normal file
View File

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