feat: 模型级联菜单、逐回合分叉、折叠态会话切换、划选引用与界面缩放
- 模型菜单改为级联悬浮结构:服务商独立成行,悬浮其上时模型列表浮出左侧;
This commit is contained in:
@@ -181,6 +181,13 @@ export function registerIpc(): AgentService {
|
|||||||
// 会话末尾分叉为新会话(pi fork,原会话保留)
|
// 会话末尾分叉为新会话(pi fork,原会话保留)
|
||||||
ipcMain.handle('session:fork', () => pass(() => service.forkSession()))
|
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())
|
ipcMain.handle('settings:get', () => service.getSettings())
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { app, dialog, shell } from 'electron'
|
|||||||
import { nativeTheme } from 'electron'
|
import { nativeTheme } from 'electron'
|
||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
import {
|
import {
|
||||||
createAgentSessionRuntime,
|
createAgentSessionRuntime,
|
||||||
createAgentSessionServices,
|
createAgentSessionServices,
|
||||||
createAgentSessionFromServices,
|
createAgentSessionFromServices,
|
||||||
estimateTokens,
|
estimateTokens,
|
||||||
getAgentDir,
|
getAgentDir,
|
||||||
loadSkills,
|
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type AgentSession,
|
type AgentSession,
|
||||||
type AgentSessionEvent,
|
type AgentSessionEvent,
|
||||||
@@ -476,7 +476,7 @@ export class AgentService {
|
|||||||
]
|
]
|
||||||
const byKey = new Map(breakdown.map((b) => [b.key, b]))
|
const byKey = new Map(breakdown.map((b) => [b.key, b]))
|
||||||
const systemTokens = Math.ceil(session.systemPrompt.length / 4)
|
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('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens)
|
||||||
byKey.get('skills')!.tokens = skillsTokens
|
byKey.get('skills')!.tokens = skillsTokens
|
||||||
|
|
||||||
@@ -522,15 +522,14 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化) */
|
/** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化)。
|
||||||
private estimateSkillsTokens(): number {
|
* 数据源与系统提示词同源(resource loader,覆盖 ~/.agents/skills 等全部默认位置) */
|
||||||
|
private estimateSkillsTokens(session: AgentSession): number {
|
||||||
if (this.skillsEstimate && this.skillsEstimate.cwd === this.cwd) return this.skillsEstimate.value
|
if (this.skillsEstimate && this.skillsEstimate.cwd === this.cwd) return this.skillsEstimate.value
|
||||||
let value = 0
|
let value = 0
|
||||||
try {
|
try {
|
||||||
const loaded = loadSkills({ cwd: this.cwd, agentDir: getAgentDir(), skillPaths: [], includeDefaults: true })
|
const loaded = session.resourceLoader.getSkills()
|
||||||
value = Math.ceil(
|
value = Math.ceil(loaded.skills.reduce((sum, s) => sum + s.name.length + s.description.length + 8, 0) / 4)
|
||||||
loaded.skills.reduce((sum, s) => sum + s.name.length + (s.description?.length ?? 0) + 8, 0) / 4
|
|
||||||
)
|
|
||||||
} catch {
|
} catch {
|
||||||
value = 0
|
value = 0
|
||||||
}
|
}
|
||||||
@@ -633,18 +632,11 @@ export class AgentService {
|
|||||||
|
|
||||||
async listChatResources(): Promise<ChatResources> {
|
async listChatResources(): Promise<ChatResources> {
|
||||||
const runtime = await this.getRuntime()
|
const runtime = await this.getRuntime()
|
||||||
let skills: ChatResources['skills'] = []
|
// 与系统提示词同源:resource loader 的技能发现(含 ~/.agents/skills 与工作区 .agents/skills)
|
||||||
try {
|
const skills = runtime.session.resourceLoader.getSkills().skills.map((s) => ({
|
||||||
const loaded = loadSkills({
|
name: s.name,
|
||||||
cwd: this.cwd,
|
description: s.description
|
||||||
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)
|
|
||||||
}
|
|
||||||
const commands = runtime.session.promptTemplates.map((t) => ({
|
const commands = runtime.session.promptTemplates.map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: t.description ?? '',
|
description: t.description ?? '',
|
||||||
@@ -653,30 +645,31 @@ export class AgentService {
|
|||||||
return { commands, skills }
|
return { commands, skills }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新) */
|
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。
|
||||||
|
* 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。 */
|
||||||
async listResourceDetails(): Promise<ResourceDetails> {
|
async listResourceDetails(): Promise<ResourceDetails> {
|
||||||
const agentDir = getAgentDir()
|
const agentDir = getAgentDir()
|
||||||
const dirs = {
|
const dirs = {
|
||||||
globalSkills: join(agentDir, 'skills'),
|
globalSkills: join(agentDir, 'skills'),
|
||||||
|
globalAgentsSkills: join(homedir(), '.agents', 'skills'),
|
||||||
projectSkills: join(this.cwd, '.pi', 'skills'),
|
projectSkills: join(this.cwd, '.pi', 'skills'),
|
||||||
|
projectAgentsSkills: join(this.cwd, '.agents', 'skills'),
|
||||||
globalCommands: join(agentDir, 'prompts'),
|
globalCommands: join(agentDir, 'prompts'),
|
||||||
projectCommands: join(this.cwd, '.pi', 'prompts')
|
projectCommands: join(this.cwd, '.pi', 'prompts')
|
||||||
}
|
}
|
||||||
let skills: SkillDetail[] = []
|
const skills: SkillDetail[] = []
|
||||||
try {
|
try {
|
||||||
const loaded = loadSkills({
|
const runtime = await this.getRuntime()
|
||||||
cwd: this.cwd,
|
for (const s of runtime.session.resourceLoader.getSkills().skills) {
|
||||||
agentDir,
|
skills.push({
|
||||||
skillPaths: [],
|
name: s.name,
|
||||||
includeDefaults: true
|
description: s.description,
|
||||||
})
|
filePath: s.filePath,
|
||||||
skills = loaded.skills.map((s) => ({
|
// 与 resource loader 的默认归类一致:无来源信息时按用户级
|
||||||
name: s.name,
|
scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user',
|
||||||
description: s.description ?? '',
|
disableModelInvocation: Boolean(s.disableModelInvocation)
|
||||||
filePath: s.filePath,
|
})
|
||||||
scope: s.sourceInfo?.scope === 'user' ? 'user' : 'project',
|
}
|
||||||
disableModelInvocation: Boolean(s.disableModelInvocation)
|
|
||||||
}))
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[agent-service] load skill details failed:', err)
|
console.error('[agent-service] load skill details failed:', err)
|
||||||
}
|
}
|
||||||
@@ -953,6 +946,12 @@ export class AgentService {
|
|||||||
const mr = runtime.session.modelRuntime
|
const mr = runtime.session.modelRuntime
|
||||||
const credentials = await mr.listCredentials()
|
const credentials = await mr.listCredentials()
|
||||||
const credSet = new Set(credentials.map((c) => `${c.providerId}:${c.type}`))
|
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
|
const providers = mr
|
||||||
.getProviders()
|
.getProviders()
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
@@ -960,7 +959,7 @@ export class AgentService {
|
|||||||
return {
|
return {
|
||||||
id: pid,
|
id: pid,
|
||||||
name: String(p.name ?? 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`),
|
isOAuth: credSet.has(`${pid}:oauth`),
|
||||||
models: mr
|
models: mr
|
||||||
.getModels(pid)
|
.getModels(pid)
|
||||||
@@ -1076,7 +1075,17 @@ export class AgentService {
|
|||||||
if (!leafId) {
|
if (!leafId) {
|
||||||
throw new Error('会话还没有可分叉的内容')
|
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
|
if (result.cancelled) return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer, webFrame } from 'electron'
|
||||||
import type {
|
import type {
|
||||||
AgentUiEvent,
|
AgentUiEvent,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
@@ -28,6 +28,15 @@ const api = {
|
|||||||
minimize: (): Promise<void> => ipcRenderer.invoke('win:minimize'),
|
minimize: (): Promise<void> => ipcRenderer.invoke('win:minimize'),
|
||||||
toggleMaximize: (): Promise<void> => ipcRenderer.invoke('win:toggleMaximize'),
|
toggleMaximize: (): Promise<void> => ipcRenderer.invoke('win:toggleMaximize'),
|
||||||
close: (): Promise<void> => ipcRenderer.invoke('win:close'),
|
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) => {
|
onWindowState: (callback: (maximized: boolean) => void): (() => void) => {
|
||||||
const listener = (_e: Electron.IpcRendererEvent, maximized: boolean): void => callback(maximized)
|
const listener = (_e: Electron.IpcRendererEvent, maximized: boolean): void => callback(maximized)
|
||||||
ipcRenderer.on('win:state', listener)
|
ipcRenderer.on('win:state', listener)
|
||||||
@@ -78,6 +87,8 @@ const api = {
|
|||||||
navigateTree: (entryId: string, options?: NavigateTreeOptions): Promise<void> =>
|
navigateTree: (entryId: string, options?: NavigateTreeOptions): Promise<void> =>
|
||||||
ipcRenderer.invoke('session:navigate', entryId, options),
|
ipcRenderer.invoke('session:navigate', entryId, options),
|
||||||
forkSession: (): Promise<void> => ipcRenderer.invoke('session:fork'),
|
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'),
|
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
|
||||||
getSystemPrompt: (): Promise<string> => ipcRenderer.invoke('settings:getSystemPrompt'),
|
getSystemPrompt: (): Promise<string> => ipcRenderer.invoke('settings:getSystemPrompt'),
|
||||||
setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id),
|
setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id),
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ export default function App(): JSX.Element {
|
|||||||
setAutoCompaction,
|
setAutoCompaction,
|
||||||
dismissCompactionNotice,
|
dismissCompactionNotice,
|
||||||
navigateTree,
|
navigateTree,
|
||||||
forkSession
|
forkSession,
|
||||||
|
forkSessionAt
|
||||||
} = useAgent()
|
} = useAgent()
|
||||||
const prefillRef = useRef<string | null>(null)
|
const prefillRef = useRef<string | null>(null)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
@@ -146,7 +147,7 @@ export default function App(): JSX.Element {
|
|||||||
return stopWatching
|
return stopWatching
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Esc 关闭弹窗
|
// 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Ctrl+= / Ctrl+- 界面缩放(Ctrl+0 复位)/ Esc 关闭弹窗
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
||||||
@@ -158,6 +159,15 @@ export default function App(): JSX.Element {
|
|||||||
} else if (e.ctrlKey && e.key === ',') {
|
} else if (e.ctrlKey && e.key === ',') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSettingsOpen(true)
|
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') {
|
} else if (e.key === 'Escape') {
|
||||||
setSettingsOpen(false)
|
setSettingsOpen(false)
|
||||||
setSearchOpen(false)
|
setSearchOpen(false)
|
||||||
@@ -171,10 +181,12 @@ export default function App(): JSX.Element {
|
|||||||
prefillRef.current = text
|
prefillRef.current = text
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选区操作(解释/改进/精简/自定义指令):把指令 + 引用发给 Agent
|
// 划选「引用」:选中文本以 blockquote 形式注入输入框开头(保留已有草稿)
|
||||||
const handleApplySelection = (instruction: string, selection: string): void => {
|
const [pendingQuote, setPendingQuote] = useState<string | null>(null)
|
||||||
send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`)
|
const handleQuoteSelection = useCallback((selection: string) => {
|
||||||
}
|
setPendingQuote(`> ${selection}\n\n`)
|
||||||
|
}, [])
|
||||||
|
const consumePendingQuote = useCallback(() => setPendingQuote(null), [])
|
||||||
|
|
||||||
// 回溯入口(稳定引用:MessageItem memo 依赖它,内联箭头每次渲染都会失效 memo)
|
// 回溯入口(稳定引用:MessageItem memo 依赖它,内联箭头每次渲染都会失效 memo)
|
||||||
const handleRewind = useCallback(
|
const handleRewind = useCallback(
|
||||||
@@ -261,8 +273,9 @@ export default function App(): JSX.Element {
|
|||||||
onAbortCompaction={abortCompaction}
|
onAbortCompaction={abortCompaction}
|
||||||
onRewind={handleRewind}
|
onRewind={handleRewind}
|
||||||
onFork={forkSession}
|
onFork={forkSession}
|
||||||
|
onForkAt={(entryId) => forkSessionAt(entryId, 'before')}
|
||||||
onPickSuggestion={handlePickSuggestion}
|
onPickSuggestion={handlePickSuggestion}
|
||||||
onApplySelection={handleApplySelection}
|
onQuoteSelection={handleQuoteSelection}
|
||||||
/>
|
/>
|
||||||
<BottomSlot
|
<BottomSlot
|
||||||
approvals={state.approvals}
|
approvals={state.approvals}
|
||||||
@@ -292,6 +305,8 @@ export default function App(): JSX.Element {
|
|||||||
onAbortCompaction={abortCompaction}
|
onAbortCompaction={abortCompaction}
|
||||||
onToggleAutoCompaction={setAutoCompaction}
|
onToggleAutoCompaction={setAutoCompaction}
|
||||||
prefillRef={prefillRef}
|
prefillRef={prefillRef}
|
||||||
|
pendingQuote={pendingQuote}
|
||||||
|
onQuoteConsumed={consumePendingQuote}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } 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 { ApprovalRequest, ForkPoint } from '@shared/types'
|
||||||
import type { CompactionNotice, ToolState, UiMessage } from '../hooks/useAgent'
|
import type { CompactionNotice, ToolState, UiMessage } from '../hooks/useAgent'
|
||||||
import { formatTokens } from '../lib/utils'
|
import { formatTokens } from '../lib/utils'
|
||||||
@@ -66,7 +66,10 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
|
|||||||
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
|
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
|
||||||
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序),
|
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序),
|
||||||
* startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。
|
* startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。
|
||||||
* animate=false(正在补齐历史)时挂载的消息不播入场动画。 */
|
* animate=false(正在补齐历史)时挂载的消息不播入场动画。
|
||||||
|
* 分叉入口:onFork(会话末尾)只给延伸到列表末尾的最后一个回合;
|
||||||
|
* 其余回合用「本回合之后第一条用户消息」的 entryId 走 onForkAt(entryId,'before')
|
||||||
|
* ——新会话包含到本回合答案为止。 */
|
||||||
function renderTurns(
|
function renderTurns(
|
||||||
messages: UiMessage[],
|
messages: UiMessage[],
|
||||||
tools: Record<string, ToolState>,
|
tools: Record<string, ToolState>,
|
||||||
@@ -78,11 +81,25 @@ function renderTurns(
|
|||||||
rewinding: boolean,
|
rewinding: boolean,
|
||||||
onRewind: (entryId: string, summarize: boolean) => void,
|
onRewind: (entryId: string, summarize: boolean) => void,
|
||||||
startUserIdx = 0,
|
startUserIdx = 0,
|
||||||
animate = true
|
animate = true,
|
||||||
|
forking = false,
|
||||||
|
onFork?: () => void,
|
||||||
|
onForkAt?: (entryId: string) => void
|
||||||
): ReactNode[] {
|
): ReactNode[] {
|
||||||
const items: ReactNode[] = []
|
const items: ReactNode[] = []
|
||||||
let i = 0
|
let i = 0
|
||||||
let userMessageIdx = startUserIdx
|
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) {
|
while (i < messages.length) {
|
||||||
const message = messages[i]
|
const message = messages[i]
|
||||||
if (message.role === 'assistant') {
|
if (message.role === 'assistant') {
|
||||||
@@ -92,6 +109,12 @@ function renderTurns(
|
|||||||
// assistant 组是「活跃回合」。透传给历史回合会让它们误显示
|
// assistant 组是「活跃回合」。透传给历史回合会让它们误显示
|
||||||
// 「已工作/处理中」并保持过程区展开。
|
// 「已工作/处理中」并保持过程区展开。
|
||||||
const isLive = j === messages.length
|
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(
|
items.push(
|
||||||
<AssistantTurn
|
<AssistantTurn
|
||||||
key={`${message.key}-turn`}
|
key={`${message.key}-turn`}
|
||||||
@@ -101,6 +124,10 @@ function renderTurns(
|
|||||||
turnActive={turnActive && isLive}
|
turnActive={turnActive && isLive}
|
||||||
pendingApprovals={pendingApprovals}
|
pendingApprovals={pendingApprovals}
|
||||||
scrollPinnedRef={pinnedRef}
|
scrollPinnedRef={pinnedRef}
|
||||||
|
forking={forking}
|
||||||
|
onFork={isLive ? onFork : undefined}
|
||||||
|
forkEntryId={turnForkEntryId}
|
||||||
|
onForkAt={onForkAt}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
i = j
|
i = j
|
||||||
@@ -154,8 +181,9 @@ export function ChatView({
|
|||||||
onAbortCompaction,
|
onAbortCompaction,
|
||||||
onRewind,
|
onRewind,
|
||||||
onFork,
|
onFork,
|
||||||
|
onForkAt,
|
||||||
onPickSuggestion,
|
onPickSuggestion,
|
||||||
onApplySelection
|
onQuoteSelection
|
||||||
}: {
|
}: {
|
||||||
messages: UiMessage[]
|
messages: UiMessage[]
|
||||||
tools: Record<string, ToolState>
|
tools: Record<string, ToolState>
|
||||||
@@ -180,8 +208,10 @@ export function ChatView({
|
|||||||
onRewind: (entryId: string, summarize: boolean) => void
|
onRewind: (entryId: string, summarize: boolean) => void
|
||||||
/** 在会话末尾分叉出新会话(原会话保留) */
|
/** 在会话末尾分叉出新会话(原会话保留) */
|
||||||
onFork: () => void
|
onFork: () => void
|
||||||
|
/** 从指定锚点分叉出新会话(含该锚点前的整个回合) */
|
||||||
|
onForkAt: (entryId: string) => void
|
||||||
onPickSuggestion: (text: string) => void
|
onPickSuggestion: (text: string) => void
|
||||||
onApplySelection: (instruction: string, selection: string) => void
|
onQuoteSelection: (selection: string) => void
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const pinnedRef = useRef(true)
|
const pinnedRef = useRef(true)
|
||||||
@@ -260,6 +290,8 @@ export function ChatView({
|
|||||||
}, [compactionNotice, onDismissCompactionNotice])
|
}, [compactionNotice, onDismissCompactionNotice])
|
||||||
|
|
||||||
const hasContent = messages.length > 0
|
const hasContent = messages.length > 0
|
||||||
|
// 分叉入口挂在每个回合的复制按钮旁:回合进行中/压缩/回溯时收起
|
||||||
|
const canFork = !turnActive && !streaming && !compacting && !navigating
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
@@ -378,26 +410,10 @@ export function ChatView({
|
|||||||
navigating,
|
navigating,
|
||||||
onRewind,
|
onRewind,
|
||||||
countUserMessages(messages, 0, hidden),
|
countUserMessages(messages, 0, hidden),
|
||||||
hidden === 0
|
hidden === 0,
|
||||||
)}
|
forking,
|
||||||
{/* 会话结尾:从此处分叉为新会话(pi fork,原会话保留)。回合进行中不打断,收起入口 */}
|
canFork ? onFork : undefined,
|
||||||
{hasContent && !turnActive && !streaming && !compacting && !navigating && (
|
canFork ? onForkAt : undefined
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -407,8 +423,8 @@ export function ChatView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 划选消息文本后浮出的操作条 */}
|
{/* 划选消息文本后浮出的操作条:引用进输入框 / 复制 */}
|
||||||
<SelectionActions containerRef={scrollRef} onApply={onApplySelection} />
|
<SelectionActions containerRef={scrollRef} onQuote={onQuoteSelection} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import type { JSX, RefObject } from 'react'
|
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 {
|
import type {
|
||||||
AttachmentInfo,
|
AttachmentInfo,
|
||||||
ChatResources,
|
ChatResources,
|
||||||
@@ -123,7 +123,9 @@ export function Composer({
|
|||||||
onCompact,
|
onCompact,
|
||||||
onAbortCompaction,
|
onAbortCompaction,
|
||||||
onToggleAutoCompaction,
|
onToggleAutoCompaction,
|
||||||
prefillRef
|
prefillRef,
|
||||||
|
pendingQuote = null,
|
||||||
|
onQuoteConsumed
|
||||||
}: {
|
}: {
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
/** 排队消息(steering 先于 followUp 送达) */
|
/** 排队消息(steering 先于 followUp 送达) */
|
||||||
@@ -157,6 +159,10 @@ export function Composer({
|
|||||||
onToggleAutoCompaction: (enabled: boolean) => void
|
onToggleAutoCompaction: (enabled: boolean) => void
|
||||||
/** 供空状态建议词点击后填入 */
|
/** 供空状态建议词点击后填入 */
|
||||||
prefillRef: RefObject<string | null>
|
prefillRef: RefObject<string | null>
|
||||||
|
/** 划选「引用」注入的内容(blockquote);注入到输入框开头,保留已有草稿 */
|
||||||
|
pendingQuote?: string | null
|
||||||
|
/** 注入完成后由 Composer 通知上层清空 */
|
||||||
|
onQuoteConsumed?: () => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [modelOpen, setModelOpen] = useState(false)
|
const [modelOpen, setModelOpen] = useState(false)
|
||||||
@@ -437,6 +443,19 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}, [prefillRef])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
saveDraft(value)
|
saveDraft(value)
|
||||||
@@ -467,6 +486,9 @@ export function Composer({
|
|||||||
const permBtnRef = useRef<HTMLButtonElement>(null)
|
const permBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
const usageBtnRef = useRef<HTMLButtonElement>(null)
|
const usageBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
const thinkingBtnRef = 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 => {
|
const anchorOf = (el: HTMLElement | null): { top: number; left: number; right: number } | null => {
|
||||||
if (!el) return null
|
if (!el) return null
|
||||||
@@ -480,6 +502,7 @@ export function Composer({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setModelPos(anchorOf(modelBtnRef.current))
|
setModelPos(anchorOf(modelBtnRef.current))
|
||||||
|
setHoveredProvider(null)
|
||||||
setModelOpen((open) => {
|
setModelOpen((open) => {
|
||||||
if (!open && !catalogLoadedRef.current) {
|
if (!open && !catalogLoadedRef.current) {
|
||||||
catalogLoadedRef.current = true
|
catalogLoadedRef.current = true
|
||||||
@@ -650,6 +673,28 @@ export function Composer({
|
|||||||
|
|
||||||
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
|
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
|
||||||
const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? []
|
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 hasContentAttachment = attachments.some((a) => (a.data && a.mimeType.startsWith('image/')) || a.text)
|
||||||
const canSend = !disabled && (value.trim().length > 0 || hasContentAttachment)
|
const canSend = !disabled && (value.trim().length > 0 || hasContentAttachment)
|
||||||
|
|
||||||
@@ -845,7 +890,7 @@ export function Composer({
|
|||||||
aria-label="添加附件"
|
aria-label="添加附件"
|
||||||
onClick={addAttachments}
|
onClick={addAttachments}
|
||||||
disabled={disabled || picking}
|
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"
|
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">
|
<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-expanded={usageOpen}
|
||||||
aria-label="上下文用量"
|
aria-label="上下文用量"
|
||||||
onClick={toggleUsageCard}
|
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"
|
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' : ''} />
|
<LuGauge size={13} className={compacting ? 'animate-pulse' : ''} />
|
||||||
@@ -913,7 +958,7 @@ export function Composer({
|
|||||||
aria-label={`权限模式:${permMeta.label}`}
|
aria-label={`权限模式:${permMeta.label}`}
|
||||||
onClick={togglePermMenu}
|
onClick={togglePermMenu}
|
||||||
disabled={disabled}
|
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 ${
|
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'
|
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 && (
|
{modelOpen && modelPos && (
|
||||||
<ComposerMenu anchor={modelPos} align="right" onClose={() => setModelOpen(false)}>
|
<ComposerMenu anchor={modelPos} align="right" onClose={() => setModelOpen(false)}>
|
||||||
<div className="w-72">
|
<div className="w-60 p-1.5" data-model-menu>
|
||||||
<div className="max-h-80 overflow-y-auto p-1.5">
|
{catalogLoading && <div className="px-2.5 py-3 text-[12px] text-ink-3">加载模型中…</div>}
|
||||||
{catalogLoading && <div className="px-2.5 py-3 text-[12px] text-ink-3">加载模型中…</div>}
|
{!catalogLoading && providers.length === 0 && (
|
||||||
{!catalogLoading && providers.length === 0 && (
|
<div className="px-2.5 py-3 text-[12px] text-ink-3">没有可用模型,请先在设置中配置密钥</div>
|
||||||
<div className="px-2.5 py-3 text-[12px] text-ink-3">没有可用模型,请先在设置中配置密钥</div>
|
)}
|
||||||
)}
|
{providers.map((p) => {
|
||||||
{providers.map((provider) => (
|
const inUse = model?.provider === p.id
|
||||||
<div key={provider.id}>
|
const hovered = hoveredProvider?.id === p.id
|
||||||
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-medium uppercase tracking-wide text-ink-3">
|
return (
|
||||||
{provider.name}
|
<div
|
||||||
</div>
|
key={p.id}
|
||||||
{provider.models.map((m) => {
|
onMouseEnter={(e) => enterProvider(p.id, e.currentTarget)}
|
||||||
const selected = model?.provider === provider.id && model?.id === m.id
|
onMouseLeave={leaveProvider}
|
||||||
return (
|
>
|
||||||
<button
|
<button
|
||||||
key={`${provider.id}/${m.id}`}
|
type="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 ${
|
||||||
onClick={() => {
|
hovered ? 'bg-hover' : ''
|
||||||
onSetModel(provider.id, m.id)
|
}`}
|
||||||
setModelOpen(false)
|
>
|
||||||
}}
|
<span className="min-w-0 flex-1 truncate text-[13px] text-ink">{p.name}</span>
|
||||||
className="flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors hover:bg-hover"
|
{inUse && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
|
||||||
>
|
<LuChevronRight size={12} className="shrink-0 text-ink-3" />
|
||||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink">{m.name}</span>
|
</button>
|
||||||
{selected && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
</div>
|
})}
|
||||||
<div className="border-t border-line p-1.5">
|
<div className="mt-1 border-t border-line pt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1144,6 +1187,50 @@ export function Composer({
|
|||||||
</div>
|
</div>
|
||||||
</ComposerMenu>
|
</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>
|
</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 ReactMarkdown, { type Components } from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import remarkCjkFriendly from 'remark-cjk-friendly'
|
import remarkCjkFriendly from 'remark-cjk-friendly'
|
||||||
@@ -98,18 +98,86 @@ const LANG_ALIAS: Record<string, string> = {
|
|||||||
plaintext: ''
|
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 = {
|
const mdComponents: Components = {
|
||||||
p({ children }) {
|
p({ children }) {
|
||||||
return <p className="my-4 leading-normal">{children}</p>
|
return <p className="my-4 leading-normal">{children}</p>
|
||||||
},
|
},
|
||||||
|
/** h1/h2 带强调条(对齐参考渲染),h3 起按层级递减,保证标题有可辨识的层级 */
|
||||||
h1({ children }) {
|
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 }) {
|
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 }) {
|
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 }) {
|
h4({ children }) {
|
||||||
return <h6 className="mb-1.5 mt-4 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h6>
|
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 {
|
function MarkdownBase({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode {
|
||||||
|
const safeText = useMemo(() => neutralizeSingleTilde(text), [text])
|
||||||
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
|
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
|
||||||
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
|
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
|
||||||
const { blockMatches } = useLLMOutput({
|
const { blockMatches } = useLLMOutput({
|
||||||
llmOutput: text,
|
llmOutput: safeText,
|
||||||
isStreamFinished: !streaming,
|
isStreamFinished: !streaming,
|
||||||
fallbackBlock: markdownFallback,
|
fallbackBlock: markdownFallback,
|
||||||
blocks: [codeBlock]
|
blocks: [codeBlock]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { StreamingText } from './StreamingText'
|
|||||||
import { Thinking, thinkingRows } from './Thinking'
|
import { Thinking, thinkingRows } from './Thinking'
|
||||||
import { ToolChips, type ToolCallView } from './ToolChips'
|
import { ToolChips, type ToolCallView } from './ToolChips'
|
||||||
import { useEntrance } from '../hooks/useEntrance'
|
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'
|
import { formatTokens } from '../lib/utils'
|
||||||
|
|
||||||
interface ContentBlock {
|
interface ContentBlock {
|
||||||
@@ -129,7 +129,11 @@ function AssistantTurnBase({
|
|||||||
streaming,
|
streaming,
|
||||||
turnActive,
|
turnActive,
|
||||||
pendingApprovals,
|
pendingApprovals,
|
||||||
scrollPinnedRef
|
scrollPinnedRef,
|
||||||
|
forking = false,
|
||||||
|
onFork,
|
||||||
|
forkEntryId,
|
||||||
|
onForkAt
|
||||||
}: {
|
}: {
|
||||||
messages: UiMessage[]
|
messages: UiMessage[]
|
||||||
tools: Record<string, ToolState>
|
tools: Record<string, ToolState>
|
||||||
@@ -139,6 +143,14 @@ function AssistantTurnBase({
|
|||||||
/** 聊天滚动容器的吸底标记(ChatView 持有):回合结束时只在用户位于底部
|
/** 聊天滚动容器的吸底标记(ChatView 持有):回合结束时只在用户位于底部
|
||||||
* 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */
|
* 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */
|
||||||
scrollPinnedRef?: { current: boolean }
|
scrollPinnedRef?: { current: boolean }
|
||||||
|
/** 分叉进行中 */
|
||||||
|
forking?: boolean
|
||||||
|
/** 会话末尾分叉(只有最后一个回合透传) */
|
||||||
|
onFork?: () => void
|
||||||
|
/** 历史回合的分叉锚点:本回合之后第一条用户消息的 entryId
|
||||||
|
* (配合 onForkAt 以 'before' 分叉,新会话包含到本回合答案为止) */
|
||||||
|
forkEntryId?: string
|
||||||
|
onForkAt?: (entryId: string) => void
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
// ---- 第一遍:块级描述符(只做类型判断与字符串引用收集,不拆行、不建 JSX) ----
|
// ---- 第一遍:块级描述符(只做类型判断与字符串引用收集,不拆行、不建 JSX) ----
|
||||||
// 长会话渐进补齐时每个批次会挂载几十个历史回合,若在首遍就执行
|
// 长会话渐进补齐时每个批次会挂载几十个历史回合,若在首遍就执行
|
||||||
@@ -424,10 +436,31 @@ function AssistantTurnBase({
|
|||||||
{/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */}
|
{/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */}
|
||||||
{turnActive && !stream && <Loading label="处理中" variant="Dots" />}
|
{turnActive && !stream && <Loading label="处理中" variant="Dots" />}
|
||||||
|
|
||||||
{/* 结论底部的复制操作:常驻不闪现 */}
|
{/* 结论底部的操作行:复制 + 会话分叉。末回合走会话末尾分叉(onFork),
|
||||||
{(hasAnswer || answerText.trim().length > 0) && (
|
历史回合以「本回合之后第一条用户消息」为锚点(forkEntryId + onForkAt),
|
||||||
<div className="mt-1.5 flex">
|
新会话都包含到本回合答案为止。常驻不闪现。 */}
|
||||||
|
{(hasAnswer || answerText.trim().length > 0 || onFork || (forkEntryId && onForkAt)) && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-0.5">
|
||||||
<CopyAnswerButton text={answerText} />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -443,7 +476,7 @@ function RewindMenu({ entryId, onRewind }: { entryId: string; onRewind?: (entryI
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="回溯到此处"
|
aria-label="回溯到此处"
|
||||||
title="回溯到这条消息(会话树上此节点之后的分叉将被离开)"
|
title="回溯到这条消息"
|
||||||
onClick={() => setOpen((v) => !v)}
|
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"
|
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>
|
next: ComponentProps<typeof AssistantTurnBase>
|
||||||
): boolean {
|
): boolean {
|
||||||
if (prev.streaming !== next.streaming || prev.turnActive !== next.turnActive) return false
|
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 (!sameApprovals(prev.pendingApprovals, next.pendingApprovals)) return false
|
||||||
if (!sameMessages(prev.messages, next.messages)) return false
|
if (!sameMessages(prev.messages, next.messages)) return false
|
||||||
if (prev.tools !== next.tools && referencedToolChanged(prev.tools, next.tools, 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 { 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 内划选文本后,于选区上方浮出胶囊工具条:
|
* 在 containerRef 内划选文本后,于选区上方浮出胶囊工具条:
|
||||||
* 「描述修改」输入框 + 解释/改进,更多按钮展开 精简/语气/语法;
|
* 「引用」把选中文本注入输入框开头(blockquote 形式),「复制」写入剪贴板;
|
||||||
* 输入内容后出现发送钮。预设动作与自定义指令都经 onApply 交给上层。 */
|
* Esc / 滚动 / 点击后收起。 */
|
||||||
|
|
||||||
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} /> }
|
|
||||||
]
|
|
||||||
|
|
||||||
const pillButton =
|
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'
|
'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({
|
export function SelectionActions({
|
||||||
containerRef,
|
containerRef,
|
||||||
onApply
|
onQuote
|
||||||
}: {
|
}: {
|
||||||
containerRef: RefObject<HTMLElement | null>
|
containerRef: RefObject<HTMLElement | null>
|
||||||
onApply: (instruction: string, selection: string) => void
|
onQuote: (selection: string) => void
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
|
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
|
||||||
const [selection, setSelection] = useState('')
|
const [selection, setSelection] = useState('')
|
||||||
const [instruction, setInstruction] = useState('')
|
const [copied, setCopied] = useState(false)
|
||||||
const [moreOpen, setMoreOpen] = useState(false)
|
|
||||||
const pillRef = useRef<HTMLDivElement>(null)
|
const pillRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const dismiss = (): void => {
|
const dismiss = (): void => {
|
||||||
setAnchor(null)
|
setAnchor(null)
|
||||||
setSelection('')
|
setSelection('')
|
||||||
setInstruction('')
|
setCopied(false)
|
||||||
setMoreOpen(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起)
|
// 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起)
|
||||||
@@ -94,19 +81,19 @@ export function SelectionActions({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [anchor])
|
}, [anchor])
|
||||||
|
|
||||||
const apply = (label: string): void => {
|
const quote = (): void => {
|
||||||
if (!selection) return
|
if (!selection) return
|
||||||
onApply(label, selection)
|
onQuote(selection)
|
||||||
dismiss()
|
dismiss()
|
||||||
window.getSelection()?.removeAllRanges()
|
window.getSelection()?.removeAllRanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitInstruction = (): void => {
|
const copy = (): void => {
|
||||||
const text = instruction.trim()
|
if (!selection) return
|
||||||
if (!text || !selection) return
|
void navigator.clipboard.writeText(selection).then(() => {
|
||||||
onApply(`${text}(针对以下选中内容)`, selection)
|
setCopied(true)
|
||||||
dismiss()
|
window.setTimeout(dismiss, 900)
|
||||||
window.getSelection()?.removeAllRanges()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = anchor !== null
|
const visible = anchor !== null
|
||||||
@@ -122,81 +109,20 @@ export function SelectionActions({
|
|||||||
pointerEvents: visible ? undefined : 'none'
|
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 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}>
|
||||||
<div className="flex w-fit shrink-0 items-center justify-center gap-0.5 overflow-hidden" style={{ maxWidth: 152 }}>
|
<LuQuote size={14} strokeWidth={1.8} />
|
||||||
<form
|
引用
|
||||||
className="flex h-7 shrink-0 items-center"
|
</button>
|
||||||
style={{ width: 152 }}
|
<span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
|
||||||
onSubmit={(e) => {
|
<button type="button" className={pillButton} onClick={copy}>
|
||||||
e.preventDefault()
|
{copied ? (
|
||||||
submitInstruction()
|
<LuCheck size={14} strokeWidth={2} className="text-accent-ink" />
|
||||||
}}
|
) : (
|
||||||
>
|
<LuCopy size={14} strokeWidth={1.8} />
|
||||||
<input
|
)}
|
||||||
value={instruction}
|
{copied ? '已复制' : '复制'}
|
||||||
onChange={(e) => setInstruction(e.target.value)}
|
</button>
|
||||||
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}
|
|
||||||
</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>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|||||||
import type { JSX, RefObject } from 'react'
|
import type { JSX, RefObject } from 'react'
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import {
|
import {
|
||||||
LuBot,
|
|
||||||
LuBrain,
|
|
||||||
LuCheck,
|
LuCheck,
|
||||||
LuCpu,
|
LuCpu,
|
||||||
LuFolderOpen,
|
LuFolderOpen,
|
||||||
@@ -12,29 +10,17 @@ import {
|
|||||||
LuPlus,
|
LuPlus,
|
||||||
LuPuzzle,
|
LuPuzzle,
|
||||||
LuSearch,
|
LuSearch,
|
||||||
LuServer,
|
|
||||||
LuSettings2,
|
LuSettings2,
|
||||||
LuSparkles,
|
LuSparkles,
|
||||||
LuTerminal,
|
LuTerminal,
|
||||||
LuTextQuote,
|
LuTextQuote,
|
||||||
LuWebhook,
|
|
||||||
LuX
|
LuX
|
||||||
} from 'react-icons/lu'
|
} from 'react-icons/lu'
|
||||||
import type { IconType } from 'react-icons'
|
import type { IconType } from 'react-icons'
|
||||||
import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState, ThemeMode } from '@shared/types'
|
import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState, ThemeMode } from '@shared/types'
|
||||||
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
|
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
|
||||||
|
|
||||||
type Tab =
|
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands'
|
||||||
| 'general'
|
|
||||||
| 'model'
|
|
||||||
| 'prompt'
|
|
||||||
| 'memory'
|
|
||||||
| 'subagent'
|
|
||||||
| 'plugins'
|
|
||||||
| 'mcp'
|
|
||||||
| 'skills'
|
|
||||||
| 'commands'
|
|
||||||
| 'hooks'
|
|
||||||
|
|
||||||
/** 左侧分组导航(参考 ZCode 设置页:基础设置 / Agent 能力) */
|
/** 左侧分组导航(参考 ZCode 设置页:基础设置 / Agent 能力) */
|
||||||
const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: IconType }> }> = [
|
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 能力',
|
group: 'Agent 能力',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'memory', label: '记忆', icon: LuBrain },
|
|
||||||
{ id: 'subagent', label: '子智能体', icon: LuBot },
|
|
||||||
{ id: 'plugins', label: '插件', icon: LuPuzzle },
|
{ id: 'plugins', label: '插件', icon: LuPuzzle },
|
||||||
{ id: 'mcp', label: 'MCP 服务器', icon: LuServer },
|
|
||||||
{ id: 'skills', label: '技能', icon: LuSparkles },
|
{ id: 'skills', label: '技能', icon: LuSparkles },
|
||||||
{ id: 'commands', label: '命令', icon: LuTerminal },
|
{ id: 'commands', label: '命令', icon: LuTerminal }
|
||||||
{ id: 'hooks', label: '钩子', icon: LuWebhook }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const PLACEHOLDER: Record<string, string> = {
|
const PLACEHOLDER: Record<string, string> = {
|
||||||
memory: '让 Agent 在会话之间记住偏好与事实,越用越懂你的店铺。',
|
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。'
|
||||||
subagent: '为主流程配置可调度的子智能体,分头完成检索、写作与校验。',
|
|
||||||
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。',
|
|
||||||
mcp: '接入 MCP 服务器,把外部工具与数据源挂载到 Agent。',
|
|
||||||
hooks: '在会话生命周期的关键节点上挂载自动化钩子。'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_META: Record<Tab, { title: string; desc: string }> = {
|
const TAB_META: Record<Tab, { title: string; desc: string }> = {
|
||||||
general: { title: '常规', desc: '主题色等外观偏好。' },
|
general: { title: '常规', desc: '主题色等外观偏好。' },
|
||||||
model: { title: '模型设置', desc: '' },
|
model: { title: '模型设置', desc: '' },
|
||||||
prompt: { title: '系统提示词', desc: '当前生效的 pi 原生系统提示词(含技能清单注入),只读。' },
|
prompt: { title: '系统提示词', desc: '当前生效的 pi 原生系统提示词(含技能清单注入),只读。' },
|
||||||
memory: { title: '记忆', desc: PLACEHOLDER.memory },
|
|
||||||
subagent: { title: '子智能体', desc: PLACEHOLDER.subagent },
|
|
||||||
plugins: { title: '插件', desc: PLACEHOLDER.plugins },
|
plugins: { title: '插件', desc: PLACEHOLDER.plugins },
|
||||||
mcp: { title: 'MCP 服务器', desc: PLACEHOLDER.mcp },
|
|
||||||
skills: { title: '技能', desc: 'SKILL.md 技能包,Agent 自动按需加载。' },
|
skills: { title: '技能', desc: 'SKILL.md 技能包,Agent 自动按需加载。' },
|
||||||
commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' },
|
commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' }
|
||||||
hooks: { title: '钩子', desc: PLACEHOLDER.hooks }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.Element {
|
function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.Element {
|
||||||
@@ -131,50 +105,60 @@ function ResourcePanel({
|
|||||||
}: {
|
}: {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
items: ResourceItem[]
|
items: ResourceItem[]
|
||||||
dirs: { user: string; project: string }
|
/** 当前作用域的资源目录(可多个,如技能的 ~/.pi/agent/skills 与 ~/.agents/skills) */
|
||||||
|
dirs: { user: string[]; project: string[] }
|
||||||
emptyTitle: string
|
emptyTitle: string
|
||||||
hints: { user: string; project: string }
|
/** 与 dirs 一一对应的短标签(展示用;完整路径放 tooltip) */
|
||||||
|
hints: { user: string[]; project: string[] }
|
||||||
onOpen: (path: string) => void
|
onOpen: (path: string) => void
|
||||||
onOpenDir: (path: string) => void
|
onOpenDir: (path: string) => void
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const [scope, setScope] = useState<'user' | 'project'>('user')
|
const [scope, setScope] = useState<'user' | 'project'>('user')
|
||||||
const scoped = items.filter((i) => i.scope === scope)
|
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 (
|
return (
|
||||||
<div className="flex min-h-0 flex-col">
|
<div className="flex min-h-0 flex-col">
|
||||||
<div className="flex items-center gap-2 pb-3">
|
<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) => (
|
{(['user', 'project'] as const).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setScope(s)}
|
onClick={() => setScope(s)}
|
||||||
className={`h-6 rounded-[6px] px-2.5 text-[11.5px] transition-colors ${
|
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' ? '全局' : '项目'}
|
{s === 'user' ? '全局' : '项目'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{dir && (
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
<button
|
{scopedDirs.map((d, i) => (
|
||||||
type="button"
|
<button
|
||||||
title={dir}
|
key={d || i}
|
||||||
onClick={() => onOpenDir(dir)}
|
type="button"
|
||||||
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)}
|
||||||
<LuFolderOpen size={12} className="shrink-0" />
|
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"
|
||||||
打开目录
|
>
|
||||||
</button>
|
<LuFolderOpen size={12} className="shrink-0" />
|
||||||
)}
|
{scopedHints[i] ?? '打开目录'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!loading && scoped.length === 0 ? (
|
{!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="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-ink-2">{emptyTitle}</div>
|
<div className="text-[13px] font-medium text-zinc-600">{emptyTitle}</div>
|
||||||
<div className="text-[12px] text-ink-3">{hints[scope]}</div>
|
{scopedHints.map((h) => (
|
||||||
|
<div key={h} className="font-mono text-[11.5px] text-zinc-400">
|
||||||
|
{h}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -394,7 +378,7 @@ function ModelSettings({
|
|||||||
type="password"
|
type="password"
|
||||||
value={keyValue}
|
value={keyValue}
|
||||||
onChange={(e) => onKeyChange(e.target.value)}
|
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"
|
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
|
<button
|
||||||
@@ -910,11 +894,14 @@ export function SettingsModal({
|
|||||||
filePath: s.filePath
|
filePath: s.filePath
|
||||||
}))}
|
}))}
|
||||||
dirs={{
|
dirs={{
|
||||||
user: resources?.dirs.globalSkills ?? '',
|
user: [resources?.dirs.globalSkills ?? '', resources?.dirs.globalAgentsSkills ?? ''],
|
||||||
project: resources?.dirs.projectSkills ?? ''
|
project: [resources?.dirs.projectSkills ?? '', resources?.dirs.projectAgentsSkills ?? '']
|
||||||
}}
|
}}
|
||||||
emptyTitle="暂无技能"
|
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)}
|
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
|
||||||
onOpenDir={(path) => {
|
onOpenDir={(path) => {
|
||||||
window.api.openDirectory(path).catch((err: unknown) =>
|
window.api.openDirectory(path).catch((err: unknown) =>
|
||||||
@@ -936,11 +923,11 @@ export function SettingsModal({
|
|||||||
filePath: c.filePath
|
filePath: c.filePath
|
||||||
}))}
|
}))}
|
||||||
dirs={{
|
dirs={{
|
||||||
user: resources?.dirs.globalCommands ?? '',
|
user: [resources?.dirs.globalCommands ?? ''],
|
||||||
project: resources?.dirs.projectCommands ?? ''
|
project: [resources?.dirs.projectCommands ?? '']
|
||||||
}}
|
}}
|
||||||
emptyTitle="暂无自定义命令"
|
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)}
|
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
|
||||||
onOpenDir={(path) => {
|
onOpenDir={(path) => {
|
||||||
window.api.openDirectory(path).catch((err: unknown) =>
|
window.api.openDirectory(path).catch((err: unknown) =>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
|
|||||||
import {
|
import {
|
||||||
LuCheck,
|
LuCheck,
|
||||||
LuChevronDown,
|
LuChevronDown,
|
||||||
|
LuMessagesSquare,
|
||||||
LuPencil,
|
LuPencil,
|
||||||
LuPlus,
|
LuPlus,
|
||||||
LuSearch,
|
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({
|
export function SidebarNav({
|
||||||
sessionState,
|
sessionState,
|
||||||
sessions,
|
sessions,
|
||||||
@@ -122,6 +204,8 @@ export function SidebarNav({
|
|||||||
const [renameValue, setRenameValue] = useState('')
|
const [renameValue, setRenameValue] = useState('')
|
||||||
const [wsMenuOpen, setWsMenuOpen] = useState(false)
|
const [wsMenuOpen, setWsMenuOpen] = useState(false)
|
||||||
const [expandedWs, setExpandedWs] = useState<Set<string>>(new Set())
|
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 renameInputRef = useRef<HTMLInputElement>(null)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -139,6 +223,7 @@ export function SidebarNav({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (collapsed) setWsMenuOpen(false)
|
if (collapsed) setWsMenuOpen(false)
|
||||||
|
else setSessionSwitcher(null)
|
||||||
}, [collapsed])
|
}, [collapsed])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -340,68 +425,86 @@ export function SidebarNav({
|
|||||||
|
|
||||||
{/* 会话列表区 */}
|
{/* 会话列表区 */}
|
||||||
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
|
<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">
|
||||||
<button
|
<div className="sidebar-copy absolute inset-0">
|
||||||
type="button"
|
|
||||||
onClick={() => setChatsOpen((v) => !v)}
|
|
||||||
aria-expanded={chatsOpen}
|
|
||||||
className="absolute inset-0 flex items-center gap-1.5 px-2 text-[12.5px] font-medium text-ink-3"
|
|
||||||
>
|
|
||||||
<LuChevronDown
|
|
||||||
size={16}
|
|
||||||
strokeWidth={2}
|
|
||||||
className={`shrink-0 transition-transform duration-150 ${chatsOpen ? '' : '-rotate-90'}`}
|
|
||||||
/>
|
|
||||||
<span>会话</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 内联搜索:放大镜 ↔ 输入框宽度过渡 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="搜索会话"
|
|
||||||
aria-expanded={searchOpen}
|
|
||||||
onClick={() => setSearchOpen((v) => !v)}
|
|
||||||
className="absolute right-0 top-0 z-10 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color,transform] hover:bg-hover-2 hover:text-ink active:scale-[0.96]"
|
|
||||||
style={{ opacity: searchOpen ? 0 : 1, pointerEvents: searchOpen ? 'none' : undefined }}
|
|
||||||
>
|
|
||||||
<LuSearch size={16} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-0 z-20 flex h-8 items-center overflow-hidden rounded-[8px] bg-field text-ink-3 shadow-hairline transition-[width,opacity] focus-within:text-ink-2"
|
|
||||||
style={{ width: searchOpen ? '100%' : 28, opacity: searchOpen ? 1 : 0, pointerEvents: searchOpen ? undefined : 'none', transitionDuration: '180ms', transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}
|
|
||||||
>
|
|
||||||
<span className="ml-2 flex shrink-0 items-center justify-center">
|
|
||||||
<LuSearch size={15} strokeWidth={2} />
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="关闭搜索"
|
onClick={() => setChatsOpen((v) => !v)}
|
||||||
onClick={() => {
|
aria-expanded={chatsOpen}
|
||||||
setQuery('')
|
className="absolute inset-0 flex items-center gap-1.5 px-2 text-[12.5px] font-medium text-ink-3"
|
||||||
setSearchOpen(false)
|
|
||||||
}}
|
|
||||||
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96]"
|
|
||||||
>
|
>
|
||||||
<LuX size={16} strokeWidth={2} />
|
<LuChevronDown
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
className={`shrink-0 transition-transform duration-150 ${chatsOpen ? '' : '-rotate-90'}`}
|
||||||
|
/>
|
||||||
|
<span>会话</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 内联搜索:放大镜 ↔ 输入框宽度过渡 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="搜索会话"
|
||||||
|
aria-expanded={searchOpen}
|
||||||
|
onClick={() => setSearchOpen((v) => !v)}
|
||||||
|
className="absolute right-0 top-0 z-10 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color,transform] hover:bg-hover-2 hover:text-ink active:scale-[0.96]"
|
||||||
|
style={{ opacity: searchOpen ? 0 : 1, pointerEvents: searchOpen ? 'none' : undefined }}
|
||||||
|
>
|
||||||
|
<LuSearch size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 z-20 flex h-8 items-center overflow-hidden rounded-[8px] bg-field text-ink-3 shadow-hairline transition-[width,opacity] focus-within:text-ink-2"
|
||||||
|
style={{ width: searchOpen ? '100%' : 28, opacity: searchOpen ? 1 : 0, pointerEvents: searchOpen ? undefined : 'none', transitionDuration: '180ms', transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}
|
||||||
|
>
|
||||||
|
<span className="ml-2 flex shrink-0 items-center justify-center">
|
||||||
|
<LuSearch size={15} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭搜索"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery('')
|
||||||
|
setSearchOpen(false)
|
||||||
|
}}
|
||||||
|
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96]"
|
||||||
|
>
|
||||||
|
<LuX size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 折叠态:会话切换入口(类浏览器标签切换,仅切换)。
|
||||||
|
收窄到 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>
|
</div>
|
||||||
|
|
||||||
{chatsOpen && (
|
{chatsOpen && !collapsed && (
|
||||||
<GlideMenu highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
|
<GlideMenu highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
|
||||||
{filtered.length === 0 ? (
|
{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">
|
<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
|
data-row
|
||||||
title={sessionTitle(s)}
|
title={sessionTitle(s)}
|
||||||
onClick={() => onOpenSession(s.path)}
|
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 */}
|
{/* 官方形态:纯文本单行,收起时随 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
|
||||||
<span className="absolute right-0.5 hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
|
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 && (
|
{active && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -463,6 +577,17 @@ export function SidebarNav({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 折叠态会话切换浮层(portal 到 body) */}
|
||||||
|
{sessionSwitcher && (
|
||||||
|
<SessionSwitcherPortal
|
||||||
|
anchor={sessionSwitcher}
|
||||||
|
sessions={sessions}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onOpenSession={onOpenSession}
|
||||||
|
onClose={() => setSessionSwitcher(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 行内操作浮层:锚定触发按钮,官方 WorkspaceMenu 形态(pop-in + overlay 阴影) */}
|
{/* 行内操作浮层:锚定触发按钮,官方 WorkspaceMenu 形态(pop-in + overlay 阴影) */}
|
||||||
{actionMenu && (
|
{actionMenu && (
|
||||||
<RowActionPortal rect={actionMenu.rect} onClose={() => setActionMenu(null)}>
|
<RowActionPortal rect={actionMenu.rect} onClose={() => setActionMenu(null)}>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
/** 可展开的思考轨迹(ZCode 式实时预览)。
|
/** 可展开的思考轨迹。
|
||||||
* 默认全部展开:工作中随流式增长完整展示,完成后原样保留;表头由
|
* 默认收起:表头展示「状态词 + 开头第一行内容」式预览,点击展开完整轨迹,
|
||||||
* 「正在思考」渐变为「思考 · 首行」。点击表头可手动收起/展开。
|
* 再点收起;流式中表头标签保持 shimmer。
|
||||||
* 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填):
|
* 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填):
|
||||||
* 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时
|
* 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时
|
||||||
* 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明
|
* 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明
|
||||||
@@ -22,22 +22,21 @@ export function Thinking({
|
|||||||
activeLabel?: string
|
activeLabel?: string
|
||||||
doneLabel?: string
|
doneLabel?: string
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
// 默认全部展开(工作中即完整展示思考轨迹);用户点击后以手动状态为准
|
// 默认收起(不想默认展开思考内容);用户点击后以手动状态为准
|
||||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
|
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 firstLine = rows.length > 0 ? rows[0].primary : ''
|
||||||
const statusWord = streaming ? activeLabel : expanded ? '思考' : doneLabel
|
const statusWord = streaming ? activeLabel : doneLabel
|
||||||
const suffix = streaming ? (expanded ? '' : latest) : expanded ? firstLine : ''
|
const suffix = expanded ? '' : firstLine
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
{/* 表头:状态词 +(流式收起时)最新一行 + 旋转箭头 */}
|
{/* 表头:状态词 +(收起时)首行预览 + 旋转箭头 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={expanded}
|
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"
|
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)'}>
|
<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"
|
role="switch"
|
||||||
aria-checked={autoCompactionEnabled}
|
aria-checked={autoCompactionEnabled}
|
||||||
onClick={() => onToggleAutoCompaction(!autoCompactionEnabled)}
|
onClick={() => onToggleAutoCompaction(!autoCompactionEnabled)}
|
||||||
title="上下文接近窗口上限时自动压缩历史(阈值/溢出触发)"
|
title="接近窗口上限时自动压缩历史"
|
||||||
className="flex items-center gap-2 text-[12px] text-ink-2 transition-colors hover:text-ink"
|
className="flex items-center gap-2 text-[12px] text-ink-2 transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -163,7 +163,7 @@ export function UsageCard({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onCompact}
|
onClick={onCompact}
|
||||||
disabled={total === null || total === 0}
|
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"
|
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
|
navigateTree: (entryId: string, options?: NavigateTreeOptions) => void
|
||||||
/** 在会话末尾分叉出新会话(pi fork,原会话保留) */
|
/** 在会话末尾分叉出新会话(pi fork,原会话保留) */
|
||||||
forkSession: () => void
|
forkSession: () => void
|
||||||
|
/** 从锚点分叉:'at' 含该条为止 / 'before' 到其父条目为止 */
|
||||||
|
forkSessionAt: (entryId: string, position: 'at' | 'before') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgent(): UseAgent {
|
export function useAgent(): UseAgent {
|
||||||
@@ -580,6 +582,18 @@ export function useAgent(): UseAgent {
|
|||||||
.finally(() => setForking(false))
|
.finally(() => setForking(false))
|
||||||
}, [dispatchError])
|
}, [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(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
state,
|
state,
|
||||||
@@ -607,7 +621,8 @@ export function useAgent(): UseAgent {
|
|||||||
setAutoCompaction,
|
setAutoCompaction,
|
||||||
dismissCompactionNotice,
|
dismissCompactionNotice,
|
||||||
navigateTree,
|
navigateTree,
|
||||||
forkSession
|
forkSession,
|
||||||
|
forkSessionAt
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
state,
|
state,
|
||||||
@@ -635,7 +650,8 @@ export function useAgent(): UseAgent {
|
|||||||
setAutoCompaction,
|
setAutoCompaction,
|
||||||
dismissCompactionNotice,
|
dismissCompactionNotice,
|
||||||
navigateTree,
|
navigateTree,
|
||||||
forkSession
|
forkSession,
|
||||||
|
forkSessionAt
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ declare global {
|
|||||||
minimize: () => Promise<void>
|
minimize: () => Promise<void>
|
||||||
toggleMaximize: () => Promise<void>
|
toggleMaximize: () => Promise<void>
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
|
/** 界面缩放:factor 为相对倍率(如 1.1 放大 / 0.9 缩小),限制 50%–200% */
|
||||||
|
zoom: (factor: number) => void
|
||||||
|
zoomReset: () => void
|
||||||
onWindowState: (callback: (maximized: boolean) => void) => () => void
|
onWindowState: (callback: (maximized: boolean) => void) => () => void
|
||||||
send: (text: string, images?: SendImage[]) => Promise<void>
|
send: (text: string, images?: SendImage[]) => Promise<void>
|
||||||
steer: (text: string) => Promise<void>
|
steer: (text: string) => Promise<void>
|
||||||
@@ -81,6 +84,7 @@ declare global {
|
|||||||
listForkPoints: () => Promise<ForkPoint[]>
|
listForkPoints: () => Promise<ForkPoint[]>
|
||||||
navigateTree: (entryId: string, options?: NavigateTreeOptions) => Promise<void>
|
navigateTree: (entryId: string, options?: NavigateTreeOptions) => Promise<void>
|
||||||
forkSession: () => Promise<void>
|
forkSession: () => Promise<void>
|
||||||
|
forkSessionAt: (entryId: string, position: 'at' | 'before') => Promise<void>
|
||||||
getSettings: () => Promise<AppSettings>
|
getSettings: () => Promise<AppSettings>
|
||||||
getSystemPrompt: () => Promise<string>
|
getSystemPrompt: () => Promise<string>
|
||||||
setThemeColor: (id: string) => Promise<void>
|
setThemeColor: (id: string) => Promise<void>
|
||||||
|
|||||||
@@ -312,6 +312,15 @@ a:focus-visible .animated-underline::after {
|
|||||||
width: 36px;
|
width: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
|
||||||
|
.sidebar-collapsed-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── shared keyframes ─────────────────────────────────── */
|
/* ── shared keyframes ─────────────────────────────────── */
|
||||||
|
|
||||||
@keyframes shimmer-text {
|
@keyframes shimmer-text {
|
||||||
|
|||||||
@@ -209,7 +209,14 @@ export interface CommandDetail {
|
|||||||
export interface ResourceDetails {
|
export interface ResourceDetails {
|
||||||
skills: SkillDetail[]
|
skills: SkillDetail[]
|
||||||
commands: CommandDetail[]
|
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 的斜杠路径) */
|
/** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */
|
||||||
|
|||||||
Reference in New Issue
Block a user