feat: move model and mode pickers to header bar

This commit is contained in:
JOJO 2026-01-30 15:55:27 +08:00
parent 7890926c3d
commit 462b0ed6f3
4 changed files with 271 additions and 69 deletions

View File

@ -111,8 +111,71 @@
:format-rate="formatRate"
/>
<div v-if="titleRibbonVisible" class="conversation-ribbon">
<div
v-if="titleRibbonVisible"
class="conversation-ribbon"
ref="titleRibbon"
>
<button
type="button"
class="conversation-ribbon__selector"
:class="{ open: headerMenuOpen }"
@click.stop="toggleHeaderMenu"
:disabled="!isConnected"
>
<span class="selector-label">
<span class="selector-model">{{ currentModelLabel }}</span>
<span class="selector-sep">·</span>
<span class="selector-mode">{{ headerRunModeLabel }}</span>
</span>
<svg viewBox="0 0 24 24" aria-hidden="true" class="selector-caret">
<path d="M7 10l5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<span class="conversation-ribbon__text">{{ titleTypingText || currentConversationTitle }}</span>
<transition name="header-menu">
<div
v-if="headerMenuOpen"
class="model-mode-dropdown"
ref="headerMenu"
>
<div class="dropdown-column">
<div class="dropdown-title">模型</div>
<button
v-for="option in modelOptions"
:key="option.key"
type="button"
class="dropdown-item"
:class="{ active: option.key === currentModelKey, disabled: option.disabled }"
@click.stop="handleHeaderModelSelect(option.key, option.disabled)"
:disabled="streamingMessage || !isConnected || option.disabled"
>
<div class="item-label">{{ option.label }}</div>
<div class="item-desc">{{ option.description }}</div>
<span v-if="option.key === currentModelKey" class="item-check"></span>
</button>
</div>
<div class="dropdown-column">
<div class="dropdown-title">运行模式</div>
<button
v-for="option in headerRunModeOptions"
:key="option.value"
type="button"
class="dropdown-item"
:class="{ active: option.value === resolvedRunMode }"
@click.stop="handleHeaderRunModeSelect(option.value)"
:disabled="streamingMessage || !isConnected"
>
<div class="item-label">{{ option.label }}</div>
<div class="item-desc">{{ option.desc }}</div>
<span v-if="option.value === resolvedRunMode" class="item-check"></span>
</button>
</div>
</div>
</transition>
</div>
<ChatArea

View File

@ -269,6 +269,7 @@ const appOptions = {
titleTypingTimer: null,
titleReady: false,
suppressTitleTyping: false,
headerMenuOpen: false,
blankWelcomePool: [
'有什么可以帮忙的?',
'想了解些热点吗?',
@ -346,6 +347,7 @@ const appOptions = {
document.addEventListener('click', this.handleClickOutsideQuickMenu);
document.addEventListener('click', this.handleClickOutsidePanelMenu);
document.addEventListener('click', this.handleClickOutsideHeaderMenu);
document.addEventListener('click', this.handleClickOutsideMobileMenu);
window.addEventListener('popstate', this.handlePopState);
window.addEventListener('keydown', this.handleMobileOverlayEscape);
@ -436,6 +438,21 @@ const appOptions = {
}
return this.thinkingMode ? 'thinking' : 'fast';
},
headerRunModeOptions() {
return [
{ value: 'fast', label: '快速模式', desc: '低思考,响应更快' },
{ value: 'thinking', label: '思考模式', desc: '更长思考,综合回答' },
{ value: 'deep', label: '深度思考', desc: '持续推理,适合复杂任务' }
];
},
headerRunModeLabel() {
const current = this.headerRunModeOptions.find((o) => o.value === this.resolvedRunMode);
return current ? current.label : '快速模式';
},
currentModelLabel() {
const modelStore = useModelStore();
return modelStore.currentModel?.label || 'Kimi-k2.5';
},
policyUiBlocks() {
const store = usePolicyStore();
return store.uiBlocks || {};
@ -505,6 +522,7 @@ const appOptions = {
beforeUnmount() {
document.removeEventListener('click', this.handleClickOutsideQuickMenu);
document.removeEventListener('click', this.handleClickOutsidePanelMenu);
document.removeEventListener('click', this.handleClickOutsideHeaderMenu);
document.removeEventListener('click', this.handleClickOutsideMobileMenu);
window.removeEventListener('popstate', this.handlePopState);
window.removeEventListener('keydown', this.handleMobileOverlayEscape);
@ -2780,6 +2798,16 @@ const appOptions = {
}
},
toggleHeaderMenu() {
if (!this.isConnected) return;
this.headerMenuOpen = !this.headerMenuOpen;
if (this.headerMenuOpen) {
this.closeQuickMenu();
this.inputCloseMenus();
}
},
async handleModeSelect(mode) {
if (!this.isConnected || this.streamingMessage) {
return;
@ -2787,6 +2815,11 @@ const appOptions = {
await this.setRunMode(mode);
},
async handleHeaderRunModeSelect(mode) {
await this.handleModeSelect(mode);
this.closeHeaderMenu();
},
async handleModelSelect(key) {
if (!this.isConnected || this.streamingMessage) {
return;
@ -2863,6 +2896,12 @@ const appOptions = {
}
},
async handleHeaderModelSelect(key, disabled) {
if (disabled) return;
await this.handleModelSelect(key);
this.closeHeaderMenu();
},
async handleCycleRunMode() {
const modes: Array<'fast' | 'thinking' | 'deep'> = ['fast', 'thinking', 'deep'];
const currentMode = this.resolvedRunMode;
@ -3024,6 +3063,10 @@ const appOptions = {
this.closeQuickMenu();
},
closeHeaderMenu() {
this.headerMenuOpen = false;
},
handleReviewSelect(id) {
if (id === this.currentConversationId) {
this.uiPushToast({
@ -3208,6 +3251,16 @@ const appOptions = {
this.closeQuickMenu();
},
handleClickOutsideHeaderMenu(event) {
if (!this.headerMenuOpen) return;
const ribbon = this.$refs.titleRibbon as HTMLElement | undefined;
const menu = this.$refs.headerMenu as HTMLElement | undefined;
if ((ribbon && ribbon.contains(event.target)) || (menu && menu.contains(event.target))) {
return;
}
this.closeHeaderMenu();
},
handleClickOutsidePanelMenu(event) {
if (!this.panelMenuOpen) {
return;

View File

@ -26,24 +26,6 @@
>
发送图片
</button>
<button
type="button"
class="menu-entry has-submenu"
@click.stop="$emit('toggle-mode-menu')"
:disabled="!isConnected || streamingMessage"
>
<span>运行模式</span>
<span class="entry-arrow">{{ runModeLabel }}</span>
</button>
<button
type="button"
class="menu-entry has-submenu"
@click.stop="$emit('toggle-model-menu')"
:disabled="!isConnected"
>
<span>切换模型</span>
<span class="entry-arrow">{{ currentModelLabel }}</span>
</button>
<button
type="button"
class="menu-entry has-submenu"
@ -63,45 +45,6 @@
<span class="entry-arrow"></span>
</button>
<transition name="submenu-slide">
<div class="quick-submenu mode-submenu" v-if="modeMenuOpen">
<div class="submenu-list">
<button
v-for="option in runModeOptions"
:key="option.value"
type="button"
class="menu-entry submenu-entry"
:class="{ active: option.value === resolvedRunMode }"
@click.stop="$emit('select-run-mode', option.value)"
:disabled="streamingMessage || !isConnected"
>
{{ option.label }}
</button>
</div>
</div>
</transition>
<transition name="submenu-slide">
<div class="quick-submenu model-submenu" v-if="modelMenuOpen">
<div class="submenu-list">
<button
v-for="option in modelOptions"
:key="option.key"
type="button"
class="menu-entry submenu-entry"
:class="{ active: option.key === currentModelKey }"
@click.stop="$emit('select-model', option.key)"
:disabled="streamingMessage || !isConnected"
>
<span class="submenu-label">
<span>{{ option.label }}</span>
<span class="submenu-desc">{{ option.description }}</span>
</span>
<span v-if="option.key === currentModelKey" class="entry-arrow"></span>
</button>
</div>
</div>
</transition>
<transition name="submenu-slide">
<div class="quick-submenu tool-submenu" v-if="toolMenuOpen">

View File

@ -15,18 +15,19 @@
.conversation-ribbon {
position: absolute;
inset: 0 0 auto 0;
height: 38px;
height: 44px;
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: space-between;
color: #0f172a;
background: var(--chat-surface-color); /* 与对话区域保持一致且不透明 */
box-shadow: none;
pointer-events: none;
pointer-events: auto;
user-select: none;
backdrop-filter: none;
z-index: 40;
gap: 12px;
}
.conversation-ribbon::after {
@ -53,10 +54,12 @@
color: #0f172a;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.12);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-container.has-title-ribbon .messages-area {
padding-top: 42px;
padding-top: 54px;
}
.chat-container--monitor .virtual-monitor-surface {
@ -129,6 +132,146 @@
padding-top: 68px;
}
.conversation-ribbon__selector {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border-radius: 0;
border: none;
background: transparent;
box-shadow: none;
color: #0f172a;
font-weight: 600;
font-size: 14px;
line-height: 1;
cursor: pointer;
transition: color 0.15s ease;
}
.conversation-ribbon__selector:hover {
color: #111827;
}
.conversation-ribbon__selector:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.conversation-ribbon__selector.open {
color: #111827;
}
.selector-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.selector-model {
font-weight: 700;
}
.selector-sep {
opacity: 0.6;
}
.selector-mode {
font-weight: 500;
color: rgba(15, 23, 42, 0.8);
}
.selector-caret {
width: 14px;
height: 14px;
stroke: currentColor;
}
.model-mode-dropdown {
position: absolute;
top: 48px;
left: 16px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
padding: 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(8px);
pointer-events: auto;
min-width: 480px;
z-index: 50;
}
.dropdown-column {
display: flex;
flex-direction: column;
gap: 6px;
}
.dropdown-title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(15, 23, 42, 0.65);
padding: 2px 0;
}
.dropdown-item {
text-align: left;
background: transparent;
border: 1px solid transparent;
border-radius: 12px;
padding: 10px 12px;
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
cursor: pointer;
transition: all 0.18s ease;
color: #0f172a;
}
.dropdown-item:hover:not(.disabled) {
background: #f6f7fb;
}
.dropdown-item.disabled {
opacity: 0.55;
cursor: not-allowed;
}
.item-label {
font-weight: 700;
font-size: 14px;
}
.item-desc {
grid-column: 1 / 2;
font-size: 12px;
color: rgba(15, 23, 42, 0.65);
}
.item-check {
align-self: center;
font-weight: 800;
color: #0f172a;
}
.header-menu-enter-active,
.header-menu-leave-active {
transition: all 0.16s ease, opacity 0.16s ease;
transform-origin: top left;
}
.header-menu-enter-from,
.header-menu-leave-to {
opacity: 0;
transform: translateY(-6px) scale(0.98);
}
.scroll-lock-btn {
width: 36px;
height: 36px;