agent-Specialization/static/src/components/personalization/PersonalizationDrawer.vue
JOJO 43409c523e fix: 移除错误的对话切换跳转逻辑并修复工具执行返回值问题
主要修复:
1. 移除前端"取消跳转到正在运行的对话"的错误逻辑
   - 删除 switchConversation 中的任务检查和确认提示
   - 删除 createNewConversation 中的跳转回运行对话逻辑
   - 删除 loadConversation 中对未定义变量 hasActiveTask 的引用

2. 修复后端工具执行返回值问题
   - 修复 execute_tool_calls 在用户停止时返回 None 的 bug
   - 确保所有返回路径都返回包含 stopped 和 last_tool_call_time 的字典

3. 其他改进
   - 添加代码复制功能 (handleCopyCodeClick)
   - 移除 FocusPanel 相关代码
   - 更新个性化配置 (enhanced_tool_display)
   - 样式和主题优化

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 17:42:07 +08:00

1105 lines
50 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">
<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" @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"
: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">
<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">
<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">
<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">
<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 === 'behavior'" key="behavior" class="personal-page behavior-page">
<div class="behavior-section">
<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">使用新版堆叠动画展示思考/工具块,超过 6 条自动收纳为"更多"。默认开启。</p>
</div>
<label class="toggle-row">
<input
type="checkbox"
:checked="experiments.stackedBlocksEnabled"
@change="handleStackedBlocksToggle($event)"
/>
<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.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">默认禁用工具类别</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">
<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">
<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 === 'experiments'" key="experiments" class="personal-page experiment-page">
<div class="experiment-hero">
<div class="experiment-visual" aria-hidden="true">
<span class="experiment-visual-glow"></span>
<span class="experiment-orb orb-one"></span>
<span class="experiment-orb orb-two"></span>
<span class="experiment-grid"></span>
</div>
<div class="experiment-copy">
<p class="experiment-subtitle">Liquid Glass</p>
<h3>液态玻璃界面实验</h3>
<p>
引用 Apple 最新的液态玻璃语言,通过折射位移贴图让 Q 弹玻璃漂浮在屏幕中。开启后,全局会出现一个可拖拽的实验面板,方便随时观察效果。
</p>
</div>
</div>
<div class="experiment-toggle-card">
<div class="experiment-toggle-info">
<h4>在界面上悬浮显示</h4>
<p>开启后即使关闭个人空间,可拖动的玻璃面板依然驻留在当前页面。</p>
</div>
<label class="personal-toggle experiment-toggle">
<span class="toggle-text">
<span class="toggle-title">液态玻璃实验面板</span>
<span class="toggle-desc">点击开关投放一块可拖动的液态玻璃矩形</span>
</span>
<span class="toggle-switch">
<input
type="checkbox"
:checked="experiments.liquidGlassEnabled"
@change="handleLiquidGlassToggle($event)"
/>
<span class="switch-slider"></span>
</span>
</label>
</div>
<div class="experiment-note">
<p>完成实验只需回到这里关闭开关,面板会立刻消失。</p>
<ul class="experiment-hint-list">
<li>可在屏幕任意位置拖动,超出边界会自动回弹。</li>
<li>记忆上次的位置,下次开启直接延续。</li>
<li>实验状态仅存储在本地浏览器,不会上报服务器。</li>
</ul>
</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 } from 'vue';
import { storeToRefs } from 'pinia';
import { usePersonalizationStore } from '@/stores/personalization';
import { useResourceStore } from '@/stores/resource';
import { useUiStore } from '@/stores/ui';
import { usePolicyStore } from '@/stores/policy';
import { useTheme } from '@/utils/theme';
import type { ThemeKey } from '@/utils/theme';
defineOptions({ name: 'PersonalizationDrawer' });
const personalization = usePersonalizationStore();
const resourceStore = useResourceStore();
const uiStore = useUiStore();
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: 'behavior', label: '模型行为', description: '工具提示与界面表现' },
{ id: 'skills', label: 'Skills', description: '可用技能开关' },
{ id: 'image', label: '图片压缩', description: '发送图片的尺寸策略' },
{ id: 'theme', label: '主题切换', description: '浅色 / 深色 / Claude' },
{ id: 'experiments', label: '实验功能', description: 'Liquid Glass' }
] as const;
type PersonalTab = 'preferences' | 'model' | 'behavior' | 'skills' | 'image' | 'theme' | 'experiments' | 'admin-monitor';
const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
const personalTabs = computed(() => {
const tabs = [...baseTabs];
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;
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 setActiveTab = (tab: PersonalTab) => {
activeTab.value = tab;
};
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 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 handleLiquidGlassToggle = (event: Event) => {
const target = event.target as HTMLInputElement | null;
personalization.setLiquidGlassExperimentEnabled(!!target?.checked);
};
const handleStackedBlocksToggle = (event: Event) => {
const target = event.target as HTMLInputElement | null;
personalization.setStackedBlocksEnabled(!!target?.checked);
};
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();
};
// ===== 主题切换 =====
import { useTheme } from '@/utils/theme';
import type { ThemeKey } from '@/utils/theme';
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>
.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(--claude-border);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(247, 243, 234, 0.92));
box-shadow: var(--claude-shadow);
display: flex;
flex-direction: column;
gap: 18px;
}
.admin-monitor-panel.secondary {
background: linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(247, 243, 234, 0.92));
}
.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: rgba(70, 56, 40, 0.82);
max-width: 460px;
}
.admin-monitor-chip {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(61, 57, 41, 0.15);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--claude-text-secondary);
background: rgba(255, 255, 255, 0.65);
}
.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 rgba(61, 57, 41, 0.18);
background: rgba(255, 255, 255, 0.8);
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: 0 14px 28px rgba(61, 57, 41, 0.18);
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 rgba(61, 57, 41, 0.25);
box-shadow: none;
}
.admin-monitor-button:hover {
transform: translateY(-1px);
box-shadow: 0 18px 32px rgba(61, 57, 41, 0.24);
}
.admin-monitor-button:active {
transform: translateY(0);
box-shadow: 0 12px 20px rgba(61, 57, 41, 0.2);
}
.admin-monitor-hint {
margin: 0;
font-size: 13px;
color: var(--claude-text-secondary);
}
@media (max-width: 640px) {
.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>