cc 1 week ago
parent
commit
baa620b299

+ 6 - 1
ai-electron/frontend/src/ai/components/ChatComposer.vue

@@ -152,7 +152,12 @@
     nextTick(() => inputRef.value?.focus?.());
   }
 
-  defineExpose({ fill });
+  /** 供外部(新建会话后)只聚焦、不动草稿 */
+  function focus() {
+    nextTick(() => inputRef.value?.focus?.());
+  }
+
+  defineExpose({ fill, focus });
 
   watch(
     () => props.disabled,

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

@@ -0,0 +1,327 @@
+<template>
+  <main class="ai-main">
+    <!-- 未打开案件 -->
+    <div v-if="disabled" class="ai-empty-tip ai-chat__center">
+      请先打开一个案件,AI 数据分析将基于当前案件的数据空间工作。
+    </div>
+
+    <!-- 空态:用法引导 -->
+    <div v-else-if="!hasConversation" class="ai-scroll">
+      <div class="ai-column ai-chat__welcome">
+        <h1 class="ai-chat__welcome-title">用自然语言完成一次数据研判</h1>
+        <p class="ai-chat__welcome-desc">
+          AI 会在当前案件的数据空间中检索表结构、编写并执行只读 SQL,把结果整理成可翻页的数据表、图表与关系图谱,
+          并给出分析结论。所有取数过程都可展开核对。
+        </p>
+
+        <ol class="ai-chat__steps">
+          <li v-for="(item, idx) in EXAMPLES" :key="item.text" class="ai-chat__step">
+            <span class="ai-chat__step-index">{{ idx + 1 }}</span>
+            <div class="ai-chat__step-body">
+              <div class="ai-chat__step-title">{{ item.title }}</div>
+              <button class="ai-chat__step-prompt" type="button" @click="fill(item.text)">
+                {{ item.text }}
+              </button>
+            </div>
+          </li>
+        </ol>
+      </div>
+    </div>
+
+    <!-- 消息流 -->
+    <div v-else class="ai-chat__flow">
+      <div ref="scrollRef" class="ai-scroll" @scroll="onScroll">
+        <div class="ai-column ai-chat__messages">
+          <ChatMessageItem
+            v-for="(msg, idx) in messages"
+            :key="msg.id"
+            :message="msg"
+            :title="title"
+            @pick="fill"
+            @star="emit('star', msg.id)"
+            @remove="emit('remove', msg.id)"
+            @regenerate="onRegenerate(idx)"
+          />
+        </div>
+      </div>
+
+      <!-- 上翻历史后一键回到底部(贴着输入区上方,不遮挡正文) -->
+      <button
+        v-if="showToBottom"
+        class="ai-chat__to-bottom"
+        type="button"
+        aria-label="回到底部"
+        @click="toBottom(true)"
+      >
+        <Icon icon="mdi:arrow-down" :size="16" />
+      </button>
+    </div>
+
+    <!-- 输入区 -->
+    <ChatComposer
+      ref="composerRef"
+      :model-id="modelId"
+      :models="models"
+      :disabled="disabled"
+      :streaming="streaming"
+      @update:model-id="emit('update:modelId', $event)"
+      @send="emit('send', $event)"
+      @stop="emit('stop')"
+    />
+  </main>
+</template>
+
+<script lang="ts" setup name="AiChatPanel">
+  /**
+   * AI 对话面板:消息流 + 输入区(不含会话侧栏,也不含任何页级顶栏)。
+   *
+   * 两个壳按各自的设计装配它:aiAnalysis 是「顶栏 + 侧栏 + 本面板」的独立页,
+   * aiWorkbench 是「工作台顶栏 + 左栏(菜单与会话)+ 本面板」的工作台视图。
+   * 面板内的滚动、建议词回填、模型选择都是纯视图行为,自持即可;
+   * 消息的增删与发送属于会话状态,一律通过 props / emit 交给调用方。
+   */
+  import { computed, nextTick, ref, watch } from 'vue';
+  import Icon from '@/core/components/Icon/src/Icon.vue';
+  import type { AiModel } from '../api/types';
+  import type { ChatMessage } from '../store/chatStream';
+  import ChatComposer from './ChatComposer.vue';
+  import ChatMessageItem from './ChatMessageItem.vue';
+
+  const props = withDefaults(
+    defineProps<{
+      messages: ChatMessage[];
+      /** 当前会话标题(消息导出、复制时作为标题) */
+      title?: string;
+      /** 是否已有会话上下文(无会话时显示用法引导) */
+      hasConversation?: boolean;
+      /** 未打开案件时禁用输入与消息区 */
+      disabled?: boolean;
+      streaming?: boolean;
+      models?: AiModel[];
+      /** 模型ID(雪花 ID 字符串,禁止转 number) */
+      modelId?: string | null;
+    }>(),
+    {
+      title: '',
+      hasConversation: false,
+      disabled: false,
+      streaming: false,
+      models: () => [],
+      modelId: null,
+    },
+  );
+
+  const emit = defineEmits<{
+    send: [text: string];
+    stop: [];
+    star: [id: string];
+    remove: [id: string];
+    'update:modelId': [id: string];
+  }>();
+
+  const scrollRef = ref<HTMLElement>();
+  const composerRef = ref<InstanceType<typeof ChatComposer>>();
+
+  /** 是否贴底:用户主动上翻后不再自动跟随流式增量 */
+  let stickToBottom = true;
+  /** 距底部超过一屏阈值时露出「回到底部」按钮 */
+  const showToBottom = ref(false);
+
+  const EXAMPLES = [
+    { title: '看数据有什么', text: '当前案件的数据空间里有哪些表?分别有哪些字段?' },
+    { title: '找重点对象', text: '统计通话次数最多的前 10 个号码,并列出对应的机主信息' },
+    { title: '看资金链路', text: '分析指定卡号近三个月的资金流向,找出金额最大的 20 笔交易' },
+    { title: '看时空碰撞', text: '找出两个目标号码在同一基站下同时出现的记录,按时间排序' },
+  ];
+
+  /* ---------------- 对外方法(供壳层按 panelRef 调用) ---------------- */
+
+  /** 建议词 / 历史追问回填:只填空并聚焦,不自动发送 */
+  function fill(text: string) {
+    composerRef.value?.fill(text);
+  }
+
+  function focus() {
+    composerRef.value?.focus();
+  }
+
+  async function toBottom(force = false) {
+    if (!force && !stickToBottom) return;
+    await nextTick();
+    const el = scrollRef.value;
+    if (el) el.scrollTop = el.scrollHeight;
+    if (force) showToBottom.value = false;
+  }
+
+  defineExpose({ fill, focus, toBottom });
+
+  /* ---------------- 滚动 ---------------- */
+
+  function onScroll() {
+    const el = scrollRef.value;
+    if (!el) return;
+    const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
+    stickToBottom = distance < 80;
+    showToBottom.value = distance > 200;
+  }
+
+  // 新消息 / 流式增量时保持贴底
+  watch(
+    () => [props.messages.length, props.messages[props.messages.length - 1]?.content?.length],
+    () => toBottom(),
+  );
+
+  /** 重新生成:面板只负责定位到该回答之前最近的一次提问,重发由壳层执行 */
+  function onRegenerate(index: number) {
+    if (props.streaming) return;
+    for (let i = index - 1; i >= 0; i -= 1) {
+      const message = props.messages[i];
+      if (message.role === 'user') {
+        emit('send', message.content);
+        return;
+      }
+    }
+  }
+</script>
+
+<style lang="less" scoped>
+  .ai-chat {
+    &__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>

+ 189 - 0
ai-electron/frontend/src/ai/composables/useAiChatPage.ts

@@ -0,0 +1,189 @@
+import { computed, onMounted, ref, watch } from 'vue';
+import { useMessage } from '@/core/hooks/web/useMessage';
+import { useUserStore } from '@/core/store/modules/case';
+import { listModels } from '../api/modelApi';
+import type { AiModel } from '../api/types';
+import { useChatStreamStore } from '../store/chatStream';
+
+/**
+ * AI 对话页的共享装配逻辑:状态与全部交互 handler。
+ *
+ * aiAnalysis(传统模式页)与 aiWorkbench(智能模式工作台)共用同一套粘合层,
+ * 两边只保留各自的页面骨架模板——会话数据源、模型选择、会话增删改查、发送与
+ * 重新生成必须只有一份实现,否则两处各写一份必然漂移。
+ *
+ * 消息流 / 输入区收在 ChatPanel 里,本文件只按 `panelRef` 调它暴露的方法
+ * (填充草稿 / 回到底部 / 聚焦),与 codex 侧按 listRef、composerRef 调用的口径一致。
+ */
+export function useAiChatPage() {
+  const store = useChatStreamStore();
+  const userStore = useUserStore();
+  const { showMessage } = useMessage();
+
+  /** 聊天面板(消息流 + 输入区)的模板引用 */
+  const panelRef = ref();
+
+  const models = ref<AiModel[]>([]);
+  /** 当前选中的模型ID(雪花 ID 字符串,禁止转 number) */
+  const modelId = ref<string | null>(null);
+
+  /**
+   * 当前案件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'));
+
+  /**
+   * 当前模型的展示名(顶栏 chip 用)。
+   * 取不到选中项时退化为「默认模型」:后端默认模型由列表接口的 defaultModel 标记,不在这里兜底查询。
+   */
+  const modelLabel = computed(() => {
+    const hit = chatModels.value.find((m) => String(m.id) === String(modelId.value ?? ''));
+    return hit?.name || hit?.modelId || '默认模型';
+  });
+
+  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();
+      panelRef.value?.toBottom(true);
+      panelRef.value?.focus();
+    } catch (e: any) {
+      showMessage(e?.message || '创建会话失败', 'error');
+    }
+  }
+
+  async function onSelect(id: string) {
+    if (id === store.activeSessionId) return;
+    await store.selectSession(id);
+    panelRef.value?.toBottom(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 onReload() {
+    await store.loadSessions();
+  }
+
+  /* ---------------- 发送 ---------------- */
+
+  async function onSend(text: string) {
+    panelRef.value?.toBottom(true);
+    await store.send(text, modelId.value ?? undefined);
+  }
+
+  function usePrompt(text: string) {
+    panelRef.value?.fill(text);
+  }
+
+  /** 重新生成:找到该回答之前最近的一条用户消息重发 */
+  async function onRegenerate(index: number) {
+    if (store.streaming) return;
+    const messages = store.messages;
+    for (let i = index - 1; i >= 0; i -= 1) {
+      if (messages[i].role === 'user') {
+        await onSend(messages[i].content);
+        return;
+      }
+    }
+  }
+
+  return {
+    store,
+    panelRef,
+    models,
+    modelId,
+    chatModels,
+    modelLabel,
+    workspaceId,
+    caseName,
+    activeTitle,
+    hasConversation,
+    loadModels,
+    onCreate,
+    onSelect,
+    onRename,
+    onPin,
+    onRemove,
+    onRemoveMessage,
+    onReload,
+    onSend,
+    onRegenerate,
+    usePrompt,
+  };
+}

+ 38 - 386
ai-electron/frontend/src/ai/views/aiAnalysis/index.vue

@@ -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>

+ 298 - 0
ai-electron/frontend/src/ai/views/aiWorkbench/index.vue

@@ -0,0 +1,298 @@
+<template>
+  <div class="awb-page">
+    <!-- 工作台顶栏:与布局壳共用 AppTopBar(结构/模式切换/头像/窗口控制都在里面) -->
+    <AppTopBar
+      :case-name="caseName"
+      :logo-title="'清鉴 · 智能工作台'"
+      show-case-exit
+      @exit-case="onExitCase"
+    >
+      <!-- 工作台特有状态 chip:当前会话 / 当前模型 -->
+      <template #info>
+        <span v-if="activeTitle" class="awb-chip awb-chip--muted" :title="activeTitle">
+          {{ activeTitle }}
+        </span>
+        <span class="awb-chip awb-chip--model">
+          <Icon icon="mdi:robot-outline" :size="13" />
+          {{ modelLabel }}
+        </span>
+      </template>
+
+      <template #actions>
+        <Tooltip title="重新加载会话列表" placement="bottom">
+          <button class="awb-iconbtn" type="button" @click="onReload">
+            <Icon icon="mdi:refresh" :size="15" />
+          </button>
+        </Tooltip>
+      </template>
+    </AppTopBar>
+
+    <div class="awb-body">
+      <!-- 左栏:视图菜单 + 会话 -->
+      <aside class="awb-rail">
+        <nav class="awb-rail__menu">
+          <button
+            v-for="item in RAIL_ITEMS"
+            :key="item.key"
+            type="button"
+            class="awb-rm"
+            :class="{ 'is-on': view === item.key }"
+            @click="view = item.key"
+          >
+            <Icon :icon="item.icon" :size="15" />
+            {{ item.label }}
+          </button>
+        </nav>
+
+        <SessionList
+          :sessions="store.sessions"
+          :active-id="store.activeSessionId"
+          :loading="store.loadingSessions"
+          @create="onCreate"
+          @select="onSelect"
+          @rename="onRename"
+          @pin="onPin"
+          @remove="onRemove"
+        />
+      </aside>
+
+      <!-- 中栏:视图 -->
+      <main class="awb-view">
+        <!-- ① 对话 -->
+        <ChatPanel
+          v-if="view === 'chat'"
+          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"
+        />
+
+        <!-- ② 模型管理 -->
+        <AiModelPage v-else-if="view === 'model'" />
+
+        <!-- ③ 插件与技能 -->
+        <AiPluginPage v-else />
+      </main>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup name="AiWorkbench">
+  /**
+   * 智能工作台(智能模式的主界面)。
+   *
+   * 不套传统 LAYOUT 的全屏路由:AI 能力集中呈现——对话 / 模型管理 / 插件与技能
+   * 三个视图经左栏菜单切换,交互逻辑与 aiAnalysis 完全同源(useAiChatPage),
+   * 案件上下文也一致(useUserStore().getCaseInfo → workspaceId)。
+   * 切换案件必须退出后从案件管理重进。
+   *
+   * 组件级复用:会话侧栏 / 对话面板 / 模型页 / 插件页全部来自 ai 模块现有实现,
+   * 本文件只做装配与工作台外壳,不含协议逻辑。
+   */
+  import { computed, ref } from 'vue';
+  import { Tooltip } from 'ant-design-vue';
+  import Icon from '@/core/components/Icon/src/Icon.vue';
+  import { AppTopBar } from '@/core/components/Application';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import { exitCaseConfirmOptions } from '@/core/logics/exitCaseConfirm';
+  import { useUserStore } from '@/core/store/modules/case';
+  import SessionList from '../../components/SessionList.vue';
+  import ChatPanel from '../../components/ChatPanel.vue';
+  import AiModelPage from '../aiModel/index.vue';
+  import AiPluginPage from '../aiPlugin/index.vue';
+  import { useAiChatPage } from '../../composables/useAiChatPage';
+
+  const userStore = useUserStore();
+  const { createConfirm } = useMessage();
+
+  type WorkbenchView = 'chat' | 'model' | 'plugin';
+  const view = ref<WorkbenchView>('chat');
+
+  const RAIL_ITEMS: Array<{ key: WorkbenchView; icon: string; label: string }> = [
+    { key: 'chat', icon: 'mdi:robot-outline', label: '对话' },
+    { key: 'model', icon: 'mdi:brain', label: '模型管理' },
+    { key: 'plugin', icon: 'mdi:puzzle-outline', label: '插件与技能' },
+  ];
+
+  const {
+    store,
+    panelRef,
+    modelId,
+    chatModels,
+    modelLabel,
+    workspaceId,
+    caseName,
+    activeTitle,
+    hasConversation,
+    onCreate,
+    onSelect,
+    onRename,
+    onPin,
+    onRemove,
+    onRemoveMessage,
+    onReload,
+    onSend,
+  } = useAiChatPage();
+
+  /**
+   * 退出案件:回案件管理页重新选择(工作台不允许直接切案件)。
+   * 破坏性动作(当前会话上下文、工作台状态都会离开)先弹确认框。
+   *
+   * 模式偏好保持不动:用户选的是「我的主界面是工作台」,退出案件只是去挑另一个案件,
+   * 重新开案后仍按偏好回工作台(落点在 case store 的 afteropenCaseAction)。
+   */
+  function onExitCase() {
+    createConfirm(
+      exitCaseConfirmOptions(async () => {
+        await userStore.logout(true);
+      }, caseName.value),
+    );
+  }
+</script>
+
+<style lang="less">
+  .awb-page {
+    display: flex;
+    flex-direction: column;
+    width: 100vw;
+    height: 100vh;
+    overflow: hidden;
+    background: var(--ai-bg-page);
+  }
+
+  .awb-chip {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    flex: 0 0 auto;
+    max-width: 260px;
+    padding: 3px 11px;
+    overflow: hidden;
+    font-size: 12.5px;
+    color: var(--ai-text-2);
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    background: var(--ai-bg-subtle);
+    border-radius: 99px;
+
+    &.awb-chip--muted {
+      color: var(--ai-text-2);
+    }
+
+    &.awb-chip--model {
+      font-weight: 600;
+      color: var(--ai-primary);
+      background: var(--ai-primary-soft);
+    }
+  }
+
+  /* 顶栏图标按钮:尺寸对齐传统顶栏的图标按钮(36px 方块 + 悬停底色) */
+  .awb-iconbtn {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 36px;
+    min-width: 36px;
+    height: 36px;
+    padding: 0;
+    font-size: 18px;
+    color: var(--cb-text-primary);
+    background: transparent;
+    border: none;
+    border-radius: 10px;
+    cursor: pointer;
+    transition: background-color 0.2s ease;
+
+    &:hover {
+      color: var(--ai-primary);
+      background: var(--ai-bg-hover);
+    }
+  }
+
+  .awb-body {
+    display: flex;
+    flex: 1;
+    min-height: 0;
+  }
+
+  /* ── 左栏:菜单 + 会话 ── */
+  .awb-rail {
+    display: flex;
+    flex-direction: column;
+    flex: 0 0 auto;
+    width: 264px;
+    min-height: 0;
+    overflow: hidden;
+    background: var(--ai-bg-subtle);
+    border-right: 1px solid var(--ai-border);
+  }
+
+  .awb-rail__menu {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    flex: 0 0 auto;
+    padding: 10px 8px;
+    border-bottom: 1px solid var(--ai-border);
+  }
+
+  .awb-rm {
+    display: flex;
+    align-items: center;
+    gap: 9px;
+    padding: 8px 10px;
+    font-size: 13px;
+    color: var(--ai-text-2);
+    text-align: left;
+    background: transparent;
+    border: none;
+    border-radius: 7px;
+    cursor: pointer;
+
+    &:hover {
+      color: var(--ai-primary);
+      background: var(--ai-bg-hover);
+    }
+
+    &.is-on {
+      font-weight: 600;
+      color: var(--ai-primary);
+      background: var(--ai-primary-soft);
+    }
+  }
+
+  /* 会话列表吃满左栏剩余高度;它自带「搜索 + 新建对话」头,不再另做一套 */
+  .awb-rail .ai-sessions {
+    flex: 1 1 auto;
+    min-height: 0;
+  }
+
+  /* ── 中栏 ── */
+  .awb-view {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    min-width: 0;
+    min-height: 0;
+  }
+
+  /*
+   * 模型 / 插件页是按传统布局容器写的(.ai-page 反冲了左右 10px、自带圆角描边)。
+   * 工作台中栏是全屏列,这里把页壳还原成「铺满、无外框」,避免溢出与多余卡片感。
+   */
+  .awb-view > .ai-page {
+    width: 100%;
+    height: 100%;
+    margin: 0;
+    border: 0;
+    border-radius: 0;
+  }
+</style>

+ 2 - 0
ai-electron/frontend/src/core/enums/pageEnum.ts

@@ -5,6 +5,8 @@ export enum PageEnum {
   LEGACY_LOGIN = '/login',
   BASE_CASE = '/case',
   BASE_WORKBENCH = '/workbench',
+  // 旧版智能工作台(codex 运行时):保留地址,仅作对照 / 回退入口,不再是智能模式的落点
+  BASE_CODEX_WORKBENCH = '/codexWorkbench',
   // basic home path
   BASE_HOME = '/data/index',
   BASE_UPLOAD = '/data/upload',

+ 8 - 0
ai-electron/frontend/src/core/logics/workbenchMode.ts

@@ -5,6 +5,14 @@ import { PageEnum } from '@/core/enums/pageEnum';
 /** 智能工作台的全屏路由地址(守卫与切换器同口径) */
 export const WORKBENCH_PATH = '/workbench';
 
+/**
+ * 全部「工作台」地址:当前工作台 + 保留的旧版工作台(codex 运行时)。
+ *
+ * 旧版仅保留代码与地址供对照/回退,不再是智能模式的落点(落点只认 WORKBENCH_PATH);
+ * 但两者都是不套布局的全屏壳,传统模式下一律不放行。
+ */
+export const WORKBENCH_PATHS: string[] = [WORKBENCH_PATH, PageEnum.BASE_CODEX_WORKBENCH];
+
 /**
  * 当前模式下的「主界面」地址(打开案件后的落点)。
  *

+ 4 - 4
ai-electron/frontend/src/core/router/guard/index.ts

@@ -15,7 +15,7 @@ import {
 } from '@/core/logics/analysisRefreshPrompt';
 import { createPermissionGuard } from './permissionGuard';
 import { createStateGuard } from './stateGuard';
-import { WORKBENCH_PATH } from '@/core/logics/workbenchMode';
+import { WORKBENCH_PATH, WORKBENCH_PATHS } from '@/core/logics/workbenchMode';
 // import nProgress from 'nprogress';
 import projectSetting from '@/core/settings/projectSetting';
 // import { createParamMenuGuard } from './paramMenuGuard';
@@ -36,7 +36,7 @@ export function setupRouterGuard(router: Router) {
 
 /**
  * 主界面模式守卫:
- * - 传统模式敲 /workbench → 回首页;
+ * - 传统模式敲工作台地址(当前 /workbench 与保留的旧版 /codexWorkbench)→ 回首页;
  * - 智能模式敲 AI 菜单组页面 → 引导回工作台(AI 能力集中在工作台呈现)。
  * 业务分析各页面两种模式都可直接访问(直接敲 URL 不拦),页面本身自带布局壳,
  * 所以这里**不改写**用户的模式偏好 —— 偏好只由模式开关设置,用来决定开案后的落点。
@@ -55,8 +55,8 @@ function createWorkbenchGuard(router: Router) {
       return;
     }
 
-    // 传统模式不接受工作台地址
-    if (to.path === WORKBENCH_PATH) {
+    // 传统模式不接受工作台地址(当前与旧版都是不套布局的全屏壳)
+    if (WORKBENCH_PATHS.includes(to.path)) {
       return { path: PageEnum.BASE_HOME, replace: true };
     }
   });

+ 23 - 1
ai-electron/frontend/src/core/router/routes/index.ts

@@ -130,11 +130,14 @@ export const CaseCleanProgressRoute: AppRouteRecordRaw = {
 /**
  * 智能工作台:独立于布局的全屏路由(不套 LAYOUT,覆盖包括菜单在内的整个视口)。
  * 「智能模式」的主界面;「传统 ⇄ 智能」切换即在本路由与 BASE_HOME 间跳转。
+ *
+ * <p>由 ai 模块的 aiWorkbench 承担:左栏菜单(对话 / 模型管理 / 插件与技能)+ 对话面板,
+ * 交互与 aiAnalysis 同源(useAiChatPage)。
  */
 export const WorkbenchRoute: AppRouteRecordRaw = {
   path: PageEnum.BASE_WORKBENCH,
   name: 'Workbench',
-  component: () => import('@/codex/views/workbench/Workbench.vue'),
+  component: () => import('@/ai/views/aiWorkbench/index.vue'),
   meta: {
     title: '智能工作台',
     hideMenu: true,
@@ -142,11 +145,30 @@ export const WorkbenchRoute: AppRouteRecordRaw = {
   },
 };
 
+/**
+ * 旧版智能工作台(基于 codex 运行时):**保留**,只换地址不再作为智能模式的落点。
+ *
+ * <p>原样保留的原因是可回退对照:新工作台走服务端 Agent(SSE),旧工作台走本机 Codex
+ * 子进程(产物 / 看板 / 审批),两者能力并不完全重叠。代码与路由都在,直接敲 URL 即可进入;
+ * 传统模式同样不放行(见 router/guard 的 workbench 守卫)。
+ */
+export const CodexWorkbenchRoute: AppRouteRecordRaw = {
+  path: PageEnum.BASE_CODEX_WORKBENCH,
+  name: 'CodexWorkbench',
+  component: () => import('@/codex/views/workbench/Workbench.vue'),
+  meta: {
+    title: '智能工作台(Codex)',
+    hideMenu: true,
+    hideTab: true,
+  },
+};
+
 // Basic routing without permission
 export const basicRoutes = [
   AuthorizationRoute,
   CaseRoute,
   WorkbenchRoute,
+  CodexWorkbenchRoute,
   CaseUploadRoute,
   CaseCleaningRoute,
   CaseCleanProgressRoute,