feat: 模型级联菜单、逐回合分叉、折叠态会话切换、划选引用与界面缩放
- 模型菜单改为级联悬浮结构:服务商独立成行,悬浮其上时模型列表浮出左侧;
This commit is contained in:
@@ -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())
|
||||
|
||||
|
||||
@@ -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<ChatResources> {
|
||||
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<ResourceDetails> {
|
||||
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) => ({
|
||||
const runtime = await this.getRuntime()
|
||||
for (const s of runtime.session.resourceLoader.getSkills().skills) {
|
||||
skills.push({
|
||||
name: s.name,
|
||||
description: s.description ?? '',
|
||||
description: s.description,
|
||||
filePath: s.filePath,
|
||||
scope: s.sourceInfo?.scope === 'user' ? 'user' : 'project',
|
||||
// 与 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<void> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> => ipcRenderer.invoke('win:minimize'),
|
||||
toggleMaximize: (): Promise<void> => ipcRenderer.invoke('win:toggleMaximize'),
|
||||
close: (): Promise<void> => 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<void> =>
|
||||
ipcRenderer.invoke('session:navigate', entryId, options),
|
||||
forkSession: (): Promise<void> => ipcRenderer.invoke('session:fork'),
|
||||
forkSessionAt: (entryId: string, position: 'at' | 'before'): Promise<void> =>
|
||||
ipcRenderer.invoke('session:forkAt', entryId, position),
|
||||
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
|
||||
getSystemPrompt: (): Promise<string> => ipcRenderer.invoke('settings:getSystemPrompt'),
|
||||
setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id),
|
||||
|
||||
@@ -82,7 +82,8 @@ export default function App(): JSX.Element {
|
||||
setAutoCompaction,
|
||||
dismissCompactionNotice,
|
||||
navigateTree,
|
||||
forkSession
|
||||
forkSession,
|
||||
forkSessionAt
|
||||
} = useAgent()
|
||||
const prefillRef = useRef<string | null>(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<string | null>(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}
|
||||
/>
|
||||
<BottomSlot
|
||||
approvals={state.approvals}
|
||||
@@ -292,6 +305,8 @@ export default function App(): JSX.Element {
|
||||
onAbortCompaction={abortCompaction}
|
||||
onToggleAutoCompaction={setAutoCompaction}
|
||||
prefillRef={prefillRef}
|
||||
pendingQuote={pendingQuote}
|
||||
onQuoteConsumed={consumePendingQuote}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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<string, ToolState>,
|
||||
@@ -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<number, string>()
|
||||
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(
|
||||
<AssistantTurn
|
||||
key={`${message.key}-turn`}
|
||||
@@ -101,6 +124,10 @@ function renderTurns(
|
||||
turnActive={turnActive && isLive}
|
||||
pendingApprovals={pendingApprovals}
|
||||
scrollPinnedRef={pinnedRef}
|
||||
forking={forking}
|
||||
onFork={isLive ? onFork : undefined}
|
||||
forkEntryId={turnForkEntryId}
|
||||
onForkAt={onForkAt}
|
||||
/>
|
||||
)
|
||||
i = j
|
||||
@@ -154,8 +181,9 @@ export function ChatView({
|
||||
onAbortCompaction,
|
||||
onRewind,
|
||||
onFork,
|
||||
onForkAt,
|
||||
onPickSuggestion,
|
||||
onApplySelection
|
||||
onQuoteSelection
|
||||
}: {
|
||||
messages: UiMessage[]
|
||||
tools: Record<string, ToolState>
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
@@ -378,26 +410,10 @@ export function ChatView({
|
||||
navigating,
|
||||
onRewind,
|
||||
countUserMessages(messages, 0, hidden),
|
||||
hidden === 0
|
||||
)}
|
||||
{/* 会话结尾:从此处分叉为新会话(pi fork,原会话保留)。回合进行中不打断,收起入口 */}
|
||||
{hasContent && !turnActive && !streaming && !compacting && !navigating && (
|
||||
<div className="mt-1 flex">
|
||||
<button
|
||||
type="button"
|
||||
disabled={forking}
|
||||
onClick={onFork}
|
||||
title="复制当前会话到新的会话文件,从这里继续探索(原会话保留不动)"
|
||||
className="flex h-7 items-center gap-1.5 rounded-control px-2 text-[12px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2 disabled:cursor-default disabled:opacity-60"
|
||||
>
|
||||
{forking ? (
|
||||
<LuLoaderCircle size={12} className="animate-spin" />
|
||||
) : (
|
||||
<LuGitFork size={12} className="rotate-180" />
|
||||
)}
|
||||
{forking ? '正在创建分支…' : '从此处分叉为新会话'}
|
||||
</button>
|
||||
</div>
|
||||
hidden === 0,
|
||||
forking,
|
||||
canFork ? onFork : undefined,
|
||||
canFork ? onForkAt : undefined
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -407,8 +423,8 @@ export function ChatView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 划选消息文本后浮出的操作条 */}
|
||||
<SelectionActions containerRef={scrollRef} onApply={onApplySelection} />
|
||||
{/* 划选消息文本后浮出的操作条:引用进输入框 / 复制 */}
|
||||
<SelectionActions containerRef={scrollRef} onQuote={onQuoteSelection} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string | null>
|
||||
/** 划选「引用」注入的内容(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<HTMLButtonElement>(null)
|
||||
const usageBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const thinkingBtnRef = useRef<HTMLButtonElement>(null)
|
||||
/** 模型菜单:悬浮的 provider 行及其行内 rect;模型子列表悬浮其左侧(null = 未悬浮) */
|
||||
const [hoveredProvider, setHoveredProvider] = useState<{ id: string; rect: DOMRect } | null>(null)
|
||||
const hoverGraceRef = useRef<number | null>(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"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
@@ -862,7 +907,7 @@ export function Composer({
|
||||
aria-expanded={usageOpen}
|
||||
aria-label="上下文用量"
|
||||
onClick={toggleUsageCard}
|
||||
title="上下文容量、分类用量与缓存命中率"
|
||||
title="上下文用量"
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-[8px] px-1.5 text-[12px] font-medium tabular-nums text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<LuGauge size={13} className={compacting ? 'animate-pulse' : ''} />
|
||||
@@ -913,7 +958,7 @@ export function Composer({
|
||||
aria-label={`权限模式:${permMeta.label}`}
|
||||
onClick={togglePermMenu}
|
||||
disabled={disabled}
|
||||
title={`权限模式:${permMeta.label}(${permMeta.desc})`}
|
||||
title={`权限模式:${permMeta.label}`}
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:text-ink-2'
|
||||
}`}
|
||||
@@ -1095,41 +1140,39 @@ export function Composer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 模型选择菜单(portal,向上弹出) */}
|
||||
{/* 模型选择菜单(portal,向上弹出)。服务商并列独立成行;悬浮某行时
|
||||
该服务商的模型以悬浮列表出现在左侧(级联子菜单,portal 到 body),
|
||||
点击模型即切换 */}
|
||||
{modelOpen && modelPos && (
|
||||
<ComposerMenu anchor={modelPos} align="right" onClose={() => setModelOpen(false)}>
|
||||
<div className="w-72">
|
||||
<div className="max-h-80 overflow-y-auto p-1.5">
|
||||
<div className="w-60 p-1.5" data-model-menu>
|
||||
{catalogLoading && <div className="px-2.5 py-3 text-[12px] text-ink-3">加载模型中…</div>}
|
||||
{!catalogLoading && providers.length === 0 && (
|
||||
<div className="px-2.5 py-3 text-[12px] text-ink-3">没有可用模型,请先在设置中配置密钥</div>
|
||||
)}
|
||||
{providers.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-medium uppercase tracking-wide text-ink-3">
|
||||
{provider.name}
|
||||
</div>
|
||||
{provider.models.map((m) => {
|
||||
const selected = model?.provider === provider.id && model?.id === m.id
|
||||
{providers.map((p) => {
|
||||
const inUse = model?.provider === p.id
|
||||
const hovered = hoveredProvider?.id === p.id
|
||||
return (
|
||||
<button
|
||||
key={`${provider.id}/${m.id}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSetModel(provider.id, m.id)
|
||||
setModelOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
<div
|
||||
key={p.id}
|
||||
onMouseEnter={(e) => enterProvider(p.id, e.currentTarget)}
|
||||
onMouseLeave={leaveProvider}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink">{m.name}</span>
|
||||
{selected && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded-control px-2.5 py-2 text-left transition-colors hover:bg-hover ${
|
||||
hovered ? 'bg-hover' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] text-ink">{p.name}</span>
|
||||
{inUse && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
|
||||
<LuChevronRight size={12} className="shrink-0 text-ink-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-line p-1.5">
|
||||
<div className="mt-1 border-t border-line pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1144,6 +1187,50 @@ export function Composer({
|
||||
</div>
|
||||
</ComposerMenu>
|
||||
)}
|
||||
|
||||
{/* 悬浮模型列表:悬浮服务商行时出现在其左侧(portal 到 body,
|
||||
ComposerMenu 的 overflow-hidden 会裁剪子元素) */}
|
||||
{modelOpen &&
|
||||
hoveredProvider &&
|
||||
(() => {
|
||||
const p = providers.find((x) => x.id === hoveredProvider.id)
|
||||
if (!p) return null
|
||||
return createPortal(
|
||||
<div
|
||||
data-model-flyout
|
||||
className="fixed z-40 w-64 overflow-hidden rounded-card border border-line bg-surface shadow-overlay"
|
||||
style={{
|
||||
right: window.innerWidth - hoveredProvider.rect.left + 8,
|
||||
bottom: window.innerHeight - hoveredProvider.rect.bottom,
|
||||
maxHeight: 336
|
||||
}}
|
||||
onMouseEnter={keepProviderFlyout}
|
||||
onMouseLeave={leaveProvider}
|
||||
>
|
||||
<div className="max-h-[330px] overflow-y-auto p-1.5">
|
||||
{p.models.map((m) => {
|
||||
const selected = model?.provider === p.id && model?.id === m.id
|
||||
return (
|
||||
<button
|
||||
key={`${p.id}/${m.id}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSetModel(p.id, m.id)
|
||||
setModelOpen(false)
|
||||
setHoveredProvider(null)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink">{m.name}</span>
|
||||
{selected && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 <p className="my-4 leading-normal">{children}</p>
|
||||
},
|
||||
/** h1/h2 带强调条(对齐参考渲染),h3 起按层级递减,保证标题有可辨识的层级 */
|
||||
h1({ children }) {
|
||||
return <h3 className="mb-2 mt-5 text-[17px] font-semibold text-ink first:mt-0">{children}</h3>
|
||||
return (
|
||||
<h3 className="mt-5 mb-2 flex items-center gap-2 text-[18px] font-semibold text-ink first:mt-0">
|
||||
<span aria-hidden className="h-[16px] w-[3px] shrink-0 rounded-full bg-accent" />
|
||||
<span className="min-w-0">{children}</span>
|
||||
</h3>
|
||||
)
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h4 className="mb-2 mt-5 text-[15.5px] font-semibold text-ink first:mt-0">{children}</h4>
|
||||
return (
|
||||
<h4 className="mt-5 mb-2 flex items-center gap-2 text-[17px] font-semibold text-ink first:mt-0">
|
||||
<span aria-hidden className="h-[15px] w-[3px] shrink-0 rounded-full bg-accent" />
|
||||
<span className="min-w-0">{children}</span>
|
||||
</h4>
|
||||
)
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h5 className="mb-1.5 mt-4 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h5>
|
||||
return <h5 className="mb-1.5 mt-4 text-[15px] font-semibold text-ink first:mt-0">{children}</h5>
|
||||
},
|
||||
h4({ children }) {
|
||||
return <h6 className="mb-1.5 mt-4 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h6>
|
||||
@@ -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]
|
||||
|
||||
@@ -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<string, ToolState>
|
||||
@@ -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 && <Loading label="处理中" variant="Dots" />}
|
||||
|
||||
{/* 结论底部的复制操作:常驻不闪现 */}
|
||||
{(hasAnswer || answerText.trim().length > 0) && (
|
||||
<div className="mt-1.5 flex">
|
||||
{/* 结论底部的操作行:复制 + 会话分叉。末回合走会话末尾分叉(onFork),
|
||||
历史回合以「本回合之后第一条用户消息」为锚点(forkEntryId + onForkAt),
|
||||
新会话都包含到本回合答案为止。常驻不闪现。 */}
|
||||
{(hasAnswer || answerText.trim().length > 0 || onFork || (forkEntryId && onForkAt)) && (
|
||||
<div className="mt-1.5 flex items-center gap-0.5">
|
||||
<CopyAnswerButton text={answerText} />
|
||||
{(onFork || (forkEntryId && onForkAt)) && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="从此处分叉为新会话"
|
||||
title="从此处分叉为新会话"
|
||||
disabled={forking}
|
||||
onClick={() => {
|
||||
if (onFork) onFork()
|
||||
else if (forkEntryId && onForkAt) onForkAt(forkEntryId)
|
||||
}}
|
||||
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2 disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
{forking ? (
|
||||
<LuLoaderCircle size={15} className="animate-spin" />
|
||||
) : (
|
||||
<LuGitFork size={15} className="rotate-180" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -443,7 +476,7 @@ function RewindMenu({ entryId, onRewind }: { entryId: string; onRewind?: (entryI
|
||||
<button
|
||||
type="button"
|
||||
aria-label="回溯到此处"
|
||||
title="回溯到这条消息(会话树上此节点之后的分叉将被离开)"
|
||||
title="回溯到这条消息"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2"
|
||||
>
|
||||
@@ -636,6 +669,8 @@ function sameTurnProps(
|
||||
next: ComponentProps<typeof AssistantTurnBase>
|
||||
): boolean {
|
||||
if (prev.streaming !== next.streaming || prev.turnActive !== next.turnActive) return false
|
||||
if (prev.forking !== next.forking || prev.onFork !== next.onFork) return false
|
||||
if (prev.forkEntryId !== next.forkEntryId || prev.onForkAt !== next.onForkAt) return false
|
||||
if (!sameApprovals(prev.pendingApprovals, next.pendingApprovals)) return false
|
||||
if (!sameMessages(prev.messages, next.messages)) return false
|
||||
if (prev.tools !== next.tools && referencedToolChanged(prev.tools, next.tools, next.messages)) return false
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
|
||||
import { LuCircleHelp, LuScissors, LuSmile, LuSpellCheck, LuWandSparkles } from 'react-icons/lu'
|
||||
import { LuCheck, LuCopy, LuQuote } from 'react-icons/lu'
|
||||
|
||||
/** 选区操作条(beautifului.dev Selection Actions)。
|
||||
/** 选区操作条。
|
||||
* 在 containerRef 内划选文本后,于选区上方浮出胶囊工具条:
|
||||
* 「描述修改」输入框 + 解释/改进,更多按钮展开 精简/语气/语法;
|
||||
* 输入内容后出现发送钮。预设动作与自定义指令都经 onApply 交给上层。 */
|
||||
|
||||
const PRESET_MAIN: { label: string; icon: ReactNode }[] = [
|
||||
{ label: '解释', icon: <LuCircleHelp size={14} strokeWidth={1.8} /> },
|
||||
{ label: '改进', icon: <LuWandSparkles size={14} strokeWidth={1.8} /> }
|
||||
]
|
||||
|
||||
const PRESET_MORE: { label: string; icon: ReactNode }[] = [
|
||||
{ label: '精简', icon: <LuScissors size={14} strokeWidth={1.8} /> },
|
||||
{ label: '语气', icon: <LuSmile size={14} strokeWidth={1.8} /> },
|
||||
{ label: '语法', icon: <LuSpellCheck size={14} strokeWidth={1.8} /> }
|
||||
]
|
||||
* 「引用」把选中文本注入输入框开头(blockquote 形式),「复制」写入剪贴板;
|
||||
* Esc / 滚动 / 点击后收起。 */
|
||||
|
||||
const pillButton =
|
||||
'inline-flex items-center justify-center select-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none text-ink hover:bg-hover h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1 shrink-0'
|
||||
|
||||
export function SelectionActions({
|
||||
containerRef,
|
||||
onApply
|
||||
onQuote
|
||||
}: {
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
onApply: (instruction: string, selection: string) => void
|
||||
onQuote: (selection: string) => void
|
||||
}): ReactNode {
|
||||
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selection, setSelection] = useState('')
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const pillRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const dismiss = (): void => {
|
||||
setAnchor(null)
|
||||
setSelection('')
|
||||
setInstruction('')
|
||||
setMoreOpen(false)
|
||||
setCopied(false)
|
||||
}
|
||||
|
||||
// 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起)
|
||||
@@ -94,19 +81,19 @@ export function SelectionActions({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [anchor])
|
||||
|
||||
const apply = (label: string): void => {
|
||||
const quote = (): void => {
|
||||
if (!selection) return
|
||||
onApply(label, selection)
|
||||
onQuote(selection)
|
||||
dismiss()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
const submitInstruction = (): void => {
|
||||
const text = instruction.trim()
|
||||
if (!text || !selection) return
|
||||
onApply(`${text}(针对以下选中内容)`, selection)
|
||||
dismiss()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
const copy = (): void => {
|
||||
if (!selection) return
|
||||
void navigator.clipboard.writeText(selection).then(() => {
|
||||
setCopied(true)
|
||||
window.setTimeout(dismiss, 900)
|
||||
})
|
||||
}
|
||||
|
||||
const visible = anchor !== null
|
||||
@@ -122,82 +109,21 @@ export function SelectionActions({
|
||||
pointerEvents: visible ? undefined : 'none'
|
||||
}}
|
||||
>
|
||||
<div className="flex h-9 w-fit max-w-[calc(100vw-48px)] items-center justify-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 font-sans font-normal text-ink shadow-overlay">
|
||||
{/* 描述修改输入框(常显,站点默认态) */}
|
||||
<div className="flex w-fit shrink-0 items-center justify-center gap-0.5 overflow-hidden" style={{ maxWidth: 152 }}>
|
||||
<form
|
||||
className="flex h-7 shrink-0 items-center"
|
||||
style={{ width: 152 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submitInstruction()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={instruction}
|
||||
onChange={(e) => setInstruction(e.target.value)}
|
||||
placeholder="描述修改…"
|
||||
aria-label="描述修改"
|
||||
className="h-7 w-full bg-transparent pr-2.5 pl-3 text-[12.5px] text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<span className="mx-1 h-4 w-px shrink-0 bg-line-strong" />
|
||||
|
||||
{/* 预设动作 + 更多 */}
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-hidden">
|
||||
{PRESET_MAIN.map((action) => (
|
||||
<button key={action.label} type="button" className={pillButton} onClick={() => apply(action.label)}>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
<div className="flex h-9 w-fit items-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 shadow-overlay">
|
||||
<button type="button" className={pillButton} onClick={quote}>
|
||||
<LuQuote size={14} strokeWidth={1.8} />
|
||||
引用
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity] duration-[400ms]"
|
||||
style={{ maxWidth: moreOpen ? 224 : 0, opacity: moreOpen ? 1 : 0, transitionTimingFunction: 'cubic-bezier(0.23,1,0.32,1)' }}
|
||||
>
|
||||
{PRESET_MORE.map((action) => (
|
||||
<button key={action.label} type="button" className={pillButton} onClick={() => apply(action.label)}>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="更多操作"
|
||||
aria-expanded={moreOpen}
|
||||
onClick={() => setMoreOpen((v) => !v)}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full text-ink transition-[background-color,transform] duration-200 hover:bg-hover active:scale-[0.96]"
|
||||
>
|
||||
<span className="flex transition-transform duration-[400ms]" style={{ transform: moreOpen ? 'rotate(180deg)' : 'rotate(0deg)', transitionTimingFunction: 'cubic-bezier(0.23,1,0.32,1)' }}>
|
||||
<svg width="14" height="14" strokeWidth="1.8" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M9 6L15 12L9 18" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<button type="button" className={pillButton} onClick={copy}>
|
||||
{copied ? (
|
||||
<LuCheck size={14} strokeWidth={2} className="text-accent-ink" />
|
||||
) : (
|
||||
<LuCopy size={14} strokeWidth={1.8} />
|
||||
)}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 发送钮:输入内容后出现 */}
|
||||
<div
|
||||
className="flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-[400ms]"
|
||||
style={{ maxWidth: instruction.trim() ? 28 : 0, opacity: instruction.trim() ? 1 : 0, transform: instruction.trim() ? 'scale(1)' : 'scale(0.88)', transitionTimingFunction: 'cubic-bezier(0.23,1,0.32,1)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="发送修改指令"
|
||||
onClick={submitInstruction}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-ink text-surface transition-[opacity,transform] duration-200 active:scale-[0.94]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" strokeWidth="2.4" fill="none" aria-hidden>
|
||||
<path d="M12 21L12 3M12 3L20.5 11.5M12 3L3.5 11.5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
memory: '让 Agent 在会话之间记住偏好与事实,越用越懂你的店铺。',
|
||||
subagent: '为主流程配置可调度的子智能体,分头完成检索、写作与校验。',
|
||||
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。',
|
||||
mcp: '接入 MCP 服务器,把外部工具与数据源挂载到 Agent。',
|
||||
hooks: '在会话生命周期的关键节点上挂载自动化钩子。'
|
||||
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。'
|
||||
}
|
||||
|
||||
const TAB_META: Record<Tab, { title: string; desc: string }> = {
|
||||
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 (
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 pb-3">
|
||||
<div className="flex items-center rounded-control bg-hover p-0.5">
|
||||
<div className="flex items-center rounded-control bg-zinc-100 p-0.5">
|
||||
{(['user', 'project'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={`h-6 rounded-[6px] px-2.5 text-[11.5px] transition-colors ${
|
||||
scope === s ? 'bg-surface font-medium text-ink shadow-sm' : 'text-ink-3 hover:text-ink-2'
|
||||
scope === s ? 'bg-white font-medium text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{s === 'user' ? '全局' : '项目'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{dir && (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{scopedDirs.map((d, i) => (
|
||||
<button
|
||||
key={d || i}
|
||||
type="button"
|
||||
title={dir}
|
||||
onClick={() => onOpenDir(dir)}
|
||||
className="ml-auto flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 text-[11.5px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
||||
title={d}
|
||||
onClick={() => onOpenDir(d)}
|
||||
className="flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 font-mono text-[10.5px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
<LuFolderOpen size={12} className="shrink-0" />
|
||||
打开目录
|
||||
{scopedHints[i] ?? '打开目录'}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && scoped.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center">
|
||||
<div className="text-[13px] font-medium text-ink-2">{emptyTitle}</div>
|
||||
<div className="text-[12px] text-ink-3">{hints[scope]}</div>
|
||||
<div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-zinc-200 py-12 text-center">
|
||||
<div className="text-[13px] font-medium text-zinc-600">{emptyTitle}</div>
|
||||
{scopedHints.map((h) => (
|
||||
<div key={h} className="font-mono text-[11.5px] text-zinc-400">
|
||||
{h}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
@@ -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"
|
||||
/>
|
||||
<button
|
||||
@@ -910,11 +894,14 @@ export function SettingsModal({
|
||||
filePath: s.filePath
|
||||
}))}
|
||||
dirs={{
|
||||
user: resources?.dirs.globalSkills ?? '',
|
||||
project: resources?.dirs.projectSkills ?? ''
|
||||
user: [resources?.dirs.globalSkills ?? '', resources?.dirs.globalAgentsSkills ?? ''],
|
||||
project: [resources?.dirs.projectSkills ?? '', resources?.dirs.projectAgentsSkills ?? '']
|
||||
}}
|
||||
emptyTitle="暂无技能"
|
||||
hints={{ user: '~/.pi/agent/skills', project: '工作区/.pi/skills' }}
|
||||
hints={{
|
||||
user: ['~/.pi/agent/skills', '~/.agents/skills'],
|
||||
project: ['工作区/.pi/skills', '工作区/.agents/skills']
|
||||
}}
|
||||
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
|
||||
onOpenDir={(path) => {
|
||||
window.api.openDirectory(path).catch((err: unknown) =>
|
||||
@@ -936,11 +923,11 @@ export function SettingsModal({
|
||||
filePath: c.filePath
|
||||
}))}
|
||||
dirs={{
|
||||
user: resources?.dirs.globalCommands ?? '',
|
||||
project: resources?.dirs.projectCommands ?? ''
|
||||
user: [resources?.dirs.globalCommands ?? ''],
|
||||
project: [resources?.dirs.projectCommands ?? '']
|
||||
}}
|
||||
emptyTitle="暂无自定义命令"
|
||||
hints={{ user: '~/.pi/agent/prompts', project: '工作区/.pi/prompts' }}
|
||||
hints={{ user: ['~/.pi/agent/prompts'], project: ['工作区/.pi/prompts'] }}
|
||||
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
|
||||
onOpenDir={(path) => {
|
||||
window.api.openDirectory(path).catch((err: unknown) =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
|
||||
import {
|
||||
LuCheck,
|
||||
LuChevronDown,
|
||||
LuMessagesSquare,
|
||||
LuPencil,
|
||||
LuPlus,
|
||||
LuSearch,
|
||||
@@ -78,6 +79,87 @@ function RowActionPortal({
|
||||
)
|
||||
}
|
||||
|
||||
/** 折叠态会话切换浮层:锚定 rail 图标向右弹出,类浏览器标签切换(仅切换,不管理)。
|
||||
* portal 到 body(aside overflow-hidden 会裁剪内联浮层);点外部 / Esc / 选中即关。 */
|
||||
function SessionSwitcherPortal({
|
||||
anchor,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onOpenSession,
|
||||
onClose
|
||||
}: {
|
||||
anchor: { top: number; right: number }
|
||||
sessions: SessionMeta[]
|
||||
activeSessionId?: string
|
||||
onOpenSession: (path: string) => void
|
||||
onClose: () => void
|
||||
}): ReactNode {
|
||||
const closeRef = useRef(onClose)
|
||||
closeRef.current = onClose
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent): void => {
|
||||
const target = event.target as Element
|
||||
if (target.closest('[data-session-flyout]') || target.closest('[data-session-toggle]')) return
|
||||
closeRef.current()
|
||||
}
|
||||
const esc = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') closeRef.current()
|
||||
}
|
||||
document.addEventListener('pointerdown', close)
|
||||
document.addEventListener('keydown', esc)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', close)
|
||||
document.removeEventListener('keydown', esc)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const top = Math.max(8, Math.min(anchor.top, window.innerHeight - 220))
|
||||
const listMaxHeight = Math.max(160, window.innerHeight - top - 52)
|
||||
return createPortal(
|
||||
<div
|
||||
data-session-flyout
|
||||
className="fixed z-50 w-64 rounded-[14px] bg-surface p-1.5 shadow-overlay"
|
||||
style={{
|
||||
left: anchor.right + 12,
|
||||
top,
|
||||
animation: 'pop-in 180ms cubic-bezier(0.23,1,0.32,1) both',
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
<div className="px-2 pb-1 pt-1 text-[11px] font-medium text-ink-3">会话</div>
|
||||
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-[12px] text-ink-3">暂无历史会话</div>
|
||||
) : (
|
||||
sessions.map((s) => {
|
||||
const active = s.id === activeSessionId
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
title={sessionTitle(s)}
|
||||
onClick={() => {
|
||||
onOpenSession(s.path)
|
||||
onClose()
|
||||
}}
|
||||
className={`flex h-8 w-full items-center gap-2 rounded-[7px] px-2 text-left transition-colors ${
|
||||
active ? 'bg-hover-2 text-ink' : 'text-ink-2 hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
<span className={`min-w-0 flex-1 truncate text-[13px] ${active ? 'font-medium' : 'font-normal'}`}>
|
||||
{sessionTitle(s)}
|
||||
</span>
|
||||
{active && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
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<Set<string>>(new Set())
|
||||
/** 折叠态会话切换浮层:锚点(rail 图标顶缘/右缘),null = 关闭 */
|
||||
const [sessionSwitcher, setSessionSwitcher] = useState<{ top: number; right: number } | null>(null)
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -139,6 +223,7 @@ export function SidebarNav({
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed) setWsMenuOpen(false)
|
||||
else setSessionSwitcher(null)
|
||||
}, [collapsed])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -340,7 +425,8 @@ export function SidebarNav({
|
||||
|
||||
{/* 会话列表区 */}
|
||||
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="sidebar-copy relative mx-2 mb-1 h-8">
|
||||
<div className="relative mx-2 mb-1 h-8">
|
||||
<div className="sidebar-copy absolute inset-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChatsOpen((v) => !v)}
|
||||
@@ -401,7 +487,24 @@ export function SidebarNav({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chatsOpen && (
|
||||
{/* 折叠态:会话切换入口(类浏览器标签切换,仅切换)。
|
||||
收窄到 rail 可见的 36px(与 nav 图标钮同宽),否则图标会被 aside 裁剪 */}
|
||||
<button
|
||||
type="button"
|
||||
data-session-toggle
|
||||
aria-label="切换会话"
|
||||
title="切换会话"
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setSessionSwitcher((v) => (v ? null : { top: rect.top, right: rect.right }))
|
||||
}}
|
||||
className="sidebar-collapsed-only absolute left-0 top-0 h-8 w-9 items-center justify-center rounded-[8px] text-ink-2 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96] [-webkit-app-region:no-drag]"
|
||||
>
|
||||
<LuMessagesSquare size={18} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{chatsOpen && !collapsed && (
|
||||
<GlideMenu highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="sidebar-copy mx-2 mb-1 rounded-[8px] border border-dashed border-line px-3 py-4 text-center text-[12px] leading-5 text-ink-3">
|
||||
@@ -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 */}
|
||||
<span className="sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium">{sessionTitle(s)}</span>
|
||||
{/* 悬停浮动操作:quiet 图标钮,无底板,贴近官方 primitive-icon-button */}
|
||||
<span className="absolute right-0.5 hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
|
||||
{/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px。
|
||||
激活行除灰底外再用字重区分(激活加粗 / 非激活常规) */}
|
||||
<span
|
||||
className={`sidebar-copy min-w-0 flex-1 truncate text-[14px] ${
|
||||
active ? 'font-medium' : 'font-normal'
|
||||
}`}
|
||||
>
|
||||
{sessionTitle(s)}
|
||||
</span>
|
||||
{/* 悬停操作:quiet 图标钮,无底板。行内流式布局(非绝对定位):
|
||||
悬停时标题自动收窄让位,按钮不越出行、不遮相邻行的文字;
|
||||
收起侧栏时随 sidebar-copy 一并淡出并禁用点击 */}
|
||||
<span className="sidebar-copy hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
|
||||
{active && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -463,6 +577,17 @@ export function SidebarNav({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 折叠态会话切换浮层(portal 到 body) */}
|
||||
{sessionSwitcher && (
|
||||
<SessionSwitcherPortal
|
||||
anchor={sessionSwitcher}
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onOpenSession={onOpenSession}
|
||||
onClose={() => setSessionSwitcher(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 行内操作浮层:锚定触发按钮,官方 WorkspaceMenu 形态(pop-in + overlay 阴影) */}
|
||||
{actionMenu && (
|
||||
<RowActionPortal rect={actionMenu.rect} onClose={() => setActionMenu(null)}>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 可展开的思考轨迹(ZCode 式实时预览)。
|
||||
* 默认全部展开:工作中随流式增长完整展示,完成后原样保留;表头由
|
||||
* 「正在思考」渐变为「思考 · 首行」。点击表头可手动收起/展开。
|
||||
/** 可展开的思考轨迹。
|
||||
* 默认收起:表头展示「状态词 + 开头第一行内容」式预览,点击展开完整轨迹,
|
||||
* 再点收起;流式中表头标签保持 shimmer。
|
||||
* 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填):
|
||||
* 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时
|
||||
* 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明
|
||||
@@ -22,22 +22,21 @@ export function Thinking({
|
||||
activeLabel?: string
|
||||
doneLabel?: string
|
||||
}): ReactNode {
|
||||
// 默认全部展开(工作中即完整展示思考轨迹);用户点击后以手动状态为准
|
||||
// 默认收起(不想默认展开思考内容);用户点击后以手动状态为准
|
||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
|
||||
const expanded = manualExpanded ?? true
|
||||
const expanded = manualExpanded ?? false
|
||||
|
||||
const latest = rows.length > 0 ? rows[rows.length - 1].primary : ''
|
||||
const firstLine = rows.length > 0 ? rows[0].primary : ''
|
||||
const statusWord = streaming ? activeLabel : expanded ? '思考' : doneLabel
|
||||
const suffix = streaming ? (expanded ? '' : latest) : expanded ? firstLine : ''
|
||||
const statusWord = streaming ? activeLabel : doneLabel
|
||||
const suffix = expanded ? '' : firstLine
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{/* 表头:状态词 +(流式收起时)最新一行 + 旋转箭头 */}
|
||||
{/* 表头:状态词 +(收起时)首行预览 + 旋转箭头 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setManualExpanded((current) => !(current ?? true))}
|
||||
onClick={() => setManualExpanded((current) => !(current ?? false))}
|
||||
className="-mx-1.5 flex w-fit min-w-0 max-w-full items-center gap-2 rounded-control px-1.5 py-1 transition-colors hover:bg-hover-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={streaming ? 'var(--ink-2)' : 'var(--ink-3)'}>
|
||||
|
||||
@@ -126,7 +126,7 @@ export function UsageCard({
|
||||
role="switch"
|
||||
aria-checked={autoCompactionEnabled}
|
||||
onClick={() => onToggleAutoCompaction(!autoCompactionEnabled)}
|
||||
title="上下文接近窗口上限时自动压缩历史(阈值/溢出触发)"
|
||||
title="接近窗口上限时自动压缩历史"
|
||||
className="flex items-center gap-2 text-[12px] text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
<span
|
||||
@@ -163,7 +163,7 @@ export function UsageCard({
|
||||
type="button"
|
||||
onClick={onCompact}
|
||||
disabled={total === null || total === 0}
|
||||
title="把较早的对话历史压缩为摘要,释放上下文空间(可附带自定义指令,见 /compact)"
|
||||
title="把较早的对话压缩为摘要,释放上下文空间"
|
||||
className="flex h-7 items-center rounded-[7px] border border-line bg-surface px-2.5 text-[12px] text-ink-2 transition-colors hover:bg-hover hover:text-ink disabled:cursor-default disabled:opacity-40 disabled:hover:bg-surface"
|
||||
>
|
||||
立即压缩
|
||||
|
||||
@@ -373,6 +373,8 @@ export interface UseAgent {
|
||||
navigateTree: (entryId: string, options?: NavigateTreeOptions) => void
|
||||
/** 在会话末尾分叉出新会话(pi fork,原会话保留) */
|
||||
forkSession: () => void
|
||||
/** 从锚点分叉:'at' 含该条为止 / 'before' 到其父条目为止 */
|
||||
forkSessionAt: (entryId: string, position: 'at' | 'before') => void
|
||||
}
|
||||
|
||||
export function useAgent(): UseAgent {
|
||||
@@ -580,6 +582,18 @@ export function useAgent(): UseAgent {
|
||||
.finally(() => setForking(false))
|
||||
}, [dispatchError])
|
||||
|
||||
/** 从会话树上的锚点分叉出新会话('at' 含该条为止 / 'before' 到其父条目为止,即包含前面整个回合) */
|
||||
const forkSessionAt = useCallback(
|
||||
(entryId: string, position: 'at' | 'before'): void => {
|
||||
setForking(true)
|
||||
window.api
|
||||
.forkSessionAt(entryId, position)
|
||||
.catch(dispatchError)
|
||||
.finally(() => setForking(false))
|
||||
},
|
||||
[dispatchError]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
state,
|
||||
@@ -607,7 +621,8 @@ export function useAgent(): UseAgent {
|
||||
setAutoCompaction,
|
||||
dismissCompactionNotice,
|
||||
navigateTree,
|
||||
forkSession
|
||||
forkSession,
|
||||
forkSessionAt
|
||||
}),
|
||||
[
|
||||
state,
|
||||
@@ -635,7 +650,8 @@ export function useAgent(): UseAgent {
|
||||
setAutoCompaction,
|
||||
dismissCompactionNotice,
|
||||
navigateTree,
|
||||
forkSession
|
||||
forkSession,
|
||||
forkSessionAt
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ declare global {
|
||||
minimize: () => Promise<void>
|
||||
toggleMaximize: () => Promise<void>
|
||||
close: () => Promise<void>
|
||||
/** 界面缩放:factor 为相对倍率(如 1.1 放大 / 0.9 缩小),限制 50%–200% */
|
||||
zoom: (factor: number) => void
|
||||
zoomReset: () => void
|
||||
onWindowState: (callback: (maximized: boolean) => void) => () => void
|
||||
send: (text: string, images?: SendImage[]) => Promise<void>
|
||||
steer: (text: string) => Promise<void>
|
||||
@@ -81,6 +84,7 @@ declare global {
|
||||
listForkPoints: () => Promise<ForkPoint[]>
|
||||
navigateTree: (entryId: string, options?: NavigateTreeOptions) => Promise<void>
|
||||
forkSession: () => Promise<void>
|
||||
forkSessionAt: (entryId: string, position: 'at' | 'before') => Promise<void>
|
||||
getSettings: () => Promise<AppSettings>
|
||||
getSystemPrompt: () => Promise<string>
|
||||
setThemeColor: (id: string) => Promise<void>
|
||||
|
||||
@@ -312,6 +312,15 @@ a:focus-visible .animated-underline::after {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
|
||||
.sidebar-collapsed-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ── shared keyframes ─────────────────────────────────── */
|
||||
|
||||
@keyframes shimmer-text {
|
||||
|
||||
@@ -209,7 +209,14 @@ export interface CommandDetail {
|
||||
export interface ResourceDetails {
|
||||
skills: SkillDetail[]
|
||||
commands: CommandDetail[]
|
||||
dirs: { globalSkills: string; projectSkills: string; globalCommands: string; projectCommands: string }
|
||||
dirs: {
|
||||
globalSkills: string
|
||||
globalAgentsSkills: string
|
||||
projectSkills: string
|
||||
projectAgentsSkills: string
|
||||
globalCommands: string
|
||||
projectCommands: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */
|
||||
|
||||
Reference in New Issue
Block a user