| | |
| | | <title>WCS AI 助手</title> |
| | | <link rel="stylesheet" href="../../static/vue/element/element.css" /> |
| | | <style> |
| | | body { background: #f5f7fa; } |
| | | .container { max-width: 1100px; margin: 24px auto; } |
| | | .actions { display: flex; gap: 12px; align-items: center; } |
| | | .output { height: 60vh; } |
| | | .markdown-body { font-size: 14px; line-height: 1.4; white-space: pre-wrap; word-break: break-word; } |
| | | .markdown-body p { margin: 4px 0; } |
| | | .markdown-body ul, .markdown-body ol { margin: 4px 0 4px 16px; padding: 0; } |
| | | .markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 8px; } |
| | | .markdown-body pre { background: #f6f8fa; padding: 12px; border-radius: 6px; overflow: auto; } |
| | | .status { color: #909399; } |
| | | .chat { display: flex; flex-direction: column; gap: 10px; height: 100%; overflow-y: auto; padding-right: 8px; } |
| | | .msg { display: flex; align-items: flex-start; } |
| | | .msg.user { justify-content: flex-end; } |
| | | .msg.assistant { justify-content: flex-start; } |
| | | .bubble { max-width: 72%; padding: 10px 12px; border-radius: 16px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; } |
| | | .assistant .bubble { background: #ffffff; border: 1px solid #ebeef5; color: #303133; } |
| | | .user .bubble { background: #409EFF; color: #ffffff; } |
| | | .composer { display: flex; gap: 10px; align-items: center; margin-top: 12px; } |
| | | .avatar { width: 24px; height: 24px; display: flex; align-items: center; margin-right: 8px; } |
| | | .time { font-size: 12px; color: #909399; text-align: right; margin-top: 6px; } |
| | | .output .el-card__body { height: 100%; padding: 0; } |
| | | .assistant-running { display: flex; align-items: center; gap: 8px; color: #909399; } |
| | | details.think-block { |
| | | border: 1px solid #e4e7ed; |
| | | border-radius: 4px; |
| | | padding: 8px; |
| | | margin: 8px 0; |
| | | background-color: #fcfcfc; |
| | | html, body, #app { |
| | | width: 100%; |
| | | height: 100%; |
| | | margin: 0; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | body { |
| | | background: #ffffff; |
| | | color: #303133; |
| | | font-family: "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif; |
| | | } |
| | | |
| | | * { |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .drawer-shell { |
| | | height: 100%; |
| | | background: #fff; |
| | | } |
| | | |
| | | .assistant-page { |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: 100%; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .assistant-header { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | padding: 12px 14px; |
| | | border-bottom: 1px solid #ebeef5; |
| | | background: #fff; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .assistant-header-main { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .assistant-header-icon { |
| | | width: 36px; |
| | | height: 36px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | border-radius: 8px; |
| | | background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%); |
| | | box-shadow: 0 4px 10px rgba(64, 158, 255, 0.18); |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .assistant-header-text { |
| | | min-width: 0; |
| | | } |
| | | |
| | | .assistant-header-text strong { |
| | | display: block; |
| | | font-size: 18px; |
| | | line-height: 1.2; |
| | | color: #303133; |
| | | } |
| | | |
| | | .assistant-header-text span { |
| | | display: block; |
| | | margin-top: 2px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .assistant-header-side { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .status-chip { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | padding: 5px 10px; |
| | | border-radius: 999px; |
| | | background: #f4f4f5; |
| | | color: #606266; |
| | | font-size: 12px; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .status-chip::before { |
| | | content: ""; |
| | | width: 8px; |
| | | height: 8px; |
| | | border-radius: 50%; |
| | | background: #909399; |
| | | } |
| | | |
| | | .status-chip.streaming { |
| | | background: #ecf5ff; |
| | | color: #409eff; |
| | | } |
| | | |
| | | .status-chip.streaming::before { |
| | | background: #409eff; |
| | | } |
| | | |
| | | .status-chip.loading { |
| | | background: #fdf6ec; |
| | | color: #e6a23c; |
| | | } |
| | | |
| | | .status-chip.loading::before { |
| | | background: #e6a23c; |
| | | } |
| | | |
| | | .toolbar-panel { |
| | | padding: 10px 14px; |
| | | border-bottom: 1px solid #ebeef5; |
| | | background: #fafbfd; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .toolbar-row { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .toolbar-row + .toolbar-row { |
| | | margin-top: 8px; |
| | | } |
| | | |
| | | .toolbar-tip { |
| | | flex: 1; |
| | | min-width: 0; |
| | | padding: 0 2px; |
| | | color: #606266; |
| | | font-size: 12px; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .session-select { |
| | | flex: 1; |
| | | min-width: 220px; |
| | | } |
| | | |
| | | .session-count { |
| | | color: #909399; |
| | | font-size: 12px; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .assistant-content { |
| | | flex: 1; |
| | | min-height: 0; |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | padding: 12px; |
| | | background: #f5f7fa; |
| | | } |
| | | |
| | | .chat-panel { |
| | | flex: 1; |
| | | min-height: 0; |
| | | display: flex; |
| | | flex-direction: column; |
| | | background: #fff; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 4px; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .chat-scroll { |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow-y: auto; |
| | | padding: 14px; |
| | | } |
| | | |
| | | .empty-state { |
| | | height: 100%; |
| | | min-height: 260px; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | justify-content: center; |
| | | text-align: center; |
| | | color: #909399; |
| | | padding: 20px; |
| | | } |
| | | |
| | | .empty-state strong { |
| | | font-size: 16px; |
| | | color: #303133; |
| | | margin-bottom: 6px; |
| | | } |
| | | |
| | | .empty-state p { |
| | | max-width: 420px; |
| | | margin: 0; |
| | | line-height: 1.7; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .empty-presets { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | justify-content: center; |
| | | gap: 8px; |
| | | margin-top: 16px; |
| | | } |
| | | |
| | | .empty-presets button, |
| | | .quick-actions button { |
| | | border: 1px solid #dcdfe6; |
| | | background: #fff; |
| | | color: #606266; |
| | | border-radius: 4px; |
| | | padding: 8px 12px; |
| | | cursor: pointer; |
| | | transition: border-color 0.18s ease, color 0.18s ease, background 0.18s ease; |
| | | font: inherit; |
| | | } |
| | | |
| | | .empty-presets button:hover, |
| | | .quick-actions button:hover { |
| | | border-color: #409eff; |
| | | color: #409eff; |
| | | background: #ecf5ff; |
| | | } |
| | | |
| | | .message-row { |
| | | display: flex; |
| | | gap: 10px; |
| | | margin-bottom: 14px; |
| | | } |
| | | |
| | | .message-row.user { |
| | | justify-content: flex-end; |
| | | } |
| | | |
| | | .message-row.user .message-avatar { |
| | | order: 2; |
| | | } |
| | | |
| | | .message-row.user .message-content { |
| | | order: 1; |
| | | align-items: flex-end; |
| | | } |
| | | |
| | | .message-avatar { |
| | | width: 28px; |
| | | height: 28px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | margin-top: 2px; |
| | | } |
| | | |
| | | .message-content { |
| | | display: flex; |
| | | flex-direction: column; |
| | | max-width: 82%; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .message-meta { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | margin-bottom: 6px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .message-meta strong { |
| | | font-weight: 600; |
| | | color: #606266; |
| | | } |
| | | |
| | | .message-bubble { |
| | | padding: 12px 14px; |
| | | border-radius: 8px; |
| | | border: 1px solid #ebeef5; |
| | | background: #fff; |
| | | color: #303133; |
| | | line-height: 1.6; |
| | | word-break: break-word; |
| | | } |
| | | |
| | | .message-bubble.user { |
| | | background: #409eff; |
| | | border-color: #409eff; |
| | | color: #fff; |
| | | } |
| | | |
| | | .message-bubble.user .plain-text, |
| | | .message-bubble.user .markdown-body { |
| | | color: inherit; |
| | | } |
| | | |
| | | .plain-text { |
| | | white-space: pre-wrap; |
| | | word-break: break-word; |
| | | font-size: 14px; |
| | | line-height: 1.6; |
| | | } |
| | | |
| | | .message-time { |
| | | margin-top: 6px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .assistant-running { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | color: #909399; |
| | | min-height: 24px; |
| | | } |
| | | |
| | | .typing-dot { |
| | | width: 8px; |
| | | height: 8px; |
| | | border-radius: 50%; |
| | | background: #409eff; |
| | | box-shadow: 12px 0 0 rgba(64, 158, 255, 0.6), 24px 0 0 rgba(64, 158, 255, 0.3); |
| | | margin-right: 24px; |
| | | animation: pulseDots 1.2s infinite linear; |
| | | } |
| | | |
| | | @keyframes pulseDots { |
| | | 0% { transform: translateX(0); opacity: 0.6; } |
| | | 50% { transform: translateX(2px); opacity: 1; } |
| | | 100% { transform: translateX(0); opacity: 0.6; } |
| | | } |
| | | |
| | | .markdown-body { |
| | | font-size: 14px; |
| | | line-height: 1.7; |
| | | white-space: normal; |
| | | word-break: break-word; |
| | | } |
| | | |
| | | .markdown-body > :first-child { |
| | | margin-top: 0; |
| | | } |
| | | |
| | | .markdown-body > :last-child { |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .markdown-body p { |
| | | margin: 0 0 10px; |
| | | } |
| | | |
| | | .markdown-body ul, |
| | | .markdown-body ol { |
| | | margin: 0 0 10px 18px; |
| | | padding: 0; |
| | | } |
| | | |
| | | .markdown-body h1, |
| | | .markdown-body h2, |
| | | .markdown-body h3, |
| | | .markdown-body h4 { |
| | | margin: 14px 0 8px; |
| | | line-height: 1.4; |
| | | } |
| | | |
| | | .markdown-body pre { |
| | | margin: 10px 0; |
| | | padding: 12px; |
| | | border-radius: 4px; |
| | | overflow-x: auto; |
| | | background: #2b2f3a; |
| | | color: #eff5fb; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .markdown-body code { |
| | | font-family: "SFMono-Regular", "Consolas", monospace; |
| | | } |
| | | |
| | | .markdown-body p code, |
| | | .markdown-body li code { |
| | | background: #f5f7fa; |
| | | color: #337ecc; |
| | | padding: 2px 6px; |
| | | border-radius: 3px; |
| | | } |
| | | |
| | | .markdown-body blockquote { |
| | | margin: 10px 0; |
| | | padding: 10px 12px; |
| | | border-left: 4px solid #409eff; |
| | | background: #ecf5ff; |
| | | color: #606266; |
| | | } |
| | | |
| | | details.think-block { |
| | | border: 1px solid #d9ecff; |
| | | border-radius: 4px; |
| | | padding: 10px 12px; |
| | | margin: 10px 0; |
| | | background: #f5faff; |
| | | } |
| | | |
| | | details.think-block summary { |
| | | cursor: pointer; |
| | | color: #909399; |
| | | color: #409eff; |
| | | font-size: 13px; |
| | | font-weight: bold; |
| | | font-weight: 600; |
| | | outline: none; |
| | | } |
| | | |
| | | details.think-block .content { |
| | | margin-top: 8px; |
| | | color: #606266; |
| | | font-size: 13px; |
| | | white-space: pre-wrap; |
| | | line-height: 1.7; |
| | | } |
| | | |
| | | .composer-panel { |
| | | background: #fff; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 4px; |
| | | padding: 12px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .composer-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | margin-bottom: 10px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .composer-head strong { |
| | | color: #303133; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .composer-panel .el-textarea__inner { |
| | | min-height: 92px !important; |
| | | border-radius: 4px; |
| | | resize: none; |
| | | font-size: 14px; |
| | | line-height: 1.6; |
| | | padding: 10px 12px; |
| | | font-family: inherit; |
| | | } |
| | | |
| | | .composer-tools { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 10px; |
| | | margin-top: 10px; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .quick-actions { |
| | | display: flex; |
| | | gap: 8px; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .composer-actions { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | margin-left: auto; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .composer-hint { |
| | | color: #909399; |
| | | font-size: 12px; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | @media (max-width: 520px) { |
| | | .toolbar-tip, |
| | | .assistant-header-text span, |
| | | .composer-hint { |
| | | display: none; |
| | | } |
| | | |
| | | .session-select { |
| | | min-width: 100%; |
| | | } |
| | | |
| | | .message-content { |
| | | max-width: 100%; |
| | | } |
| | | } |
| | | </style> |
| | | </head> |
| | | <body> |
| | | <div id="app" class="container"> |
| | | <el-card shadow="hover"> |
| | | <div slot="header" class="clearfix" style="display: flex; align-items: center;"> |
| | | <div v-html="headerIcon" style="margin-right: 10px; display: flex;"></div> |
| | | <span>WCS AI 助手</span> |
| | | </div> |
| | | |
| | | <div class="actions" style="flex-wrap: wrap;"> |
| | | <el-button type="primary" :loading="loading" :disabled="streaming" @click="start">一键诊断系统</el-button> |
| | | <el-button type="warning" :disabled="!streaming" @click="stop">停止</el-button> |
| | | <!-- <el-button @click="clear">清空当前聊天</el-button> --> |
| | | <span class="status" style="margin-right: 12px;">{{ statusText }}</span> |
| | | <el-select v-model="currentChatId" placeholder="选择会话" style="min-width:240px;" @change="switchChat" :disabled="streaming"> |
| | | <el-option v-for="c in chats" :key="c.chatId" :label="(c.title||('会话 '+c.chatId)) + '('+(c.size||0)+')'" :value="c.chatId" /> |
| | | </el-select> |
| | | <el-button type="success" plain icon="el-icon-plus" @click="newChat" :disabled="streaming">新会话</el-button> |
| | | <el-button type="danger" plain icon="el-icon-delete" @click="deleteChat" :disabled="!currentChatId || streaming">删除会话</el-button> |
| | | </div> |
| | | |
| | | <el-divider></el-divider> |
| | | |
| | | <el-card class="output" shadow="never"> |
| | | <div ref="chat" class="chat"> |
| | | <div v-for="(m,i) in messages" :key="i" class="msg" :class="m.role"> |
| | | <div class="avatar" v-html="m.role === 'assistant' ? assistantIcon : userIcon"></div> |
| | | <div class="bubble"> |
| | | <div v-if="m.role === 'assistant' && m.html" class="markdown-body" v-html="m.html"></div> |
| | | <div v-else-if="m.role === 'assistant' && streaming && i === messages.length - 1" class="assistant-running"> |
| | | <span v-html="assistantIcon"></span> |
| | | <span>AI助手正在运行中</span> |
| | | </div> |
| | | <div v-else v-text="m.text"></div> |
| | | <div class="time">{{ m.ts }}</div> |
| | | </div> |
| | | <div id="app" class="drawer-shell"> |
| | | <div class="assistant-page"> |
| | | <header class="assistant-header"> |
| | | <div class="assistant-header-main"> |
| | | <div class="assistant-header-icon" v-html="headerIcon"></div> |
| | | <div class="assistant-header-text"> |
| | | <strong>AI 助手</strong> |
| | | <span>系统巡检、异常问答、历史会话</span> |
| | | </div> |
| | | </div> |
| | | </el-card> |
| | | <div class="assistant-header-side"> |
| | | <span class="status-chip" :class="{ streaming: streaming, loading: loading && !streaming }">{{ statusText }}</span> |
| | | <el-button v-if="embedded" size="mini" icon="el-icon-close" @click="closeDrawer">关闭</el-button> |
| | | </div> |
| | | </header> |
| | | |
| | | <div class="composer"> |
| | | <el-input v-model="userInput" placeholder="向 AI 助手提问" clearable :disabled="streaming" @keyup.enter.native="ask"></el-input> |
| | | <el-button type="success" :disabled="sendDisabled" @click="ask">发送</el-button> |
| | | </div> |
| | | </el-card> |
| | | <section class="toolbar-panel"> |
| | | <div class="toolbar-row"> |
| | | <el-button type="primary" size="small" :disabled="streaming" @click="start">一键巡检</el-button> |
| | | <el-button size="small" :disabled="streaming" @click="newChat">新会话</el-button> |
| | | <el-button size="small" type="danger" plain :disabled="!currentChatId || streaming" @click="deleteChat">删除</el-button> |
| | | <el-button size="small" type="warning" plain :disabled="!streaming" @click="stop">停止</el-button> |
| | | </div> |
| | | <div class="toolbar-row"> |
| | | <el-select |
| | | v-model="currentChatId" |
| | | class="session-select" |
| | | size="small" |
| | | filterable |
| | | placeholder="选择历史会话" |
| | | :disabled="streaming || (!sortedChats.length && !currentChatId)" |
| | | @change="switchChat" |
| | | > |
| | | <el-option |
| | | v-if="currentChatId && !findChat(currentChatId)" |
| | | :key="currentChatId" |
| | | :label="currentChatSummary" |
| | | :value="currentChatId" |
| | | ></el-option> |
| | | <el-option |
| | | v-for="chat in sortedChats" |
| | | :key="chat.chatId" |
| | | :label="chatOptionLabel(chat)" |
| | | :value="chat.chatId" |
| | | ></el-option> |
| | | </el-select> |
| | | <div class="toolbar-tip">{{ currentChatSummary }}</div> |
| | | <div class="session-count">{{ sortedChats.length }} 个会话</div> |
| | | </div> |
| | | </section> |
| | | |
| | | <main class="assistant-content"> |
| | | <section class="chat-panel"> |
| | | <div ref="chat" class="chat-scroll"> |
| | | <div v-if="!messages.length" class="empty-state"> |
| | | <strong>输入问题,或先执行一次巡检</strong> |
| | | <p>支持连续追问、历史会话切换,以及 AI 思考过程折叠展示。</p> |
| | | <div class="empty-presets"> |
| | | <button v-for="preset in promptPresets" :key="preset.title" @click="applyPreset(preset)"> |
| | | {{ preset.title }} |
| | | </button> |
| | | </div> |
| | | </div> |
| | | |
| | | <template v-else> |
| | | <div v-for="(m, i) in messages" :key="i" class="message-row" :class="m.role"> |
| | | <div class="message-avatar" v-html="m.role === 'assistant' ? assistantIcon : userIcon"></div> |
| | | <div class="message-content"> |
| | | <div class="message-meta"> |
| | | <strong>{{ m.role === 'assistant' ? 'AI 助手' : '用户' }}</strong> |
| | | <span>{{ m.role === 'assistant' ? 'WCS 诊断回复' : '问题输入' }}</span> |
| | | </div> |
| | | <div class="message-bubble" :class="m.role"> |
| | | <div v-if="m.role === 'assistant' && m.html" class="markdown-body" v-html="m.html"></div> |
| | | <div v-else-if="m.role === 'assistant' && streaming && i === messages.length - 1" class="assistant-running"> |
| | | <span class="typing-dot"></span> |
| | | <span>AI 正在生成回复...</span> |
| | | </div> |
| | | <div v-else class="plain-text" v-text="m.role === 'assistant' ? (m.md || '') : (m.text || '')"></div> |
| | | </div> |
| | | <div class="message-time">{{ m.ts }}</div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | </section> |
| | | |
| | | <footer class="composer-panel"> |
| | | <div class="composer-head"> |
| | | <div><strong>向 AI 助手提问</strong></div> |
| | | <div>{{ currentChatId ? '会话已绑定' : '临时会话' }}</div> |
| | | </div> |
| | | <el-input |
| | | v-model="userInput" |
| | | type="textarea" |
| | | :autosize="{ minRows: 3, maxRows: 7 }" |
| | | placeholder="例如:最近哪个设备最值得优先排查?异常是否和堆垛机任务、工位堵塞或日志波动有关?" |
| | | :disabled="streaming" |
| | | @keydown.native="handleComposerKeydown" |
| | | ></el-input> |
| | | <div class="composer-tools"> |
| | | <div class="quick-actions" v-if="!streaming"> |
| | | <button v-for="preset in inlinePrompts" :key="preset.title" @click="applyPreset(preset, true)"> |
| | | {{ preset.title }} |
| | | </button> |
| | | </div> |
| | | <div class="composer-actions"> |
| | | <span class="composer-hint">Enter 发送,Shift+Enter 换行</span> |
| | | <el-button type="primary" size="small" :disabled="sendDisabled" @click="ask">发送</el-button> |
| | | </div> |
| | | </div> |
| | | </footer> |
| | | </main> |
| | | </div> |
| | | </div> |
| | | |
| | | <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script> |
| | |
| | | }); |
| | | |
| | | function getUserIconHtml(width, height) { |
| | | width = width || 24; height = height || 24; |
| | | return '<svg width="'+width+'" height="'+height+'" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">\n' |
| | | + '<circle cx="12" cy="7" r="4" fill="#909399"/>\n' |
| | | + '<path d="M4 20c0-4 4-6 8-6s8 2 8 6" fill="#909399" opacity="0.35"/>\n' |
| | | width = width || 24; |
| | | height = height || 24; |
| | | return '<svg width="' + width + '" height="' + height + '" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' |
| | | + '<circle cx="12" cy="7" r="4" fill="#6c7782"></circle>' |
| | | + '<path d="M4 20c0-4 4-6 8-6s8 2 8 6" fill="#6c7782" opacity="0.34"></path>' |
| | | + '</svg>'; |
| | | } |
| | | |
| | |
| | | el: '#app', |
| | | data: function() { |
| | | return { |
| | | headerIcon: getAiIconHtml(50, 50), |
| | | headerIcon: getAiIconHtml(42, 42), |
| | | assistantIcon: getAiIconHtml(24, 24), |
| | | userIcon: getUserIconHtml(24, 24), |
| | | loading: false, |
| | | streaming: false, |
| | | embedded: window !== window.top, |
| | | source: null, |
| | | messages: [], |
| | | pendingText: '', |
| | |
| | | autoScrollThreshold: 80, |
| | | chats: [], |
| | | currentChatId: '', |
| | | resetting: false |
| | | resetting: false, |
| | | promptPresets: [ |
| | | { |
| | | title: '巡检当前系统', |
| | | description: '让 AI 主动梳理设备、任务和日志,给出一轮完整巡检。', |
| | | prompt: '' |
| | | }, |
| | | { |
| | | title: '定位堆垛机异常', |
| | | description: '结合近期日志与任务状态,判断是否存在堆垛机链路异常。', |
| | | prompt: '帮我定位当前堆垛机相关的异常风险,按可能性从高到低列出。' |
| | | }, |
| | | { |
| | | title: '分析堵塞与积压', |
| | | description: '让 AI 优先关注工位堵塞、任务堆积和节拍异常。', |
| | | prompt: '请重点分析当前是否存在工位堵塞、任务积压或节拍异常。' |
| | | }, |
| | | { |
| | | title: '追问最近告警', |
| | | description: '把最近异常事件压缩成可执行排查建议。', |
| | | prompt: '帮我总结最近最值得关注的异常,并给出下一步排查动作。' |
| | | } |
| | | ] |
| | | }; |
| | | }, |
| | | computed: { |
| | | statusText: function() { |
| | | if (this.streaming) return '诊断进行中'; |
| | | if (this.streaming) return '诊断中'; |
| | | if (this.loading) return '连接中'; |
| | | return '空闲'; |
| | | }, |
| | | sendDisabled: function() { |
| | | var t = (this.userInput || '').trim(); |
| | | return this.streaming || t.length === 0; |
| | | var text = (this.userInput || '').trim(); |
| | | return this.streaming || text.length === 0; |
| | | }, |
| | | sortedChats: function() { |
| | | var arr = Array.isArray(this.chats) ? this.chats.slice() : []; |
| | | return arr.sort(function(a, b) { |
| | | var at = a && a.updatedAt ? Number(a.updatedAt) : 0; |
| | | var bt = b && b.updatedAt ? Number(b.updatedAt) : 0; |
| | | return bt - at; |
| | | }); |
| | | }, |
| | | currentChatSummary: function() { |
| | | if (!this.currentChatId) return '未绑定历史会话'; |
| | | var current = this.findChat(this.currentChatId); |
| | | if (!current && this.resetting) return '新建会话,等待首条消息'; |
| | | if (!current) return '会话 ' + this.currentChatId; |
| | | return this.chatLabel(current); |
| | | }, |
| | | inlinePrompts: function() { |
| | | return this.promptPresets.slice(1); |
| | | } |
| | | }, |
| | | methods: { |
| | | authHeaders: function() { |
| | | var token = localStorage.getItem('token'); |
| | | return token ? { token: token } : {}; |
| | | }, |
| | | notifyError: function(message) { |
| | | if (this.$message && message) { |
| | | this.$message.error(message); |
| | | } |
| | | }, |
| | | closeDrawer: function() { |
| | | if (!this.embedded) return; |
| | | try { |
| | | if (window.parent && window.parent.layer) { |
| | | var index = window.parent.layer.getFrameIndex(window.name); |
| | | if (typeof index === 'number' && index >= 0) { |
| | | window.parent.layer.close(index); |
| | | return; |
| | | } |
| | | } |
| | | } catch (e) {} |
| | | }, |
| | | renderMarkdown: function(md, streaming) { |
| | | if (!md) return ''; |
| | | var src = md.replace(/\\n/g, '\n'); |
| | | var source = md.replace(/\\n/g, '\n'); |
| | | var openAttr = streaming ? ' open' : ''; |
| | | src = src.replace(/<think>/g, '<details class="think-block"' + openAttr + '><summary>AI深度思考</summary><div class="content">'); |
| | | src = src.replace(/<\/think>/g, '</div></details>'); |
| | | if (streaming && src.indexOf('<details class="think-block"') >= 0 && src.indexOf('</div></details>') < 0) { |
| | | src += '</div></details>'; |
| | | source = source.replace(/<think>/g, '<details class="think-block"' + openAttr + '><summary>AI 深度思考</summary><div class="content">'); |
| | | source = source.replace(/<\/think>/g, '</div></details>'); |
| | | if (streaming && source.indexOf('<details class="think-block"') >= 0 && source.indexOf('</div></details>') < 0) { |
| | | source += '</div></details>'; |
| | | } |
| | | return DOMPurify.sanitize(marked.parse(src)); |
| | | return DOMPurify.sanitize(marked.parse(source)); |
| | | }, |
| | | loadChats: function() { |
| | | loadChats: function(preferKeepCurrent) { |
| | | var self = this; |
| | | fetch(baseUrl + '/ai/diagnose/chats', { headers: { 'token': localStorage.getItem('token') } }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(arr){ if (Array.isArray(arr)) { self.chats = arr; } }); |
| | | fetch(baseUrl + '/ai/diagnose/chats', { headers: self.authHeaders() }) |
| | | .then(function(r) { return r.json(); }) |
| | | .then(function(arr) { |
| | | self.chats = Array.isArray(arr) ? arr : []; |
| | | if (preferKeepCurrent && self.currentChatId) return; |
| | | if (!self.currentChatId && self.sortedChats.length > 0) { |
| | | self.openChat(self.sortedChats[0].chatId); |
| | | return; |
| | | } |
| | | if (!self.currentChatId && self.sortedChats.length === 0) { |
| | | self.newChat(); |
| | | } |
| | | }) |
| | | .catch(function() { |
| | | self.chats = []; |
| | | if (!preferKeepCurrent) { |
| | | self.newChat(); |
| | | } |
| | | self.notifyError('加载 AI 会话列表失败'); |
| | | }); |
| | | }, |
| | | chatLabel: function(chat) { |
| | | if (!chat) return '未命名会话'; |
| | | if (chat.title) return chat.title; |
| | | var id = chat.chatId || ''; |
| | | return '会话 ' + (id.length > 8 ? id.slice(-8) : id); |
| | | }, |
| | | chatOptionLabel: function(chat) { |
| | | if (!chat) return '未命名会话'; |
| | | return this.chatLabel(chat) + ' · ' + (chat.size || 0) + ' 条 · ' + this.chatUpdatedAt(chat); |
| | | }, |
| | | chatUpdatedAt: function(chat) { |
| | | if (!chat || !chat.updatedAt) return '刚刚创建'; |
| | | var time = Number(chat.updatedAt); |
| | | if (!time) return '最近更新'; |
| | | var diff = Date.now() - time; |
| | | if (diff < 60000) return '刚刚更新'; |
| | | if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'; |
| | | if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'; |
| | | var date = new Date(time); |
| | | return date.Format('MM-dd hh:mm'); |
| | | }, |
| | | findChat: function(chatId) { |
| | | for (var i = 0; i < this.chats.length; i++) { |
| | | if (this.chats[i] && this.chats[i].chatId === chatId) { |
| | | return this.chats[i]; |
| | | } |
| | | } |
| | | return null; |
| | | }, |
| | | openChat: function(chatId) { |
| | | if (!chatId || this.streaming) return; |
| | | this.currentChatId = chatId; |
| | | this.switchChat(); |
| | | }, |
| | | switchChat: function() { |
| | | var self = this; |
| | | if (!self.currentChatId) { self.clear(); return; } |
| | | fetch(baseUrl + '/ai/diagnose/chats/' + encodeURIComponent(self.currentChatId) + '/history', { headers: { 'token': localStorage.getItem('token') } }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(arr){ |
| | | if (!self.currentChatId) { |
| | | self.clear(); |
| | | return; |
| | | } |
| | | fetch(baseUrl + '/ai/diagnose/chats/' + encodeURIComponent(self.currentChatId) + '/history', { headers: self.authHeaders() }) |
| | | .then(function(r) { return r.json(); }) |
| | | .then(function(arr) { |
| | | if (!Array.isArray(arr)) return; |
| | | var msgs = []; |
| | | for (var i=0;i<arr.length;i++) { |
| | | var m = arr[i]; |
| | | if (m.role === 'assistant') msgs.push({ role: 'assistant', md: m.content || '', html: self.renderMarkdown(m.content || '', false), ts: self.nowStr() }); |
| | | else msgs.push({ role: 'user', text: m.content || '', ts: self.nowStr() }); |
| | | for (var i = 0; i < arr.length; i++) { |
| | | var m = arr[i] || {}; |
| | | if (m.role === 'assistant') { |
| | | msgs.push({ |
| | | role: 'assistant', |
| | | md: m.content || '', |
| | | html: self.renderMarkdown(m.content || '', false), |
| | | ts: self.nowStr() |
| | | }); |
| | | } else { |
| | | msgs.push({ |
| | | role: 'user', |
| | | text: m.content || '', |
| | | ts: self.nowStr() |
| | | }); |
| | | } |
| | | } |
| | | self.messages = msgs; |
| | | self.$nextTick(function(){ self.scrollToBottom(true); }); |
| | | self.pendingText = ''; |
| | | self.resetting = false; |
| | | self.$nextTick(function() { self.scrollToBottom(true); }); |
| | | }) |
| | | .catch(function() { |
| | | self.clear(); |
| | | self.notifyError('加载会话历史失败'); |
| | | }); |
| | | }, |
| | | newChat: function() { |
| | | var id = Date.now() + '_' + Math.random().toString(36).substr(2,8); |
| | | this.currentChatId = id; |
| | | if (this.streaming) return; |
| | | this.currentChatId = Date.now() + '_' + Math.random().toString(36).substr(2, 8); |
| | | this.resetting = true; |
| | | this.clear(); |
| | | }, |
| | | deleteChat: function() { |
| | | var self = this; |
| | | if (!self.currentChatId) return; |
| | | fetch(baseUrl + '/ai/diagnose/chats/' + encodeURIComponent(self.currentChatId), { method: 'DELETE', headers: { 'token': localStorage.getItem('token') } }) |
| | | .then(function(r){ return r.json(); }) |
| | | .then(function(ok){ if (ok === true) { self.currentChatId = ''; self.clear(); self.loadChats(); self.newChat(); } }); |
| | | if (!self.currentChatId || self.streaming) return; |
| | | fetch(baseUrl + '/ai/diagnose/chats/' + encodeURIComponent(self.currentChatId), { |
| | | method: 'DELETE', |
| | | headers: self.authHeaders() |
| | | }) |
| | | .then(function(r) { return r.json(); }) |
| | | .then(function(ok) { |
| | | if (ok === true) { |
| | | self.currentChatId = ''; |
| | | self.clear(); |
| | | self.loadChats(false); |
| | | } |
| | | }) |
| | | .catch(function() { |
| | | self.notifyError('删除会话失败'); |
| | | }); |
| | | }, |
| | | shouldAutoScroll: function() { |
| | | var el = this.$refs.chat; |
| | |
| | | } |
| | | }, |
| | | nowStr: function() { |
| | | var d = new Date(); |
| | | function pad(n) { return (n<10?'0':'') + n; } |
| | | var y = d.getFullYear(); |
| | | var m = pad(d.getMonth() + 1); |
| | | var day = pad(d.getDate()); |
| | | var hh = pad(d.getHours()); |
| | | var mm = pad(d.getMinutes()); |
| | | var ss = pad(d.getSeconds()); |
| | | return y + '-' + m + '-' + day + ' ' + hh + ':' + mm + ':' + ss; |
| | | return new Date().Format('yyyy-MM-dd hh:mm:ss'); |
| | | }, |
| | | handleComposerKeydown: function(e) { |
| | | if (!e) return; |
| | | if (e.key === 'Enter' && !e.shiftKey) { |
| | | e.preventDefault(); |
| | | this.ask(); |
| | | } |
| | | }, |
| | | applyPreset: function(preset, immediate) { |
| | | if (this.streaming || !preset) return; |
| | | if (!preset.prompt) { |
| | | this.start(); |
| | | return; |
| | | } |
| | | this.userInput = preset.prompt; |
| | | if (immediate) { |
| | | this.ask(); |
| | | } |
| | | }, |
| | | appendAssistantPlaceholder: function() { |
| | | this.messages.push({ role: 'assistant', md: '', html: '', ts: this.nowStr() }); |
| | | }, |
| | | ask: function() { |
| | | if (this.streaming) return; |
| | | var msg = (this.userInput || '').trim(); |
| | | if (!msg) return; |
| | | var message = (this.userInput || '').trim(); |
| | | if (!message) return; |
| | | this.loading = true; |
| | | this.streaming = true; |
| | | this.messages.push({ role: 'user', text: msg, ts: this.nowStr() }); |
| | | this.messages.push({ role: 'assistant', md: '', html: '', ts: this.nowStr() }); |
| | | this.messages.push({ role: 'user', text: message, ts: this.nowStr() }); |
| | | this.appendAssistantPlaceholder(); |
| | | this.scrollToBottom(true); |
| | | var url = baseUrl + '/ai/diagnose/askStream?prompt=' + encodeURIComponent(msg); |
| | | |
| | | var url = baseUrl + '/ai/diagnose/askStream?prompt=' + encodeURIComponent(message); |
| | | if (this.currentChatId) url += '&chatId=' + encodeURIComponent(this.currentChatId); |
| | | if (this.resetting) url += '&reset=true'; |
| | | |
| | | this.source = new EventSource(url); |
| | | var self = this; |
| | | this.source.onopen = function() { |
| | |
| | | this.clear(); |
| | | this.loading = true; |
| | | this.streaming = true; |
| | | var url = baseUrl + '/ai/diagnose/runAiStream'; |
| | | this.source = new EventSource(url); |
| | | this.messages.push({ role: 'assistant', md: '', html: '', ts: this.nowStr() }); |
| | | this.appendAssistantPlaceholder(); |
| | | this.scrollToBottom(true); |
| | | |
| | | var self = this; |
| | | this.source = new EventSource(baseUrl + '/ai/diagnose/runAiStream'); |
| | | this.source.onopen = function() { |
| | | self.loading = false; |
| | | }; |
| | | this.source.onmessage = function(e) { |
| | | if (!e || !e.data) return; |
| | | // 后端把换行转义成 \n,这里还原为真正的换行 |
| | | var chunk = (e.data || '').replace(/\\n/g, '\n'); |
| | | if (!chunk) return; |
| | | // 如果仅包含换行符(如单独 \n 或 \n\n),丢弃避免空白行 |
| | | var normalized = chunk.replace(/\r/g, ''); |
| | | if (/^\n+$/.test(normalized)) return; |
| | | self.pendingText += chunk; |
| | |
| | | return; |
| | | } |
| | | if (self.pendingText.length > 0) { |
| | | var n = Math.min(self.stepChars, self.pendingText.length); |
| | | var part = self.pendingText.slice(0, n); |
| | | self.pendingText = self.pendingText.slice(n); |
| | | var last = self.messages.length > 0 ? self.messages[self.messages.length - 1] : null; |
| | | var count = Math.min(self.stepChars, self.pendingText.length); |
| | | var piece = self.pendingText.slice(0, count); |
| | | self.pendingText = self.pendingText.slice(count); |
| | | var last = self.messages.length ? self.messages[self.messages.length - 1] : null; |
| | | if (last && last.role === 'assistant') { |
| | | last.md = (last.md || '') + part; |
| | | last.md = (last.md || '') + piece; |
| | | } |
| | | } |
| | | var now = Date.now(); |
| | | if (now - self.lastRenderTs > self.renderIntervalMs) { |
| | | self.lastRenderTs = now; |
| | | var last = self.messages.length > 0 ? self.messages[self.messages.length - 1] : null; |
| | | if (last && last.role === 'assistant') { |
| | | last.html = self.renderMarkdown(last.md || '', true); |
| | | var latest = self.messages.length ? self.messages[self.messages.length - 1] : null; |
| | | if (latest && latest.role === 'assistant') { |
| | | latest.html = self.renderMarkdown(latest.md || '', true); |
| | | self.$nextTick(function() { self.scrollToBottom(true); }); |
| | | } |
| | | } |
| | | }, 50); |
| | | }, |
| | | stop: function() { |
| | | stop: function(skipReload) { |
| | | if (this.source) { |
| | | this.source.close(); |
| | | this.source = null; |
| | | } |
| | | var last = this.messages.length > 0 ? this.messages[this.messages.length - 1] : null; |
| | | if (last && last.role === 'assistant' && this.pendingText && this.pendingText.length > 0) { |
| | | var last = this.messages.length ? this.messages[this.messages.length - 1] : null; |
| | | if (last && last.role === 'assistant' && this.pendingText) { |
| | | last.md = (last.md || '') + this.pendingText; |
| | | this.pendingText = ''; |
| | | } |
| | |
| | | last.html = this.renderMarkdown(last.md || '', false); |
| | | } |
| | | this.$nextTick(function() { this.scrollToBottom(true); }.bind(this)); |
| | | this.loadChats(); |
| | | if (!skipReload) { |
| | | this.loadChats(true); |
| | | } |
| | | }, |
| | | clear: function() { |
| | | this.messages = []; |
| | | this.pendingText = ''; |
| | | } |
| | | } |
| | | ,mounted: function() { |
| | | this.loadChats(); |
| | | this.newChat(); |
| | | }, |
| | | mounted: function() { |
| | | this.loadChats(false); |
| | | }, |
| | | beforeDestroy: function() { |
| | | this.stop(true); |
| | | } |
| | | }); |
| | | </script> |