637456ee34
Add support for cancelling in-progress LLM completion requests via new /v1/completions/cancel endpoint with task tracking. Implement mermaid diagram rendering in the Milkdown editor with a new mermaidPlugin. Update copilotPlugin to properly abort requests with descriptive reasons. Refactor settings panel to handle system theme changes reactively. Add camera capture support for image uploads.
566 lines
14 KiB
Vue
566 lines
14 KiB
Vue
<script setup>
|
|
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
|
import { useSettingsStore } from '../stores/settings'
|
|
import { useTheme } from '../composables/useTheme'
|
|
|
|
const store = useSettingsStore()
|
|
const { setTheme } = useTheme()
|
|
|
|
const isOpen = ref(false)
|
|
let systemThemeMediaQuery = null
|
|
|
|
const togglePanel = () => {
|
|
isOpen.value = !isOpen.value
|
|
}
|
|
|
|
const closePanel = () => {
|
|
isOpen.value = false
|
|
}
|
|
|
|
const applyThemeByPreference = () => {
|
|
if (store.theme === 'system') {
|
|
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
setTheme(isDark ? 'dark' : 'light')
|
|
return
|
|
}
|
|
setTheme('light')
|
|
return
|
|
}
|
|
|
|
setTheme(store.theme)
|
|
}
|
|
|
|
watch(
|
|
() => store.theme,
|
|
() => {
|
|
applyThemeByPreference()
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
const handleSystemThemeChange = (event) => {
|
|
if (store.theme !== 'system') return
|
|
setTheme(event.matches ? 'dark' : 'light')
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
|
|
|
systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
|
|
|
if (typeof systemThemeMediaQuery.addEventListener === 'function') {
|
|
systemThemeMediaQuery.addEventListener('change', handleSystemThemeChange)
|
|
} else if (typeof systemThemeMediaQuery.addListener === 'function') {
|
|
systemThemeMediaQuery.addListener(handleSystemThemeChange)
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (!systemThemeMediaQuery) return
|
|
|
|
if (typeof systemThemeMediaQuery.removeEventListener === 'function') {
|
|
systemThemeMediaQuery.removeEventListener('change', handleSystemThemeChange)
|
|
} else if (typeof systemThemeMediaQuery.removeListener === 'function') {
|
|
systemThemeMediaQuery.removeListener(handleSystemThemeChange)
|
|
}
|
|
})
|
|
|
|
const appearanceMode = computed({
|
|
get() {
|
|
if (store.backgroundType === 'warm') return 'warm'
|
|
if (store.backgroundType === 'reading') return 'reading'
|
|
if (store.backgroundType === 'image') return 'image'
|
|
if (store.theme === 'dark') return 'dark'
|
|
if (store.theme === 'light') return 'light'
|
|
return 'system'
|
|
},
|
|
set(mode) {
|
|
if (mode === 'dark') {
|
|
store.theme = 'dark'
|
|
store.backgroundType = 'default'
|
|
return
|
|
}
|
|
|
|
if (mode === 'light') {
|
|
store.theme = 'light'
|
|
store.backgroundType = 'default'
|
|
return
|
|
}
|
|
|
|
if (mode === 'system') {
|
|
store.theme = 'system'
|
|
store.backgroundType = 'default'
|
|
return
|
|
}
|
|
|
|
if (mode === 'warm') {
|
|
store.theme = 'light'
|
|
store.backgroundType = 'warm'
|
|
return
|
|
}
|
|
|
|
if (mode === 'reading') {
|
|
store.theme = 'light'
|
|
store.backgroundType = 'reading'
|
|
return
|
|
}
|
|
|
|
if (mode === 'image') {
|
|
store.theme = 'light'
|
|
store.backgroundType = 'image'
|
|
}
|
|
}
|
|
})
|
|
|
|
// 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]
|
|
</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('appearance') }}</label>
|
|
<select v-model="appearanceMode" class="select-input">
|
|
<option value="dark">{{ t('dark') }}</option>
|
|
<option value="light">{{ t('light') }}</option>
|
|
<option value="system">{{ t('system') }}</option>
|
|
<option value="warm">{{ t('warm') }}</option>
|
|
<option value="reading">{{ t('reading') }}</option>
|
|
<option value="image">{{ t('image') }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div v-if="appearanceMode === '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>
|
|
|