feat: 布局&AI助手调整

This commit is contained in:
chaoszhu
2026-08-01 15:49:53 +08:00
parent e83a6a9136
commit c88da51aac
27 changed files with 1374 additions and 390 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ export function renderTranscript(messages = []) {
return lines.join('\n')
}
const SUMMARY_PROMPT = `你在为一个 Linux 服务器运维助手压缩对话历史。请把下面的笔录浓缩成一段结构化摘要,供助手继续工作时参考。
const SUMMARY_PROMPT = `你在为一个 Linux 服务器 AI 助手压缩对话历史。请把下面的笔录浓缩成一段结构化摘要,供助手继续工作时参考。
必须保留:
1. **已执行的操作** —— 在哪台主机上做了什么,结果成功还是失败
+27
View File
@@ -0,0 +1,27 @@
export function normalizeAIConfig(config = {}) {
const source = config || {}
return {
...source,
ui: {
...(source.ui || {}),
petEnabled: source.ui?.petEnabled !== false
}
}
}
export function mergeAIProviderConfig(existingConfig = {}, providerConfig = {}) {
return normalizeAIConfig({
...providerConfig,
ui: {
...(existingConfig?.ui || {}),
...(providerConfig.ui || {})
}
})
}
export function mergeAIPreferences(existingConfig = {}, preferences = {}) {
return {
...(existingConfig?.ui || {}),
petEnabled: preferences.petEnabled
}
}
+2 -2
View File
@@ -43,7 +43,7 @@ function describeHosts(hosts) {
export function buildSystemPrompt(ctx) {
if (ctx.scope === 'terminal') return buildTerminalSystemPrompt(ctx)
return `你是 easynode 面板内置的运维助手。easynode 是一个 Linux 服务器管理面板,用户通过它管理多台远程主机。你的职责是帮用户查看状态、排查问题、执行运维操作。
return `你是 easynode 面板内置的 AI 助手。easynode 是一个 Linux 服务器管理面板,用户通过它管理多台远程主机。你的职责是帮用户查看状态、排查问题、执行运维操作。
## 权限
@@ -89,7 +89,7 @@ ${ describeAvailableTools(ctx) }
function buildTerminalSystemPrompt(ctx) {
const host = ctx.hosts?.[0]
const permission = describeMode(ctx.policy?.mode)
return `你是 easynode Web 终端内的 AI 助手。你只能协助用户分析当前浏览器终端,不能通过 SSH、SFTP 或任何后台连接主动访问主机。
return `你是 easynode Web 终端内的终端助手。你只能协助用户分析当前浏览器终端,不能通过 SSH、SFTP 或任何后台连接主动访问主机。
## 当前终端
+2 -2
View File
@@ -223,7 +223,7 @@ export async function getSession(id) {
/** 列表只返回摘要,不带 messages —— 历史可能有几百 KB */
export async function listSessions(filter = {}) {
const query = {}
// 旧会话创建时尚无 scope 字段,视为运维助手会话,不能因升级被历史列表隐藏。
// 旧会话创建时尚无 scope 字段,视为全局 AI 助手会话,不能因升级被历史列表隐藏。
if (filter.scope === 'terminal') query.scope = 'terminal'
if (filter.scope === 'ops') query.$or = [{ scope: 'ops' }, { scope: { $exists: false } }]
if (filter.hostId) query.hostId = filter.hostId
@@ -376,7 +376,7 @@ export async function removeSessions(filter = {}) {
query.scope = 'terminal'
query.hostId = filter.hostId
} else if (filter.scope === 'ops') {
// 兼容升级前未写入 scope 的运维助手历史。
// 兼容升级前未写入 scope 的全局 AI 助手历史。
query.$or = [{ scope: 'ops' }, { scope: { $exists: false } }]
} else {
throw new Error('无效的会话范围')
+1 -1
View File
@@ -36,7 +36,7 @@ export function buildTools(ctx) {
}).filter(Boolean))
}
// 运维助手未选择主机时是纯聊天模式。不能仅在 executor 里拒绝:
// AI 助手未选择主机时是纯聊天模式。不能仅在 executor 里拒绝:
// 那样模型仍可通过 host_list 枚举资产,并反复尝试越权工具调用。
if (!hasSelectedHosts(ctx)) return {}
+25 -5
View File
@@ -6,6 +6,7 @@
*/
import { AIConfigDB } from '../utils/db-class.js'
import { mergeAIProviderConfig, mergeAIPreferences, normalizeAIConfig } from '../ai/config.js'
import { deriveBaseURL } from '../ai/provider.js'
const aiConfigDB = new AIConfigDB().getInstance()
@@ -14,8 +15,7 @@ const MODEL_DISCOVERY_TIMEOUT_MS = 15 * 1000
async function getAIConfig({ res }) {
try {
const config = await aiConfigDB.findOneAsync({})
if (!config) return res.success({ data: {} })
res.success({ data: config })
res.success({ data: normalizeAIConfig(config) })
} catch {
res.fail({ msg: '获取配置失败' })
}
@@ -60,10 +60,11 @@ async function saveAIConfig({ res, request }) {
}
try {
const existConfig = await aiConfigDB.findOneAsync({})
const nextConfig = mergeAIProviderConfig(existConfig, body)
if (existConfig) {
await aiConfigDB.updateAsync({ _id: existConfig._id }, body)
await aiConfigDB.updateAsync({ _id: existConfig._id }, nextConfig)
} else {
await aiConfigDB.insertAsync(body)
await aiConfigDB.insertAsync(nextConfig)
}
res.success({ msg: 'save success', data: { success: true } })
} catch {
@@ -71,8 +72,27 @@ async function saveAIConfig({ res, request }) {
}
}
async function updateAIPreferences({ res, request }) {
const { petEnabled } = request.body || {}
if (typeof petEnabled !== 'boolean') return res.fail({ msg: 'param error' })
try {
const existConfig = await aiConfigDB.findOneAsync({})
const ui = mergeAIPreferences(existConfig, { petEnabled })
if (existConfig) {
await aiConfigDB.updateAsync({ _id: existConfig._id }, { $set: { ui } })
} else {
await aiConfigDB.insertAsync({ ui })
}
res.success({ msg: 'save success', data: { petEnabled } })
} catch {
res.fail({ msg: 'save AI preferences failed', data: { success: false } })
}
}
export {
getAIConfig,
saveAIConfig,
getAIModels
getAIModels,
updateAIPreferences
}
+11 -6
View File
@@ -8,7 +8,7 @@ import { getScriptList, getLocalScriptList, addScript, updateScriptList, removeS
import { getScriptGroupList, addScriptGroup, removeScriptGroup, updateScriptGroup } from '../controller/script-group.js'
import { getOnekeyRecord, removeOnekeyRecord } from '../controller/onekey.js'
import { getLog, saveIpWhiteList, removeSomeLoginRecords, revokeLoginSid } from '../controller/sessionLog.js'
import { getAIConfig, saveAIConfig, getAIModels } from '../controller/chat.js'
import { getAIConfig, saveAIConfig, getAIModels, updateAIPreferences } from '../controller/chat.js'
import { getAgentSessions, getAgentSessionDetail, updateAgentSession, forkAgentSession, removeAgentSession, clearAgentSessions, editAgentSessionMessage } from '../controller/agent-session.js'
import { getProxyList, addProxy, updateProxy, removeProxy } from '../controller/proxy.js'
import { getTerminalConfig, saveTerminalConfig } from '../controller/terminal-config.js'
@@ -311,11 +311,16 @@ const aiConfig = [
path: '/ai-config',
controller: getAIConfig
},
{
method: 'post',
path: '/ai-config',
controller: saveAIConfig
},
{
method: 'post',
path: '/ai-config',
controller: saveAIConfig
},
{
method: 'patch',
path: '/ai-config/preferences',
controller: updateAIPreferences
},
{
method: 'post',
path: '/ai-models',
+1 -1
View File
@@ -14,7 +14,7 @@
"test:api": "node test/test-rest-api-auth.js",
"test:ws": "node test/test-ws-comprehensive.js",
"test:mobile": "node test/test-mobile-crypto.js && node test/test-mobile-ssh-payload.js",
"test:ai": "node test/test-ai-safety.js && node test/test-ai-session.js && node test/test-ai-compaction.js && node test/test-ai-access.js && node test/test-ai-data.js"
"test:ai": "node test/test-ai-config.js && node test/test-ai-safety.js && node test/test-ai-session.js && node test/test-ai-compaction.js && node test/test-ai-access.js && node test/test-ai-data.js"
},
"keywords": [],
"author": "",
+46
View File
@@ -0,0 +1,46 @@
import {
mergeAIProviderConfig,
mergeAIPreferences,
normalizeAIConfig
} from '../app/ai/config.js'
let passed = 0
let failed = 0
const failures = []
function expect(label, actual, expected) {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
passed += 1
return
}
failed += 1
failures.push(`${ label }\n 期望: ${ JSON.stringify(expected) }\n 实际: ${ JSON.stringify(actual) }`)
}
expect('旧配置默认显示宠物入口', normalizeAIConfig({}).ui.petEnabled, true)
expect('保留已关闭的宠物入口状态', normalizeAIConfig({ ui: { petEnabled: false } }).ui.petEnabled, false)
expect(
'保存 Provider 时保留界面偏好',
mergeAIProviderConfig(
{ apiUrl: 'old', ui: { petEnabled: false, theme: 'robot' } },
{ apiUrl: 'new', apiKey: 'key', models: ['model'] }
),
{
apiUrl: 'new',
apiKey: 'key',
models: ['model'],
ui: { petEnabled: false, theme: 'robot' }
}
)
expect(
'更新宠物入口状态不丢失其他界面偏好',
mergeAIPreferences({ ui: { petEnabled: true, theme: 'robot' } }, { petEnabled: false }),
{ petEnabled: false, theme: 'robot' }
)
if (failures.length) {
console.error(`\n❌ AI 配置测试失败 (${ failed } 项)\n${ failures.join('\n') }`)
process.exit(1)
}
console.log(`\n✅ AI 配置测试全部通过 (${ passed } 项)`)
+3
View File
@@ -166,6 +166,9 @@ export default {
saveAIConfig(data) {
return axios({ url: '/ai-config', method: 'post', data })
},
updateAIPreferences(data) {
return axios({ url: '/ai-config/preferences', method: 'patch', data })
},
getAIModels(data) {
return axios({ url: '/ai-models', method: 'post', data })
},
-17
View File
@@ -5,23 +5,6 @@
.mobile_menu_drawer {
width: 82% !important;
.mobile_logo_wrap {
display: flex;
align-items: center;
justify-content: center;
margin-top: 15px;
img {
width: 30px;
}
h1 {
font-size: 14px;
margin-left: 3px;
// color: #ffc021;
background: linear-gradient(to right, #ffc021, #e4d1a1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
.el-drawer__body {
padding: 0;
}
+26 -2
View File
@@ -49,6 +49,16 @@
<el-icon><Sunny v-if="isDark" /><Moon v-else /></el-icon>
</div>
</el-dropdown-item>
<el-dropdown-item :disabled="savingAIPreferences || !$store.aiConfigLoaded" @click="toggleAIEntry">
<div class="account_menu_item">
<span>{{ aiEntryEnabled ? '隐藏 AI 助手' : '显示 AI 助手' }}</span>
<el-icon>
<Loading v-if="savingAIPreferences" class="is-loading" />
<Hide v-else-if="aiEntryEnabled" />
<View v-else />
</el-icon>
</div>
</el-dropdown-item>
<el-dropdown-item v-if="showCollapse" @click="$store.setMenuCollapse()">
<div class="account_menu_item">
<span>{{ collapsed ? '展开侧栏' : '收起侧栏' }}</span>
@@ -67,8 +77,8 @@
</template>
<script setup>
import { computed, getCurrentInstance } from 'vue'
import { Expand, Fold, Moon, Refresh, Sunny, SwitchButton, User } from '@element-plus/icons-vue'
import { computed, getCurrentInstance, ref } from 'vue'
import { Expand, Fold, Hide, Loading, Moon, Refresh, Sunny, SwitchButton, User, View } from '@element-plus/icons-vue'
defineProps({
collapsed: { type: Boolean, default: false },
@@ -84,6 +94,8 @@ const { proxy: { $router, $store, $message } } = getCurrentInstance()
const user = computed(() => $store.user)
const isDark = computed(() => $store.isDark)
const isPlusActive = computed(() => $store.isPlusActive)
const aiEntryEnabled = computed(() => $store.aiConfig.ui?.petEnabled !== false)
const savingAIPreferences = ref(false)
function toggleTheme() {
$store.setTheme(!isDark.value)
@@ -93,6 +105,18 @@ function gotoPlusPage() {
$router.push('/setting?tabKey=plus')
}
async function toggleAIEntry() {
if (savingAIPreferences.value) return
savingAIPreferences.value = true
try {
await $store.setAIPreferences({ petEnabled: !aiEntryEnabled.value })
} catch (error) {
$message.error(error.message || '保存 AI 助手显示设置失败')
} finally {
savingAIPreferences.value = false
}
}
async function handleLogout() {
try {
await $store.removeLoginInfo(true)
@@ -21,11 +21,11 @@
<h3>助手类型</h3>
<div class="assistant_comparison">
<section :class="{ 'is_current': scope === 'ops' }">
<strong>运维助手</strong>
<strong>EasyNode · 助手</strong>
<p>面向所选的一台或多台主机,通过独立 SSH / SFTP 查询状态、执行脚本和读写文件,不共享 Web 终端的当前目录与环境。</p>
</section>
<section :class="{ 'is_current': scope === 'terminal' }">
<strong>终端助手</strong>
<strong>终端助手 · 当前终端</strong>
<p>仅绑定当前终端和单台主机,命令会写入当前 PTY,继承当前目录、环境变量及会话状态。</p>
</section>
</div>
@@ -63,7 +63,7 @@ const props = defineProps({
import useStore from '@/store'
const $store = useStore()
const isDark = computed(() => $store.isDark)
// 运维助手的代码块没有执行入口;只有绑定到某个 Web 终端的 AI 对话才允许。
// 全局 AI 助手的代码块没有执行入口;只有绑定到某个 Web 终端的 AI 对话才允许。
const canExecute = computed(() => props.customId.startsWith('terminal-agent-'))
// 获取语言标签
+506 -257
View File
@@ -1,227 +1,248 @@
<template>
<el-drawer
v-model="visible"
:size="drawerSize"
:with-header="false"
:close-on-press-escape="false"
:modal="false"
:resizable="!isMobileScreen"
append-to-body
modal-class="agent_drawer_overlay"
direction="rtl"
class="agent_drawer"
@open="handleOpen"
@opened="handleOpened"
@mousedown="handleDrawerMouseDown"
>
<div class="agent_layout" :class="{ 'is_dark': isDark }">
<transition name="session_panel">
<SessionList
v-if="showSessions"
class="layout_side"
:sessions="sessions"
:active-id="state.sessionId"
clear-scope-label="运维助手"
@select="handleSelectSession"
@create="handleNewSession"
@remove="handleRemoveSession"
@clear="handleClearSessions"
@rename="renameSession"
<Teleport to="body">
<section
v-show="visible"
ref="windowRef"
class="agent_window"
:class="{
'is_dark': isDark,
'is_mobile': isMobileScreen,
'is_maximized': maximized,
'is_interacting': Boolean(windowInteraction)
}"
:style="windowStyle"
role="dialog"
aria-label="AI 助手"
>
<template v-if="!isMobileScreen && !maximized">
<i
v-for="direction in RESIZE_DIRECTIONS"
:key="direction"
:class="['resize_handle', `resize_${ direction }`]"
@pointerdown.stop.prevent="startWindowResize($event, direction)"
/>
</transition>
</template>
<div class="layout_main">
<header class="agent_header">
<el-button link :title="showSessions ? '收起历史' : '展开历史'" @click="showSessions = !showSessions">
<el-icon><Expand v-if="!showSessions" /><Fold v-else /></el-icon>
</el-button>
<span class="header_title" :title="state.title">{{ state.title || 'AI 运维助手' }}</span>
<span class="header_spacer" />
<el-tag
v-if="!connected"
type="danger"
size="small"
effect="plain"
>
未连接
</el-tag>
<AssistantInfoPopover
scope="ops"
:tools="options.tools"
:preset="settings.preset"
:selected-host-count="settings.hostIds.length"
:plus-available="options.plusAvailable"
:connected="connected"
<div class="agent_layout" :class="{ 'is_dark': isDark }">
<transition name="session_panel">
<SessionList
v-if="showSessions"
class="layout_side"
:sessions="sessions"
:active-id="state.sessionId"
clear-scope-label="AI 助手"
@select="handleSelectSession"
@create="handleNewSession"
@remove="handleRemoveSession"
@clear="handleClearSessions"
@rename="renameSession"
/>
<el-button
class="header_icon_button"
link
title="新会话"
@click="handleNewSession"
>
<el-icon><Plus /></el-icon>
</el-button>
<el-button
class="header_icon_button"
link
title="AI助手设置"
@click="openAgentSettings"
>
<el-icon><Setting /></el-icon>
</el-button>
<el-button
class="header_icon_button"
link
title="关闭"
@click="visible = false"
>
<el-icon><Close /></el-icon>
</el-button>
</header>
</transition>
<el-alert
v-if="connectError"
class="agent_alert"
type="error"
:title="connectError"
:closable="false"
show-icon
/>
<el-alert
v-else-if="state.plusRequired"
class="agent_alert plus_required_alert"
type="warning"
show-icon
center
@close="dismissPlusRequired"
@mouseenter="pausePlusRequiredTimer"
@mouseleave="resumePlusRequiredTimer"
>
<template #title>
<span class="plus_alert_message">{{ state.plusRequired.message }}</span>
<el-button
class="plus_activation_button"
type="primary"
link
@click="openPlusSettings"
>
去激活
<div class="layout_main">
<header class="agent_header" @pointerdown="startWindowDrag" @dblclick="handleHeaderDoubleClick">
<el-button link :title="showSessions ? '收起历史' : '展开历史'" @click="showSessions = !showSessions">
<el-icon><Expand v-if="!showSessions" /><Fold v-else /></el-icon>
</el-button>
</template>
</el-alert>
<el-alert
v-else-if="notice"
class="agent_alert"
type="warning"
:title="notice"
show-icon
@close="dismissNotice"
/>
<el-alert
v-else-if="clampedTip"
class="agent_alert"
type="info"
:title="clampedTip"
:closable="false"
show-icon
/>
<el-scrollbar ref="scrollRef" class="agent_body" @scroll="handleScroll">
<div ref="bodyRef" class="body_inner">
<div v-if="!state.messages.length" class="empty_state">
<el-icon class="empty_icon"><ChatDotRound /></el-icon>
<p class="empty_title">我可以帮你排查问题、查看状态、执行运维操作</p>
</div>
<span class="header_title" :title="state.title">
EasyNode · 助手<span v-if="state.title"> · {{ state.title }}</span>
</span>
<MessageItem
v-for="(message, index) in state.messages"
:key="message.id"
:message="message"
:running="state.running"
:waiting-for-model="state.waitingForModel && index === state.messages.length - 1"
:editable="message.role === 'user' && !state.running"
:editing="editingMessageId === message.id"
:regeneratable="message.role === 'assistant' && !state.running"
@start-edit="editingMessageId = message.id"
@cancel-edit="editingMessageId = ''"
@confirm-edit="handleConfirmEdit"
@regenerate="handleRegenerate"
@fork="handleFork"
<span class="header_spacer" />
<el-tag
v-if="!connected"
type="danger"
size="small"
effect="plain"
>
未连接
</el-tag>
<AssistantInfoPopover
scope="ops"
:tools="options.tools"
:preset="settings.preset"
:selected-host-count="settings.hostIds.length"
:plus-available="options.plusAvailable"
:connected="connected"
/>
<el-button
class="header_icon_button"
link
title="新会话"
@click="handleNewSession"
>
<el-icon><Plus /></el-icon>
</el-button>
<el-button
class="header_icon_button"
link
title="AI 助手设置"
@click="openAgentSettings"
>
<el-icon><Setting /></el-icon>
</el-button>
<el-button
v-if="!isMobileScreen"
class="header_icon_button"
link
:title="maximized ? '还原窗口' : '最大化窗口'"
@click="toggleMaximize"
>
<el-icon><CopyDocument v-if="maximized" /><FullScreen v-else /></el-icon>
</el-button>
<el-button
class="header_icon_button"
link
title="关闭"
@click="visible = false"
>
<el-icon><Close /></el-icon>
</el-button>
</header>
<ApprovalPrompt
v-for="item in state.pendingApprovals"
:key="item.requestId"
:item="item"
@respond="respondApproval"
/>
<p v-if="state.error" class="turn_error">{{ state.error }}</p>
<p v-else-if="state.aborted" class="turn_aborted">已停止生成</p>
</div>
</el-scrollbar>
<el-button
v-if="!isAtBottom"
class="to_bottom"
circle
size="small"
@click="scrollToBottom"
>
<el-icon><Bottom /></el-icon>
</el-button>
<footer class="agent_footer">
<ChatSender
ref="senderRef"
v-model:value="draft"
:loading="state.running"
:placeholder="senderPlaceholder"
@submit="handleSend"
@cancel="stop"
<el-alert
v-if="connectError"
class="agent_alert"
type="error"
:title="connectError"
:closable="false"
show-icon
/>
<el-alert
v-else-if="state.plusRequired"
class="agent_alert plus_required_alert"
type="warning"
show-icon
center
@close="dismissPlusRequired"
@mouseenter="pausePlusRequiredTimer"
@mouseleave="resumePlusRequiredTimer"
>
<template #title>
<span class="plus_alert_message">{{ state.plusRequired.message }}</span>
<el-button
class="plus_activation_button"
type="primary"
link
@click="openPlusSettings"
>
去激活
</el-button>
</template>
</el-alert>
<el-alert
v-else-if="notice"
class="agent_alert"
type="warning"
:title="notice"
show-icon
@close="dismissNotice"
/>
<el-alert
v-else-if="clampedTip"
class="agent_alert"
type="info"
:title="clampedTip"
:closable="false"
show-icon
/>
<div class="footer_controls">
<div class="mode_control">
<ModeSwitcher :model-value="settings.preset" :presets="presets" @change="setPreset" />
</div>
<el-scrollbar ref="scrollRef" class="agent_body" @scroll="handleScroll">
<div ref="bodyRef" class="body_inner">
<div v-if="!state.messages.length" class="empty_state">
<el-icon class="empty_icon"><ChatDotRound /></el-icon>
<p class="empty_title">选择一台或多台主机,我可以帮你排查问题、查看状态和执行运维操作</p>
</div>
<el-select
:model-value="settings.modelId"
size="small"
class="model_select"
placeholder="选择模型"
@update:model-value="setModel"
>
<el-option
v-for="model in options.models"
:key="model"
:label="model"
:value="model"
<MessageItem
v-for="(message, index) in state.messages"
:key="message.id"
:message="message"
:running="state.running"
:waiting-for-model="state.waitingForModel && index === state.messages.length - 1"
:editable="message.role === 'user' && !state.running"
:editing="editingMessageId === message.id"
:regeneratable="message.role === 'assistant' && !state.running"
@start-edit="editingMessageId = message.id"
@cancel-edit="editingMessageId = ''"
@confirm-edit="handleConfirmEdit"
@regenerate="handleRegenerate"
@fork="handleFork"
/>
</el-select>
<HostSelector v-model="settings.hostIds" class="host_select" />
<ApprovalPrompt
v-for="item in state.pendingApprovals"
:key="item.requestId"
:item="item"
@respond="respondApproval"
/>
<span class="footer_spacer" />
</div>
</footer>
<p v-if="state.error" class="turn_error">{{ state.error }}</p>
<p v-else-if="state.aborted" class="turn_aborted">已停止生成</p>
</div>
</el-scrollbar>
<el-button
v-if="!isAtBottom"
class="to_bottom"
circle
size="small"
@click="scrollToBottom"
>
<el-icon><Bottom /></el-icon>
</el-button>
<footer class="agent_footer">
<ChatSender
ref="senderRef"
v-model:value="draft"
:loading="state.running"
:placeholder="senderPlaceholder"
@submit="handleSend"
@cancel="stop"
/>
<div class="footer_controls">
<div class="mode_control">
<ModeSwitcher :model-value="settings.preset" :presets="presets" @change="setPreset" />
</div>
<el-select
:model-value="settings.modelId"
size="small"
class="model_select"
placeholder="选择模型"
@update:model-value="setModel"
>
<el-option
v-for="model in options.models"
:key="model"
:label="model"
:value="model"
/>
</el-select>
<HostSelector v-model="settings.hostIds" class="host_select" />
<span class="footer_spacer" />
</div>
</footer>
</div>
</div>
</div>
</el-drawer>
</section>
</Teleport>
</template>
<script setup>
import { ref, computed, nextTick, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
import { ref, reactive, computed, nextTick, onMounted, onBeforeUnmount, getCurrentInstance, watch } from 'vue'
import { setCustomComponents } from 'markstream-vue'
import 'markstream-vue/index.css'
import 'highlight.js/styles/github-dark.css'
import {
Plus, Close, Setting, Expand, Fold, ChatDotRound, Bottom
Plus, Close, Setting, Expand, Fold, ChatDotRound, Bottom, CopyDocument, FullScreen
} from '@element-plus/icons-vue'
import CustomCodeBlock from './custom-code-block.vue'
import { EventBus } from '@/utils'
@@ -240,6 +261,7 @@ import { PRESET_FALLBACK } from './presets'
const { proxy: { $api, $message, $store, $router } } = getCurrentInstance()
const visible = defineModel('show', { type: Boolean, default: false })
const emit = defineEmits(['status-change',])
const {
connected,
@@ -279,15 +301,41 @@ let autoFollow = true
let bodyResizeObserver = null
const dismissedNotice = ref('')
const editingMessageId = ref('')
const DRAWER_WIDTH_KEY = 'agentDrawerWidth'
const MIN_DRAWER_WIDTH = 300
const DEFAULT_DRAWER_WIDTH = 350
const savedDrawerWidth = ref(readSavedDrawerWidth())
const windowRef = ref(null)
const maximized = ref(false)
const windowInteraction = ref(null)
const WINDOW_GEOMETRY_KEY = 'agentWindowGeometry'
const LEGACY_DRAWER_WIDTH_KEY = 'agentDrawerWidth'
const WINDOW_EDGE_GAP = 12
const DEFAULT_WINDOW_WIDTH = 420
const DEFAULT_WINDOW_HEIGHT = 680
const MIN_WINDOW_WIDTH = 360
const MIN_WINDOW_HEIGHT = 480
const RESIZE_DIRECTIONS = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw',]
const windowGeometry = reactive({
x: WINDOW_EDGE_GAP,
y: WINDOW_EDGE_GAP,
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT
})
const isDark = computed(() => $store.isDark)
const drawerSize = computed(() => {
if (isMobileScreen.value) return '100%'
return `${ savedDrawerWidth.value || DEFAULT_DRAWER_WIDTH }px`
const windowStyle = computed(() => {
if (isMobileScreen.value) return {}
if (maximized.value) {
return {
left: `${ WINDOW_EDGE_GAP }px`,
top: `${ WINDOW_EDGE_GAP }px`,
width: `calc(100vw - ${ WINDOW_EDGE_GAP * 2 }px)`,
height: `calc(100vh - ${ WINDOW_EDGE_GAP * 2 }px)`
}
}
return {
left: `${ windowGeometry.x }px`,
top: `${ windowGeometry.y }px`,
width: `${ windowGeometry.width }px`,
height: `${ windowGeometry.height }px`
}
})
const senderPlaceholder = computed(() => (settings.hostIds.length
? '输入任务或者问题,Enter 发送'
@@ -317,7 +365,7 @@ function handleOpen() {
function handleOpened() {
focusSender()
if (!bodyResizeObserver) {
if (!bodyResizeObserver && bodyRef.value) {
bodyResizeObserver = new ResizeObserver(() => {
if (autoFollow) scrollToBottom()
})
@@ -326,27 +374,156 @@ function handleOpened() {
scrollToBottom()
}
function readSavedDrawerWidth() {
const width = Number(localStorage.getItem(DRAWER_WIDTH_KEY))
return Number.isFinite(width) && width > 0 ? Math.max(MIN_DRAWER_WIDTH, Math.round(width)) : 0
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max)
}
function handleDrawerMouseDown(event) {
if (isMobileScreen.value || !event.target?.closest?.('.el-drawer__dragger')) return
window.removeEventListener('mouseup', rememberDrawerWidth)
window.addEventListener('mouseup', rememberDrawerWidth, { once: true })
function normalizeWindowGeometry(input = {}) {
const maxWidth = Math.max(1, window.innerWidth - WINDOW_EDGE_GAP * 2)
const maxHeight = Math.max(1, window.innerHeight - WINDOW_EDGE_GAP * 2)
const minWidth = Math.min(MIN_WINDOW_WIDTH, maxWidth)
const minHeight = Math.min(MIN_WINDOW_HEIGHT, maxHeight)
const width = Math.round(clamp(Number(input.width) || DEFAULT_WINDOW_WIDTH, minWidth, maxWidth))
const height = Math.round(clamp(Number(input.height) || DEFAULT_WINDOW_HEIGHT, minHeight, maxHeight))
const maxX = Math.max(WINDOW_EDGE_GAP, window.innerWidth - width - WINDOW_EDGE_GAP)
const maxY = Math.max(WINDOW_EDGE_GAP, window.innerHeight - height - WINDOW_EDGE_GAP)
return {
x: Math.round(clamp(Number(input.x) || WINDOW_EDGE_GAP, WINDOW_EDGE_GAP, maxX)),
y: Math.round(clamp(Number(input.y) || WINDOW_EDGE_GAP, WINDOW_EDGE_GAP, maxY)),
width,
height
}
}
function rememberDrawerWidth() {
requestAnimationFrame(() => {
const drawer = document.querySelector('.agent_drawer.el-drawer')
if (!drawer) return
const width = Math.max(MIN_DRAWER_WIDTH, Math.round(drawer.getBoundingClientRect().width))
savedDrawerWidth.value = width
localStorage.setItem(DRAWER_WIDTH_KEY, String(width))
function applyWindowGeometry(input) {
Object.assign(windowGeometry, normalizeWindowGeometry(input))
}
function saveWindowGeometry() {
localStorage.setItem(WINDOW_GEOMETRY_KEY, JSON.stringify({ ...windowGeometry }))
}
function restoreWindowGeometry() {
try {
const saved = JSON.parse(localStorage.getItem(WINDOW_GEOMETRY_KEY) || 'null')
if (saved && ['x', 'y', 'width', 'height',].every((key) => Number.isFinite(Number(saved[key])))) {
applyWindowGeometry(saved)
return
}
} catch (error) {
console.warn('读取 AI 助手窗口位置失败:', error.message)
}
const legacyWidth = Number(localStorage.getItem(LEGACY_DRAWER_WIDTH_KEY))
const width = Number.isFinite(legacyWidth) && legacyWidth > 0 ? legacyWidth : DEFAULT_WINDOW_WIDTH
const initial = normalizeWindowGeometry({ width, height: DEFAULT_WINDOW_HEIGHT })
applyWindowGeometry({
...initial,
x: window.innerWidth - initial.width - 24,
y: 24
})
}
function addWindowInteractionListeners() {
window.addEventListener('pointermove', handleWindowInteractionMove)
window.addEventListener('pointerup', finishWindowInteraction)
window.addEventListener('pointercancel', finishWindowInteraction)
}
function removeWindowInteractionListeners() {
window.removeEventListener('pointermove', handleWindowInteractionMove)
window.removeEventListener('pointerup', finishWindowInteraction)
window.removeEventListener('pointercancel', finishWindowInteraction)
}
function startWindowDrag(event) {
if (event.button !== 0 || isMobileScreen.value || maximized.value) return
if (event.target?.closest?.('button, a, input, textarea, select, [role="button"], .el-select')) return
windowInteraction.value = {
type: 'move',
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
geometry: { ...windowGeometry }
}
addWindowInteractionListeners()
event.preventDefault()
}
function startWindowResize(event, direction) {
if (event.button !== 0 || isMobileScreen.value || maximized.value) return
windowInteraction.value = {
type: 'resize',
direction,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
geometry: { ...windowGeometry }
}
addWindowInteractionListeners()
}
function resizeGeometry(interaction, deltaX, deltaY) {
const { direction, geometry } = interaction
const next = { ...geometry }
if (direction.includes('e')) next.width = geometry.width + deltaX
if (direction.includes('s')) next.height = geometry.height + deltaY
if (direction.includes('w')) {
next.width = geometry.width - deltaX
next.x = geometry.x + deltaX
}
if (direction.includes('n')) {
next.height = geometry.height - deltaY
next.y = geometry.y + deltaY
}
const normalized = normalizeWindowGeometry(next)
if (direction.includes('w')) normalized.x = geometry.x + geometry.width - normalized.width
if (direction.includes('n')) normalized.y = geometry.y + geometry.height - normalized.height
return normalizeWindowGeometry(normalized)
}
function handleWindowInteractionMove(event) {
const interaction = windowInteraction.value
if (!interaction || interaction.pointerId !== event.pointerId) return
const deltaX = event.clientX - interaction.startX
const deltaY = event.clientY - interaction.startY
if (interaction.type === 'move') {
applyWindowGeometry({
...interaction.geometry,
x: interaction.geometry.x + deltaX,
y: interaction.geometry.y + deltaY
})
} else {
applyWindowGeometry(resizeGeometry(interaction, deltaX, deltaY))
}
}
function finishWindowInteraction(event) {
const interaction = windowInteraction.value
if (!interaction || interaction.pointerId !== event.pointerId) return
windowInteraction.value = null
removeWindowInteractionListeners()
saveWindowGeometry()
}
function toggleMaximize() {
if (isMobileScreen.value) return
maximized.value = !maximized.value
if (!maximized.value) nextTick(() => applyWindowGeometry(windowGeometry))
}
function handleHeaderDoubleClick(event) {
if (event.target?.closest?.('button, a, input, [role="button"]')) return
toggleMaximize()
}
function handleViewportResize() {
if (isMobileScreen.value || maximized.value) return
applyWindowGeometry(windowGeometry)
saveWindowGeometry()
}
function openAgentSettings() {
$router.push({ path: '/setting', query: { tabKey: 'ai-agent' } })
}
@@ -355,7 +532,6 @@ function openPlusSettings() {
$router.push(state.plusRequired?.activationPath || '/setting?tabKey=plus')
}
// Drawer 的 focus trap 会在打开期间接管焦点;等 opened 后再聚焦输入框。
function focusSender() {
nextTick(() => {
requestAnimationFrame(() => senderRef.value?.focus())
@@ -372,14 +548,34 @@ function handleExternalInput(text) {
EventBus.$on('sendToAIInput', handleExternalInput)
watch(visible, async (show) => {
if (!show) return
handleOpen()
await nextTick()
handleOpened()
})
watch([() => state.running, connectError, () => state.completionId,], ([running, error, completionId,]) => {
emit('status-change', { running, connectError: error || '', completionId })
}, { immediate: true })
watch(isMobileScreen, (mobile) => {
if (mobile) maximized.value = false
else nextTick(() => applyWindowGeometry(windowGeometry))
})
onMounted(() => {
restoreWindowGeometry()
window.addEventListener('resize', handleViewportResize)
// 让 markdown 里的代码块带上「复制 / 在终端执行」按钮。
// custom-id 要与 message-item.vue 里传给 MarkdownRender 的一致。
setCustomComponents('agent', { code_block: CustomCodeBlock })
})
onBeforeUnmount(() => {
window.removeEventListener('mouseup', rememberDrawerWidth)
EventBus.$off('sendToAIInput', handleExternalInput)
window.removeEventListener('resize', handleViewportResize)
removeWindowInteractionListeners()
bodyResizeObserver?.disconnect()
})
@@ -500,7 +696,7 @@ async function handleClearSessions() {
try {
await clearSessions()
showSessions.value = false
$message.success('已清空运维助手历史会话')
$message.success('已清空 AI 助手历史会话')
} catch (error) {
$message.error(`清空失败: ${ error.message }`)
}
@@ -525,10 +721,94 @@ function handleScroll({ scrollTop }) {
</script>
<style lang="scss" scoped>
.agent_window {
position: fixed;
z-index: 1900;
box-sizing: border-box;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 12px;
background-color: var(--el-bg-color);
box-shadow: 0 18px 48px rgba(16, 24, 40, 0.24);
&.is_interacting {
user-select: none;
}
&.is_dark {
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.55);
}
&.is_mobile {
inset: 0 !important;
width: 100vw !important;
height: 100dvh !important;
border: 0;
border-radius: 0;
.agent_header {
height: calc(44px + env(safe-area-inset-top));
padding-top: calc(8px + env(safe-area-inset-top));
cursor: default;
}
.agent_footer {
padding-bottom: calc(12px + env(safe-area-inset-bottom));
}
.layout_side {
top: calc(44px + env(safe-area-inset-top));
height: calc(100% - 44px - env(safe-area-inset-top));
}
}
}
.resize_handle {
position: absolute;
z-index: 10;
display: block;
touch-action: none;
&.resize_n,
&.resize_s {
left: 10px;
right: 10px;
height: 7px;
cursor: ns-resize;
}
&.resize_e,
&.resize_w {
top: 10px;
bottom: 10px;
width: 7px;
cursor: ew-resize;
}
&.resize_n { top: 0; }
&.resize_s { bottom: 0; }
&.resize_e { right: 0; }
&.resize_w { left: 0; }
&.resize_ne,
&.resize_nw,
&.resize_se,
&.resize_sw {
width: 12px;
height: 12px;
}
&.resize_ne { top: 0; right: 0; cursor: nesw-resize; }
&.resize_nw { top: 0; left: 0; cursor: nwse-resize; }
&.resize_se { right: 0; bottom: 0; cursor: nwse-resize; }
&.resize_sw { bottom: 0; left: 0; cursor: nesw-resize; }
}
.agent_layout {
position: relative;
height: 100%;
overflow: hidden;
background-color: var(--el-bg-color);
.layout_side {
position: absolute;
@@ -558,6 +838,7 @@ function handleScroll({ scrollTop }) {
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid #e4e7ed;
cursor: move;
.header_title {
font-size: 14px;
@@ -754,35 +1035,3 @@ function handleScroll({ scrollTop }) {
transform: translateX(-12px);
}
</style>
<style lang="scss">
.agent_drawer {
&.el-drawer {
// AI 面板挂在 sticky 顶栏中时,Element Plus 的进入过渡会叠加一次布局
// 重算,表现为右侧滑入后回弹。移到 body 后禁用这条默认过渡。
max-width: 100vw;
transition: none !important;
}
.el-drawer__body {
padding: 0;
overflow: hidden;
}
}
@media (min-width: 968px) {
.agent_drawer.el-drawer {
min-width: 350px;
}
}
// modal=false 下 Element Plus 仍会渲染一个全屏容器。让其穿透,
// 仅保留抽屉本身接收点击,背景页面即可继续操作。
.agent_drawer_overlay {
pointer-events: none;
.agent_drawer {
pointer-events: auto;
}
}
</style>
+384 -33
View File
@@ -1,55 +1,165 @@
<template>
<div class="ai_entry_host">
<div
v-show="enabled"
ref="entryRef"
class="entry_button"
:class="{ 'is_dragging': dragging }"
:class="{
'is_dragging': dragging,
'is_resizing': resizing,
'is_running': agentStatus.running,
'has_error': agentStatus.connectError
}"
:style="entryStyle"
role="button"
tabindex="0"
aria-label="打开 AI 助手"
:aria-label="visible ? '关闭 AI 助手' : '打开 AI 助手'"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="handlePointerCancel"
@keydown.enter.prevent="visible = true"
@keydown.space.prevent="visible = true"
@pointerenter="handlePointerEnter"
@pointerleave="handlePointerLeave"
@keydown.enter.prevent="visible = !visible"
@keydown.space.prevent="visible = !visible"
>
<img src="@/assets/image/coding32.png" alt="">
<span>AI 助手</span>
<AiPet class="pet_sprite" :state="petState" :hovered="hovering" />
<button
ref="resizeHandleRef"
class="pet_resize_handle"
type="button"
aria-label="调整宠物大小"
title="调整宠物大小"
@pointerdown.stop.prevent="startResize"
@pointermove.stop.prevent="handleResizeMove"
@pointerup.stop.prevent="finishResize"
@pointercancel.stop.prevent="finishResize"
@click.stop.prevent
@keydown.left.stop.prevent="resizeBy(8)"
@keydown.up.stop.prevent="resizeBy(-8)"
@keydown.right.stop.prevent="resizeBy(-8)"
@keydown.down.stop.prevent="resizeBy(8)"
>
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 11L11 3M7 3H11V7M7 11H3V7" />
</svg>
</button>
</div>
<AiAgent v-model:show="visible" />
<AiAgent v-model:show="visible" @status-change="handleStatusChange" />
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import AiAgent from '@/components/ai-agent/index.vue'
import AiPet from '@/components/ai-pet.vue'
const props = defineProps({
enabled: { type: Boolean, default: true }
})
const POSITION_KEY = 'aiEntryPosition'
const SIZE_KEY = 'aiEntrySize'
const EDGE_GAP = 12
const DEFAULT_GAP = 24
const DESKTOP_DEFAULT_BOTTOM_GAP = 88
const MOBILE_BREAKPOINT = 968
const DRAG_THRESHOLD = 5
const DEFAULT_SIZE = 56
const MIN_SIZE = 48
const MAX_SIZE = 160
const IDLE_MIN_DELAY = 6000
const IDLE_MAX_DELAY = 12000
const IDLE_EXPRESSION_DURATION = 800
const HAPPY_DURATION = 2000
const visible = ref(false)
const entryRef = ref(null)
const resizeHandleRef = ref(null)
const dragging = ref(false)
const resizing = ref(false)
const hovering = ref(false)
const idleExpression = ref(false)
const happyExpression = ref(false)
const dragPetState = ref('')
const size = ref(DEFAULT_SIZE)
const position = reactive({ x: 0, y: 0 })
const agentStatus = reactive({ running: false, connectError: '', completionId: 0 })
let pointerState = null
let resizeState = null
let idleTimer = null
let idleExpressionTimer = null
let happyTimer = null
const entryStyle = computed(() => ({
left: `${ position.x }px`,
top: `${ position.y }px`
top: `${ position.y }px`,
width: `${ size.value }px`,
height: `${ size.value }px`
}))
const petState = computed(() => {
if (dragging.value && dragPetState.value) return dragPetState.value
if (happyExpression.value) return 'happy'
if (agentStatus.running) return 'working'
if (idleExpression.value) return 'idle'
return 'normal'
})
function clearIdleTimers() {
window.clearTimeout(idleTimer)
window.clearTimeout(idleExpressionTimer)
idleTimer = null
idleExpressionTimer = null
}
function canShowIdleExpression() {
return props.enabled && !document.hidden && !agentStatus.running && !happyExpression.value && !dragging.value && !resizing.value && !hovering.value
}
function scheduleIdleExpression() {
clearIdleTimers()
idleExpression.value = false
if (!canShowIdleExpression()) return
const delay = IDLE_MIN_DELAY + Math.random() * (IDLE_MAX_DELAY - IDLE_MIN_DELAY)
idleTimer = window.setTimeout(() => {
if (!canShowIdleExpression()) {
scheduleIdleExpression()
return
}
idleExpression.value = true
idleExpressionTimer = window.setTimeout(() => {
idleExpression.value = false
scheduleIdleExpression()
}, IDLE_EXPRESSION_DURATION)
}, delay)
}
function showHappyExpression() {
window.clearTimeout(happyTimer)
clearIdleTimers()
idleExpression.value = false
if (!props.enabled) return
happyExpression.value = true
happyTimer = window.setTimeout(() => {
happyExpression.value = false
scheduleIdleExpression()
}, HAPPY_DURATION)
}
function entrySize() {
return {
width: entryRef.value?.offsetWidth || 108,
height: entryRef.value?.offsetHeight || 40
width: entryRef.value?.offsetWidth || size.value,
height: entryRef.value?.offsetHeight || size.value
}
}
function clampSize(value) {
const viewportMax = Math.min(MAX_SIZE, window.innerWidth - EDGE_GAP * 2, window.innerHeight - EDGE_GAP * 2)
return Math.round(Math.min(Math.max(MIN_SIZE, value), Math.max(MIN_SIZE, viewportMax)))
}
function clampPosition(x, y) {
const { width, height } = entrySize()
return {
@@ -68,6 +178,15 @@ function savePosition() {
localStorage.setItem(POSITION_KEY, JSON.stringify({ x: position.x, y: position.y }))
}
function saveSize() {
localStorage.setItem(SIZE_KEY, String(size.value))
}
function restoreSize() {
const savedSize = Number(localStorage.getItem(SIZE_KEY))
size.value = clampSize(Number.isFinite(savedSize) && savedSize > 0 ? savedSize : DEFAULT_SIZE)
}
function restorePosition() {
try {
const saved = JSON.parse(localStorage.getItem(POSITION_KEY) || 'null')
@@ -78,8 +197,9 @@ function restorePosition() {
} catch (error) {
console.warn('读取 AI 助手悬浮位置失败:', error.message)
}
const { width, height } = entrySize()
setPosition(window.innerWidth - width - DEFAULT_GAP, window.innerHeight - height - DEFAULT_GAP)
const { height } = entrySize()
const bottomGap = window.innerWidth > MOBILE_BREAKPOINT ? DESKTOP_DEFAULT_BOTTOM_GAP : DEFAULT_GAP
setPosition(DEFAULT_GAP, window.innerHeight - height - bottomGap)
}
function handlePointerDown(event) {
@@ -98,7 +218,10 @@ function handlePointerMove(event) {
if (!pointerState || pointerState.pointerId !== event.pointerId) return
const deltaX = event.clientX - pointerState.startX
const deltaY = event.clientY - pointerState.startY
if (!dragging.value && Math.hypot(deltaX, deltaY) >= DRAG_THRESHOLD) dragging.value = true
if (!dragging.value && Math.hypot(deltaX, deltaY) >= DRAG_THRESHOLD) {
dragPetState.value = petState.value
dragging.value = true
}
if (dragging.value) setPosition(pointerState.originX + deltaX, pointerState.originY + deltaY)
}
@@ -106,8 +229,9 @@ function finishPointer(event, cancelled = false) {
if (!pointerState || pointerState.pointerId !== event.pointerId) return
if (entryRef.value?.hasPointerCapture(event.pointerId)) entryRef.value.releasePointerCapture(event.pointerId)
if (dragging.value) savePosition()
else if (!cancelled) visible.value = true
else if (!cancelled) visible.value = !visible.value
dragging.value = false
dragPetState.value = ''
pointerState = null
}
@@ -119,18 +243,134 @@ function handlePointerCancel(event) {
finishPointer(event, true)
}
function handleViewportResize() {
setPosition(position.x, position.y)
function startResize(event) {
if (event.button !== 0) return
resizeHandleRef.value?.setPointerCapture(event.pointerId)
clearIdleTimers()
resizing.value = true
resizeState = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startSize: size.value,
originX: position.x,
originY: position.y
}
}
function handleResizeMove(event) {
if (!resizeState || resizeState.pointerId !== event.pointerId) return
const delta = ((resizeState.startX - event.clientX) + (event.clientY - resizeState.startY)) / 2
const nextSize = clampSize(resizeState.startSize + delta)
const nextX = resizeState.originX + resizeState.startSize - nextSize
size.value = nextSize
setPosition(nextX, resizeState.originY)
}
function finishResize(event) {
if (!resizeState || resizeState.pointerId !== event.pointerId) return
if (resizeHandleRef.value?.hasPointerCapture(event.pointerId)) resizeHandleRef.value.releasePointerCapture(event.pointerId)
resizing.value = false
resizeState = null
saveSize()
savePosition()
scheduleIdleExpression()
}
function resizeBy(delta) {
const previousSize = size.value
size.value = clampSize(previousSize + delta)
setPosition(position.x + previousSize - size.value, position.y)
saveSize()
savePosition()
}
function handlePointerEnter() {
hovering.value = true
clearIdleTimers()
idleExpression.value = false
}
function handlePointerLeave() {
hovering.value = false
scheduleIdleExpression()
}
function handleViewportResize() {
size.value = clampSize(size.value)
setPosition(position.x, position.y)
saveSize()
savePosition()
}
function handleStatusChange(status = {}) {
const completionId = Number(status.completionId) || 0
const completed = completionId > agentStatus.completionId
agentStatus.running = Boolean(status.running)
agentStatus.connectError = status.connectError || ''
agentStatus.completionId = completionId
if (agentStatus.running) {
window.clearTimeout(happyTimer)
happyExpression.value = false
clearIdleTimers()
idleExpression.value = false
} else if (completed) {
showHappyExpression()
} else {
scheduleIdleExpression()
}
}
function handleVisibilityChange() {
if (document.hidden) {
clearIdleTimers()
window.clearTimeout(happyTimer)
idleExpression.value = false
happyExpression.value = false
} else {
scheduleIdleExpression()
}
}
watch(() => props.enabled, (enabled) => {
if (!enabled) {
visible.value = false
clearIdleTimers()
window.clearTimeout(happyTimer)
idleExpression.value = false
happyExpression.value = false
} else {
scheduleIdleExpression()
}
})
watch(dragging, (isDragging) => {
if (isDragging) {
clearIdleTimers()
} else {
scheduleIdleExpression()
}
})
watch(visible, (show) => {
if (show && !props.enabled) visible.value = false
})
onMounted(() => {
nextTick(restorePosition)
nextTick(() => {
restoreSize()
restorePosition()
scheduleIdleExpression()
})
window.addEventListener('resize', handleViewportResize)
document.addEventListener('visibilitychange', handleVisibilityChange)
})
onBeforeUnmount(() => {
clearIdleTimers()
window.clearTimeout(happyTimer)
window.removeEventListener('resize', handleViewportResize)
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
</script>
@@ -138,39 +378,150 @@ onBeforeUnmount(() => {
.entry_button {
position: fixed;
z-index: 1800;
height: 40px;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 14px;
border-radius: 20px;
background: #887dfd;
box-shadow: 0 6px 18px rgba(73, 62, 185, 0.3);
color: #fff;
font-size: 14px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
cursor: default;
touch-action: none;
user-select: none;
transition: filter 0.2s, box-shadow 0.2s;
transition: filter 0.2s, transform 0.2s;
&:hover,
&:focus-visible {
outline: none;
filter: brightness(1.08);
box-shadow: 0 8px 22px rgba(73, 62, 185, 0.4);
filter: brightness(1.05);
.pet_sprite {
animation: ai_pet_hover 0.68s cubic-bezier(0.2, 0.75, 0.3, 1);
}
}
&:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 3px;
}
&.is_dragging {
cursor: grabbing;
transition: none;
.pet_sprite {
animation: none;
}
}
img {
width: 18px;
height: 18px;
border-radius: 10%;
&.is_resizing {
.pet_sprite {
animation: none;
}
.pet_resize_handle {
opacity: 1;
transform: scale(1);
}
}
&.is_running::before {
position: absolute;
z-index: -1;
inset: 4px;
border-radius: 50%;
background: rgba(117, 108, 245, 0.34);
content: '';
animation: ai_pet_pulse 1.4s ease-out infinite;
}
&.has_error::after {
position: absolute;
top: 2px;
right: 2px;
width: 10px;
height: 10px;
box-sizing: border-box;
border: 2px solid var(--el-bg-color);
border-radius: 50%;
background: var(--el-color-danger);
content: '';
}
.pet_sprite {
width: 100%;
height: 100%;
filter: drop-shadow(0 7px 8px rgba(31, 40, 111, 0.25));
pointer-events: none;
animation: ai_pet_idle 3.8s ease-in-out infinite;
}
&:hover .pet_resize_handle,
.pet_resize_handle:focus-visible {
opacity: 1;
transform: scale(1);
}
}
.pet_resize_handle {
position: absolute;
left: -3px;
bottom: -3px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 7px;
background: rgba(30, 32, 40, 0.88);
box-shadow: 0 3px 9px rgba(0, 0, 0, 0.28);
color: #fff;
cursor: nesw-resize;
opacity: 0;
outline: none;
transform: scale(0.82);
transition: opacity 0.16s ease, transform 0.16s ease;
touch-action: none;
svg {
width: 13px;
height: 13px;
fill: none;
stroke: currentColor;
stroke-linecap: square;
stroke-linejoin: miter;
stroke-width: 1.6;
}
}
@keyframes ai_pet_idle {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-2px) scale(1.015); }
}
@keyframes ai_pet_hover {
0% { transform: translateY(0) rotate(0) scale(1); }
28% { transform: translateY(-8px) rotate(-4deg) scale(1.04); }
58% { transform: translateY(-6px) rotate(3deg) scale(1.025); }
82% { transform: translateY(1px) rotate(-1deg) scaleX(1.035) scaleY(0.965); }
100% { transform: translateY(0) rotate(0) scale(1); }
}
@keyframes ai_pet_pulse {
0% { opacity: 0.65; transform: scale(0.85); }
100% { opacity: 0; transform: scale(1.35); }
}
@media (prefers-reduced-motion: reduce) {
.entry_button,
.entry_button .pet_sprite,
.entry_button::before {
animation: none !important;
transition: none !important;
}
}
</style>
+218
View File
@@ -0,0 +1,218 @@
<template>
<svg
class="ai_pet"
:class="[`is_${ state }`, { 'is_hovered': hovered }]"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g class="pet_body">
<path d="M22 35H42V38H46V55H43V58H21V55H18V38H22V35Z" fill="#202536" />
<path d="M23 37H41V39H44V54H41V56H23V54H20V40H23V37Z" fill="#FFB916" />
<path d="M23 37H41V40H23V37Z" fill="#FFD040" />
<path d="M20 47H23V54H20V47ZM41 47H44V54H41V47Z" fill="#E98709" />
<path d="M28 46H36V54H28V46Z" fill="#282D40" />
<path d="M30 48H34V52H30V48Z" fill="#8658F6" />
<path class="pet_core" d="M31 48H33V50H31V48Z" fill="#E9D8FF" />
</g>
<g class="pet_arm pet_arm_left">
<path d="M16 39H21V51H16V39Z" fill="#E98709" />
<path d="M14 41H19V52H14V41Z" fill="#FFBB18" />
<path d="M12 49H17V54H12V49Z" fill="#2A3042" />
<path d="M13 50H15V52H13V50Z" fill="#596176" />
</g>
<g class="pet_arm pet_arm_right">
<path d="M43 39H48V51H43V39Z" fill="#E98709" />
<path d="M45 41H50V52H45V41Z" fill="#FFBB18" />
<path d="M48 49H53V54H48V49Z" fill="#2A3042" />
<path d="M50 50H52V52H50V50Z" fill="#596176" />
</g>
<g class="pet_legs">
<path d="M23 55H31V61H23V55ZM34 55H42V61H34V55Z" fill="#252A3B" />
<path d="M21 59H31V63H21V59ZM34 59H44V63H34V59Z" fill="#181D2B" />
<path d="M24 56H30V59H24V56ZM35 56H41V59H35V56Z" fill="#FFC62A" />
<path d="M22 61H31V63H22V61ZM34 61H43V63H34V61Z" fill="#0F1320" />
<path d="M22 59H25V60H22V59ZM35 59H38V60H35V59Z" fill="#616A7E" />
</g>
<g class="pet_head">
<path d="M17 2H47V4H53V7H57V11H60V30H57V34H53V37H11V34H7V30H4V11H7V7H11V4H17V2Z" fill="#1A1F2E" />
<path d="M18 4H46V6H52V9H55V12H58V29H55V32H51V35H13V32H9V29H6V12H9V9H12V6H18V4Z" fill="#FFB817" />
<path d="M19 6H45V8H50V10H53V13H56V28H53V31H49V33H15V31H11V28H8V13H11V10H14V8H19V6Z" fill="#FFC72E" />
<path d="M19 6H44V8H49V10H17V12H13V15H10V12H13V9H19V6Z" fill="#FFE05A" />
<path d="M15 11H49V13H52V31H49V33H15V31H12V13H15V11Z" fill="#171C29" />
<path d="M16 13H48V15H50V29H48V31H16V29H14V15H16V13Z" fill="#222938" />
<path d="M17 8H21V10H19V12H16V10H17V8Z" fill="#FFF7C2" />
<g class="pet_face pet_face_normal">
<path d="M20 18H23V21H26V24H29V27H26V30H23V33H20V30H23V27H26V24H23V21H20V18Z" fill="#FFD040" />
<path d="M33 28H43V31H33V28Z" fill="#FFD040" />
</g>
<g class="pet_face pet_face_idle">
<path d="M19 23H22V26H25V23H28V26H25V28H22V26H19V23ZM35 23H38V26H41V23H44V26H41V28H38V26H35V23Z" fill="#FFE276" />
</g>
<g class="pet_face pet_face_happy">
<path d="M18 27H21V24H24V22H27V24H30V29H27V26H24V25H21V29H18V27ZM34 27H37V24H40V22H43V24H46V29H43V26H40V25H37V29H34V27Z" fill="#FFF0A0" />
</g>
<path d="M55 13H59V16H61V28H59V31H55V13Z" fill="#202536" />
<path d="M56 15H59V29H56V15Z" fill="#E78808" />
<path d="M58 17H61V27H58V17Z" fill="#6F42E8" />
<path d="M59 18H61V22H59V18Z" fill="#B698FF" />
</g>
</svg>
</template>
<script setup>
defineProps({
state: {
type: String,
default: 'normal',
validator: value => ['normal', 'idle', 'working', 'happy',].includes(value)
},
hovered: { type: Boolean, default: false }
})
</script>
<style scoped lang="scss">
.ai_pet {
display: block;
overflow: visible;
shape-rendering: geometricPrecision;
.pet_face_idle,
.pet_face_happy {
display: none;
}
&.is_idle {
.pet_face_normal { display: none; }
.pet_face_idle { display: block; }
}
&.is_happy {
.pet_face_normal { display: none; }
.pet_face_happy { display: block; }
.pet_arm_left {
transform: translate(-3px, -7px) rotate(-38deg);
animation: pet_happy_left 0.64s cubic-bezier(0.45, 0, 0.3, 1) infinite;
}
.pet_arm_right {
transform: translate(3px, -7px) rotate(38deg);
animation: pet_happy_right 0.64s cubic-bezier(0.45, 0, 0.3, 1) infinite;
}
.pet_head { animation: pet_happy_head 0.64s ease-in-out infinite; }
.pet_legs { animation: pet_happy_legs 0.64s ease-in-out infinite; }
}
&.is_working {
.pet_face_normal {
filter: drop-shadow(0 0 2px rgba(255, 208, 64, 0.9));
}
.pet_core {
animation: pet_core_pulse 0.7s steps(2, end) infinite;
}
.pet_arm_left {
animation: pet_work_left 0.72s ease-in-out infinite;
}
.pet_arm_right {
animation: pet_work_right 0.72s ease-in-out infinite;
}
}
&.is_hovered:not(.is_working):not(.is_happy) {
.pet_arm_left { animation: pet_hover_left 0.68s cubic-bezier(0.2, 0.75, 0.3, 1); }
.pet_arm_right { animation: pet_hover_right 0.68s cubic-bezier(0.2, 0.75, 0.3, 1); }
.pet_head { animation: pet_hover_head 0.68s cubic-bezier(0.2, 0.75, 0.3, 1); }
.pet_legs { animation: pet_hover_legs 0.68s cubic-bezier(0.2, 0.75, 0.3, 1); }
}
}
.pet_arm,
.pet_head,
.pet_legs {
transform-box: view-box;
}
.pet_arm_left { transform-origin: 20px 40px; }
.pet_arm_right { transform-origin: 44px 40px; }
.pet_head { transform-origin: 32px 35px; }
.pet_legs { transform-origin: 32px 56px; }
@keyframes pet_work_left {
0%, 100% { transform: translateY(0); }
50% { transform: translate(-2px, -5px) rotate(-18deg); }
}
@keyframes pet_work_right {
0%, 100% { transform: translate(2px, -5px) rotate(18deg); }
50% { transform: translateY(0); }
}
@keyframes pet_happy_left {
0%, 100% { transform: translate(-3px, -7px) rotate(-38deg); }
50% { transform: translate(-5px, -9px) rotate(-52deg); }
}
@keyframes pet_happy_right {
0%, 100% { transform: translate(3px, -7px) rotate(38deg); }
50% { transform: translate(5px, -9px) rotate(52deg); }
}
@keyframes pet_happy_head {
0%, 100% { transform: translateY(0) rotate(-2deg); }
50% { transform: translateY(-2px) rotate(2deg); }
}
@keyframes pet_happy_legs {
0%, 100% { transform: translateY(0) scaleY(1); }
50% { transform: translateY(2px) scaleY(0.9); }
}
@keyframes pet_hover_left {
0%, 100% { transform: translateY(0) rotate(0); }
30%, 60% { transform: translate(-4px, -8px) rotate(-46deg); }
45% { transform: translate(-5px, -9px) rotate(-58deg); }
}
@keyframes pet_hover_right {
0%, 100% { transform: translateY(0) rotate(0); }
35% { transform: translate(3px, -5px) rotate(26deg); }
65% { transform: translate(2px, -6px) rotate(34deg); }
}
@keyframes pet_hover_head {
0%, 100% { transform: rotate(0); }
32% { transform: translateY(-1px) rotate(-4deg); }
62% { transform: rotate(3deg); }
}
@keyframes pet_hover_legs {
0%, 100% { transform: translateY(0) scaleY(1); }
30%, 62% { transform: translateY(3px) scaleY(0.88); }
82% { transform: translateY(-1px) scaleY(1.06); }
}
@keyframes pet_core_pulse {
0%, 100% { fill: #E9D8FF; }
50% { fill: #FFFFFF; }
}
@media (prefers-reduced-motion: reduce) {
.ai_pet .pet_arm,
.ai_pet .pet_head,
.ai_pet .pet_legs,
.ai_pet .pet_core {
animation: none !important;
}
}
</style>
+2 -36
View File
@@ -1,12 +1,5 @@
<template>
<aside class="aside_container">
<div class="logo_wrap">
<img src="/logo_v2_01.png" alt="logo">
<Transition name="el-fade-in-linear">
<h1 v-show="!menuCollapse">EasyNode</h1>
</Transition>
</div>
<MenuList />
<div class="aside_footer">
@@ -45,40 +38,13 @@ const menuCollapse = computed(() => $store.menuCollapse)
background-color: #fff;
:deep(.el-menu) {
padding-top: 8px;
border-right: none;
}
.logo_wrap {
height: 60px;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 15px 17px;
overflow: hidden;
img {
width: 30px;
height: 30px;
flex-shrink: 0;
}
h1 {
margin-left: 2px;
font-size: 14px;
background: linear-gradient(to right, #ffc021, #e4d1a1);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 600;
white-space: nowrap;
user-select: none;
}
}
.aside_footer {
margin-top: auto;
padding: 8px 10px 10px;
padding: 8px 10px 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
}
+1
View File
@@ -225,6 +225,7 @@ export function applyEvent(state, event) {
state.running = false
state.waitingForModel = false
state.finishReason = event.finishReason
state.completionId = (state.completionId || 0) + 1
// 用 totalUsage 校正本轮估算;会话累计量由 session_saved 从库里带回,
// 那才是权威值
if (event.usage) {
+1
View File
@@ -41,6 +41,7 @@ export function useAgentSession(config = {}) {
availableTools: [],
waitingForModel: false,
stopping: false,
completionId: 0,
terminalCancelWarning: '',
plusRequired: null
})
+34 -4
View File
@@ -76,7 +76,8 @@ const useStore = defineStore('global', {
},
plusInfo: {},
isPlusActive: false,
aiConfig: {}
aiConfig: { ui: { petEnabled: true } },
aiConfigLoaded: false
}),
actions: {
async setJwtToken(token) {
@@ -104,6 +105,7 @@ const useStore = defineStore('global', {
}
},
async getMainData() {
await this.getAIConfig()
await this.getGroupList()
await this.getHostList()
await this.getSSHList()
@@ -113,7 +115,6 @@ const useStore = defineStore('global', {
await this.getProxyList()
await this.getTerminalConfig() // 添加终端配置获取
await this.getServerListConfig() // 添加服务器列表配置获取
this.getAIConfig()
},
async getHostList() {
let { data: newHostList } = await $api.getHostList()
@@ -126,8 +127,37 @@ const useStore = defineStore('global', {
this.$patch({ hostList: newHostList })
},
async getAIConfig() {
const { data: aiConfig } = await $api.getAIConfig()
this.$patch({ aiConfig })
try {
const { data: aiConfig = {} } = await $api.getAIConfig()
this.$patch({
aiConfig: {
...aiConfig,
ui: {
...(aiConfig.ui || {}),
petEnabled: aiConfig.ui?.petEnabled !== false
}
}
})
} catch (error) {
console.warn('获取 AI 配置失败:', error.message)
this.$patch({ aiConfig: { ...this.aiConfig, ui: { ...this.aiConfig.ui, petEnabled: true } } })
} finally {
this.$patch({ aiConfigLoaded: true })
}
},
async setAIPreferences(preferences = {}) {
const nextPreferences = {
...this.aiConfig.ui,
...preferences
}
const { data = {} } = await $api.updateAIPreferences(nextPreferences)
this.$patch({
aiConfig: {
...this.aiConfig,
ui: { ...nextPreferences, ...data }
}
})
return this.aiConfig.ui
},
async getGroupList() {
const { data: groupList } = await $api.getGroupList()
+2 -11
View File
@@ -22,10 +22,6 @@
class="mobile_menu_drawer global_mobile_menu"
>
<div class="mobile_menu_content">
<div class="mobile_logo_wrap">
<img src="/logo_v2_01.png" alt="logo">
<h1>EasyNode</h1>
</div>
<MenuList force-expanded @select="mobileMenuVisible = false" />
<div class="mobile_account">
<AccountMenu
@@ -52,7 +48,7 @@
</router-view>
</main>
<AiEntry />
<AiEntry :enabled="$store.aiConfigLoaded && $store.aiConfig.ui?.petEnabled !== false" />
<VersionUpdateDialog
v-model="versionUpdate.visible.value"
:current-version="versionUpdate.currentVersion.value"
@@ -152,6 +148,7 @@ onMounted(() => {
width: 100%;
min-height: 0;
flex: 1;
padding-top: 8px;
overflow-y: auto;
border-right: none;
}
@@ -199,12 +196,6 @@ onMounted(() => {
padding: 0;
overflow: hidden;
}
.mobile_logo_wrap {
flex-shrink: 0;
justify-content: flex-start;
padding: 0 20px;
}
}
}
</style>
+58 -1
View File
@@ -4,7 +4,7 @@
<div class="section_head">
<div>
<h3>Provider 设置</h3>
<p>配置 AI 助手使用的模型服务,保存后运维助手和终端助手共用此配置。</p>
<p>配置 AI 助手使用的模型服务,保存后 AI 助手和终端助手共用此配置。</p>
</div>
</div>
@@ -106,6 +106,23 @@
</el-form>
</section>
<section class="settings_section">
<div class="section_head interface_setting_head">
<div>
<h3>界面设置</h3>
<p>控制登录后各页面是否显示 AI 助手宠物入口,不影响终端AI助手。</p>
</div>
<div class="interface_switch">
<el-switch
:model-value="petEnabled"
:loading="savingPreferences"
@change="savePetEnabled"
/>
<span :class="{ 'is_active': petEnabled }">显示 AI 助手</span>
</div>
</div>
</section>
<section class="settings_section host_policy_section">
<div class="section_head">
<div>
@@ -202,10 +219,12 @@ const DEFAULT_MAX_STEPS = 25
const providerFormRef = ref(null)
const fetchingModels = ref(false)
const savingProvider = ref(false)
const savingPreferences = ref(false)
const savingHostId = ref('')
const providerForm = ref(createProviderForm())
const availableModels = ref([])
const hostPolicies = ref([])
const petEnabled = computed(() => $store.aiConfig.ui?.petEnabled !== false)
const providerRules = {
apiUrl: [
@@ -320,6 +339,18 @@ async function saveProvider() {
}
}
async function savePetEnabled(value) {
savingPreferences.value = true
try {
await $store.setAIPreferences({ petEnabled: value })
$message.success(value ? '已显示 AI 助手' : '已隐藏 AI 助手')
} catch (error) {
$message.error(error.message || '保存 AI 助手显示设置失败')
} finally {
savingPreferences.value = false
}
}
async function saveHostPolicy(row) {
savingHostId.value = row.id
try {
@@ -366,6 +397,31 @@ onMounted(async () => {
p { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; }
}
.interface_setting_head {
align-items: center;
display: flex;
justify-content: space-between;
gap: 20px;
margin-bottom: 0;
}
.interface_switch {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 10px;
line-height: 1;
span {
color: var(--el-text-color-secondary);
font-size: 14px;
&.is_active {
color: var(--el-color-primary);
}
}
}
.provider_form { max-width: 760px; }
.api_url_wrap { width: min(620px, 100%); }
@@ -394,5 +450,6 @@ onMounted(async () => {
.host_policy_section { overflow-x: auto; }
.host_policy_table { min-width: 760px; }
.models_input_wrap { align-items: flex-start; flex-direction: column; }
.interface_setting_head { align-items: flex-start; flex-direction: column; }
}
</style>
+1 -1
View File
@@ -13,7 +13,7 @@
<el-tab-pane label="代理服务" name="proxy">
<Proxy />
</el-tab-pane>
<el-tab-pane label="AI助手设置" name="ai-agent" lazy>
<el-tab-pane label="AI 助手设置" name="ai-agent" lazy>
<AiAgent />
</el-tab-pane>
<el-tab-pane label="Plus激活" name="plus">
@@ -11,7 +11,9 @@
<el-button link title="历史会话" @click="showSessions = !showSessions">
<el-icon><Expand v-if="!showSessions" /><Fold v-else /></el-icon>
</el-button>
<span class="chat_title" :title="host.name">{{ state.title || `${ host.name } AI 助手` }}</span>
<span class="chat_title" :title="state.title">
终端助手 · {{ host.name }}<template v-if="state.title"> · {{ state.title }}</template>
</span>
<span class="header_spacer" />
<el-tag
v-if="!connected"
@@ -41,7 +43,7 @@
<el-button
class="header_icon_button"
link
title="AI助手设置"
title="AI 助手设置"
@click="openAgentSettings"
>
<el-icon><Setting /></el-icon>
@@ -89,7 +91,14 @@
>
<template #title>
<span class="plus_alert_message">{{ state.plusRequired.message }}</span>
<el-button class="plus_activation_button" type="primary" link @click="openPlusSettings">去激活</el-button>
<el-button
class="plus_activation_button"
type="primary"
link
@click="openPlusSettings"
>
去激活
</el-button>
</template>
</el-alert>
<el-alert
@@ -104,7 +113,7 @@
<div ref="bodyRef" class="body_inner">
<div v-if="!state.messages.length" class="empty_state">
<el-icon class="empty_icon"><ChatDotRound /></el-icon>
<p>{{ host.name }} 的终端 AI 助手</p>
<p>终端助手仅绑定 {{ host.name }} 的当前终端和会话环境</p>
<span>AI 命令会在当前终端执行,并自动读取本次输出</span>
</div>
@@ -209,7 +209,7 @@
<el-tooltip
v-if="isSingleWindowMode"
effect="dark"
content="终端AI助手"
content="终端助手 · 当前终端"
placement="bottom"
>
<el-button link class="single_window_ai_button" @click="changeTerminalAi">
@@ -299,7 +299,7 @@
</span>
</div>
<div :class="['tab_content_wrap_header_item', { 'active': showTerminalAi }]">
<span title="终端AI助手" @click="changeTerminalAi">
<span title="终端助手 · 当前终端" @click="changeTerminalAi">
<el-icon class="icon"><ChatDotRound /></el-icon>
</span>
</div>
+4 -1
View File
@@ -59,7 +59,8 @@ function newState() {
aborted: false,
error: null,
streamWarning: null,
finishReason: ''
finishReason: '',
completionId: 0
}
}
@@ -235,6 +236,7 @@ expect(
{ type: 'aborted' },
])
expect('中断后不再运行', state.running, false)
expect('中断不递增正常完成标记', state.completionId, 0)
expect('未完成的工具收尾为失败', state.messages[0].parts[0].status, ToolStatus.ERROR)
assert('失败原因说明是中断', /中断/.test(state.messages[0].parts[0].error))
}
@@ -269,6 +271,7 @@ expect(
reasoningTokens: 0
})
expect('finish 不覆盖会话累计量', state.usage.totalTokens, 500)
expect('finish 递增正常完成标记', state.completionId, 1)
feed(state, [{ type: 'turn_start' },])
expect('新一轮开始时本轮用量归零', state.turnUsage.totalTokens, 0)