From e5fea048a1be881419fe193d9ee9e02d3cdeb465 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 31 Jan 2026 21:26:24 +0800 Subject: [PATCH 1/5] fix(ci): add xdg-utils for ARM64 AppImage and suppress dead_code warnings - Add xdg-utils dependency for xdg-mime binary required by AppImage bundler - Remove unused McpStatus struct from gemini_mcp.rs (duplicate of claude_mcp.rs) - Add #![allow(dead_code)] to proxy models reserved for future type-safe API --- .github/workflows/release.yml | 3 ++- src-tauri/src/gemini_mcp.rs | 9 --------- src-tauri/src/proxy/providers/models/anthropic.rs | 2 ++ src-tauri/src/proxy/providers/models/openai.rs | 2 ++ 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf91a7382..7b73d1011 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,7 +59,8 @@ jobs: rpm \ flatpak \ flatpak-builder \ - elfutils + elfutils \ + xdg-utils # GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0 sudo apt-get install -y --no-install-recommends \ libgtk-3-dev \ diff --git a/src-tauri/src/gemini_mcp.rs b/src-tauri/src/gemini_mcp.rs index 72d7a86d5..9cb849bfc 100644 --- a/src-tauri/src/gemini_mcp.rs +++ b/src-tauri/src/gemini_mcp.rs @@ -1,4 +1,3 @@ -use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::fs; use std::path::{Path, PathBuf}; @@ -7,14 +6,6 @@ use crate::config::atomic_write; use crate::error::AppError; use crate::gemini_config::get_gemini_settings_path; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct McpStatus { - pub user_config_path: String, - pub user_config_exists: bool, - pub server_count: usize, -} - /// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json) fn user_config_path() -> PathBuf { get_gemini_settings_path() diff --git a/src-tauri/src/proxy/providers/models/anthropic.rs b/src-tauri/src/proxy/providers/models/anthropic.rs index 02791ecce..2dd8945b1 100644 --- a/src-tauri/src/proxy/providers/models/anthropic.rs +++ b/src-tauri/src/proxy/providers/models/anthropic.rs @@ -2,6 +2,8 @@ //! //! 用于 Anthropic Messages API 的请求/响应格式转换 +#![allow(dead_code)] + use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/src-tauri/src/proxy/providers/models/openai.rs b/src-tauri/src/proxy/providers/models/openai.rs index e04aefb4d..c3a9f5c39 100644 --- a/src-tauri/src/proxy/providers/models/openai.rs +++ b/src-tauri/src/proxy/providers/models/openai.rs @@ -2,6 +2,8 @@ //! //! 用于 OpenAI Chat Completions API 的请求/响应格式转换 +#![allow(dead_code)] + use serde::{Deserialize, Serialize}; use serde_json::Value; From 809a1fcf846d90135b2ae62d7b6ae6fe2541c86d Mon Sep 17 00:00:00 2001 From: makoMako <48956204+zhu-jl18@users.noreply.github.com> Date: Sun, 1 Feb 2026 11:21:43 +0800 Subject: [PATCH 2/5] feat(ui): dynamic endpoint hint based on API format selection (#860) - Add apiHintOAI i18n key for OpenAI Chat Completions format hint - Update ClaudeFormFields to show format-specific endpoint hints - When API format is "openai_chat", show OAI-specific hint - Maintains consistency between hint and selected API format Co-authored-by: Claude Opus 4.5 --- src/components/providers/forms/ClaudeFormFields.tsx | 6 +++++- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh.json | 1 + 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 75181972f..699f5aa01 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -163,7 +163,11 @@ export function ClaudeFormFields({ value={baseUrl} onChange={onBaseUrlChange} placeholder={t("providerForm.apiEndpointPlaceholder")} - hint={t("providerForm.apiHint")} + hint={ + apiFormat === "openai_chat" + ? t("providerForm.apiHintOAI") + : t("providerForm.apiHint") + } onManageClick={() => onEndpointModalToggle(true)} /> )} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 78634bb2b..dfa583098 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -458,6 +458,7 @@ "fastModelPlaceholder": "e.g., GLM-4.5-Air", "modelHint": "💡 Leave blank to use provider's default model", "apiHint": "💡 Fill in Claude API compatible service endpoint, avoid trailing slash", + "apiHintOAI": "💡 Fill in OpenAI Chat Completions compatible service endpoint, avoid trailing slash", "codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format", "fillSupplierName": "Please fill in provider name", "fillConfigContent": "Please fill in configuration content", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c3eeb4fc0..a9c4f87e1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -458,6 +458,7 @@ "fastModelPlaceholder": "例: GLM-4.5-Air", "modelHint": "💡 空欄ならプロバイダーのデフォルトモデルを使用します", "apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください", + "apiHintOAI": "💡 OpenAI Chat Completions 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください", "codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください", "fillSupplierName": "プロバイダー名を入力してください", "fillConfigContent": "設定内容を入力してください", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c10cad3fc..819af11f9 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -458,6 +458,7 @@ "fastModelPlaceholder": "例如: GLM-4.5-Air", "modelHint": "💡 留空将使用供应商的默认模型", "apiHint": "💡 填写兼容 Claude API 的服务端点地址,不要以斜杠结尾", + "apiHintOAI": "💡 填写兼容 OpenAI Chat Completions 的服务端点地址,不要以斜杠结尾", "codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址", "fillSupplierName": "请填写供应商名称", "fillConfigContent": "请填写配置内容", From d098ecad649de09794f4604fd3791af0f121e0bf Mon Sep 17 00:00:00 2001 From: stmoonar Date: Mon, 2 Feb 2026 09:05:05 +0800 Subject: [PATCH 3/5] fix(skills): handle Windows path separator in installed status matching (#868) * fix(skills): handle Windows path separator in installed status matching Use regex to split directory path by both / and \ to correctly extract the install name on Windows, fixing the issue where installed skills were not showing as installed in the discovery page filter. Co-Authored-By: Claude Opus 4.5 * feat(skills): add repository filter to skills discovery page - Add dropdown to filter skills by repository (owner/name) - Extract unique repos from discoverable skills list - Add truncate style for long repo names with hover title - Add i18n translations for zh/en/ja Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- src/components/skills/SkillsPage.tsx | 65 +++++++++++++++++++++++++--- src/i18n/locales/en.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/zh.json | 4 +- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index 73865513f..adb100e42 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -43,6 +43,7 @@ export const SkillsPage = forwardRef( const { t } = useTranslation(); const [repoManagerOpen, setRepoManagerOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [filterRepo, setFilterRepo] = useState("all"); const [filterStatus, setFilterStatus] = useState< "all" | "installed" | "uninstalled" >("all"); @@ -80,12 +81,25 @@ export const SkillsPage = forwardRef( type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean }; + // 从可发现技能中提取所有仓库选项 + const repoOptions = useMemo(() => { + if (!discoverableSkills) return []; + const repoSet = new Set(); + discoverableSkills.forEach((s) => { + if (s.repoOwner && s.repoName) { + repoSet.add(`${s.repoOwner}/${s.repoName}`); + } + }); + return Array.from(repoSet).sort(); + }, [discoverableSkills]); + // 为发现列表补齐 installed 状态,供 SkillCard 使用 const skills: DiscoverableSkillItem[] = useMemo(() => { if (!discoverableSkills) return []; return discoverableSkills.map((d) => { + // 同时处理 / 和 \ 路径分隔符(兼容 Windows 和 Unix) const installName = - d.directory.split("/").pop()?.toLowerCase() || + d.directory.split(/[/\\]/).pop()?.toLowerCase() || d.directory.toLowerCase(); // 使用 directory + repoOwner + repoName 组合判断是否已安装 const key = `${installName}:${d.repoOwner.toLowerCase()}:${d.repoName.toLowerCase()}`; @@ -179,12 +193,21 @@ export const SkillsPage = forwardRef( // 过滤技能列表 const filteredSkills = useMemo(() => { - const byStatus = skills.filter((skill) => { + // 按仓库筛选 + const byRepo = skills.filter((skill) => { + if (filterRepo === "all") return true; + const skillRepo = `${skill.repoOwner}/${skill.repoName}`; + return skillRepo === filterRepo; + }); + + // 按安装状态筛选 + const byStatus = byRepo.filter((skill) => { if (filterStatus === "installed") return skill.installed; if (filterStatus === "uninstalled") return !skill.installed; return true; }); + // 按搜索关键词筛选 if (!searchQuery.trim()) return byStatus; const query = searchQuery.toLowerCase(); @@ -199,7 +222,7 @@ export const SkillsPage = forwardRef( directory.includes(query) ); }); - }, [skills, searchQuery, filterStatus]); + }, [skills, searchQuery, filterRepo, filterStatus]); return (
@@ -228,7 +251,7 @@ export const SkillsPage = forwardRef(
) : ( <> - {/* 搜索框 */} + {/* 搜索框和筛选器 */}
@@ -240,7 +263,39 @@ export const SkillsPage = forwardRef( className="pl-9 pr-3" />
-
+ {/* 仓库筛选 */} +
+ +
+ {/* 安装状态筛选 */} +
setSearch(event.target.value)} + placeholder={t("sessionManager.searchPlaceholder")} + className="h-8 pl-8 pr-8 text-sm" + autoFocus + onKeyDown={(e) => { + if (e.key === "Escape") { + setIsSearchOpen(false); + setSearch(""); + } + }} + onBlur={() => { + if (search.trim() === "") { + setIsSearchOpen(false); + } + }} + /> + +
+ ) : ( +
+
+ + {t("sessionManager.sessionList")} + + + {filteredSessions.length} + +
+
+ + + + + 搜索会话 + + + + + + + + + {t("common.refresh")} + +
+
+ )} + + + +
+ {isLoading ? ( +
+ +
+ ) : filteredSessions.length === 0 ? ( +
+ +

+ {t("sessionManager.noSessions")} +

+
+ ) : ( +
+ {filteredSessions.map((session) => { + const isSelected = + selectedKey !== null && + getSessionKey(session) === selectedKey; + + return ( + + ); + })} +
+ )} +
+
+
+ + + {/* 右侧会话详情 */} + + {!selectedSession ? ( +
+ +

{t("sessionManager.selectSession")}

+
+ ) : ( + <> + {/* 详情头部 */} + +
+ {/* 左侧:会话信息 */} +
+
+ + + + + + + + {getProviderLabel(selectedSession.providerId, t)} + + +

+ {formatSessionTitle(selectedSession)} +

+
+ + {/* 元信息 */} +
+
+ + + {formatTimestamp( + selectedSession.lastActiveAt ?? + selectedSession.createdAt + )} + +
+ {selectedSession.projectDir && ( + + + + + +

+ {selectedSession.projectDir} +

+

+ 点击复制路径 +

+
+
+ )} +
+
+ + {/* 右侧:操作按钮组 */} +
+ {isMac() && ( + <> + + + + + + + + {selectedSession.resumeCommand + ? t("sessionManager.resumeTooltip", { + defaultValue: "在终端中恢复此会话", + }) + : t("sessionManager.noResumeCommand", { + defaultValue: "此会话无法恢复", + })} + + + + )} +
+
+ + {/* 恢复命令预览 */} + {selectedSession.resumeCommand && ( +
+
+ {selectedSession.resumeCommand} +
+ + + + + + {t("sessionManager.copyCommand", { + defaultValue: "复制命令", + })} + + +
+ )} +
+ + {/* 消息列表区域 */} + +
+ {/* 消息列表 */} + +
+
+ + + {t("sessionManager.conversationHistory", { + defaultValue: "对话记录", + })} + + + {messages.length} + +
+ + {isLoadingMessages ? ( +
+ +
+ ) : messages.length === 0 ? ( +
+ +

+ {t("sessionManager.emptySession")} +

+
+ ) : ( +
+ {messages.map((message, index) => ( + { + if (el) messageRefs.current.set(index, el); + }} + onCopy={(content) => + handleCopy( + content, + t("sessionManager.messageCopied", { + defaultValue: "已复制消息内容", + }) + ) + } + /> + ))} +
+
+ )} +
+ + + {/* 右侧目录 - 类似少数派 (大屏幕) */} + +
+ + {/* 浮动目录按钮 (小屏幕) */} + + + + )} + +
+
+
+ + ); +} diff --git a/src/components/sessions/SessionMessageItem.tsx b/src/components/sessions/SessionMessageItem.tsx new file mode 100644 index 000000000..805d50ba6 --- /dev/null +++ b/src/components/sessions/SessionMessageItem.tsx @@ -0,0 +1,75 @@ +import { Copy } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { SessionMessage } from "@/types"; +import { formatTimestamp, getRoleLabel, getRoleTone } from "./utils"; + +interface SessionMessageItemProps { + message: SessionMessage; + index: number; + isActive: boolean; + setRef: (el: HTMLDivElement | null) => void; + onCopy: (content: string) => void; +} + +export function SessionMessageItem({ + message, + isActive, + setRef, + onCopy, +}: SessionMessageItemProps) { + const { t } = useTranslation(); + + return ( +
+ + + + + + {t("sessionManager.copyMessage", { + defaultValue: "复制内容", + })} + + +
+ + {getRoleLabel(message.role)} + + {message.ts && ( + + {formatTimestamp(message.ts)} + + )} +
+
+ {message.content} +
+
+ ); +} diff --git a/src/components/sessions/SessionToc.tsx b/src/components/sessions/SessionToc.tsx new file mode 100644 index 000000000..650bf5b3f --- /dev/null +++ b/src/components/sessions/SessionToc.tsx @@ -0,0 +1,134 @@ +import { List, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +interface TocItem { + index: number; + preview: string; + ts?: number; +} + +interface SessionTocSidebarProps { + items: TocItem[]; + onItemClick: (index: number) => void; +} + +export function SessionTocSidebar({ + items, + onItemClick, +}: SessionTocSidebarProps) { + if (items.length <= 2) return null; + + return ( +
+
+
+ + 对话目录 +
+
+ +
+ {items.map((item, tocIndex) => ( + + ))} +
+
+
+ ); +} + +interface SessionTocDialogProps { + items: TocItem[]; + onItemClick: (index: number) => void; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function SessionTocDialog({ + items, + onItemClick, + open, + onOpenChange, +}: SessionTocDialogProps) { + if (items.length <= 2) return null; + + return ( + + + + + onOpenChange(false)} + onEscapeKeyDown={() => onOpenChange(false)} + > + + + + 对话目录 + + + + + +
+
+ {items.map((item, tocIndex) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/components/sessions/utils.ts b/src/components/sessions/utils.ts new file mode 100644 index 000000000..0cb4aa233 --- /dev/null +++ b/src/components/sessions/utils.ts @@ -0,0 +1,75 @@ +import { SessionMeta } from "@/types"; + +export const getSessionKey = (session: SessionMeta) => + `${session.providerId}:${session.sessionId}:${session.sourcePath ?? ""}`; + +export const getBaseName = (value?: string | null) => { + if (!value) return ""; + const trimmed = value.trim(); + if (!trimmed) return ""; + const normalized = trimmed.replace(/[\\/]+$/, ""); + const parts = normalized.split(/[\\/]/).filter(Boolean); + return parts[parts.length - 1] || trimmed; +}; + +export const formatTimestamp = (value?: number) => { + if (!value) return ""; + return new Date(value).toLocaleString(); +}; + +export const formatRelativeTime = (value?: number) => { + if (!value) return ""; + const now = Date.now(); + const diff = now - value; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "刚刚"; + if (minutes < 60) return `${minutes} 分钟前`; + if (hours < 24) return `${hours} 小时前`; + if (days < 7) return `${days} 天前`; + return new Date(value).toLocaleDateString(); +}; + +export const getProviderLabel = ( + providerId: string, + t: (key: string) => string +) => { + const key = `apps.${providerId}`; + const translated = t(key); + return translated === key ? providerId : translated; +}; + +// 根据 providerId 获取对应的图标名称 +export const getProviderIconName = (providerId: string) => { + if (providerId === "codex") return "openai"; + if (providerId === "claude") return "claude"; + return providerId; +}; + +export const getRoleTone = (role: string) => { + const normalized = role.toLowerCase(); + if (normalized === "assistant") return "text-blue-500"; + if (normalized === "user") return "text-emerald-500"; + if (normalized === "system") return "text-amber-500"; + if (normalized === "tool") return "text-purple-500"; + return "text-muted-foreground"; +}; + +export const getRoleLabel = (role: string) => { + const normalized = role.toLowerCase(); + if (normalized === "assistant") return "AI"; + if (normalized === "user") return "用户"; + if (normalized === "system") return "系统"; + if (normalized === "tool") return "工具"; + return role; +}; + +export const formatSessionTitle = (session: SessionMeta) => { + return ( + session.title || + getBaseName(session.projectDir) || + session.sessionId.slice(0, 8) + ); +}; diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 000000000..32cf968d2 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client"; + +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@/lib/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 09c5511e6..82fb3ff32 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -82,7 +82,7 @@ const SelectItem = React.forwardRef< , + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/src/hooks/useSessionSearch.ts b/src/hooks/useSessionSearch.ts new file mode 100644 index 000000000..e298a9a67 --- /dev/null +++ b/src/hooks/useSessionSearch.ts @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import FlexSearch from "flexsearch"; +import type { SessionMeta } from "@/types"; + +// FlexSearch Index 类型 +type FlexSearchIndex = InstanceType; + +interface UseSessionSearchOptions { + sessions: SessionMeta[]; + providerFilter: string; +} + +interface UseSessionSearchResult { + search: (query: string) => SessionMeta[]; + isIndexing: boolean; +} + +/** + * 使用 FlexSearch 实现会话全文搜索 + * 索引会话元数据(标题、摘要、项目目录等) + */ +export function useSessionSearch({ + sessions, + providerFilter, +}: UseSessionSearchOptions): UseSessionSearchResult { + const [isIndexing, setIsIndexing] = useState(false); + + // 会话元数据索引 + const indexRef = useRef(null); + // 索引 ID 到 session 的映射 + const sessionByIdxRef = useRef([]); + + // 初始化索引 + useEffect(() => { + setIsIndexing(true); + + // 创建索引实例 + // 使用 forward tokenizer 支持中文前缀搜索 + const index = new FlexSearch.Index({ + tokenize: "forward", + resolution: 9, + }); + + // 索引所有会话 + sessions.forEach((session, idx) => { + // 索引会话元数据 + const metaContent = [ + session.sessionId, + session.title, + session.summary, + session.projectDir, + session.sourcePath, + ] + .filter(Boolean) + .join(" "); + + index.add(idx, metaContent); + }); + + indexRef.current = index; + sessionByIdxRef.current = sessions; + + setIsIndexing(false); + }, [sessions]); + + // 搜索函数 + const search = useCallback( + (query: string): SessionMeta[] => { + const needle = query.trim().toLowerCase(); + + // 先按 provider 过滤 + let filtered = sessions; + if (providerFilter !== "all") { + filtered = sessions.filter((s) => s.providerId === providerFilter); + } + + // 如果没有搜索词,返回按时间排序的结果 + if (!needle) { + return [...filtered].sort((a, b) => { + const aTs = a.lastActiveAt ?? a.createdAt ?? 0; + const bTs = b.lastActiveAt ?? b.createdAt ?? 0; + return bTs - aTs; + }); + } + + const index = indexRef.current; + + if (!index) { + // 索引未就绪,使用简单搜索 + return filtered + .filter((session) => { + const haystack = [ + session.sessionId, + session.title, + session.summary, + session.projectDir, + session.sourcePath, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(needle); + }) + .sort((a, b) => { + const aTs = a.lastActiveAt ?? a.createdAt ?? 0; + const bTs = b.lastActiveAt ?? b.createdAt ?? 0; + return bTs - aTs; + }); + } + + // 使用 FlexSearch 搜索 + const results = index.search(needle, { limit: 100 }) as number[]; + + // 转换为 session 并过滤 + const matchedSessions = results + .map((idx) => sessionByIdxRef.current[idx]) + .filter( + (session) => + session && + (providerFilter === "all" || session.providerId === providerFilter) + ); + + // 按时间排序 + return matchedSessions.sort((a, b) => { + const aTs = a.lastActiveAt ?? a.createdAt ?? 0; + const bTs = b.lastActiveAt ?? b.createdAt ?? 0; + return bTs - aTs; + }); + }, + [sessions, providerFilter] + ); + + return useMemo(() => ({ search, isIndexing }), [search, isIndexing]); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e46202dd9..2fd797aba 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -400,6 +400,35 @@ "gemini": "Gemini", "opencode": "OpenCode" }, + "sessionManager": { + "title": "Session Manager", + "subtitle": "Manage Codex and Claude Code sessions", + "searchPlaceholder": "Search by content, directory, or ID", + "providerFilterAll": "All", + "sessionList": "Sessions", + "loadingSessions": "Loading sessions...", + "noSessions": "No sessions found", + "selectSession": "Select a session to view details", + "noSummary": "No summary available", + "lastActive": "Last active", + "projectDir": "Project directory", + "sourcePath": "Source file", + "copyResumeCommand": "Copy resume command", + "resumeCommandCopied": "Resume command copied", + "openInTerminal": "Resume in terminal", + "terminalTargetTerminal": "Terminal", + "terminalTargetKitty": "kitty", + "terminalTargetCopy": "Copy only", + "terminalLaunched": "Terminal launched", + "openFailed": "Failed to launch terminal", + "resumeFallbackCopied": "Resume command copied for manual use", + "copyProjectDir": "Copy directory", + "projectDirCopied": "Directory copied", + "copySourcePath": "Copy source file", + "sourcePathCopied": "Source file copied", + "loadingMessages": "Loading transcript...", + "emptySession": "No messages available" + }, "console": { "providerSwitchReceived": "Received provider switch event:", "setupListenerFailed": "Failed to setup provider switch listener:", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7df35b2bb..371496c54 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -400,6 +400,35 @@ "gemini": "Gemini", "opencode": "OpenCode" }, + "sessionManager": { + "title": "セッション管理", + "subtitle": "Codex / Claude Code のセッションを管理", + "searchPlaceholder": "内容・ディレクトリ・ID で検索", + "providerFilterAll": "すべて", + "sessionList": "セッション一覧", + "loadingSessions": "セッションを読み込み中...", + "noSessions": "セッションが見つかりません", + "selectSession": "セッションを選択してください", + "noSummary": "概要なし", + "lastActive": "最終アクティブ", + "projectDir": "プロジェクトディレクトリ", + "sourcePath": "元ファイル", + "copyResumeCommand": "再開コマンドをコピー", + "resumeCommandCopied": "再開コマンドをコピーしました", + "openInTerminal": "ターミナルで再開", + "terminalTargetTerminal": "Terminal", + "terminalTargetKitty": "kitty", + "terminalTargetCopy": "コピーのみ", + "terminalLaunched": "ターミナルを起動しました", + "openFailed": "ターミナルの起動に失敗しました", + "resumeFallbackCopied": "再開コマンドをコピーしました(手動で実行してください)", + "copyProjectDir": "ディレクトリをコピー", + "projectDirCopied": "ディレクトリをコピーしました", + "copySourcePath": "元ファイルをコピー", + "sourcePathCopied": "元ファイルをコピーしました", + "loadingMessages": "内容を読み込み中...", + "emptySession": "表示できる内容がありません" + }, "console": { "providerSwitchReceived": "プロバイダー切り替えイベントを受信:", "setupListenerFailed": "プロバイダー切り替えリスナーの設定に失敗:", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 45ff5d7cf..fa7a1c88f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -400,6 +400,35 @@ "gemini": "Gemini", "opencode": "OpenCode" }, + "sessionManager": { + "title": "会话管理", + "subtitle": "管理 Codex 与 Claude Code 会话记录", + "searchPlaceholder": "搜索会话内容、目录或 ID", + "providerFilterAll": "全部", + "sessionList": "会话列表", + "loadingSessions": "加载会话中...", + "noSessions": "未发现会话", + "selectSession": "请选择会话查看详情", + "noSummary": "暂无摘要", + "lastActive": "最近活跃", + "projectDir": "项目目录", + "sourcePath": "原始文件", + "copyResumeCommand": "复制恢复命令", + "resumeCommandCopied": "恢复命令已复制", + "openInTerminal": "在终端恢复", + "terminalTargetTerminal": "Terminal", + "terminalTargetKitty": "kitty", + "terminalTargetCopy": "仅复制", + "terminalLaunched": "终端已启动", + "openFailed": "终端启动失败", + "resumeFallbackCopied": "已复制恢复命令,可手动粘贴到终端", + "copyProjectDir": "复制目录", + "projectDirCopied": "目录已复制", + "copySourcePath": "复制原始文件", + "sourcePathCopied": "原始文件已复制", + "loadingMessages": "加载会话内容中...", + "emptySession": "该会话暂无可展示内容" + }, "console": { "providerSwitchReceived": "收到供应商切换事件:", "setupListenerFailed": "设置供应商切换监听器失败:", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index b027203f0..8e48d0151 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -7,6 +7,7 @@ export { skillsApi } from "./skills"; export { usageApi } from "./usage"; export { vscodeApi } from "./vscode"; export { proxyApi } from "./proxy"; +export { sessionsApi } from "./sessions"; export * as configApi from "./config"; export type { ProviderSwitchEvent } from "./providers"; export type { Prompt } from "./prompts"; diff --git a/src/lib/api/sessions.ts b/src/lib/api/sessions.ts new file mode 100644 index 000000000..358d9abc1 --- /dev/null +++ b/src/lib/api/sessions.ts @@ -0,0 +1,30 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { SessionMessage, SessionMeta } from "@/types"; + +export const sessionsApi = { + async list(): Promise { + return await invoke("list_sessions"); + }, + + async getMessages( + providerId: string, + sourcePath: string + ): Promise { + return await invoke("get_session_messages", { providerId, sourcePath }); + }, + + async launchTerminal(options: { + target: string; + command: string; + cwd?: string | null; + customConfig?: string | null; + }): Promise { + const { target, command, cwd, customConfig } = options; + return await invoke("launch_session_terminal", { + target, + command, + cwd, + customConfig, + }); + }, +}; diff --git a/src/lib/query/queries.ts b/src/lib/query/queries.ts index 47a404166..2948e367c 100644 --- a/src/lib/query/queries.ts +++ b/src/lib/query/queries.ts @@ -3,8 +3,20 @@ import { type UseQueryResult, keepPreviousData, } from "@tanstack/react-query"; -import { providersApi, settingsApi, usageApi, type AppId } from "@/lib/api"; -import type { Provider, Settings, UsageResult } from "@/types"; +import { + providersApi, + settingsApi, + usageApi, + sessionsApi, + type AppId, +} from "@/lib/api"; +import type { + Provider, + Settings, + UsageResult, + SessionMeta, + SessionMessage, +} from "@/types"; const sortProviders = ( providers: Record, @@ -132,3 +144,23 @@ export const useUsageQuery = ( lastQueriedAt: query.dataUpdatedAt || null, }; }; + +export const useSessionsQuery = () => { + return useQuery({ + queryKey: ["sessions"], + queryFn: async () => sessionsApi.list(), + staleTime: 30 * 1000, + }); +}; + +export const useSessionMessagesQuery = ( + providerId?: string, + sourcePath?: string, +) => { + return useQuery({ + queryKey: ["sessionMessages", providerId, sourcePath], + queryFn: async () => sessionsApi.getMessages(providerId!, sourcePath!), + enabled: Boolean(providerId && sourcePath), + staleTime: 30 * 1000, + }); +}; diff --git a/src/types.ts b/src/types.ts index 2416e3730..330ba38c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -213,6 +213,24 @@ export interface Settings { preferredTerminal?: string; } +export interface SessionMeta { + providerId: string; + sessionId: string; + title?: string; + summary?: string; + projectDir?: string | null; + createdAt?: number; + lastActiveAt?: number; + sourcePath?: string; + resumeCommand?: string; +} + +export interface SessionMessage { + role: string; + content: string; + ts?: number; +} + // MCP 服务器连接参数(宽松:允许扩展字段) export interface McpServerSpec { // 可选:社区常见 .mcp.json 中 stdio 配置可不写 type