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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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.  <OCR:description>.
|
||||
|
||||
@@ -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
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
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
14
public/icons/icon-192.svg
Normal 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
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
14
public/icons/icon-512.svg
Normal 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 |
14
public/icons/icon-maskable.svg
Normal file
14
public/icons/icon-maskable.svg
Normal 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 |
50
public/manifest.webmanifest
Normal file
50
public/manifest.webmanifest
Normal 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
79
public/sw.js
Normal 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));
|
||||
}
|
||||
});
|
||||
205
src/App.vue
205
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
490
src/components/SettingsPanel.vue
Normal file
490
src/components/SettingsPanel.vue
Normal 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>
|
||||
13
src/main.js
13
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
156
src/stores/settings.js
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
248
src/utils/i18n.js
Normal 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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user