agent-Specialization/static/src/components/personalization/PersonalizationDrawer.vue

1898 lines
81 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.

<template>
<transition name="personal-page-fade">
<div
class="personal-page-overlay"
v-if="visible"
@mousedown.self="personalization.handleOverlayPressStart($event)"
@mouseup.self="personalization.handleOverlayPressEnd"
@mouseleave.self="personalization.handleOverlayPressCancel"
@touchstart.self.prevent="personalization.handleOverlayPressStart($event)"
@touchend.self.prevent="personalization.handleOverlayPressEnd"
@touchcancel.self="personalization.handleOverlayPressCancel"
>
<div class="personal-page-card" data-tutorial="personal-card">
<div class="personal-page-header">
<div>
<h2>个人空间</h2>
<p>配置 AI 智能体的个性化偏好</p>
</div>
<div class="personal-page-actions">
<button type="button" class="personal-page-logout" @click="personalization.logout()">退出登录</button>
<button type="button" class="personal-page-close" data-tutorial="personal-close" @click="personalization.closeDrawer()">返回工作区</button>
</div>
</div>
<div class="personalization-body" v-if="!loading">
<form class="personal-form" @submit.prevent="personalization.save()">
<div class="personalization-layout">
<nav class="personal-page-tabs" aria-label="个人空间分组切换">
<button
v-for="tab in personalTabs"
:key="tab.id"
type="button"
class="personal-tab-button"
:data-tutorial="`personal-tab-${tab.id}`"
:class="{ active: activeTab === tab.id }"
:aria-pressed="activeTab === tab.id"
@click.prevent="setActiveTab(tab.id)"
>
<span>{{ tab.label }}</span>
<small v-if="tab.description" class="personal-tab-desc">{{ tab.description }}</small>
</button>
</nav>
<div class="personalization-content-shell" data-tutorial="personal-content-shell">
<div
class="personalization-content"
@touchstart.passive="handleSwipeStart"
@touchend.passive="handleSwipeEnd"
>
<transition name="personal-page-vertical" mode="out-in">
<section v-if="activeTab === 'preferences'" key="preferences" class="personal-page personal-page-pref" data-tutorial="personal-page-preferences">
<div class="personal-toggle-row">
<label class="personal-toggle">
<span class="toggle-text">
<span class="toggle-title">启用个性化提示</span>
<span class="toggle-desc">开启后才会注入您的偏好</span>
</span>
<span class="toggle-switch">
<input
type="checkbox"
:checked="form.enabled"
:disabled="toggleUpdating"
@change="personalization.toggleEnabled()"
/>
<span class="switch-slider"></span>
</span>
</label>
</div>
<p class="section-note">仅作用于当前工作区,可随时在左下角开关。</p>
<div class="personalization-sections">
<div class="personal-left-column">
<div class="personal-section personal-info">
<label class="personal-field">
<span>您希望AI智能体怎么自称</span>
<input
type="text"
placeholder="如小秘、助理小A"
:value="form.self_identify"
maxlength="20"
@input="personalization.updateField({ key: 'self_identify', value: $event.target.value })"
@focus="personalization.clearFeedback()"
/>
</label>
<label class="personal-field">
<span>您希望AI智能体怎么称呼您</span>
<input
type="text"
placeholder="如Jojo、老师"
:value="form.user_name"
maxlength="20"
@input="personalization.updateField({ key: 'user_name', value: $event.target.value })"
@focus="personalization.clearFeedback()"
/>
</label>
<label class="personal-field">
<span>您的职业是?</span>
<input
type="text"
placeholder="如:产品经理、设计师"
:value="form.profession"
maxlength="20"
@input="personalization.updateField({ key: 'profession', value: $event.target.value })"
@focus="personalization.clearFeedback()"
/>
</label>
<div class="personal-field">
<label>
<span>您希望AI智能体用何种语气与您交流</span>
<input
type="text"
placeholder="请选择或输入语气"
:value="form.tone"
maxlength="20"
@input="personalization.updateField({ key: 'tone', value: $event.target.value })"
@focus="personalization.clearFeedback()"
/>
</label>
<div class="tone-preset-row">
<span>快速填入:</span>
<div class="tone-preset-buttons">
<button type="button" v-for="preset in tonePresets" :key="preset" @click.prevent="personalization.applyTonePreset(preset)">
{{ preset }}
</button>
</div>
</div>
</div>
</div>
</div>
<div class="personal-right-column">
<div class="personal-section personal-considerations">
<div class="personal-field">
<span>您希望AI智能体在回答问题时必须考虑的信息是</span>
<div class="consideration-input">
<input
type="text"
:value="newConsideration"
maxlength="50"
placeholder="输入后点击 + 号添加"
@input="personalization.updateNewConsideration($event.target.value)"
@focus="personalization.clearFeedback()"
/>
<button
type="button"
class="consideration-add"
:disabled="!newConsideration || form.considerations.length >= maxConsiderations"
@click="personalization.addConsideration()"
>
+
</button>
</div>
<ul class="consideration-list" v-if="form.considerations.length">
<li
v-for="(item, idx) in form.considerations"
:key="`consideration-${idx}`"
class="consideration-item"
draggable="true"
@dragstart="personalization.considerationDragStart(idx, $event)"
@dragover.prevent="personalization.considerationDragOver(idx, $event)"
@drop.prevent="personalization.considerationDrop(idx, $event)"
@dragend="personalization.considerationDragEnd()"
>
<span class="drag-handle" aria-hidden="true">≡</span>
<span class="consideration-text">{{ item }}</span>
<button type="button" class="consideration-remove" @click="personalization.removeConsideration(idx)">
</button>
</li>
</ul>
<p class="consideration-hint" v-else>尚未添加任何必备信息</p>
<p class="consideration-limit">最多 {{ maxConsiderations }} 条,可拖动排序</p>
</div>
</div>
<div class="personal-form-actions card-aligned">
<div class="personal-status-group">
<transition name="personal-status-fade">
<span class="status success" v-if="status">{{ status }}</span>
</transition>
<transition name="personal-status-fade">
<span class="status error" v-if="error">{{ error }}</span>
</transition>
</div>
<button type="submit" class="primary" :disabled="saving">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
</div>
</section>
<section v-else-if="activeTab === 'image'" key="image" class="personal-page behavior-page" data-tutorial="personal-page-image">
<div class="behavior-section">
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">图片压缩</span>
<p class="behavior-desc">发送图片或调用 view_image 时自动等比缩放,防止大图占用过多 tokens。</p>
</div>
<div class="run-mode-options">
<button
v-for="option in imageCompressionOptions"
:key="option.id"
type="button"
class="run-mode-card"
:class="{ active: form.image_compression === option.id }"
:aria-pressed="form.image_compression === option.id"
@click.prevent="personalization.setImageCompression(option.id)"
>
<div class="run-mode-card-header">
<span class="run-mode-title">{{ option.label }}</span>
</div>
<p class="run-mode-desc">{{ option.desc }}</p>
</button>
</div>
<p class="behavior-hint">缩放保持原始比例;若原图已低于目标分辨率,将直接使用原图。</p>
</div>
</div>
<div class="personal-actions-row">
<div class="personal-form-actions card-aligned">
<div class="personal-status-group">
<transition name="personal-status-fade">
<span class="status success" v-if="status">{{ status }}</span>
</transition>
<transition name="personal-status-fade">
<span class="status error" v-if="error">{{ error }}</span>
</transition>
</div>
<button type="submit" class="primary" :disabled="saving">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
</section>
<section v-else-if="activeTab === 'model'" key="model" class="personal-page behavior-page" data-tutorial="personal-page-model">
<div class="behavior-section">
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">默认模型</span>
<p class="field-desc">为新对话/登录后首次请求选择首选模型,会与默认思考模式一起生效。</p>
</div>
<div class="run-mode-options model-options">
<button
v-for="option in filteredModelOptions"
:key="option.id"
type="button"
class="run-mode-card"
:class="[{ active: form.default_model === option.value }, { disabled: option.disabled }]"
:aria-pressed="form.default_model === option.value"
:disabled="option.disabled"
@click.prevent="!option.disabled && setDefaultModel(option.value)"
>
<div class="run-mode-card-header">
<span class="run-mode-title">{{ option.label }}</span>
<span v-if="option.badge" class="run-mode-badge">{{ option.badge }}</span>
<span v-if="option.disabled" class="run-mode-badge danger">已禁用</span>
</div>
<p class="run-mode-desc">{{ option.desc }}</p>
</button>
</div>
<p class="behavior-hint">
MiniMax-M2.5 仅支持深度思考模式,选择时会给出提示。
</p>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">默认思考模型</span>
<p class="field-desc">设定登录或新建任务时的初始运行模式,仍可通过主界面随时切换。</p>
</div>
<div class="run-mode-options">
<button
v-for="option in runModeOptions"
:key="option.id"
type="button"
class="run-mode-card"
:class="{ active: isRunModeActive(option.value) }"
:aria-pressed="isRunModeActive(option.value)"
@click.prevent="setDefaultRunMode(option.value)"
>
<div class="run-mode-card-header">
<span class="run-mode-title">{{ option.label }}</span>
<span v-if="option.badge" class="run-mode-badge">{{ option.badge }}</span>
</div>
<p class="run-mode-desc">{{ option.desc }}</p>
</button>
</div>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">思考频率</span>
<p class="field-desc">控制连续快速模式运行多少轮后,系统会强制切换到思考模型。</p>
</div>
<div class="thinking-presets">
<button
v-for="preset in thinkingPresets"
:key="preset.id"
type="button"
:class="{ active: isPresetActive(preset.value) }"
@click.prevent="applyThinkingPreset(preset.value)"
>
{{ preset.label }}
<small>{{ preset.value }} 轮</small>
</button>
</div>
<div class="thinking-input-row">
<label>
<span>自定义轮数</span>
<input
type="number"
:min="thinkingIntervalRange.min"
:max="thinkingIntervalRange.max"
:placeholder="`默认 ${thinkingIntervalDefault} 轮`"
:value="form.thinking_interval ?? ''"
@input="handleThinkingInput"
@focus="personalization.clearFeedback()"
/>
</label>
<span class="thinking-hint">范围 {{ thinkingIntervalRange.min }}~{{ thinkingIntervalRange.max }} 轮。</span>
<button type="button" class="link-button" @click.prevent="restoreThinkingInterval">
恢复默认
</button>
</div>
</div>
</div>
<div class="personal-actions-row">
<div class="personal-form-actions card-aligned">
<div class="personal-status-group">
<transition name="personal-status-fade">
<span class="status success" v-if="status">{{ status }}</span>
</transition>
<transition name="personal-status-fade">
<span class="status error" v-if="error">{{ error }}</span>
</transition>
</div>
<button type="submit" class="primary" :disabled="saving">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
</section>
<section v-else-if="activeTab === 'usage'" key="usage" class="personal-page usage-summary-page" data-tutorial="personal-page-usage">
<div class="usage-summary-card">
<div class="usage-summary-header">
<div>
<p class="usage-summary-eyebrow">对话用量</p>
<h3>用量统计</h3>
<p class="usage-summary-desc">累计统计所有对话的输入/输出 Token、对话数量、用户消息与工具调用。</p>
<p class="usage-summary-note">宿主机模式同样统计 data/conversations 内的记录。</p>
</div>
</div>
<div class="usage-summary-grid usage-summary-grid--tokens">
<div class="usage-summary-item">
<div class="label">累计输入</div>
<div class="value value--success">{{ formatTokenCount(usageSummary.total_input_tokens) }}</div>
</div>
<div class="usage-summary-item">
<div class="label">累计输出</div>
<div class="value value--warning">{{ formatTokenCount(usageSummary.total_output_tokens) }}</div>
</div>
</div>
<div class="usage-summary-grid usage-summary-grid--counts">
<div class="usage-summary-item">
<div class="label">总对话数</div>
<div class="value">{{ formatTokenCount(usageSummary.total_conversations) }}</div>
</div>
<div class="usage-summary-item">
<div class="label">用户消息数</div>
<div class="value">{{ formatTokenCount(usageSummary.total_user_messages) }}</div>
</div>
<div class="usage-summary-item">
<div class="label">工具调用次数</div>
<div class="value">{{ formatTokenCount(usageSummary.total_tools) }}</div>
</div>
</div>
<div class="usage-summary-meta">
<span v-if="usageError" class="usage-summary-error">{{ usageError }}</span>
<span v-else-if="usageLoading">正在同步最新统计...</span>
<span v-else>最近更新:{{ usageUpdatedText }}</span>
<button
type="button"
class="usage-summary-refresh"
@click="fetchUsageSummary"
:disabled="usageLoading"
>
{{ usageLoading ? '刷新中...' : '刷新数据' }}
</button>
</div>
</div>
</section>
<section v-else-if="activeTab === 'app-update'" key="app-update" class="personal-page app-update-page" data-tutorial="personal-page-app-update">
<div class="usage-summary-card">
<div class="usage-summary-header">
<div>
<p class="usage-summary-eyebrow">App 更新</p>
<h3>软件更新</h3>
<p class="usage-summary-desc">仅移动 App 可见。自动检查新版本,也可手动检查。</p>
<p class="usage-summary-note">
当前版本:{{ appCurrentVersionText }}
</p>
</div>
</div>
<div class="usage-summary-grid usage-summary-grid--counts">
<div class="usage-summary-item">
<div class="label">最新版本</div>
<div class="value">{{ appLatestVersionText }}</div>
</div>
<div class="usage-summary-item">
<div class="label">安装包大小</div>
<div class="value">{{ appLatestFileSizeText }}</div>
</div>
<div class="usage-summary-item">
<div class="label">更新状态</div>
<div class="value" :class="{ 'value--warning': appHasUpdate }">
{{ appUpdateStateText }}
</div>
</div>
</div>
<div class="app-update-changelog" v-if="appUpdateChangelogText">
<div class="app-update-changelog-title">更新说明</div>
<pre class="app-update-changelog-content">{{ appUpdateChangelogText }}</pre>
</div>
<div class="usage-summary-meta">
<span v-if="appUpdateError" class="usage-summary-error">{{ appUpdateError }}</span>
<span v-else-if="appUpdateChecking">正在检查版本...</span>
<span v-else>{{ appUpdateCheckedText }}</span>
<div class="app-update-actions">
<button type="button" class="usage-summary-refresh" @click="checkAppUpdate" :disabled="appUpdateChecking">
{{ appUpdateChecking ? '检查中...' : '手动检查' }}
</button>
<button
type="button"
class="usage-summary-refresh"
:disabled="!appHasUpdate || !appDownloadUrl"
@click="downloadLatestApp"
>
下载更新
</button>
</div>
</div>
</div>
</section>
<section v-else-if="activeTab === 'behavior'" key="behavior" class="personal-page behavior-page" data-tutorial="personal-page-behavior">
<div class="behavior-section">
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">使用自定义称呼</span>
<p class="field-desc">开启后,对话区域将使用您在"个性化设置"中配置的自称和称呼。关闭则显示默认的"AI Assistant"和"用户"。</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.use_custom_names"
@change="personalization.updateField({ key: 'use_custom_names', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>在对话中显示自定义称呼</span>
</label>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">增强工具显示</span>
<p class="field-desc">开启后工具块将显示格式化的内容,关闭则显示原始 JSON 数据。默认开启。</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.enhanced_tool_display"
@change="personalization.updateField({ key: 'enhanced_tool_display', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>显示格式化的工具结果(更清晰易读)</span>
</label>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">上下文压缩策略</span>
<p class="field-desc">可分别控制自动浅层压缩与自动深层压缩。</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.auto_shallow_compress_enabled"
@change="personalization.updateField({ key: 'auto_shallow_compress_enabled', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>自动浅层压缩</span>
</label>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.auto_deep_compress_enabled"
@change="personalization.updateField({ key: 'auto_deep_compress_enabled', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>自动深层压缩</span>
</label>
<div class="compression-custom-grid">
<label class="compression-input-row">
<span>浅压缩触发上下文</span>
<input
type="number"
min="1000"
step="1"
placeholder="默认 80000"
:value="form.shallow_compress_trigger_tokens ?? ''"
@input="handleCompressionNumberInput('shallow_compress_trigger_tokens', $event)"
@focus="personalization.clearFeedback()"
/>
</label>
<label class="compression-input-row">
<span>浅压缩保留最近 Tool 条数</span>
<input
type="number"
min="0"
step="1"
placeholder="默认 15"
:value="form.shallow_compress_keep_recent_tools ?? ''"
@input="handleCompressionNumberInput('shallow_compress_keep_recent_tools', $event)"
@focus="personalization.clearFeedback()"
/>
</label>
<label class="compression-input-row">
<span>浅压缩每轮最多替换条数</span>
<input
type="number"
min="1"
step="1"
placeholder="默认 10"
:value="form.shallow_compress_max_replace_per_round ?? ''"
@input="handleCompressionNumberInput('shallow_compress_max_replace_per_round', $event)"
@focus="personalization.clearFeedback()"
/>
</label>
<label class="compression-input-row">
<span>浅压缩触发间隔Tool 调用次数)</span>
<input
type="number"
min="1"
step="1"
placeholder="默认 10"
:value="form.shallow_compress_trigger_tool_calls_interval ?? ''"
@input="handleCompressionNumberInput('shallow_compress_trigger_tool_calls_interval', $event)"
@focus="personalization.clearFeedback()"
/>
</label>
<label class="compression-input-row">
<span>深压缩触发上下文</span>
<input
type="number"
min="1000"
step="1"
placeholder="默认 150000"
:value="form.deep_compress_trigger_tokens ?? ''"
@input="handleCompressionNumberInput('deep_compress_trigger_tokens', $event)"
@focus="personalization.clearFeedback()"
/>
</label>
</div>
<div class="compression-actions-row">
<button type="button" class="compression-reset-btn" @click.prevent="restoreCompressionDefaults">
恢复默认
</button>
</div>
<p class="field-desc compression-custom-hint">
自定义项留空则使用默认值;深压缩触发上下文必须大于浅压缩触发上下文。
</p>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">堆叠块显示模式</span>
<p class="field-desc">选择思考/工具块的显示方式。默认为堆叠动画。</p>
</div>
<div class="run-mode-options">
<button
v-for="option in blockDisplayOptions"
:key="option.id"
type="button"
class="run-mode-card"
:class="{ active: experiments.blockDisplayMode === option.value }"
:aria-pressed="experiments.blockDisplayMode === option.value"
@click.prevent="handleBlockDisplayModeChange(option.value)"
>
<div class="run-mode-card-header">
<span class="run-mode-title">{{ option.label }}</span>
<span v-if="option.badge" class="run-mode-badge">{{ option.badge }}</span>
</div>
<p class="run-mode-desc">{{ option.desc }}</p>
</button>
</div>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">静默禁用</span>
<p class="field-desc">禁用工具时不再注入提示消息,模型不会感知被禁用项。</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.silent_tool_disable"
@change="personalization.updateField({ key: 'silent_tool_disable', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>静默禁用工具(不向模型提示)</span>
</label>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">自动生成对话标题</span>
<p class="field-desc">默认开启;关闭后标题将沿用首条消息。</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.auto_generate_title"
@change="personalization.updateField({ key: 'auto_generate_title', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>使用快速模型为新对话生成含 emoji 的简短标题</span>
</label>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">工具意图提示</span>
<p class="field-desc">开启后调用工具时会先用约15字告诉你要做什么替代“正在/完成”文案。</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.tool_intent_enabled"
@change="personalization.updateField({ key: 'tool_intent_enabled', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>在工具块显示"我要做什么"的简短提示</span>
</label>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">Skill 提示系统</span>
<p class="field-desc">开启后,检测到特定关键词时会自动提示模型阅读相关 skill如 terminal-guide、sub-agent-guide 等)。</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.skill_hints_enabled"
@change="personalization.updateField({ key: 'skill_hints_enabled', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>根据关键词自动提示阅读相关 skill</span>
</label>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">强约束系统</span>
<p class="field-desc">开启后,若未在当前对话先阅读对应 SKILL.md直接调用工具会返回“必须先阅读”。仅对个人空间中已启用的 skill 生效。</p>
</div>
<div class="strict-skill-grid">
<label class="toggle-row">
<input
type="checkbox"
:checked="form.skill_strict_terminal_enabled"
@change="personalization.updateField({ key: 'skill_strict_terminal_enabled', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>Terminal 系列工具</span>
</label>
<label class="toggle-row">
<input
type="checkbox"
:checked="form.skill_strict_sub_agent_enabled"
@change="personalization.updateField({ key: 'skill_strict_sub_agent_enabled', value: $event.target.checked })"
/>
<span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path>
</svg>
</span>
<span>子智能体系列工具</span>
</label>
</div>
</div>
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">默认禁用工具类别</span>
<p class="field-desc">选择后,这些类别在新任务中会保持关闭,也可随时通过快捷菜单临时开启。</p>
</div>
<div class="tool-category-grid" v-if="toolCategories.length">
<label
v-for="category in toolCategories"
:key="category.id"
class="tool-category-chip"
>
<input
type="checkbox"
:checked="form.disabled_tool_categories.includes(category.id)"
@change="toggleCategory(category.id)"
/>
<span>{{ category.label }}</span>
</label>
</div>
<p class="behavior-hint" v-else>暂无可配置的工具类别。</p>
</div>
</div>
<div class="personal-actions-row">
<div class="personal-form-actions card-aligned">
<div class="personal-status-group">
<transition name="personal-status-fade">
<span class="status success" v-if="status">{{ status }}</span>
</transition>
<transition name="personal-status-fade">
<span class="status error" v-if="error">{{ error }}</span>
</transition>
</div>
<button type="submit" class="primary" :disabled="saving">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
</section>
<section v-else-if="activeTab === 'skills'" key="skills" class="personal-page behavior-page" data-tutorial="personal-page-skills">
<div class="behavior-section">
<div class="behavior-field">
<div class="behavior-field-header">
<span class="field-title">可用 Skills</span>
<p class="field-desc">勾选后会注入 system prompt并同步到工作区的 skills/ 目录。</p>
</div>
<div class="tool-category-grid" v-if="skillsCatalog.length">
<label
v-for="skill in skillsCatalog"
:key="skill.id"
class="tool-category-chip"
>
<input
type="checkbox"
:checked="form.enabled_skills.includes(skill.id)"
@change="personalization.toggleSkill(skill.id)"
/>
<span>{{ skill.label }}</span>
</label>
</div>
<p class="behavior-hint" v-else>暂无可用的 skills。</p>
</div>
</div>
<div class="personal-actions-row">
<div class="personal-form-actions card-aligned">
<div class="personal-status-group">
<transition name="personal-status-fade">
<span class="status success" v-if="status">{{ status }}</span>
</transition>
<transition name="personal-status-fade">
<span class="status error" v-if="error">{{ error }}</span>
</transition>
</div>
<button type="submit" class="primary" :disabled="saving">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
</section>
<section v-else-if="activeTab === 'theme'" key="theme" class="personal-page theme-page" data-tutorial="personal-page-theme">
<div class="theme-section">
<div class="theme-header">
<div>
<h3>界面主题</h3>
<p>在浅色深色和 Claude 经典之间一键切换会立即应用并保存在本地</p>
</div>
<div class="theme-badge">Beta</div>
</div>
<div class="theme-grid">
<button
v-for="option in themeOptions"
:key="option.id"
type="button"
class="theme-card"
:class="{ active: activeTheme === option.id }"
@click.prevent="applyThemeOption(option.id)"
>
<div class="theme-card-head">
<div class="theme-dot-row">
<span
v-for="(color, idx) in option.swatches"
:key="`${option.id}-${idx}`"
class="theme-dot"
:style="{ background: color }"
aria-hidden="true"
></span>
</div>
<span class="theme-check" v-if="activeTheme === option.id">✓</span>
</div>
<div class="theme-card-body">
<h4>{{ option.label }}</h4>
<p>{{ option.desc }}</p>
</div>
</button>
</div>
<p class="theme-note">虚拟显示器保持原样;仅主界面、侧边栏与个人空间配色随主题改变。</p>
</div>
</section>
<section v-else-if="activeTab === 'tutorial'" key="tutorial" class="personal-page tutorial-page">
<div class="tutorial-intro-content">
<div class="tutorial-intro-header">
<p class="tutorial-intro-eyebrow">功能引导</p>
<h3>新手教程</h3>
<p class="tutorial-intro-desc">
通过高亮 + 悬浮说明窗,快速了解主要功能入口和常用设置。
</p>
</div>
<div class="tutorial-intro-features">
<div class="tutorial-feature-item">
<div>
<h4>主界面导览</h4>
<p>认识侧边栏、工作区、模型与模式选择。</p>
</div>
</div>
<div class="tutorial-feature-item">
<div>
<h4>快捷菜单</h4>
<p>学习上传文件、对话回顾、用量统计等高频功能。</p>
</div>
</div>
<div class="tutorial-feature-item">
<div>
<h4>个人空间</h4>
<p>了解模型偏好、主题、Skills、图片压缩等配置。</p>
</div>
</div>
</div>
<div class="tutorial-intro-actions">
<button type="button" class="tutorial-start-button" @click="startTutorial">
开始新手教程
</button>
<p class="tutorial-intro-hint">教程开始后会自动关闭个人空间,您可随时退出或跳过当前步骤。</p>
</div>
</div>
</section>
<section v-else-if="activeTab === 'admin-monitor'" key="admin-monitor" class="personal-page admin-monitor-page">
<div class="admin-monitor-panel">
<div class="admin-monitor-heading">
<p class="admin-monitor-eyebrow">系统资源</p>
<div class="admin-monitor-title-row">
<h3>管理员监控入口</h3>
<span class="admin-monitor-chip">自动刷新</span>
</div>
<p class="admin-monitor-desc">独立面板集中展示配额趋势、容器运行、项目存储与上传安全。</p>
</div>
<div class="admin-monitor-highlights">
<span class="admin-monitor-tag">用量统计</span>
<span class="admin-monitor-tag">容器健康</span>
<span class="admin-monitor-tag">上传审计</span>
<span class="admin-monitor-tag">邀请码管理</span>
</div>
<div class="admin-monitor-actions">
<button type="button" class="admin-monitor-button" @click="openAdminPanel">
打开监控面板
</button>
<button type="button" class="admin-monitor-button ghost" @click="openApiAdmin">
API 管理
</button>
<p class="admin-monitor-hint">新窗口开启,不打断当前对话与配置。</p>
</div>
</div>
<div class="admin-monitor-panel secondary">
<div class="admin-monitor-heading">
<p class="admin-monitor-eyebrow">自定义工具</p>
<div class="admin-monitor-title-row">
<h3>自定义工具管理</h3>
<span class="admin-monitor-chip alt">低代码 · Python</span>
</div>
<p class="admin-monitor-desc">在线创建/编辑 definition.json、execution.py、return.json、meta.json保存后自动生效。</p>
</div>
<div class="admin-monitor-highlights">
<span class="admin-monitor-tag">仅管理员可见</span>
<span class="admin-monitor-tag">容器内执行</span>
<span class="admin-monitor-tag">无需重启</span>
<span class="admin-monitor-tag">三层拆分</span>
</div>
<div class="admin-monitor-actions">
<button type="button" class="admin-monitor-button" @click="openCustomTools">
打开自定义工具
</button>
<p class="admin-monitor-hint">新窗口打开 /admin/custom-tools 页面。</p>
</div>
</div>
</section>
</transition>
</div>
</div>
</div>
</form>
</div>
<div class="personalization-loading" v-else>正在加载个性化配置...</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { usePersonalizationStore, type BlockDisplayMode } from '@/stores/personalization';
import { useResourceStore } from '@/stores/resource';
import { useUiStore } from '@/stores/ui';
import { usePolicyStore } from '@/stores/policy';
import { useTutorialStore } from '@/stores/tutorial';
import { formatTokenCount } from '@/utils/formatters';
import { useTheme } from '@/utils/theme';
import type { ThemeKey } from '@/utils/theme';
defineOptions({ name: 'PersonalizationDrawer' });
const personalization = usePersonalizationStore();
const resourceStore = useResourceStore();
const uiStore = useUiStore();
const tutorialStore = useTutorialStore();
const {
visible,
loading,
form,
tonePresets,
newConsideration,
maxConsiderations,
status,
error,
saving,
toggleUpdating,
toolCategories,
skillsCatalog,
thinkingIntervalDefault,
thinkingIntervalRange,
experiments
} = storeToRefs(personalization);
const baseTabs = [
{ id: 'preferences', label: '个性化设置', description: '称呼语气与注意事项' },
{ id: 'model', label: '模型偏好', description: '默认模型选择' },
{ id: 'usage', label: '用量统计', description: '对话累计数据' },
{ id: 'app-update', label: '软件更新', description: '检查并下载新版 APK' },
{ id: 'behavior', label: '模型行为', description: '工具提示与界面表现' },
{ id: 'skills', label: 'Skills', description: '可用技能开关' },
{ id: 'image', label: '图片压缩', description: '发送图片的尺寸策略' },
{ id: 'theme', label: '主题切换', description: '浅色 / 深色 / Claude' },
{ id: 'tutorial', label: '新手教程', description: '功能引导与使用说明' }
] as const;
type PersonalTab =
| 'preferences'
| 'model'
| 'usage'
| 'app-update'
| 'behavior'
| 'skills'
| 'image'
| 'theme'
| 'tutorial'
| 'admin-monitor';
const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
const isAppShell = computed(() => {
if (typeof window === 'undefined') return false;
const params = new URLSearchParams(window.location.search);
if (params.has('app_shell')) return true;
return Boolean((window as any)?.AndroidThemeBridge);
});
const personalTabs = computed(() => {
const tabs = baseTabs.filter((tab) => (tab.id === 'app-update' ? isAppShell.value : true));
if (isAdmin.value) {
tabs.push({ id: 'admin-monitor' as const, label: '管理员监控', description: '系统总览' });
}
return tabs;
});
const activeTab = ref<PersonalTab>('preferences');
const swipeState = ref<{ startY: number; active: boolean }>({ startY: 0, active: false });
type RunModeValue = 'fast' | 'thinking' | 'deep' | null;
type CompressionField =
| 'shallow_compress_trigger_tokens'
| 'shallow_compress_keep_recent_tools'
| 'shallow_compress_max_replace_per_round'
| 'shallow_compress_trigger_tool_calls_interval'
| 'deep_compress_trigger_tokens';
const runModeOptions: Array<{ id: string; label: string; desc: string; value: RunModeValue; badge?: string }> = [
{ id: 'fast', label: '快速模式', desc: '追求响应速度,跳过思考模型', value: 'fast' },
{ id: 'thinking', label: '思考模式', desc: '首轮回复会先输出思考过程', value: 'thinking', badge: '推荐' },
{ id: 'deep', label: '深度思考', desc: '整轮对话都使用思考模型', value: 'deep' }
];
const policyStore = usePolicyStore();
const modelOptions = [
{ id: 'deepseek', label: 'DeepSeek', desc: '通用 + 思考强化', value: 'deepseek' },
{ id: 'kimi-k2.5', label: 'Kimi-k2.5', desc: '新版 Kimi思考开关 + 图文多模态', value: 'kimi-k2.5', badge: '图文' },
{ id: 'kimi', label: 'Kimi-k2', desc: '旧版 Kimi-k2兼顾通用对话', value: 'kimi' },
{ id: 'qwen3-vl-plus', label: 'Qwen3.5', desc: '图文多模态 + 深度思考', value: 'qwen3-vl-plus', badge: '图文' },
{ id: 'minimax-m2.5', label: 'MiniMax-M2.5', desc: '仅深度思考,超长上下文', value: 'minimax-m2.5', badge: '深度思考' }
] as const;
const filteredModelOptions = computed(() =>
modelOptions.map((opt) => ({
...opt,
disabled: policyStore.disabledModelSet.has(opt.value)
}))
);
const thinkingPresets = [
{ id: 'low', label: '低', value: 10 },
{ id: 'medium', label: '中', value: 5 },
{ id: 'high', label: '高', value: 3 }
];
const imageCompressionOptions = [
{ id: 'original', label: '原图', desc: '不压缩' },
{ id: '1080p', label: '1080p', desc: '最长边不超过 1080p 等比缩放' },
{ id: '720p', label: '720p', desc: '最长边不超过 720p 等比缩放' },
{ id: '540p', label: '540p', desc: '最长边不超过 540p 等比缩放' }
] as const;
const usageSummary = ref({
total_input_tokens: 0,
total_output_tokens: 0,
total_conversations: 0,
total_user_messages: 0,
total_tools: 0
});
const usageLoading = ref(false);
const usageError = ref('');
const usageUpdatedAt = ref<string | null>(null);
const usageUpdatedText = computed(() => {
if (!usageUpdatedAt.value) {
return '尚未刷新';
}
const date = new Date(usageUpdatedAt.value);
if (Number.isNaN(date.getTime())) {
return '时间未知';
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
});
const appCurrentVersionCode = ref<number | null>(null);
const appCurrentVersionName = ref<string>('');
const appUpdateChecking = ref(false);
const appUpdateError = ref('');
const appUpdateCheckedAt = ref<string | null>(null);
const appUpdateInfo = ref<{
latestVersionCode: number;
latestVersionName: string;
apkUrl: string;
fileSizeBytes: number;
changelog?: string;
publishedAt?: string | null;
hasUpdate?: boolean | null;
} | null>(null);
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
const vcRaw = params.get('app_vc') || '';
const vnRaw = params.get('app_vn') || '';
const bridge = (window as any)?.AndroidThemeBridge;
const bridgeVcRaw = bridge && typeof bridge.getAppVersionCode === 'function' ? String(bridge.getAppVersionCode() || '') : '';
const bridgeVnRaw = bridge && typeof bridge.getAppVersionName === 'function' ? String(bridge.getAppVersionName() || '') : '';
// App 端优先使用 Bridge 实时版本,避免 WebView 恢复旧 URL 参数导致显示过期版本号
const finalVcRaw = bridgeVcRaw || vcRaw;
const finalVnRaw = bridgeVnRaw || vnRaw;
appCurrentVersionCode.value = /^\d+$/.test(finalVcRaw) ? Number(finalVcRaw) : null;
appCurrentVersionName.value = finalVnRaw || '';
}
const hydrateAppVersionFromBridge = () => {
if (typeof window === 'undefined') return;
const bridge = (window as any)?.AndroidThemeBridge;
if (!bridge) return;
try {
const vcRaw = typeof bridge.getAppVersionCode === 'function' ? String(bridge.getAppVersionCode() || '') : '';
const vnRaw = typeof bridge.getAppVersionName === 'function' ? String(bridge.getAppVersionName() || '') : '';
if (vcRaw && /^\d+$/.test(vcRaw)) {
appCurrentVersionCode.value = Number(vcRaw);
}
if (vnRaw) {
appCurrentVersionName.value = vnRaw;
}
} catch {
// ignore bridge read failures
}
};
const appCurrentVersionText = computed(() => {
const vn = appCurrentVersionName.value || '未知';
const vc = appCurrentVersionCode.value ?? '?';
return `${vn} (${vc})`;
});
const appLatestVersionText = computed(() => {
if (!appUpdateInfo.value) return '--';
return `${appUpdateInfo.value.latestVersionName || '未知'} (${appUpdateInfo.value.latestVersionCode ?? '?'})`;
});
const appLatestFileSizeText = computed(() => {
const bytes = Number(appUpdateInfo.value?.fileSizeBytes || 0);
if (!bytes) return '--';
const mb = bytes / 1024 / 1024;
return `${mb.toFixed(mb >= 100 ? 0 : 1)} MB`;
});
const appUpdateChangelogText = computed(() => {
const text = (appUpdateInfo.value?.changelog || '').trim();
return text || '';
});
const appHasUpdate = computed(() => {
if (!appUpdateInfo.value) return false;
if (typeof appUpdateInfo.value.hasUpdate === 'boolean') return appUpdateInfo.value.hasUpdate;
if (appCurrentVersionCode.value == null) return false;
return Number(appUpdateInfo.value.latestVersionCode || 0) > appCurrentVersionCode.value;
});
const appDownloadUrl = computed(() => appUpdateInfo.value?.apkUrl || '');
const appUpdateStateText = computed(() => {
if (!appUpdateInfo.value) return '未检查';
return appHasUpdate.value ? '发现新版本' : '已是最新';
});
const appUpdateCheckedText = computed(() => {
if (!appUpdateCheckedAt.value) return '尚未检查更新';
const d = new Date(appUpdateCheckedAt.value);
if (Number.isNaN(d.getTime())) return '刚刚检查过';
return `最近检查:${d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`;
});
const fetchUsageSummary = async () => {
if (usageLoading.value) {
return;
}
usageLoading.value = true;
usageError.value = '';
try {
const response = await fetch('/api/conversations/statistics');
const payload = await response.json();
if (!response.ok || !payload.success) {
throw new Error(payload.error || payload.message || '获取用量统计失败');
}
const data = payload.data || {};
const tokenStats = data.token_statistics || {};
usageSummary.value = {
total_input_tokens: Number(tokenStats.total_input_tokens || 0),
total_output_tokens: Number(tokenStats.total_output_tokens || 0),
total_conversations: Number(data.total_conversations || 0),
total_user_messages: Number(data.total_user_messages || 0),
total_tools: Number(data.total_tools || 0)
};
usageUpdatedAt.value = new Date().toISOString();
} catch (error: any) {
usageError.value = error?.message || '获取用量统计失败';
} finally {
usageLoading.value = false;
}
};
const checkAppUpdate = async () => {
if (appUpdateChecking.value) return;
hydrateAppVersionFromBridge();
appUpdateChecking.value = true;
appUpdateError.value = '';
try {
const params = new URLSearchParams();
if (appCurrentVersionCode.value != null) {
params.set('currentVersionCode', String(appCurrentVersionCode.value));
}
const suffix = params.toString() ? `?${params.toString()}` : '';
const resp = await fetch(`/api/app/version${suffix}`);
const payload = await resp.json();
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '检查更新失败');
}
const data = payload?.data || {};
appUpdateInfo.value = {
latestVersionCode: Number(data.latestVersionCode || 0),
latestVersionName: String(data.latestVersionName || ''),
apkUrl: String(data.apkUrl || ''),
fileSizeBytes: Number(data.fileSizeBytes || 0),
changelog: String(data.changelog || ''),
publishedAt: data.publishedAt || null,
hasUpdate: typeof data.hasUpdate === 'boolean' ? data.hasUpdate : null
};
appUpdateCheckedAt.value = new Date().toISOString();
} catch (error: any) {
appUpdateError.value = error?.message || '检查更新失败';
} finally {
appUpdateChecking.value = false;
}
};
const downloadLatestApp = () => {
const url = appDownloadUrl.value;
if (!url) {
appUpdateError.value = '未拿到下载地址';
return;
}
window.location.href = url;
};
onMounted(() => {
hydrateAppVersionFromBridge();
});
watch(
() => [activeTab.value, visible.value],
([tab, isVisible]) => {
if (isVisible && tab === 'usage') {
fetchUsageSummary();
}
if (isVisible && tab === 'app-update' && isAppShell.value && !appUpdateInfo.value && !appUpdateChecking.value) {
checkAppUpdate();
}
}
);
const setActiveTab = (tab: PersonalTab) => {
activeTab.value = tab;
};
const startTutorial = () => {
personalization.closeDrawer();
window.setTimeout(() => {
tutorialStore.startTutorial();
}, 320);
};
const shiftTab = (direction: 1 | -1) => {
const tabs = personalTabs.value;
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab.value);
if (currentIndex === -1) {
return;
}
const nextIndex = currentIndex + direction;
if (nextIndex < 0 || nextIndex >= tabs.length) {
return;
}
activeTab.value = tabs[nextIndex].id as PersonalTab;
};
const handleSwipeStart = (event: TouchEvent) => {
// 手机端禁用滑动切换标签页
if (window.innerWidth <= 768) {
return;
}
if (!event.touches.length) {
return;
}
swipeState.value = { startY: event.touches[0].clientY, active: true };
};
const handleSwipeEnd = (event: TouchEvent) => {
// 手机端禁用滑动切换标签页
if (window.innerWidth <= 768) {
return;
}
if (!swipeState.value.active || !event.changedTouches.length) {
return;
}
const deltaY = event.changedTouches[0].clientY - swipeState.value.startY;
swipeState.value.active = false;
if (Math.abs(deltaY) < 60) {
return;
}
shiftTab(deltaY < 0 ? 1 : -1);
};
const applyThinkingPreset = (value: number) => {
personalization.setThinkingInterval(value);
};
const isRunModeActive = (value: RunModeValue) => {
if (value === null) {
return !form.value.default_run_mode;
}
return form.value.default_run_mode === value;
};
const setDefaultRunMode = (value: RunModeValue) => {
if (checkModeModelConflict(value, form.value.default_model)) {
return;
}
personalization.setDefaultRunMode(value);
};
const setDefaultModel = (value: string) => {
if (policyStore.disabledModelSet.has(value)) {
uiStore.pushToast({
title: '模型被禁用',
message: '已被管理员禁用,无法选择',
type: 'warning'
});
return;
}
if (checkModeModelConflict(form.value.default_run_mode, value)) {
return;
}
personalization.setDefaultModel(value);
};
const checkModeModelConflict = (mode: RunModeValue, model: string | null): boolean => {
const warnings: string[] = [];
if (model === 'minimax-m2.5' && mode && mode !== 'deep') {
warnings.push('MiniMax-M2.5 仅支持深度思考模式,已保持原设置。');
}
if (warnings.length) {
uiStore.pushToast({
title: '模型/思考模式不兼容',
message: warnings.join(' '),
type: 'warning',
duration: 6000
});
return true;
}
return false;
};
const handleThinkingInput = (event: Event) => {
const target = event.target as HTMLInputElement;
if (!target.value) {
personalization.setThinkingInterval(null);
return;
}
const parsed = Number(target.value);
personalization.setThinkingInterval(Number.isNaN(parsed) ? null : parsed);
};
const restoreThinkingInterval = () => {
personalization.setThinkingInterval(null);
};
const handleCompressionNumberInput = (key: CompressionField, event: Event) => {
const target = event.target as HTMLInputElement | null;
if (!target || !target.value) {
personalization.updateField({ key, value: null });
return;
}
const parsed = Number(target.value);
if (!Number.isFinite(parsed)) {
personalization.updateField({ key, value: null });
return;
}
personalization.updateField({ key, value: Math.round(parsed) });
};
const restoreCompressionDefaults = () => {
personalization.updateField({ key: 'shallow_compress_trigger_tokens', value: null });
personalization.updateField({ key: 'shallow_compress_keep_recent_tools', value: null });
personalization.updateField({ key: 'shallow_compress_max_replace_per_round', value: null });
personalization.updateField({ key: 'shallow_compress_trigger_tool_calls_interval', value: null });
personalization.updateField({ key: 'deep_compress_trigger_tokens', value: null });
};
const isPresetActive = (value: number) => {
if (form.value.thinking_interval === null || typeof form.value.thinking_interval === 'undefined') {
return value === thinkingIntervalDefault.value;
}
return form.value.thinking_interval === value;
};
const toggleCategory = (categoryId: string) => {
personalization.toggleDefaultToolCategory(categoryId);
};
const blockDisplayOptions = [
{ id: 'traditional', label: '传统列表', desc: '按时间顺序垂直排列,经典样式', value: 'traditional' as const },
{ id: 'stacked', label: '堆叠动画', desc: '超过 6 条自动收纳为"更多",动态展示', value: 'stacked' as const, badge: '推荐' },
{ id: 'minimal', label: '极简模式', desc: '摘要行 + 无缝展开,流式输出效果', value: 'minimal' as const, badge: '新' }
];
const handleBlockDisplayModeChange = (mode: 'traditional' | 'stacked' | 'minimal') => {
personalization.setBlockDisplayMode(mode);
};
const openAdminPanel = () => {
window.open('/admin/monitor', '_blank', 'noopener');
personalization.closeDrawer();
};
const openCustomTools = () => {
window.open('/admin/custom-tools', '_blank', 'noopener');
personalization.closeDrawer();
};
const openApiAdmin = () => {
window.open('/admin/api', '_blank', 'noopener');
personalization.closeDrawer();
};
// ===== 主题切换 =====
const { setTheme, loadTheme } = useTheme();
const themeOptions: Array<{ id: ThemeKey; label: string; desc: string; swatches: string[] }> = [
{
id: 'claude',
label: 'Claude 经典',
desc: '仿 Claude 的米色质感,柔和高对比',
swatches: ['#eeece2', '#f7f3ea', '#da7756']
},
{
id: 'light',
label: '明亮',
desc: '纯白底色 + 优雅灰,类似 ChatGPT 风格',
swatches: ['#ffffff', '#f7f7f8', '#6b7280']
},
{
id: 'dark',
label: '夜间',
desc: '深灰 + 黑,低亮度并保持彩色点缀',
swatches: ['#1a1a1a', '#2a2a2a', '#3a3a3a']
}
];
const activeTheme = ref<ThemeKey>(loadTheme());
const applyThemeOption = (theme: ThemeKey) => {
activeTheme.value = theme;
setTheme(theme);
};
</script>
<style scoped>
.compression-custom-grid {
margin-top: 10px;
display: grid;
gap: 10px;
}
.compression-input-row {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--claude-text-secondary);
font-size: 12px;
}
.compression-input-row input {
border: 1px solid var(--claude-border);
border-radius: 10px;
background: var(--claude-panel);
color: var(--claude-text);
padding: 8px 10px;
font-size: 13px;
}
.compression-custom-hint {
margin-top: 8px;
}
.compression-actions-row {
margin-top: 4px;
display: flex;
justify-content: flex-end;
}
.compression-reset-btn {
border: 1px solid var(--claude-border);
border-radius: 10px;
padding: 6px 12px;
background: var(--claude-panel);
color: var(--claude-text);
font-size: 12px;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.compression-reset-btn:hover {
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(61, 57, 41, 0.14);
}
.usage-summary-page {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 12px 0 24px;
height: 100%;
}
.usage-summary-card {
width: 100%;
max-width: 720px;
padding: 0;
min-height: 320px;
display: flex;
flex-direction: column;
gap: 18px;
flex: 1 1 auto;
min-height: 0;
}
.usage-summary-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.usage-summary-header h3 {
margin: 4px 0 6px;
font-size: 24px;
}
.usage-summary-eyebrow {
margin: 0;
font-size: 12px;
color: var(--claude-accent-strong);
letter-spacing: 0.18em;
text-transform: uppercase;
}
.usage-summary-desc {
margin: 0;
color: var(--claude-text-secondary);
}
.usage-summary-note {
margin: 6px 0 0;
font-size: 12px;
color: var(--claude-text-tertiary);
}
.usage-summary-refresh {
border: 1px solid var(--claude-border);
border-radius: 12px;
padding: 6px 14px;
background: var(--claude-panel);
color: var(--claude-text);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
box-shadow: 0 6px 14px rgba(61, 57, 41, 0.12);
display: inline-flex;
align-items: center;
justify-content: center;
}
.usage-summary-refresh:disabled {
opacity: 0.7;
cursor: not-allowed;
box-shadow: none;
}
.usage-summary-refresh:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(61, 57, 41, 0.22);
}
.app-update-page .app-update-actions {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.app-update-changelog {
border: 1px solid var(--claude-border);
border-radius: 14px;
padding: 12px;
background: var(--claude-panel);
max-height: 300px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.app-update-changelog-title {
font-size: 13px;
font-weight: 600;
color: var(--claude-text);
margin-bottom: 8px;
}
.app-update-changelog-content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.55;
color: var(--claude-text-secondary);
flex: 1 1 auto;
min-height: 0;
max-height: 240px;
overflow: auto;
}
.usage-summary-grid {
display: grid;
gap: 14px;
}
.usage-summary-grid--tokens {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.usage-summary-grid--counts {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.usage-summary-item {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--claude-border);
background: transparent;
display: flex;
flex-direction: column;
gap: 6px;
}
.usage-summary-item .label {
font-size: 12px;
color: var(--claude-text-secondary);
}
.usage-summary-item .value {
font-size: 22px;
font-weight: 600;
color: var(--claude-text);
}
.usage-summary-item .value--success {
color: var(--claude-success);
}
.usage-summary-item .value--warning {
color: var(--claude-warning);
}
.usage-summary-item .value--accent {
color: var(--claude-text);
}
.usage-summary-meta {
font-size: 12px;
color: var(--claude-text-secondary);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: auto;
}
.usage-summary-error {
color: var(--claude-warning);
}
.admin-monitor-page {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
min-height: 320px;
padding: 12px 0 24px;
}
.admin-monitor-panel {
width: 100%;
max-width: 640px;
padding: 28px 32px;
border-radius: 26px;
border: 1px solid var(--theme-control-border, var(--claude-border));
background: linear-gradient(145deg, var(--theme-surface-soft), var(--theme-surface-muted));
box-shadow: var(--theme-shadow-soft, var(--claude-shadow));
display: flex;
flex-direction: column;
gap: 18px;
}
.admin-monitor-panel.secondary {
background: linear-gradient(145deg, var(--theme-surface-muted), var(--theme-surface-soft));
}
.admin-monitor-heading {
display: flex;
flex-direction: column;
gap: 6px;
}
.admin-monitor-title-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.admin-monitor-title-row h3 {
margin: 0;
font-size: 24px;
}
.admin-monitor-desc {
margin: 0;
color: var(--claude-text-secondary);
max-width: 460px;
}
.admin-monitor-chip {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--theme-chip-border, var(--claude-border));
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--claude-text-secondary);
background: var(--theme-chip-bg, var(--claude-panel));
}
.admin-monitor-eyebrow {
font-size: 12px;
color: var(--claude-accent-strong);
letter-spacing: 0.18em;
text-transform: uppercase;
}
.admin-monitor-highlights {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.admin-monitor-tag {
padding: 6px 14px;
border-radius: 999px;
border: 1px dashed var(--theme-chip-border, var(--claude-border));
background: var(--theme-chip-bg, var(--claude-panel));
font-size: 13px;
color: var(--claude-text-secondary);
}
.section-note {
margin: 6px 0 14px;
font-size: 12px;
color: var(--claude-text-secondary);
}
.admin-monitor-actions {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.admin-monitor-button {
border: none;
border-radius: 16px;
padding: 11px 28px;
background: linear-gradient(135deg, var(--claude-accent), var(--claude-accent-strong));
color: #fff;
font-weight: 600;
letter-spacing: 0.04em;
box-shadow: var(--theme-shadow-soft, var(--claude-shadow));
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.admin-monitor-button.ghost {
background: transparent;
color: var(--claude-text);
border: 1px dashed var(--theme-chip-border, var(--claude-border));
box-shadow: none;
}
.admin-monitor-button:hover {
transform: translateY(-1px);
box-shadow: var(--theme-shadow-mid, var(--claude-shadow));
}
.admin-monitor-button:active {
transform: translateY(0);
box-shadow: var(--theme-shadow-soft, var(--claude-shadow));
}
.admin-monitor-hint {
margin: 0;
font-size: 13px;
color: var(--claude-text-secondary);
}
@media (max-width: 640px) {
.usage-summary-refresh {
width: 100%;
justify-content: center;
}
.usage-summary-grid--tokens,
.usage-summary-grid--counts {
grid-template-columns: 1fr;
}
.admin-monitor-panel {
padding: 24px;
}
.admin-monitor-actions {
flex-direction: column;
align-items: stretch;
}
.admin-monitor-button {
width: 100%;
justify-content: center;
}
.admin-monitor-hint {
text-align: center;
}
}
</style>