From 452d52aede3fc2cf4bb9d4046ede43e97ab6c572 Mon Sep 17 00:00:00 2001 From: "clim.ashscape" Date: Thu, 6 Aug 2026 20:47:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(agent):=20=E9=97=AE=E7=AD=94=20Tab=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=B8=BA=20Agent=20=E6=94=AF=E7=BA=BF?= =?UTF-8?q?=E8=BF=BD=E9=97=AE=E9=9D=A2=E6=9D=BF=EF=BC=88=E5=90=AB=20#1440?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据模型:ConversationMeta 新增 sourceType/parentAgentSessionId/sourceKind/sourceRef/sourceLabel/seedSelection(shared 0.1.52),createConversation/IPC/preload 透传。 创建链路:抽 useOpenSideChat 统一四处新建入口,新建继承 Agent 会话模型,来源标注与首问引用持久化。 UI:ChatView 新增 variant=side-qa 精简问答视图(裁置顶/并排/系统提示/提示词侧栏/AgentRecommendBanner),SideQaHeader 来源标注,窄面板空态精简文案,快捷键按 conversationId 精确寻址。 上下文继承:主进程按归属注入 Agent 已选文件(字节截断+总量预检+二进制过滤),追问延续自动携带 seedSelection 引用。 列表语义:左侧 Chat 列表/全局搜索/切换器排除问答会话,右侧按 Agent 会话聚合线程,支持删除与父会话级联清理。 自审修复:来源标签/seed 移除一致性/Cmd+L 焦点竞争/stop 寻址(null=仅 Agent)/minimap 裁剪/模型兜底等全部落地。 --- apps/electron/src/main/ipc.ts | 34 ++- apps/electron/src/main/lib/chat-service.ts | 149 ++++++++++++- .../src/main/lib/conversation-manager.ts | 66 ++++++ apps/electron/src/preload/index.ts | 44 +++- .../agent/AgentHistorySelectionLayer.tsx | 93 ++------ .../renderer/components/agent/SidePanel.tsx | 168 +++++++++++++- .../components/app-shell/LeftSidebar.tsx | 51 +++-- .../components/app-shell/SearchDialog.tsx | 3 +- .../renderer/components/chat/ChatInput.tsx | 68 ++++-- .../renderer/components/chat/ChatMessages.tsx | 51 +++-- .../src/renderer/components/chat/ChatView.tsx | 83 +++++-- .../renderer/components/chat/SideQaHeader.tsx | 62 ++++++ .../components/diff/DiffTabContent.tsx | 88 ++------ .../components/scratch-pad/ScratchPadView.tsx | 90 ++------ .../selection/SelectionActionPopover.tsx | 6 +- .../components/shortcuts/GlobalShortcuts.tsx | 32 ++- .../renderer/components/tabs/TabSwitcher.tsx | 3 +- .../hooks/useFocusAgentSessionInput.ts | 4 +- .../src/renderer/hooks/useOpenSideChat.ts | 208 ++++++++++++++++++ apps/electron/src/renderer/lib/time-format.ts | 21 ++ bun.lock | 2 +- packages/shared/package.json | 2 +- packages/shared/src/types/chat.ts | 27 +++ 23 files changed, 1022 insertions(+), 333 deletions(-) create mode 100644 apps/electron/src/renderer/components/chat/SideQaHeader.tsx create mode 100644 apps/electron/src/renderer/hooks/useOpenSideChat.ts create mode 100644 apps/electron/src/renderer/lib/time-format.ts diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index e7e15b154..2008b7d8b 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -180,6 +180,7 @@ import { getRecentMessages, updateConversationMeta, deleteConversation, + clearConversationSeedSelection, deleteMessage, truncateMessagesFrom, updateContextDividers, @@ -1370,8 +1371,29 @@ export function registerIpcHandlers(): void { // 创建对话 ipcMain.handle( CHAT_IPC_CHANNELS.CREATE_CONVERSATION, - async (_, title?: string, modelId?: string, channelId?: string): Promise => { - return createConversation(title, modelId, channelId) + async ( + _, + title?: string, + modelId?: string, + channelId?: string, + sourceType?: ConversationMeta['sourceType'], + parentAgentSessionId?: string, + sourceKind?: ConversationMeta['sourceKind'], + sourceRef?: string, + sourceLabel?: string, + seedSelection?: ConversationMeta['seedSelection'], + ): Promise => { + return createConversation( + title, + modelId, + channelId, + sourceType, + parentAgentSessionId, + sourceKind, + sourceRef, + sourceLabel, + seedSelection, + ) } ) @@ -1407,6 +1429,14 @@ export function registerIpcHandlers(): void { } ) + // 清空对话的首问引用种子(用户手动移除引用 chip 后调用) + ipcMain.handle( + CHAT_IPC_CHANNELS.CLEAR_SEED_SELECTION, + async (_, id: string): Promise => { + return clearConversationSeedSelection(id) + } + ) + // 删除对话 ipcMain.handle( CHAT_IPC_CHANNELS.DELETE_CONVERSATION, diff --git a/apps/electron/src/main/lib/chat-service.ts b/apps/electron/src/main/lib/chat-service.ts index a8a995f96..9c4a804f2 100644 --- a/apps/electron/src/main/lib/chat-service.ts +++ b/apps/electron/src/main/lib/chat-service.ts @@ -13,6 +13,8 @@ */ import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { extname } from 'node:path' import type { WebContents } from 'electron' import { CHAT_IPC_CHANNELS } from '@proma/shared' import type { ChatSendInput, ChatMessage, GenerateTitleInput, FileAttachment, ChatToolActivity } from '@proma/shared' @@ -23,9 +25,10 @@ import { } from '@proma/core' import type { ImageAttachmentData, ContinuationMessage } from '@proma/core' import { listChannels, resolveChannelRuntimeApiKey } from './channel-manager' -import { appendMessage, updateConversationMeta, getConversationMessages } from './conversation-manager' +import { appendMessage, updateConversationMeta, getConversationMessages, getConversationMeta } from './conversation-manager' +import { getAgentSessionMeta } from './agent-session-manager' import { readAttachmentAsBase64, isImageAttachment } from './attachment-service' -import { extractTextFromAttachment, isDocumentAttachment } from './document-parser' +import { extractTextFromAttachment, isDocumentAttachment, extractTextFromFile } from './document-parser' import { getFetchFn } from './proxy-fetch' import { getEffectiveProxyUrl } from './proxy-settings-service' import { getEnabledTools } from './chat-tool-registry' @@ -38,6 +41,139 @@ const activeControllers = new Map() /** 最大工具续接轮数(安全上限,防止极端情况下的无限循环) */ const MAX_TOOL_ROUNDS = 999 +// ===== Agent 已选文件注入 ===== + +/** Agent 已选文件注入:单文件文本上限(字节),超出截断 */ +const MAX_AGENT_FILE_CONTEXT_BYTES = 100 * 1024 +/** Agent 已选文件注入:全部文件总上限(字节),超出停止追加后续文件 */ +const MAX_AGENT_TOTAL_CONTEXT_BYTES = 500 * 1024 + +/** 已知二进制扩展名:文本注入无意义且 UTF-8 直读会产生乱码,注入前直接跳过 */ +const BINARY_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', + '.bin', '.exe', '.dll', '.so', '.dylib', + '.zip', '.7z', '.rar', '.tar', '.gz', +]) + +/** + * 判断文件扩展名是否为已知二进制格式(含点号小写比较) + */ +function isBinaryExtension(filePath: string): boolean { + return BINARY_EXTENSIONS.has(extname(filePath).toLowerCase()) +} + +/** + * 按 UTF-8 字节数安全截断文本 + * + * Buffer.subarray 直接截断可能切断多字节字符(末尾残缺序列解码成 U+FFFD), + * 这里从截断点向前回退到合法的 UTF-8 首字节边界,保证输出可正常解码。 + */ +function truncateUtf8ByBytes(text: string, maxBytes: number): string { + const buf = Buffer.from(text, 'utf-8') + if (buf.length <= maxBytes) return text + let end = maxBytes + // 若 maxBytes 落在续字节(10xxxxxx)上,说明该字符被切断,回退到其首字节之前 + // (end 恒 < buf.length,索引不会越界,?? 0 仅为满足 noUncheckedIndexedAccess) + if (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80) { + end-- + while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80) end-- + } + return buf.subarray(0, end).toString('utf-8') +} + +/** + * 粗略判断文本是否像二进制内容被误读 + * + * 未知扩展名按 UTF-8 直读二进制时会产生大量 U+FFFD 替换符, + * 满足「替换符不少于 4 个且占比超过 5%」即视为二进制,跳过注入。 + */ +function looksLikeBinaryText(text: string): boolean { + if (text.length === 0) return false + const replacementCount = (text.match(/\uFFFD/g) ?? []).length + return replacementCount >= 4 && replacementCount / text.length > 0.05 +} + +/** + * XML 属性转义(quoted_file 的 path 属性,与 renderer lib/quoted-selection.ts 对齐) + */ +function escapeXmlAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +/** + * 引用块文本净化:防止文件内容中的闭合标签提前终止引用块 + * (与 renderer lib/quoted-selection.ts 的 sanitizeQuotedText 对齐) + */ +function sanitizeQuotedText(value: string): string { + return value.replace(/<\/quoted_file>/gi, '') +} + +/** + * 构建 Agent 已选文件上下文块 + * + * 问答会话(agent-side-qa)发送消息时,自动带上归属 Agent 会话的 + * attachedFiles(Agent 正在操作的代码),使追问能引用最新文件内容。 + * 普通 chat 会话没有 parentAgentSessionId,直接返回空串(零开销)。 + * + * 块格式与 renderer 的 parseQuotedSelectionRefs 同构(), + * 注入到 userMessage 前部,避免污染问答会话自定义系统提示。 + * + * @param conversationId 对话 ID + * @returns 拼接好的引用块(无归属会话或文件时为 '') + */ +async function buildAgentAttachedFilesContext(conversationId: string): Promise { + try { + // 普通 chat 会话没有归属 Agent 会话,零开销跳过 + const convMeta = getConversationMeta(conversationId) + if (!convMeta?.parentAgentSessionId) return '' + + // Agent 会话已删除或未记录已选文件时跳过 + const agentSession = getAgentSessionMeta(convMeta.parentAgentSessionId) + if (!agentSession?.attachedFiles || agentSession.attachedFiles.length === 0) return '' + + const blocks: string[] = [] + let totalBytes = 0 + for (const filePath of agentSession.attachedFiles) { + if (!existsSync(filePath)) continue + // 已知二进制扩展名直接跳过(图片/压缩包/可执行文件等,注入无意义) + if (isBinaryExtension(filePath)) continue + try { + const text = await extractTextFromFile(filePath) + if (!text.trim()) continue + // 未知扩展名被当作文本直读时,若内容含大量替换符则视为二进制误读,跳过注入 + if (looksLikeBinaryText(text)) { + console.warn(`[聊天服务] Agent 已选文件疑似二进制内容,跳过注入: ${filePath}`) + continue + } + + // 单文件截断:按 UTF-8 字节数判断并安全截断,避免切断多字节字符(如中文) + const truncated = Buffer.byteLength(text, 'utf-8') > MAX_AGENT_FILE_CONTEXT_BYTES + ? truncateUtf8ByBytes(text, MAX_AGENT_FILE_CONTEXT_BYTES) + '\n... [文件过长已截断]' + : text + + const block = `\n${sanitizeQuotedText(truncated)}\n\n\n` + // 追加前预检总量,避免单个文件块让实际注入超过上限 + const blockBytes = Buffer.byteLength(block, 'utf-8') + if (totalBytes + blockBytes > MAX_AGENT_TOTAL_CONTEXT_BYTES) break + totalBytes += blockBytes + blocks.push(block) + } catch (error) { + // 对齐现有文档附件容错:单文件失败仅跳过,不影响主流程 + console.warn(`[聊天服务] Agent 已选文件提取失败,跳过: ${filePath}`, error) + } + } + return blocks.join('') + } catch (error) { + // 会话查询等异常不影响主流程 + console.warn(`[聊天服务] 构建 Agent 已选文件上下文失败,跳过注入: ${conversationId}`, error) + return '' + } +} + // ===== 平台相关:图片附件读取器 ===== /** @@ -253,6 +389,11 @@ export async function sendMessage( const enrichedHistory = await enrichHistoryWithDocuments(filteredHistory) const enrichedUserMessage = await enrichMessageWithDocuments(userMessage, attachments) + // Agent 已选文件注入:问答会话自动带上归属 Agent 会话的 attachedFiles 内容 + // (工具续接循环多次调用 buildStreamRequest,缓存到变量避免每次重读文件) + const agentContextBlock = await buildAgentAttachedFilesContext(conversationId) + const effectiveUserMessage = agentContextBlock + enrichedUserMessage + // 6. 创建 AbortController const controller = new AbortController() activeControllers.set(conversationId, controller) @@ -327,7 +468,7 @@ export async function sendMessage( apiKey, modelId, history: enrichedHistory, - userMessage: enrichedUserMessage, + userMessage: effectiveUserMessage, systemMessage: effectiveSystemMessage, attachments, readImageAttachments: getImageAttachmentData, @@ -403,7 +544,7 @@ export async function sendMessage( apiKey, modelId, history: enrichedHistory, - userMessage: enrichedUserMessage, + userMessage: effectiveUserMessage, systemMessage: effectiveSystemMessage, attachments, readImageAttachments: getImageAttachmentData, diff --git a/apps/electron/src/main/lib/conversation-manager.ts b/apps/electron/src/main/lib/conversation-manager.ts index 93c84048a..e152f4c0a 100644 --- a/apps/electron/src/main/lib/conversation-manager.ts +++ b/apps/electron/src/main/lib/conversation-manager.ts @@ -63,18 +63,43 @@ export function listConversations(): ConversationMeta[] { return index.conversations.sort((a, b) => b.updatedAt - a.updatedAt) } +/** + * 获取单个对话的元数据 + * + * 问答会话继承链路使用:读取归属的 parentAgentSessionId 等字段。 + * + * @param id 对话 ID + * @returns 对话元数据(不存在时返回 undefined) + */ +export function getConversationMeta(id: string): ConversationMeta | undefined { + const index = readIndex() + return index.conversations.find((c) => c.id === id) +} + /** * 创建新对话 * * @param title 对话标题(默认"新对话") * @param modelId 默认模型 ID * @param channelId 使用的渠道 ID + * @param sourceType 会话类型(chat / agent-side-qa) + * @param parentAgentSessionId 归属的 Agent 会话 ID(agent-side-qa 会话) + * @param sourceKind 首问选区来源 + * @param sourceRef 来源引用:messageId / filePath + * @param sourceLabel 来源展示标签 + * @param seedSelection 首问引用种子(用于追问延续) * @returns 创建的对话元数据 */ export function createConversation( title?: string, modelId?: string, channelId?: string, + sourceType?: ConversationMeta['sourceType'], + parentAgentSessionId?: string, + sourceKind?: ConversationMeta['sourceKind'], + sourceRef?: string, + sourceLabel?: string, + seedSelection?: ConversationMeta['seedSelection'], ): ConversationMeta { const index = readIndex() const now = Date.now() @@ -84,6 +109,13 @@ export function createConversation( title: title || '新对话', modelId, channelId, + // 问答 Tab 归属/来源/种子字段(全部可选,旧数据缺省视为 chat) + sourceType, + parentAgentSessionId, + sourceKind, + sourceRef, + sourceLabel, + seedSelection, createdAt: now, updatedAt: now, } @@ -282,6 +314,37 @@ export function deleteConversation(id: string): void { deleteConversationAttachments(id) } +/** + * 清空对话的首问引用种子(用户手动移除引用 chip 后调用) + * + * 独立实现而非复用 updateConversationMeta:避免归档会话被自动取消归档, + * 且 seedSelection 不在 updateConversationMeta 的更新白名单内。 + * + * @param id 对话 ID + * @returns 更新后的对话元数据 + */ +export function clearConversationSeedSelection(id: string): ConversationMeta { + const index = readIndex() + const idx = index.conversations.findIndex((c) => c.id === id) + + if (idx === -1) { + throw new Error(`对话不存在: ${id}`) + } + + const existing = index.conversations[idx]! + const updated: ConversationMeta = { + ...existing, + seedSelection: undefined, + updatedAt: Date.now(), + } + + index.conversations[idx] = updated + writeIndex(index) + + console.log(`[对话管理] 已清空首问引用种子: ${updated.title} (${updated.id})`) + return updated +} + /** * 删除指定消息 * @@ -415,6 +478,9 @@ export async function searchConversationMessages(query: string): Promise= maxResults) break + // 跳过问答会话(agent-side-qa),避免在全局搜索中暴露/点开后污染 Chat 主区 + if (conv.sourceType === 'agent-side-qa') continue + const filePath = getConversationMessagesPath(conv.id) if (!existsSync(filePath)) continue diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index edd63b423..8593e915d 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -292,7 +292,17 @@ export interface ElectronAPI { listConversations: () => Promise /** 创建对话 */ - createConversation: (title?: string, modelId?: string, channelId?: string) => Promise + createConversation: ( + title?: string, + modelId?: string, + channelId?: string, + sourceType?: ConversationMeta['sourceType'], + parentAgentSessionId?: string, + sourceKind?: ConversationMeta['sourceKind'], + sourceRef?: string, + sourceLabel?: string, + seedSelection?: ConversationMeta['seedSelection'], + ) => Promise /** 获取对话消息 */ getConversationMessages: (id: string) => Promise @@ -309,6 +319,9 @@ export interface ElectronAPI { /** 删除对话 */ deleteConversation: (id: string) => Promise + /** 清空对话的首问引用种子(用户手动移除引用 chip 后调用) */ + clearConversationSeedSelection: (id: string) => Promise + /** 切换对话置顶状态 */ togglePinConversation: (id: string) => Promise @@ -1393,8 +1406,29 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke(CHAT_IPC_CHANNELS.LIST_CONVERSATIONS) }, - createConversation: (title?: string, modelId?: string, channelId?: string) => { - return ipcRenderer.invoke(CHAT_IPC_CHANNELS.CREATE_CONVERSATION, title, modelId, channelId) + createConversation: ( + title?: string, + modelId?: string, + channelId?: string, + sourceType?: ConversationMeta['sourceType'], + parentAgentSessionId?: string, + sourceKind?: ConversationMeta['sourceKind'], + sourceRef?: string, + sourceLabel?: string, + seedSelection?: ConversationMeta['seedSelection'], + ) => { + return ipcRenderer.invoke( + CHAT_IPC_CHANNELS.CREATE_CONVERSATION, + title, + modelId, + channelId, + sourceType, + parentAgentSessionId, + sourceKind, + sourceRef, + sourceLabel, + seedSelection, + ) }, getConversationMessages: (id: string) => { @@ -1417,6 +1451,10 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke(CHAT_IPC_CHANNELS.DELETE_CONVERSATION, id) }, + clearConversationSeedSelection: (id: string) => { + return ipcRenderer.invoke(CHAT_IPC_CHANNELS.CLEAR_SEED_SELECTION, id) + }, + togglePinConversation: (id: string) => { return ipcRenderer.invoke(CHAT_IPC_CHANNELS.TOGGLE_PIN, id) }, diff --git a/apps/electron/src/renderer/components/agent/AgentHistorySelectionLayer.tsx b/apps/electron/src/renderer/components/agent/AgentHistorySelectionLayer.tsx index e4bc8281f..63804ed7e 100644 --- a/apps/electron/src/renderer/components/agent/AgentHistorySelectionLayer.tsx +++ b/apps/electron/src/renderer/components/agent/AgentHistorySelectionLayer.tsx @@ -3,20 +3,14 @@ * * 在 Agent 历史消息里划选文本后,提供两个轻量动作: * 1. 添加到当前 Agent 输入框引用 - * 2. 打开 Agent 右侧问答 Tab,用选区作为上下文提问 + * 2. 新建右侧问答对话,用选区作为上下文提问 */ import * as React from 'react' -import { useAtomValue, useSetAtom } from 'jotai' +import { useSetAtom } from 'jotai' import { toast } from 'sonner' -import { - agentSideChatMapAtom, - conversationsAtom, - conversationDraftsAtom, - selectedModelAtom, -} from '@/atoms/chat-atoms' import { quotedSelectionMapAtom } from '@/atoms/preview-atoms' -import { agentDiffPanelTabAtom, agentSidePanelOpenAtom } from '@/atoms/agent-atoms' +import { useOpenSideChat } from '@/hooks/useOpenSideChat' import { SelectionActionPopover } from '@/components/selection/SelectionActionPopover' import { SELECTION_ACTION_POPOVER_SELECTOR } from '@/lib/quoted-selection' @@ -57,16 +51,9 @@ export function AgentHistorySelectionLayer({ rootRef, }: AgentHistorySelectionLayerProps): React.ReactElement { const setQuotedSelectionMap = useSetAtom(quotedSelectionMapAtom) - const selectedChatModel = useAtomValue(selectedModelAtom) - const setConversations = useSetAtom(conversationsAtom) - const setConversationDrafts = useSetAtom(conversationDraftsAtom) - const setSideChatMap = useSetAtom(agentSideChatMapAtom) - const setSidePanelOpen = useSetAtom(agentSidePanelOpenAtom) - const setSidePanelTabMap = useSetAtom(agentDiffPanelTabAtom) const [selection, setSelection] = React.useState(null) const pointerSelectingRef = React.useRef(false) const captureTimerRef = React.useRef(null) - const openChatPendingRef = React.useRef(false) const clearSelection = React.useCallback((): void => { setSelection(null) @@ -212,69 +199,27 @@ export function AgentHistorySelectionLayer({ toast.success('已添加到 Agent 引用') }, [clearSelection, selection, sessionId, setQuotedSelectionMap]) + const openSideChat = useOpenSideChat({ + title: '历史选区问答', + sourceKind: 'agent-history', + // 不传 sourceLabel:让 seed.sourceLabel(更细的「Agent 历史 · 用户/回复/系统」角色标签)生效 + errorLogPrefix: 'AgentMessages', + }) + const handleOpenChatTab = React.useCallback(async (): Promise => { if (!selection) return - if (openChatPendingRef.current) return - openChatPendingRef.current = true - try { - const conversation = await window.electronAPI.createConversation( - '历史选区问答', - selectedChatModel?.modelId, - selectedChatModel?.channelId, - ) - setConversations((prev) => { - if (prev.some((item) => item.id === conversation.id)) return prev - return [conversation, ...prev] - }) - setConversationDrafts((prev) => { - const next = new Map(prev) - next.set(conversation.id, '我的问题:') - return next - }) - setQuotedSelectionMap((prev) => { - const next = new Map(prev) - next.set(conversation.id, { - text: selection.text, - filePath: selection.sourceLabel, - sourceType: 'agent-history', - sourceLabel: selection.sourceLabel, - messageId: selection.messageId, - messageRole: selection.messageRole, - capturedAt: Date.now(), - }) - return next - }) - setSideChatMap((prev) => { - const next = new Map(prev) - next.set(sessionId, conversation.id) - return next - }) - setSidePanelOpen(true) - setSidePanelTabMap((prev) => { - const next = new Map(prev) - next.set(sessionId, 'chat') - return next - }) + const ok = await openSideChat(sessionId, { + text: selection.text, + sourceType: 'agent-history', + sourceLabel: selection.sourceLabel, + messageId: selection.messageId, + messageRole: selection.messageRole, + }) + if (ok) { window.getSelection()?.removeAllRanges() clearSelection() - } catch (error) { - console.error('[AgentMessages] 打开历史选区聊天标签失败:', error) - toast.error('打开聊天标签失败') - } finally { - openChatPendingRef.current = false } - }, [ - clearSelection, - selectedChatModel, - selection, - sessionId, - setConversationDrafts, - setConversations, - setQuotedSelectionMap, - setSideChatMap, - setSidePanelOpen, - setSidePanelTabMap, - ]) + }, [clearSelection, openSideChat, selection, sessionId]) return ( <> diff --git a/apps/electron/src/renderer/components/agent/SidePanel.tsx b/apps/electron/src/renderer/components/agent/SidePanel.tsx index d171f15d4..d030049b4 100644 --- a/apps/electron/src/renderer/components/agent/SidePanel.tsx +++ b/apps/electron/src/renderer/components/agent/SidePanel.tsx @@ -7,7 +7,8 @@ import * as React from 'react' import { useAtom, useAtomValue, useSetAtom } from 'jotai' -import { X, ExternalLink, ChevronRight, MoreHorizontal, FolderSearch, Pencil, FolderInput, MessageSquarePlus } from 'lucide-react' +import { X, ExternalLink, ChevronRight, MoreHorizontal, FolderSearch, Pencil, FolderInput, MessageSquarePlus, Plus, Trash2 } from 'lucide-react' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { @@ -40,14 +41,22 @@ import { agentSelectedWorktreeAtom, } from '@/atoms/agent-atoms' import type { AgentSidePanelTab, AgentFileSourceFilter } from '@/atoms/agent-atoms' -import { agentSideChatMapAtom } from '@/atoms/chat-atoms' +import { agentSideChatMapAtom, conversationsAtom } from '@/atoms/chat-atoms' +import { draftSessionIdsAtom } from '@/atoms/draft-session-atoms' +import { useOpenSideChat } from '@/hooks/useOpenSideChat' import { interfaceVariantAtom } from '@/atoms/theme' import { previewFileMapAtom } from '@/atoms/preview-atoms' import { useOpenPreview } from '@/components/diff/preview-opener' import { detectIsWindows } from '@/lib/platform' -import type { FileEntry, AgentPendingFile } from '@proma/shared' +import { formatRelativeUpdatedAt } from '@/lib/time-format' +import type { FileEntry, AgentPendingFile, ConversationMeta } from '@proma/shared' import { setFilePanelDragData, getMediaTypeFromFilename, dispatchInsertFileMention } from '@/lib/file-panel-drag' +// ===== 常量 ===== + +/** 右侧「查看现有问答」列表最多展示的会话数 */ +const VIEWABLE_CONVERSATIONS_LIMIT = 30 + function getPathBasename(filePath: string): string { return filePath.split(/[\\/]/).filter(Boolean).pop() || filePath } @@ -425,10 +434,58 @@ export function SidePanel({ sessionId, sessionPath, activeTab, onTabChange, widt const sideChatMap = useAtomValue(agentSideChatMapAtom) const setSideChatMap = useSetAtom(agentSideChatMapAtom) const sideChatConversationId = sideChatMap.get(sessionId) ?? null - const effectiveActiveTab: AgentSidePanelTab = activeTab === 'chat' && !sideChatConversationId + + // === 右侧问答 Tab:查看现有对话列表 === + const [conversations, setConversations] = useAtom(conversationsAtom) + const draftSessionIds = useAtomValue(draftSessionIdsAtom) + /** 可选的现有问答对话(当前 Agent 会话的 agent-side-qa,非归档且非草稿,按更新时间倒序,最多 30 条) */ + const viewableConversations = React.useMemo( + () => conversations + .filter((c) => + c.sourceType === 'agent-side-qa' + && c.parentAgentSessionId === sessionId + && !c.archived + && !draftSessionIds.has(c.id), + ) + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, VIEWABLE_CONVERSATIONS_LIMIT), + [conversations, draftSessionIds, sessionId], + ) + const hasViewableConversations = viewableConversations.length > 0 + + // 相对时间基准:60 秒刷新一次,避免面板停留时列表时间标签过期 + const [relativeTimeNow, setRelativeTimeNow] = React.useState(() => Date.now()) + React.useEffect(() => { + const id = window.setInterval(() => setRelativeTimeNow(Date.now()), 60_000) + return () => window.clearInterval(id) + }, []) + + /** 无问答会话时隐藏「问答」Tab;有绑定对话或可选现有对话时展示 */ + const effectiveActiveTab: AgentSidePanelTab = activeTab === 'chat' && !sideChatConversationId && !hasViewableConversations ? 'files' : activeTab + /** 从「查看现有问答」列表选择对话并绑定为当前会话的侧边问答 */ + const handleOpenExistingChat = React.useCallback((conversationId: string) => { + setSideChatMap((prev) => { + const next = new Map(prev) + next.set(sessionId, conversationId) + return next + }) + }, [sessionId, setSideChatMap]) + + /** 在右侧面板直接新建问答对话(无选区上下文) */ + const openSideChat = useOpenSideChat({ + title: '右侧问答', + sourceKind: 'side-panel', + draft: null, + errorLogPrefix: 'SidePanel', + }) + + const handleCreateSideChat = React.useCallback(async (): Promise => { + await openSideChat(sessionId, null) + }, [openSideChat, sessionId]) + const handleCloseChatTab = React.useCallback(() => { setSideChatMap((prev) => { if (!prev.has(sessionId)) return prev @@ -441,6 +498,37 @@ export function SidePanel({ sessionId, sessionPath, activeTab, onTabChange, widt } }, [activeTab, onTabChange, sessionId, setSideChatMap]) + /** 删除问答对话(「查看现有问答」列表行删除按钮) */ + const handleDeleteSideChat = React.useCallback(async (conversationId: string): Promise => { + const targetTitle = conversations.find((c) => c.id === conversationId)?.title ?? conversationId + if (!window.confirm(`确定删除问答「${targetTitle}」吗?删除后不可恢复。`)) return + try { + await window.electronAPI.deleteConversation(conversationId) + setConversations((prev) => prev.filter((c) => c.id !== conversationId)) + // 若删除的是当前绑定会话,解绑并回到列表 + if (sideChatConversationId === conversationId) { + setSideChatMap((prev) => { + const next = new Map(prev) + next.delete(sessionId) + return next + }) + } + toast.success('已删除问答对话') + } catch (error) { + console.error('[SidePanel] 删除问答对话失败:', error) + toast.error('删除问答对话失败') + } + }, [conversations, setConversations, setSideChatMap, sideChatConversationId, sessionId]) + + // 激活态写回:无会话且无可选对话时「问答」Tab 被 effectiveActiveTab 兜底隐藏, + // 但持久化 atom 仍停留在 chat。这里用 effect 写回 files,避免后续会话切换、 + // 全局出现新对话时视图无操作跳变(不 setState,避免渲染期写状态)。 + React.useEffect(() => { + if (activeTab === 'chat' && !sideChatConversationId && !hasViewableConversations) { + onTabChange('files') + } + }, [activeTab, sideChatConversationId, hasViewableConversations, onTabChange]) + return (
setIsOpen(false)} onCloseChat={handleCloseChatTab} - showChatTab={Boolean(sideChatConversationId)} + showChatTab={Boolean(sideChatConversationId) || hasViewableConversations} isWindows={isWindows} /> {effectiveActiveTab === 'chat' ? ( sideChatConversationId ? (
- +
) : ( -
暂无问答会话
+
+ {/* 头部:查看现有问答 + 新建入口 */} +
+ 查看现有问答 + +
+
+
+ {viewableConversations.map((conv) => ( + + ))} +
+
+
) ) : effectiveActiveTab === 'changes' ? ( sessionPath ? ( @@ -1355,3 +1469,43 @@ function AttachedDirItem({ entry, depth, selectedPaths, onSelect, refreshVersion ) } + +// ===== 右侧问答「查看现有问答」列表行 ===== + +interface SideChatConversationRowProps { + conversation: ConversationMeta + /** 当前相对时间基准(父级 60 秒刷新),保证停留时标签不过期 */ + relativeTimeNow: number + onSelect: (conversationId: string) => void + /** 删除问答对话(hover 显示删除按钮) */ + onDelete: (conversationId: string) => void +} + +/** 右侧问答列表行 — 样式对齐左侧边栏对话列表项(memo 惯例同 ConversationItem) */ +const SideChatConversationRow = React.memo(function SideChatConversationRow({ conversation, relativeTimeNow, onSelect, onDelete }: SideChatConversationRowProps): React.ReactElement { + return ( +
+ + + {formatRelativeUpdatedAt(conversation.updatedAt, relativeTimeNow)} + + +
+ ) +}) diff --git a/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx b/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx index 92ea45eea..c75d0c2c8 100644 --- a/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx +++ b/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx @@ -97,6 +97,7 @@ import { type SessionMiniMapType, } from '@/components/session-preview/SessionMiniMapPopover' import { detectIsMac } from '@/lib/platform' +import { formatRelativeUpdatedAt } from '@/lib/time-format' import { ShortcutKeycaps } from '@/components/shortcuts/ShortcutKeycaps' import { getActiveAccelerator, getAcceleratorDisplay } from '@/lib/shortcut-registry' import { @@ -350,22 +351,6 @@ const ACTIVE_SESSION_STATUS_PRIORITY: Record = { idle: 3, } -function formatRelativeUpdatedAt(updatedAt: number, now: number): string { - const diff = Math.max(0, now - updatedAt) - const minute = 60_000 - const hour = 60 * minute - const day = 24 * hour - const month = 30 * day - const year = 365 * day - - if (diff < minute) return '刚刚' - if (diff < hour) return `${Math.max(1, Math.floor(diff / minute))} 分钟` - if (diff < day) return `${Math.floor(diff / hour)} 小时` - if (diff < month) return `${Math.floor(diff / day)} 天` - if (diff < year) return `${Math.floor(diff / month)} 月` - return `${Math.floor(diff / year)} 年` -} - /** 按 updatedAt 将项目分为 今天 / 昨天 / 更早 三组 */ function groupByDate(items: T[]): Array<{ label: DateGroup; items: T[] }> { const now = new Date() @@ -961,9 +946,9 @@ export function LeftSidebar({ width, noTransition }: LeftSidebarProps): React.Re .catch(console.error) }, [currentWorkspaceSlug, mode, activeView, capabilitiesVersion]) - /** 置顶对话列表(仅活跃模式显示,排除 draft) */ + /** 置顶对话列表(仅活跃模式显示,排除 draft 与问答会话) */ const pinnedConversations = React.useMemo( - () => viewMode === 'active' ? conversations.filter((c) => c.pinned && !draftSessionIds.has(c.id)) : [], + () => viewMode === 'active' ? conversations.filter((c) => c.sourceType !== 'agent-side-qa' && c.pinned && !draftSessionIds.has(c.id)) : [], [conversations, viewMode, draftSessionIds] ) @@ -993,20 +978,20 @@ export function LeftSidebar({ width, noTransition }: LeftSidebarProps): React.Re [agentSessions, draftSessionIds, pinnedAgentSessions], ) - /** 对话按日期分组(根据 viewMode 过滤归档状态,排除 draft) */ + /** 对话按日期分组(根据 viewMode 过滤归档状态,排除 draft 与问答会话) */ const conversationGroups = React.useMemo( () => { const filtered = viewMode === 'archived' - ? conversations.filter((c) => c.archived && !draftSessionIds.has(c.id)) - : conversations.filter((c) => !c.archived && !c.pinned && !draftSessionIds.has(c.id)) + ? conversations.filter((c) => c.sourceType !== 'agent-side-qa' && c.archived && !draftSessionIds.has(c.id)) + : conversations.filter((c) => c.sourceType !== 'agent-side-qa' && !c.archived && !c.pinned && !draftSessionIds.has(c.id)) return groupByDate(filtered) }, [conversations, viewMode, draftSessionIds] ) - /** 已归档对话数量 */ + /** 已归档对话数量(排除问答会话,保持与归档列表一致) */ const archivedConversationCount = React.useMemo( - () => conversations.filter((c) => c.archived).length, + () => conversations.filter((c) => c.sourceType !== 'agent-side-qa' && c.archived).length, [conversations] ) @@ -1200,6 +1185,24 @@ export function LeftSidebar({ width, noTransition }: LeftSidebarProps): React.Re ? getDirectDelegatedChildren(store.get(agentSessionsAtom), pendingDeleteId).map((child) => child.id) : [] try { + // 级联删除该 Agent 会话的问答子会话(agent-side-qa),避免孤儿残留: + // 问答会话是标准 conversation(parentAgentSessionId 归属),左侧列表已过滤, + // 删除父会话时若不清理将留下无入口的数据文件。 + const qaConversationIds = store + .get(conversationsAtom) + .filter((c) => c.sourceType === 'agent-side-qa' && c.parentAgentSessionId === pendingDeleteId) + .map((c) => c.id) + if (qaConversationIds.length > 0) { + for (const qaId of qaConversationIds) { + try { + await window.electronAPI.deleteConversation(qaId) + } catch (error) { + console.error(`[侧边栏] 级联删除问答会话失败 (${qaId}):`, error) + } + cleanupMapAtoms(qaId) + } + setConversations((prev) => prev.filter((c) => !qaConversationIds.includes(c.id))) + } // 先删子后删父:若子会话删除中途失败,父会话仍在,UI 一致性更好。 if (childIds.length > 0) { const failedChildIds: string[] = [] @@ -2241,7 +2244,7 @@ export function LeftSidebar({ width, noTransition }: LeftSidebarProps): React.Re const railRecentItems = React.useMemo(() => { if (mode === 'chat') { return conversations - .filter((c) => !c.archived && !draftSessionIds.has(c.id)) + .filter((c) => c.sourceType !== 'agent-side-qa' && !c.archived && !draftSessionIds.has(c.id)) .sort((a, b) => { const activeDelta = Number(b.id === activeSessionId) - Number(a.id === activeSessionId) if (activeDelta !== 0) return activeDelta diff --git a/apps/electron/src/renderer/components/app-shell/SearchDialog.tsx b/apps/electron/src/renderer/components/app-shell/SearchDialog.tsx index 592853419..d9593693a 100644 --- a/apps/electron/src/renderer/components/app-shell/SearchDialog.tsx +++ b/apps/electron/src/renderer/components/app-shell/SearchDialog.tsx @@ -301,7 +301,8 @@ export function SearchDialog(): React.ReactElement { const qLower = q.toLowerCase() const titles: TitleResult[] = [ ...conversations - .filter((c) => c.title.toLowerCase().includes(qLower)) + // 排除问答会话:避免在全局搜索中暴露/点开后污染 Chat 主区 + .filter((c) => c.sourceType !== 'agent-side-qa' && c.title.toLowerCase().includes(qLower)) .map((c) => ({ id: c.id, title: c.title, type: 'chat' as const, archived: c.archived, updatedAt: c.updatedAt })), ...agentSessions .filter((s) => s.title.toLowerCase().includes(qLower)) diff --git a/apps/electron/src/renderer/components/chat/ChatInput.tsx b/apps/electron/src/renderer/components/chat/ChatInput.tsx index 4c304ca18..f3f9673d0 100644 --- a/apps/electron/src/renderer/components/chat/ChatInput.tsx +++ b/apps/electron/src/renderer/components/chat/ChatInput.tsx @@ -56,6 +56,8 @@ import { toast } from 'sonner' interface ChatInputProps { /** 当前对话 ID */ conversationId: string + /** 视图形态:full 主 Tab 全功能;side-qa 右侧问答窄面板(精简工具栏 items,低频项优先折叠) */ + variant?: 'full' | 'side-qa' /** 是否正在流式生成 */ streaming: boolean /** 待发送附件列表 */ @@ -68,9 +70,13 @@ interface ChatInputProps { onStop: () => void /** 清除上下文回调 */ onClearContext?: () => void + /** 移除引用 chip 回调(用于同步清空持久化的 seedSelection,避免首问仍注入已移除的引用) */ + onRemoveQuotedSelection?: () => void } -export function ChatInput({ conversationId, streaming, pendingAttachments, onSetPendingAttachments, onSend, onStop, onClearContext }: ChatInputProps): React.ReactElement { +export function ChatInput({ conversationId, variant = 'full', streaming, pendingAttachments, onSetPendingAttachments, onSend, onStop, onClearContext, onRemoveQuotedSelection }: ChatInputProps): React.ReactElement { + const isSideQa = variant === 'side-qa' + const containerRef = React.useRef(null) const sendWithCmdEnter = useAtomValue(sendWithCmdEnterAtom) // 从 Map atom 读写草稿 const draftsMap = useAtomValue(conversationDraftsAtom) @@ -227,7 +233,9 @@ export function ChatInput({ conversationId, streaming, pendingAttachments, onSet next.delete(conversationId) return next }) - }, [conversationId, setQuotedSelectionMap]) + // 同步通知父层清空持久化的 seedSelection,避免首问发送时仍注入已移除的引用 + onRemoveQuotedSelection?.() + }, [conversationId, onRemoveQuotedSelection, setQuotedSelectionMap]) /** 编辑完成 — 用编辑后的图片替换原 pending 附件 */ const handleEditComplete = React.useCallback((attachmentId: string, editedDataUrl: string): void => { @@ -299,28 +307,41 @@ export function ChatInput({ conversationId, streaming, pendingAttachments, onSet }, [addFilesAsAttachments]) // 监听快捷键系统分发的 clear-context 事件(Cmd+K) + // 事件携带 conversationId 时做精确寻址:非本实例对话忽略,避免主区 Chat 与右侧问答互抢 React.useEffect(() => { - const handler = (): void => { + const handler = (e: Event): void => { + const detail = (e as CustomEvent).detail as { conversationId?: string } | undefined + if (detail?.conversationId && detail.conversationId !== conversationId) return onClearContext?.() } window.addEventListener('proma:clear-context', handler) return () => window.removeEventListener('proma:clear-context', handler) - }, [onClearContext]) + }, [onClearContext, conversationId]) // 监听快捷键系统分发的 focus-input 事件(Cmd+L) + // 先按 conversationId 过滤,再在本容器内查找 ProseMirror,精确聚焦当前实例。 + // detail 缺失(undefined)→ 旧事件无目标信息,保持全响应兼容; + // detail.conversationId 显式 null → agent 模式(无 ChatInput 目标),不响应,避免与 AgentView 竞争; + // detail.conversationId 为字符串 → 精确寻址,仅匹配本实例。 + // 容器内找不到 ProseMirror 时,用 data-conversation-id 属性全局兜底寻址(属性与容器 ref 同源, + // 覆盖 ref 因条件渲染未挂载等边缘情况)。 React.useEffect(() => { - const handler = (): void => { - // 聚焦 TipTap 编辑器:查找 Chat 输入框内的 ProseMirror 元素 - const proseMirror = document.querySelector('[data-input-mode="chat"] .ProseMirror') as HTMLElement | null + const handler = (e: Event): void => { + const detail = (e as CustomEvent).detail as { conversationId?: string | null } | undefined + if (detail !== undefined && detail.conversationId !== conversationId) return + let proseMirror = containerRef.current?.querySelector('.ProseMirror') as HTMLElement | null + if (!proseMirror) { + proseMirror = document.querySelector(`[data-conversation-id="${conversationId}"] .ProseMirror`) as HTMLElement | null + } proseMirror?.focus() } window.addEventListener('proma:focus-input', handler) return () => window.removeEventListener('proma:focus-input', handler) - }, []) + }, [conversationId]) - const toolbarItems = React.useMemo(() => [ - { key: 'model', node: }, - { + const toolbarItems = React.useMemo(() => { + const modelItem: ToolbarItem = { key: 'model', node: } + const thinkingItem: ToolbarItem = { key: 'thinking', node: ( @@ -343,8 +364,8 @@ export function ChatInput({ conversationId, streaming, pendingAttachments, onSet ), - }, - { + } + const attachItem: ToolbarItem = { key: 'attach', node: ( @@ -364,12 +385,19 @@ export function ChatInput({ conversationId, streaming, pendingAttachments, onSet ), - }, - { key: 'speech', node: }, - { key: 'tools', node: }, - { key: 'context', node: }, - { key: 'clear', node: }, - ], [handleOpenFileDialog, thinkingEnabled, setThinkingEnabled, onClearContext, chatVoiceInputId]) + } + const speechItem: ToolbarItem = { key: 'speech', node: } + const toolsItem: ToolbarItem = { key: 'tools', node: } + const contextItem: ToolbarItem = { key: 'context', node: } + const clearItem: ToolbarItem = { key: 'clear', node: } + + if (isSideQa) { + // 问答窄面板:语音/工具选择等低频项排在末尾,宽度不足时优先折叠进「更多」菜单 + return [modelItem, thinkingItem, attachItem, contextItem, clearItem, speechItem, toolsItem] + } + // 主 Tab 全功能:保持原有顺序 + return [modelItem, thinkingItem, attachItem, speechItem, toolsItem, contextItem, clearItem] + }, [handleOpenFileDialog, thinkingEnabled, setThinkingEnabled, onClearContext, chatVoiceInputId, isSideQa]) const trailingNode = streaming ? ( @@ -418,7 +446,7 @@ export function ChatInput({ conversationId, streaming, pendingAttachments, onSet ) return ( -
+
{/* 卡片式输入容器 — 对标 Cherry Studio: border-radius 17px, 0.5px border */}
void } -/** 空状态引导 — 使用 WelcomeEmptyState */ -function EmptyState(): React.ReactElement { +/** 空状态引导 — 使用 WelcomeEmptyState;问答窄面板用精简文案(避免完整欢迎页的模式切换 Tab 误触卸载面板) */ +function EmptyState({ variant }: { variant?: 'full' | 'side-qa' }): React.ReactElement { + if (variant === 'side-qa') { + return ( +
+
+

暂无消息,发送第一条消息开始问答

+

可在侧栏直接输入,或划选文本后点「新建问答对话」

+
+
+ ) + } return } export function ChatMessages({ conversationId, + variant = 'full', messages, messagesLoaded, streaming, @@ -312,19 +325,26 @@ export function ChatMessages({ }, [parallelMode, hasMore, handleLoadMore]) // 迷你地图数据(必须在所有条件分支之前调用,遵守 hooks 规则) + // side-qa 窄面板不渲染 ScrollMinimap(见下方条件渲染),此处跳过计算避免冗余开销; + // 守卫写在 hook 内部而非条件调用,遵守 hooks 规则。 const minimapItems: MinimapItem[] = React.useMemo( - () => messages.map((m) => ({ - id: m.id, - role: m.role as MinimapItem['role'], - preview: m.content.slice(0, 200), - avatar: m.role === 'user' ? userProfile.avatar : undefined, - model: m.model, - })), - [messages, userProfile.avatar] + () => { + if (variant === 'side-qa') return [] + return messages.map((m) => ({ + id: m.id, + role: m.role as MinimapItem['role'], + preview: m.content.slice(0, 200), + avatar: m.role === 'user' ? userProfile.avatar : undefined, + model: m.model, + })) + }, + [messages, userProfile.avatar, variant] ) // 同步 minimap 缓存到 Tab 级别(供 Tab hover 预览使用) + // side-qa 窄面板无需写缓存(主区 Chat 的 Tab 预览才需要),同样用内部守卫跳过。 React.useEffect(() => { + if (variant === 'side-qa') return if (minimapItems.length > 0) { setMinimapCache((prev) => { const next = new Map(prev) @@ -332,10 +352,10 @@ export function ChatMessages({ return next }) } - }, [conversationId, minimapItems, setMinimapCache]) + }, [conversationId, minimapItems, setMinimapCache, variant]) - // 并排模式 - if (parallelMode) { + // 并排模式(仅主 Tab 全功能视图;问答窄面板无空间不启用) + if (variant !== 'side-qa' && parallelMode) { return ( {messages.length === 0 && !streaming ? ( - + ) : ( <> {/* 已有消息 + 分隔线 */} @@ -444,7 +464,8 @@ export function ChatMessages({ )} - + {/* 迷你地图(仅主 Tab 全功能视图;问答窄面板无空间不渲染) */} + {variant !== 'side-qa' && } ) diff --git a/apps/electron/src/renderer/components/chat/ChatView.tsx b/apps/electron/src/renderer/components/chat/ChatView.tsx index 46ee45662..188bfe83d 100644 --- a/apps/electron/src/renderer/components/chat/ChatView.tsx +++ b/apps/electron/src/renderer/components/chat/ChatView.tsx @@ -17,6 +17,7 @@ import * as React from 'react' import { useAtomValue, useSetAtom, useStore } from 'jotai' import { AlertCircle, X } from 'lucide-react' import { ChatHeader } from './ChatHeader' +import { SideQaHeader } from './SideQaHeader' import { ChatMessages } from './ChatMessages' import { ChatInput } from './ChatInput' import { AgentRecommendBanner } from './AgentRecommendBanner' @@ -57,6 +58,8 @@ import type { interface ChatViewProps { conversationId: string + /** 视图形态:full 主 Tab 全功能;side-qa 右侧问答窄面板(裁剪头部/横幅/提示词侧栏/并排/迷你地图) */ + variant?: 'full' | 'side-qa' } function cleanupPendingAttachments(attachments: PendingAttachment[]): void { @@ -68,15 +71,16 @@ function cleanupPendingAttachments(attachments: PendingAttachment[]): void { } } -export function ChatView({ conversationId }: ChatViewProps): React.ReactElement { +export function ChatView({ conversationId, variant = 'full' }: ChatViewProps): React.ReactElement { return ( - + ) } -function ChatViewInner({ conversationId }: ChatViewProps): React.ReactElement { +function ChatViewInner({ conversationId, variant }: ChatViewProps): React.ReactElement { + const isSideQa = variant === 'side-qa' // ===== 本地状态(每个实例独立) ===== const [messages, setMessages] = React.useState([]) const [contextDividers, setContextDividers] = React.useState([]) @@ -294,9 +298,24 @@ function ChatViewInner({ conversationId }: ChatViewProps): React.ReactElement { } const quotedSelection = store.get(quotedSelectionMapAtom).get(conversationId) + // 追问延续:seedSelection 每次发送都带(不消费),quotedSelection 仅首问覆盖 + const seed = conversation?.seedSelection + const seedBlock = seed + ? buildQuotedSelectionBlock({ + text: seed.text, + filePath: seed.filePath ?? seed.sourceLabel ?? '', + // 类型已收窄为字面量联合,与 QuotedSelectionSourceType / messageRole 完全一致,自然流动 + sourceType: seed.sourceType, + sourceLabel: seed.sourceLabel, + messageId: seed.messageId, + messageRole: seed.messageRole, + // 仅占位:buildQuotedSelectionBlock 不读取 capturedAt + capturedAt: 0, + }) + : '' const finalContent = quotedSelection ? buildQuotedSelectionBlock(quotedSelection) + content - : content + : seedBlock + content if (quotedSelection) { const capturedAt = quotedSelection.capturedAt @@ -392,9 +411,26 @@ function ChatViewInner({ conversationId }: ChatViewProps): React.ReactElement { setChatStreamErrors, setStreamingStates, setConversations, + conversation, store, ]) + /** 用户手动移除引用 chip:同步清空持久化 seedSelection,避免首问发送仍注入已移除的引用 */ + const handleRemoveQuotedSelection = React.useCallback((): void => { + setConversations((prev) => { + const idx = prev.findIndex((c) => c.id === conversationId) + if (idx === -1) return prev + const conv = prev[idx]! + if (!conv.seedSelection) return prev + const next = [...prev] + next[idx] = { ...conv, seedSelection: undefined } + return next + }) + void window.electronAPI.clearConversationSeedSelection(conversationId).catch((error) => { + console.error('[ChatView] 清空首问引用种子失败:', error) + }) + }, [conversationId, setConversations]) + // ===== 自动发送快速任务消息 ===== // 使用 queueMicrotask 延迟发送:microtask 在当前任务结束后、React 下一次渲染前执行, // 避免 setState → 重渲染 → cleanup 取消 timer 的竞态。 @@ -469,13 +505,17 @@ function ChatViewInner({ conversationId }: ChatViewProps): React.ReactElement { }, [conversationId, setStreamingStates]) // 监听快捷键系统分发的 stop-generation 事件 + // 事件携带 conversationId 时做精确寻址:非本实例对话忽略,避免主区 Chat 与右侧问答同时流式时一次快捷键互抢; + // detail 缺失(undefined)→ 兼容旧事件全量响应;显式 null → agent 模式语义(仅 AgentView 目标),本实例不响应。 React.useEffect(() => { - const handler = (): void => { + const handler = (e: Event): void => { + const detail = (e as CustomEvent).detail as { conversationId?: string | null } | undefined + if (detail !== undefined && detail.conversationId !== conversationId) return if (isStreaming) handleStop() } window.addEventListener('proma:stop-generation', handler) return () => window.removeEventListener('proma:stop-generation', handler) - }, [isStreaming, handleStop]) + }, [isStreaming, handleStop, conversationId]) /** 删除消息 */ const handleDeleteMessage = React.useCallback(async (messageId: string): Promise => { @@ -629,12 +669,13 @@ function ChatViewInner({ conversationId }: ChatViewProps): React.ReactElement {
{/* 主内容区域 */}
- {/* Header 在 max-w 外,按钮可到达最右侧 */} - + {/* Header 在 max-w 外,按钮可到达最右侧;问答模式替换为精简头部(标题 + 来源标注) */} + {isSideQa ? : }
{/* 中间:消息区域 */} )} - {/* Agent 模式推荐横幅 */} - + {/* Agent 模式推荐横幅(仅主 Tab 全功能视图;问答窄面板无空间不展示) */} + {!isSideQa && } {/* 底部:输入框 */}
- {/* 提示词编辑侧栏 */} -
+ {/* 提示词编辑侧栏(仅主 Tab 全功能视图;问答窄面板 300px 侧栏必然溢出) */} + {!isSideQa && (
- +
+ +
-
+ )}
) } diff --git a/apps/electron/src/renderer/components/chat/SideQaHeader.tsx b/apps/electron/src/renderer/components/chat/SideQaHeader.tsx new file mode 100644 index 000000000..31df5ba0a --- /dev/null +++ b/apps/electron/src/renderer/components/chat/SideQaHeader.tsx @@ -0,0 +1,62 @@ +/** + * SideQaHeader — 右侧问答窄面板的精简头部 + * + * 问答模式(ChatView variant="side-qa")专用头部: + * - 仅显示标题 + 来源标注副标题 + * - 裁剪主 Tab 头部的标题编辑 / 置顶 / 并排 / SystemPromptSelector / titlebar-drag-region + * - 来源标注从对话元数据读取 sourceKind / sourceLabel / sourceRef: + * 有 sourceLabel 时优先展示(各入口已写入如「Agent 历史消息」「文件名」), + * 缺省时按 sourceKind 生成默认文案;side-panel(右侧面板直接新建)或无来源不显示 + */ + +import * as React from 'react' +import type { ConversationMeta } from '@proma/shared' + +/** 从文件名/路径中取 basename(与 SidePanel 的 getPathBasename 一致) */ +function getPathBasename(filePath: string): string { + return filePath.split(/[\\/]/).filter(Boolean).pop() || filePath +} + +/** 构建来源标注文案;无来源返回 null(不展示) */ +export function buildSourceCaption(conversation: ConversationMeta): string | null { + if (!conversation.sourceKind || conversation.sourceKind === 'side-panel') return null + + // 优先使用入口写入的展示标签 + if (conversation.sourceLabel) return `来自 ${conversation.sourceLabel}` + + switch (conversation.sourceKind) { + case 'agent-history': + return '来自 Agent 历史消息' + case 'file': + return conversation.sourceRef ? `来自 ${getPathBasename(conversation.sourceRef)}` : '来自文件' + case 'scratch-pad': + return '来自草稿页' + default: + return null + } +} + +interface SideQaHeaderProps { + conversation: ConversationMeta | null +} + +export function SideQaHeader({ conversation }: SideQaHeaderProps): React.ReactElement | null { + if (!conversation) return null + + const sourceCaption = buildSourceCaption(conversation) + + return ( +
+ {/* 拖拽层:右侧问答头部整体可拖拽移动窗口(与 ChatHeader 一致,无按钮故无需 titlebar-no-drag 隔离) */} +
+ + {conversation.title} + + {sourceCaption && ( + + {sourceCaption} + + )} +
+ ) +} diff --git a/apps/electron/src/renderer/components/diff/DiffTabContent.tsx b/apps/electron/src/renderer/components/diff/DiffTabContent.tsx index 3871f2a2a..3d83150ca 100644 --- a/apps/electron/src/renderer/components/diff/DiffTabContent.tsx +++ b/apps/electron/src/renderer/components/diff/DiffTabContent.tsx @@ -13,21 +13,14 @@ import { File as PierreFile } from '@pierre/diffs/react' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { - agentDiffPanelTabAtom, agentDiffViewModeAtom, agentDiffRefreshVersionAtom, - agentSidePanelOpenAtom, } from '@/atoms/agent-atoms' import { resolvedThemeAtom } from '@/atoms/theme' import { previewCodeWrapAtom, quotedSelectionMapAtom } from '@/atoms/preview-atoms' -import { - agentSideChatMapAtom, - conversationsAtom, - conversationDraftsAtom, - selectedModelAtom, -} from '@/atoms/chat-atoms' import { markdownTocOpenAtom } from '@/atoms/markdown-toc' import { useFocusAgentSessionInput } from '@/hooks/useFocusAgentSessionInput' +import { useOpenSideChat } from '@/hooks/useOpenSideChat' import { useShortcut } from '@/hooks/useShortcut' import { initShortcutRegistry } from '@/lib/shortcut-registry' import { DiffView } from './DiffView' @@ -390,12 +383,6 @@ export function DiffTabContent({ filePath, dirPath, sessionId, gitRoot, previewO // ===== 选中文本引用(Quoted Selection)===== const setQuotedSelectionMap = useSetAtom(quotedSelectionMapAtom) - const selectedChatModel = useAtomValue(selectedModelAtom) - const setConversations = useSetAtom(conversationsAtom) - const setConversationDrafts = useSetAtom(conversationDraftsAtom) - const setSideChatMap = useSetAtom(agentSideChatMapAtom) - const setSidePanelOpen = useSetAtom(agentSidePanelOpenAtom) - const setSidePanelTabMap = useSetAtom(agentDiffPanelTabAtom) const focusAgentSessionInput = useFocusAgentSessionInput() const [previewSelection, setPreviewSelection] = React.useState(null) const filePathRef = React.useRef(filePath) @@ -403,7 +390,6 @@ export function DiffTabContent({ filePath, dirPath, sessionId, gitRoot, previewO const shadowRootsRef = React.useRef>(new Set()) const pointerSelectingRef = React.useRef(false) const captureTimerRef = React.useRef(null) - const openSelectionChatPendingRef = React.useRef(false) /** 当前正在展示的截断 toast id;选中回落到上限内或选区消失时主动 dismiss */ const lastToastIdRef = React.useRef(null) @@ -1284,67 +1270,27 @@ export function DiffTabContent({ filePath, dirPath, sessionId, gitRoot, previewO focusAgentSessionInput(sessionId) }, [clearPreviewSelection, focusAgentSessionInput, previewSelection, sessionId, setQuotedSelectionMap]) + const openSideChat = useOpenSideChat({ + title: '预览选区问答', + sourceKind: 'file', + errorLogPrefix: 'DiffTabContent', + }) + const handleOpenSelectionChat = React.useCallback(async (): Promise => { if (!previewSelection) return - if (openSelectionChatPendingRef.current) return - openSelectionChatPendingRef.current = true - try { - const conversation = await window.electronAPI.createConversation( - '预览选区问答', - selectedChatModel?.modelId, - selectedChatModel?.channelId, - ) - setConversations((prev) => { - if (prev.some((item) => item.id === conversation.id)) return prev - return [conversation, ...prev] - }) - setConversationDrafts((prev) => { - const next = new Map(prev) - next.set(conversation.id, '我的问题:') - return next - }) - setQuotedSelectionMap((prev) => { - const next = new Map(prev) - next.set(conversation.id, { - text: previewSelection.text, - filePath: previewSelection.filePath, - sourceType: 'file', - sourceLabel: previewSelection.filePath, - capturedAt: Date.now(), - }) - return next - }) - setSideChatMap((prev) => { - const next = new Map(prev) - next.set(sessionId, conversation.id) - return next - }) - setSidePanelOpen(true) - setSidePanelTabMap((prev) => { - const next = new Map(prev) - next.set(sessionId, 'chat') - return next - }) + // 来源展示标签取文件名(sourceRef 由 seed.filePath 自动推导) + const fileName = previewSelection.filePath.split(/[\\/]/).filter(Boolean).pop() ?? previewSelection.filePath + const ok = await openSideChat(sessionId, { + text: previewSelection.text, + sourceType: 'file', + sourceLabel: fileName, + filePath: previewSelection.filePath, + }) + if (ok) { window.getSelection()?.removeAllRanges() clearPreviewSelection() - } catch (error) { - console.error('[DiffTabContent] 打开预览选区聊天标签失败:', error) - toast.error('打开聊天标签失败') - } finally { - openSelectionChatPendingRef.current = false } - }, [ - clearPreviewSelection, - previewSelection, - selectedChatModel, - sessionId, - setConversationDrafts, - setConversations, - setQuotedSelectionMap, - setSideChatMap, - setSidePanelOpen, - setSidePanelTabMap, - ]) + }, [clearPreviewSelection, openSideChat, previewSelection, sessionId]) // persistRef 始终持有最新 persistMarkdownDraft,供 setTimeout / unmount cleanup 调用。 // 用 effect 而非渲染期赋值,避免 React 19 严格模式下并发渲染中途读到中间态。 diff --git a/apps/electron/src/renderer/components/scratch-pad/ScratchPadView.tsx b/apps/electron/src/renderer/components/scratch-pad/ScratchPadView.tsx index 0c570b9a6..6b66a8d68 100644 --- a/apps/electron/src/renderer/components/scratch-pad/ScratchPadView.tsx +++ b/apps/electron/src/renderer/components/scratch-pad/ScratchPadView.tsx @@ -19,15 +19,11 @@ import { FileDown, List, ListTodo, PanelRight, X } from 'lucide-react' import { toast } from 'sonner' import { scratchPadContentAtom, scratchPadLoadedAtom, tabsAtom, activeTabIdAtom } from '@/atoms/tab-atoms' import { - agentDiffPanelTabAtom, - agentSidePanelOpenAtom, - currentAgentSessionIdAtom, currentAgentWorkspaceIdAtom, agentSessionsAtom, agentWorkspacesAtom, } from '@/atoms/agent-atoms' -import { agentSideChatMapAtom, conversationsAtom, conversationDraftsAtom, selectedModelAtom } from '@/atoms/chat-atoms' -import { appModeAtom } from '@/atoms/app-mode' +import { useOpenSideChat } from '@/hooks/useOpenSideChat' import { quotedSelectionMapAtom } from '@/atoms/preview-atoms' import { useFocusAgentSessionInput } from '@/hooks/useFocusAgentSessionInput' import { @@ -145,7 +141,6 @@ function ScratchPadEditor({ variant }: ScratchPadEditorProps): React.ReactElemen const [selection, setSelection] = React.useState(null) const pointerSelectingRef = React.useRef(false) const captureTimerRef = React.useRef(null) - const openSideChatPendingRef = React.useRef(false) const voicePreviewRef = React.useRef(null) // Image lightbox state for edit functionality @@ -158,14 +153,6 @@ function ScratchPadEditor({ variant }: ScratchPadEditorProps): React.ReactElemen contentRef.current = content const setQuotedSelectionMap = useSetAtom(quotedSelectionMapAtom) - const selectedChatModel = useAtomValue(selectedModelAtom) - const setConversations = useSetAtom(conversationsAtom) - const setConversationDrafts = useSetAtom(conversationDraftsAtom) - const setAgentSideChatMap = useSetAtom(agentSideChatMapAtom) - const setAgentSidePanelOpen = useSetAtom(agentSidePanelOpenAtom) - const setAgentSidePanelTabMap = useSetAtom(agentDiffPanelTabAtom) - const setCurrentAgentSessionId = useSetAtom(currentAgentSessionIdAtom) - const setAppMode = useSetAtom(appModeAtom) const focusAgentSessionInput = useFocusAgentSessionInput() const extensions = React.useMemo(() => [ @@ -422,74 +409,29 @@ function ScratchPadEditor({ variant }: ScratchPadEditorProps): React.ReactElemen focusAgentSessionInput(sessionId) }, [clearSelection, focusAgentSessionInput, getTargetAgentSessionId, selection, setQuotedSelectionMap]) + const openSideChat = useOpenSideChat({ + title: '草稿选区问答', + sourceKind: 'scratch-pad', + sourceLabel: '草稿页', + switchToAgentMode: true, + errorLogPrefix: 'ScratchPad', + }) + const handleOpenSideChat = React.useCallback(async (): Promise => { if (!selection) return - if (openSideChatPendingRef.current) return const sessionId = getTargetAgentSessionId() if (!sessionId) return - openSideChatPendingRef.current = true - try { - const conversation = await window.electronAPI.createConversation( - '草稿选区问答', - selectedChatModel?.modelId, - selectedChatModel?.channelId, - ) - setConversations((prev) => { - if (prev.some((item) => item.id === conversation.id)) return prev - return [conversation, ...prev] - }) - setConversationDrafts((prev) => { - const next = new Map(prev) - next.set(conversation.id, '我的问题:') - return next - }) - setQuotedSelectionMap((prev) => { - const next = new Map(prev) - next.set(conversation.id, { - text: selection.text, - filePath: '草稿页', - sourceType: 'scratch-pad', - sourceLabel: '草稿页', - capturedAt: Date.now(), - }) - return next - }) - setCurrentAgentSessionId(sessionId) - setAppMode('agent') - setAgentSideChatMap((prev) => { - const next = new Map(prev) - next.set(sessionId, conversation.id) - return next - }) - setAgentSidePanelOpen(true) - setAgentSidePanelTabMap((prev) => { - const next = new Map(prev) - next.set(sessionId, 'chat') - return next - }) + const ok = await openSideChat(sessionId, { + text: selection.text, + sourceType: 'scratch-pad', + sourceLabel: '草稿页', + }) + if (ok) { window.getSelection()?.removeAllRanges() clearSelection() - } catch (error) { - console.error('[ScratchPad] 打开草稿选区右侧问答失败:', error) - toast.error('打开右侧问答失败') - } finally { - openSideChatPendingRef.current = false } - }, [ - clearSelection, - getTargetAgentSessionId, - selectedChatModel, - selection, - setAgentSideChatMap, - setAgentSidePanelOpen, - setAgentSidePanelTabMap, - setAppMode, - setConversationDrafts, - setConversations, - setCurrentAgentSessionId, - setQuotedSelectionMap, - ]) + }, [clearSelection, getTargetAgentSessionId, openSideChat, selection]) const makeFilename = () => { const now = new Date() diff --git a/apps/electron/src/renderer/components/selection/SelectionActionPopover.tsx b/apps/electron/src/renderer/components/selection/SelectionActionPopover.tsx index 9f0571927..d51730b0a 100644 --- a/apps/electron/src/renderer/components/selection/SelectionActionPopover.tsx +++ b/apps/electron/src/renderer/components/selection/SelectionActionPopover.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Bot, MessageCircle } from 'lucide-react' +import { Bot, MessageSquarePlus } from 'lucide-react' interface SelectionActionPopoverProps { x: number @@ -37,8 +37,8 @@ export function SelectionActionPopover({ void onOpenChat() }} > - - 打开右侧问答 + + 新建问答对话
diff --git a/apps/electron/src/renderer/components/shortcuts/GlobalShortcuts.tsx b/apps/electron/src/renderer/components/shortcuts/GlobalShortcuts.tsx index cf7dbcf47..2c3b1c043 100644 --- a/apps/electron/src/renderer/components/shortcuts/GlobalShortcuts.tsx +++ b/apps/electron/src/renderer/components/shortcuts/GlobalShortcuts.tsx @@ -74,6 +74,9 @@ export function GlobalShortcuts(): null { const setSendWithCmdEnter = useSetAtom(sendWithCmdEnterAtom) const { createChat, createAgent } = useCreateSession() + // 当前 Chat 对话 ID:快捷键分发时携带,供 ChatInput 精确寻址(主区 Chat 与右侧问答并存时不互抢焦点) + const currentConversationId = useAtomValue(currentConversationIdAtom) + // Tab 管理(用于关闭标签页) const activeTabId = useAtomValue(activeTabIdAtom) @@ -188,28 +191,30 @@ export function GlobalShortcuts(): null { ), ) - // Cmd+K → 清除上下文(通过 CustomEvent 分发到 ChatInput) + // Cmd+K → 清除上下文(通过 CustomEvent 分发到 ChatInput,携带当前对话 ID 精确寻址) useShortcut( 'clear-context', useCallback(() => { - window.dispatchEvent(new CustomEvent('proma:clear-context')) - }, []), + window.dispatchEvent(new CustomEvent('proma:clear-context', { detail: { conversationId: currentConversationId } })) + }, [currentConversationId]), ) - // Cmd+L → 聚焦输入框(通过 CustomEvent 分发到 ChatInput/AgentView) + // Cmd+L → 聚焦输入框(通过 CustomEvent 分发到 ChatInput/AgentView,携带当前对话 ID 精确寻址) useShortcut( 'focus-input', useCallback(() => { - window.dispatchEvent(new CustomEvent('proma:focus-input')) - }, []), + window.dispatchEvent(new CustomEvent('proma:focus-input', { detail: { conversationId: currentConversationId } })) + }, [currentConversationId]), ) - // Cmd+Shift+Backspace → 停止 Agent(通过 CustomEvent 分发到 ChatView/AgentView) + // Cmd+Shift+Backspace → 停止生成(通过 CustomEvent 分发到 ChatView/AgentView,携带当前对话 ID 精确寻址) + // chat 模式下带 currentConversationId,ChatView 按 detail 过滤避免主区 Chat 与右侧问答互抢; + // agent 模式下为 null,AgentView 保持全量响应(detail 不匹配即停止 Agent 会话)。 useShortcut( 'stop-generation', useCallback(() => { - window.dispatchEvent(new CustomEvent('proma:stop-generation')) - }, []), + window.dispatchEvent(new CustomEvent('proma:stop-generation', { detail: { conversationId: currentConversationId } })) + }, [currentConversationId]), ) // ===== 快速任务窗口 → 创建会话并自动发送 ===== @@ -389,7 +394,8 @@ export function GlobalShortcuts(): null { })) if (insertedAtCursor) { acknowledgeDelivery(true) - window.dispatchEvent(new CustomEvent('proma:focus-input')) + // 已插入光标场景:仍携带当前对话 ID,ChatInput 精确寻址兜底 + window.dispatchEvent(new CustomEvent('proma:focus-input', { detail: { conversationId: store.get(currentConversationIdAtom) } })) return } @@ -431,7 +437,9 @@ export function GlobalShortcuts(): null { map.delete(sessionId) return map }) - window.dispatchEvent(new CustomEvent('proma:focus-input')) + // 显式 null = 仅 Agent 目标(与 chat 分支带实际 conversationId 语义统一: + // null 让 ChatInput 精确寻址时跳过,避免与右侧问答输入框竞争焦点) + window.dispatchEvent(new CustomEvent('proma:focus-input', { detail: { conversationId: null } })) acknowledgeDelivery(true) return } @@ -446,7 +454,7 @@ export function GlobalShortcuts(): null { map.set(conversationId, current ? `${current}\n${trimmed}` : trimmed) return map }) - window.dispatchEvent(new CustomEvent('proma:focus-input')) + window.dispatchEvent(new CustomEvent('proma:focus-input', { detail: { conversationId } })) acknowledgeDelivery(true) return } diff --git a/apps/electron/src/renderer/components/tabs/TabSwitcher.tsx b/apps/electron/src/renderer/components/tabs/TabSwitcher.tsx index 02d2677dc..f0f710559 100644 --- a/apps/electron/src/renderer/components/tabs/TabSwitcher.tsx +++ b/apps/electron/src/renderer/components/tabs/TabSwitcher.tsx @@ -116,7 +116,8 @@ export function TabSwitcher(): ReactElement | null { } const chatCandidates = conversations - .filter((conversation) => !conversation.archived && !draftSessionIds.has(conversation.id)) + // 排除问答会话:问答仅从右侧面板查看,不参与全局切换器寻址 + .filter((conversation) => conversation.sourceType !== 'agent-side-qa' && !conversation.archived && !draftSessionIds.has(conversation.id)) .map((conversation: ConversationMeta): SwitchCandidate => ({ id: conversation.id, type: 'chat', diff --git a/apps/electron/src/renderer/hooks/useFocusAgentSessionInput.ts b/apps/electron/src/renderer/hooks/useFocusAgentSessionInput.ts index 4459e091c..152d4f6ff 100644 --- a/apps/electron/src/renderer/hooks/useFocusAgentSessionInput.ts +++ b/apps/electron/src/renderer/hooks/useFocusAgentSessionInput.ts @@ -51,10 +51,12 @@ export function useFocusAgentSessionInput(): FocusAgentSessionInput { } // 等待 AgentView 成为当前内容,再复用其既有输入框聚焦事件。 + // 显式携带 conversationId: null(null = 仅 Agent 目标), + // 让 ChatInput(含右侧问答)精确寻址时跳过,避免焦点竞争。 requestAnimationFrame(() => { requestAnimationFrame(() => { if (store.get(activeTabIdAtom) !== agentTab.id) return - window.dispatchEvent(new CustomEvent('proma:focus-input')) + window.dispatchEvent(new CustomEvent('proma:focus-input', { detail: { conversationId: null } })) }) }) diff --git a/apps/electron/src/renderer/hooks/useOpenSideChat.ts b/apps/electron/src/renderer/hooks/useOpenSideChat.ts new file mode 100644 index 000000000..d9a7307ed --- /dev/null +++ b/apps/electron/src/renderer/hooks/useOpenSideChat.ts @@ -0,0 +1,208 @@ +/** + * useOpenSideChat — 统一的「新建右侧问答会话」操作 + * + * 问答 Tab 重构(波 A):Agent 历史选区 / 预览选区 / 草稿选区 / 侧边面板 + * 四个新建入口共用同一条创建链路,保证: + * - sourceType='agent-side-qa' + parentAgentSessionId 持久化,问答会话归属当前 Agent 会话 + * - 模型/渠道继承 Agent 会话(agentSessionModelMapAtom / agentSessionChannelMapAtom), + * 而非 Chat 全局选中模型(selectedModelAtom) + * - 来源标注(sourceKind / sourceRef / sourceLabel)写入会话元数据 + * - 首问引用持久化到 seedSelection(追问延续基础),同时兼容写 quotedSelectionMap + * 供现有 ChatView 发送链路消费(波 B 再统一为只读 seedSelection) + * - agentSideChatMap 绑定 + 打开右侧面板并切到 chat Tab + */ + +import * as React from 'react' +import { useSetAtom, useStore } from 'jotai' +import { toast } from 'sonner' +import { + agentSideChatMapAtom, + conversationsAtom, + conversationDraftsAtom, + selectedModelAtom, +} from '@/atoms/chat-atoms' +import { + agentDiffPanelTabAtom, + agentSessionChannelMapAtom, + agentSessionModelMapAtom, + agentSidePanelOpenAtom, + currentAgentSessionIdAtom, +} from '@/atoms/agent-atoms' +import { quotedSelectionMapAtom, type QuotedSelection, type QuotedSelectionSourceType } from '@/atoms/preview-atoms' +import { appModeAtom } from '@/atoms/app-mode' +import type { ConversationMeta } from '@proma/shared' + +/** 首问引用种子(与 quotedSelectionMap 兼容的子集) */ +export interface SideChatSeed { + text: string + sourceType: QuotedSelectionSourceType + sourceLabel?: string + filePath?: string + messageId?: string + messageRole?: QuotedSelection['messageRole'] +} + +export interface OpenSideChatOptions { + /** 新建对话标题(如「历史选区问答」「右侧问答」) */ + title: string + /** 首问选区来源类型 */ + sourceKind: ConversationMeta['sourceKind'] + /** + * 来源引用(messageId / filePath)。 + * 缺省时由 seed 自动推导:seed.messageId ?? seed.filePath + */ + sourceRef?: string + /** 来源展示标签(列表与元数据展示;缺省时回退 seed.sourceLabel) */ + sourceLabel?: string + /** 新建后预填的输入框草稿;null 表示不预填(默认「我的问题:」) */ + draft?: string | null + /** 草稿选区等入口需要:打开右侧面板时同步切到 Agent 模式 */ + switchToAgentMode?: boolean + /** 失败日志前缀 */ + errorLogPrefix?: string +} + +type OpenSideChatFn = (sessionId: string, seed: SideChatSeed | null) => Promise + +export function useOpenSideChat(options: OpenSideChatOptions): OpenSideChatFn { + const store = useStore() + const setConversations = useSetAtom(conversationsAtom) + const setConversationDrafts = useSetAtom(conversationDraftsAtom) + const setQuotedSelectionMap = useSetAtom(quotedSelectionMapAtom) + const setSideChatMap = useSetAtom(agentSideChatMapAtom) + const setSidePanelOpen = useSetAtom(agentSidePanelOpenAtom) + const setSidePanelTabMap = useSetAtom(agentDiffPanelTabAtom) + const setCurrentAgentSessionId = useSetAtom(currentAgentSessionIdAtom) + const setAppMode = useSetAtom(appModeAtom) + const pendingRef = React.useRef(false) + + const { + title, + sourceKind, + sourceRef, + sourceLabel, + draft = '我的问题:', + switchToAgentMode = false, + errorLogPrefix = 'useOpenSideChat', + } = options + + return React.useCallback( + async (sessionId: string, seed: SideChatSeed | null): Promise => { + if (!sessionId || pendingRef.current) return false + pendingRef.current = true + try { + // 模型继承:读 Agent 会话自己的模型/渠道(而非 Chat 全局选中模型) + // 取不到时回退 Chat 全局选中模型(selectedModelAtom 的 modelId/channelId, + // 无独立渠道 atom,渠道是 SelectedModel 的字段),避免静默无模型创建。 + const fallbackModel = store.get(selectedModelAtom) + const modelId = store.get(agentSessionModelMapAtom).get(sessionId) ?? fallbackModel?.modelId ?? undefined + const channelId = store.get(agentSessionChannelMapAtom).get(sessionId) ?? fallbackModel?.channelId ?? undefined + + // 首问引用持久化到 seedSelection(追问延续基础) + const seedSelection: ConversationMeta['seedSelection'] = seed + ? { + text: seed.text, + sourceType: seed.sourceType, + sourceLabel: seed.sourceLabel, + filePath: seed.filePath, + messageId: seed.messageId, + messageRole: seed.messageRole, + } + : undefined + + const conversation = await window.electronAPI.createConversation( + title, + modelId, + channelId, + 'agent-side-qa', + sessionId, + sourceKind, + // sourceRef 是展示用引用标识(列表/头部的来源引用文本): + // 缺省时按 messageId → filePath 推导,仍无来源时回退 seed.sourceLabel 作为展示兜底 + sourceRef ?? seed?.messageId ?? seed?.filePath ?? seed?.sourceLabel, + sourceLabel ?? seed?.sourceLabel, + seedSelection, + ) + + // 前置插入对话列表 + setConversations((prev) => { + if (prev.some((item) => item.id === conversation.id)) return prev + return [conversation, ...prev] + }) + + // 预填输入框草稿(null 表示不预填) + if (draft !== null) { + setConversationDrafts((prev) => { + const next = new Map(prev) + next.set(conversation.id, draft) + return next + }) + } + + // 兼容写 quotedSelectionMap,供现有 ChatView 发送链路消费(波 B 统一为读 seedSelection) + if (seed) { + setQuotedSelectionMap((prev) => { + const next = new Map(prev) + next.set(conversation.id, { + text: seed.text, + filePath: seed.filePath ?? seed.sourceLabel ?? '', + sourceType: seed.sourceType, + sourceLabel: seed.sourceLabel, + messageId: seed.messageId, + messageRole: seed.messageRole, + capturedAt: Date.now(), + }) + return next + }) + } + + // 绑定为当前 Agent 会话的侧边问答 + setSideChatMap((prev) => { + const next = new Map(prev) + next.set(sessionId, conversation.id) + return next + }) + + // 打开右侧面板并切到 chat Tab + setSidePanelOpen(true) + setSidePanelTabMap((prev) => { + const next = new Map(prev) + next.set(sessionId, 'chat') + return next + }) + + // 草稿选区等入口需要切回 Agent 模式 + if (switchToAgentMode) { + setCurrentAgentSessionId(sessionId) + setAppMode('agent') + } + + return true + } catch (error) { + console.error(`[${errorLogPrefix}] 新建右侧问答会话失败:`, error) + toast.error('新建问答对话失败') + return false + } finally { + pendingRef.current = false + } + }, + [ + store, + setConversations, + setConversationDrafts, + setQuotedSelectionMap, + setSideChatMap, + setSidePanelOpen, + setSidePanelTabMap, + setCurrentAgentSessionId, + setAppMode, + title, + sourceKind, + sourceRef, + sourceLabel, + draft, + switchToAgentMode, + errorLogPrefix, + ], + ) +} diff --git a/apps/electron/src/renderer/lib/time-format.ts b/apps/electron/src/renderer/lib/time-format.ts new file mode 100644 index 000000000..1fb0db025 --- /dev/null +++ b/apps/electron/src/renderer/lib/time-format.ts @@ -0,0 +1,21 @@ +/** + * 相对时间格式化(左右侧栏共用) + * + * 按 updatedAt 与当前时间的差值输出中文相对时间;`now` 由调用方注入, + * 以便列表复用同一时刻(配合定时刷新),避免每行各自取 Date.now() 造成标签不同步。 + */ +export function formatRelativeUpdatedAt(updatedAt: number, now: number): string { + const diff = Math.max(0, now - updatedAt) + const minute = 60_000 + const hour = 60 * minute + const day = 24 * hour + const month = 30 * day + const year = 365 * day + + if (diff < minute) return '刚刚' + if (diff < hour) return `${Math.max(1, Math.floor(diff / minute))} 分钟` + if (diff < day) return `${Math.floor(diff / hour)} 小时` + if (diff < month) return `${Math.floor(diff / day)} 天` + if (diff < year) return `${Math.floor(diff / month)} 月` + return `${Math.floor(diff / year)} 年` +} diff --git a/bun.lock b/bun.lock index a91e92876..ddb3fad0a 100644 --- a/bun.lock +++ b/bun.lock @@ -188,7 +188,7 @@ }, "packages/shared": { "name": "@proma/shared", - "version": "0.1.49", + "version": "0.1.52", "devDependencies": { "typescript": "^5.0.0", }, diff --git a/packages/shared/package.json b/packages/shared/package.json index 18e360df2..319c41e52 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@proma/shared", - "version": "0.1.49", + "version": "0.1.52", "license": "AGPL-3.0-only", "description": "Shared types, configs and utilities for proma", "type": "module", diff --git a/packages/shared/src/types/chat.ts b/packages/shared/src/types/chat.ts index e62de66a3..5ac9ec4a0 100644 --- a/packages/shared/src/types/chat.ts +++ b/packages/shared/src/types/chat.ts @@ -166,6 +166,31 @@ export interface ConversationMeta { pinned?: boolean /** 是否已归档 */ archived?: boolean + /** 会话类型:普通聊天为 chat,Agent 支线追问面板为 agent-side-qa(旧数据缺省视为 chat) */ + sourceType?: 'chat' | 'agent-side-qa' + /** 归属的 Agent 会话 ID(agent-side-qa 会话用于关联来源 Agent 会话) */ + parentAgentSessionId?: string + /** 首问选区来源:Agent 历史 / 文件 / 便签 / 侧边面板 */ + sourceKind?: 'agent-history' | 'file' | 'scratch-pad' | 'side-panel' + /** 来源引用:messageId / filePath */ + sourceRef?: string + /** 来源展示标签(列表与标题中展示的简短描述) */ + sourceLabel?: string + /** 首问引用种子:用于追问延续的选区上下文 */ + seedSelection?: { + /** 选中的原文 */ + text: string + /** 来源类型:Agent 历史 / 文件 / 便签 */ + sourceType: 'file' | 'agent-history' | 'scratch-pad' + /** 来源展示标签 */ + sourceLabel?: string + /** 来源文件路径(文件来源时) */ + filePath?: string + /** 来源消息 ID(消息来源时) */ + messageId?: string + /** 来源消息角色(消息来源时) */ + messageRole?: 'user' | 'assistant' | 'system' + } /** 创建时间戳 */ createdAt: number /** 更新时间戳 */ @@ -370,6 +395,8 @@ export const CHAT_IPC_CHANNELS = { DELETE_CONVERSATION: 'chat:delete-conversation', /** 更新对话使用的模型/渠道 */ UPDATE_MODEL: 'chat:update-conversation-model', + /** 清空对话的首问引用种子(用户手动移除引用 chip 后调用) */ + CLEAR_SEED_SELECTION: 'chat:clear-seed-selection', // 消息发送 /** 发送消息(触发 AI 流式响应) */ From 0e704cfe7aa3d46b982799ad4f5c9f3505d71f31 Mon Sep 17 00:00:00 2001 From: "clim.ashscape" Date: Thu, 6 Aug 2026 21:15:42 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(agent):=20=E5=8F=B3=E4=BE=A7=E9=97=AE?= =?UTF-8?q?=E7=AD=94=20Tab=20=E4=B8=8E=E6=96=87=E4=BB=B6/=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=94=B9=E5=8A=A8=20Tab=20=E7=BB=9F=E4=B8=80=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=BA=AF=E5=88=87=E6=8D=A2=E6=8C=89=E9=92=AE=20+=20?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E5=85=A5=E5=8F=A3=E7=A7=BB=E8=87=B3=E9=97=AE?= =?UTF-8?q?=E7=AD=94=E5=A4=B4=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tab 栏:问答 Tab 改为与「文件」「文件改动」一致的纯切换 button(补 hover 反馈、点击区域、文字对齐),移除 Tab 栏上的 X 关闭按钮 - 关闭入口迁移:SideQaHeader 新增关闭按钮,解绑当前问答会话后停留在问答 Tab 回到「查看现有问答」列表(无可选对话时由 effectiveActiveTab 兜底隐藏) --- .../renderer/components/agent/SidePanel.tsx | 10 +++--- .../src/renderer/components/chat/ChatView.tsx | 10 +++--- .../renderer/components/chat/SideQaHeader.tsx | 17 ++++++++-- .../components/diff/DiffPanelTabBar.tsx | 32 ++++--------------- 4 files changed, 32 insertions(+), 37 deletions(-) diff --git a/apps/electron/src/renderer/components/agent/SidePanel.tsx b/apps/electron/src/renderer/components/agent/SidePanel.tsx index d030049b4..5f277d6db 100644 --- a/apps/electron/src/renderer/components/agent/SidePanel.tsx +++ b/apps/electron/src/renderer/components/agent/SidePanel.tsx @@ -493,10 +493,9 @@ export function SidePanel({ sessionId, sessionPath, activeTab, onTabChange, widt next.delete(sessionId) return next }) - if (activeTab === 'chat') { - onTabChange('files') - } - }, [activeTab, onTabChange, sessionId, setSideChatMap]) + // 解绑后停留在问答 Tab:有可选对话时自然回到「查看现有问答」列表; + // 无任何可选对话时由 effectiveActiveTab 兜底切回「文件」并隐藏问答 Tab。 + }, [sessionId, setSideChatMap]) /** 删除问答对话(「查看现有问答」列表行删除按钮) */ const handleDeleteSideChat = React.useCallback(async (conversationId: string): Promise => { @@ -553,7 +552,6 @@ export function SidePanel({ sessionId, sessionPath, activeTab, onTabChange, widt activeTab={effectiveActiveTab} onTabChange={onTabChange} onClose={() => setIsOpen(false)} - onCloseChat={handleCloseChatTab} showChatTab={Boolean(sideChatConversationId) || hasViewableConversations} isWindows={isWindows} /> @@ -561,7 +559,7 @@ export function SidePanel({ sessionId, sessionPath, activeTab, onTabChange, widt {effectiveActiveTab === 'chat' ? ( sideChatConversationId ? (
- +
) : (
diff --git a/apps/electron/src/renderer/components/chat/ChatView.tsx b/apps/electron/src/renderer/components/chat/ChatView.tsx index 188bfe83d..af29601ba 100644 --- a/apps/electron/src/renderer/components/chat/ChatView.tsx +++ b/apps/electron/src/renderer/components/chat/ChatView.tsx @@ -60,6 +60,8 @@ interface ChatViewProps { conversationId: string /** 视图形态:full 主 Tab 全功能;side-qa 右侧问答窄面板(裁剪头部/横幅/提示词侧栏/并排/迷你地图) */ variant?: 'full' | 'side-qa' + /** side-qa 专用:关闭当前问答(解绑会话绑定),透传给 SideQaHeader */ + onCloseSideQa?: () => void } function cleanupPendingAttachments(attachments: PendingAttachment[]): void { @@ -71,15 +73,15 @@ function cleanupPendingAttachments(attachments: PendingAttachment[]): void { } } -export function ChatView({ conversationId, variant = 'full' }: ChatViewProps): React.ReactElement { +export function ChatView({ conversationId, variant = 'full', onCloseSideQa }: ChatViewProps): React.ReactElement { return ( - + ) } -function ChatViewInner({ conversationId, variant }: ChatViewProps): React.ReactElement { +function ChatViewInner({ conversationId, variant, onCloseSideQa }: ChatViewProps): React.ReactElement { const isSideQa = variant === 'side-qa' // ===== 本地状态(每个实例独立) ===== const [messages, setMessages] = React.useState([]) @@ -670,7 +672,7 @@ function ChatViewInner({ conversationId, variant }: ChatViewProps): React.ReactE {/* 主内容区域 */}
{/* Header 在 max-w 外,按钮可到达最右侧;问答模式替换为精简头部(标题 + 来源标注) */} - {isSideQa ? : } + {isSideQa ? : }
{/* 中间:消息区域 */} void } -export function SideQaHeader({ conversation }: SideQaHeaderProps): React.ReactElement | null { +export function SideQaHeader({ conversation, onClose }: SideQaHeaderProps): React.ReactElement | null { if (!conversation) return null const sourceCaption = buildSourceCaption(conversation) return (
- {/* 拖拽层:右侧问答头部整体可拖拽移动窗口(与 ChatHeader 一致,无按钮故无需 titlebar-no-drag 隔离) */} + {/* 拖拽层:右侧问答头部整体可拖拽移动窗口;关闭按钮区域单独 titlebar-no-drag 隔离 */}
{conversation.title} @@ -57,6 +60,16 @@ export function SideQaHeader({ conversation }: SideQaHeaderProps): React.ReactEl {sourceCaption} )} + {onClose && ( + + )}
) } diff --git a/apps/electron/src/renderer/components/diff/DiffPanelTabBar.tsx b/apps/electron/src/renderer/components/diff/DiffPanelTabBar.tsx index e187a4b8c..986c63e98 100644 --- a/apps/electron/src/renderer/components/diff/DiffPanelTabBar.tsx +++ b/apps/electron/src/renderer/components/diff/DiffPanelTabBar.tsx @@ -6,7 +6,7 @@ import * as React from 'react' import { useAtomValue, useSetAtom } from 'jotai' -import { PanelRightClose, X } from 'lucide-react' +import { PanelRightClose } from 'lucide-react' import { cn } from '@/lib/utils' import { WINDOW_CONTROLS_INSET_RIGHT } from '@/lib/platform' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' @@ -18,7 +18,6 @@ interface DiffPanelTabBarProps { activeTab: AgentSidePanelTab onTabChange: (tab: AgentSidePanelTab) => void onClose?: () => void - onCloseChat?: () => void showChatTab?: boolean isWindows?: boolean } @@ -32,7 +31,6 @@ export function DiffPanelTabBar({ activeTab, onTabChange, onClose, - onCloseChat, showChatTab = false, isWindows = false, }: DiffPanelTabBarProps): React.ReactElement { @@ -116,9 +114,11 @@ export function DiffPanelTabBar({ {showChatTab && ( -
onTabChange('chat')} className={cn( - 'flex-1 h-[34px] text-xs transition-colors select-none relative whitespace-nowrap overflow-hidden', + 'flex-1 px-3 h-[34px] text-xs transition-colors select-none cursor-pointer whitespace-nowrap overflow-hidden', isClassic ? 'rounded-t-lg' : 'rounded-none', 'border-t border-l border-r', activeTab === 'chat' @@ -130,26 +130,8 @@ export function DiffPanelTabBar({ : 'app-tab-inactive text-muted-foreground border-transparent hover:text-foreground', )} > -
- - {onCloseChat && ( - - )} -
-
+ 问答 + )} {/* 右侧关闭按钮(常驻,三个 tab 下都可见) */} {onClose && (