Bläddra i källkod

[Feat 0000]AI智能助手添加消息来源

wangkeyi 2 veckor sedan
förälder
incheckning
19eb72b2be

+ 29 - 1
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -842,7 +842,17 @@
     return str.replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
   };
 
-  const transformHistoryToMessages = (data: Array<{ role?: string; type?: string; content: string; created_at?: string; report_url?: string; report_filename?: string }>): Message[] => {
+  const transformHistoryToMessages = (
+    data: Array<{
+      role?: string;
+      type?: string;
+      content: string;
+      created_at?: string;
+      report_url?: string;
+      report_filename?: string;
+      citations?: any;
+    }>
+  ): Message[] => {
     const messages: Message[] = [];
 
     for (const item of data) {
@@ -858,6 +868,15 @@
         if (item.report_url && item.report_filename) {
           msg.downloadFiles = [{ url: item.report_url, filename: item.report_filename }];
         }
+        if (item.citations) {
+          // 历史接口返回 citations 为扁平数组,SSE 事件返回 { items, model_only },统一归一化
+          const raw = item.citations;
+          if (Array.isArray(raw)) {
+            msg.citations = { items: raw, model_only: raw.length === 0 };
+          } else {
+            msg.citations = raw;
+          }
+        }
         messages.push(msg);
       }
       // 兼容旧格式:type 字段
@@ -1323,6 +1342,15 @@
         aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
         break;
 
+      case 'citations': {
+        aiMsg.citations = {
+          items: data.items || [],
+          model_only: data.model_only || false,
+          message: data.message,
+        };
+        break;
+      }
+
       case 'word_download':
         if (normalizedContent) {
           aiMsg.wordDownloadUrl = normalizedContent;

+ 229 - 1
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -101,7 +101,56 @@
               </div>
             </div>
 
-            <div v-if="message.content" class="message-text" v-html="renderMarkdown(message.content, tableIcons)"></div>
+            <div
+              v-if="message.content"
+              class="message-text"
+              v-html="
+                renderMarkdown(
+                  message.content,
+                  tableIcons,
+                  message.citations?.items?.length ? Math.max(...message.citations.items.map((i) => i.id)) : undefined
+                )
+              "
+              @click="handleFootnoteClick($event, index)"
+            ></div>
+
+            <!-- 数据来源卡片 citations -->
+            <div v-if="message.citations" class="citations-card">
+              <div v-if="message.citations.model_only" class="citations-header citations-header-simple">
+                <span class="citations-title">本回答依据模型知识生成,仅供参考</span>
+              </div>
+              <template v-else-if="message.citations.items?.length">
+                <div class="citations-header" @click="toggleCitations(index)">
+                  <div class="citations-header-left">
+                    <span class="citations-title">数据来源</span>
+                    <span class="citations-count">{{ message.citations.items.length }}</span>
+                  </div>
+                  <div :class="['thinking-arrow', { expanded: !collapsedCitations[index] }]">
+                    <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
+                  </div>
+                </div>
+                <div v-show="!collapsedCitations[index]" class="citations-body">
+                  <div v-for="item in message.citations.items" :key="item.id" :id="`citation-${index}-${item.id}`" class="citation-item">
+                    <div class="citation-item-row">
+                      <span :class="['citation-badge', `citation-badge-${item.type}`]">{{ item.type_cn }}</span>
+                      <span class="citation-title-text">{{ item.title }}</span>
+                      <a v-if="item.search_url" :href="item.search_url" target="_blank" class="citation-link">知识库检索</a>
+                      <a v-if="item.url" :href="item.url" target="_blank" class="citation-link">链接</a>
+                    </div>
+                    <div class="citation-item-attrs">
+                      <span v-if="item.type === 'knowledge_base' && item.score != null" class="citation-attr">
+                        相似度 {{ item.score.toFixed(2) }}
+                        <span v-if="item.low_score" class="citation-low-score">(低相关度)</span>
+                      </span>
+                      <span v-if="item.source_system" class="citation-attr">{{ item.source_system }}</span>
+                      <span v-if="item.fetched_at" class="citation-attr">{{ item.fetched_at }}</span>
+                    </div>
+                    <div v-if="item.snippet" class="citation-snippet" :title="item.snippet">{{ item.snippet }}</div>
+                  </div>
+                </div>
+              </template>
+            </div>
+
             <div v-if="message.wordDownloadUrl" class="word-download-area">
               <a-button type="link" @click="emit('download-word', message.wordDownloadUrl)"> 下载报告文件 (.docx) </a-button>
             </div>
@@ -505,6 +554,7 @@
   const collapsedThinking = reactive<Record<number, boolean>>({});
   const collapsedThinkingContent = reactive<Record<number, boolean>>({});
   const expandedAgents = reactive<Record<string, boolean>>({});
+  const collapsedCitations = reactive<Record<number, boolean>>({});
   const isAtBottom = ref(true);
   const copiedMessageIndex = ref(-1);
 
@@ -633,6 +683,23 @@
     expandedAgents[key] = !expandedAgents[key];
   };
 
+  const toggleCitations = (index: number) => {
+    collapsedCitations[index] = !collapsedCitations[index];
+  };
+
+  const handleFootnoteClick = (e: Event, msgIndex: number) => {
+    const target = (e.target as HTMLElement).closest('.footnote-ref') as HTMLElement;
+    if (!target) return;
+    const n = target.dataset.n;
+    if (!n) return;
+    const el = document.getElementById(`citation-${msgIndex}-${n}`);
+    if (el) {
+      el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+      el.classList.add('citation-highlight');
+      setTimeout(() => el.classList.remove('citation-highlight'), 1500);
+    }
+  };
+
   const getLatestStepMessage = (group: AgentGroup): string => {
     if (group.steps.length === 0) return '';
     const last = group.steps[group.steps.length - 1];
@@ -1367,6 +1434,167 @@
           }
         }
 
+        .citations-card {
+          margin-bottom: 12px;
+          overflow: hidden;
+
+          .citations-header {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            padding: 8px 14px;
+            border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+            cursor: pointer;
+            user-select: none;
+            transition: background 0.2s;
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.08);
+            }
+
+            &.citations-header-simple {
+              cursor: default;
+
+              &:hover {
+                background: transparent;
+              }
+            }
+
+            .citations-header-left {
+              display: flex;
+              align-items: center;
+              gap: 8px;
+            }
+
+            .citations-title {
+              font-size: 13px;
+              font-weight: 500;
+              color: #8b949e;
+            }
+
+            .citations-count {
+              font-size: 12px;
+              color: #8b949e;
+              background: rgba(10, 132, 255, 0.1);
+              padding: 1px 6px;
+              border-radius: 10px;
+            }
+
+            .thinking-arrow {
+              display: flex;
+              align-items: center;
+              transition: transform 0.2s;
+
+              &.expanded {
+                transform: rotate(180deg);
+              }
+            }
+          }
+
+          .citations-body {
+            padding: 4px 14px;
+
+            .citation-item {
+              padding: 6px 0;
+              border-bottom: 1px solid rgba(63, 80, 106, 0.15);
+
+              &:last-child {
+                border-bottom: none;
+              }
+
+              &.citation-highlight {
+                background: rgba(10, 132, 255, 0.08);
+                border-radius: 4px;
+                padding: 6px 8px;
+                margin: 0 -8px;
+              }
+
+              .citation-item-row {
+                display: flex;
+                align-items: center;
+                gap: 8px;
+                line-height: 1.5;
+
+                .citation-badge {
+                  font-size: 12px;
+                  color: #79c0ff;
+                  font-family: monospace;
+                  background: rgba(10, 132, 255, 0.1);
+                  padding: 1px 5px;
+                  border-radius: 3px;
+                  white-space: nowrap;
+                  flex-shrink: 0;
+                }
+
+                .citation-badge-knowledge_base {
+                  color: #3fb950;
+                  background: rgba(46, 160, 67, 0.1);
+                }
+
+                .citation-badge-web {
+                  color: #d29922;
+                  background: rgba(210, 153, 34, 0.1);
+                }
+
+                .citation-badge-calc {
+                  color: #a371f7;
+                  background: rgba(163, 113, 247, 0.1);
+                }
+
+                .citation-title-text {
+                  font-size: 13px;
+                  color: #b0b8c4;
+                  overflow: hidden;
+                  text-overflow: ellipsis;
+                  white-space: nowrap;
+                  min-width: 0;
+                }
+
+                .citation-link {
+                  font-size: 12px;
+                  color: #0a84ff;
+                  text-decoration: none;
+                  white-space: nowrap;
+                  flex-shrink: 0;
+
+                  &:hover {
+                    text-decoration: underline;
+                  }
+                }
+              }
+
+              .citation-item-attrs {
+                display: flex;
+                align-items: center;
+                flex-wrap: wrap;
+                gap: 4px 10px;
+                padding-left: 0;
+                margin-top: 2px;
+
+                .citation-attr {
+                  font-size: 12px;
+                  color: #8b949e;
+                  white-space: nowrap;
+                }
+
+                .citation-low-score {
+                  color: #d29922;
+                }
+              }
+
+              .citation-snippet {
+                margin-top: 4px;
+                font-size: 12px;
+                color: #8b949e;
+                line-height: 1.5;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                white-space: nowrap;
+              }
+            }
+          }
+        }
+
         .download-file-cards {
           display: flex;
           flex-direction: column;

+ 26 - 0
src/views/ventAI/manageAssistent/components/chatModal/types.ts

@@ -21,6 +21,31 @@ export interface AgentAnswer {
   timestamp: number;
 }
 
+export interface CitationItem {
+  id: number;
+  type: 'knowledge_base' | 'mcp' | 'web' | 'calc' | 'file' | 'history' | 'system' | 'model';
+  type_cn: string;
+  title: string;
+  tool: string;
+  tool_cn: string;
+  score?: number;
+  low_score?: boolean;
+  doc_path?: string;
+  search_url?: string;
+  snippet?: string;
+  url?: string;
+  fetched_at?: string;
+  source_system?: string;
+  param?: Record<string, any>;
+  meta?: any;
+}
+
+export interface CitationsData {
+  items: CitationItem[];
+  model_only: boolean;
+  message?: string;
+}
+
 export interface ThinkingStep {
   type: 'agent_start' | 'agent_done' | 'tool_call' | 'tool_result' | 'executing' | 'updated_todo_list' | 'token';
   agent?: string;
@@ -69,6 +94,7 @@ export interface Message {
   sessionId?: string;
   wordDownloadUrl?: string;
   downloadFiles?: Array<{ url: string; filename: string }>;
+  citations?: CitationsData;
   durationMs?: number;
   baseDurationMs?: number;
   generateStartTime?: number;

+ 21 - 1
src/views/ventAI/manageAssistent/components/chatModal/utils.ts

@@ -22,10 +22,30 @@ const renderLatexInHtml = (html: string): string => {
   return html;
 };
 
-export const renderMarkdown = (text: string, icons?: { copy: string; download: string; preview: string; copySuccess: string }): string => {
+const processFootnotes = (html: string, maxId?: number): string => {
+  if (maxId === undefined) {
+    // 无 citations 上下文,剥离所有 [^n] 标记
+    return html.replace(/\[\^\d+\]/g, '');
+  }
+  // 有效编号范围 [1, maxId],范围外的剥离
+  return html.replace(/\[\^(\d+)\]/g, (_match, n) => {
+    const id = parseInt(n, 10);
+    if (id >= 1 && id <= maxId) {
+      return `<sup class="footnote-ref" data-n="${id}">[${n}]</sup>`;
+    }
+    return '';
+  });
+};
+
+export const renderMarkdown = (
+  text: string,
+  icons?: { copy: string; download: string; preview: string; copySuccess: string },
+  maxCitationId?: number
+): string => {
   if (!text) return '';
   const processed = text.replace(/<br\s*\/?>/gi, '  \n');
   let html = marked.parse(processed) as string;
+  html = processFootnotes(html, maxCitationId);
   html = renderLatexInHtml(html);
   return icons ? wrapTables(html, icons) : html;
 };