Prechádzať zdrojové kódy

feat(codex): 产物栏区分本会话产物与全部文件

会话共用同一个案件工作空间,产物栏原来列的是整个工作空间,每个会话看到的都一样。改为双视图:「本会话产物」(默认)= 工作空间里 mtime ≥ 会话创建时间的文件(主进程新增 listAllArtifacts 递归列举,深度≤6/文件≤3000);「全部文件」保留原目录浏览。关键词与类型筛选两视图共用,「本轮变更」不受影响。

验证:前端 type:check 89 条基线/本包 0 报错 + build exit=0;主进程 tsc --noEmit 0 报错 + 91 单测全过 + build-electron exit=0。
cc 2 týždňov pred
rodič
commit
4473cc4f92

+ 18 - 0
.workbuddy/memory/2026-09-24.md

@@ -277,3 +277,21 @@ composer 的 `cwd` prop 仅用于「切会话清空草稿」的 watch,不渲
 不碰 Codex 侧落盘历史,与 removeSession 同一原则)。
 
 验证:`pnpm type:check` 89 基线 / 本包 0 报错;`pnpm build` exit=0。
+
+## 产物栏双视图:本会话产物(默认)/ 全部文件
+
+用户反馈:右侧展示的是当前工作空间,没有展示**当前会话**的产物——所有会话共用同一个
+案件工作空间,看到的文件列表一模一样。
+
+方案(保留 cwd = 案件工作空间不变,不动 Codex 的取数流程):
+- **主进程**:`codexArtifactService.listAllArtifacts(root)` 递归列举工作空间全部文件
+  (深度 ≤6、文件 ≤3000,异常子目录跳过),`codexArtifactCtl.listAll` 暴露。
+- **判定口径**:`mtime ≥ session.createdAt`。会话创建后 Codex 产出或改过的文件都会命中;
+  别的会话更早生成的不会混进来。并行会话改同一个文件会同时出现在两边,属可接受误差。
+- **前端**:store 增 `sessionFiles` + `loadSessionFiles()`(reloadArtifacts 与回合结束时刷新);
+  产物栏加「本会话产物(默认)/ 全部文件」切换条,关键词与类型筛选两个视图共用;
+  「全部文件」保留原目录浏览 + 上一级 + 新角标;「本轮变更」两个视图都在。
+- 面板默认视图 = 本会话产物;空态文案引导「想看工作空间里的全部文件请切到全部文件」。
+
+验证:前端 type:check 89 基线 / 本包 0 报错 + build exit=0;
+主进程 tsc --noEmit 0 报错 + 91 单测全过 + build-electron exit=0(listAll 已进 bundle)。

+ 6 - 0
ai-electron/electron/controller/codexArtifactCtl.ts

@@ -1,5 +1,6 @@
 import { logger } from 'ee-core/log';
 import {
+  listAllArtifacts,
   listArtifacts,
   openArtifact,
   openArtifactRoot,
@@ -43,6 +44,11 @@ class CodexArtifactCtl {
     );
   }
 
+  /** 递归列出工作空间全部文件(「本会话产物」视图用) */
+  async listAll(params?: { root?: string }): Promise<Rpc<unknown>> {
+    return guard('listAll', async () => listAllArtifacts(requireString(params?.root, 'root')));
+  }
+
   async readText(params?: { root?: string; path?: string; maxBytes?: number }): Promise<Rpc<unknown>> {
     return guard('readText', async () =>
       readArtifactText(

+ 62 - 0
ai-electron/electron/service/codex/codexArtifactService.ts

@@ -18,6 +18,10 @@ import { getCodexDataDir } from './codexHome';
 
 /** 单次列目录返回的条目上限 */
 const MAX_ENTRIES = 2000;
+/** 递归列举的目录深度上限(防死循环软链 / 过深目录树) */
+const MAX_WALK_DEPTH = 6;
+/** 递归列举返回的文件条目上限 */
+const MAX_WALK_FILES = 3000;
 /** 文本预览字节上限 */
 const MAX_TEXT_BYTES = 2 * 1024 * 1024;
 /** 二进制预览字节上限(图片 / PDF) */
@@ -237,6 +241,64 @@ export async function listArtifacts(root: string, dir?: string): Promise<Artifac
   return { root: base, relative: relativeDir, entries, truncated };
 }
 
+/**
+ * 递归列出工作空间里的全部文件(扁平、相对路径)。
+ *
+ * 「本会话产物」视图靠它:会话共用同一个案件工作空间,
+ * 单层列目录只能看到根下的文件,子目录里的产物(图表、输出目录)会漏。
+ * 有深度与数量上限,异常目录不会拖垮主进程。
+ */
+export async function listAllArtifacts(root: string): Promise<{ root: string; entries: ArtifactEntry[]; truncated: boolean }> {
+  const base = await requireAllowedRoot(root);
+  const entries: ArtifactEntry[] = [];
+  let truncated = false;
+
+  const walk = async (relativeDir: string, depth: number): Promise<void> => {
+    if (truncated || depth > MAX_WALK_DEPTH || entries.length >= MAX_WALK_FILES) {
+      truncated = truncated || entries.length >= MAX_WALK_FILES;
+      return;
+    }
+    const target = resolveInside(base, relativeDir);
+    let names: string[] = [];
+    try {
+      names = await fs.readdir(target);
+    } catch {
+      // 单个子目录读不到(被删 / 无权限)就跳过,不影响整体
+      return;
+    }
+    for (const name of names) {
+      if (entries.length >= MAX_WALK_FILES) {
+        truncated = true;
+        return;
+      }
+      const relative = relativeDir ? `${relativeDir}/${name}` : name;
+      const absolute = resolveInside(base, relative);
+      let stat;
+      try {
+        stat = await fs.stat(absolute);
+      } catch {
+        continue;
+      }
+      if (stat.isDirectory()) {
+        await walk(relative, depth + 1);
+        continue;
+      }
+      entries.push({
+        name,
+        relative: toPosix(relative),
+        isDir: false,
+        size: stat.size,
+        mtime: stat.mtime.toISOString(),
+        kind: artifactKindOf(name, false),
+      });
+    }
+  };
+
+  await walk('', 1);
+  entries.sort((x, y) => Date.parse(y.mtime) - Date.parse(x.mtime));
+  return { root: base, entries, truncated };
+}
+
 export interface ArtifactTextResult {
   content: string;
   size: number;

+ 5 - 0
ai-electron/frontend/src/codex/api/codexArtifactApi.ts

@@ -44,6 +44,11 @@ export function codexArtifactList(input: { root: string; dir?: string }): Promis
   return invoke<CodexArtifactListResult>('list', input);
 }
 
+/** 递归列出工作空间里的全部文件(扁平相对路径)——「本会话产物」视图用 */
+export function codexArtifactListAll(input: { root: string }): Promise<CodexArtifactListResult> {
+  return invoke<CodexArtifactListResult>('listAll', input);
+}
+
 /** 读文本产物(md / 代码 / csv / json 等),超过上限只回前一段并置 truncated */
 export function codexArtifactReadText(input: {
   root: string;

+ 113 - 28
ai-electron/frontend/src/codex/components/codexArtifactPanel.vue

@@ -3,7 +3,8 @@
     <div class="codex-panel-head">
       <span>产物</span>
       <span class="codex-panel-head__count">
-        {{ filtered.length }}<template v-if="filtered.length !== entries.length"> / {{ entries.length }}</template>
+        {{ displayEntries.length
+        }}<template v-if="displayEntries.length !== displayTotal.length"> / {{ displayTotal.length }}</template>
       </span>
       <span class="codex-panel-head__spacer"></span>
       <button class="codex-btn" type="button" :disabled="loading" @click="refresh()">
@@ -16,6 +17,26 @@
       </button>
     </div>
 
+    <!-- 视图切换:默认看本会话产物,避免不同会话互相看到对方的文件 -->
+    <div v-if="root" class="codex-artifact__views">
+      <button
+        class="codex-artifact__view"
+        :class="{ 'is-active': view === 'session' }"
+        type="button"
+        @click="switchView('session')"
+      >
+        本会话产物
+      </button>
+      <button
+        class="codex-artifact__view"
+        :class="{ 'is-active': view === 'all' }"
+        type="button"
+        @click="switchView('all')"
+      >
+        全部文件
+      </button>
+    </div>
+
     <!-- 搜索 + 类型筛选:目录多了以后靠翻是翻不过来的 -->
     <div v-if="root" class="codex-artifact__filters">
       <input v-model="keyword" class="codex-artifact__search" type="text" placeholder="按文件名筛选" />
@@ -23,7 +44,7 @@
     </div>
 
     <!-- 面包屑:只在进到子目录后出现 -->
-    <div v-if="root" class="codex-artifact__crumb">
+    <div v-if="root && view === 'all'" class="codex-artifact__crumb">
       <button v-if="relative" class="codex-btn" type="button" @click="onUp">
         <Icon icon="mdi:arrow-up" :size="13" />
         上一级
@@ -64,20 +85,25 @@
         新建会话后,Codex 生成的文件会出现在这里。
       </div>
 
-      <template v-else-if="loading && !entries.length">
+      <template v-else-if="displayLoading && !displayEntries.length">
         <div class="codex-skeleton codex-artifact__skeleton"></div>
         <div class="codex-skeleton codex-artifact__skeleton"></div>
         <div class="codex-skeleton codex-artifact__skeleton"></div>
       </template>
 
-      <div v-else-if="!entries.length" class="codex-placeholder">
-        目录为空。让 Codex 生成图表或报告后,这里会出现产物。
+      <div v-else-if="!displayTotal.length" class="codex-placeholder">
+        <template v-if="view === 'session'">
+          本会话还没有产生文件。<br />
+          让 Codex 生成图表或报告后,它产出 / 修改的文件会出现在这里;<br />
+          想看工作空间里的全部文件请切到「全部文件」。
+        </template>
+        <template v-else> 目录为空。让 Codex 生成图表或报告后,这里会出现产物。 </template>
       </div>
 
-      <div v-else-if="!filtered.length" class="codex-placeholder">没有匹配的文件。</div>
+      <div v-else-if="!displayEntries.length" class="codex-placeholder">没有匹配的文件。</div>
 
       <ul v-else class="codex-artifact__list">
-        <li v-for="entry in sorted" :key="entry.relative" class="codex-artifact__item">
+        <li v-for="entry in displaySorted" :key="entry.relative" class="codex-artifact__item">
           <button class="codex-artifact__main" type="button" :title="entry.relative" @click="onOpenEntry(entry)">
             <Icon class="codex-artifact__icon" :icon="codexArtifactIcon(entry.kind)" :size="15" />
             <span class="codex-artifact__name">{{ entry.name }}</span>
@@ -94,7 +120,7 @@
         </li>
       </ul>
 
-      <div v-if="truncated" class="codex-artifact__truncated">条目较多,仅显示前一部分。</div>
+      <div v-if="displayTruncated" class="codex-artifact__truncated">条目较多,仅显示前一部分。</div>
     </div>
 
     <!-- 预览 -->
@@ -116,10 +142,14 @@
 
 <script lang="ts" setup>
   /**
-   * 产物栏:会话工作目录的文件浏览 / 预览 / 另存为 + 本轮回合的变更清单。
+   * 产物栏:双视图 + 预览 / 另存为 + 本轮回合的变更清单。
+   *
+   *  - **本会话产物**(默认):工作空间里 mtime ≥ 会话创建时间的文件(含子目录)。
+   *    会话共用同一个案件工作空间,不做这份过滤的话每个会话看到的都是同一堆文件;
+   *  - **全部文件**:按目录浏览工作空间(原有能力)。
    *
    * 目录信息全部来自主进程(沙箱限定在会话 cwd 内),
-   * 前端只保留「当前相对目录 + 筛选条件」这一点状态,刷新即回到主进程的真实结果。
+   * 前端只保留「当前视图 + 相对目录 + 筛选条件」这一点状态,刷新即回到主进程的真实结果。
    */
   import { computed, ref } from 'vue';
   import { Button, Modal, Select } from 'ant-design-vue';
@@ -157,6 +187,7 @@
   const previewEntry = ref<CodexArtifactEntry | null>(null);
   const keyword = ref('');
   const kindFilter = ref('all');
+  const view = ref<'session' | 'all'>('session');
   const expandedChange = ref('');
 
   const root = computed(() => store.artifact.root);
@@ -167,29 +198,48 @@
   const changes = computed<CodexArtifactChange[]>(() => store.artifactChanges);
   const newEntries = computed(() => store.artifact.newEntries);
 
-  /** 目录项永远显示(否则筛选后没法导航),文件按关键词 + 类型过滤 */
-  const filtered = computed(() => {
+  const sessionEntries = computed(() => store.sessionFiles.entries);
+  const sessionLoading = computed(() => store.sessionFiles.loading);
+  const sessionTruncated = computed(() => store.sessionFiles.truncated);
+
+  /** 关键词 + 类型过滤;目录项永远显示(否则「全部文件」视图没法导航) */
+  function matchFilter(entry: CodexArtifactEntry): boolean {
     const key = keyword.value.trim().toLowerCase();
+    if (entry.isDir) return true;
+    if (key && !entry.name.toLowerCase().includes(key)) return false;
     const kind = kindFilter.value;
-    return entries.value.filter((entry) => {
-      if (entry.isDir) return true;
-      if (key && !entry.name.toLowerCase().includes(key)) return false;
-      if (kind === 'all') return true;
-      if (kind === 'table') return entry.kind === 'table';
-      if (kind === 'image') return entry.kind === 'image';
-      if (kind === 'report') return REPORT_KINDS.includes(entry.kind);
-      if (kind === 'code') return CODE_KINDS.includes(entry.kind);
-      return ![...REPORT_KINDS, ...CODE_KINDS, 'table', 'image'].includes(entry.kind);
-    });
-  });
+    if (kind === 'all') return true;
+    if (kind === 'table') return entry.kind === 'table';
+    if (kind === 'image') return entry.kind === 'image';
+    if (kind === 'report') return REPORT_KINDS.includes(entry.kind);
+    if (kind === 'code') return CODE_KINDS.includes(entry.kind);
+    return ![...REPORT_KINDS, ...CODE_KINDS, 'table', 'image'].includes(entry.kind);
+  }
 
-  /** 目录在前,其次按修改时间倒序 */
-  const sorted = computed(() =>
-    [...filtered.value].sort((a, b) => {
+  const filtered = computed(() => entries.value.filter(matchFilter));
+  const filteredSession = computed(() => sessionEntries.value.filter(matchFilter));
+
+  /** 当前视图的取数 */
+  const displayTotal = computed(() => (view.value === 'all' ? entries.value : sessionEntries.value));
+  const displayEntries = computed(() => (view.value === 'all' ? filtered.value : filteredSession.value));
+  const displayLoading = computed(() => (view.value === 'all' ? loading.value : sessionLoading.value));
+  const displayTruncated = computed(() => (view.value === 'all' ? truncated.value : sessionTruncated.value));
+  /** 「全部文件」目录在前、按时间倒序;「本会话产物」主进程已按时间倒序 */
+  const displaySorted = computed(() => {
+    if (view.value !== 'all') return displayEntries.value;
+    return [...displayEntries.value].sort((a, b) => {
       if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
       return Date.parse(b.mtime) - Date.parse(a.mtime);
-    }),
-  );
+    });
+  });
+
+  function switchView(next: 'session' | 'all') {
+    if (view.value === next) return;
+    view.value = next;
+    // 切视图时补一次数据:会话产物按需加载,「全部文件」回到上次浏览的目录
+    if (next === 'session') void store.loadSessionFiles();
+    else void refresh();
+  }
 
   const crumb = computed(() => relative.value || '会话工作目录');
 
@@ -209,6 +259,10 @@
 
   async function refresh(dir?: string) {
     try {
+      if (view.value === 'session') {
+        await store.loadSessionFiles();
+        return;
+      }
       await store.refreshArtifacts(dir);
       // 手动刷新后不再高亮
       store.clearArtifactMarks();
@@ -273,6 +327,37 @@
   }
 
   .codex-artifact {
+    /* 视图切换:本会话产物(默认)/ 全部文件 */
+    &__views {
+      display: flex;
+      align-items: center;
+      gap: 2px;
+      flex: 0 0 auto;
+      padding: 6px 10px 0;
+    }
+
+    &__view {
+      padding: 3px 10px;
+      font-size: 12px;
+      color: var(--codex-text-2);
+      background: none;
+      border: 1px solid transparent;
+      border-radius: var(--codex-radius-sm);
+      cursor: pointer;
+
+      &:hover {
+        color: var(--codex-text);
+        background: var(--codex-bg-hover);
+      }
+
+      &.is-active {
+        font-weight: 600;
+        color: var(--codex-primary);
+        background: var(--codex-primary-soft);
+        border-color: fade(@primary-color, 25%);
+      }
+    }
+
     &__filters {
       display: flex;
       align-items: center;

+ 36 - 1
ai-electron/frontend/src/codex/store/codexChatStore.ts

@@ -208,6 +208,11 @@ export const useCodexChatStore = defineStore('codex-chat', {
     /** 正在提交回合的会话(多会话可并行,所以是列表不是布尔) */
     runningThreads: [] as string[],
     lastError: '',
+    /**
+     * 本会话产物:工作空间里在该会话创建之后产生/修改过的文件(含子目录)。
+     * 会话共用同一个案件工作空间,不做这份过滤的话每个会话看到的都是同一堆文件。
+     */
+    sessionFiles: { entries: [] as CodexArtifactEntry[], truncated: false, loading: false },
     /** 产物栏当前目录 */
     artifact: {
       root: '',
@@ -707,8 +712,9 @@ export const useCodexChatStore = defineStore('codex-chat', {
       } finally {
         this.runningThreads = this.runningThreads.filter((id) => id !== threadId);
         await this.ensureEvents(threadId);
-        // 回合结束后标记新产物(与上一轮的快照对比)
+        // 回合结束后标记新产物(与上一轮的快照对比),并刷新本会话产物
         await this.refreshArtifacts(undefined, true);
+        await this.loadSessionFiles();
       }
     },
 
@@ -754,6 +760,7 @@ export const useCodexChatStore = defineStore('codex-chat', {
     async reloadArtifacts() {
       await this.syncArtifactRoot();
       if (this.resolveCwd()) await this.refreshArtifacts('');
+      await this.loadSessionFiles();
     },
 
     async syncArtifactRoot() {
@@ -770,6 +777,34 @@ export const useCodexChatStore = defineStore('codex-chat', {
       return this.caseWorkspace || this.defaultCwd;
     },
 
+    /**
+     * 加载「本会话产物」。
+     *
+     * 判定口径:mtime ≥ 会话创建时间。会话创建后 Codex 产出或改过的文件都会命中;
+     * 别的会话更早生成的文件不会混进来。并行会话改同一个文件时会同时出现在两边,属可接受误差。
+     */
+    async loadSessionFiles() {
+      const root = this.resolveCwd();
+      const session = this.sessions.find((item) => item.threadId === this.activeThreadId);
+      if (!root || !this.bridgeAvailable || !session) {
+        this.sessionFiles = { entries: [], truncated: false, loading: false };
+        return;
+      }
+      this.sessionFiles.loading = true;
+      try {
+        const result = await codexArtifactApi.codexArtifactListAll({ root });
+        const since = Date.parse(session.createdAt);
+        this.sessionFiles = {
+          entries: result.entries.filter((item) => Date.parse(item.mtime) >= since),
+          truncated: result.truncated,
+          loading: false,
+        };
+      } catch {
+        // 读取失败按空处理:面板显示空态,不打断会话
+        this.sessionFiles = { entries: [], truncated: false, loading: false };
+      }
+    },
+
     /** 清掉「新产物」角标(用户手动刷新后不再高亮) */
     clearArtifactMarks() {
       if (this.artifact.newEntries.length) {