|
|
@@ -60,6 +60,17 @@
|
|
60
|
60
|
min-width="100" />
|
|
61
|
61
|
</el-table>
|
|
62
|
62
|
<div v-else-if="msg.result.rows" class="no-data">暂无数据</div>
|
|
|
63
|
+
|
|
|
64
|
+ <!-- SQL 折叠 -->
|
|
|
65
|
+ <div v-if="msg.result.sql" class="sql-block">
|
|
|
66
|
+ <div class="sql-toggle" @click="toggleSql(msg.id)">
|
|
|
67
|
+ <span class="sql-label">SQL</span>
|
|
|
68
|
+ <span class="sql-arrow">{{ msg.showSql ? '▲' : '▼' }}</span>
|
|
|
69
|
+ </div>
|
|
|
70
|
+ <div v-if="msg.showSql" class="sql-code">
|
|
|
71
|
+ <pre>{{ msg.result.sql }}</pre>
|
|
|
72
|
+ </div>
|
|
|
73
|
+ </div>
|
|
63
|
74
|
</div>
|
|
64
|
75
|
|
|
65
|
76
|
<!-- 耗时 -->
|
|
|
@@ -81,6 +92,11 @@
|
|
81
|
92
|
</div>
|
|
82
|
93
|
</div>
|
|
83
|
94
|
|
|
|
95
|
+ <!-- 意图澄清 -->
|
|
|
96
|
+ <div v-if="clarifyQuestion" class="clarify-bar">
|
|
|
97
|
+ <span class="clarify-text">{{ clarifyQuestion }}</span>
|
|
|
98
|
+ </div>
|
|
|
99
|
+
|
|
84
|
100
|
<!-- 底部输入区 -->
|
|
85
|
101
|
<div class="chat-container">
|
|
86
|
102
|
<div class="quick-action">
|
|
|
@@ -88,10 +104,9 @@
|
|
88
|
104
|
<el-button class="report-btn" @click="handlePerformanceClick">绩效分析报告</el-button>
|
|
89
|
105
|
<el-button class="report-btn" @click="handleUseReportClick">使用报表</el-button>
|
|
90
|
106
|
</div>
|
|
91
|
|
- <!--请输入您的问题,按 Enter 发送...-->
|
|
92
|
107
|
<div class="input-container">
|
|
93
|
|
- <el-input v-model="inputMessage" type="textarea" :rows="3" placeholder="功能开发中,请敬请期待"
|
|
94
|
|
- class="chat-input" @keydown.enter.exact.prevent="handleSend" :disabled="true" />
|
|
|
108
|
+ <el-input v-model="inputMessage" type="textarea" :rows="3" placeholder="请输入您的问题,按 Enter 发送..."
|
|
|
109
|
+ class="chat-input" @keydown.enter.exact.prevent="handleSend" />
|
|
95
|
110
|
<el-button :disabled="!inputMessage.trim() || isLoading" circle class="send-btn" @click="handleSend">
|
|
96
|
111
|
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
|
97
|
112
|
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
|
|
@@ -114,7 +129,9 @@ import PerformanceAnalysis from './components/performanceAnalysis.vue'
|
|
114
|
129
|
import useUserStore from '@/store/modules/user'
|
|
115
|
130
|
import UseReports from './components/useReports.vue'
|
|
116
|
131
|
const userStore = useUserStore()
|
|
117
|
|
-const AI_SERVICE_URL = import.meta.env.VITE_AI_SERVICE_URL || 'http://localhost:8000'
|
|
|
132
|
+
|
|
|
133
|
+const AI_BASE = import.meta.env.VITE_AI_SERVICE_URL || 'http://airport-test.samsundot.com:9015'
|
|
|
134
|
+const AI_URL = AI_BASE + '/api/query-stream'
|
|
118
|
135
|
|
|
119
|
136
|
const inputMessage = ref('')
|
|
120
|
137
|
const isLoading = ref(false)
|
|
|
@@ -123,6 +140,9 @@ const messagesRef = ref(null)
|
|
123
|
140
|
const showDataBoard = ref(false)
|
|
124
|
141
|
const showPerformanceAnalysis = ref(false)
|
|
125
|
142
|
const showUseReports = ref(false)
|
|
|
143
|
+const sessionId = ref('app_' + Date.now())
|
|
|
144
|
+const history = ref([])
|
|
|
145
|
+const clarifyQuestion = ref('')
|
|
126
|
146
|
|
|
127
|
147
|
onMounted(() => resetState())
|
|
128
|
148
|
onActivated(() => resetState())
|
|
|
@@ -135,6 +155,9 @@ const resetState = () => {
|
|
135
|
155
|
messages.value = []
|
|
136
|
156
|
showUseReports.value = false
|
|
137
|
157
|
isLoading.value = false
|
|
|
158
|
+ sessionId.value = 'app_' + Date.now()
|
|
|
159
|
+ history.value = []
|
|
|
160
|
+ clarifyQuestion.value = ''
|
|
138
|
161
|
}
|
|
139
|
162
|
|
|
140
|
163
|
const scrollToBottom = async () => {
|
|
|
@@ -144,11 +167,84 @@ const scrollToBottom = async () => {
|
|
144
|
167
|
}
|
|
145
|
168
|
}
|
|
146
|
169
|
|
|
|
170
|
+const getUserId = () => {
|
|
|
171
|
+ return String(userStore.id || 1)
|
|
|
172
|
+}
|
|
|
173
|
+
|
|
|
174
|
+const toggleSql = (msgId) => {
|
|
|
175
|
+ const msg = messages.value.find(m => m.id === msgId)
|
|
|
176
|
+ if (msg) {
|
|
|
177
|
+ msg.showSql = !msg.showSql
|
|
|
178
|
+ }
|
|
|
179
|
+}
|
|
|
180
|
+
|
|
|
181
|
+const handleEvent = (aiMsg, data) => {
|
|
|
182
|
+ if (data.step !== undefined && data.name) {
|
|
|
183
|
+ // 进度步骤
|
|
|
184
|
+ const steps = [...(aiMsg.steps || [])]
|
|
|
185
|
+ const si = steps.findIndex(s => s.name === data.name)
|
|
|
186
|
+ const step = {
|
|
|
187
|
+ step: si >= 0 ? si : steps.length,
|
|
|
188
|
+ name: data.name,
|
|
|
189
|
+ status: data.status,
|
|
|
190
|
+ duration: data.duration
|
|
|
191
|
+ }
|
|
|
192
|
+ if (si >= 0) {
|
|
|
193
|
+ steps[si] = step
|
|
|
194
|
+ } else {
|
|
|
195
|
+ steps.push(step)
|
|
|
196
|
+ }
|
|
|
197
|
+ aiMsg.steps = steps
|
|
|
198
|
+ messages.value = [...messages.value]
|
|
|
199
|
+ return
|
|
|
200
|
+ }
|
|
|
201
|
+
|
|
|
202
|
+ if (data.question) {
|
|
|
203
|
+ // 意图澄清
|
|
|
204
|
+ clarifyQuestion.value = data.question
|
|
|
205
|
+ aiMsg.status = 'done'
|
|
|
206
|
+ messages.value = [...messages.value]
|
|
|
207
|
+ return
|
|
|
208
|
+ }
|
|
|
209
|
+
|
|
|
210
|
+ if (data.columns !== undefined || data.answer !== undefined) {
|
|
|
211
|
+ // 最终结果
|
|
|
212
|
+ aiMsg.steps.forEach(s => { if (s.status === 'running') s.status = 'done' })
|
|
|
213
|
+
|
|
|
214
|
+ // 适配数据结构
|
|
|
215
|
+ aiMsg.result = {
|
|
|
216
|
+ query_type: data.answer ? 'knowledge' : 'data',
|
|
|
217
|
+ answer: data.answer || '',
|
|
|
218
|
+ message: data.message || '',
|
|
|
219
|
+ sql: data.sql || '',
|
|
|
220
|
+ columns: data.columns || [],
|
|
|
221
|
+ rows: data.rows || [],
|
|
|
222
|
+ sources: data.sources || [],
|
|
|
223
|
+ timing: { total: 0 },
|
|
|
224
|
+ success: data.success !== false
|
|
|
225
|
+ }
|
|
|
226
|
+ aiMsg.showSql = false
|
|
|
227
|
+ aiMsg.isError = !data.success
|
|
|
228
|
+ aiMsg.status = 'done'
|
|
|
229
|
+ messages.value = [...messages.value]
|
|
|
230
|
+
|
|
|
231
|
+ // 记录历史
|
|
|
232
|
+ const prevUserMsg = messages.value.find(m => m.id < aiMsg.id && m.type === 'user')
|
|
|
233
|
+ if (prevUserMsg) {
|
|
|
234
|
+ history.value.push({
|
|
|
235
|
+ question: prevUserMsg.text || '',
|
|
|
236
|
+ sql: data.sql || '',
|
|
|
237
|
+ })
|
|
|
238
|
+ }
|
|
|
239
|
+ }
|
|
|
240
|
+}
|
|
|
241
|
+
|
|
147
|
242
|
const handleSend = async () => {
|
|
148
|
243
|
const text = inputMessage.value.trim()
|
|
149
|
244
|
if (!text || isLoading.value) return
|
|
150
|
245
|
|
|
151
|
246
|
inputMessage.value = ''
|
|
|
247
|
+ clarifyQuestion.value = ''
|
|
152
|
248
|
isLoading.value = true
|
|
153
|
249
|
|
|
154
|
250
|
// 添加用户消息
|
|
|
@@ -162,25 +258,27 @@ const handleSend = async () => {
|
|
162
|
258
|
status: 'loading',
|
|
163
|
259
|
steps: [],
|
|
164
|
260
|
result: null,
|
|
165
|
|
- errorText: ''
|
|
|
261
|
+ errorText: '',
|
|
|
262
|
+ showSql: false,
|
|
|
263
|
+ isError: false
|
|
166
|
264
|
}
|
|
167
|
265
|
messages.value.push(assistantMsg)
|
|
168
|
266
|
await scrollToBottom()
|
|
169
|
267
|
|
|
170
|
268
|
try {
|
|
171
|
|
- const response = await fetch(`${AI_SERVICE_URL}/api/smart-query-stream`, {
|
|
|
269
|
+ const response = await fetch(AI_URL, {
|
|
172
|
270
|
method: 'POST',
|
|
173
|
271
|
headers: { 'Content-Type': 'application/json' },
|
|
174
|
272
|
body: JSON.stringify({
|
|
175
|
273
|
question: text,
|
|
176
|
|
- user_id: String(userStore.id || 'anonymous'),
|
|
177
|
|
- llm_type: 'claude'
|
|
178
|
|
- })
|
|
|
274
|
+ user_id: getUserId(),
|
|
|
275
|
+ llm_type: 'qwen',
|
|
|
276
|
+ session_id: sessionId.value,
|
|
|
277
|
+ history: history.value.slice(-3),
|
|
|
278
|
+ }),
|
|
179
|
279
|
})
|
|
180
|
280
|
|
|
181
|
|
- if (!response.ok) {
|
|
182
|
|
- throw new Error(`服务请求失败 (${response.status})`)
|
|
183
|
|
- }
|
|
|
281
|
+ if (!response.ok) throw new Error('请求失败 ' + response.status)
|
|
184
|
282
|
|
|
185
|
283
|
const reader = response.body.getReader()
|
|
186
|
284
|
const decoder = new TextDecoder()
|
|
|
@@ -191,66 +289,27 @@ const handleSend = async () => {
|
|
191
|
289
|
if (done) break
|
|
192
|
290
|
|
|
193
|
291
|
buffer += decoder.decode(value, { stream: true })
|
|
194
|
|
- // SSE 事件以两个换行符分隔
|
|
195
|
|
- const blocks = buffer.split('\n\n')
|
|
196
|
|
- buffer = blocks.pop() // 保留未完整的块
|
|
197
|
|
-
|
|
198
|
|
- for (const block of blocks) {
|
|
199
|
|
- if (!block.trim()) continue
|
|
200
|
|
- const lines = block.split('\n')
|
|
201
|
|
- let eventType = ''
|
|
202
|
|
- let dataStr = ''
|
|
203
|
|
- for (const line of lines) {
|
|
204
|
|
- if (line.startsWith('event: ')) eventType = line.slice(7).trim()
|
|
205
|
|
- else if (line.startsWith('data: ')) dataStr = line.slice(6).trim()
|
|
206
|
|
- }
|
|
207
|
|
- if (!dataStr) continue
|
|
208
|
|
-
|
|
209
|
|
- let data
|
|
210
|
|
- try { data = JSON.parse(dataStr) } catch { continue }
|
|
211
|
|
-
|
|
212
|
|
- if (eventType === 'progress') {
|
|
213
|
|
- const existing = assistantMsg.steps.find(s => s.step === data.step)
|
|
214
|
|
- if (existing) {
|
|
215
|
|
- Object.assign(existing, data)
|
|
216
|
|
- } else {
|
|
217
|
|
- assistantMsg.steps.push({ ...data })
|
|
218
|
|
- }
|
|
219
|
|
- // 触发响应式更新
|
|
220
|
|
- messages.value = [...messages.value]
|
|
221
|
|
- await scrollToBottom()
|
|
222
|
|
-
|
|
223
|
|
- } else if (eventType === 'result') {
|
|
224
|
|
- // 将所有仍在 running 的步骤标记为 done
|
|
225
|
|
- assistantMsg.steps.forEach(s => { if (s.status === 'running') s.status = 'done' })
|
|
226
|
|
- assistantMsg.result = data
|
|
227
|
|
- assistantMsg.status = 'done'
|
|
228
|
|
- messages.value = [...messages.value]
|
|
229
|
|
- await scrollToBottom()
|
|
230
|
|
-
|
|
231
|
|
- } else if (eventType === 'error') {
|
|
232
|
|
- assistantMsg.status = 'error'
|
|
233
|
|
- assistantMsg.errorText = data.message || '请求失败,请稍后重试'
|
|
234
|
|
- messages.value = [...messages.value]
|
|
|
292
|
+ const lines = buffer.split('\n')
|
|
|
293
|
+ buffer = lines.pop()
|
|
|
294
|
+
|
|
|
295
|
+ for (const line of lines) {
|
|
|
296
|
+ if (line.startsWith('data: ')) {
|
|
|
297
|
+ try {
|
|
|
298
|
+ const data = JSON.parse(line.slice(6))
|
|
|
299
|
+ handleEvent(assistantMsg, data)
|
|
|
300
|
+ await scrollToBottom()
|
|
|
301
|
+ } catch (e) {}
|
|
235
|
302
|
}
|
|
236
|
303
|
}
|
|
237
|
304
|
}
|
|
238
|
|
-
|
|
239
|
|
- // 若未收到 result 事件
|
|
240
|
|
- if (assistantMsg.status === 'loading') {
|
|
241
|
|
- assistantMsg.status = 'error'
|
|
242
|
|
- assistantMsg.errorText = '未收到服务响应,请检查 AI 服务是否正常运行'
|
|
243
|
|
- messages.value = [...messages.value]
|
|
244
|
|
- }
|
|
245
|
|
-
|
|
246
|
|
- } catch (e) {
|
|
|
305
|
+ } catch (err) {
|
|
247
|
306
|
assistantMsg.status = 'error'
|
|
248
|
|
- assistantMsg.errorText = e.message || '网络错误,请检查 AI 服务连接'
|
|
|
307
|
+ assistantMsg.errorText = '请求失败,请检查网络后重试'
|
|
249
|
308
|
messages.value = [...messages.value]
|
|
250
|
|
- } finally {
|
|
251
|
|
- isLoading.value = false
|
|
252
|
|
- await scrollToBottom()
|
|
253
|
309
|
}
|
|
|
310
|
+
|
|
|
311
|
+ isLoading.value = false
|
|
|
312
|
+ await scrollToBottom()
|
|
254
|
313
|
}
|
|
255
|
314
|
|
|
256
|
315
|
const handleReportClick = () => { showDataBoard.value = true }
|
|
|
@@ -459,6 +518,61 @@ const handleBack = () => {
|
|
459
|
518
|
font-size: 13px;
|
|
460
|
519
|
}
|
|
461
|
520
|
|
|
|
521
|
+/* ===== SQL 折叠样式 ===== */
|
|
|
522
|
+.sql-block {
|
|
|
523
|
+ margin-top: 12px;
|
|
|
524
|
+}
|
|
|
525
|
+
|
|
|
526
|
+.sql-toggle {
|
|
|
527
|
+ display: flex;
|
|
|
528
|
+ align-items: center;
|
|
|
529
|
+ gap: 8px;
|
|
|
530
|
+ cursor: pointer;
|
|
|
531
|
+ color: #557DDB;
|
|
|
532
|
+ font-size: 13px;
|
|
|
533
|
+}
|
|
|
534
|
+
|
|
|
535
|
+.sql-label {
|
|
|
536
|
+ background: #e6f0ff;
|
|
|
537
|
+ padding: 2px 8px;
|
|
|
538
|
+ border-radius: 4px;
|
|
|
539
|
+ font-weight: 600;
|
|
|
540
|
+}
|
|
|
541
|
+
|
|
|
542
|
+.sql-arrow {
|
|
|
543
|
+ font-size: 12px;
|
|
|
544
|
+}
|
|
|
545
|
+
|
|
|
546
|
+.sql-code {
|
|
|
547
|
+ margin-top: 8px;
|
|
|
548
|
+ background: #f5f7fa;
|
|
|
549
|
+ padding: 12px;
|
|
|
550
|
+ border-radius: 8px;
|
|
|
551
|
+ overflow-x: auto;
|
|
|
552
|
+}
|
|
|
553
|
+
|
|
|
554
|
+.sql-code pre {
|
|
|
555
|
+ margin: 0;
|
|
|
556
|
+ font-size: 12px;
|
|
|
557
|
+ color: #333;
|
|
|
558
|
+ white-space: pre-wrap;
|
|
|
559
|
+ word-break: break-all;
|
|
|
560
|
+}
|
|
|
561
|
+
|
|
|
562
|
+/* ===== 意图澄清 ===== */
|
|
|
563
|
+.clarify-bar {
|
|
|
564
|
+ background: #fff3cd;
|
|
|
565
|
+ border: 1px solid #ffc107;
|
|
|
566
|
+ padding: 12px 16px;
|
|
|
567
|
+ border-radius: 8px;
|
|
|
568
|
+ margin-bottom: 16px;
|
|
|
569
|
+}
|
|
|
570
|
+
|
|
|
571
|
+.clarify-text {
|
|
|
572
|
+ color: #856404;
|
|
|
573
|
+ font-size: 14px;
|
|
|
574
|
+}
|
|
|
575
|
+
|
|
462
|
576
|
/* ===== 底部输入区 ===== */
|
|
463
|
577
|
.chat-container {
|
|
464
|
578
|
flex-shrink: 0;
|