Files
llm-in-text/src/components/SettingsPanel.vue
T
“ydy0615” 5a26dfde2a refactor: 全栈架构升级 - 风险控制、会话管理、审计日志和验证码功能
后端变更:
- 新增 risk_config.py: 风险配置数据类,支持环境变量驱动
- 新增 risk_control.py: 风险控制控制器,管理并发和预算
- 新增 session_store.py: 匿名会话存储,基于 cookie 的 session ID
- 新增 audit_store.py: API 审计日志存储,记录请求和 LLM 调用
- 新增 captcha_api.py: 验证码 API,用于验证用户操作真实性
- 新增 llm_policy.py: LLM 策略配置,管理 completion/pro/vision 模型
- main.py: 集成 middleware、risk/audit/session 模块 (+467/-7)
- job_handlers.py: LLM 执行流程重构,新增 risk/audit 集成 (+207/-4)
- llm.py: 异步客户端封装,新增 max_output_tokens 参数 (+78/-1)
- job_system.py: stream_events 逻辑优化,支持心跳检测 (+12/-4)
- pro_completions.py: SSE heartbeat 机制,防止连接超时 (+14/-4)
- prompt.py: _normalize_preferences 支持 Mapping 类型 (+13/-0)
- tts_asr.py: asyncio loop 初始化,router export (+10/-0)

前端变更:
- src/components/CaptchaComponent.vue: 新增验证码组件 (NEW)
- src/utils/cookie_policy.js: Cookie 策略工具 (NEW)
- SettingsPanel.vue: 集成验证码组件,新增安全设置部分 (+59/-0)
- MilkdownEditor.vue: 移除硬编码 API_KEY,新增 credentials (+32/-10)
- ProBlockCrepe.vue: 样式简化,移除渐变动画 (+18/-4)
- proBlockPlugin.ts: 重构 schema/serializer 引用方式,通过 Ctx 管理 (+40/-10)
- api.js: 新增 credentials,重构 headers 条件逻辑 (+50/-14)
- config.js: API 基址改为 https://api.imageteach.tech:8002 (+8/-4)
- convert.js, docsApi.js, i18n.js: 新增 credentials 和验证码 i18n (+54/-12)
- proAccept.js: 重构正则和转义处理,修复捕获组索引 (+14/-4)

配置和基础设施:
- docker-compose.yml: 新增端口映射 8001:8001 (+2/-0)
- docker/nginx.conf: 改为 307 redirect,优化代理配置 (+8/-6)
- vite.config.js: 移除 proxy 配置,直接调用远程 API (+8/-4)
- .env.example: 新增 VITE_API_BASE_URL, VITE_API_KEY (+3/-1)
- backend/.env.example: 大量 RISK_*, SESSION_*, CORS_* 配置 (+54/-0)
- pytest.ini: 扩展 coverage 范围到整个 backend,移除 fail_under (+3/-2)
- .coveragerc: 移除 fail_under = 90 (+0/-1)
- .gitignore: 新增 docker-data/ (+3/-0)
- package.json: 新增 vue3-captcha 依赖 (+3/-1)
- AGENTS.md, README.md: 更新 Docker 部署和前端网络约定 (+20/-5)
- public/sw.js: Service Worker cache 版本从 v1 升级到 v2 (+0/-1)

测试变更:
- test_main_endpoints.py: 新增 session/risk/audit reset,新增测试用例 (+63/-4)
- test_main_cancel.py: 新增 reset 调用 (+6/-0)
- test_pro_completions.py: 新增 preferences 序列化和测试 (+23/-0)

总计: 45 个文件变更,+1009/-280 行
2026-06-08 11:51:39 +08:00

732 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme'
import CaptchaComponent from './CaptchaComponent.vue'
import packageJson from '../../package.json'
const store = useSettingsStore()
const { setTheme } = useTheme()
const router = useRouter()
const route = useRoute()
const VERSION = packageJson.version || '0.0.0'
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'
}
}
})
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)
}
}
const t = (key) => store.t[key]
const switchView = (view) => {
router.push(view === 'editor' ? '/' : '/docs')
closePanel()
}
// --- Security & Captcha State ---
const showCaptcha = ref(false) // Show captcha in privacy mode
const userInput = ref('') // User's captcha input
const isValid = ref(false) // Validation success state
const attempted = ref(false) // Whether validation was attempted
const captchaComp = ref(null)
// Watch privacy mode to show/hide captcha
watch(
() => store.privacyMode,
(newVal) => {
showCaptcha.value = newVal // Show captcha when privacy mode is enabled
}
)
// Captcha validation handler
const validateCaptcha = () => {
attempted.value = true
// Call the captcha component's validate method
isValid.value = captchaComp.value?.validate(userInput.value) ?? false
// If validation failed, refresh the captcha
if (!isValid.value) {
captchaComp.value?.refresh()
}
}
</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>
<!-- View Switch -->
<section class="settings-section">
<h3>{{ t('view') || '视图' }}</h3>
<div class="view-switch">
<button
class="view-btn"
:class="{ active: route.path === '/' }"
@click="switchView('editor')"
>
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
<span>{{ t('editor') || '编辑器' }}</span>
</button>
<button
class="view-btn"
:class="{ active: route.path === '/docs' }"
@click="switchView('docs')"
>
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<span>{{ t('docs') || '文档' }}</span>
</button>
</div>
</section>
<section class="settings-section">
<h3>{{ t('proMode') || 'PRO 模式' }}</h3>
<div class="form-group">
<label>{{ t('proThinkingLevel') || t('thinkingLevel') }}</label>
<div class="segment-control">
<button :class="{ active: store.proThinking === 'low' }" @click="store.proThinking = 'low'">{{ t('low') }}</button>
<button :class="{ active: store.proThinking === 'medium' }" @click="store.proThinking = 'medium'">{{ t('medium') }}</button>
<button :class="{ active: store.proThinking === 'high' }" @click="store.proThinking = 'high'">{{ t('high') }}</button>
</div>
<p class="help-text">{{ t('proThinkingDesc') || 'PRO 模式使用独立思考强度,普通补全设置不会影响它。' }}</p>
</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>
<!-- Security & Captcha Section -->
<section class="settings-section" v-if="showCaptcha">
<h3>{{ t('security') || '安全设置' }}</h3>
<div class="captcha-section">
<!-- Captcha Component -->
<CaptchaComponent ref="captchaComp" />
<!-- User Input Field -->
<input
v-model="userInput"
:placeholder="t('captchaInputPlaceholder') || '请输入验证码'"
class="captcha-input"
/>
<!-- Validate Button -->
<button
@click="validateCaptcha"
class="validate-btn"
:disabled="!userInput"
>{{ t('captchaValidate') || '验证' }}</button>
<!-- Validation Result -->
<p v-if="isValid" class="success-text">{{ t('captchaSuccess') || '验证成功' }}</p>
<p v-else-if="attempted && !isValid" class="error-text">{{ t('captchaFailed') || '验证失败请重试' }}</p>
<!-- Help Text -->
<p class="help-text">{{ t('captchaDesc') || '用于验证用户操作真实性' }}</p>
</div>
</section>
<!-- TTS Settings -->
<section class="settings-section">
<h3>{{ t('ttsSettings') || '语音设置' }}</h3>
<div class="form-group">
<label>{{ t('voiceInstruction') || '声音描述' }}</label>
<input
type="text"
v-model="store.ttsInstruct"
class="select-input"
:placeholder="t('voiceInstructionPlaceholder') || '例如:用温柔的语气说'"
/>
<p class="help-text">{{ t('voiceInstructionDesc') || '描述你想要的声音风格,如语气、情感等' }}</p>
</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">v{{ VERSION }}</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);
border-radius: 0 8px 8px 0;
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 {
top: 0;
bottom: 0;
width: 100%;
border-right: none;
border-radius: 0;
}
}
.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;
min-height: 0;
}
.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;
}
.view-switch {
display: flex;
gap: 8px;
}
.view-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--panel-border);
background: var(--ghost-code-bg);
color: var(--muted-text);
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.view-btn:hover {
color: var(--app-text);
border-color: var(--focus-ring);
}
.view-btn.active {
background: var(--app-bg);
color: var(--app-text);
border-color: var(--focus-ring);
font-weight: 600;
}
</style>