cc hace 1 semana
padre
commit
9b3b2c5196

+ 48 - 3
ai-electron/frontend/src/ai/components/ChatComposer.vue

@@ -16,8 +16,8 @@
       />
 
       <div class="ai-composer__bar">
-        <!-- 专家 chip(只读):会话创建时绑定所选专家,绑定后不可更换 -->
-        <span v-if="expertName" class="ai-composer__expert" title="该会话已绑定此专家,不可更换(要换请新建会话)">
+        <!-- 专家 chip(只读):草稿态显示将绑定的专家,会话已建则显示绑定者 -->
+        <span v-if="expertName" class="ai-composer__expert" :title="expertTitle">
           <Icon icon="mdi:brain" :size="14" />
           <span class="ai-composer__expert-name">{{ expertName }}</span>
         </span>
@@ -36,6 +36,12 @@
           </template>
         </a-dropdown>
 
+        <!-- 分析模板库:选模板填入输入框(只填不发) -->
+        <button class="ai-composer__tpl" type="button" title="分析模板库" @click="emit('templates')">
+          <Icon icon="mdi:bookmark-multiple-outline" :size="13" />
+          模板
+        </button>
+
         <span class="ai-composer__spacer"></span>
 
         <!-- 发送 / 停止:同一个位置、同一个尺寸,流式切换时不产生跳动 -->
@@ -92,16 +98,27 @@
       modelId?: string | null;
       /** 当前专家名(只读展示) */
       expertName?: string;
+      /** 是否已有会话(false = 草稿态,第一条消息发送时才建会话并绑定) */
+      sessionBound?: boolean;
       disabled?: boolean;
       streaming?: boolean;
     }>(),
-    { models: () => [], modelId: null, expertName: '', disabled: false, streaming: false },
+    { models: () => [], modelId: null, expertName: '', sessionBound: true, disabled: false, streaming: false },
+  );
+
+  /** chip 悬浮说明:草稿态是「将绑定」,已有会话是「已绑定、不可更换」 */
+  const expertTitle = computed(() =>
+    props.sessionBound
+      ? '该会话已绑定此专家,不可更换(要换请新建会话)'
+      : '发送第一条消息后将绑定该专家,绑定后不可更换',
   );
 
   const emit = defineEmits<{
     send: [text: string];
     stop: [];
     'update:modelId': [id: string];
+    /** 打开分析模板抽屉 */
+    templates: [];
   }>();
 
   const draft = ref('');
@@ -291,6 +308,34 @@
     transform: rotate(90deg);
   }
 
+  /* 分析模板库入口:与模型 chip 同形但更轻 */
+  .ai-composer__tpl {
+    display: inline-flex;
+    align-items: center;
+    gap: 5px;
+    height: 26px;
+    padding: 0 10px;
+    font-size: 12px;
+    color: var(--ai-text-2);
+    background: transparent;
+    border: 1px solid var(--ai-border);
+    border-radius: 13px;
+    cursor: pointer;
+    transition:
+      color 0.15s ease-out,
+      border-color 0.15s ease-out;
+
+    &:hover:not(:disabled) {
+      color: var(--ai-primary);
+      border-color: var(--ai-primary);
+    }
+
+    &:disabled {
+      cursor: not-allowed;
+      opacity: 0.5;
+    }
+  }
+
   .ai-composer__spacer {
     flex: 1 1 auto;
   }

+ 9 - 0
ai-electron/frontend/src/ai/components/ChatPanel.vue

@@ -63,12 +63,17 @@
       :model-id="modelId"
       :models="models"
       :expert-name="expertName"
+      :session-bound="hasConversation"
       :disabled="disabled"
       :streaming="streaming"
       @update:model-id="emit('update:modelId', $event)"
+      @templates="templatesOpen = true"
       @send="emit('send', $event)"
       @stop="emit('stop')"
     />
+
+    <!-- 分析模板库:选模板填入输入框(只填不发) -->
+    <TemplatePanel v-model:open="templatesOpen" @pick="fill" />
   </main>
 </template>
 
@@ -87,6 +92,7 @@
   import type { ChatMessage } from '../store/chatStream';
   import ChatComposer from './ChatComposer.vue';
   import ChatMessageItem from './ChatMessageItem.vue';
+  import TemplatePanel from './TemplatePanel.vue';
 
   const props = withDefaults(
     defineProps<{
@@ -126,6 +132,9 @@
   const scrollRef = ref<HTMLElement>();
   const composerRef = ref<InstanceType<typeof ChatComposer>>();
 
+  /** 分析模板抽屉开关 */
+  const templatesOpen = ref(false);
+
   /** 是否贴底:用户主动上翻后不再自动跟随流式增量 */
   let stickToBottom = true;
   /** 距底部超过一屏阈值时露出「回到底部」按钮 */

+ 167 - 0
ai-electron/frontend/src/ai/components/TemplatePanel.vue

@@ -0,0 +1,167 @@
+<template>
+  <a-drawer
+    :open="open"
+    title="分析模板库"
+    wrap-class-name="ai-template"
+    :width="520"
+    @close="emit('update:open', false)"
+  >
+    <div v-if="!groups.length" class="ai-template__empty">没有可用的模板。</div>
+
+    <section v-for="group in groups" :key="group.group" class="ai-template__group">
+      <div class="ai-template__group-head">
+        <span>{{ group.group }}</span>
+        <span class="ai-template__group-count">{{ group.items.length }}</span>
+      </div>
+
+      <article v-for="item in group.items" :key="item.id" class="ai-template__item">
+        <div class="ai-template__item-head">
+          <span class="ai-template__title" :title="item.title">{{ item.title }}</span>
+          <span v-if="item.builtin" class="ai-template__badge">预置</span>
+          <span class="ai-template__spacer"></span>
+          <a-button size="small" @click="onPick(item)">插入</a-button>
+          <a-button v-if="!item.builtin" size="small" danger type="text" @click="onRemove(item)">删除</a-button>
+        </div>
+        <p class="ai-template__prompt">{{ item.prompt }}</p>
+      </article>
+    </section>
+  </a-drawer>
+</template>
+
+<script lang="ts" setup>
+  /**
+   * 分析模板抽屉:选一个模板填入输入框(只填不发)。
+   *
+   * 从旧版 Codex 工作台的 codexTemplatePanel 迁移并按需裁剪:只保留「选」,
+   * 不带存为模板 / 导出导入工具条;存储同键(自定义模板共享),样式用 --ai-* 令牌。
+   */
+  import { computed, ref, watch } from 'vue';
+  import { Button, Drawer } from 'ant-design-vue';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import {
+    groupTemplates,
+    readCustomTemplates,
+    removeTemplate,
+    type PromptTemplate,
+  } from '../utils/promptTemplateLibrary';
+
+  const props = defineProps<{ open: boolean }>();
+  const emit = defineEmits<{
+    (e: 'update:open', value: boolean): void;
+    (e: 'pick', prompt: string): void;
+  }>();
+
+  const ADrawer = Drawer;
+  const AButton = Button;
+
+  const { showMessage } = useMessage();
+
+  const custom = ref<PromptTemplate[]>(readCustomTemplates());
+
+  const groups = computed(() => groupTemplates(custom.value));
+
+  // 每次打开都重读,避免多标签/外部改动导致列表过期
+  watch(
+    () => props.open,
+    (open) => {
+      if (open) {
+        custom.value = readCustomTemplates();
+      }
+    },
+  );
+
+  function onPick(item: PromptTemplate) {
+    emit('pick', item.prompt);
+    emit('update:open', false);
+  }
+
+  function onRemove(item: PromptTemplate) {
+    custom.value = removeTemplate(item.id);
+    showMessage('已删除模板', 'success');
+  }
+</script>
+
+<style lang="less" scoped>
+  .ai-template {
+    &__empty {
+      padding: var(--ai-sp-4) 0;
+      font-size: 12px;
+      color: var(--ai-text-2);
+      text-align: center;
+    }
+
+    &__group {
+      margin-bottom: var(--ai-sp-4);
+    }
+
+    &__group-head {
+      display: flex;
+      align-items: baseline;
+      gap: 6px;
+      margin-bottom: var(--ai-sp-2);
+      padding-bottom: 4px;
+      font-size: 11px;
+      font-weight: 600;
+      letter-spacing: 0.04em;
+      text-transform: uppercase;
+      color: var(--ai-text-2);
+      border-bottom: 1px solid var(--ai-border);
+    }
+
+    &__group-count {
+      font-weight: 400;
+      color: var(--ai-text-2);
+    }
+
+    &__item {
+      padding: var(--ai-sp-2) 0;
+
+      & + & {
+        border-top: 1px dashed var(--ai-border);
+      }
+    }
+
+    &__item-head {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+    }
+
+    &__title {
+      font-size: 13px;
+      font-weight: 600;
+      color: var(--ai-text);
+      overflow: hidden;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__badge {
+      flex: 0 0 auto;
+      padding: 0 6px;
+      font-size: 10.5px;
+      line-height: 17px;
+      color: var(--ai-text-2);
+      border: 1px solid var(--ai-border);
+      border-radius: 4px;
+    }
+
+    &__spacer {
+      flex: 1 1 auto;
+    }
+
+    &__prompt {
+      margin: 4px 0 0;
+      font-size: 12px;
+      line-height: 1.7;
+      color: var(--ai-text-2);
+      /* 模板正文可能带占位符,保留换行但压到 3 行,避免抽屉变成文章 */
+      display: -webkit-box;
+      -webkit-line-clamp: 3;
+      -webkit-box-orient: vertical;
+      overflow: hidden;
+      white-space: pre-wrap;
+      word-break: break-word;
+    }
+  }
+</style>

+ 8 - 8
ai-electron/frontend/src/ai/composables/useAiChatPage.ts

@@ -101,15 +101,15 @@ export function useAiChatPage() {
 
   /* ---------------- 会话操作 ---------------- */
 
-  async function onCreate() {
+  /**
+   * 新建对话 = 进入草稿态:欢迎页(示例提示词)可见、不落库;
+   * 发送第一条消息时才真正创建会话(store.send 的兜底路径)。
+   */
+  function onCreate() {
     if (!workspaceId.value) return;
-    try {
-      await store.createSession();
-      panelRef.value?.toBottom(true);
-      panelRef.value?.focus();
-    } catch (e: any) {
-      showMessage(e?.message || '创建会话失败', 'error');
-    }
+    store.startDraft(null);
+    panelRef.value?.toBottom(true);
+    panelRef.value?.focus();
   }
 
   async function onSelect(id: string) {

+ 13 - 0
ai-electron/frontend/src/ai/store/chatStream.ts

@@ -184,6 +184,19 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
       this.pendingExpert = expert;
     },
 
+    /**
+     * 进入「新对话草稿」:清空选中会话与消息但**不落库**——欢迎页重新可见,
+     * 真正的会话要等发送第一条消息时才创建(send 的兜底路径),避免点了按钮却不说话留下空会话。
+     *
+     * @param expert 预选专家(null = 默认专家,用于侧栏「新建对话」)
+     */
+    startDraft(expert: { id: string; name: string } | null) {
+      if (this.streaming) this.stop();
+      this.activeSessionId = null;
+      this.messages = [];
+      this.pendingExpert = expert;
+    },
+
     /** 新建会话(不立即选中时可传 false);有待选专家时本次创建即绑定它 */
     async createSession(title?: string, select = true): Promise<ChatSession | null> {
       if (!this.workspaceId) return null;

+ 184 - 0
ai-electron/frontend/src/ai/utils/promptTemplateLibrary.ts

@@ -0,0 +1,184 @@
+/**
+ * 分析模板库:把高频的提问方式沉淀成可复用模板(AI 对话输入框的「模板」抽屉用)。
+ *
+ * 从旧版 Codex 工作台({@code codex/utils/codexTemplateLibrary})迁移而来,两处差异:
+ * <ul>
+ *   <li>自定义模板沿用<b>同一 localStorage 键</b>(codex.templates.v1)——用户在旧版里
+ *       存过的模板在新对话框里继续可用,两边共享同一份;</li>
+ *   <li>预置模板按当前 AI 研判 agent 的口径重写:说清要查什么、结果怎么展示即可,
+ *       不再教 ```table / ```echarts 这类 Codex 围栏协议(新对话用 Markdown 表格与产物)。</li>
+ * </ul>
+ */
+
+export interface PromptTemplate {
+  id: string;
+  title: string;
+  prompt: string;
+  /** 分组名,用于抽屉里的分节 */
+  group: string;
+  /** true = 内置,不可删除/编辑 */
+  builtin: boolean;
+}
+
+/** 与旧版 Codex 同键:升级后用户已存的自定义模板不丢 */
+const CUSTOM_KEY = 'codex.templates.v1';
+const MAX_CUSTOM = 100;
+const MAX_PROMPT_LENGTH = 8000;
+
+/** 预置模板:贴合案件数据研判场景(与欢迎页示例同口径) */
+export const BUILTIN_TEMPLATES: PromptTemplate[] = [
+  {
+    id: 'builtin-overview',
+    title: '数据概览',
+    group: '摸底',
+    builtin: true,
+    prompt: '当前案件的数据空间里有哪些表?分别有哪些字段?先给出表清单,并说明每张表能回答什么问题。',
+  },
+  {
+    id: 'builtin-profile',
+    title: '字段画像与空值排查',
+    group: '摸底',
+    builtin: true,
+    prompt:
+      '对「<填写表名>」做字段画像:每个字段的类型、非空率、取值分布与最小/最大值(或高频取值),' +
+      '用表格列出,并把可疑字段(空值占比高、取值异常)单独标出来。',
+  },
+  {
+    id: 'builtin-topn',
+    title: 'TopN 排行',
+    group: '统计',
+    builtin: true,
+    prompt:
+      '统计「<填写字段>」出现次数最多的前 20 项,列出次数与占比(表格),' + '并指出明显异常、值得进一步核查的对象。',
+  },
+  {
+    id: 'builtin-trend',
+    title: '时序趋势',
+    group: '统计',
+    builtin: true,
+    prompt:
+      '按「<填写时间字段>」做时间聚合,统计每天的记录数变化趋势,用图表展示并指出峰值时段与突变点,' +
+      '说明可能的业务含义,原始明细用表格附在后面。',
+  },
+  {
+    id: 'builtin-money',
+    title: '资金流向链路',
+    group: '关联',
+    builtin: true,
+    prompt:
+      '围绕「<填写账号/卡号>」梳理资金流向:找出与其有交易往来的对象、交易笔数与金额合计,' +
+      '按金额从高到低列出,并总结最值得关注的资金链路与可疑点。',
+  },
+  {
+    id: 'builtin-spacetime',
+    title: '时空碰撞',
+    group: '关联',
+    builtin: true,
+    prompt:
+      '找出「<填写对象A>」与「<填写对象B>」在同一地点(基站/卡口/住宿)同一时段内同时出现的记录,' +
+      '按时间排序列出(含地点、起止时间、重合时长),并说明重合次数最多的地点。',
+  },
+  {
+    id: 'builtin-outlier',
+    title: '数据质量排查',
+    group: '质检',
+    builtin: true,
+    prompt:
+      '检查当前案件数据的质量问题:字段缺失、时间异常(早于 2000 年等)、金额异常(负数等)、' +
+      '重复记录、编码格式异常。用表格列出问题清单(问题类型、涉及字段、样例行、影响行数),并给出处理建议。',
+  },
+  {
+    id: 'builtin-report',
+    title: '结论报告成稿',
+    group: '输出',
+    builtin: true,
+    prompt:
+      '把本次会话前面的分析结论整理成一份可直接交付的报告:数据来源与时间范围、分析口径、' +
+      '主要发现(每条都要有数据支撑)、可疑点与建议的下一步核查方向。',
+  },
+];
+
+/* ------------------------------------------------------------------ *
+ * 自定义模板(localStorage)
+ * ------------------------------------------------------------------ */
+
+function sanitize(list: unknown): PromptTemplate[] {
+  if (!Array.isArray(list)) return [];
+  return list
+    .map((item: any) => ({
+      id: String(item?.id ?? ''),
+      title: String(item?.title ?? '').slice(0, 60),
+      prompt: String(item?.prompt ?? '').slice(0, MAX_PROMPT_LENGTH),
+      group: String(item?.group ?? '我的模板').slice(0, 20) || '我的模板',
+      builtin: false,
+    }))
+    .filter((item) => item.id && item.title && item.prompt);
+}
+
+export function readCustomTemplates(): PromptTemplate[] {
+  try {
+    const raw = window.localStorage.getItem(CUSTOM_KEY);
+    if (!raw) return [];
+    return sanitize(JSON.parse(raw));
+  } catch {
+    return [];
+  }
+}
+
+function writeCustomTemplates(list: PromptTemplate[]): PromptTemplate[] {
+  const trimmed = list.slice(-MAX_CUSTOM);
+  try {
+    window.localStorage.setItem(CUSTOM_KEY, JSON.stringify(trimmed));
+  } catch {
+    // 超配额:只影响持久化
+  }
+  return trimmed;
+}
+
+/** 新增自定义模板;标题重复时覆盖同名条目 */
+export function saveTemplate(input: { title: string; prompt: string; group?: string }): PromptTemplate[] {
+  const title = input.title.trim().slice(0, 60);
+  const prompt = input.prompt.trim().slice(0, MAX_PROMPT_LENGTH);
+  if (!title || !prompt) return readCustomTemplates();
+
+  const existing = readCustomTemplates();
+  const hit = existing.find((item) => item.title === title);
+  const record: PromptTemplate = {
+    id: hit?.id ?? `custom-${timestampTag()}-${Math.random().toString(36).slice(2, 7)}`,
+    title,
+    prompt,
+    group: (input.group ?? '我的模板').trim().slice(0, 20) || '我的模板',
+    builtin: false,
+  };
+  const next = hit ? existing.map((item) => (item.id === hit.id ? record : item)) : [...existing, record];
+  return writeCustomTemplates(next);
+}
+
+export function removeTemplate(id: string): PromptTemplate[] {
+  return writeCustomTemplates(readCustomTemplates().filter((item) => item.id !== id));
+}
+
+/** 预置 + 自定义,按分组聚合 */
+export function groupTemplates(
+  custom: PromptTemplate[],
+  keyword = '',
+): Array<{ group: string; items: PromptTemplate[] }> {
+  const key = keyword.trim().toLowerCase();
+  const all = [...BUILTIN_TEMPLATES, ...custom].filter((item) =>
+    key ? `${item.title} ${item.prompt} ${item.group}`.toLowerCase().includes(key) : true,
+  );
+  const groups: string[] = [];
+  for (const item of all) {
+    if (!groups.includes(item.group)) groups.push(item.group);
+  }
+  return groups.map((group) => ({ group, items: all.filter((item) => item.group === group) }));
+}
+
+/** 导出文件名用的时间标签:YYYYMMDD-HHmmss */
+export function timestampTag(date = new Date()): string {
+  const p = (n: number) => String(n).padStart(2, '0');
+  return (
+    `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-` +
+    `${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`
+  );
+}

+ 1 - 1
ai-electron/frontend/src/ai/views/aiExpert/index.vue

@@ -16,7 +16,7 @@
 
     <div class="ai-expert__hint">
       专家 = 一套「提示词 + 可用工具组 +
-      可用技能」的智能体。对话会话创建时绑定所选专家,<b>绑定后不可更换</b>(要换请新建会话)。
+      可用技能」的智能体。点「开始分析」进入新对话草稿,<b>发送第一条消息后才会保存会话</b>(发送时自动绑定该专家,绑定后不可更换)。
     </div>
 
     <a-spin :spinning="loading">

+ 3 - 4
ai-electron/frontend/src/ai/views/aiWorkbench/index.vue

@@ -149,12 +149,11 @@
   const artifacts = computed(() => extractArtifacts(store.messages || []));
 
   /**
-   * 专家页「去对话」:预选该专家并切回对话视图。
-   * 会话是「创建时绑定、绑定后不可更换」——预选在下一条消息**新建会话**时生效,
-   * 用户若显式选择已有会话则被清掉(store.selectSession)。
+   * 专家页「开始分析」:切回对话视图并进入**新对话草稿**——欢迎页可见、此时不落库,
+   * 发送第一条消息时才创建会话并绑定该专家(绑定后不可更换)。
    */
   async function onChatWithExpert(expert: AiExpert) {
-    store.setPendingExpert({ id: expert.id, name: expert.name });
+    store.startDraft({ id: expert.id, name: expert.name });
     view.value = 'chat';
     await nextTick();
     panelRef.value?.focus();