mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 16:07:52 +08:00
feat: 布局&AI助手调整
This commit is contained in:
@@ -113,7 +113,7 @@ export function renderTranscript(messages = []) {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const SUMMARY_PROMPT = `你在为一个 Linux 服务器运维助手压缩对话历史。请把下面的笔录浓缩成一段结构化摘要,供助手继续工作时参考。
|
||||
const SUMMARY_PROMPT = `你在为一个 Linux 服务器 AI 助手压缩对话历史。请把下面的笔录浓缩成一段结构化摘要,供助手继续工作时参考。
|
||||
|
||||
必须保留:
|
||||
1. **已执行的操作** —— 在哪台主机上做了什么,结果成功还是失败
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 或任何后台连接主动访问主机。
|
||||
|
||||
## 当前终端
|
||||
|
||||
|
||||
@@ -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('无效的会话范围')
|
||||
|
||||
@@ -36,7 +36,7 @@ export function buildTools(ctx) {
|
||||
}).filter(Boolean))
|
||||
}
|
||||
|
||||
// 运维助手未选择主机时是纯聊天模式。不能仅在 executor 里拒绝:
|
||||
// AI 助手未选择主机时是纯聊天模式。不能仅在 executor 里拒绝:
|
||||
// 那样模型仍可通过 host_list 枚举资产,并反复尝试越权工具调用。
|
||||
if (!hasSelectedHosts(ctx)) return {}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": "",
|
||||
|
||||
@@ -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 } 项)`)
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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-'))
|
||||
|
||||
// 获取语言标签
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -41,6 +41,7 @@ export function useAgentSession(config = {}) {
|
||||
availableTools: [],
|
||||
waitingForModel: false,
|
||||
stopping: false,
|
||||
completionId: 0,
|
||||
terminalCancelWarning: '',
|
||||
plusRequired: null
|
||||
})
|
||||
|
||||
+34
-4
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user