|
|
@@ -13,7 +13,7 @@
|
|
|
<span class="ai-topbar__spacer"></span>
|
|
|
<div class="ai-topbar__actions">
|
|
|
<a-tooltip title="重新加载会话列表">
|
|
|
- <a-button size="small" :loading="store.loadingSessions" @click="store.loadSessions()">
|
|
|
+ <a-button size="small" :loading="store.loadingSessions" @click="onReload">
|
|
|
<template #icon><Icon icon="mdi:refresh" :size="14" /></template>
|
|
|
</a-button>
|
|
|
</a-tooltip>
|
|
|
@@ -40,75 +40,20 @@
|
|
|
</div>
|
|
|
|
|
|
<!-- 对话区 -->
|
|
|
- <main class="ai-main">
|
|
|
- <!-- 未打开案件 -->
|
|
|
- <div v-if="!workspaceId" class="ai-empty-tip ai-analysis__center">
|
|
|
- 请先打开一个案件,AI 数据分析将基于当前案件的数据空间工作。
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 空态:用法引导 -->
|
|
|
- <div v-else-if="!hasConversation" class="ai-scroll">
|
|
|
- <div class="ai-column ai-analysis__welcome">
|
|
|
- <h1 class="ai-analysis__welcome-title">用自然语言完成一次数据研判</h1>
|
|
|
- <p class="ai-analysis__welcome-desc">
|
|
|
- AI 会在当前案件的数据空间中检索表结构、编写并执行只读 SQL,把结果整理成可翻页的数据表、图表与关系图谱,
|
|
|
- 并给出分析结论。所有取数过程都可展开核对。
|
|
|
- </p>
|
|
|
-
|
|
|
- <ol class="ai-analysis__steps">
|
|
|
- <li v-for="(item, idx) in EXAMPLES" :key="item.text" class="ai-analysis__step">
|
|
|
- <span class="ai-analysis__step-index">{{ idx + 1 }}</span>
|
|
|
- <div class="ai-analysis__step-body">
|
|
|
- <div class="ai-analysis__step-title">{{ item.title }}</div>
|
|
|
- <button class="ai-analysis__step-prompt" type="button" @click="usePrompt(item.text)">
|
|
|
- {{ item.text }}
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </li>
|
|
|
- </ol>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 消息流 -->
|
|
|
- <div v-else class="ai-analysis__flow">
|
|
|
- <div ref="scrollRef" class="ai-scroll" @scroll="onScroll">
|
|
|
- <div class="ai-column ai-analysis__messages">
|
|
|
- <ChatMessageItem
|
|
|
- v-for="(msg, idx) in store.messages"
|
|
|
- :key="msg.id"
|
|
|
- :message="msg"
|
|
|
- :title="activeTitle"
|
|
|
- @pick="usePrompt"
|
|
|
- @star="store.toggleStar(msg.id)"
|
|
|
- @remove="onRemoveMessage(msg.id)"
|
|
|
- @regenerate="onRegenerate(idx)"
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 上翻历史后一键回到底部(贴着输入区上方,不遮挡正文) -->
|
|
|
- <button
|
|
|
- v-if="showToBottom"
|
|
|
- class="ai-analysis__to-bottom"
|
|
|
- type="button"
|
|
|
- aria-label="回到底部"
|
|
|
- @click="scrollToBottom(true)"
|
|
|
- >
|
|
|
- <Icon icon="mdi:arrow-down" :size="16" />
|
|
|
- </button>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 输入区 -->
|
|
|
- <ChatComposer
|
|
|
- ref="composerRef"
|
|
|
- v-model:model-id="modelId"
|
|
|
- :models="chatModels"
|
|
|
- :disabled="!workspaceId"
|
|
|
- :streaming="store.streaming"
|
|
|
- @send="onSend"
|
|
|
- @stop="store.stop()"
|
|
|
- />
|
|
|
- </main>
|
|
|
+ <ChatPanel
|
|
|
+ ref="panelRef"
|
|
|
+ :messages="store.messages"
|
|
|
+ :title="activeTitle"
|
|
|
+ :has-conversation="hasConversation"
|
|
|
+ :disabled="!workspaceId"
|
|
|
+ :streaming="store.streaming"
|
|
|
+ :models="chatModels"
|
|
|
+ v-model:model-id="modelId"
|
|
|
+ @send="onSend"
|
|
|
+ @stop="store.stop()"
|
|
|
+ @star="store.toggleStar($event)"
|
|
|
+ @remove="onRemoveMessage"
|
|
|
+ />
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
@@ -117,193 +62,38 @@
|
|
|
/**
|
|
|
* AI 数据分析 · 对话页
|
|
|
*
|
|
|
- * 左侧会话列表 + 右侧消息流/输入区。会话归属当前案件(workspaceId = 案件ID),
|
|
|
+ * 左侧会话列表 + 右侧对话区。会话归属当前案件(workspaceId = 案件ID),
|
|
|
* 与后端 AgentChatController 的 workspaceId 入参一致。
|
|
|
+ *
|
|
|
+ * 交互逻辑全部在 useAiChatPage(与智能工作台同源),本文件只搭传统模式的页壳。
|
|
|
*/
|
|
|
- import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
|
|
import { Button, Tooltip } from 'ant-design-vue';
|
|
|
import Icon from '@/core/components/Icon/src/Icon.vue';
|
|
|
- import { useUserStore } from '@/core/store/modules/case';
|
|
|
- import { useMessage } from '@/core/hooks/web/useMessage';
|
|
|
- import { listModels } from '../../api/modelApi';
|
|
|
- import type { AiModel } from '../../api/types';
|
|
|
- import { useChatStreamStore } from '../../store/chatStream';
|
|
|
import SessionList from '../../components/SessionList.vue';
|
|
|
- import ChatMessageItem from '../../components/ChatMessageItem.vue';
|
|
|
- import ChatComposer from '../../components/ChatComposer.vue';
|
|
|
+ import ChatPanel from '../../components/ChatPanel.vue';
|
|
|
+ import { useAiChatPage } from '../../composables/useAiChatPage';
|
|
|
|
|
|
const AButton = Button;
|
|
|
const ATooltip = Tooltip;
|
|
|
|
|
|
- const store = useChatStreamStore();
|
|
|
- const userStore = useUserStore();
|
|
|
- const { showMessage } = useMessage();
|
|
|
-
|
|
|
- const scrollRef = ref<HTMLElement>();
|
|
|
- const composerRef = ref<InstanceType<typeof ChatComposer>>();
|
|
|
- const models = ref<AiModel[]>([]);
|
|
|
- /** 当前选中的模型ID(雪花 ID 字符串,禁止转 number) */
|
|
|
- const modelId = ref<string | null>(null);
|
|
|
- let stickToBottom = true;
|
|
|
- /** 距底部超过一屏阈值时露出"回到底部"按钮 */
|
|
|
- const showToBottom = ref(false);
|
|
|
-
|
|
|
- /**
|
|
|
- * 当前案件ID —— 作为后端 workspaceId。
|
|
|
- * 案件 ID 是雪花 ID(19 位 Long),后端以字符串下发,这里**必须原样保持字符串**:
|
|
|
- * 一旦 Number() 转换,末位精度丢失,会话就会挂到不存在的案件下。
|
|
|
- */
|
|
|
- const workspaceId = computed(() => (userStore.getCaseInfo?.id ? String(userStore.getCaseInfo.id) : ''));
|
|
|
- const caseName = computed(() => userStore.getCaseInfo?.name || '未选择案件');
|
|
|
- const activeTitle = computed(() => store.activeSession?.title || '');
|
|
|
- const hasConversation = computed(() => !!store.activeSessionId || store.messages.length > 0);
|
|
|
-
|
|
|
- /**
|
|
|
- * 供聊天选择的模型:只保留对话模型(type=llm)。
|
|
|
- * 模型表里同时存在 type=embedding 的向量模型(用于表结构语义检索),
|
|
|
- * 它不能用于对话 —— 列进选择器会让用户选中后必然失败。
|
|
|
- */
|
|
|
- const chatModels = computed(() => models.value.filter((m) => !m.type || m.type === 'llm'));
|
|
|
-
|
|
|
- const EXAMPLES = [
|
|
|
- { title: '看数据有什么', text: '当前案件的数据空间里有哪些表?分别有哪些字段?' },
|
|
|
- { title: '找重点对象', text: '统计通话次数最多的前 10 个号码,并列出对应的机主信息' },
|
|
|
- { title: '看资金链路', text: '分析指定卡号近三个月的资金流向,找出金额最大的 20 笔交易' },
|
|
|
- { title: '看时空碰撞', text: '找出两个目标号码在同一基站下同时出现的记录,按时间排序' },
|
|
|
- ];
|
|
|
-
|
|
|
- /* ---------------- 初始化 ---------------- */
|
|
|
-
|
|
|
- onMounted(async () => {
|
|
|
- if (workspaceId.value) {
|
|
|
- await store.init(workspaceId.value);
|
|
|
- }
|
|
|
- await loadModels();
|
|
|
- });
|
|
|
-
|
|
|
- // 切换案件时重置会话上下文
|
|
|
- watch(workspaceId, async (id, old) => {
|
|
|
- if (id === old) return;
|
|
|
- if (!id) {
|
|
|
- store.reset();
|
|
|
- return;
|
|
|
- }
|
|
|
- store.reset();
|
|
|
- await store.init(id, true);
|
|
|
- });
|
|
|
-
|
|
|
- async function loadModels() {
|
|
|
- try {
|
|
|
- models.value = (await listModels()) || [];
|
|
|
- // 后端默认模型按 type 隔离(getDefaultModelId 只取 llm),这里再过滤一次以兼容历史数据
|
|
|
- const llmModels = models.value.filter((m) => !m.type || m.type === 'llm');
|
|
|
- const def = llmModels.find((m) => m.defaultModel);
|
|
|
- modelId.value = def?.id ?? llmModels[0]?.id ?? null;
|
|
|
- } catch {
|
|
|
- models.value = [];
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /* ---------------- 会话操作 ---------------- */
|
|
|
-
|
|
|
- async function onCreate() {
|
|
|
- if (!workspaceId.value) return;
|
|
|
- try {
|
|
|
- await store.createSession();
|
|
|
- await nextTick();
|
|
|
- composerRef.value?.fill('');
|
|
|
- } catch (e: any) {
|
|
|
- showMessage(e?.message || '创建会话失败', 'error');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- async function onSelect(id: string) {
|
|
|
- if (id === store.activeSessionId) return;
|
|
|
- await store.selectSession(id);
|
|
|
- stickToBottom = true;
|
|
|
- await scrollToBottom(true);
|
|
|
- }
|
|
|
-
|
|
|
- async function onRename(id: string, title: string) {
|
|
|
- try {
|
|
|
- await store.renameSession(id, title);
|
|
|
- } catch (e: any) {
|
|
|
- showMessage(e?.message || '重命名失败', 'error');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- async function onPin(id: string) {
|
|
|
- try {
|
|
|
- await store.togglePin(id);
|
|
|
- } catch (e: any) {
|
|
|
- showMessage(e?.message || '操作失败', 'error');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /** 确认框在 SessionList 的删除图标上(那里能带上会话标题),这里只负责执行 */
|
|
|
- async function onRemove(id: string) {
|
|
|
- try {
|
|
|
- await store.removeSession(id);
|
|
|
- showMessage('已删除', 'success');
|
|
|
- } catch (e: any) {
|
|
|
- showMessage(e?.message || '删除失败', 'error');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- async function onRemoveMessage(id: string) {
|
|
|
- try {
|
|
|
- await store.removeMessage(id);
|
|
|
- } catch (e: any) {
|
|
|
- showMessage(e?.message || '删除失败', 'error');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /* ---------------- 发送 ---------------- */
|
|
|
-
|
|
|
- async function onSend(text: string) {
|
|
|
- stickToBottom = true;
|
|
|
- await scrollToBottom(true);
|
|
|
- await store.send(text, modelId.value ?? undefined);
|
|
|
- }
|
|
|
-
|
|
|
- function usePrompt(text: string) {
|
|
|
- composerRef.value?.fill(text);
|
|
|
- }
|
|
|
-
|
|
|
- /** 重新生成:找到该回答之前最近的一条用户消息重发 */
|
|
|
- async function onRegenerate(index: number) {
|
|
|
- if (store.streaming) return;
|
|
|
- for (let i = index - 1; i >= 0; i--) {
|
|
|
- if (store.messages[i].role === 'user') {
|
|
|
- await onSend(store.messages[i].content);
|
|
|
- return;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /* ---------------- 自动滚动 ---------------- */
|
|
|
-
|
|
|
- function onScroll() {
|
|
|
- const el = scrollRef.value;
|
|
|
- if (!el) return;
|
|
|
- const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
|
- stickToBottom = distance < 80;
|
|
|
- showToBottom.value = distance > 200;
|
|
|
- }
|
|
|
-
|
|
|
- async function scrollToBottom(force = false) {
|
|
|
- if (!force && !stickToBottom) return;
|
|
|
- await nextTick();
|
|
|
- const el = scrollRef.value;
|
|
|
- if (el) el.scrollTop = el.scrollHeight;
|
|
|
- if (force) showToBottom.value = false;
|
|
|
- }
|
|
|
-
|
|
|
- // 新消息 / 流式增量时保持贴底
|
|
|
- watch(
|
|
|
- () => [store.messages.length, store.messages[store.messages.length - 1]?.content?.length],
|
|
|
- () => scrollToBottom(),
|
|
|
- );
|
|
|
+ const {
|
|
|
+ store,
|
|
|
+ panelRef,
|
|
|
+ modelId,
|
|
|
+ chatModels,
|
|
|
+ workspaceId,
|
|
|
+ caseName,
|
|
|
+ activeTitle,
|
|
|
+ hasConversation,
|
|
|
+ onCreate,
|
|
|
+ onSelect,
|
|
|
+ onRename,
|
|
|
+ onPin,
|
|
|
+ onRemove,
|
|
|
+ onRemoveMessage,
|
|
|
+ onReload,
|
|
|
+ onSend,
|
|
|
+ } = useAiChatPage();
|
|
|
</script>
|
|
|
|
|
|
<style lang="less" scoped>
|
|
|
@@ -312,143 +102,5 @@
|
|
|
color: var(--ai-text-2);
|
|
|
opacity: 0.6;
|
|
|
}
|
|
|
-
|
|
|
- &__center {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- flex: 1 1 auto;
|
|
|
- }
|
|
|
-
|
|
|
- &__welcome {
|
|
|
- padding-top: var(--ai-sp-6);
|
|
|
- padding-bottom: var(--ai-sp-6);
|
|
|
- }
|
|
|
-
|
|
|
- &__welcome-title {
|
|
|
- margin: 0 0 var(--ai-sp-2);
|
|
|
- font-size: 20px;
|
|
|
- font-weight: 600;
|
|
|
- line-height: 1.4;
|
|
|
- color: var(--ai-text);
|
|
|
- }
|
|
|
-
|
|
|
- &__welcome-desc {
|
|
|
- max-width: 620px;
|
|
|
- margin: 0 0 var(--ai-sp-5);
|
|
|
- font-size: 13.5px;
|
|
|
- line-height: 1.8;
|
|
|
- color: var(--ai-text-2);
|
|
|
- }
|
|
|
-
|
|
|
- /* 用法引导:编号列表 + 可点击示例,比等宽卡片网格更省空间也更像"操作指引" */
|
|
|
- &__steps {
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- gap: 0;
|
|
|
- margin: 0;
|
|
|
- padding: 0;
|
|
|
- list-style: none;
|
|
|
- border-top: 1px solid var(--ai-border);
|
|
|
- }
|
|
|
-
|
|
|
- &__step {
|
|
|
- display: flex;
|
|
|
- align-items: flex-start;
|
|
|
- gap: var(--ai-sp-3);
|
|
|
- padding: var(--ai-sp-3) 0;
|
|
|
- border-bottom: 1px solid var(--ai-border);
|
|
|
- }
|
|
|
-
|
|
|
- &__step-index {
|
|
|
- flex: 0 0 auto;
|
|
|
- display: inline-flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- width: 22px;
|
|
|
- height: 22px;
|
|
|
- margin-top: 1px;
|
|
|
- font-size: 11px;
|
|
|
- font-weight: 600;
|
|
|
- color: var(--ai-primary);
|
|
|
- background: var(--ai-primary-soft);
|
|
|
- border-radius: 50%;
|
|
|
- }
|
|
|
-
|
|
|
- &__step-body {
|
|
|
- flex: 1 1 auto;
|
|
|
- min-width: 0;
|
|
|
- }
|
|
|
-
|
|
|
- &__step-title {
|
|
|
- font-size: 13px;
|
|
|
- font-weight: 600;
|
|
|
- color: var(--ai-text);
|
|
|
- }
|
|
|
-
|
|
|
- &__step-prompt {
|
|
|
- display: block;
|
|
|
- margin-top: var(--ai-sp-1);
|
|
|
- padding: 0;
|
|
|
- font-size: 13px;
|
|
|
- line-height: 1.7;
|
|
|
- text-align: left;
|
|
|
- color: var(--ai-text-2);
|
|
|
- background: none;
|
|
|
- border: 0;
|
|
|
- cursor: pointer;
|
|
|
- transition: color 0.15s ease-out;
|
|
|
-
|
|
|
- &:hover {
|
|
|
- color: var(--ai-primary);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /* 对话区:滚动区 + 悬浮按钮的定位父级 */
|
|
|
- &__flow {
|
|
|
- position: relative;
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- flex: 1 1 auto;
|
|
|
- min-height: 0;
|
|
|
- }
|
|
|
-
|
|
|
- &__to-bottom {
|
|
|
- position: absolute;
|
|
|
- left: 50%;
|
|
|
- bottom: var(--ai-sp-3);
|
|
|
- z-index: var(--ai-z-sticky);
|
|
|
- display: inline-flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- width: 32px;
|
|
|
- height: 32px;
|
|
|
- color: var(--ai-text-2);
|
|
|
- background: var(--ai-bg);
|
|
|
- border: 1px solid var(--ai-border);
|
|
|
- border-radius: 50%;
|
|
|
- box-shadow: var(--ai-shadow);
|
|
|
- transform: translateX(-50%);
|
|
|
- cursor: pointer;
|
|
|
- transition:
|
|
|
- color 0.15s ease-out,
|
|
|
- border-color 0.15s ease-out;
|
|
|
-
|
|
|
- &:hover {
|
|
|
- color: var(--ai-primary);
|
|
|
- border-color: fade(@primary-color, 35%);
|
|
|
- }
|
|
|
-
|
|
|
- &:focus-visible {
|
|
|
- outline: 2px solid var(--ai-primary);
|
|
|
- outline-offset: 2px;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- &__messages {
|
|
|
- padding-top: var(--ai-sp-4);
|
|
|
- /* 底部留白:最后一条的悬浮操作条不要贴着输入区 */
|
|
|
- padding-bottom: var(--ai-sp-6);
|
|
|
- }
|
|
|
}
|
|
|
</style>
|