主要修复: 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>
1105 lines
50 KiB
Vue
1105 lines
50 KiB
Vue
<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>
|