diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 98f7df1..7146003 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -181,6 +181,13 @@ export function registerIpc(): AgentService { // 会话末尾分叉为新会话(pi fork,原会话保留) ipcMain.handle('session:fork', () => pass(() => service.forkSession())) + // 从会话树上的锚点分叉为新会话('at' 含该条为止 / 'before' 到其父条目为止) + ipcMain.handle('session:forkAt', (_event, entryId: unknown, position: unknown) => { + if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id') + if (position !== 'at' && position !== 'before') throw new Error('invalid fork position') + return pass(() => service.forkSessionAt(entryId, position)) + }) + // ---- 应用设置 ---- ipcMain.handle('settings:get', () => service.getSettings()) diff --git a/apps/desktop/src/main/pi/agent-service.ts b/apps/desktop/src/main/pi/agent-service.ts index a0b6b5a..c43188c 100644 --- a/apps/desktop/src/main/pi/agent-service.ts +++ b/apps/desktop/src/main/pi/agent-service.ts @@ -2,13 +2,13 @@ import { app, dialog, shell } from 'electron' import { nativeTheme } from 'electron' import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' +import { homedir } from 'node:os' import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, estimateTokens, getAgentDir, - loadSkills, SessionManager, type AgentSession, type AgentSessionEvent, @@ -476,7 +476,7 @@ export class AgentService { ] const byKey = new Map(breakdown.map((b) => [b.key, b])) const systemTokens = Math.ceil(session.systemPrompt.length / 4) - const skillsTokens = this.estimateSkillsTokens() + const skillsTokens = this.estimateSkillsTokens(session) byKey.get('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens) byKey.get('skills')!.tokens = skillsTokens @@ -522,15 +522,14 @@ export class AgentService { } } - /** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化) */ - private estimateSkillsTokens(): number { + /** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化)。 + * 数据源与系统提示词同源(resource loader,覆盖 ~/.agents/skills 等全部默认位置) */ + private estimateSkillsTokens(session: AgentSession): number { if (this.skillsEstimate && this.skillsEstimate.cwd === this.cwd) return this.skillsEstimate.value let value = 0 try { - const loaded = loadSkills({ cwd: this.cwd, agentDir: getAgentDir(), skillPaths: [], includeDefaults: true }) - value = Math.ceil( - loaded.skills.reduce((sum, s) => sum + s.name.length + (s.description?.length ?? 0) + 8, 0) / 4 - ) + const loaded = session.resourceLoader.getSkills() + value = Math.ceil(loaded.skills.reduce((sum, s) => sum + s.name.length + s.description.length + 8, 0) / 4) } catch { value = 0 } @@ -633,18 +632,11 @@ export class AgentService { async listChatResources(): Promise { const runtime = await this.getRuntime() - let skills: ChatResources['skills'] = [] - try { - const loaded = loadSkills({ - cwd: this.cwd, - agentDir: getAgentDir(), - skillPaths: [], - includeDefaults: true - }) - skills = loaded.skills.map((s) => ({ name: s.name, description: s.description ?? '' })) - } catch (err) { - console.error('[agent-service] load skills failed:', err) - } + // 与系统提示词同源:resource loader 的技能发现(含 ~/.agents/skills 与工作区 .agents/skills) + const skills = runtime.session.resourceLoader.getSkills().skills.map((s) => ({ + name: s.name, + description: s.description + })) const commands = runtime.session.promptTemplates.map((t) => ({ name: t.name, description: t.description ?? '', @@ -653,30 +645,31 @@ export class AgentService { return { commands, skills } } - /** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新) */ + /** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。 + * 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。 */ async listResourceDetails(): Promise { const agentDir = getAgentDir() const dirs = { globalSkills: join(agentDir, 'skills'), + globalAgentsSkills: join(homedir(), '.agents', 'skills'), projectSkills: join(this.cwd, '.pi', 'skills'), + projectAgentsSkills: join(this.cwd, '.agents', 'skills'), globalCommands: join(agentDir, 'prompts'), projectCommands: join(this.cwd, '.pi', 'prompts') } - let skills: SkillDetail[] = [] + const skills: SkillDetail[] = [] try { - const loaded = loadSkills({ - cwd: this.cwd, - agentDir, - skillPaths: [], - includeDefaults: true - }) - skills = loaded.skills.map((s) => ({ - name: s.name, - description: s.description ?? '', - filePath: s.filePath, - scope: s.sourceInfo?.scope === 'user' ? 'user' : 'project', - disableModelInvocation: Boolean(s.disableModelInvocation) - })) + const runtime = await this.getRuntime() + for (const s of runtime.session.resourceLoader.getSkills().skills) { + skills.push({ + name: s.name, + description: s.description, + filePath: s.filePath, + // 与 resource loader 的默认归类一致:无来源信息时按用户级 + scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user', + disableModelInvocation: Boolean(s.disableModelInvocation) + }) + } } catch (err) { console.error('[agent-service] load skill details failed:', err) } @@ -953,6 +946,12 @@ export class AgentService { const mr = runtime.session.modelRuntime const credentials = await mr.listCredentials() const credSet = new Set(credentials.map((c) => `${c.providerId}:${c.type}`)) + // 自定义提供商的 apiKey 存在 models.json(不进凭据存储),有 key 即视为已配置 + const customKeyed = new Set( + Object.entries(this.readModelsConfig().providers) + .filter(([, v]) => Boolean((v as { apiKey?: unknown })?.apiKey)) + .map(([id]) => id) + ) const providers = mr .getProviders() .map((p) => { @@ -960,7 +959,7 @@ export class AgentService { return { id: pid, name: String(p.name ?? pid), - hasApiKey: credSet.has(`${pid}:api_key`), + hasApiKey: credSet.has(`${pid}:api_key`) || customKeyed.has(pid), isOAuth: credSet.has(`${pid}:oauth`), models: mr .getModels(pid) @@ -1076,7 +1075,17 @@ export class AgentService { if (!leafId) { throw new Error('会话还没有可分叉的内容') } - const result = await runtime.fork(leafId, { position: 'at' }) + await this.forkSessionAt(leafId, 'at') + } + + /** 从会话树上的任意锚点分叉新会话:'at' = 新会话包含该条为止;'before' = 到其父条目为止 */ + async forkSessionAt(entryId: string, position: 'at' | 'before'): Promise { + const runtime = await this.getRuntime() + const session = runtime.session + if (session.isStreaming) { + await session.abort() + } + const result = await runtime.fork(entryId, { position }) if (result.cancelled) return } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 8b2fb05..07937b4 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer, webFrame } from 'electron' import type { AgentUiEvent, AppSettings, @@ -28,6 +28,15 @@ const api = { minimize: (): Promise => ipcRenderer.invoke('win:minimize'), toggleMaximize: (): Promise => ipcRenderer.invoke('win:toggleMaximize'), close: (): Promise => ipcRenderer.invoke('win:close'), + + // 界面缩放(Ctrl+= / Ctrl+- / Ctrl+0),factor 为相对倍率,限制在 50%–200% + zoom: (factor: number): void => { + const next = Math.min(2, Math.max(0.5, webFrame.getZoomFactor() * factor)) + webFrame.setZoomFactor(next) + }, + zoomReset: (): void => { + webFrame.setZoomFactor(1) + }, onWindowState: (callback: (maximized: boolean) => void): (() => void) => { const listener = (_e: Electron.IpcRendererEvent, maximized: boolean): void => callback(maximized) ipcRenderer.on('win:state', listener) @@ -78,6 +87,8 @@ const api = { navigateTree: (entryId: string, options?: NavigateTreeOptions): Promise => ipcRenderer.invoke('session:navigate', entryId, options), forkSession: (): Promise => ipcRenderer.invoke('session:fork'), + forkSessionAt: (entryId: string, position: 'at' | 'before'): Promise => + ipcRenderer.invoke('session:forkAt', entryId, position), getSettings: (): Promise => ipcRenderer.invoke('settings:get'), getSystemPrompt: (): Promise => ipcRenderer.invoke('settings:getSystemPrompt'), setThemeColor: (id: string): Promise => ipcRenderer.invoke('settings:setTheme', id), diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 5925804..4460f37 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -82,7 +82,8 @@ export default function App(): JSX.Element { setAutoCompaction, dismissCompactionNotice, navigateTree, - forkSession + forkSession, + forkSessionAt } = useAgent() const prefillRef = useRef(null) const [settingsOpen, setSettingsOpen] = useState(false) @@ -146,7 +147,7 @@ export default function App(): JSX.Element { return stopWatching }, []) - // 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Esc 关闭弹窗 + // 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Ctrl+= / Ctrl+- 界面缩放(Ctrl+0 复位)/ Esc 关闭弹窗 useEffect(() => { const onKey = (e: KeyboardEvent): void => { if (e.ctrlKey && e.key.toLowerCase() === 'n') { @@ -158,6 +159,15 @@ export default function App(): JSX.Element { } else if (e.ctrlKey && e.key === ',') { e.preventDefault() setSettingsOpen(true) + } else if (e.ctrlKey && (e.key === '=' || e.key === '+')) { + e.preventDefault() + window.api.zoom(1.1) + } else if (e.ctrlKey && (e.key === '-' || e.key === '_')) { + e.preventDefault() + window.api.zoom(1 / 1.1) + } else if (e.ctrlKey && e.key === '0') { + e.preventDefault() + window.api.zoomReset() } else if (e.key === 'Escape') { setSettingsOpen(false) setSearchOpen(false) @@ -171,10 +181,12 @@ export default function App(): JSX.Element { prefillRef.current = text } - // 选区操作(解释/改进/精简/自定义指令):把指令 + 引用发给 Agent - const handleApplySelection = (instruction: string, selection: string): void => { - send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`) - } + // 划选「引用」:选中文本以 blockquote 形式注入输入框开头(保留已有草稿) + const [pendingQuote, setPendingQuote] = useState(null) + const handleQuoteSelection = useCallback((selection: string) => { + setPendingQuote(`> ${selection}\n\n`) + }, []) + const consumePendingQuote = useCallback(() => setPendingQuote(null), []) // 回溯入口(稳定引用:MessageItem memo 依赖它,内联箭头每次渲染都会失效 memo) const handleRewind = useCallback( @@ -261,8 +273,9 @@ export default function App(): JSX.Element { onAbortCompaction={abortCompaction} onRewind={handleRewind} onFork={forkSession} + onForkAt={(entryId) => forkSessionAt(entryId, 'before')} onPickSuggestion={handlePickSuggestion} - onApplySelection={handleApplySelection} + onQuoteSelection={handleQuoteSelection} /> } /> diff --git a/apps/desktop/src/renderer/src/components/ChatView.tsx b/apps/desktop/src/renderer/src/components/ChatView.tsx index e05cd2f..31fddbd 100644 --- a/apps/desktop/src/renderer/src/components/ChatView.tsx +++ b/apps/desktop/src/renderer/src/components/ChatView.tsx @@ -1,6 +1,6 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' -import { LuFoldVertical, LuGitFork, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu' +import { LuFoldVertical, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu' import type { ApprovalRequest, ForkPoint } from '@shared/types' import type { CompactionNotice, ToolState, UiMessage } from '../hooks/useAgent' import { formatTokens } from '../lib/utils' @@ -66,7 +66,10 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode { * pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。 * forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序), * startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。 - * animate=false(正在补齐历史)时挂载的消息不播入场动画。 */ + * animate=false(正在补齐历史)时挂载的消息不播入场动画。 + * 分叉入口:onFork(会话末尾)只给延伸到列表末尾的最后一个回合; + * 其余回合用「本回合之后第一条用户消息」的 entryId 走 onForkAt(entryId,'before') + * ——新会话包含到本回合答案为止。 */ function renderTurns( messages: UiMessage[], tools: Record, @@ -78,11 +81,25 @@ function renderTurns( rewinding: boolean, onRewind: (entryId: string, summarize: boolean) => void, startUserIdx = 0, - animate = true + animate = true, + forking = false, + onFork?: () => void, + onForkAt?: (entryId: string) => void ): ReactNode[] { const items: ReactNode[] = [] let i = 0 let userMessageIdx = startUserIdx + // 每条真实用户消息(非乐观插入)对应的分叉点,供回合级分叉取「下一条用户消息」的锚点 + const userForkIds = new Map() + for (let idx = 0; idx < messages.length; idx++) { + const m = messages[idx] + if (m.role === 'user' && !m.local) { + const fp = forkPoints[userMessageIdx] + userMessageIdx += 1 + if (fp) userForkIds.set(idx, fp.entryId) + } + } + userMessageIdx = startUserIdx while (i < messages.length) { const message = messages[i] if (message.role === 'assistant') { @@ -92,6 +109,12 @@ function renderTurns( // assistant 组是「活跃回合」。透传给历史回合会让它们误显示 // 「已工作/处理中」并保持过程区展开。 const isLive = j === messages.length + // 本回合之后第一条真实用户消息(跳过压缩/摘要行):分叉锚点,'before' 含本回合答案为止 + let nextUser = j + while (nextUser < messages.length && !(messages[nextUser].role === 'user' && !messages[nextUser].local)) { + nextUser += 1 + } + const turnForkEntryId = nextUser < messages.length ? userForkIds.get(nextUser) : undefined items.push( ) i = j @@ -154,8 +181,9 @@ export function ChatView({ onAbortCompaction, onRewind, onFork, + onForkAt, onPickSuggestion, - onApplySelection + onQuoteSelection }: { messages: UiMessage[] tools: Record @@ -180,8 +208,10 @@ export function ChatView({ onRewind: (entryId: string, summarize: boolean) => void /** 在会话末尾分叉出新会话(原会话保留) */ onFork: () => void + /** 从指定锚点分叉出新会话(含该锚点前的整个回合) */ + onForkAt: (entryId: string) => void onPickSuggestion: (text: string) => void - onApplySelection: (instruction: string, selection: string) => void + onQuoteSelection: (selection: string) => void }): ReactNode { const scrollRef = useRef(null) const pinnedRef = useRef(true) @@ -260,6 +290,8 @@ export function ChatView({ }, [compactionNotice, onDismissCompactionNotice]) const hasContent = messages.length > 0 + // 分叉入口挂在每个回合的复制按钮旁:回合进行中/压缩/回溯时收起 + const canFork = !turnActive && !streaming && !compacting && !navigating return (
@@ -378,26 +410,10 @@ export function ChatView({ navigating, onRewind, countUserMessages(messages, 0, hidden), - hidden === 0 - )} - {/* 会话结尾:从此处分叉为新会话(pi fork,原会话保留)。回合进行中不打断,收起入口 */} - {hasContent && !turnActive && !streaming && !compacting && !navigating && ( -
- -
+ hidden === 0, + forking, + canFork ? onFork : undefined, + canFork ? onForkAt : undefined )}
) : ( @@ -407,8 +423,8 @@ export function ChatView({ )} - {/* 划选消息文本后浮出的操作条 */} - + {/* 划选消息文本后浮出的操作条:引用进输入框 / 复制 */} + ) } diff --git a/apps/desktop/src/renderer/src/components/Composer.tsx b/apps/desktop/src/renderer/src/components/Composer.tsx index eea5b1a..f6054d7 100644 --- a/apps/desktop/src/renderer/src/components/Composer.tsx +++ b/apps/desktop/src/renderer/src/components/Composer.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { createPortal } from 'react-dom' import type { JSX, RefObject } from 'react' -import { LuBrain, LuCheck, LuFileText, LuGauge, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu' +import { LuBrain, LuCheck, LuChevronRight, LuFileText, LuGauge, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu' import type { AttachmentInfo, ChatResources, @@ -123,7 +123,9 @@ export function Composer({ onCompact, onAbortCompaction, onToggleAutoCompaction, - prefillRef + prefillRef, + pendingQuote = null, + onQuoteConsumed }: { streaming: boolean /** 排队消息(steering 先于 followUp 送达) */ @@ -157,6 +159,10 @@ export function Composer({ onToggleAutoCompaction: (enabled: boolean) => void /** 供空状态建议词点击后填入 */ prefillRef: RefObject + /** 划选「引用」注入的内容(blockquote);注入到输入框开头,保留已有草稿 */ + pendingQuote?: string | null + /** 注入完成后由 Composer 通知上层清空 */ + onQuoteConsumed?: () => void }): JSX.Element { const [value, setValue] = useState('') const [modelOpen, setModelOpen] = useState(false) @@ -437,6 +443,19 @@ export function Composer({ } }, [prefillRef]) + // 划选「引用」→ 注入输入框开头(已有草稿顺延到引用块之后),光标落在引用块末尾 + useEffect(() => { + if (!pendingQuote) return + setValue((current) => (current.trim() ? `${pendingQuote}${current}` : pendingQuote)) + onQuoteConsumed?.() + const el = textareaRef.current + if (el) { + el.focus() + const pos = pendingQuote.length + el.setSelectionRange(pos, pos) + } + }, [pendingQuote, onQuoteConsumed]) + // 草稿实时保存(发送后输入框清空,草稿随之清除) useEffect(() => { saveDraft(value) @@ -467,6 +486,9 @@ export function Composer({ const permBtnRef = useRef(null) const usageBtnRef = useRef(null) const thinkingBtnRef = useRef(null) + /** 模型菜单:悬浮的 provider 行及其行内 rect;模型子列表悬浮其左侧(null = 未悬浮) */ + const [hoveredProvider, setHoveredProvider] = useState<{ id: string; rect: DOMRect } | null>(null) + const hoverGraceRef = useRef(null) const anchorOf = (el: HTMLElement | null): { top: number; left: number; right: number } | null => { if (!el) return null @@ -480,6 +502,7 @@ export function Composer({ return } setModelPos(anchorOf(modelBtnRef.current)) + setHoveredProvider(null) setModelOpen((open) => { if (!open && !catalogLoadedRef.current) { catalogLoadedRef.current = true @@ -650,6 +673,28 @@ export function Composer({ // 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看 const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? [] + + // 服务商行的悬浮跟进:离开行时有 120ms 宽限,让鼠标能跨过缝隙进入悬浮模型列表 + const enterProvider = (id: string, el: HTMLElement): void => { + if (hoverGraceRef.current !== null) { + window.clearTimeout(hoverGraceRef.current) + hoverGraceRef.current = null + } + setHoveredProvider({ id, rect: el.getBoundingClientRect() }) + } + const leaveProvider = (): void => { + if (hoverGraceRef.current !== null) window.clearTimeout(hoverGraceRef.current) + hoverGraceRef.current = window.setTimeout(() => { + hoverGraceRef.current = null + setHoveredProvider(null) + }, 120) + } + const keepProviderFlyout = (): void => { + if (hoverGraceRef.current !== null) { + window.clearTimeout(hoverGraceRef.current) + hoverGraceRef.current = null + } + } const hasContentAttachment = attachments.some((a) => (a.data && a.mimeType.startsWith('image/')) || a.text) const canSend = !disabled && (value.trim().length > 0 || hasContentAttachment) @@ -845,7 +890,7 @@ export function Composer({ aria-label="添加附件" onClick={addAttachments} disabled={disabled || picking} - title="添加附件(图片/文本文件随消息发送,其余文件以 @路径 引用)" + title="添加附件" className="flex size-7 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-40" >
-
- {catalogLoading &&
加载模型中…
} - {!catalogLoading && providers.length === 0 && ( -
没有可用模型,请先在设置中配置密钥
- )} - {providers.map((provider) => ( -
-
- {provider.name} -
- {provider.models.map((m) => { - const selected = model?.provider === provider.id && model?.id === m.id - return ( - - ) - })} +
+ {catalogLoading &&
加载模型中…
} + {!catalogLoading && providers.length === 0 && ( +
没有可用模型,请先在设置中配置密钥
+ )} + {providers.map((p) => { + const inUse = model?.provider === p.id + const hovered = hoveredProvider?.id === p.id + return ( +
enterProvider(p.id, e.currentTarget)} + onMouseLeave={leaveProvider} + > +
- ))} -
-
+ ) + })} +
)} + + {/* 悬浮模型列表:悬浮服务商行时出现在其左侧(portal 到 body, + ComposerMenu 的 overflow-hidden 会裁剪子元素) */} + {modelOpen && + hoveredProvider && + (() => { + const p = providers.find((x) => x.id === hoveredProvider.id) + if (!p) return null + return createPortal( +
+
+ {p.models.map((m) => { + const selected = model?.provider === p.id && model?.id === m.id + return ( + + ) + })} +
+
, + document.body + ) + })()}
diff --git a/apps/desktop/src/renderer/src/components/Markdown.tsx b/apps/desktop/src/renderer/src/components/Markdown.tsx index 3471c96..a9daf95 100644 --- a/apps/desktop/src/renderer/src/components/Markdown.tsx +++ b/apps/desktop/src/renderer/src/components/Markdown.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useState, type ReactNode } from 'react' +import { memo, useEffect, useMemo, useState, type ReactNode } from 'react' import ReactMarkdown, { type Components } from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkCjkFriendly from 'remark-cjk-friendly' @@ -98,18 +98,86 @@ const LANG_ALIAS: Record = { plaintext: '' } +/** 行内处理:反引号代码 span 内不动;连续 2+ 个 ~ 保留(删除线标记),单个 ~ 转义为字面量 */ +function escapeSingleTildeInline(line: string): string { + let out = '' + let i = 0 + while (i < line.length) { + const ch = line[i] + if (ch === '`' || ch === '~') { + let j = i + while (j < line.length && line[j] === ch) j++ + const run = line.slice(i, j) + if (ch === '`') { + out += run + const rest = line.slice(j) + const close = rest.indexOf(run) + if (close >= 0) { + out += rest.slice(0, close + run.length) + i = j + close + run.length + } else { + out += rest + i = line.length + } + continue + } + out += run.length >= 2 ? run : '\\~' + i = j + continue + } + out += ch + i++ + } + return out +} + +/** 单个 ~ 在 GFM 里会误触发删除线——中文常用 ~ 表区间(如「6强~7」「周~月」), + * 同段里出现两个就会被连成一条删除线。约定:只有成对的 ~~ 才是删除线, + * 不成对的单 ~ 转义为字面量;围栏代码块与行内代码内一律不动。 */ +function neutralizeSingleTilde(text: string): string { + if (!text.includes('~')) return text + const lines = text.split('\n') + let fence: string | null = null + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (fence) { + const close = line.match(/^\s{0,3}(`{3,}|~{3,})\s*$/) + if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null + continue + } + const open = line.match(/^\s{0,3}(`{3,}|~{3,})/) + if (open) { + fence = open[1] + continue + } + lines[i] = escapeSingleTildeInline(line) + } + return lines.join('\n') +} + const mdComponents: Components = { p({ children }) { return

{children}

}, + /** h1/h2 带强调条(对齐参考渲染),h3 起按层级递减,保证标题有可辨识的层级 */ h1({ children }) { - return

{children}

+ return ( +

+ + {children} +

+ ) }, h2({ children }) { - return

{children}

+ return ( +

+ + {children} +

+ ) }, h3({ children }) { - return
{children}
+ return
{children}
}, h4({ children }) { return
{children}
@@ -275,10 +343,11 @@ function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode { } function MarkdownBase({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode { + const safeText = useMemo(() => neutralizeSingleTilde(text), [text]) // 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown), // 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态 const { blockMatches } = useLLMOutput({ - llmOutput: text, + llmOutput: safeText, isStreamFinished: !streaming, fallbackBlock: markdownFallback, blocks: [codeBlock] diff --git a/apps/desktop/src/renderer/src/components/MessageItem.tsx b/apps/desktop/src/renderer/src/components/MessageItem.tsx index 87a31c3..70af401 100644 --- a/apps/desktop/src/renderer/src/components/MessageItem.tsx +++ b/apps/desktop/src/renderer/src/components/MessageItem.tsx @@ -7,7 +7,7 @@ import { StreamingText } from './StreamingText' import { Thinking, thinkingRows } from './Thinking' import { ToolChips, type ToolCallView } from './ToolChips' import { useEntrance } from '../hooks/useEntrance' -import { LuFoldVertical, LuHistory, LuUndo2 } from 'react-icons/lu' +import { LuFoldVertical, LuGitFork, LuHistory, LuLoaderCircle, LuUndo2 } from 'react-icons/lu' import { formatTokens } from '../lib/utils' interface ContentBlock { @@ -129,7 +129,11 @@ function AssistantTurnBase({ streaming, turnActive, pendingApprovals, - scrollPinnedRef + scrollPinnedRef, + forking = false, + onFork, + forkEntryId, + onForkAt }: { messages: UiMessage[] tools: Record @@ -139,6 +143,14 @@ function AssistantTurnBase({ /** 聊天滚动容器的吸底标记(ChatView 持有):回合结束时只在用户位于底部 * 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */ scrollPinnedRef?: { current: boolean } + /** 分叉进行中 */ + forking?: boolean + /** 会话末尾分叉(只有最后一个回合透传) */ + onFork?: () => void + /** 历史回合的分叉锚点:本回合之后第一条用户消息的 entryId + * (配合 onForkAt 以 'before' 分叉,新会话包含到本回合答案为止) */ + forkEntryId?: string + onForkAt?: (entryId: string) => void }): ReactNode { // ---- 第一遍:块级描述符(只做类型判断与字符串引用收集,不拆行、不建 JSX) ---- // 长会话渐进补齐时每个批次会挂载几十个历史回合,若在首遍就执行 @@ -424,10 +436,31 @@ function AssistantTurnBase({ {/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */} {turnActive && !stream && } - {/* 结论底部的复制操作:常驻不闪现 */} - {(hasAnswer || answerText.trim().length > 0) && ( -
+ {/* 结论底部的操作行:复制 + 会话分叉。末回合走会话末尾分叉(onFork), + 历史回合以「本回合之后第一条用户消息」为锚点(forkEntryId + onForkAt), + 新会话都包含到本回合答案为止。常驻不闪现。 */} + {(hasAnswer || answerText.trim().length > 0 || onFork || (forkEntryId && onForkAt)) && ( +
+ {(onFork || (forkEntryId && onForkAt)) && ( + + )}
)}
@@ -443,7 +476,7 @@ function RewindMenu({ entryId, onRewind }: { entryId: string; onRewind?: (entryI - ))} -
- {PRESET_MORE.map((action) => ( - - ))} -
- - -
- - {/* 发送钮:输入内容后出现 */} -
- -
+
+ + +
) diff --git a/apps/desktop/src/renderer/src/components/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/SettingsModal.tsx index 5d6a6f8..e3339b2 100644 --- a/apps/desktop/src/renderer/src/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsModal.tsx @@ -2,8 +2,6 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import type { JSX, RefObject } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { - LuBot, - LuBrain, LuCheck, LuCpu, LuFolderOpen, @@ -12,29 +10,17 @@ import { LuPlus, LuPuzzle, LuSearch, - LuServer, LuSettings2, LuSparkles, LuTerminal, LuTextQuote, - LuWebhook, LuX } from 'react-icons/lu' import type { IconType } from 'react-icons' import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState, ThemeMode } from '@shared/types' import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme' -type Tab = - | 'general' - | 'model' - | 'prompt' - | 'memory' - | 'subagent' - | 'plugins' - | 'mcp' - | 'skills' - | 'commands' - | 'hooks' +type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' /** 左侧分组导航(参考 ZCode 设置页:基础设置 / Agent 能力) */ const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: IconType }> }> = [ @@ -49,36 +35,24 @@ const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: I { group: 'Agent 能力', items: [ - { id: 'memory', label: '记忆', icon: LuBrain }, - { id: 'subagent', label: '子智能体', icon: LuBot }, { id: 'plugins', label: '插件', icon: LuPuzzle }, - { id: 'mcp', label: 'MCP 服务器', icon: LuServer }, { id: 'skills', label: '技能', icon: LuSparkles }, - { id: 'commands', label: '命令', icon: LuTerminal }, - { id: 'hooks', label: '钩子', icon: LuWebhook } + { id: 'commands', label: '命令', icon: LuTerminal } ] } ] const PLACEHOLDER: Record = { - memory: '让 Agent 在会话之间记住偏好与事实,越用越懂你的店铺。', - subagent: '为主流程配置可调度的子智能体,分头完成检索、写作与校验。', - plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。', - mcp: '接入 MCP 服务器,把外部工具与数据源挂载到 Agent。', - hooks: '在会话生命周期的关键节点上挂载自动化钩子。' + plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。' } const TAB_META: Record = { general: { title: '常规', desc: '主题色等外观偏好。' }, model: { title: '模型设置', desc: '' }, prompt: { title: '系统提示词', desc: '当前生效的 pi 原生系统提示词(含技能清单注入),只读。' }, - memory: { title: '记忆', desc: PLACEHOLDER.memory }, - subagent: { title: '子智能体', desc: PLACEHOLDER.subagent }, plugins: { title: '插件', desc: PLACEHOLDER.plugins }, - mcp: { title: 'MCP 服务器', desc: PLACEHOLDER.mcp }, skills: { title: '技能', desc: 'SKILL.md 技能包,Agent 自动按需加载。' }, - commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' }, - hooks: { title: '钩子', desc: PLACEHOLDER.hooks } + commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' } } function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.Element { @@ -131,50 +105,60 @@ function ResourcePanel({ }: { loading: boolean items: ResourceItem[] - dirs: { user: string; project: string } + /** 当前作用域的资源目录(可多个,如技能的 ~/.pi/agent/skills 与 ~/.agents/skills) */ + dirs: { user: string[]; project: string[] } emptyTitle: string - hints: { user: string; project: string } + /** 与 dirs 一一对应的短标签(展示用;完整路径放 tooltip) */ + hints: { user: string[]; project: string[] } onOpen: (path: string) => void onOpenDir: (path: string) => void }): ReactNode { const [scope, setScope] = useState<'user' | 'project'>('user') const scoped = items.filter((i) => i.scope === scope) - const dir = scope === 'user' ? dirs.user : dirs.project + const scopedDirs = (scope === 'user' ? dirs.user : dirs.project).filter(Boolean) + const scopedHints = scope === 'user' ? hints.user : hints.project return (
-
+
{(['user', 'project'] as const).map((s) => ( ))}
- {dir && ( - - )} +
+ {scopedDirs.map((d, i) => ( + + ))} +
{!loading && scoped.length === 0 ? ( -
-
{emptyTitle}
-
{hints[scope]}
+
+
{emptyTitle}
+ {scopedHints.map((h) => ( +
+ {h} +
+ ))}
) : (
@@ -394,7 +378,7 @@ function ModelSettings({ type="password" value={keyValue} onChange={(e) => onKeyChange(e.target.value)} - placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)" + placeholder="输入 API Key" className="flex-1 rounded-control border border-line-strong bg-surface px-2.5 py-1.5 font-mono text-[12px] text-ink outline-none focus:border-ink-3" /> + ) + }) + )} +
+
, + document.body + ) +} + export function SidebarNav({ sessionState, sessions, @@ -122,6 +204,8 @@ export function SidebarNav({ const [renameValue, setRenameValue] = useState('') const [wsMenuOpen, setWsMenuOpen] = useState(false) const [expandedWs, setExpandedWs] = useState>(new Set()) + /** 折叠态会话切换浮层:锚点(rail 图标顶缘/右缘),null = 关闭 */ + const [sessionSwitcher, setSessionSwitcher] = useState<{ top: number; right: number } | null>(null) const renameInputRef = useRef(null) const searchInputRef = useRef(null) @@ -139,6 +223,7 @@ export function SidebarNav({ useEffect(() => { if (collapsed) setWsMenuOpen(false) + else setSessionSwitcher(null) }, [collapsed]) useEffect(() => { @@ -340,68 +425,86 @@ export function SidebarNav({ {/* 会话列表区 */}
-
- - - {/* 内联搜索:放大镜 ↔ 输入框宽度过渡 */} - -
- - - - setQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setQuery('') - setSearchOpen(false) - } - }} - placeholder="搜索会话" - aria-label="搜索会话历史" - className="ml-1.5 min-w-0 flex-1 bg-transparent text-[13px] font-medium text-ink outline-none placeholder:text-ink-3" - /> +
+
+ + {/* 内联搜索:放大镜 ↔ 输入框宽度过渡 */} + +
+ + + + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setQuery('') + setSearchOpen(false) + } + }} + placeholder="搜索会话" + aria-label="搜索会话历史" + className="ml-1.5 min-w-0 flex-1 bg-transparent text-[13px] font-medium text-ink outline-none placeholder:text-ink-3" + /> + +
+ + {/* 折叠态:会话切换入口(类浏览器标签切换,仅切换)。 + 收窄到 rail 可见的 36px(与 nav 图标钮同宽),否则图标会被 aside 裁剪 */} +
- {chatsOpen && ( + {chatsOpen && !collapsed && ( {filtered.length === 0 ? (
@@ -416,12 +519,23 @@ export function SidebarNav({ data-row title={sessionTitle(s)} onClick={() => onOpenSession(s.path)} - className={`${navRowClass} group/row cursor-pointer ${active ? 'text-ink' : 'text-ink-2'}`} + className={`${navRowClass} group/row cursor-pointer ${ + active ? 'bg-hover-2 text-ink' : 'text-ink-2' + }`} > - {/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px */} - {sessionTitle(s)} - {/* 悬停浮动操作:quiet 图标钮,无底板,贴近官方 primitive-icon-button */} - + {/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px。 + 激活行除灰底外再用字重区分(激活加粗 / 非激活常规) */} + + {sessionTitle(s)} + + {/* 悬停操作:quiet 图标钮,无底板。行内流式布局(非绝对定位): + 悬停时标题自动收窄让位,按钮不越出行、不遮相邻行的文字; + 收起侧栏时随 sidebar-copy 一并淡出并禁用点击 */} + {active && (