<!DOCTYPE html>
|
<html lang="zh-CN">
|
<head>
|
<meta charset="UTF-8" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<title>WCS AI 助手</title>
|
<link rel="stylesheet" href="../../static/vue/element/element.css" />
|
<style>
|
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: #409eff;
|
font-size: 13px;
|
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="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>
|
<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>
|
|
<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>
|
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
|
<script type="text/javascript" src="../../static/js/common.js?v=20260309_i18n_fix1" charset="utf-8"></script>
|
<script src="../../static/js/marked.min.js"></script>
|
<script src="../../static/js/purify.min.js"></script>
|
<script>
|
marked.setOptions({
|
gfm: true,
|
breaks: true
|
});
|
|
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">'
|
+ '<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>';
|
}
|
|
new Vue({
|
el: '#app',
|
data: function() {
|
return {
|
headerIcon: getAiIconHtml(42, 42),
|
assistantIcon: getAiIconHtml(24, 24),
|
userIcon: getUserIconHtml(24, 24),
|
loading: false,
|
streaming: false,
|
embedded: window !== window.top,
|
source: null,
|
messages: [],
|
pendingText: '',
|
typingTimer: null,
|
lastRenderTs: 0,
|
renderIntervalMs: 120,
|
stepChars: 6,
|
userInput: '',
|
autoScrollThreshold: 80,
|
chats: [],
|
currentChatId: '',
|
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.loading) return '连接中';
|
return '空闲';
|
},
|
sendDisabled: function() {
|
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 source = md.replace(/\\n/g, '\n');
|
var openAttr = streaming ? ' open' : '';
|
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(source));
|
},
|
loadChats: function(preferKeepCurrent) {
|
var self = this;
|
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: 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()
|
});
|
}
|
}
|
self.messages = msgs;
|
self.pendingText = '';
|
self.resetting = false;
|
self.$nextTick(function() { self.scrollToBottom(true); });
|
})
|
.catch(function() {
|
self.clear();
|
self.notifyError('加载会话历史失败');
|
});
|
},
|
newChat: function() {
|
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 || 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;
|
if (!el) return false;
|
return (el.scrollHeight - el.scrollTop - el.clientHeight) <= this.autoScrollThreshold;
|
},
|
scrollToBottom: function(force) {
|
var el = this.$refs.chat;
|
if (!el) return;
|
if (force || this.streaming || this.shouldAutoScroll()) {
|
el.scrollTop = el.scrollHeight;
|
}
|
},
|
nowStr: function() {
|
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 message = (this.userInput || '').trim();
|
if (!message) return;
|
this.loading = true;
|
this.streaming = true;
|
this.messages.push({ role: 'user', text: message, ts: this.nowStr() });
|
this.appendAssistantPlaceholder();
|
this.scrollToBottom(true);
|
|
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() {
|
self.loading = false;
|
};
|
this.source.onmessage = function(e) {
|
if (!e || !e.data) return;
|
var chunk = (e.data || '').replace(/\\n/g, '\n');
|
if (!chunk) return;
|
var normalized = chunk.replace(/\r/g, '');
|
if (/^\n+$/.test(normalized)) return;
|
self.pendingText += chunk;
|
self.ensureTyping();
|
self.scrollToBottom(true);
|
};
|
this.source.onerror = function() {
|
self.stop();
|
};
|
this.userInput = '';
|
this.resetting = false;
|
},
|
start: function() {
|
if (this.streaming) return;
|
this.clear();
|
this.loading = true;
|
this.streaming = true;
|
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;
|
var chunk = (e.data || '').replace(/\\n/g, '\n');
|
if (!chunk) return;
|
var normalized = chunk.replace(/\r/g, '');
|
if (/^\n+$/.test(normalized)) return;
|
self.pendingText += chunk;
|
self.ensureTyping();
|
self.scrollToBottom(true);
|
};
|
this.source.onerror = function() {
|
self.stop();
|
};
|
},
|
ensureTyping: function() {
|
if (this.typingTimer) return;
|
var self = this;
|
this.typingTimer = setInterval(function() {
|
if (!self.streaming && self.pendingText.length === 0) {
|
clearInterval(self.typingTimer);
|
self.typingTimer = null;
|
return;
|
}
|
if (self.pendingText.length > 0) {
|
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 || '') + piece;
|
}
|
}
|
var now = Date.now();
|
if (now - self.lastRenderTs > self.renderIntervalMs) {
|
self.lastRenderTs = now;
|
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(skipReload) {
|
if (this.source) {
|
this.source.close();
|
this.source = null;
|
}
|
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 = '';
|
}
|
this.streaming = false;
|
this.loading = false;
|
if (this.typingTimer) {
|
clearInterval(this.typingTimer);
|
this.typingTimer = null;
|
}
|
if (last && last.role === 'assistant') {
|
last.html = this.renderMarkdown(last.md || '', false);
|
}
|
this.$nextTick(function() { this.scrollToBottom(true); }.bind(this));
|
if (!skipReload) {
|
this.loadChats(true);
|
}
|
},
|
clear: function() {
|
this.messages = [];
|
this.pendingText = '';
|
}
|
},
|
mounted: function() {
|
this.loadChats(false);
|
},
|
beforeDestroy: function() {
|
this.stop(true);
|
}
|
});
|
</script>
|
</body>
|
</html>
|