2 Commits
Author SHA1 Message Date
Rick Ye 1e6b4f1dce docs: modify a word 2026-09-07 10:50:22 +08:00
Rick Ye 0803b79c79 docs: 新增 AGENTS.md 说明 worktree 布局 2026-09-07 02:15:59 +08:00
67 changed files with 6737 additions and 14576 deletions
+10
View File
@@ -0,0 +1,10 @@
# AGENTS.md
git workspace 位于 `.worktrees` 之下,可能看到:
- release 分支:发布分支,用户能够使用的
- harness 分支:仅保留 harness 功能
- harness-feature 分支:harness-feature-{功能名称}
- develop-feature 分支:develop-feature-{功能名称}
根目录一般位于 main 或者 develop 分支上
+1 -30
View File
@@ -1,27 +1,7 @@
import { copyFileSync, mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import type { Plugin } from 'vite'
/** 构建期把主题 token 样式表拷到 out/renderer/tokens.css:
* 生产环境 src/ 不进包,插件页经 peer://assets/tokens.css 引用它保证深浅色/accent 连续 */
function copyTokensCss(): Plugin {
return {
name: 'aso-copy-tokens-css',
closeBundle() {
const src = resolve(__dirname, 'src/renderer/src/styles/beautifului.css')
const outDir = resolve(__dirname, 'out/renderer')
try {
mkdirSync(outDir, { recursive: true })
copyFileSync(src, resolve(outDir, 'tokens.css'))
} catch (err) {
this.warn(`tokens.css 拷贝失败: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
}
export default defineConfig({
main: {
@@ -65,16 +45,7 @@ export default defineConfig({
}
},
renderer: {
// Windows 上 Hyper-V/WSL 的 WinNAT 每次开机会随机保留若干端口段,
// 默认 5173 常被圈进保留段导致 EACCES;固定用 5323 避开(若日后再撞,
// 可换端口,或管理员执行 net stop winnat && net start winnat 后
// netsh int ipv4 add excludedportrange protocol=tcp startport=<端口> numberofports=1 永久占用)
server: {
port: 5323,
// 允许 dev server 读取工作区源码包(@aso/plugin-sdk 从 packages/ 链接进来)
fs: { allow: [resolve(__dirname, '../..')] }
},
plugins: [react(), tailwindcss(), copyTokensCss()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': resolve(__dirname, 'src/renderer/src'),
+5624
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -30,15 +30,12 @@
"react-dom": "19.2.8",
"react-icons": "5.7.0",
"react-markdown": "^10.1.0",
"remark-cjk-friendly": "^2.3.1",
"remark-cjk-friendly-gfm-strikethrough": "^2.3.1",
"remark-gfm": "^4.0.1",
"shadow-plugin": "2.1.0",
"shiki": "^1.29.2",
"tailwind-merge": "3.6.0"
},
"devDependencies": {
"@aso/plugin-sdk": "workspace:*",
"@tailwindcss/vite": "4.3.3",
"@types/node": "26.4.1",
"@types/react": "19.2.18",
@@ -1,79 +0,0 @@
/**
* 回合事件时序冒烟:验证 agent_settled 是否会在多回合工作(含 >600ms 慢工具)中途发出。
* 背景:渲染层回合折叠绑定 agent_settled(54b99a1),若 settled 在回合间被发出,
* 折叠会随 live 抖动反复展开/收起。本脚本在隔离的临时 cwd 下跑一轮真实多回合工作。
* 运行:node scripts/smoke-turn-events.mjs
*/
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
createAgentSessionServices,
createAgentSessionFromServices,
defineTool,
getAgentDir,
SessionManager
} from '@earendil-works/pi-coding-agent'
import { Type } from '@earendil-works/pi-ai'
const cwd = mkdtempSync(join(tmpdir(), 'aso-smoke-'))
const t0 = Date.now()
const ts = () => `${((Date.now() - t0) / 1000).toFixed(2).padStart(7)}s`
const slowTool = defineTool({
name: 'slow_probe',
label: '慢探针',
description: '等待 1500ms 后返回 done(模拟浏览器工具耗时,测试回合折叠时序)',
promptSnippet: 'slow_probe: 测试用慢工具',
parameters: Type.Object({}),
async execute() {
await new Promise((r) => setTimeout(r, 1500))
return { content: [{ type: 'text', text: 'done' }] }
}
})
const loggingExtension = {
name: 'smoke-log',
factory: (pi) => {
for (const type of ['session_start', 'agent_start', 'agent_end', 'agent_settled', 'message_start', 'message_end', 'tool_call']) {
pi.on(type, (event) => console.log(ts(), `[ext] ${type}${event?.toolName ? ` (${event.toolName})` : ''}`))
}
}
}
const toolExtension = {
name: 'smoke-tools',
factory: (pi) => {
pi.registerTool(slowTool)
}
}
const services = await createAgentSessionServices({
cwd,
agentDir: getAgentDir(),
resourceLoaderOptions: { extensionFactories: [loggingExtension, toolExtension] }
})
const sessionManager = SessionManager.continueRecent(cwd)
const { session } = await createAgentSessionFromServices({ services, sessionManager })
const unsubscribe = session.subscribe((event) => {
const message = event.message ?? {}
const detail =
event.type === 'tool_execution_start' || event.type === 'tool_execution_end'
? ` (${event.toolName})`
: event.type === 'message_start' || event.type === 'message_end'
? ` (role=${message.role ?? '?'})`
: ''
console.log(ts(), `[session] ${event.type}${detail}`)
})
console.log(ts(), '--- prompt 开始:两次串行 slow_probe + 总结 ---')
try {
await session.prompt('调用 slow_probe 工具两次(串行调用,不要并行),然后只回复"完成"两个字。')
console.log(ts(), '--- prompt 返回(整个工作结束) ---')
} finally {
unsubscribe()
await session.abort?.().catch(() => undefined)
process.exit(0)
}
@@ -1,60 +0,0 @@
/**
* CDP 命令的基础设施:超时与中止。
*
* Electron Debugger 的 sendCommand 在目标页面卡死或 DevTools 通道异常时可能永不
* settle;超时/中止只让调用方继续执行,底层命令后续 settle 时会被安全忽略。
*/
export const BROWSER_CDP_COMMAND_TIMEOUT_MS = 8_000
export const BROWSER_OBSERVE_TIMEOUT_MS = 5_000
export const BROWSER_OBSERVE_AX_DEPTH = 8
export const BROWSER_OBSERVE_EXTENDED_AX_DEPTH = 16
/** 高预算 Observe 读取更深的 AX tree,常规观察保持紧凑避免 Chromium 长时间阻塞 */
export function resolveBrowserObserveAxDepth(maxElements: number): number {
return maxElements > 240 ? BROWSER_OBSERVE_EXTENDED_AX_DEPTH : BROWSER_OBSERVE_AX_DEPTH
}
export class BrowserCdpTimeoutError extends Error {
constructor(method: string, timeoutMs: number) {
super(`浏览器页面未在 ${Math.ceil(timeoutMs / 1_000)} 秒内响应 ${method},请稍后重试或重新加载页面。`)
this.name = 'BrowserCdpTimeoutError'
}
}
/** 已发出的 DevTools 命令无法撤销;该错误只保证不再执行后续页面动作 */
export class BrowserOperationAbortedError extends Error {
constructor() {
super('浏览器操作已停止。已发送的页面指令可能已执行,页面状态请重新观察确认。')
this.name = 'BrowserOperationAbortedError'
}
}
export function throwIfBrowserOperationAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw new BrowserOperationAbortedError()
}
export interface CdpResponse {
[key: string]: unknown
}
export function withBrowserCdpTimeout<T>(
command: () => Promise<T>,
method: string,
timeoutMs: number = BROWSER_CDP_COMMAND_TIMEOUT_MS
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false
const settle = (callback: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
callback()
}
const timer = setTimeout(() => settle(() => reject(new BrowserCdpTimeoutError(method, timeoutMs))), timeoutMs)
void Promise.resolve()
.then(command)
.then((value) => settle(() => resolve(value)))
.catch((error: unknown) => settle(() => reject(error)))
})
}
@@ -1,956 +0,0 @@
import { app, BrowserWindow, session as electronSession, WebContentsView, type Rectangle, type Session } from 'electron'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { BrowserImportInfo, BrowserTabInfo, BrowserUiState } from '@shared/types'
import {
BrowserCdpTimeoutError,
BROWSER_OBSERVE_TIMEOUT_MS,
resolveBrowserObserveAxDepth,
withBrowserCdpTimeout,
type CdpResponse
} from './browser-cdp'
import {
assertSafeBrowserUrl,
browserObservationNameLimit,
NAMELESS_ALLOWED_ROLES,
prioritizeBrowserObservationCandidates,
resolveBrowserDestination,
resolveBrowserObserveMaxElements
} from './browser-policy'
/**
* 内置受管浏览器控制器。
*
* 架构(参考 Proma 的成熟实现,适配本应用单窗口/单聊天会话形态):
* - 每个标签页 = 一个原生 WebContentsView,通过 contentView.addChildView 呈现;
* 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。
* - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制
* + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。
* - 登录态隔离在 persist:aso-browser 分区;本地浏览器配置导入由 browser-profile.ts
* 直接迁移分区目录数据(Cookies/Local Storage + 密钥移植)。
*/
const PARTITION = 'persist:aso-browser'
// 新标签页默认地址:google.com 在目标市场(国内)不可达,新开标签会长时间白屏,
// 误以为浏览器故障;用可稳定访问的导航页
const NEW_TAB_URL = 'https://www.baidu.com/'
const MAX_TABS = 12
const MAX_FILL_CHARS = 10_000
const MAX_SCRIPT_CHARS = 20_000
const MAX_EXTRACT_CHARS = 50_000
interface RefEntry {
backendNodeId: number
generation: number
label: string
editable: boolean
}
interface TabRecord {
tabId: string
view: WebContentsView
createdAt: number
url: string
title: string
loading: boolean
/** ref 表与代际号:每次观察/导航后旧 ref 失效 */
refs: Map<string, RefEntry>
generation: number
/** 同一 tab 的 Agent 操作串行化,避免 CDP 命令交错 */
commandTail: Promise<unknown>
lastLayoutRevision: number
attached: boolean
}
interface BrowserSettings {
riskAcknowledged: boolean
imported?: BrowserImportInfo | null
}
export interface BrowserObservation {
tabId: string
url: string
title: string
generation: number
elements: Array<{ ref: string; role: string; name: string; editable: boolean }>
}
export interface BrowserWaitCondition {
kind: 'url' | 'text' | 'selector'
value: string
}
function textValue(v: unknown): string {
if (v && typeof v === 'object') {
const value = (v as { value?: unknown }).value
if (typeof value === 'string') return value
}
return ''
}
function isEditableAxNode(node: Record<string, unknown>): boolean {
const role = textValue(node.role).toLowerCase()
if (role === 'textfield' || role === 'textbox' || role === 'searchbox') return true
const props = Array.isArray(node.properties) ? (node.properties as Array<Record<string, unknown>>) : []
for (const p of props) {
if (String(p.name) === 'editable') {
const inner = p.value as { value?: unknown } | undefined
if (inner && typeof inner.value === 'boolean') return inner.value
}
}
return false
}
/** 导航键 → CDP key 事件参数(rawKeyDown + VK code 才会触发默认行为:滚动/提交/焦点移动) */
const KEY_EVENTS: Record<string, { key: string; code: string; vk: number }> = {
enter: { key: 'Enter', code: 'Enter', vk: 13 },
tab: { key: 'Tab', code: 'Tab', vk: 9 },
escape: { key: 'Escape', code: 'Escape', vk: 27 },
backspace: { key: 'Backspace', code: 'Backspace', vk: 8 },
delete: { key: 'Delete', code: 'Delete', vk: 46 },
pageup: { key: 'PageUp', code: 'PageUp', vk: 33 },
pagedown: { key: 'PageDown', code: 'PageDown', vk: 34 },
home: { key: 'Home', code: 'Home', vk: 36 },
end: { key: 'End', code: 'End', vk: 35 },
arrowup: { key: 'ArrowUp', code: 'ArrowUp', vk: 38 },
arrowdown: { key: 'ArrowDown', code: 'ArrowDown', vk: 40 },
arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft', vk: 37 },
arrowright: { key: 'ArrowRight', code: 'ArrowRight', vk: 39 },
space: { key: ' ', code: 'Space', vk: 32 }
}
export class BrowserController {
private owner: BrowserWindow | null = null
private browserSession: Session
private tabs = new Map<string, TabRecord>()
private activeTabId: string | null = null
private agentTabId: string | null = null
private listeners = new Set<(state: BrowserUiState) => void>()
private traceText: string | null = null
private settings: BrowserSettings = { riskAcknowledged: false }
/** 最近关闭的页签(新的在前,供「最近关闭的标签页」恢复) */
private closedTabs: Array<{ title: string; url: string; closedAt: number }> = []
constructor() {
this.browserSession = electronSession.fromPartition(PARTITION)
// 诚实的 UA:保留 Chromium token 保证站点兼容,去掉 Electron 标识
this.browserSession.setUserAgent(
this.browserSession.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`)
)
// 网页权限请求(摄像头/定位等)默认拒绝
this.browserSession.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
// 下载留在受管浏览器内,落到系统下载目录(重名自动加序号)
this.browserSession.on('will-download', (_event, item) => {
const dir = app.getPath('downloads')
const base = item.getFilename() || 'download'
const dot = base.lastIndexOf('.')
const stem = dot > 0 ? base.slice(0, dot) : base
const ext = dot > 0 ? base.slice(dot) : ''
let target = join(dir, base)
for (let i = 1; existsSync(target) && i < 100; i++) {
target = join(dir, `${stem} (${i})${ext}`)
}
item.setSavePath(target)
})
this.settings = this.loadSettings()
}
// ---- 设置持久化 ----
private get settingsFile(): string {
return join(app.getPath('userData'), 'aso-browser.json')
}
private loadSettings(): BrowserSettings {
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings>
return {
riskAcknowledged: raw.riskAcknowledged === true,
imported: raw.imported ?? null
}
} catch {
return { riskAcknowledged: false }
}
}
private saveSettings(): void {
try {
writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2), 'utf8')
} catch (err) {
console.error('[browser] save settings failed:', err)
}
}
/** 配置导入完成后由 browser-profile.ts 回写摘要 */
setImportedInfo(info: BrowserImportInfo | null): void {
this.settings.imported = info
this.saveSettings()
this.emitState()
}
get importedInfo(): BrowserImportInfo | null {
return this.settings.imported ?? null
}
/** 当前是否有活跃标签页(导入配置前必须没有) */
get hasTabs(): boolean {
return this.tabs.size > 0
}
// ---- 窗口与状态广播 ----
setOwnerWindow(win: BrowserWindow): void {
this.owner = win
}
handleWindowClosed(): void {
for (const tab of this.tabs.values()) {
try {
if (tab.view.webContents.debugger.isAttached()) tab.view.webContents.debugger.detach()
} catch {
// 已销毁
}
try {
tab.view.setVisible(false)
} catch {
// 已销毁
}
}
this.tabs.clear()
this.activeTabId = null
this.agentTabId = null
this.owner = null
}
onState(fn: (state: BrowserUiState) => void): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
private buildState(): BrowserUiState {
const tabs: BrowserTabInfo[] = [...this.tabs.values()]
.sort((a, b) => a.createdAt - b.createdAt)
.map((t) => ({ tabId: t.tabId, url: t.url, title: t.title, loading: t.loading }))
return {
open: tabs.length > 0,
riskAcknowledged: this.settings.riskAcknowledged,
tabs,
activeTabId: this.activeTabId,
recentClosed: this.closedTabs.map(({ title, url }) => ({ title, url })),
trace: this.traceText,
imported: this.settings.imported ?? null
}
}
getState(): BrowserUiState {
return this.buildState()
}
private emitState(): void {
const state = this.buildState()
for (const fn of this.listeners) {
try {
fn(state)
} catch (err) {
console.error('[browser] state listener error:', err)
}
}
}
private trace(tab: TabRecord, action: string, detail: string): void {
this.traceText = `[${action}] ${detail}`
void tab
this.emitState()
}
// ---- 风险告知门禁 ----
acknowledgeRisk(): void {
this.settings.riskAcknowledged = true
this.saveSettings()
this.emitState()
}
private assertRiskAcknowledged(): void {
if (!this.settings.riskAcknowledged) {
throw new Error('内置浏览器首次使用尚未确认风险告知:请在应用右侧浏览器面板中阅读并点击「我已了解,开始使用」后重试。')
}
}
// ---- 会话与标签页 ----
private ensureBrowser(): TabRecord {
if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法使用内置浏览器。')
if (this.tabs.size === 0) this.createTab(NEW_TAB_URL)
const active = this.activeTabId ? this.tabs.get(this.activeTabId) : null
const fallback = this.tabs.values().next().value
const tab = active ?? fallback
if (!tab) throw new Error('浏览器标签页不可用。')
this.emitState()
return tab
}
private invalidateTabDocument(tab: TabRecord): void {
tab.generation += 1
tab.refs.clear()
}
private createTab(initialUrl: string, viewOptions?: Electron.WebContentsViewConstructorOptions): TabRecord {
if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法创建浏览器标签。')
if (this.tabs.size >= MAX_TABS) throw new Error(`浏览器标签页已达上限(${MAX_TABS}),请先关闭不需要的标签。`)
const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const view = new WebContentsView(
viewOptions ?? {
webPreferences: {
partition: PARTITION,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true
}
}
)
const tab: TabRecord = {
tabId,
view,
createdAt: Date.now(),
url: '',
title: '新标签页',
loading: false,
refs: new Map(),
generation: 0,
commandTail: Promise.resolve(),
lastLayoutRevision: 0,
attached: false
}
view.setVisible(false)
// popup 收编为受管同级标签(必须同步返回 webContents,故提前建好 TabRecord)
view.webContents.setWindowOpenHandler(({ url }) => {
try {
assertSafeBrowserUrl(url)
} catch {
this.trace(tab, 'popup', `已阻止不安全的新窗口: ${url.slice(0, 80)}`)
return { action: 'deny' }
}
if (this.tabs.get(tabId) !== tab || this.tabs.size >= MAX_TABS) return { action: 'deny' }
return {
action: 'allow',
outlivesOpener: true,
createWindow: (options) => {
const popup = this.createTab(url, options)
this.selectTab(popup.tabId)
this.trace(popup, 'popup', '网页打开了新标签页')
return popup.view.webContents
}
}
})
const updateNav = (): void => {
const wc = view.webContents
if (wc.isDestroyed()) return
try {
tab.url = wc.getURL()
tab.title = wc.getTitle() || tab.url
tab.loading = wc.isLoading()
} catch {
// 竞态时忽略
}
this.emitState()
}
view.webContents.on('did-start-loading', () => {
tab.loading = true
this.invalidateTabDocument(tab)
this.emitState()
})
view.webContents.on('did-stop-loading', updateNav)
view.webContents.on('page-title-updated', updateNav)
view.webContents.on('did-navigate', () => {
this.invalidateTabDocument(tab)
updateNav()
})
view.webContents.on('did-navigate-in-page', () => {
this.invalidateTabDocument(tab)
updateNav()
})
view.webContents.on('destroyed', () => {
this.tabs.delete(tabId)
this.removeView(view)
if (this.activeTabId === tabId) this.activeTabId = this.tabs.keys().next().value ?? null
if (this.agentTabId === tabId) this.agentTabId = this.activeTabId
this.emitState()
})
try {
view.webContents.debugger.attach('1.3')
} catch (err) {
console.warn('[browser] CDP attach 失败:', err)
}
this.tabs.set(tabId, tab)
if (!this.activeTabId) this.activeTabId = tabId
// popup 场景(带 viewOptions)由 Electron 的 createWindow 机制自行导航,避免双重加载
if (initialUrl && !viewOptions) void view.webContents.loadURL(assertSafeBrowserUrl(initialUrl)).catch(() => undefined)
return tab
}
private removeView(view: WebContentsView): void {
try {
this.owner?.contentView.removeChildView(view)
} catch {
// 窗口已销毁
}
try {
view.webContents.close()
} catch {
// 已销毁
}
}
listTabs(): BrowserUiState {
return this.buildState()
}
newTab(url?: string, byAgent = true): BrowserUiState {
this.assertRiskAcknowledged()
const tab = this.createTab(url ? resolveBrowserDestination(url) : NEW_TAB_URL)
this.activeTabId = tab.tabId
if (byAgent) this.agentTabId = tab.tabId
this.emitState()
return this.buildState()
}
selectTab(tabId: string): BrowserUiState {
const tab = this.tabs.get(tabId)
if (!tab) throw new Error(`标签页不存在: ${tabId}`)
this.activeTabId = tabId
try {
tab.view.webContents.focus()
} catch {
// 页面可能正在加载
}
this.emitState()
return this.buildState()
}
closeTab(tabId?: string): BrowserUiState {
const id = tabId ?? this.activeTabId
if (!id) return this.buildState()
const tab = this.tabs.get(id)
if (!tab) return this.buildState()
try {
if (tab.view.webContents.debugger.isAttached()) tab.view.webContents.debugger.detach()
} catch {
// 已销毁
}
this.removeView(tab.view)
this.tabs.delete(id)
// 记入「最近关闭的标签页」(新的在前,最多 10 条)
this.closedTabs.unshift({ title: tab.title || tab.url || '新标签页', url: tab.url, closedAt: Date.now() })
this.closedTabs = this.closedTabs.slice(0, 10)
if (this.activeTabId === id) this.activeTabId = this.tabs.keys().next().value ?? null
if (this.agentTabId === id) this.agentTabId = this.activeTabId
this.emitState()
return this.buildState()
}
/** 关闭整个受管浏览器(销毁全部标签;导入配置前也会走这里) */
close(): BrowserUiState {
for (const tabId of [...this.tabs.keys()]) this.closeTab(tabId)
this.agentTabId = null
this.traceText = null
return this.buildState()
}
// ---- 渲染层布局同步 ----
applyLayout(layout: { tabId: string; visible: boolean; bounds: Rectangle; revision: number }): void {
const tab = this.tabs.get(layout.tabId)
if (!tab || !this.owner || this.owner.isDestroyed()) return
if (layout.revision <= tab.lastLayoutRevision) return
tab.lastLayoutRevision = layout.revision
try {
if (layout.visible && layout.bounds.width > 4 && layout.bounds.height > 4) {
if (!tab.attached) {
this.owner.contentView.addChildView(tab.view)
tab.attached = true
}
tab.view.setBounds(layout.bounds)
tab.view.setVisible(true)
} else {
tab.view.setVisible(false)
}
} catch (err) {
console.error('[browser] apply layout failed:', err)
}
}
// ---- 用户导航(地址栏/按钮) ----
navigateDisplay(input: string): BrowserUiState {
this.assertRiskAcknowledged()
const tab = this.ensureBrowser()
void tab.view.webContents.loadURL(resolveBrowserDestination(input)).catch(() => undefined)
this.trace(tab, 'navigate', `打开 ${input.slice(0, 80)}`)
return this.buildState()
}
goBack(): BrowserUiState {
this.assertRiskAcknowledged()
const tab = this.ensureBrowser()
if (tab.view.webContents.navigationHistory.canGoBack()) tab.view.webContents.navigationHistory.goBack()
return this.buildState()
}
goForward(): BrowserUiState {
this.assertRiskAcknowledged()
const tab = this.ensureBrowser()
if (tab.view.webContents.navigationHistory.canGoForward()) tab.view.webContents.navigationHistory.goForward()
return this.buildState()
}
reload(): BrowserUiState {
this.assertRiskAcknowledged()
const tab = this.ensureBrowser()
tab.view.webContents.reload()
return this.buildState()
}
// ---- CDP 基础 ----
private cdp(tab: TabRecord, method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<CdpResponse> {
const debuggerClient = tab.view.webContents.debugger
if (!debuggerClient.isAttached()) throw new Error('浏览器调试通道不可用,请重新打开该标签页。')
return withBrowserCdpTimeout(() => debuggerClient.sendCommand(method, params) as Promise<CdpResponse>, method, timeoutMs).catch(
(error: unknown) => {
if (error instanceof BrowserCdpTimeoutError) this.recoverDebugger(tab, method)
throw error
}
)
}
/** 超时后重连 debugger,避免一个卡死页面让后续浏览器调用全部失败 */
private recoverDebugger(tab: TabRecord, method: string): void {
try {
const debuggerClient = tab.view.webContents.debugger
if (debuggerClient.isAttached()) debuggerClient.detach()
debuggerClient.attach('1.3')
console.warn(`[browser] ${method} 超时,已重连调试通道`)
} catch {
// 页面已关闭
}
}
/** Agent 的 tab 内操作串行化(用户操作不排队) */
private runAgentOp<T>(tab: TabRecord, fn: () => Promise<T>): Promise<T> {
const next = tab.commandTail.then(fn, fn)
tab.commandTail = next.catch(() => undefined)
return next
}
private getAgentTab(tabId?: string): TabRecord {
if (tabId) {
const explicit = this.tabs.get(tabId)
if (!explicit) throw new Error('目标标签页不存在,请先 browser_list_tabs 并用 browser_new_tab/browser_select_tab 选择。')
return explicit
}
const id = this.agentTabId ?? this.activeTabId
const tab = id ? this.tabs.get(id) : null
if (tab) return tab
// Agent 首次操作时还没有任何标签页:自动开一个工作页
return this.ensureBrowser()
}
private assertCurrentDocument(tab: TabRecord, generation: number): void {
if (tab.generation !== generation) {
throw new Error('页面已变化(导航或重渲染),旧元素引用失效:请重新 browser_observe 后再操作。')
}
}
private resolveRef(tab: TabRecord, ref: string): RefEntry {
const entry = tab.refs.get(ref)
if (!entry || entry.generation !== tab.generation) {
throw new Error('元素引用已失效,请先重新调用 browser_observe。')
}
return entry
}
/** AX ref → 视口中心坐标(先滚动到可见再读 box;懒加载列表滚动后坐标会变,不能复用旧值) */
private async centerForRef(tab: TabRecord, ref: string, generation: number): Promise<{ x: number; y: number }> {
this.assertCurrentDocument(tab, generation)
const { backendNodeId } = this.resolveRef(tab, ref)
await this.cdp(tab, 'DOM.scrollIntoViewIfNeeded', { backendNodeId })
this.assertCurrentDocument(tab, generation)
const box = await this.cdp(tab, 'DOM.getBoxModel', { backendNodeId })
this.assertCurrentDocument(tab, generation)
const model = box.model as Record<string, unknown> | undefined
const quad = Array.isArray(model?.content) ? (model?.content as unknown[]) : []
if (quad.length < 8 || !quad.every((v) => typeof v === 'number')) {
throw new Error('目标元素当前不可点击,请重新观察页面。')
}
const n = quad as number[]
return {
x: (n[0] + n[2] + n[4] + n[6]) / 4,
y: (n[1] + n[3] + n[5] + n[7]) / 4
}
}
/** 点击前在页面上短暂高亮目标,用户能看清 Agent 正在操作什么 */
private async highlightAgentTarget(tab: TabRecord, backendNodeId: number): Promise<void> {
try {
const resolved = await this.cdp(tab, 'DOM.resolveNode', { backendNodeId })
const objectId = (resolved.object as { objectId?: string } | undefined)?.objectId
if (!objectId) return
await this.cdp(tab, 'Runtime.callFunctionOn', {
functionDeclaration:
'function(){const r=this.getBoundingClientRect();const d=document.createElement("div");' +
'd.style.cssText=`position:fixed;left:${r.left-2}px;top:${r.top-2}px;width:${r.width+4}px;height:${r.height+4}px;`+' +
'"outline:2px solid #f59e0b;outline-offset:1px;pointer-events:none;z-index:2147483647;";' +
'document.documentElement.appendChild(d);setTimeout(()=>d.remove(),700);return true}',
objectId,
returnByValue: true
})
} catch {
// 高亮失败不影响操作
}
}
// ---- Agent 工具实现 ----
async navigate(url: string, tabId?: string): Promise<BrowserUiState> {
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
this.agentTabId = tab.tabId
this.activeTabId = tab.tabId
const destination = resolveBrowserDestination(url)
await tab.view.webContents.loadURL(destination)
this.trace(tab, 'navigate', `打开 ${destination.slice(0, 80)}`)
return this.buildState()
}
private parseAxNodes(response: CdpResponse): Array<{ backendNodeId: number; role: string; name: string; editable: boolean }> {
const nodes = Array.isArray(response.nodes) ? (response.nodes as Array<Record<string, unknown>>) : []
const candidates: Array<{ backendNodeId: number; role: string; name: string; editable: boolean }> = []
for (const node of nodes) {
if (!node) continue
const backendNodeId = typeof node.backendDOMNodeId === 'number' ? node.backendDOMNodeId : 0
const role = textValue(node.role).toLowerCase()
const name = textValue(node.name)
const editable = isEditableAxNode(node)
if (!backendNodeId || !role) continue
if (!name && !editable && !NAMELESS_ALLOWED_ROLES.has(role)) continue
candidates.push({ backendNodeId, role, name: name.slice(0, browserObservationNameLimit(role)), editable })
}
return candidates
}
private buildRefs(tab: TabRecord, candidates: Array<{ backendNodeId: number; role: string; name: string; editable: boolean }>): BrowserObservation['elements'] {
tab.generation += 1
tab.refs.clear()
const elements: BrowserObservation['elements'] = []
for (const candidate of candidates) {
const ref = `r${tab.generation}-${elements.length + 1}`
tab.refs.set(ref, {
backendNodeId: candidate.backendNodeId,
generation: tab.generation,
label: candidate.name ? `${candidate.role}「${candidate.name.slice(0, 80)}」` : candidate.role,
editable: candidate.editable
})
elements.push({ ref, role: candidate.role, name: candidate.name, editable: candidate.editable })
}
return elements
}
async observe(tabId?: string, requestedMaxElements?: number): Promise<BrowserObservation> {
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
this.agentTabId = tab.tabId
return this.runAgentOp(tab, async () => {
const maxElements = resolveBrowserObserveMaxElements(requestedMaxElements)
const depth = resolveBrowserObserveAxDepth(maxElements)
const response = await this.cdp(
tab,
'Accessibility.getFullAXTree',
{ depth },
BROWSER_OBSERVE_TIMEOUT_MS
)
const selected = prioritizeBrowserObservationCandidates(this.parseAxNodes(response), maxElements)
const elements = this.buildRefs(tab, selected)
this.trace(tab, 'observe', `读取到 ${elements.length}/${maxElements} 个元素(AX 深度 ${depth})`)
return { tabId: tab.tabId, url: tab.url, title: tab.title, generation: tab.generation, elements }
})
}
async find(
query: { role?: string; name?: string; exact?: boolean; maxResults?: number },
tabId?: string
): Promise<BrowserObservation> {
const role = query.role?.trim().toLowerCase()
const name = query.name?.trim().toLowerCase()
const maxResults = query.maxResults ?? 20
if (!role && !name) throw new Error('语义定位至少需要 role 或 name。')
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 50) throw new Error('maxResults 必须是 1 到 50 的整数。')
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
this.agentTabId = tab.tabId
return this.runAgentOp(tab, async () => {
// 过滤后再套观察配额:Find 能找到被紧凑 Observe 截掉的目标
const response = await this.cdp(
tab,
'Accessibility.getFullAXTree',
{ depth: resolveBrowserObserveAxDepth(MAX_BROWSER_OBSERVE_DEPTH_FALLBACK) },
BROWSER_OBSERVE_TIMEOUT_MS
)
const candidates = this.parseAxNodes(response).filter((candidate) => {
const roleMatches = !role || candidate.role === role
const candidateName = candidate.name.toLowerCase()
const nameMatches = !name || (query.exact ? candidateName === name : candidateName.includes(name))
return roleMatches && nameMatches
})
const elements = this.buildRefs(tab, candidates.slice(0, maxResults))
this.trace(tab, 'find', `语义定位到 ${elements.length} 个元素(${role ?? name})`)
return { tabId: tab.tabId, url: tab.url, title: tab.title, generation: tab.generation, elements }
})
}
private async clickRef(tab: TabRecord, ref: string, generation: number): Promise<RefEntry> {
const target = this.resolveRef(tab, ref)
const { x, y } = await this.centerForRef(tab, ref, generation)
this.assertCurrentDocument(tab, generation)
await this.highlightAgentTarget(tab, target.backendNodeId)
await this.cdp(tab, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
await this.cdp(tab, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
return target
}
async click(ref: string, tabId?: string): Promise<BrowserUiState> {
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
return this.runAgentOp(tab, async () => {
const generation = tab.generation
const target = await this.clickRef(tab, ref, generation)
this.trace(tab, 'click', `点击 ${target.label}`)
return this.buildState()
})
}
/** 点击 + 等待合并为一次串行操作(已知点击后预期状态时优先使用) */
async act(ref: string, waitFor: BrowserWaitCondition | undefined, timeoutMs = 10_000, tabId?: string): Promise<{ state: BrowserUiState; matched: boolean }> {
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
return this.runAgentOp(tab, async () => {
const generation = tab.generation
const target = await this.clickRef(tab, ref, generation)
this.trace(tab, 'click', `点击 ${target.label}`)
if (!waitFor) return { state: this.buildState(), matched: true }
const matched = await this.waitForInternal(tab, waitFor, timeoutMs)
return { state: this.buildState(), matched }
})
}
async hover(ref: string, tabId?: string): Promise<BrowserUiState> {
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
return this.runAgentOp(tab, async () => {
const generation = tab.generation
const target = this.resolveRef(tab, ref)
const { x, y } = await this.centerForRef(tab, ref, generation)
await this.highlightAgentTarget(tab, target.backendNodeId)
this.assertCurrentDocument(tab, generation)
await this.cdp(tab, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y })
this.trace(tab, 'hover', `悬停 ${target.label}`)
return this.buildState()
})
}
async fill(ref: string, text: string, tabId?: string): Promise<BrowserUiState> {
if (text.length > MAX_FILL_CHARS) throw new Error(`单次输入不能超过 ${MAX_FILL_CHARS} 个字符。`)
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
return this.runAgentOp(tab, async () => {
const generation = tab.generation
const target = this.resolveRef(tab, ref)
if (!target.editable) throw new Error('目标元素不是可编辑字段,请重新观察后选择 input、textarea 或 contenteditable。')
await this.highlightAgentTarget(tab, target.backendNodeId)
await this.cdp(tab, 'DOM.scrollIntoViewIfNeeded', { backendNodeId: target.backendNodeId })
this.assertCurrentDocument(tab, generation)
await this.cdp(tab, 'DOM.focus', { backendNodeId: target.backendNodeId })
this.assertCurrentDocument(tab, generation)
// 全选(Ctrl/Cmd+A)后 insertText 整段替换,不逐字符模拟
const selectAllModifier = process.platform === 'darwin' ? 4 : 2
await this.cdp(tab, 'Input.dispatchKeyEvent', {
type: 'keyDown',
key: 'a',
code: 'KeyA',
windowsVirtualKeyCode: 65,
modifiers: selectAllModifier
})
await this.cdp(tab, 'Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'a',
code: 'KeyA',
windowsVirtualKeyCode: 65,
modifiers: selectAllModifier
})
this.assertCurrentDocument(tab, generation)
await this.cdp(tab, 'Input.insertText', { text })
this.trace(tab, 'fill', `在 ${target.label} 输入 ${Array.from(text).length} 个字符`)
return this.buildState()
})
}
async press(key: string, tabId?: string): Promise<BrowserUiState> {
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
return this.runAgentOp(tab, async () => {
const normalized = key.trim().toLowerCase().replace(/\s+/g, '')
const keyEvent = KEY_EVENTS[normalized]
if (keyEvent) {
const event = { key: keyEvent.key, code: keyEvent.code, windowsVirtualKeyCode: keyEvent.vk }
await this.cdp(tab, 'Input.dispatchKeyEvent', { type: 'rawKeyDown', ...event })
await this.cdp(tab, 'Input.dispatchKeyEvent', { type: 'keyUp', ...event })
this.trace(tab, 'press', `按下 ${keyEvent.key}`)
} else {
await this.cdp(tab, 'Input.insertText', { text: key })
this.trace(tab, 'press', `输入 ${Array.from(key).length} 个字符`)
}
return this.buildState()
})
}
async scroll(
input: { deltaY?: number; position?: 'top' | 'bottom'; selector?: string },
tabId?: string
): Promise<{ before: { top: number; height: number }; after: { top: number; height: number }; moved: boolean }> {
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
return this.runAgentOp(tab, async () => {
const selector = typeof input.selector === 'string' && input.selector.trim() ? input.selector.trim().slice(0, 500) : ''
const position = input.position === 'top' || input.position === 'bottom' ? input.position : undefined
const deltaY = Math.max(-50_000, Math.min(50_000, Math.round(input.deltaY ?? 600)))
// 固定表达式:数据由 JSON 注入,不拼接用户脚本
const expression =
'((input)=>{const el=input.selector?document.querySelector(input.selector):(document.scrollingElement||document.documentElement);' +
'if(!el)return {ok:false};const read=()=>({top:Math.round(el.scrollTop),height:Math.round(el.scrollHeight)});' +
'const before=read();const top=input.position==="top"?0:input.position==="bottom"?Math.max(0,el.scrollHeight-el.clientHeight):el.scrollTop+input.deltaY;' +
'el.scrollTo({top});const after=read();return {ok:true,before,after}})(' +
JSON.stringify({ selector, position, deltaY }) +
')'
const response = await this.evaluateOnPage(tab, expression)
const result = (response ?? {}) as { ok?: boolean; before?: { top: number; height: number }; after?: { top: number; height: number } }
if (!result.ok || !result.before || !result.after) throw new Error('滚动目标不存在或不支持滚动,请检查 selector。')
this.trace(tab, 'scroll', `滚动页面${selector ? `(${selector.slice(0, 40)})` : ''}`)
return { before: result.before, after: result.after, moved: result.after.top !== result.before.top }
})
}
async extract(
input: { selector?: string; maxChars?: number },
tabId?: string
): Promise<{ url: string; title: string; text: string; truncated: boolean }> {
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
return this.runAgentOp(tab, async () => {
const selector = typeof input.selector === 'string' && input.selector.trim() ? input.selector.trim().slice(0, 500) : ''
const maxChars = Math.min(MAX_EXTRACT_CHARS, Math.max(500, Math.round(input.maxChars ?? MAX_EXTRACT_CHARS)))
const expression =
'((input)=>{const root=input.selector?document.querySelector(input.selector):document.body;' +
'if(!root)return null;const text=(root.innerText||"").replace(/\\n{3,}/g,"\\n\\n");' +
'return {text:text.slice(0,input.maxChars),truncated:text.length>input.maxChars}})(' +
JSON.stringify({ selector, maxChars }) +
')'
const response = await this.evaluateOnPage(tab, expression)
const result = (response ?? {}) as { text?: string; truncated?: boolean } | null
if (!result || typeof result.text !== 'string') throw new Error('提取目标不存在,请检查 selector。')
this.trace(tab, 'extract', `提取正文 ${result.text.length} 字符${selector ? `(${selector.slice(0, 40)})` : ''}`)
return { url: tab.url, title: tab.title, text: result.text, truncated: result.truncated === true }
})
}
async waitFor(condition: BrowserWaitCondition, timeoutMs = 10_000, tabId?: string): Promise<{ matched: boolean; url: string; title: string }> {
if (!condition.value.trim()) throw new Error('等待条件不能为空。')
if (!Number.isFinite(timeoutMs) || timeoutMs < 250 || timeoutMs > 30_000) throw new Error('等待超时必须在 250 到 30000 毫秒之间。')
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
return this.runAgentOp(tab, async () => {
const matched = await this.waitForInternal(tab, condition, timeoutMs)
this.trace(tab, 'wait', `${matched ? '已满足' : '超时未满足'}${condition.kind} 条件(${condition.value.slice(0, 40)})`)
return { matched, url: tab.url, title: tab.title }
})
}
/** 轮询固定表达式(只读检查,不执行任意 JS) */
private async waitForInternal(tab: TabRecord, condition: BrowserWaitCondition, timeoutMs: number): Promise<boolean> {
const startedAt = Date.now()
const payload = JSON.stringify(condition).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')
const expression =
`(() => { const condition = ${payload}; try { ` +
"if (condition.kind === 'url') return location.href.includes(condition.value); " +
"if (condition.kind === 'text') return (document.body?.innerText || '').includes(condition.value); " +
'return !!document.querySelector(condition.value); } catch { return false; } })()'
while (Date.now() - startedAt <= timeoutMs) {
const result = await this.evaluateOnPage(tab, expression)
if (result === true) return true
await new Promise((resolve) => setTimeout(resolve, 250))
}
return false
}
/** Agent 逃生舱:在页面上下文执行自写 JS(有长度上限,结果 JSON 化) */
async evaluate(script: string, tabId?: string): Promise<unknown> {
if (!script.trim()) throw new Error('脚本不能为空。')
if (script.length > MAX_SCRIPT_CHARS) throw new Error(`脚本不能超过 ${MAX_SCRIPT_CHARS} 字符。`)
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
return this.runAgentOp(tab, async () => {
const result = await this.evaluateOnPage(tab, script, true)
this.trace(tab, 'js', `执行页面脚本 ${script.length} 字符`)
return result
})
}
private async evaluateOnPage(tab: TabRecord, expression: string, userGesture = false): Promise<unknown> {
const response = await this.cdp(tab, 'Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
userGesture
})
const exception = response.exceptionDetails as { text?: string; exception?: { description?: string } } | undefined
if (exception) {
throw new Error(`页面脚本执行出错: ${exception.exception?.description || exception.text || '未知错误'}`)
}
const result = response.result as { type?: string; value?: unknown; description?: string } | undefined
if (!result) return null
if ('value' in result) return result.value ?? null
return { type: result.type ?? 'unknown', description: result.description ?? null }
}
async screenshot(tabId?: string): Promise<{ tabId: string; url: string; title: string; dataUrl: string }> {
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
return this.runAgentOp(tab, async () => {
const image = await tab.view.webContents.capturePage()
this.trace(tab, 'screenshot', '截取当前页面')
return { tabId: tab.tabId, url: tab.url, title: tab.title, dataUrl: image.toDataURL() }
})
}
/** 渲染层「冻结帧」用:菜单/浮层需要暂时隐藏原生视图前,截当前画面做占位,
* 视觉上等于菜单浮在页面之上(DOM 层级永远低于原生视图,菜单弹出必须先让位) */
async captureTabDataUrl(tabId?: string): Promise<string> {
const tab = tabId ? this.tabs.get(tabId) : this.activeTabId ? this.tabs.get(this.activeTabId) : null
if (!tab) throw new Error('标签页不存在,无法截取画面。')
const image = await tab.view.webContents.capturePage()
return image.toDataURL()
}
}
const MAX_BROWSER_OBSERVE_DEPTH_FALLBACK = 400
let controllerInstance: BrowserController | null = null
/** 惰性单例:首次取用时才创建(session 相关 API 需在 app ready 后调用) */
export function getBrowserController(): BrowserController {
if (!controllerInstance) controllerInstance = new BrowserController()
return controllerInstance
}
@@ -1,120 +0,0 @@
/**
* 受管浏览器的 URL 与观察策略。
* 独立成模块便于单测与复用(渲染层不需要,主进程专用)。
*/
const SEARCH_URL = 'https://www.google.com/search?q='
/** 导航白名单:普通 web、本地开发服务与 about:blank;file:/javascript:/data: 一律拒绝 */
export function assertSafeBrowserUrl(input: string): string {
const value = input.trim()
if (!value) throw new Error('URL 不能为空。')
if (value === 'about:blank') return value
let parsed: URL
try {
parsed = new URL(value)
} catch {
throw new Error(`无法解析的 URL: ${value}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`不支持的地址协议(仅允许 http/https 与 about:blank): ${parsed.protocol}`)
}
return parsed.toString()
}
function isLikelyUrl(value: string): boolean {
const candidate = `http://${value}`
try {
const url = new URL(candidate)
const host = url.hostname.toLowerCase()
// 域名 / IP(v4/v6)/ localhost 与 *.localhost 视为直达地址
const isHost =
host === 'localhost' ||
host.endsWith('.localhost') ||
host.includes('.') ||
host.includes(':')
// 无空格且形如域名/IP/localhost
return isHost && !/\s/.test(value)
} catch {
return false
}
}
/** 输入归一化:URL/域名/IP/localhost 直达,普通文本走搜索 */
export function resolveBrowserDestination(input: string): string {
const value = input.trim()
if (!value) throw new Error('地址不能为空。')
if (value === 'about:blank') return value
if (/^https?:\/\//i.test(value)) return assertSafeBrowserUrl(value)
if (isLikelyUrl(value)) return assertSafeBrowserUrl(`https://${value}`)
return SEARCH_URL + encodeURIComponent(value)
}
// ---- Observe 观察策略 ----
export const DEFAULT_BROWSER_OBSERVE_MAX_ELEMENTS = 240
export const MAX_BROWSER_OBSERVE_MAX_ELEMENTS = 400
export const MIN_BROWSER_OBSERVE_MAX_ELEMENTS = 20
/** 可交互 AX role 白名单(占观察结果前 2/3 配额) */
export const INTERACTIVE_AX_ROLES = new Set([
'button',
'link',
'textfield',
'textbox',
'searchbox',
'checkbox',
'radiobutton',
'combobox',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'tab',
'option',
'switch',
'slider'
])
/** 无名称也值得返回的 role(依赖 role 本身可定位) */
export const NAMELESS_ALLOWED_ROLES = new Set(['button', 'textfield', 'textbox', 'link', 'checkbox', 'combobox'])
export function resolveBrowserObserveMaxElements(requested?: number): number {
if (typeof requested !== 'number' || !Number.isFinite(requested)) return DEFAULT_BROWSER_OBSERVE_MAX_ELEMENTS
return Math.min(MAX_BROWSER_OBSERVE_MAX_ELEMENTS, Math.max(MIN_BROWSER_OBSERVE_MAX_ELEMENTS, Math.round(requested)))
}
export function isInteractiveAxRole(role: string): boolean {
return INTERACTIVE_AX_ROLES.has(role.toLowerCase())
}
export interface BrowserObservationCandidate {
role: string
name: string
editable?: boolean
}
/** 可交互元素优先(约 2/3 配额),其余语义上下文补足 */
export function prioritizeBrowserObservationCandidates<T extends BrowserObservationCandidate>(
candidates: readonly T[],
maxElements: number
): T[] {
const interactiveLimit = Math.ceil(maxElements * (2 / 3))
const interactive: T[] = []
const context: T[] = []
for (const candidate of candidates) {
if (interactive.length < interactiveLimit && (isInteractiveAxRole(candidate.role) || candidate.editable)) {
interactive.push(candidate)
} else {
context.push(candidate)
}
}
const contextLimit = maxElements - interactive.length
return [...interactive, ...context.slice(0, Math.max(0, contextLimit))]
}
/** 不同 role 的可访问名称截断上限,避免长文本撑爆快照 */
export function browserObservationNameLimit(role: string): number {
if (role === 'link' || role === 'button' || role === 'menuitem') return 80
if (role === 'heading') return 120
return 160
}
@@ -1,256 +0,0 @@
import { app } from 'electron'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types'
import { getBrowserController } from './browser-controller'
/**
* 本地浏览器配置导入(复用各网站登录态)。
*
* 实现思路(零解密依赖,纯文件迁移):
* - Local Storage / IndexedDB 在 Chromium 里是明文 leveldb,直接拷入受管分区即可生效。
* - Cookies 用 AES-GCM 加密,密钥经 OS 加密后存于浏览器根目录 Local State
* (os_crypt.encrypted_key)。Windows 上该密钥由 DPAPI(用户级)包裹——同一
* Windows 用户的任意进程都能解开,因此把这份密钥「移植」进本应用的 Local State,
* 本应用内的 Chromium 即可解密拷贝过来的 Cookies(Chrome 127+ 的 app-bound
* 加密 V20 除外,会明确提示不支持)。macOS/Linux 的密钥绑定钥匙串/KWallet,
* Cookies 无法直接迁移,仅迁移 Local Storage。
*/
/** Chromium 系浏览器的用户数据根目录(按平台) */
const SOURCE_ROOTS: Array<{ id: string; name: string; win: string; mac: string; linux: string }> = [
{
id: 'chrome',
name: 'Google Chrome',
win: join(process.env.LOCALAPPDATA ?? '', 'Google', 'Chrome', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome'),
linux: join(homedir(), '.config', 'google-chrome')
},
{
id: 'edge',
name: 'Microsoft Edge',
win: join(process.env.LOCALAPPDATA ?? '', 'Microsoft', 'Edge', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Microsoft Edge'),
linux: join(homedir(), '.config', 'microsoft-edge')
},
{
id: 'brave',
name: 'Brave',
win: join(process.env.LOCALAPPDATA ?? '', 'BraveSoftware', 'Brave-Browser', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'),
linux: join(homedir(), '.config', 'BraveSoftware', 'Brave-Browser')
},
{
id: 'chromium',
name: 'Chromium',
win: join(process.env.LOCALAPPDATA ?? '', 'Chromium', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Chromium'),
linux: join(homedir(), '.config', 'chromium')
}
]
/** 受管浏览器分区目录(persist:aso-browser 的落盘位置) */
function partitionDir(): string {
return join(app.getPath('userData'), 'Partitions', 'aso-browser')
}
function sourceRoot(id: string): string {
const source = SOURCE_ROOTS.find((s) => s.id === id)
if (!source) return ''
return process.platform === 'win32' ? source.win : process.platform === 'darwin' ? source.mac : source.linux
}
/** 读取 profile 的展示名(Preferences 的 profile.name,失败时用目录名) */
function readProfileName(profileDir: string): string {
try {
const file = join(profileDir, 'Preferences')
if (statSync(file).size > 8 * 1024 * 1024) return profileDir.split(/[\\/]/).pop() || profileDir
const raw = JSON.parse(readFileSync(file, 'utf8')) as { profile?: { name?: string } }
return raw.profile?.name?.trim() || profileDir.split(/[\\/]/).pop() || profileDir
} catch {
return profileDir.split(/[\\/]/).pop() || profileDir
}
}
/** 检测本机可导入的浏览器与 profile 列表 */
export function detectBrowserSources(): BrowserSource[] {
const sources: BrowserSource[] = []
for (const source of SOURCE_ROOTS) {
const root = sourceRoot(source.id)
if (!root || !existsSync(root)) continue
const profiles: BrowserProfileMeta[] = []
try {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
if (entry.name.startsWith('.') || entry.name === 'System Profile' || entry.name === 'Guest Profile') continue
const profileDir = join(root, entry.name)
if (!existsSync(join(profileDir, 'Preferences'))) continue
profiles.push({ dir: entry.name, name: readProfileName(profileDir) })
}
} catch {
// 目录不可读则跳过该浏览器
continue
}
if (profiles.length > 0) {
sources.push({ id: source.id, name: source.name, userDataDir: root, profiles })
}
}
return sources
}
function copyFileSafe(from: string, to: string): void {
try {
copyFileSync(from, to)
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') {
throw new Error('源浏览器文件被占用:请完全退出对应的浏览器(含后台进程)后重试。')
}
throw err
}
}
/** 拷贝 leveldb 目录(跳过 LOCK/LOG 运行时文件),目标已存在时先清空 */
function copyLevelDbDir(from: string, to: string): boolean {
if (!existsSync(from)) return false
const entries = readdirSync(from, { withFileTypes: true })
const files = entries.filter((e) => e.isFile() && e.name !== 'LOCK' && !e.name.startsWith('LOG'))
if (files.length === 0) return false
rmSync(to, { recursive: true, force: true })
mkdirSync(to, { recursive: true })
for (const file of files) {
copyFileSafe(join(from, file.name), join(to, file.name))
}
return true
}
interface SourceKeyInfo {
key: string | null
/** app-bound(V20)等不可移植场景的说明 */
note?: string
}
/**
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。
* Windows:前缀 DPAPI 可移植;V20 为 app-bound 加密(Chrome/Edge 127+),
* 其他进程无法解开,明确降级。macOS/Linux:密钥在系统钥匙串/KWallet,跳过。
*/
function readSourceKey(userDataDir: string): SourceKeyInfo {
if (process.platform !== 'win32') {
return { key: null, note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' }
}
try {
const raw = JSON.parse(readFileSync(join(userDataDir, 'Local State'), 'utf8')) as {
os_crypt?: { encrypted_key?: string }
}
const encoded = raw.os_crypt?.encrypted_key
if (!encoded) return { key: null, note: '未找到源浏览器 Cookie 密钥,跳过 Cookie 迁移。' }
const bytes = Buffer.from(encoded, 'base64')
if (bytes.subarray(0, 3).toString('utf8') === 'V20') {
return { key: null, note: '该浏览器使用了应用绑定加密(app-bound encryption),无法第三方迁移 Cookie;已导入 Local Storage,首次访问各站点仍需登录一次。' }
}
if (bytes.subarray(0, 5).toString('utf8') !== 'DPAPI') {
return { key: null, note: '源浏览器 Cookie 密钥格式不受支持,跳过 Cookie 迁移。' }
}
return { key: encoded }
} catch {
return { key: null, note: '读取源浏览器 Local State 失败,跳过 Cookie 迁移。' }
}
}
/** 把源密钥移植进本应用根 Local State(首次会备份原文件) */
function transplantKey(sourceKey: string): void {
const appLocalState = join(app.getPath('userData'), 'Local State')
const backup = `${appLocalState}.aso-backup`
let doc: Record<string, unknown> = {}
if (existsSync(appLocalState)) {
try {
doc = JSON.parse(readFileSync(appLocalState, 'utf8')) as Record<string, unknown>
if (!existsSync(backup)) copyFileSync(appLocalState, backup)
} catch {
doc = {}
}
}
const osCrypt = (doc.os_crypt ?? {}) as Record<string, unknown>
osCrypt.encrypted_key = sourceKey
doc.os_crypt = osCrypt
writeFileSync(appLocalState, JSON.stringify(doc, null, 2), 'utf8')
}
/** 导入指定浏览器的 profile 配置到受管浏览器分区 */
export function importBrowserProfile(sourceId: string, profileDir: string): BrowserImportResult {
const browser = getBrowserController()
if (browser.hasTabs) {
throw new Error('请先关闭内置浏览器的所有标签页,再导入浏览器配置。')
}
const root = sourceRoot(sourceId)
if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。')
// 只接受该根目录下的直接子目录名,拒绝任意路径注入
const dirName = profileDir.split(/[\\/]/).pop() ?? ''
const sourceProfile = join(root, dirName)
if (dirName !== profileDir || !existsSync(join(sourceProfile, 'Preferences'))) {
throw new Error('profile 目录不存在。')
}
const sourceName = SOURCE_ROOTS.find((s) => s.id === sourceId)?.name ?? sourceId
const target = partitionDir()
// 1) Local Storage(明文 leveldb,直接迁移)
let localStorage = false
try {
localStorage = copyLevelDbDir(join(sourceProfile, 'Local Storage', 'leveldb'), join(target, 'Local Storage', 'leveldb'))
} catch (err) {
throw new Error(err instanceof Error ? err.message : `Local Storage 迁移失败: ${String(err)}`)
}
// 2) Cookies + 密钥移植
let cookies = false
let cookieNote: string | undefined
const keyInfo = readSourceKey(root)
const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies'))
? join(sourceProfile, 'Network', 'Cookies')
: existsSync(join(sourceProfile, 'Cookies'))
? join(sourceProfile, 'Cookies')
: null
if (keyInfo.key && cookieDb) {
try {
transplantKey(keyInfo.key)
mkdirSync(join(target, 'Network'), { recursive: true })
copyFileSafe(cookieDb, join(target, 'Network', 'Cookies'))
for (const suffix of ['-wal', '-shm']) {
if (existsSync(`${cookieDb}${suffix}`)) copyFileSafe(`${cookieDb}${suffix}`, join(target, 'Network', `Cookies${suffix}`))
}
cookies = true
} catch (err) {
cookieNote = err instanceof Error ? err.message : 'Cookie 迁移失败。'
}
} else {
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
}
const parts: string[] = []
if (cookies) parts.push('Cookie(站点登录态)')
if (localStorage) parts.push('LocalStorage')
if (!cookies && !localStorage) {
return { ok: false, cookies, localStorage, cookieNote, message: `未在 ${sourceName}(${dirName})中找到可迁移的登录数据。` }
}
browser.setImportedInfo({
browser: sourceName,
profile: readProfileName(sourceProfile),
at: new Date().toISOString(),
cookies,
localStorage,
note: cookieNote
})
return {
ok: true,
cookies,
localStorage,
cookieNote,
message: `已从 ${sourceName} 导入 ${parts.join(' + ')}。${cookieNote ?? ''}${
cookies ? '若登录态未生效,重启应用后重试。' : ''
}`
}
}
+3 -32
View File
@@ -1,20 +1,10 @@
import { app, BrowserWindow, ipcMain, Menu, nativeTheme, protocol, shell } from 'electron'
import { app, BrowserWindow, ipcMain, Menu, shell } from 'electron'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { registerIpc } from './ipc'
import { getPluginHost } from './plugins/plugin-host'
import { getBrowserController } from './browser/browser-controller'
process.env.APP_ROOT = join(__dirname, '..')
// 外置插件页的特权协议(必须在 app ready 之前注册):
// - plugin://<id>/<file> → userData/plugins/<id>/ 内文件(宿主做路径穿越防护)
// - peer://assets/tokens.css → 主题 token 样式表(插件页 <link> 引用,保证深浅色/accent 连续)
protocol.registerSchemesAsPrivileged([
{ scheme: 'plugin', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
{ scheme: 'peer', privileges: { standard: true, secure: true, supportFetchAPI: true } }
])
export const VITE_DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL
export const RENDERER_DIST = join(process.env.APP_ROOT, 'renderer')
@@ -41,16 +31,13 @@ function registerWindowControls(): void {
}
async function createWindow(): Promise<void> {
// 窗口底色跟随深浅色(加载/resize 时不闪白);构造函数里已按持久化偏好设置过
// nativeTheme.themeSource,这里用实际生效值
const dark = nativeTheme.shouldUseDarkColors
const win = new BrowserWindow({
width: 1280,
height: 840,
minWidth: 960,
minHeight: 640,
show: false,
backgroundColor: dark ? '#212121' : '#ffffff',
backgroundColor: '#ffffff',
title: 'Inkreach Agent',
// 窗口/任务栏图标(Windows/Linux;macOS 打包时由 build 配置提供 icns)
...(existsSync(APP_ICON) ? { icon: APP_ICON } : {}),
@@ -64,11 +51,6 @@ async function createWindow(): Promise<void> {
}
})
// 深浅色切换时同步窗口底色(避免暗色下 resize/加载闪白块)
nativeTheme.on('updated', () => {
win.setBackgroundColor(nativeTheme.shouldUseDarkColors ? '#212121' : '#ffffff')
})
// 最大化状态同步给渲染层(切换「最大化/还原」字形)
const sendWinState = (): void => win.webContents.send('win:state', win.isMaximized())
win.on('maximize', sendWinState)
@@ -89,13 +71,9 @@ async function createWindow(): Promise<void> {
})
})
// 受管浏览器的原生 WebContentsView 挂在主窗口 contentView 上
getBrowserController().setOwnerWindow(win)
// 窗口关闭时未决审批一律拒绝,避免 agent 永久挂起;受管浏览器同步清理
// 窗口关闭时未决审批一律拒绝,避免 agent 永久挂起
win.on('closed', () => {
agentService.approvals.settleAll(false)
getBrowserController().handleWindowClosed()
})
// 外部链接交给系统默认浏览器,不在应用内开新窗口
@@ -143,13 +121,6 @@ if (!app.requestSingleInstanceLock()) {
app.setAppUserModelId('com.inkreach.agent')
}
Menu.setApplicationMenu(null)
// 插件宿主:扫描/装载 + 协议映射 + 插件启停后重建 agent 运行时
const pluginHost = getPluginHost()
pluginHost.init()
pluginHost.registerProtocols()
pluginHost.setRuntimeRebuilder(() => agentService.rebuildRuntime())
void createWindow()
app.on('activate', () => {
+5 -153
View File
@@ -1,10 +1,7 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { mkdirSync } from 'node:fs'
import { userInfo } from 'node:os'
import { AgentService } from './pi/agent-service'
import { getPluginHost } from './plugins/plugin-host'
import { getBrowserController } from './browser/browser-controller'
import { detectBrowserSources, importBrowserProfile } from './browser/browser-profile'
/**
* 注册 IPC 通道并把 agent 事件转发到所有渲染窗口。
@@ -142,69 +139,19 @@ export function registerIpc(): AgentService {
return pass(() => service.logoutProvider(providerId))
})
// ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ----
ipcMain.handle('thinking:set', (_event, level: unknown) => {
if (typeof level !== 'string') throw new Error('invalid thinking level')
return pass(() => service.setThinkingLevel(level as never))
})
ipcMain.handle('session:compact', (_event, instructions: unknown) => {
if (instructions !== undefined && instructions !== null && typeof instructions !== 'string') {
throw new Error('invalid compaction instructions')
}
return pass(() => service.compactSession(instructions || undefined))
})
ipcMain.handle('session:compact:abort', () => pass(() => service.abortCompaction()))
ipcMain.handle('session:autoCompact', (_event, enabled: unknown) => {
if (typeof enabled !== 'boolean') throw new Error('invalid flag')
return pass(() => service.setAutoCompactionEnabled(enabled))
})
ipcMain.handle('session:forkPoints', () => pass(() => service.listForkPoints()))
ipcMain.handle('session:navigate', (_event, entryId: unknown, options: unknown) => {
if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id')
const o = (options ?? {}) as { summarize?: unknown; customInstructions?: unknown }
if (o.summarize !== undefined && typeof o.summarize !== 'boolean') throw new Error('invalid navigate options')
if (o.customInstructions !== undefined && typeof o.customInstructions !== 'string') {
throw new Error('invalid navigate options')
}
return pass(() =>
service.navigateTree(entryId, {
...(o.summarize === true ? { summarize: true } : {}),
...(typeof o.customInstructions === 'string' ? { customInstructions: o.customInstructions } : {})
})
)
})
// 会话末尾分叉为新会话(pi fork,原会话保留)
ipcMain.handle('session:fork', () => pass(() => service.forkSession()))
// 从会话树上的锚点分叉为新会话('at' 含该条为止 / 'before' 到其父条目为止)
ipcMain.handle('session:forkAt', (_event, entryId: unknown, position: unknown) => {
if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id')
if (position !== 'at' && position !== 'before') throw new Error('invalid fork position')
return pass(() => service.forkSessionAt(entryId, position))
})
// ---- 应用设置 ----
ipcMain.handle('settings:get', () => service.getSettings())
// 当前生效的系统提示词(pi 原生 + 技能注入,只读展示)
ipcMain.handle('settings:getSystemPrompt', () => service.getSystemPrompt())
ipcMain.handle('settings:setPrompt', (_event, text: unknown) => {
if (typeof text !== 'string') throw new Error('invalid prompt')
return pass(() => service.setSystemPrompt(text))
})
ipcMain.handle('settings:setTheme', (_event, id: unknown) => {
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid theme id')
service.setThemeColor(id.trim())
})
ipcMain.handle('settings:setThemeMode', (_event, mode: unknown) => {
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') throw new Error('invalid theme mode')
service.setThemeMode(mode)
})
// ---- 工作区 ----
ipcMain.handle('workspaces:list', () =>
service.listWorkspaces().catch((err: unknown) => {
@@ -310,100 +257,5 @@ export function registerIpc(): AgentService {
return service.pickAttachments(win ?? undefined)
})
// ---- 内置受管浏览器 ----
// 控制器构造会调 session.fromPartition,必须等 app ready;
// IPC 处理器在渲染层就绪后才可能被调用,惰性取用是安全的
void app.whenReady().then(() => {
getBrowserController().onState((state) => {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send('browser:event', { type: 'state', state })
}
})
})
const browserPass = <T,>(fn: () => T): T => {
try {
return fn()
} catch (err) {
throw new Error(err instanceof Error ? err.message : String(err))
}
}
ipcMain.handle('browser:getState', () => browserPass(() => getBrowserController().getState()))
ipcMain.handle('browser:navigate', (_e, input: unknown) =>
browserPass(() => getBrowserController().navigateDisplay(typeof input === 'string' ? input : ''))
)
ipcMain.handle('browser:goBack', () => browserPass(() => getBrowserController().goBack()))
ipcMain.handle('browser:goForward', () => browserPass(() => getBrowserController().goForward()))
ipcMain.handle('browser:reload', () => browserPass(() => getBrowserController().reload()))
ipcMain.handle('browser:newTab', (_e, url: unknown) =>
browserPass(() => getBrowserController().newTab(typeof url === 'string' && url ? url : undefined, false))
)
ipcMain.handle('browser:selectTab', (_e, tabId: unknown) =>
browserPass(() => getBrowserController().selectTab(typeof tabId === 'string' ? tabId : ''))
)
ipcMain.handle('browser:closeTab', (_e, tabId: unknown) =>
browserPass(() => getBrowserController().closeTab(typeof tabId === 'string' ? tabId : undefined))
)
ipcMain.handle('browser:close', () => browserPass(() => getBrowserController().close()))
// 渲染层冻结帧:菜单弹出需暂时隐藏原生视图前,截当前页面画面做占位
ipcMain.handle('browser:captureTab', (_e, tabId: unknown) =>
browserPass(() => getBrowserController().captureTabDataUrl(typeof tabId === 'string' && tabId ? tabId : undefined))
)
ipcMain.handle('browser:acknowledgeRisk', () => browserPass(() => getBrowserController().acknowledgeRisk()))
// 渲染层 BrowserSlot 量出的占位矩形(原生 WebContentsView 对齐到 DOM 布局)
ipcMain.handle('browser:layout', (_e, layout: unknown) => {
const l = layout as { tabId?: unknown; visible?: unknown; revision?: unknown; bounds?: unknown }
const { tabId, visible, revision } = l
const bounds = l.bounds as { x?: unknown; y?: unknown; width?: unknown; height?: unknown } | undefined
const x = bounds?.x
const y = bounds?.y
const width = bounds?.width
const height = bounds?.height
if (
typeof tabId !== 'string' ||
typeof visible !== 'boolean' ||
typeof revision !== 'number' ||
typeof x !== 'number' ||
typeof y !== 'number' ||
typeof width !== 'number' ||
typeof height !== 'number'
) {
throw new Error('invalid browser layout')
}
return browserPass(() => getBrowserController().applyLayout({ tabId, visible, revision, bounds: { x, y, width, height } }))
})
// ---- 本地浏览器配置导入 ----
ipcMain.handle('browser:detectSources', () => browserPass(() => detectBrowserSources()))
ipcMain.handle('browser:importProfile', (_e, sourceId: unknown, profileDir: unknown) => {
if (typeof sourceId !== 'string' || typeof profileDir !== 'string') throw new Error('invalid import source')
return browserPass(() => importBrowserProfile(sourceId, profileDir))
})
// ---- 插件系统 ----
void app.whenReady().then(() => {
// 插件启停/重载会重建 agent 运行时(extensionFactories 重跑)
getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime())
})
ipcMain.handle('plugins:list', () => getPluginHost().list())
ipcMain.handle('plugins:uiState', () => getPluginHost().uiState())
ipcMain.handle('plugins:setEnabled', (_e, id: unknown, enabled: unknown) => {
if (typeof id !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid plugin toggle')
return getPluginHost().setEnabled(id, enabled)
})
ipcMain.handle('plugins:reload', () => getPluginHost().reload())
ipcMain.handle('plugins:openView', (_e, view: unknown, params: unknown) => {
if (typeof view !== 'string' || !view) throw new Error('invalid view')
getPluginHost().openView(view, params)
})
// 插件页(iframe)经渲染层桥转发来的状态胶囊写请求;pluginId 键控防越权
ipcMain.handle('plugins:setStatus', (_e, pluginId: unknown, itemId: unknown, item: unknown) => {
if (typeof pluginId !== 'string' || typeof itemId !== 'string') throw new Error('invalid status item')
getPluginHost().setStatusItem(pluginId, itemId, (item ?? null) as never)
return Promise.resolve()
})
return service
}
+82 -349
View File
@@ -1,14 +1,12 @@
import { app, dialog, shell } from 'electron'
import { nativeTheme } from 'electron'
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { homedir } from 'node:os'
import {
createAgentSessionRuntime,
createAgentSessionServices,
createAgentSessionFromServices,
estimateTokens,
getAgentDir,
loadSkills,
SessionManager,
type AgentSession,
type AgentSessionEvent,
@@ -17,21 +15,16 @@ import {
type SessionInfo
} from '@earendil-works/pi-coding-agent'
import { ApprovalService, createApprovalExtension } from './approval'
import { getPluginHost } from '../plugins/plugin-host'
import { browserToolNames } from '../tools/browser-tools'
import { demoTools, TOOLS_REQUIRING_APPROVAL } from '../tools/demo-tools'
import type {
AgentUiEvent,
AppSettings,
AttachmentInfo,
ChatResources,
CommandDetail,
ContextBreakdownEntry,
ContextUsageInfo,
CustomProviderConfig,
ForkPoint,
ModelCatalog,
ModelInfo,
NavigateTreeOptions,
PermissionMode,
ResourceDetails,
SendImage,
@@ -39,8 +32,6 @@ import type {
SessionState,
SessionStatsInfo,
SkillDetail,
ThinkingLevel,
ThemeMode,
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
@@ -48,13 +39,6 @@ import type {
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
const DELTA_FLUSH_MS = 40
/** SDK 注册的自定义工具名(用量卡片中与内置「系统工具」分开统计)。
* browserTools 已迁入浏览器插件(pi.registerTool 注册),这里同步其名单。 */
const CUSTOM_TOOL_NAMES = new Set<string>(browserToolNames)
/** 需要人工审批的工具名单(高危内置工具;业务演示工具已随 demo-tools.ts 移除) */
const TOOLS_REQUIRING_APPROVAL: ReadonlySet<string> = new Set(['bash', 'write', 'edit'])
/**
* pi 会话运行时的主进程封装:
* - 基于 AgentSessionRuntime 支持会话新建/切换(自动重挂事件订阅)
@@ -69,23 +53,14 @@ export class AgentService {
private flushTimer: NodeJS.Timeout | null = null
private attached: AgentSession | null = null
private unsubscribeAttached: (() => void) | null = null
private settings: AppSettings = {}
private settings: AppSettings = { systemPrompt: '' }
/** 当前工作区目录(agent cwd),会话按目录隔离 */
private workspaceDir = ''
private approvalsRegistered = false
/** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */
private permissionMode: PermissionMode = 'confirm'
/** 技能清单 token 估算缓存(随工作区失效) */
private skillsEstimate: { cwd: string; value: number } | null = null
readonly approvals = new ApprovalService()
constructor() {
// 设置同步读入 + 应用持久化的深浅色偏好(nativeTheme 会影响渲染层
// prefers-color-scheme 与原生控件配色;窗口创建前生效可避免首帧闪错色)
this.loadSettings()
nativeTheme.themeSource = this.settings.themeMode ?? 'system'
}
/** 工作区根目录:文档/Inkreach,每个子目录 = 一个工作区 */
get workspacesRoot(): string {
return join(app.getPath('documents'), 'Inkreach')
@@ -107,6 +82,7 @@ export class AgentService {
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<AppSettings>
this.settings = {
systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : '',
workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined,
customWorkspaces: Array.isArray(raw.customWorkspaces)
? raw.customWorkspaces.filter((p): p is string => typeof p === 'string')
@@ -114,11 +90,10 @@ export class AgentService {
// 历史版本存过 autoEdit/plan,统一收敛回 confirm
permissionMode:
raw.permissionMode === 'full' ? 'full' : 'confirm',
themeColor: typeof raw.themeColor === 'string' ? raw.themeColor : undefined,
themeMode: raw.themeMode === 'light' || raw.themeMode === 'dark' ? raw.themeMode : 'system'
themeColor: typeof raw.themeColor === 'string' ? raw.themeColor : undefined
}
} catch {
this.settings = { permissionMode: this.permissionMode, themeMode: 'system' }
this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
}
this.permissionMode = this.settings.permissionMode ?? 'confirm'
this.initWorkspaces()
@@ -271,10 +246,9 @@ export class AgentService {
cwd: targetCwd,
agentDir: targetAgentDir,
resourceLoaderOptions: {
...(this.settings.systemPrompt.trim() ? { systemPrompt: this.settings.systemPrompt } : {}),
extensionFactories: [
createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode),
// 插件聚合 extension:全部已启用插件在同一真实 ExtensionAPI 上下文里激活
getPluginHost().createAggregatedExtension()
createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode)
]
}
})
@@ -294,9 +268,8 @@ export class AgentService {
const result = await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent
// 业务工具(browserTools 等)由插件经 pi.registerTool 注册(聚合 extension),
// 此处不再传 customTools
sessionStartEvent,
customTools: demoTools
})
return { ...result, services, diagnostics: services.diagnostics }
}
@@ -323,15 +296,36 @@ export class AgentService {
private emitSwitched(session: AgentSession): void {
const state = this.buildState(session)
// 工具状态不再随事件重发:渲染层用 historyToTools 从 messages 自行重建,
// 重复克隆一遍全量工具参数/结果是长会话切换卡顿的大头(历史被序列化两次)
this.emit({
type: 'session_switched',
state,
messages: session.messages.map((m) => toCloneable(m))
messages: session.messages.map((m) => toCloneable(m)),
activeTools: this.collectToolStates(session)
})
// 切换/回溯后立刻带上一次统计(上下文容量、缓存命中率),Usage Card 无需等第一回合
void this.emitStats()
}
/** 从历史消息中恢复工具状态(用于切换会话后 ToolCard 的参数/结果展示) */
private collectToolStates(session: AgentSession): Record<string, unknown> {
const tools: Record<string, unknown> = {}
for (const message of session.messages) {
const m = message as { role?: string; content?: unknown; toolCallId?: unknown; toolName?: unknown; isError?: unknown }
if (m.role !== 'assistant' || !Array.isArray(m.content)) continue
for (const block of m.content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>) {
if (block.type === 'toolCall' && block.id) {
tools[block.id] = { toolName: block.name ?? 'tool', args: toCloneable(block.arguments), status: 'done' }
}
}
}
for (const message of session.messages) {
const m = message as { role?: string; toolCallId?: unknown; toolName?: unknown; content?: unknown; isError?: unknown }
if (m.role !== 'toolResult' || typeof m.toolCallId !== 'string') continue
tools[m.toolCallId] = {
...((tools[m.toolCallId] as Record<string, unknown>) ?? { toolName: m.toolName ?? 'tool' }),
result: toCloneable(m),
status: m.isError ? 'error' : 'done'
}
}
return tools
}
async ensureReady(): Promise<SessionState> {
@@ -372,8 +366,6 @@ export class AgentService {
}
case 'message_end':
this.emit({ type: 'message_end', message: toCloneable(event.message) })
// 每条 assistant 消息落地都会刷新 usage → 上下文容量/缓存命中率即时更新
if (readRole(event.message) === 'assistant') void this.emitStats()
break
case 'tool_execution_start':
this.emit({
@@ -414,27 +406,6 @@ export class AgentService {
case 'session_info_changed':
this.emit({ type: 'session_info', name: event.name })
break
case 'thinking_level_changed':
this.emit({ type: 'thinking_level_changed', level: event.level })
if (this.attached) this.emit({ type: 'state', state: this.buildState(this.attached) })
break
case 'compaction_start':
this.emit({ type: 'compaction_start', reason: event.reason })
break
case 'compaction_end': {
this.emit({
type: 'compaction_end',
reason: event.reason,
aborted: event.aborted,
summary: event.result?.summary,
tokensBefore: event.result?.tokensBefore,
errorMessage: event.errorMessage,
// 压缩替换了上下文历史(插入 compactionSummary 通知行),渲染层就地换血
messages: (this.attached?.messages ?? []).map((m) => toCloneable(m))
})
void this.emitStats()
break
}
default:
break
}
@@ -449,7 +420,6 @@ export class AgentService {
name: String(model.name ?? model.id ?? '')
}
: undefined
const thinkingLevels = session.supportsThinking() ? session.getAvailableThinkingLevels() : []
return {
sessionId: session.sessionId,
sessionName: session.sessionName,
@@ -458,145 +428,19 @@ export class AgentService {
activeTools: session.getActiveToolNames(),
messageCount: session.messages.length,
workspace: this.cwd,
permissionMode: this.permissionMode,
thinkingLevel: session.thinkingLevel,
thinkingLevels,
supportsThinking: thinkingLevels.length > 0,
isCompacting: session.isCompacting,
autoCompactionEnabled: session.autoCompactionEnabled
permissionMode: this.permissionMode
}
}
/** 上下文容量详情:getContextUsage + 按 ZCode 卡片类目的 token 估算(chars/4,与 pi 一致)。
* 类目:消息 / 自定义工具 / 系统工具 / 系统提示词 / 技能 / 其他;
* 技能块注入在系统提示词内,从提示词估算中拆出(不重复计数)。 */
private buildContextInfo(session: AgentSession): ContextUsageInfo {
const breakdown: ContextBreakdownEntry[] = [
{ key: 'messages', label: '消息', tokens: 0 },
{ key: 'customTools', label: '自定义工具', tokens: 0 },
{ key: 'builtinTools', label: '系统工具', tokens: 0 },
{ key: 'systemPrompt', label: '系统提示词', tokens: 0 },
{ key: 'skills', label: '技能', tokens: 0 },
{ key: 'other', label: '其他', tokens: 0 }
]
const byKey = new Map(breakdown.map((b) => [b.key, b]))
const systemTokens = Math.ceil(session.systemPrompt.length / 4)
const skillsTokens = this.estimateSkillsTokens(session)
byKey.get('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens)
byKey.get('skills')!.tokens = skillsTokens
for (const message of session.messages) {
const m = message as { role?: string; toolName?: unknown; content?: unknown }
switch (m.role) {
case 'user':
case 'assistant':
byKey.get('messages')!.tokens += estimateMessageTokens(message)
break
case 'toolResult': {
const key =
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
byKey.get(key)!.tokens += estimateMessageTokens(message)
break
}
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
case 'bashExecution':
case 'custom':
case 'compactionSummary':
case 'branchSummary':
byKey.get('other')!.tokens += estimateMessageTokens(message)
break
default:
break
}
}
const usage = session.getContextUsage()
let lastOutputTokens: number | undefined
for (let i = session.messages.length - 1; i >= 0; i--) {
const m = session.messages[i] as { role?: string; usage?: { output?: unknown } }
if (m.role === 'assistant' && m.usage && typeof m.usage.output === 'number') {
lastOutputTokens = m.usage.output
break
}
}
return {
tokens: usage && typeof usage.tokens === 'number' ? usage.tokens : null,
contextWindow: usage?.contextWindow ?? 0,
percent: usage && typeof usage.percent === 'number' ? usage.percent : null,
...(lastOutputTokens !== undefined ? { lastOutputTokens } : {}),
breakdown
}
}
/** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化)。
* 数据源与系统提示词同源(resource loader,覆盖 ~/.agents/skills 等全部默认位置) */
private estimateSkillsTokens(session: AgentSession): number {
if (this.skillsEstimate && this.skillsEstimate.cwd === this.cwd) return this.skillsEstimate.value
let value = 0
try {
const loaded = session.resourceLoader.getSkills()
value = Math.ceil(loaded.skills.reduce((sum, s) => sum + s.name.length + s.description.length + 8, 0) / 4)
} catch {
value = 0
}
this.skillsEstimate = { cwd: this.cwd, value }
return value
}
private async emitStats(): Promise<void> {
try {
const runtime = await this.getRuntime()
const session = runtime.session
const stats = session.getSessionStats()
const usage = session.getContextUsage()
// 缓存命中率:读缓存 / 全部提示 token(input + cacheRead + cacheWrite),
// 按本会话所有 assistant 响应聚合,0 表示会话内尚无任何响应
let cacheInput = 0
let cacheRead = 0
let cacheWrite = 0
let toolCalls = 0
let toolErrors = 0
for (const message of session.messages) {
const m = message as {
role?: string
usage?: { input?: unknown; cacheRead?: unknown; cacheWrite?: unknown }
content?: unknown
isError?: unknown
}
if (m.role === 'assistant') {
if (m.usage) {
cacheInput += Number(m.usage.input ?? 0) || 0
cacheRead += Number(m.usage.cacheRead ?? 0) || 0
cacheWrite += Number(m.usage.cacheWrite ?? 0) || 0
}
if (Array.isArray(m.content)) {
for (const block of m.content as Array<{ type?: unknown }>) {
if (block && typeof block === 'object' && block.type === 'toolCall') toolCalls += 1
}
}
} else if (m.role === 'toolResult' && m.isError === true) {
toolErrors += 1
}
}
const denominator = cacheInput + cacheRead + cacheWrite
const stats = runtime.session.getSessionStats()
const usage = runtime.session.getContextUsage()
const info: SessionStatsInfo = {
tokens: { ...stats.tokens },
cost: stats.cost,
contextPercent: usage && typeof usage.percent === 'number' ? Math.round(usage.percent) : undefined,
context: this.buildContextInfo(session),
...(denominator > 0
? {
cacheHitRate: Math.round((cacheRead / denominator) * 100),
cacheTokens: { read: cacheRead, write: cacheWrite, input: cacheInput }
}
: {}),
...(toolCalls > 0
? {
toolStats: {
calls: toolCalls,
successRate: Math.round(((toolCalls - toolErrors) / toolCalls) * 100)
}
}
: {})
contextPercent: usage && typeof usage.percent === 'number' ? Math.round(usage.percent) : undefined
}
this.emit({ type: 'stats', stats: info })
} catch {
@@ -637,11 +481,18 @@ export class AgentService {
async listChatResources(): Promise<ChatResources> {
const runtime = await this.getRuntime()
// 与系统提示词同源:resource loader 的技能发现(含 ~/.agents/skills 与工作区 .agents/skills)
const skills = runtime.session.resourceLoader.getSkills().skills.map((s) => ({
name: s.name,
description: s.description
}))
let skills: ChatResources['skills'] = []
try {
const loaded = loadSkills({
cwd: this.cwd,
agentDir: getAgentDir(),
skillPaths: [],
includeDefaults: true
})
skills = loaded.skills.map((s) => ({ name: s.name, description: s.description ?? '' }))
} catch (err) {
console.error('[agent-service] load skills failed:', err)
}
const commands = runtime.session.promptTemplates.map((t) => ({
name: t.name,
description: t.description ?? '',
@@ -650,31 +501,30 @@ export class AgentService {
return { commands, skills }
}
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。
* 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。 */
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新) */
async listResourceDetails(): Promise<ResourceDetails> {
const agentDir = getAgentDir()
const dirs = {
globalSkills: join(agentDir, 'skills'),
globalAgentsSkills: join(homedir(), '.agents', 'skills'),
projectSkills: join(this.cwd, '.pi', 'skills'),
projectAgentsSkills: join(this.cwd, '.agents', 'skills'),
globalCommands: join(agentDir, 'prompts'),
projectCommands: join(this.cwd, '.pi', 'prompts')
}
const skills: SkillDetail[] = []
let skills: SkillDetail[] = []
try {
const runtime = await this.getRuntime()
for (const s of runtime.session.resourceLoader.getSkills().skills) {
skills.push({
name: s.name,
description: s.description,
filePath: s.filePath,
// 与 resource loader 的默认归类一致:无来源信息时按用户级
scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user',
disableModelInvocation: Boolean(s.disableModelInvocation)
})
}
const loaded = loadSkills({
cwd: this.cwd,
agentDir,
skillPaths: [],
includeDefaults: true
})
skills = loaded.skills.map((s) => ({
name: s.name,
description: s.description ?? '',
filePath: s.filePath,
scope: s.sourceInfo?.scope === 'user' ? 'user' : 'project',
disableModelInvocation: Boolean(s.disableModelInvocation)
}))
} catch (err) {
console.error('[agent-service] load skill details failed:', err)
}
@@ -793,9 +643,6 @@ export class AgentService {
async stop(): Promise<void> {
if (!this.runtime && !this.initPromise) return
const runtime = await this.getRuntime()
// 压缩/分支摘要独立于 agent run,abort() 不会打断它们,停止时一并取消
runtime.session.abortCompaction()
runtime.session.abortBranchSummary()
await runtime.session.abort()
}
@@ -951,12 +798,6 @@ export class AgentService {
const mr = runtime.session.modelRuntime
const credentials = await mr.listCredentials()
const credSet = new Set(credentials.map((c) => `${c.providerId}:${c.type}`))
// 自定义提供商的 apiKey 存在 models.json(不进凭据存储),有 key 即视为已配置
const customKeyed = new Set(
Object.entries(this.readModelsConfig().providers)
.filter(([, v]) => Boolean((v as { apiKey?: unknown })?.apiKey))
.map(([id]) => id)
)
const providers = mr
.getProviders()
.map((p) => {
@@ -964,7 +805,7 @@ export class AgentService {
return {
id: pid,
name: String(p.name ?? pid),
hasApiKey: credSet.has(`${pid}:api_key`) || customKeyed.has(pid),
hasApiKey: credSet.has(`${pid}:api_key`),
isOAuth: credSet.has(`${pid}:oauth`),
models: mr
.getModels(pid)
@@ -1013,110 +854,17 @@ export class AgentService {
this.emit({ type: 'state', state: this.buildState(runtime.session) })
}
// ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ----
/** 设置思考等级(persist 到全局默认值);等级按模型能力校验 */
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
const runtime = await this.getRuntime()
const session = runtime.session
if (!session.getAvailableThinkingLevels().includes(level)) {
throw new Error(`当前模型不支持思考等级: ${level}`)
}
// thinking_level_changed 事件会回推 state;这里直接广播兜底
session.setThinkingLevel(level, { persist: true })
this.emit({ type: 'state', state: this.buildState(session) })
}
/** 手动压缩上下文(/compact): abort 当前运行后生成摘要并替换历史 */
async compactSession(customInstructions?: string): Promise<{ summary: string; tokensBefore: number }> {
const runtime = await this.getRuntime()
const trimmed = customInstructions?.trim()
const result = await runtime.session.compact(trimmed || undefined)
return { summary: result.summary, tokensBefore: result.tokensBefore }
}
/** 取消进行中的压缩 */
async abortCompaction(): Promise<void> {
this.runtime?.session.abortCompaction()
}
/** 开关自动压缩(阈值/溢出触发;pi settings 持久化) */
async setAutoCompactionEnabled(enabled: boolean): Promise<void> {
const runtime = await this.getRuntime()
runtime.session.setAutoCompactionEnabled(enabled)
this.emit({ type: 'state', state: this.buildState(runtime.session) })
}
/** 会话树上的可回溯点(当前分支上的用户消息,按序) */
async listForkPoints(): Promise<ForkPoint[]> {
const runtime = await this.getRuntime()
return runtime.session.getUserMessagesForForking().map((p) => ({ entryId: p.entryId, text: p.text }))
}
/** 回溯到某个用户消息(同会话文件内切换分支);summarize=true 时为被离开的分支生成分支摘要 */
async navigateTree(entryId: string, options?: NavigateTreeOptions): Promise<void> {
const runtime = await this.getRuntime()
const session = runtime.session
if (session.isStreaming) {
await session.abort()
}
const result = await session.navigateTree(entryId, {
...(options?.summarize ? { summarize: true } : {}),
...(options?.customInstructions?.trim() ? { customInstructions: options.customInstructions.trim() } : {})
})
if (result.cancelled) return
this.emitSwitched(session)
}
/** 在会话末尾分叉出新会话(pi fork:复制到新会话文件,原会话保留不动)。
* fork 后 runtime 重挂新会话,rebind 回调负责发 session_switched。 */
async forkSession(): Promise<void> {
const runtime = await this.getRuntime()
const session = runtime.session
if (session.isStreaming) {
await session.abort()
}
const leafId = session.sessionManager.getLeafId()
if (!leafId) {
throw new Error('会话还没有可分叉的内容')
}
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
}
// ---- 应用设置 ----
getSettings(): AppSettings {
return { ...this.settings }
}
/** 当前生效的系统提示词(pi 原生提示词 + 技能注入,只读展示用) */
async getSystemPrompt(): Promise<string> {
const runtime = await this.getRuntime()
return runtime.session.systemPrompt
}
/** 深浅色模式:写入 Electron nativeTheme(渲染层 prefers-color-scheme 随之变化)并持久化 */
setThemeMode(mode: ThemeMode): void {
this.settings = { ...this.settings, themeMode: mode }
this.saveSettings()
nativeTheme.themeSource = mode
}
/** 主题色偏好(纯外观,仅持久化;渲染层负责应用) */
setThemeColor(id: string): void {
this.settings = { ...this.settings, themeColor: id }
/** 更新系统提示词后重建运行时(continueRecent 重新挂载当前最近会话,历史保留) */
async setSystemPrompt(text: string): Promise<void> {
this.settings = { ...this.settings, systemPrompt: text }
this.saveSettings()
await this.rebuildRuntime()
}
/** 切换权限模式:即时生效(审批扩展每笔工具调用实时读取),并广播给渲染层 */
@@ -1130,8 +878,13 @@ export class AgentService {
}
}
/** 重建运行时(插件重载/启停后由 PluginHost 调用,extensionFactories 重跑) */
async rebuildRuntime(): Promise<void> {
/** 主题色偏好(纯外观,仅持久化;渲染层负责应用) */
setThemeColor(id: string): void {
this.settings = { ...this.settings, themeColor: id }
this.saveSettings()
}
private async rebuildRuntime(): Promise<void> {
const old = this.runtime
this.runtime = null
this.initPromise = null
@@ -1178,35 +931,15 @@ function samePath(a: string, b: string): boolean {
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
}
/** 事件消息对象转为可结构化克隆的普通对象(剥离函数/循环引用等不可克隆值)。
* structuredClone 远快于 JSON 往返——切换会话时整段历史都要过一遍这里,
* 长上下文下 JSON 序列化+反序列化是主进程最重的同步开销。 */
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
function toCloneable(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value ?? null
try {
return structuredClone(value)
return JSON.parse(JSON.stringify(value ?? null))
} catch {
try {
return JSON.parse(JSON.stringify(value))
} catch {
return null
}
return null
}
}
/** 消息级 token 估算缓存:消息对象落定后不可变(pi 只追加),按引用缓存。
* emitStats 在每条 assistant 消息后都会全量重扫,不缓存的话整个会话是 O(n²) 的重复估算。 */
const messageTokenCache = new WeakMap<object, number>()
function estimateMessageTokens(message: object): number {
let tokens = messageTokenCache.get(message)
if (tokens === undefined) {
tokens = estimateTokens(message as Parameters<typeof estimateTokens>[0])
messageTokenCache.set(message, tokens)
}
return tokens
}
function readRole(message: unknown): string {
if (message && typeof message === 'object' && 'role' in message) {
return String((message as { role: unknown }).role)
@@ -1,46 +0,0 @@
/**
* 浏览器插件(内置,组件级:UI 仍由宿主树 BrowserPanel 承载,面板 tab 见 App.tsx)。
* 主进程侧:browserTools 经 pi.registerTool 注册(原生路径,提示词注入/审批拦截
* 与 pi 生态一致),并示范内联 UI——受管浏览器打开时推状态胶囊。
*/
import { getBrowserController } from '../../browser/browser-controller'
import { browserTools } from '../../tools/browser-tools'
import type { BuiltinPlugin } from './index'
export const browserPlugin: BuiltinPlugin = {
manifest: {
id: 'browser',
name: '浏览器',
version: '0.1.0',
minContract: '0.1.0',
panel: true,
order: 10,
description: '受管浏览器(CDP 驱动,Agent 可操作,右侧面板查看)'
},
main: (api) => {
for (const tool of browserTools) {
api.pi.registerTool(tool)
}
const pushStatus = (): void => {
const state = getBrowserController().getState()
if (!state.open || state.tabs.length === 0) {
api.ui.removeStatusItem('tabs')
return
}
const active = state.tabs.find((t) => t.tabId === state.activeTabId)
const title = active?.title?.trim()
api.ui.setStatusItem('tabs', {
text: `浏览器 ${state.tabs.length} 页${title ? ` · ${title.slice(0, 12)}` : ''}`,
tone: 'default'
})
}
const off = getBrowserController().onState(pushStatus)
pushStatus()
return () => {
off()
api.ui.removeStatusItem('tabs')
}
}
}
@@ -1,19 +0,0 @@
/**
* 内置插件注册表(编译进宿主 bundle 的"特权实现"):
* - main:第一方工厂(不走动态 import;pi ExtensionAPI 语义与外置一致)
* - component:渲染层组件 key(plugins/builtin-components.ts 里登记的宿主树组件;
* 外置插件没有这项能力,遮蔽组件级内置需 manifest.replacesNative 声明)
* 浏览器面板(M3)在此登记。
*/
import type { HarnessPluginFactory, PluginManifest } from '@aso/plugin-sdk'
import { browserPlugin } from './browser'
export interface BuiltinPlugin {
manifest: PluginManifest
/** 主进程工厂(注册 pi 工具/事件、内联 UI) */
main?: HarnessPluginFactory
/** 渲染层宿主树组件 key(renderer/src/plugins/builtin-components.ts) */
component?: string
}
export const BUILTIN_PLUGINS: BuiltinPlugin[] = [browserPlugin]
@@ -1,495 +0,0 @@
/**
* 插件宿主(主进程):
* - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle
* - 外置插件:userData/plugins/<id>/manifest.json + 可选 main.mjs(动态 import)
* - 同 id 高 semver 胜、平级外置胜;外置遮蔽组件级内置需 manifest.replacesNative
* - 聚合为一个 InlineExtension 挂进 pi extensionFactories:插件工厂收到
* { pi, ui, storage, manifest },pi 为真实 ExtensionAPI(工具/事件/命令走原生路径)
* - 内联 UI(状态胶囊/工具栏按钮)数据存主进程(单一事实源),变更广播渲染层
* - plugin:// 协议把插件目录映射给 iframe 页(含路径穿越防护)
*
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
* ed25519 验签 + 开发者模式豁免)。
*/
import { app, BrowserWindow, net, protocol } from 'electron'
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
import type { HarnessPluginFactory, PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
export const HARNESS_CONTRACT_VERSION = '0.1.0'
interface ResolvedPlugin {
manifest: PluginManifest
source: 'builtin' | 'external'
/** 外置插件根目录(plugin:// 映射根) */
dir?: string
/** 内置:第一方工厂 / 渲染层组件 key */
builtinFactory?: HarnessPluginFactory
componentKey?: string
/** 外置 main.mjs 动态导入的工厂(reload 时替换) */
externalFactory?: HarnessPluginFactory
/** 装载/激活错误(管理页展示;不阻塞其他插件) */
error?: string
}
/** 最小 semver(x.y.z 数值三元组;忽略预发布段) */
function parseSemver(v: string): [number, number, number] | null {
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim())
if (!m) return null
return [Number(m[1]), Number(m[2]), Number(m[3])]
}
function compareSemver(a: string, b: string): number {
const pa = parseSemver(a)
const pb = parseSemver(b)
if (!pa || !pb) return 0
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1
}
return 0
}
class PluginHost {
private resolved = new Map<string, ResolvedPlugin>()
private disabled = new Set<string>()
/** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */
private rebuildRuntime: (() => Promise<void>) | null = null
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
private statusItems = new Map<string, Map<string, StatusItem>>()
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
/** 插件 KV 存储(pluginId → key → value) */
private storage = new Map<string, Map<string, unknown>>()
private storageSaveTimer: NodeJS.Timeout | null = null
private get externalRoot(): string {
return join(app.getPath('userData'), 'plugins')
}
private get settingsFile(): string {
return join(app.getPath('userData'), 'aso-plugin-settings.json')
}
private get storageFile(): string {
return join(app.getPath('userData'), 'aso-plugin-storage.json')
}
// ---- 生命周期 ----
init(): void {
try {
mkdirSync(this.externalRoot, { recursive: true })
} catch {
// 已存在或创建失败都继续(扫描时再报)
}
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as { disabled?: unknown }
if (Array.isArray(raw.disabled)) this.disabled = new Set(raw.disabled.filter((d): d is string => typeof d === 'string'))
} catch {
// 无设置文件 = 全部启用
}
try {
const raw = JSON.parse(readFileSync(this.storageFile, 'utf8')) as Record<string, unknown>
for (const [pid, kv] of Object.entries(raw)) {
if (kv && typeof kv === 'object' && !Array.isArray(kv)) {
this.storage.set(pid, new Map(Object.entries(kv as Record<string, unknown>)))
}
}
} catch {
// 无存储文件 = 空
}
void this.scan()
}
setRuntimeRebuilder(fn: () => Promise<void>): void {
this.rebuildRuntime = fn
}
/** 扫描内置注册表 + 外置目录并合并(不动已激活状态;reload 才重建) */
async scan(): Promise<void> {
const byId = new Map<string, ResolvedPlugin>()
for (const builtin of BUILTIN_PLUGINS) {
byId.set(builtin.manifest.id, {
manifest: builtin.manifest,
source: 'builtin',
builtinFactory: builtin.main,
componentKey: builtin.component
})
}
if (existsSync(this.externalRoot)) {
let entries: import('node:fs').Dirent[]
try {
entries = readdirSync(this.externalRoot, { withFileTypes: true })
} catch {
entries = []
}
for (const entry of entries) {
if (!entry.isDirectory()) continue
const dir = join(this.externalRoot, entry.name)
const manifest = this.readManifest(dir)
if (!manifest) continue
const existing = byId.get(manifest.id)
if (existing) {
const cmp = compareSemver(manifest.version, existing.manifest.version)
// 外置仅在严格更高版本时遮蔽内置;平级外置胜仅适用于同为外置的场景(此处 existing 必为内置)
if (cmp <= 0) continue
// 组件级内置只能被显式声明降级替代的外置遮蔽
if (existing.componentKey && !manifest.replacesNative) {
existing.error = `外置 ${manifest.version} 未声明 replacesNative,继续使用内置 ${existing.manifest.version}`
continue
}
}
if (compareSemver(manifest.minContract, HARNESS_CONTRACT_VERSION) > 0) {
byId.set(manifest.id, {
manifest,
source: 'external',
dir,
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
})
continue
}
byId.set(manifest.id, { manifest, source: 'external', dir })
}
}
this.resolved = byId
await this.loadExternalMains()
this.send('plugins:listChanged', null)
}
private readManifest(dir: string): PluginManifest | null {
const file = join(dir, 'manifest.json')
try {
const raw = JSON.parse(readFileSync(file, 'utf8')) as Partial<PluginManifest>
if (typeof raw.id !== 'string' || typeof raw.name !== 'string' || typeof raw.version !== 'string') return null
if (!/^[a-z][a-z0-9-]*$/.test(raw.id)) return null
if (!parseSemver(raw.version) || !parseSemver(raw.minContract ?? '0.0.0')) return null
return {
id: raw.id,
name: raw.name,
version: raw.version,
minContract: raw.minContract ?? '0.0.0',
description: typeof raw.description === 'string' ? raw.description : undefined,
icon: typeof raw.icon === 'string' ? raw.icon : undefined,
order: typeof raw.order === 'number' ? raw.order : undefined,
panel: raw.panel === true,
entry:
raw.entry && typeof raw.entry === 'object'
? {
page: typeof raw.entry.page === 'string' ? raw.entry.page : undefined,
main: typeof raw.entry.main === 'string' ? raw.entry.main : undefined
}
: undefined,
replacesNative: raw.replacesNative === true,
updateURL: typeof raw.updateURL === 'string' ? raw.updateURL : undefined
}
} catch {
return null
}
}
/** 动态导入外置 main.mjs(cache-bust 保证重载拿到新代码) */
private async loadExternalMains(): Promise<void> {
for (const plugin of this.resolved.values()) {
if (plugin.source !== 'external' || !plugin.manifest.entry?.main || plugin.error) continue
const file = resolve(plugin.dir!, plugin.manifest.entry.main)
try {
const url = pathToFileURL(file).href + `?t=${Date.now()}`
const mod = (await import(url)) as { default?: unknown }
if (typeof mod.default !== 'function') {
plugin.error = 'main.mjs 默认导出必须是插件工厂函数'
continue
}
plugin.externalFactory = mod.default as HarnessPluginFactory
plugin.error = undefined
} catch (err) {
plugin.externalFactory = undefined
plugin.error = `main 加载失败: ${err instanceof Error ? err.message : String(err)}`
}
}
}
/** 重新扫描 + 重新导入 + 清 UI 状态 + 重建 runtime(= pi /reload 等效) */
async reload(): Promise<void> {
this.statusItems.clear()
this.toolbarButtons.clear()
await this.scan()
this.send('plugins:uiState', this.uiState())
if (this.rebuildRuntime) await this.rebuildRuntime()
}
async setEnabled(id: string, enabled: boolean): Promise<void> {
if (enabled) this.disabled.delete(id)
else this.disabled.add(id)
try {
writeFileSync(this.settingsFile, JSON.stringify({ disabled: [...this.disabled] }, null, 2), 'utf8')
} catch (err) {
console.error('[plugins] save settings failed:', err)
}
await this.reload()
}
// ---- 查询 ----
isEnabled(id: string): boolean {
return !this.disabled.has(id)
}
list(): PluginInfo[] {
return [...this.resolved.values()]
.map((p) => ({
manifest: p.manifest,
source: p.source,
enabled: this.isEnabled(p.manifest.id),
hasMain: Boolean(p.builtinFactory ?? p.externalFactory),
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
hasComponent: Boolean(p.componentKey),
error: p.error
}))
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
}
getComponentKey(id: string): string | undefined {
const p = this.resolved.get(id)
return p?.componentKey
}
uiState(): PluginsUiState {
const status: PluginStatusEntry[] = []
for (const [pluginId, items] of this.statusItems) {
for (const [itemId, item] of items) status.push({ pluginId, itemId, item })
}
const toolbar: PluginToolbarEntry[] = []
for (const [pluginId, buttons] of this.toolbarButtons) {
for (const [buttonId, button] of buttons) toolbar.push({ pluginId, buttonId, button })
}
return { status, toolbar }
}
// ---- 内联 UI(数据驱动;单一事实源在主进程) ----
setStatusItem(pluginId: string, itemId: string, item: StatusItem | null): void {
if (!this.resolved.has(pluginId)) return
let items = this.statusItems.get(pluginId)
if (!items) {
items = new Map()
this.statusItems.set(pluginId, items)
}
if (item) items.set(itemId, item)
else items.delete(itemId)
if (items.size === 0) this.statusItems.delete(pluginId)
this.send('plugins:uiState', this.uiState())
}
registerToolbarButton(pluginId: string, button: ToolbarButtonDef): void {
if (!this.resolved.has(pluginId)) return
let buttons = this.toolbarButtons.get(pluginId)
if (!buttons) {
buttons = new Map()
this.toolbarButtons.set(pluginId, buttons)
}
buttons.set(button.id, button)
this.send('plugins:uiState', this.uiState())
}
removeToolbarButton(pluginId: string, buttonId: string): void {
const buttons = this.toolbarButtons.get(pluginId)
if (!buttons) return
buttons.delete(buttonId)
if (buttons.size === 0) this.toolbarButtons.delete(pluginId)
this.send('plugins:uiState', this.uiState())
}
/** 清空某插件的全部内联 UI(禁用/重载路径) */
private clearPluginUi(pluginId: string): void {
this.statusItems.delete(pluginId)
this.toolbarButtons.delete(pluginId)
this.send('plugins:uiState', this.uiState())
}
// ---- 深链 ----
openView(view: string, params?: unknown): void {
this.send('plugins:view', { view, params })
}
// ---- 插件 KV 存储 ----
storageFor(pluginId: string) {
return {
get: <T>(key: string): T | undefined => {
const kv = this.storage.get(pluginId)
const v = kv?.get(key)
return v === undefined ? undefined : (structuredClone(v) as T)
},
set: (key: string, value: unknown): void => {
let kv = this.storage.get(pluginId)
if (!kv) {
kv = new Map()
this.storage.set(pluginId, kv)
}
kv.set(key, structuredClone(value))
this.scheduleStorageSave()
},
delete: (key: string): void => {
this.storage.get(pluginId)?.delete(key)
this.scheduleStorageSave()
}
}
}
private scheduleStorageSave(): void {
if (this.storageSaveTimer) return
this.storageSaveTimer = setTimeout(() => {
this.storageSaveTimer = null
const doc: Record<string, Record<string, unknown>> = {}
for (const [pid, kv] of this.storage) doc[pid] = Object.fromEntries(kv)
try {
writeFileSync(this.storageFile, JSON.stringify(doc, null, 2), 'utf8')
} catch (err) {
console.error('[plugins] save storage failed:', err)
}
}, 300)
}
// ---- 聚合 pi extension ----
/** 挂进 AgentService 的 extensionFactories;工厂内激活全部已启用插件 */
createAggregatedExtension(): InlineExtension {
return {
name: 'aso-plugins',
factory: (pi: ExtensionAPI) => {
const disposers: Array<() => void> = []
for (const plugin of this.resolved.values()) {
if (!this.isEnabled(plugin.manifest.id) || plugin.error) continue
const factory = plugin.builtinFactory ?? plugin.externalFactory
if (!factory) continue
try {
const result = factory(this.buildApi(plugin, pi))
if (typeof result === 'function') {
disposers.push(result)
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
void (result as Promise<void | (() => void)>).then((d) => {
if (typeof d === 'function') disposers.push(d)
})
}
} catch (err) {
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
this.clearPluginUi(plugin.manifest.id)
this.send('plugins:listChanged', null)
}
}
pi.on('session_shutdown', () => {
for (const dispose of disposers) {
try {
dispose()
} catch (err) {
console.error('[plugins] dispose error:', err)
}
}
disposers.length = 0
})
}
}
}
private buildApi(plugin: ResolvedPlugin, pi: ExtensionAPI) {
const id = plugin.manifest.id
return {
pi,
manifest: plugin.manifest,
storage: this.storageFor(id),
ui: {
openView: (view: string, params?: unknown) => this.openView(view, params),
setStatusItem: (itemId: string, item: StatusItem) => this.setStatusItem(id, itemId, item),
removeStatusItem: (itemId: string) => this.setStatusItem(id, itemId, null),
registerToolbarButton: (button: ToolbarButtonDef) => this.registerToolbarButton(id, button),
removeToolbarButton: (buttonId: string) => this.removeToolbarButton(id, buttonId)
}
}
}
// ---- plugin:// 协议 ----
/** plugin://<id>/<rel> → 插件目录内文件;路径必须落在该插件根内(防穿越) */
resolvePluginFile(id: string, rel: string): string | null {
const plugin = this.resolved.get(id)
if (!plugin || plugin.source !== 'external' || !plugin.dir) return null
const root = resolve(plugin.dir)
const target = resolve(root, rel)
if (target !== root && !target.startsWith(root + sep)) return null
return target
}
registerProtocols(): void {
protocol.handle('plugin', (request) => this.serveFile(() => {
const url = new URL(request.url)
const id = url.hostname
const rel = decodeURIComponent(url.pathname).replace(/^\/+/, '')
const file = this.resolvePluginFile(id, rel)
return file ?? ''
}))
// peer:// 只暴露固定白名单资产(tokens.css 主题变量表,插件页 <link> 引用)
protocol.handle('peer', (request) => this.serveFile(() => {
const url = new URL(request.url)
const asset = `${url.hostname}${url.pathname}`.replace(/\/+$/, '')
if (asset !== 'assets/tokens.css') return ''
return this.tokensCssPath()
}))
}
private tokensCssPath(): string {
// dev:源文件;prod:构建期拷贝到 out/renderer/tokens.css(vite closeBundle 插件)
const src = resolve(app.getAppPath(), 'src', 'renderer', 'src', 'styles', 'beautifului.css')
if (existsSync(src)) return src
return resolve(app.getAppPath(), 'out', 'renderer', 'tokens.css')
}
private async serveFile(resolvePath: () => string): Promise<Response> {
let file = ''
try {
file = resolvePath()
} catch {
file = ''
}
if (!file || !existsSync(file)) return new Response('not found', { status: 404 })
try {
const res = await net.fetch(pathToFileURL(file).toString())
// no-store:热重载换 src 即取新文件,避免缓存妨碍开发预览
return new Response(res.body, {
status: res.status,
headers: {
'content-type': res.headers.get('content-type') ?? 'application/octet-stream',
'cache-control': 'no-store'
}
})
} catch (err) {
console.error('[plugins] serve failed:', file, err)
return new Response('serve error', { status: 500 })
}
}
// ---- 渲染层广播 ----
private send(channel: string, payload: unknown): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(channel, payload)
}
}
}
let host: PluginHost | null = null
export function getPluginHost(): PluginHost {
if (!host) host = new PluginHost()
return host
}
export type { BuiltinPlugin }
@@ -1,292 +0,0 @@
import { defineTool } from '@earendil-works/pi-coding-agent'
import { Type } from '@earendil-works/pi-ai'
import { getBrowserController } from '../browser/browser-controller'
/**
* 内置受管浏览器工具(进程内一等工具,非 MCP)。
*
* 使用范式(与 Playwright MCP / Proma 一致的 AX 快照 + ref 体系):
* 1. browser_observe / browser_find 拿到带 ref 的可交互元素快照;
* 2. 用 ref 调 click / fill / act 等操作;页面变化后旧 ref 失效,必须重新观察;
* 3. 语义结构足够时优先 observe,需要视觉验证时才 browser_screenshot。
*/
const TAB_ID_PARAM = Type.Optional(
Type.String({ description: '可选,目标标签页 ID。默认为 Agent 工作标签页(与用户可见面板同步)。' })
)
export const browserTools = [
defineTool({
name: 'browser_navigate',
label: '打开网页',
description:
'在内置受管浏览器中打开 URL 或搜索词。完整 URL、裸域名、localhost 与 IP 直达;普通文本用 Google 搜索。支持 about:blank 空白页。下载与弹窗会留在受管浏览器内。',
promptSnippet: 'browser_navigate: 打开网页/搜索(URL 或关键词)',
parameters: Type.Object({
url: Type.String({ description: 'URL、域名或搜索关键词' }),
tabId: TAB_ID_PARAM
}),
async execute(_toolCallId, params) {
const state = await getBrowserController().navigate(params.url, params.tabId)
return { content: [{ type: 'text', text: JSON.stringify(state.tabs.find((t) => t.tabId === state.activeTabId) ?? state) }], details: state }
}
}),
defineTool({
name: 'browser_observe',
label: '观察网页',
description:
'读取当前页面的 URL、标题与紧凑可访问性快照(元素带 ref,标注 editable)。默认返回 240 个元素(约 2/3 可交互优先)。每次 observe/find 或页面导航后旧 ref 全部失效,操作必须使用最新一次观察结果中的 ref。',
promptSnippet: 'browser_observe: 读页面结构拿 ref(先观察再操作)',
parameters: Type.Object({
maxElements: Type.Optional(
Type.Number({ minimum: 20, maximum: 400, description: '返回元素上限,默认 240;仅当目标不在快照中时才提高到 400' })
),
tabId: TAB_ID_PARAM
}),
async execute(_toolCallId, params) {
const observation = await getBrowserController().observe(params.tabId, params.maxElements)
return { content: [{ type: 'text', text: JSON.stringify(observation) }], details: observation }
}
}),
defineTool({
name: 'browser_find',
label: '语义定位元素',
description:
'按可访问性 role 和/或 name 定位少量新 ref,不返回整页快照。适合 observe 结果过大或目标被截掉时使用。与 observe 一样会作废同标签页的全部旧 ref。',
promptSnippet: 'browser_find: 按 role/name 精准找元素',
parameters: Type.Object({
role: Type.Optional(Type.String({ description: '可访问性 role,如 button/textbox/link/checkbox' })),
name: Type.Optional(Type.String({ description: '可访问性名称查询' })),
exact: Type.Optional(Type.Boolean({ description: '精确匹配 name(默认包含匹配)' })),
maxResults: Type.Optional(Type.Number({ minimum: 1, maximum: 50, description: '最多返回条数,默认 20' })),
tabId: TAB_ID_PARAM
}),
async execute(_toolCallId, params) {
const result = await getBrowserController().find(
{ role: params.role, name: params.name, exact: params.exact, maxResults: params.maxResults },
params.tabId
)
return { content: [{ type: 'text', text: JSON.stringify(result) }], details: result }
}
}),
defineTool({
name: 'browser_click',
label: '点击元素',
description: '点击最新 observe/find 结果中的指定 ref。页面会短暂高亮目标。点击导致的重渲染会让旧 ref 失效,操作后需重新观察。',
promptSnippet: 'browser_click: 点击 ref',
parameters: Type.Object({ ref: Type.String({ description: '元素引用(来自 observe/find)' }), tabId: TAB_ID_PARAM }),
async execute(_toolCallId, params) {
const state = await getBrowserController().click(params.ref, params.tabId)
return { content: [{ type: 'text', text: JSON.stringify({ clicked: params.ref, url: state.tabs.find((t) => t.tabId === state.activeTabId)?.url }) }], details: state }
}
}),
defineTool({
name: 'browser_act',
label: '点击并等待',
description:
'点击 ref 后在同一次串行操作中等待一个固定条件(URL 片段/可见文本/CSS selector)。已知点击后的预期状态时优先用它,避免 click+wait 两次调用。',
promptSnippet: 'browser_act: 点击 + 等待条件(原子操作)',
parameters: Type.Object({
ref: Type.String({ description: '元素引用(来自 observe/find)' }),
waitFor: Type.Optional(
Type.Object({
kind: Type.Union([Type.Literal('url'), Type.Literal('text'), Type.Literal('selector')]),
value: Type.String({ description: 'URL 片段 / 可见文本 / CSS selector' })
})
),
timeoutMs: Type.Optional(Type.Number({ minimum: 250, maximum: 30000, description: '等待超时毫秒,默认 10000' })),
tabId: TAB_ID_PARAM
}),
async execute(_toolCallId, params) {
const result = await getBrowserController().act(params.ref, params.waitFor, params.timeoutMs, params.tabId)
return { content: [{ type: 'text', text: JSON.stringify({ clicked: params.ref, matched: result.matched }) }], details: result.state }
}
}),
defineTool({
name: 'browser_fill',
label: '填写输入框',
description:
'整体替换指定 ref 的 input/textarea/contenteditable 内容(先全选再插入,支持空格、标点、Unicode 与换行)。完整消息、搜索词、多行文本都优先用它。',
promptSnippet: 'browser_fill: 替换输入框内容',
parameters: Type.Object({
ref: Type.String({ description: '可编辑字段的 ref(observe 结果中 editable=true)' }),
text: Type.String({ description: '要输入的完整文本,≤10000 字符' }),
tabId: TAB_ID_PARAM
}),
async execute(_toolCallId, params) {
const state = await getBrowserController().fill(params.ref, params.text, params.tabId)
return { content: [{ type: 'text', text: JSON.stringify({ filled: params.ref, chars: Array.from(params.text).length }) }], details: state }
}
}),
defineTool({
name: 'browser_press',
label: '按键',
description:
'按导航键(Enter/Tab/Escape/方向键/PageDown 等)或向已聚焦的编辑器一次性插入完整文本。字段有 ref 时优先 browser_fill。',
promptSnippet: 'browser_press: Enter/Tab 等按键或向焦点插入文本',
parameters: Type.Object({ key: Type.String({ description: '导航键名,或要插入的完整文本' }), tabId: TAB_ID_PARAM }),
async execute(_toolCallId, params) {
const state = await getBrowserController().press(params.key, params.tabId)
return { content: [{ type: 'text', text: JSON.stringify({ pressed: params.key }) }], details: state }
}
}),
defineTool({
name: 'browser_hover',
label: '悬停元素',
description: '把指针移到指定 ref 上(真实 mouseMoved 事件),用于展开悬浮菜单/提示,随后需重新观察再点击新内容。',
promptSnippet: 'browser_hover: 悬停展开菜单',
parameters: Type.Object({ ref: Type.String({ description: '元素引用' }), tabId: TAB_ID_PARAM }),
async execute(_toolCallId, params) {
const state = await getBrowserController().hover(params.ref, params.tabId)
return { content: [{ type: 'text', text: JSON.stringify({ hovered: params.ref }) }], details: state }
}
}),
defineTool({
name: 'browser_scroll',
label: '滚动页面',
description:
'滚动页面或 CSS selector 指定的内部滚动容器(SPA 信息流常在内部容器里,导航键滚不动)。返回前后 scrollTop 指标,用 moved 核验是否真的滚动。',
promptSnippet: 'browser_scroll: 滚动页面/内部容器',
parameters: Type.Object({
deltaY: Type.Optional(Type.Number({ description: '滚动像素,正向下负向上,默认 600' })),
position: Type.Optional(Type.Union([Type.Literal('top'), Type.Literal('bottom')], { description: '直接滚到顶部/底部(优先于 deltaY)' })),
selector: Type.Optional(Type.String({ description: '内部滚动容器的 CSS selector,缺省滚主文档' })),
tabId: TAB_ID_PARAM
}),
async execute(_toolCallId, params) {
const result = await getBrowserController().scroll(
{ deltaY: params.deltaY, position: params.position, selector: params.selector },
params.tabId
)
return { content: [{ type: 'text', text: JSON.stringify(result) }], details: result }
}
}),
defineTool({
name: 'browser_extract',
label: '提取页面文本',
description:
'从页面正文或 selector 区域提取受限长度的纯文本,替代为读内容而执行 JS。优先传 selector 限定正文/列表区域,避免导航栏与页脚噪声。',
promptSnippet: 'browser_extract: 提取正文/区域文本',
parameters: Type.Object({
selector: Type.Optional(Type.String({ description: '限定提取区域的 CSS selector,缺省取 body' })),
maxChars: Type.Optional(Type.Number({ description: '最多提取字符数,默认 50000' })),
tabId: TAB_ID_PARAM
}),
async execute(_toolCallId, params) {
const result = await getBrowserController().extract({ selector: params.selector, maxChars: params.maxChars }, params.tabId)
return { content: [{ type: 'text', text: result.text }], details: { url: result.url, title: result.title, truncated: result.truncated } }
}
}),
defineTool({
name: 'browser_wait_for',
label: '等待页面状态',
description: '等待一个固定条件:URL 片段、可见文本或 CSS selector。超时返回 matched=false,不执行任意 JavaScript。',
promptSnippet: 'browser_wait_for: 等待 url/text/selector 条件',
parameters: Type.Object({
kind: Type.Union([Type.Literal('url'), Type.Literal('text'), Type.Literal('selector')]),
value: Type.String({ minLength: 1, maxLength: 2000, description: 'URL 片段 / 可见文本 / CSS selector' }),
timeoutMs: Type.Optional(Type.Number({ minimum: 250, maximum: 30000, description: '超时毫秒,默认 10000' })),
tabId: TAB_ID_PARAM
}),
async execute(_toolCallId, params) {
const result = await getBrowserController().waitFor({ kind: params.kind, value: params.value }, params.timeoutMs, params.tabId)
return { content: [{ type: 'text', text: JSON.stringify(result) }], details: result }
}
}),
defineTool({
name: 'browser_screenshot',
label: '截图',
description: '截取当前页面。语义结构(observe)足够时不要截图;需要视觉验证、布局或渲染证据时使用。',
promptSnippet: 'browser_screenshot: 视觉验证用',
parameters: Type.Object({ tabId: TAB_ID_PARAM }),
async execute(_toolCallId, params) {
const shot = await getBrowserController().screenshot(params.tabId)
return {
content: [
{
type: 'image' as const,
data: shot.dataUrl.replace(/^data:image\/\w+;base64,/, ''),
mimeType: 'image/png'
},
{ type: 'text', text: JSON.stringify({ url: shot.url, title: shot.title }) }
],
details: { url: shot.url, title: shot.title }
}
}
}),
defineTool({
name: 'browser_list_tabs',
label: '列出标签页',
description: '列出受管浏览器的全部标签页(tabId/URL/标题/加载状态)与当前激活页。操作其他标签页时先调它拿 tabId。',
promptSnippet: 'browser_list_tabs: 查看标签页列表',
parameters: Type.Object({}),
async execute() {
const state = getBrowserController().listTabs()
return { content: [{ type: 'text', text: JSON.stringify({ tabs: state.tabs, activeTabId: state.activeTabId }) }], details: state }
}
}),
defineTool({
name: 'browser_new_tab',
label: '新建标签页',
description: '新建 Agent 工作标签页并激活到用户可见的浏览器面板;完成后不再需要的标签页应主动 browser_close_tab。',
promptSnippet: 'browser_new_tab: 新建工作标签页',
parameters: Type.Object({
url: Type.Optional(Type.String({ description: '初始地址(URL/域名/搜索词),缺省开空白起始页' }))
}),
async execute(_toolCallId, params) {
const state = await getBrowserController().newTab(params.url, true)
return { content: [{ type: 'text', text: JSON.stringify({ tabId: state.activeTabId, tabs: state.tabs.length }) }], details: state }
}
}),
defineTool({
name: 'browser_select_tab',
label: '切换标签页',
description: '把指定标签页设为 Agent 工作页,并同步激活到用户可见面板。后续不带 tabId 的操作都作用于它。',
promptSnippet: 'browser_select_tab: 切换工作标签页',
parameters: Type.Object({ tabId: Type.String({ description: 'browser_list_tabs 返回的标签页 ID' }) }),
async execute(_toolCallId, params) {
const state = await getBrowserController().selectTab(params.tabId)
return { content: [{ type: 'text', text: JSON.stringify({ activeTabId: state.activeTabId }) }], details: state }
}
}),
defineTool({
name: 'browser_close_tab',
label: '关闭标签页',
description: '关闭指定标签页(缺省关闭当前工作页)。绝不关闭用户手动打开的标签页。',
promptSnippet: 'browser_close_tab: 关闭工作标签页',
parameters: Type.Object({ tabId: Type.Optional(Type.String({ description: '要关闭的标签页 ID,缺省当前工作页' })) }),
async execute(_toolCallId, params) {
const state = await getBrowserController().closeTab(params.tabId)
return { content: [{ type: 'text', text: JSON.stringify({ closed: params.tabId ?? 'current', remaining: state.tabs.length }) }], details: state }
}
}),
defineTool({
name: 'browser_close',
label: '关闭浏览器',
description: '关闭整个内置受管浏览器会话(销毁全部标签页)。任务结束、不再需要网页时主动调用。',
promptSnippet: 'browser_close: 关闭受管浏览器',
parameters: Type.Object({}),
async execute() {
const state = getBrowserController().close()
return { content: [{ type: 'text', text: JSON.stringify({ closed: true }) }], details: state }
}
})
]
export const browserToolNames = new Set(browserTools.map((tool) => tool.name))
+77
View File
@@ -0,0 +1,77 @@
import { defineTool } from '@earendil-works/pi-coding-agent'
import { Type } from '@earendil-works/pi-ai'
/**
* 业务工具骨架(v0.4 演示版)。
*
* 架构约定(docs/architecture.md D2):每个业务功能 = 独立模块,
* 同时供 Agent(defineTool)与手动模式(IPC 直调)使用。
* 当前后端未设计,演示工具只返回本地构造数据;
* 真实实现将替换为「服务器 API 薄封装」,接口形态保持不变。
*/
/** 演示热点词库(后端就绪后替换为真实数据源) */
const DEMO_HOTWORDS: Array<{ word: string; heat: number; trend: 'up' | 'down' | 'flat'; category: string }> = [
{ word: '保温杯车载', heat: 9200, trend: 'up', category: '家居日用' },
{ word: '宠物自动喂食器', heat: 8500, trend: 'up', category: '宠物用品' },
{ word: '桌面升降支架', heat: 7100, trend: 'flat', category: '数码配件' },
{ word: '硅胶折叠饭盒', heat: 6600, trend: 'down', category: '家居日用' },
{ word: 'LED 化妆镜带灯', heat: 6100, trend: 'up', category: '美妆个护' }
]
export const hotwordLookup = defineTool({
name: 'hotword_lookup',
label: '热点词查询(演示)',
description:
'查询店铺运营热点词。输入品类或关键词,返回相关热点词、热度与趋势。当前为演示数据源(后端未接入),返回结果仅用于验证链路。',
promptSnippet: 'hotword_lookup: 查询热点词(热度/趋势/品类)',
parameters: Type.Object({
query: Type.String({ description: '品类或关键词,如"家居"、"宠物"' }),
limit: Type.Optional(Type.Number({ description: '返回条数,默认 5', default: 5 }))
}),
async execute(_toolCallId, params) {
const q = params.query.trim()
const limit = Math.max(1, Math.min(params.limit ?? 5, 20))
const matched = DEMO_HOTWORDS.filter(
(h) => !q || h.word.includes(q) || h.category.includes(q) || q.includes(h.category.slice(0, 2))
).slice(0, limit)
const lines =
matched.length === 0
? ['(演示数据源中无匹配项;后端接入后将返回真实采集结果)']
: matched.map((h) => `${h.word}|热度 ${h.heat}|趋势 ${h.trend === 'up' ? '↑上升' : h.trend === 'down' ? '↓回落' : '→持平'}|品类 ${h.category}`)
return {
content: [{ type: 'text', text: `热点词查询结果(演示数据源,query=${q ? `"${q}"` : '全部'}):\n${lines.join('\n')}` }],
details: { source: 'demo', matched: matched.length }
}
}
})
export const priceCheck = defineTool({
name: 'price_check',
label: '核价(演示·需审批)',
description:
'对指定商品执行核价操作。核价属于涉及资金与平台规则的敏感操作,调用前必须获得用户审批。当前为演示实现,仅回显参数。',
promptSnippet: 'price_check: 商品核价(敏感操作,需用户审批)',
promptGuidelines: ['调用 price_check 前先用一句话向用户说明将要核价的商品与价格区间。'],
parameters: Type.Object({
product_id: Type.String({ description: '商品 ID(如 Temu 商品编码)' }),
target_price: Type.Number({ description: '目标价格' }),
currency: Type.Optional(Type.String({ description: '币种,默认 CNY', default: 'CNY' }))
}),
async execute(_toolCallId, params) {
return {
content: [
{
type: 'text',
text: `核价完成(演示):商品 ${params.product_id} 目标价 ${params.target_price} ${params.currency ?? 'CNY'}。真实实现将调用 CDP 操作店铺后台核价页并回读结果。`
}
],
details: { demo: true, params }
}
}
})
/** 需要人工审批的工具名单(含高危内置工具) */
export const TOOLS_REQUIRING_APPROVAL: ReadonlySet<string> = new Set(['price_check', 'bash', 'write', 'edit'])
export const demoTools = [hotwordLookup, priceCheck]
+2 -95
View File
@@ -1,27 +1,16 @@
import { contextBridge, ipcRenderer, webFrame } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
import type {
AgentUiEvent,
AppSettings,
AttachmentInfo,
BrowserImportResult,
BrowserSource,
BrowserUiEvent,
BrowserUiState,
ChatResources,
CustomProviderConfig,
ForkPoint,
ModelCatalog,
NavigateTreeOptions,
PermissionMode,
PluginInfo,
PluginsUiState,
PluginViewChange,
ResourceDetails,
SendImage,
SessionMeta,
SessionState,
ThinkingLevel,
ThemeMode,
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
@@ -31,15 +20,6 @@ const api = {
minimize: (): Promise<void> => ipcRenderer.invoke('win:minimize'),
toggleMaximize: (): Promise<void> => ipcRenderer.invoke('win:toggleMaximize'),
close: (): Promise<void> => ipcRenderer.invoke('win:close'),
// 界面缩放(Ctrl+= / Ctrl+- / Ctrl+0),factor 为相对倍率,限制在 50%–200%
zoom: (factor: number): void => {
const next = Math.min(2, Math.max(0.5, webFrame.getZoomFactor() * factor))
webFrame.setZoomFactor(next)
},
zoomReset: (): void => {
webFrame.setZoomFactor(1)
},
onWindowState: (callback: (maximized: boolean) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, maximized: boolean): void => callback(maximized)
ipcRenderer.on('win:state', listener)
@@ -81,21 +61,9 @@ const api = {
setApiKey: (providerId: string, apiKey: string): Promise<void> =>
ipcRenderer.invoke('auth:setKey', providerId, apiKey),
logoutProvider: (providerId: string): Promise<void> => ipcRenderer.invoke('auth:logout', providerId),
setThinkingLevel: (level: ThinkingLevel): Promise<void> => ipcRenderer.invoke('thinking:set', level),
compactSession: (instructions?: string): Promise<{ summary: string; tokensBefore: number }> =>
ipcRenderer.invoke('session:compact', instructions),
abortCompaction: (): Promise<void> => ipcRenderer.invoke('session:compact:abort'),
setAutoCompaction: (enabled: boolean): Promise<void> => ipcRenderer.invoke('session:autoCompact', enabled),
listForkPoints: (): Promise<ForkPoint[]> => ipcRenderer.invoke('session:forkPoints'),
navigateTree: (entryId: string, options?: NavigateTreeOptions): Promise<void> =>
ipcRenderer.invoke('session:navigate', entryId, options),
forkSession: (): Promise<void> => ipcRenderer.invoke('session:fork'),
forkSessionAt: (entryId: string, position: 'at' | 'before'): Promise<void> =>
ipcRenderer.invoke('session:forkAt', entryId, position),
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
getSystemPrompt: (): Promise<string> => ipcRenderer.invoke('settings:getSystemPrompt'),
setSystemPrompt: (text: string): Promise<void> => ipcRenderer.invoke('settings:setPrompt', text),
setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id),
setThemeMode: (mode: ThemeMode): Promise<void> => ipcRenderer.invoke('settings:setThemeMode', mode),
resolveApproval: (id: string, approved: boolean): Promise<void> =>
ipcRenderer.invoke('approval:resolve', id, approved),
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
@@ -104,67 +72,6 @@ const api = {
return () => {
ipcRenderer.removeListener('agent:event', listener)
}
},
// ---- 内置受管浏览器 ----
browserGetState: (): Promise<BrowserUiState> => ipcRenderer.invoke('browser:getState'),
browserNavigate: (input: string): Promise<BrowserUiState> => ipcRenderer.invoke('browser:navigate', input),
browserGoBack: (): Promise<BrowserUiState> => ipcRenderer.invoke('browser:goBack'),
browserGoForward: (): Promise<BrowserUiState> => ipcRenderer.invoke('browser:goForward'),
browserReload: (): Promise<BrowserUiState> => ipcRenderer.invoke('browser:reload'),
browserNewTab: (url?: string): Promise<BrowserUiState> => ipcRenderer.invoke('browser:newTab', url),
browserSelectTab: (tabId: string): Promise<BrowserUiState> => ipcRenderer.invoke('browser:selectTab', tabId),
browserCloseTab: (tabId?: string): Promise<BrowserUiState> => ipcRenderer.invoke('browser:closeTab', tabId),
browserClose: (): Promise<BrowserUiState> => ipcRenderer.invoke('browser:close'),
browserCaptureTab: (tabId?: string): Promise<string> => ipcRenderer.invoke('browser:captureTab', tabId),
browserAcknowledgeRisk: (): Promise<void> => ipcRenderer.invoke('browser:acknowledgeRisk'),
browserSetLayout: (layout: {
tabId: string
visible: boolean
revision: number
bounds: { x: number; y: number; width: number; height: number }
}): Promise<void> => ipcRenderer.invoke('browser:layout', layout),
browserDetectSources: (): Promise<BrowserSource[]> => ipcRenderer.invoke('browser:detectSources'),
browserImportProfile: (sourceId: string, profileDir: string): Promise<BrowserImportResult> =>
ipcRenderer.invoke('browser:importProfile', sourceId, profileDir),
onBrowserEvent: (callback: (event: BrowserUiEvent) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, event: BrowserUiEvent): void => callback(event)
ipcRenderer.on('browser:event', listener)
return () => {
ipcRenderer.removeListener('browser:event', listener)
}
},
// ---- 插件系统 ----
pluginsList: (): Promise<PluginInfo[]> => ipcRenderer.invoke('plugins:list'),
pluginsUiState: (): Promise<PluginsUiState> => ipcRenderer.invoke('plugins:uiState'),
pluginsSetEnabled: (id: string, enabled: boolean): Promise<void> =>
ipcRenderer.invoke('plugins:setEnabled', id, enabled),
pluginsReload: (): Promise<void> => ipcRenderer.invoke('plugins:reload'),
pluginsOpenView: (view: string, params?: unknown): Promise<void> =>
ipcRenderer.invoke('plugins:openView', view, params),
/** 插件页桥转发(渲染层桥校验来源后调用;pluginId 键控写主进程单一事实源) */
pluginsSetStatus: (pluginId: string, itemId: string, item: unknown): Promise<void> =>
ipcRenderer.invoke('plugins:setStatus', pluginId, itemId, item),
onPluginsListChanged: (callback: () => void): (() => void) => {
const listener = (): void => callback()
ipcRenderer.on('plugins:listChanged', listener)
return () => {
ipcRenderer.removeListener('plugins:listChanged', listener)
}
},
onPluginsUiState: (callback: (state: PluginsUiState) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, state: PluginsUiState): void => callback(state)
ipcRenderer.on('plugins:uiState', listener)
return () => {
ipcRenderer.removeListener('plugins:uiState', listener)
}
},
onPluginsView: (callback: (change: PluginViewChange) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, change: PluginViewChange): void => callback(change)
ipcRenderer.on('plugins:view', listener)
return () => {
ipcRenderer.removeListener('plugins:view', listener)
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self' plugin:"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'"
/>
<title>Inkreach Agent</title>
</head>
+50 -416
View File
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { JSX, ComponentType } from 'react'
import { LuGlobe } from 'react-icons/lu'
import { useEffect, useRef, useState } from 'react'
import type { JSX } from 'react'
import { useAgent } from './hooks/useAgent'
import { useBrowser } from './hooks/useBrowser'
import { SidebarNav } from './components/SidebarNav'
import { TitleBar } from './components/TitleBar'
import { ChatView } from './components/ChatView'
@@ -10,63 +8,13 @@ import { BottomSlot } from './components/BottomSlot'
import { Composer } from './components/Composer'
import { SettingsModal } from './components/SettingsModal'
import { Search } from './components/Search'
import { BrowserPanel } from './components/browser/BrowserPanel'
import { SidePanel, type PanelTabIcon } from './components/SidePanel'
import { PluginPageFrame, PluginIcon } from './plugins/PluginPageFrame'
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components'
import { applyThemeColor, watchColorScheme } from './lib/theme'
import type { PluginInfo } from '@shared/types'
// ---- 侧边面板(TabPanel):开合 / 标签 / 宽度,跨启动持久化 ----
const SIDE_STORAGE_KEY = 'aso.sidepanel.v3'
const SIDE_DEFAULT_WIDTH = 480
const SIDE_MIN_WIDTH = 380
const SIDE_MAX_WIDTH = 760
interface SideState {
open: boolean
width: number
/** 打开的标签 id(新的追加在尾部) */
tabs: string[]
active: string | null
}
/** 面板标签元数据:browser 为宿主内置;插件标签由 manifest(panel:true)动态注册。
* loadSideState 只做形状校验,非法 id(已卸载插件等)在装配 panelTabs 时跳过。 */
function loadSideState(): SideState {
try {
const raw = localStorage.getItem(SIDE_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<SideState>
const tabs = Array.isArray(parsed.tabs) ? parsed.tabs.filter((t) => typeof t === 'string') : []
return {
open: Boolean(parsed.open),
width:
typeof parsed.width === 'number'
? Math.min(SIDE_MAX_WIDTH, Math.max(SIDE_MIN_WIDTH, parsed.width))
: SIDE_DEFAULT_WIDTH,
tabs,
active: parsed.active && tabs.includes(parsed.active) ? parsed.active : (tabs[tabs.length - 1] ?? null)
}
}
} catch {
// 损坏/不可用时按默认处理
}
return { open: false, width: SIDE_DEFAULT_WIDTH, tabs: [], active: null }
}
import { applyThemeColor } from './lib/theme'
export default function App(): JSX.Element {
const {
state,
sessions,
workspaces,
forkPoints,
navigating,
forking,
send,
stop,
clearError,
@@ -78,178 +26,32 @@ export default function App(): JSX.Element {
resolveApproval,
setPermissionMode,
refreshSessions,
openWorkspace,
setThinkingLevel,
compact,
abortCompaction,
setAutoCompaction,
dismissCompactionNotice,
navigateTree,
forkSession,
forkSessionAt
openWorkspace
} = useAgent()
const prefillRef = useRef<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const browser = useBrowser()
const [side, setSide] = useState<SideState>(loadSideState)
const [sideOverlay, setSideOverlay] = useState(false)
const { open: sideOpen, tabs: sideTabIds, active: sideActive, width: sideWidth } = side
// ---- 插件系统:视图切换('chat' | 插件 id)+ 深链参数 ----
const plugins = usePluginList()
const pluginsUi = usePluginsUiState()
const [activeView, setActiveView] = useState<{ view: string; params?: unknown }>({ view: 'chat' })
const activeViewRef = useRef(activeView)
activeViewRef.current = activeView
const pluginById = useMemo(() => new Map(plugins.map((p) => [p.manifest.id, p])), [plugins])
const pluginByIdRef = useRef(pluginById)
pluginByIdRef.current = pluginById
const entryPlugins = useMemo(() => sidebarPlugins(plugins), [plugins])
const panelPlugins = useMemo(
() => plugins.filter((p) => p.enabled && p.manifest.panel && p.hasPage),
[plugins]
)
/** 热重载令牌:变化即让插件页 iframe 换 src 整页重载(手动重载/开发期 watch 触发) */
const [pluginReloadToken, setPluginReloadToken] = useState(0)
/** 打开视图('chat' 或插件 id,携深链参数);插件声明 panel 时联动展开其面板标签 */
const handleOpenView = useCallback((view: string, params?: unknown) => {
if (view === 'chat') {
setActiveView({ view: 'chat' })
return
}
const plugin = pluginByIdRef.current.get(view)
if (!plugin || !plugin.enabled || (!plugin.hasComponent && !plugin.hasPage)) return
setActiveView({ view, params })
if (plugin.manifest.panel) {
setSide((s) => ({
...s,
open: true,
tabs: s.tabs.includes(view) ? s.tabs : [...s.tabs, view],
active: view
}))
}
}, [])
// 主进程侧 openView(插件 main 工厂 / 深链按钮)→ 渲染层切视图
useEffect(() => window.api.onPluginsView((change) => handleOpenView(change.view, change.params)), [handleOpenView])
// 插件列表变化(扫描/重载/启停)→ 刷新外置插件页(iframe 换 src 整页重载)
useEffect(
() =>
window.api.onPluginsListChanged(() => {
setPluginReloadToken((t) => t + 1)
}),
[]
)
// 插件列表变化时校正当前视图(插件被禁用/卸载后回落聊天)
// 启动时应用持久化的主题色
useEffect(() => {
setActiveView((current) => {
if (current.view === 'chat') return current
const plugin = pluginById.get(current.view)
return plugin && plugin.enabled ? current : { view: 'chat' }
})
}, [pluginById])
const handleNewSession = useCallback(() => {
void newSession()
setActiveView({ view: 'chat' })
}, [newSession])
const handleOpenSession = useCallback(
(path: string) => {
void openSession(path)
setActiveView({ view: 'chat' })
},
[openSession]
)
const handleOpenPlugin = useCallback(
(id: string) => {
handleOpenView(id, undefined)
},
[handleOpenView]
)
useEffect(() => {
try {
localStorage.setItem(SIDE_STORAGE_KEY, JSON.stringify(side))
} catch {
// 持久化失败(隐私模式等)不影响功能
}
}, [side])
const toggleSide = useCallback((): void => {
setSide((s) => ({ ...s, open: !s.open }))
}, [])
const setSideWidth = useCallback((width: number): void => {
setSide((s) => ({ ...s, width }))
}, [])
const handleSideOverlay = useCallback((open: boolean): void => {
setSideOverlay(open)
}, [])
const selectPanelTab = useCallback((id: string): void => {
setSide((s) => ({ ...s, active: id }))
}, [])
const closePanelTab = useCallback((id: string): void => {
// 关闭浏览器面板标签 = 关闭全部受管页面(引擎=persist 分区会话保留:
// 登录态/Cookie 不丢,重新打开标签即可用;Agent 后续浏览器操作会自动重开新页)
if (id === 'browser') browser.closeBrowser()
setSide((s) => {
const tabs = s.tabs.filter((t) => t !== id)
return { ...s, tabs, active: s.active === id ? (tabs[tabs.length - 1] ?? null) : s.active }
})
}, [browser.closeBrowser])
const openPanelTab = useCallback((id: string): void => {
setSide((s) => ({
...s,
open: true,
tabs: s.tabs.includes(id) ? s.tabs : [...s.tabs, id],
active: id
}))
}, [])
// 启动时应用持久化的主题色;深浅色由主进程 nativeTheme 决定 prefers-color-scheme,
// 这里监听并切换 .dark 类(设置里改模式 → nativeTheme 变化 → 媒体查询事件 → 生效)
useEffect(() => {
const stopWatching = watchColorScheme()
void window.api
.getSettings()
.then((s) => applyThemeColor(s.themeColor))
.catch(() => undefined)
return stopWatching
}, [])
// 全局快捷键:Ctrl+N 新会话(全局,切回聊天)/ Ctrl+K 搜索 / Ctrl+, 设置(后两者仅聊天视图)
// Ctrl+= / Ctrl+- 界面缩放(Ctrl+0 复位)/ Esc 关闭弹窗(不切视图,回聊天走侧栏点击)
// 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Esc 关闭弹窗
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
const inChat = activeViewRef.current.view === 'chat'
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
e.preventDefault()
handleNewSession()
} else if (inChat && e.ctrlKey && e.key.toLowerCase() === 'k') {
newSession()
} else if (e.ctrlKey && e.key.toLowerCase() === 'k') {
e.preventDefault()
setSearchOpen(true)
} else if (inChat && e.ctrlKey && e.key === ',') {
} else if (e.ctrlKey && e.key === ',') {
e.preventDefault()
setSettingsOpen(true)
} else if (e.ctrlKey && (e.key === '=' || e.key === '+')) {
e.preventDefault()
window.api.zoom(1.1)
} else if (e.ctrlKey && (e.key === '-' || e.key === '_')) {
e.preventDefault()
window.api.zoom(1 / 1.1)
} else if (e.ctrlKey && e.key === '0') {
e.preventDefault()
window.api.zoomReset()
} else if (e.key === 'Escape') {
setSettingsOpen(false)
setSearchOpen(false)
@@ -257,26 +59,16 @@ export default function App(): JSX.Element {
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [handleNewSession])
}, [newSession])
const handlePickSuggestion = (text: string): void => {
prefillRef.current = text
}
// 划选「引用」:选中文本以 blockquote 形式注入输入框开头(保留已有草稿)
const [pendingQuote, setPendingQuote] = useState<string | null>(null)
const handleQuoteSelection = useCallback((selection: string) => {
setPendingQuote(`> ${selection}\n\n`)
}, [])
const consumePendingQuote = useCallback(() => setPendingQuote(null), [])
// 回溯入口(稳定引用:MessageItem memo 依赖它,内联箭头每次渲染都会失效 memo)
const handleRewind = useCallback(
(entryId: string, summarize: boolean): void => {
navigateTree(entryId, { summarize })
},
[navigateTree]
)
// 选区操作(解释/改进/精简/自定义指令):把指令 + 引用发给 Agent
const handleApplySelection = (instruction: string, selection: string): void => {
send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`)
}
// 标题栏会话名:优先当前会话名,其次会话列表里的名称/首条消息
const activeMeta = state.sessionState?.sessionId
@@ -288,87 +80,13 @@ export default function App(): JSX.Element {
activeMeta?.firstMessage?.trim() ||
''
// 插件面板标签图标(svg 字符串 → 组件;identity 稳定防标签重挂载)
const pluginTabIcons = useMemo(() => {
const map = new Map<string, PanelTabIcon>()
for (const p of panelPlugins) {
const svg = p.manifest.icon
const Icon = ({ size, className }: { size?: number; className?: string }): JSX.Element => (
<PluginIcon svg={svg} size={size ?? 16} className={className} />
)
map.set(p.manifest.id, Icon)
}
return map
}, [panelPlugins])
// 浏览器插件(内置):面板 tab 按其启用态门控(禁用后 tab 与可开列表同步消失)
const browserEnabled = plugins.find((p) => p.manifest.id === 'browser')?.enabled ?? true
// 面板标签:browser(宿主内置组件)+ manifest 声明 panel 的外置插件(iframe 页)
const panelTabs = sideTabIds.flatMap((id) => {
if (id === 'browser') {
if (!browserEnabled) return []
return [
{
id,
title: '浏览器',
icon: LuGlobe as PanelTabIcon,
content: (
<BrowserPanel
state={browser.state}
suspended={settingsOpen || searchOpen || sideOverlay}
onNavigate={browser.navigate}
onGoBack={browser.goBack}
onGoForward={browser.goForward}
onReload={browser.reload}
onNewTab={browser.newTab}
onSelectTab={browser.selectTab}
onCloseTab={browser.closeTab}
onCloseAllTabs={browser.closeBrowser}
onAcknowledgeRisk={browser.acknowledgeRisk}
onDetectSources={browser.detectSources}
onImportProfile={browser.importProfile}
/>
)
}
]
}
const plugin = pluginById.get(id)
if (!plugin || !plugin.enabled || !plugin.manifest.panel) return []
return [
{
id,
title: plugin.manifest.name,
icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon),
content: (
<PluginPageFrame plugin={plugin} onOpenView={handleOpenView} reloadToken={pluginReloadToken} />
)
}
]
})
const panelAvailable = [
...(browserEnabled ? [{ id: 'browser', title: '浏览器', icon: LuGlobe as PanelTabIcon }] : []),
...panelPlugins.map((p) => ({
id: p.manifest.id,
title: p.manifest.name,
icon: pluginTabIcons.get(p.manifest.id) ?? (LuGlobe as PanelTabIcon)
}))
]
// 当前插件视图(非 chat 时渲染其 Main:内置组件 or 外置 iframe 页)
const activePlugin: PluginInfo | undefined =
activeView.view === 'chat' ? undefined : pluginById.get(activeView.view)
const ActivePluginComponent: ComponentType | undefined = activePlugin?.hasComponent
? BUILTIN_PLUGIN_COMPONENTS[activeView.view]
: undefined
return (
<div className="relative flex h-full w-full bg-page text-ink antialiased">
<SidebarNav
sessionState={state.sessionState}
sessions={sessions}
onNewSession={handleNewSession}
onOpenSession={handleOpenSession}
onNewSession={newSession}
onOpenSession={openSession}
onRenameSession={renameSession}
onDeleteSession={deleteSession}
onOpenSearch={() => setSearchOpen(true)}
@@ -376,136 +94,52 @@ export default function App(): JSX.Element {
workspaces={workspaces}
activeWorkspace={workspaces.find((w) => w.path === state.sessionState?.workspace)}
onOpenWorkspace={openWorkspace}
plugins={entryPlugins}
activeView={activeView.view}
onOpenPlugin={handleOpenPlugin}
chatBadge={{
approvals: state.approvals.length,
error: Boolean(state.error || state.sessionError || state.retry),
streaming: state.streaming
}}
/>
<main className="relative flex min-w-0 flex-1 flex-col">
<TitleBar
title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name}
panelOpen={sideOpen}
onTogglePanel={toggleSide}
extra={<PluginStatusCapsules entries={pluginsUi.status} onOpenView={handleOpenView} />}
<TitleBar title={sessionTitle} />
<ChatView
messages={state.messages}
tools={state.tools}
streaming={state.streaming}
error={state.error}
retry={state.retry}
sessionError={state.sessionError}
pendingApprovals={state.approvals}
onDismissError={clearError}
onPickSuggestion={handlePickSuggestion}
onApplySelection={handleApplySelection}
/>
<div className="flex min-h-0 flex-1">
{/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */}
<div
className="relative flex min-w-0 flex-1 flex-col"
style={{ display: activeView.view === 'chat' ? undefined : 'none' }}
>
<ChatView
visible={activeView.view === 'chat'}
messages={state.messages}
tools={state.tools}
<BottomSlot
approvals={state.approvals}
onResolve={resolveApproval}
composer={
<Composer
streaming={state.streaming}
turnActive={state.turnActive}
error={state.error}
retry={state.retry}
sessionError={state.sessionError}
pendingApprovals={state.approvals}
compacting={state.compacting}
compactionNotice={state.compactionNotice}
navigating={navigating}
forking={forking}
forkPoints={forkPoints}
onDismissError={clearError}
onDismissCompactionNotice={dismissCompactionNotice}
onAbortCompaction={abortCompaction}
onRewind={handleRewind}
onFork={forkSession}
onForkAt={(entryId) => forkSessionAt(entryId, 'before')}
onPickSuggestion={handlePickSuggestion}
onQuoteSelection={handleQuoteSelection}
queue={state.queue}
disabled={!state.ready}
model={state.sessionState?.model}
permissionMode={state.sessionState?.permissionMode ?? 'confirm'}
sessionKey={state.sessionState?.sessionId}
sessionMessageCount={state.sessionState?.messageCount}
onSend={send}
onStop={stop}
onNewSession={newSession}
onSetModel={setModel}
onOpenSettings={() => setSettingsOpen(true)}
onPermissionMode={setPermissionMode}
prefillRef={prefillRef}
/>
<BottomSlot
approvals={state.approvals}
onResolve={resolveApproval}
composer={
<Composer
streaming={state.streaming}
queue={state.queue}
disabled={!state.ready}
model={state.sessionState?.model}
permissionMode={state.sessionState?.permissionMode ?? 'confirm'}
sessionKey={state.sessionState?.sessionId}
sessionMessageCount={state.sessionState?.messageCount}
stats={state.stats}
thinkingLevel={state.sessionState?.thinkingLevel}
thinkingLevels={state.sessionState?.thinkingLevels}
compacting={state.compacting}
autoCompactionEnabled={state.sessionState?.autoCompactionEnabled ?? true}
onSend={send}
onStop={stop}
onNewSession={handleNewSession}
onSetModel={setModel}
onOpenSettings={() => setSettingsOpen(true)}
onPermissionMode={setPermissionMode}
onSetThinkingLevel={setThinkingLevel}
onCompact={compact}
onAbortCompaction={abortCompaction}
onToggleAutoCompaction={setAutoCompaction}
prefillRef={prefillRef}
pendingQuote={pendingQuote}
onQuoteConsumed={consumePendingQuote}
/>
}
/>
</div>
{/* 插件整页视图(同一时刻一个;错误边界兜底,崩溃不拖垮宿主) */}
{activePlugin && (
<div className="relative flex min-w-0 flex-1 flex-col">
<PluginErrorBoundary
pluginName={activePlugin.manifest.name}
onBack={() => setActiveView({ view: 'chat' })}
>
{ActivePluginComponent ? (
<ActivePluginComponent />
) : activePlugin.hasPage ? (
<PluginPageFrame
plugin={activePlugin}
params={activeView.params}
reloadToken={pluginReloadToken}
onOpenView={handleOpenView}
/>
) : (
<div className="flex h-full items-center justify-center text-[12.5px] text-ink-3">
插件未提供页面
</div>
)}
</PluginErrorBoundary>
</div>
)}
{sideOpen && (
<SidePanel
tabs={panelTabs}
activeId={sideActive}
available={panelAvailable}
width={sideWidth}
onWidthChange={setSideWidth}
onSelectTab={selectPanelTab}
onCloseTab={closePanelTab}
onOpenTab={openPanelTab}
onCollapse={toggleSide}
onOverlayChange={handleSideOverlay}
/>
)}
</div>
}
/>
</main>
<Search
open={searchOpen}
onClose={() => setSearchOpen(false)}
sessions={sessions}
onNewSession={handleNewSession}
onNewSession={newSession}
onRefreshSessions={refreshSessions}
onOpenSettings={() => setSettingsOpen(true)}
onOpenSession={handleOpenSession}
onOpenSession={openSession}
/>
<SettingsModal
open={settingsOpen}
@@ -90,23 +90,17 @@ function Ico({ path, size = 14, sw = 2 }: { path: ReactNode; size?: number; sw?:
const OPTION_APPROVE = 0
export type ApprovalOutcome = 'approved' | 'rejected'
export function ApprovalCard({
request,
outcome,
onResolve
}: {
request: ApprovalRequest
/** 外部已结算(卡片重挂后恢复结果态):给定时直接呈现结果胶囊,交互冻结 */
outcome?: ApprovalOutcome
onResolve: (id: string, approved: boolean) => void
}): ReactNode {
const [picked, setPicked] = useState<number | null>(null)
const [sent, setSent] = useState<string | null>(null)
const submitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const settled = useRef(false)
const settledNote = sent ?? (outcome === 'approved' ? '已允许执行' : outcome === 'rejected' ? '已拒绝' : null)
useEffect(
() => () => {
@@ -116,7 +110,7 @@ export function ApprovalCard({
)
const settle = (approved: boolean, note: string): void => {
if (settledNote) return
if (settled.current) return
settled.current = true
setSent(note)
if (submitTimer.current) clearTimeout(submitTimer.current)
@@ -124,7 +118,7 @@ export function ApprovalCard({
}
const pick = (index: number): void => {
if (settledNote) return
if (sent) return
setPicked(index)
// radio 自动前进:480ms 后提交(官方 single-choice 行为)
if (submitTimer.current) clearTimeout(submitTimer.current)
@@ -133,8 +127,8 @@ export function ApprovalCard({
}, 480)
}
if (settledNote) {
const ok = settledNote === '已允许执行'
if (sent) {
const ok = sent === '已允许执行'
return (
<div className="flex w-full items-center gap-3" style={{ animation: 'pop-in 260ms cubic-bezier(0.23,1,0.32,1) both' }}>
<span
@@ -153,7 +147,7 @@ export function ApprovalCard({
</svg>
)}
</span>
{settledNote}
{sent}
</span>
</div>
)
@@ -1,18 +1,41 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { ApprovalRequest } from '@shared/types'
import { ApprovalCard, type ApprovalOutcome } from './ApprovalCard'
import { ApprovalCard } from './ApprovalCard'
/**
* 底部插槽(审批抽屉):审批请求出现时,卡片从输入框上方(版面中部)像抽屉
* 一样滑出,输入框保留可见可输入(Composer 始终挂载,草稿不丢)。
* 一次只展示一张:队列中的后续请求在前一张结算后接入;结算后卡片以结果
* 胶囊态停留约一秒,再下滑淡出收起。升起/收起均为 CSS 过渡。
* 底部插槽(抽屉):审批卡出现时从 Chatbox 位置升起替换输入框,
* 结算后输入框滑回。两行用 grid-template-rows 0fr↔1fr 过渡做真实高度动画,
* 交叉淡入淡出;Composer 始终挂载(草稿不丢)。已结算的卡片以结算胶囊态
* 多停留约一秒再撤出,避免「啪」一下消失。
*/
type Phase = 'pending' | 'settled' | 'closing' | 'hidden'
const ROWS_TRANSITION = 'grid-template-rows 400ms cubic-bezier(0.22, 1, 0.36, 1)'
const FADE_TRANSITION = 'opacity 220ms ease, transform 400ms cubic-bezier(0.22, 1, 0.36, 1)'
const SETTLED_MS = 900
const CLOSE_MS = 240
/** 跟踪从 requests 里消失的审批:缓存原件让卡片以结算态多停留一拍 */
function useLeavingApprovals(requests: ApprovalRequest[]): ApprovalRequest[] {
const seenRef = useRef(new Map<string, ApprovalRequest>())
for (const request of requests) seenRef.current.set(request.id, request)
const [leaving, setLeaving] = useState<ApprovalRequest[]>([])
useEffect(() => {
const gone: ApprovalRequest[] = []
for (const [id, request] of seenRef.current) {
if (!requests.some((r) => r.id === id)) gone.push(request)
}
if (gone.length === 0) return
for (const g of gone) seenRef.current.delete(g.id)
setLeaving((cur) => [...cur.filter((l) => !gone.some((g) => g.id === l.id)), ...gone])
const timers = gone.map((g) =>
setTimeout(() => {
setLeaving((cur) => cur.filter((l) => l.id !== g.id))
}, 1000)
)
return () => timers.forEach(clearTimeout)
}, [requests])
return leaving
}
export function BottomSlot({
approvals,
@@ -23,80 +46,48 @@ export function BottomSlot({
onResolve: (id: string, approved: boolean) => void
composer: ReactNode
}): ReactNode {
const [shown, setShown] = useState<ApprovalRequest | null>(null)
const [phase, setPhase] = useState<Phase>('hidden')
const outcomesRef = useRef(new Map<string, ApprovalOutcome>())
const timerRef = useRef<number | null>(null)
useEffect(
() => () => {
if (timerRef.current) window.clearTimeout(timerRef.current)
},
[]
)
useEffect(() => {
const first = approvals[0]
if (first) {
// 新请求接入(含上一张结算中的队列换牌):立即切换为待决卡片
if (timerRef.current) {
window.clearTimeout(timerRef.current)
timerRef.current = null
}
setShown((cur) => (cur?.id === first.id ? cur : first))
setPhase('pending')
return
}
if (phase === 'pending' && shown) {
// 待决卡片刚结算且没有后续请求:胶囊态停留一拍,再下滑淡出收起
setPhase('settled')
timerRef.current = window.setTimeout(() => {
setPhase('closing')
timerRef.current = window.setTimeout(() => {
timerRef.current = null
setShown(null)
setPhase('hidden')
}, CLOSE_MS)
}, SETTLED_MS)
}
}, [approvals, phase, shown])
const handleResolve = (id: string, approved: boolean): void => {
outcomesRef.current.set(id, approved ? 'approved' : 'rejected')
onResolve(id, approved)
}
const visible = shown !== null && phase !== 'hidden'
const leaving = useLeavingApprovals(approvals)
const showApproval = approvals.length > 0 || leaving.length > 0
return (
<div className="relative">
{/* 审批抽屉:悬浮在输入框上方(版面中部),从输入框后缘滑出 */}
<div
aria-hidden={!visible}
className={`pointer-events-none absolute inset-x-0 bottom-full z-20 mb-2 flex justify-center ${visible ? '' : 'invisible'}`}
>
{shown && (
<div>
{/* Chatbox 行:审批升起时收起(高度 0 + 下沉淡出) */}
<div style={{ display: 'grid', gridTemplateRows: showApproval ? '0fr' : '1fr', transition: ROWS_TRANSITION }}>
<div className="min-h-0 overflow-hidden">
<div
key={shown.id}
className="pointer-events-auto w-[380px] max-w-[calc(100vw-48px)]"
style={
phase === 'pending'
? { animation: 'drawer-up 340ms cubic-bezier(0.22, 1, 0.36, 1) both' }
: phase === 'closing'
? {
opacity: 0,
transform: 'translateY(10px)',
transition: 'opacity 200ms ease, transform 220ms cubic-bezier(0.22, 1, 0.36, 1)'
}
: undefined
}
style={{
opacity: showApproval ? 0 : 1,
transform: showApproval ? 'translateY(10px)' : 'none',
transition: FADE_TRANSITION,
pointerEvents: showApproval ? 'none' : undefined
}}
>
<ApprovalCard request={shown} outcome={outcomesRef.current.get(shown.id)} onResolve={handleResolve} />
{composer}
</div>
)}
</div>
</div>
{/* 审批抽屉行:从输入框位置升起(高度 1fr + 上浮淡入) */}
<div style={{ display: 'grid', gridTemplateRows: showApproval ? '1fr' : '0fr', transition: ROWS_TRANSITION }}>
<div className="min-h-0 overflow-hidden">
<div
style={{
opacity: showApproval ? 1 : 0,
transform: showApproval ? 'none' : 'translateY(14px)',
transition: FADE_TRANSITION,
pointerEvents: showApproval ? undefined : 'none'
}}
>
<div className="flex flex-col items-center gap-2 pb-1">
{[...approvals, ...leaving.filter((l) => !approvals.some((a) => a.id === l.id))].map((request) => (
<div key={request.id} className="w-full max-w-80" style={{ animation: 'fade-up 380ms cubic-bezier(0.23,1,0.32,1) both' }}>
<ApprovalCard request={request} onResolve={onResolve} />
</div>
))}
</div>
</div>
</div>
</div>
{composer}
<style>{`@keyframes drawer-up { from { opacity: 0; transform: translateY(26px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
</div>
)
}
@@ -1,9 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import { LuFoldVertical, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu'
import type { ApprovalRequest, ForkPoint } from '@shared/types'
import type { CompactionNotice, ToolState, UiMessage } from '../hooks/useAgent'
import { formatTokens } from '../lib/utils'
import { LuRotateCw, LuTriangleAlert } from 'react-icons/lu'
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
import { AssistantTurn, MessageItem } from './MessageItem'
import { SelectionActions } from './SelectionActions'
@@ -13,33 +11,6 @@ const SUGGESTIONS = [
'查询最近一次上架任务的执行结果'
]
/** 渐进渲染参数:切会话首屏只挂载最近的 HISTORY_INITIAL 条消息,
* 其余在浏览器空闲时按 HISTORY_BATCH 分批向前补齐——长上下文会话
* (几百条消息、上百个 Markdown 解析)一次性挂载会让渲染线程阻塞
* 数秒,分批后首帧开销恒定,补齐过程对用户不可感。
* 首屏只需覆盖视口(约 3-5 条消息)加少量余量;批次足够小以把
* 单次挂载的长任务压到不可感知(生产构建 <50ms)。 */
const HISTORY_INITIAL = 24
const HISTORY_BATCH = 36
/** 把「第一个可见消息」回退到回合边界:避免从 assistant 组的中间切开,
* 导致首条渲染的回合缺了前半段的思考/工具调用 */
function snapToTurnStart(messages: UiMessage[], start: number): number {
let i = Math.min(Math.max(0, start), messages.length)
while (i > 0 && messages[i]?.role === 'assistant') i -= 1
return i
}
/** [from, to) 区间内的用户消息数(回溯点按用户消息序对齐,被隐藏的前缀
* 占用的序号要跳过;本地乐观插入不算) */
function countUserMessages(messages: UiMessage[], from: number, to: number): number {
let n = 0
for (let i = from; i < to && i < messages.length; i++) {
if (messages[i].role === 'user' && !messages[i].local) n += 1
}
return n
}
/** 空状态:一行小标题 + 三条建议(纯文本行,点击填入输入框) */
function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
return (
@@ -62,99 +33,38 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
}
/** 连续 assistant 消息(pi 每个工具轮次一条)合并为一个回合渲染,
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序),
* startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。
* animate=false(正在补齐历史)时挂载的消息不播入场动画。
* 分叉入口:onFork(会话末尾)只给延伸到列表末尾的最后一个回合;
* 其余回合用「本回合之后第一条用户消息」的 entryId 走 onForkAt(entryId,'before')
* ——新会话包含到本回合答案为止。 */
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。 */
function renderTurns(
messages: UiMessage[],
tools: Record<string, ToolState>,
streaming: boolean,
turnActive: boolean,
pendingApprovals: ApprovalRequest[],
pinnedRef: { current: boolean },
forkPoints: ForkPoint[],
rewinding: boolean,
onRewind: (entryId: string, summarize: boolean) => void,
startUserIdx = 0,
animate = true,
forking = false,
onFork?: () => void,
onForkAt?: (entryId: string) => void
pendingApprovals: ApprovalRequest[]
): ReactNode[] {
const items: ReactNode[] = []
let i = 0
let userMessageIdx = startUserIdx
// 每条真实用户消息(非乐观插入)对应的分叉点,供回合级分叉取「下一条用户消息」的锚点
const userForkIds = new Map<number, string>()
for (let idx = 0; idx < messages.length; idx++) {
const m = messages[idx]
if (m.role === 'user' && !m.local) {
const fp = forkPoints[userMessageIdx]
userMessageIdx += 1
if (fp) userForkIds.set(idx, fp.entryId)
}
}
userMessageIdx = startUserIdx
while (i < messages.length) {
const message = messages[i]
if (message.role === 'assistant') {
let j = i + 1
while (j < messages.length && messages[j].role === 'assistant') j += 1
// turnActive/streaming 是会话级信号:只有延伸到消息列表末尾的最后一个
// assistant 组是「活跃回合」。透传给历史回合会让它们误显示
// 「已工作/处理中」并保持过程区展开。
// 放宽:会话级 turnActive 期间,组后仅尾随非真实用户消息(自动压缩/分支
// 摘要通知行)时不算离开末尾——否则压缩间隙 live 跌落,600ms 自动收起后
// 续跑又展开,形成展开/收起反复横跳(慢工具/长会话反复压缩尤甚)。
let liveEnd = j
while (liveEnd < messages.length && !(messages[liveEnd].role === 'user' && !messages[liveEnd].local)) {
liveEnd += 1
}
const isLive = j === messages.length || (turnActive && liveEnd === messages.length)
// 本回合之后第一条真实用户消息(跳过压缩/摘要行):分叉锚点,'before' 含本回合答案为止
let nextUser = j
while (nextUser < messages.length && !(messages[nextUser].role === 'user' && !messages[nextUser].local)) {
nextUser += 1
}
const turnForkEntryId = nextUser < messages.length ? userForkIds.get(nextUser) : undefined
items.push(
<AssistantTurn
key={`${message.key}-turn`}
messages={messages.slice(i, j)}
tools={tools}
streaming={streaming && isLive}
turnActive={turnActive && isLive}
streaming={streaming}
pendingApprovals={pendingApprovals}
scrollPinnedRef={pinnedRef}
forking={forking}
onFork={isLive ? onFork : undefined}
forkEntryId={turnForkEntryId}
onForkAt={onForkAt}
/>
)
i = j
} else {
// 用户消息按序对齐回溯点;非用户角色(压缩/分支摘要通知行)不占序号
const forkEntryId =
message.role === 'user' && !message.local ? forkPoints[userMessageIdx++]?.entryId : undefined
items.push(
<MessageItem
key={message.key}
message={message}
tools={tools}
streaming={streaming && i === messages.length - 1}
turnActive={turnActive && i === messages.length - 1}
streaming={streaming}
pendingApprovals={pendingApprovals}
scrollPinnedRef={pinnedRef}
forkEntryId={forkEntryId}
rewinding={rewinding}
onRewind={onRewind}
animate={animate}
/>
)
i += 1
@@ -163,116 +73,32 @@ function renderTurns(
return items
}
const COMPACTION_REASON_LABELS: Record<CompactionNotice['reason'], string> = {
manual: '手动触发',
threshold: '接近窗口上限,自动触发',
overflow: '上下文溢出,自动触发'
}
export function ChatView({
messages,
tools,
streaming,
turnActive,
error,
retry,
sessionError,
pendingApprovals,
compacting,
compactionNotice,
navigating,
forking,
forkPoints,
onDismissError,
onDismissCompactionNotice,
onAbortCompaction,
onRewind,
onFork,
onForkAt,
onPickSuggestion,
onQuoteSelection,
visible = true
onApplySelection
}: {
messages: UiMessage[]
tools: Record<string, ToolState>
streaming: boolean
turnActive: boolean
error: string | null
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
sessionError: string | null
pendingApprovals: ApprovalRequest[]
/** 正在压缩上下文 */
compacting: boolean
/** 最近一次压缩结果通知(自动消退) */
compactionNotice: CompactionNotice | null
/** 分支回溯/摘要生成进行中 */
navigating: boolean
/** 会话末尾分叉(pi fork)进行中 */
forking: boolean
forkPoints: ForkPoint[]
onDismissError: () => void
onDismissCompactionNotice: () => void
onAbortCompaction: () => void
onRewind: (entryId: string, summarize: boolean) => void
/** 在会话末尾分叉出新会话(原会话保留) */
onFork: () => void
/** 从指定锚点分叉出新会话(含该锚点前的整个回合) */
onForkAt: (entryId: string) => void
onPickSuggestion: (text: string) => void
onQuoteSelection: (selection: string) => void
/** 聊天列是否可见(切到插件视图时 display:none 保活;隐藏期滚动测量为 0,切回重新吸底) */
visible?: boolean
onApplySelection: (instruction: string, selection: string) => void
}): ReactNode {
const scrollRef = useRef<HTMLDivElement>(null)
const pinnedRef = useRef(true)
// ---- 历史渐进填充 ----
// 检测「历史被整体替换」(切换会话 / 压缩换血 / 回溯):这些路径会重建全部
// 消息 key,而流式追加只改尾部。以首条 key 为代际标记,渲染期直接截断首屏
// 挂载量(render-phase setState,React 会丢弃中间帧,全量列表不会被提交)。
const historyKey = messages.length > 0 ? messages[0].key : ''
const epochRef = useRef<string | null>(null)
const [hidden, setHidden] = useState(0)
if (epochRef.current !== historyKey) {
epochRef.current = historyKey
// 切换后回到吸底状态(旧会话里上翻过的 pinned 需要复位)
pinnedRef.current = true
setHidden(snapToTurnStart(messages, messages.length - HISTORY_INITIAL))
}
// 空闲时分批向前补齐(超时兜底较紧:忙时也持续小步推进,不会停滞)
const messagesRef = useRef(messages)
messagesRef.current = messages
useEffect(() => {
if (hidden <= 0) return
const id = requestIdleCallback(
() => setHidden((h) => snapToTurnStart(messagesRef.current, h - HISTORY_BATCH)),
{ timeout: 150 }
)
return () => cancelIdleCallback(id)
}, [hidden])
// 补齐批次在视口上方插入内容:吸底时保持贴底;用户已上翻阅读时锚定视口
// (滚动位置随内容增长平移,阅读位置不跳)。cleanup 在下一次更新前捕获
// 变更前的滚动状态,作为锚点。代际切换时高度差跨内容无意义,跳过锚定。
const fillAnchorRef = useRef<{ scrollTop: number; height: number } | null>(null)
const anchorEpochRef = useRef<string | null>(null)
useLayoutEffect(() => {
const el = scrollRef.current
if (!el) return
const epochChanged = anchorEpochRef.current !== historyKey
anchorEpochRef.current = historyKey
const anchor = fillAnchorRef.current
if (anchor && !epochChanged) {
if (pinnedRef.current) el.scrollTop = el.scrollHeight
else el.scrollTop = anchor.scrollTop + (el.scrollHeight - anchor.height)
}
fillAnchorRef.current = { scrollTop: el.scrollTop, height: el.scrollHeight }
return () => {
fillAnchorRef.current = { scrollTop: el.scrollTop, height: el.scrollHeight }
}
}, [hidden, historyKey])
// 用户上滚时停止自动吸底,回到底部附近恢复
const handleScroll = (): void => {
const el = scrollRef.current
@@ -283,148 +109,52 @@ export function ChatView({
useEffect(() => {
const el = scrollRef.current
if (!el || !pinnedRef.current) return
// rAF 合帧:流式 delta 一帧内多次触发时只吸底一次,
// 且读 scrollHeight 的强制布局每帧至多一次
const raf = requestAnimationFrame(() => {
if (el && pinnedRef.current) {
el.scrollTop = el.scrollHeight
})
return () => cancelAnimationFrame(raf)
}, [messages, tools, streaming, visible])
// 压缩结果通知 8 秒后自动消退
useEffect(() => {
if (!compactionNotice) return
const timer = window.setTimeout(onDismissCompactionNotice, 8000)
return () => window.clearTimeout(timer)
}, [compactionNotice, onDismissCompactionNotice])
}
}, [messages, tools, streaming])
const hasContent = messages.length > 0
// 分叉入口挂在每个回合的复制按钮旁:回合进行中/压缩/回溯时收起
const canFork = !turnActive && !streaming && !compacting && !navigating
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="absolute inset-x-4 top-3 z-10 flex flex-col gap-2">
{(error || sessionError) && (
<div className="flex items-start gap-2.5 rounded-control border border-red/40 bg-red-tint px-4 py-3 text-[13px] text-red">
<LuTriangleAlert size={16} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
{sessionError ? (
<>
<div className="mb-0.5 font-medium">Agent 会话初始化失败</div>
<div className="break-all text-red/80">{sessionError}</div>
</>
) : (
<div className="break-all">{error}</div>
)}
</div>
<button
type="button"
onClick={onDismissError}
className="shrink-0 rounded-control px-1.5 text-red/70 transition-colors hover:bg-red/10 hover:text-red"
>
✕
</button>
</div>
)}
{retry && (
<div className="flex items-center gap-2.5 rounded-card border border-orange/40 bg-orange-tint px-4 py-2.5 text-[13px] text-orange">
<LuRotateCw size={14} className="animate-spin" />
<span>
请求失败,自动重试 {retry.attempt}/{retry.maxAttempts}
</span>
<span className="truncate text-orange/80">{retry.errorMessage}</span>
</div>
)}
{compacting && (
<div className="flex items-center gap-2.5 rounded-card border border-line bg-surface px-4 py-2.5 text-[13px] text-ink-2 shadow-card">
<LuLoaderCircle size={14} className="animate-spin text-ink-3" />
<span className="min-w-0 flex-1">正在压缩上下文,较早的对话将归档为摘要…</span>
<button
type="button"
onClick={onAbortCompaction}
className="shrink-0 rounded-control px-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink"
>
停止
</button>
</div>
)}
{navigating && (
<div className="flex items-center gap-2.5 rounded-card border border-line bg-surface px-4 py-2.5 text-[13px] text-ink-2 shadow-card">
<LuLoaderCircle size={14} className="animate-spin text-ink-3" />
<span className="min-w-0 flex-1">正在回溯并生成分支摘要…</span>
</div>
)}
{compactionNotice && !compacting && (
<div className="flex items-start gap-2.5 rounded-card border border-line bg-surface px-4 py-2.5 text-[13px] text-ink-2 shadow-card">
{compactionNotice.aborted || compactionNotice.errorMessage ? (
<LuTriangleAlert size={14} className="mt-0.5 shrink-0 text-orange" />
{(error || sessionError) && (
<div className="absolute inset-x-4 top-3 z-10 flex items-start gap-2.5 rounded-control border border-red/40 bg-red-tint px-4 py-3 text-[13px] text-red">
<LuTriangleAlert size={16} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
{sessionError ? (
<>
<div className="mb-0.5 font-medium">Agent 会话初始化失败</div>
<div className="break-all text-red/80">{sessionError}</div>
</>
) : (
<LuFoldVertical size={14} className="mt-0.5 shrink-0 text-accent-ink" />
<div className="break-all">{error}</div>
)}
<div className="min-w-0 flex-1">
{compactionNotice.aborted ? (
<span>已停止压缩</span>
) : compactionNotice.errorMessage ? (
<span className="break-all">压缩失败:{compactionNotice.errorMessage}</span>
) : (
<>
<span>
上下文已压缩({COMPACTION_REASON_LABELS[compactionNotice.reason]}
{compactionNotice.tokensBefore
? `,约 ${formatTokens(compactionNotice.tokensBefore)} tokens 归档为摘要`
: ''}
)
</span>
{compactionNotice.summary && (
<span className="mt-0.5 line-clamp-2 block text-[12px] leading-4 text-ink-3">
{compactionNotice.summary}
</span>
)}
</>
)}
</div>
<button
type="button"
onClick={onDismissCompactionNotice}
className="shrink-0 rounded-control px-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink"
>
<LuX size={13} />
</button>
</div>
)}
</div>
<button
type="button"
onClick={onDismissError}
className="shrink-0 rounded-control px-1.5 text-red/70 transition-colors hover:bg-red/10 hover:text-red"
>
✕
</button>
</div>
)}
{retry && (
<div className="absolute inset-x-4 top-3 z-10 flex items-center gap-2.5 rounded-card border border-orange/40 bg-orange-tint px-4 py-2.5 text-[13px] text-orange">
<LuRotateCw size={14} className="animate-spin" />
<span>
请求失败,自动重试 {retry.attempt}/{retry.maxAttempts}
</span>
<span className="truncate text-orange/80">{retry.errorMessage}</span>
</div>
)}
<div ref={scrollRef} onScroll={handleScroll} className="min-h-0 flex-1 overflow-y-auto">
{hasContent ? (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
{hidden > 0 && (
<div className="flex items-center justify-center gap-1.5 text-[12px] text-ink-3">
<LuLoaderCircle size={12} className="animate-spin" />
正在载入更早的对话…
</div>
)}
{renderTurns(
messages.slice(hidden),
tools,
streaming,
turnActive,
pendingApprovals,
pinnedRef,
forkPoints,
navigating,
onRewind,
countUserMessages(messages, 0, hidden),
hidden === 0,
forking,
canFork ? onFork : undefined,
canFork ? onForkAt : undefined
)}
{renderTurns(messages, tools, streaming, pendingApprovals)}
</div>
) : (
<div className="h-full">
@@ -433,8 +163,8 @@ export function ChatView({
)}
</div>
{/* 划选消息文本后浮出的操作条:引用进输入框 / 复制 */}
<SelectionActions containerRef={scrollRef} onQuote={onQuoteSelection} />
{/* 划选消息文本后浮出的操作条 */}
<SelectionActions containerRef={scrollRef} onApply={onApplySelection} />
</div>
)
}
@@ -1,24 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import type { JSX, RefObject } from 'react'
import { LuBrain, LuCheck, LuChevronRight, LuFileText, LuGauge, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
import type {
AttachmentInfo,
ChatResources,
ModelCatalog,
ModelInfo,
PermissionMode,
SessionStatsInfo,
ThinkingLevel,
WorkspaceFile
} from '@shared/types'
import { UsageCard } from './UsageCard'
import { formatPercent } from '../lib/utils'
import { LuCheck, LuFileText, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
import type { AttachmentInfo, ChatResources, ModelCatalog, ModelInfo, PermissionMode, WorkspaceFile } from '@shared/types'
/**
* Prompt Bar(beautifului.dev #prompt-bar,布局仿 ZCode Chatbox):
* 14px 圆角容器,输入区整行在上,工具行在下(左「+」、右模型/权限/发送),
* focus 边框加深。
* Prompt Bar(beautifului.dev #prompt-bar):
* 14px 圆角容器 + 五列网格(+/输入/模型/权限/发送),focus 边框加深。
* "/" 命令面板:应用内动作 + pi 文件命令模板 + /skill: 技能(发送时由 SDK 展开);
* "@" 引用工作区文件;"+" 添加附件(图片为真实 ImageContent,文本文件内容注入,其余 @path 引用);
* 支持直接粘贴截图(clipboard 图片 → 附件)。
@@ -88,18 +76,6 @@ const PERMISSION_MODES: Array<{ id: PermissionMode; label: string; desc: string;
{ id: 'full', label: '完全访问', desc: '自动执行所有工具调用,不再逐一确认。', icon: LuShieldCheck }
]
/** 思考等级展示名与排序(pi ThinkingLevel) */
const THINKING_LEVEL_ORDER: ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
const THINKING_LEVEL_LABELS: Record<ThinkingLevel, { label: string; desc: string }> = {
off: { label: '关闭', desc: '不进行思考,直接回答。' },
minimal: { label: '最简', desc: '极少量思考,最快响应。' },
low: { label: '低', desc: '轻量思考,开销小幅增加。' },
medium: { label: '中', desc: '平衡思考深度与速度。' },
high: { label: '高', desc: '更深入的推理,适合复杂任务。' },
xhigh: { label: '超高', desc: '深度推理,明显增加耗时与开销。' },
max: { label: '最大', desc: '模型支持的极限推理预算。' }
}
export function Composer({
streaming,
queue,
@@ -108,24 +84,13 @@ export function Composer({
permissionMode = 'confirm',
sessionKey,
sessionMessageCount,
stats,
thinkingLevel,
thinkingLevels = [],
compacting,
autoCompactionEnabled = true,
onSend,
onStop,
onNewSession,
onSetModel,
onOpenSettings,
onPermissionMode,
onSetThinkingLevel,
onCompact,
onAbortCompaction,
onToggleAutoCompaction,
prefillRef,
pendingQuote = null,
onQuoteConsumed
prefillRef
}: {
streaming: boolean
/** 排队消息(steering 先于 followUp 送达) */
@@ -137,42 +102,20 @@ export function Composer({
sessionKey?: string
/** 当前会话消息数(0 = 新建未发言的空会话) */
sessionMessageCount?: number
/** 会话统计(Usage Card 数据源) */
stats: SessionStatsInfo | null
/** 当前思考等级 */
thinkingLevel?: ThinkingLevel
/** 当前模型可用的思考等级(空 = 不支持思考,隐藏切换器) */
thinkingLevels?: ThinkingLevel[]
/** 正在压缩上下文 */
compacting: boolean
/** 自动压缩开关 */
autoCompactionEnabled?: boolean
onSend: (text: string, images?: { mimeType: string; data: string }[]) => void
onStop: () => void
onNewSession: () => void
onSetModel: (providerId: string, modelId: string) => void
onOpenSettings: () => void
onPermissionMode: (mode: PermissionMode) => void
onSetThinkingLevel: (level: ThinkingLevel) => void
onCompact: (instructions?: string) => void
onAbortCompaction: () => void
onToggleAutoCompaction: (enabled: boolean) => void
/** 供空状态建议词点击后填入 */
prefillRef: RefObject<string | null>
/** 划选「引用」注入的内容(blockquote);注入到输入框开头,保留已有草稿 */
pendingQuote?: string | null
/** 注入完成后由 Composer 通知上层清空 */
onQuoteConsumed?: () => void
}): JSX.Element {
const [value, setValue] = useState('')
const [modelOpen, setModelOpen] = useState(false)
const [modelPos, setModelPos] = useState<{ top: number; left: number; right: number } | null>(null)
const [permOpen, setPermOpen] = useState(false)
const [permPos, setPermPos] = useState<{ top: number; left: number; right: number } | null>(null)
const [usageOpen, setUsageOpen] = useState(false)
const [usagePos, setUsagePos] = useState<{ top: number; left: number; right: number } | null>(null)
const [thinkingOpen, setThinkingOpen] = useState(false)
const [thinkingPos, setThinkingPos] = useState<{ top: number; left: number; right: number } | null>(null)
const [catalog, setCatalog] = useState<ModelCatalog | null>(null)
const [catalogLoading, setCatalogLoading] = useState(false)
const [attachments, setAttachments] = useState<AttachmentInfo[]>([])
@@ -186,7 +129,6 @@ export function Composer({
const localCommands = useMemo<LocalCommand[]>(
() => [
{ id: 'new', label: '/new', hint: '新会话', run: onNewSession },
{ id: 'compact', label: '/compact', hint: '压缩上下文(可附指令,如 /compact 专注数据表设计)', run: () => undefined },
{ id: 'settings', label: '/settings', hint: '打开设置', run: onOpenSettings },
{ id: 'stop', label: '/stop', hint: '停止生成', run: onStop, enabled: streaming },
{ id: 'clear', label: '/clear', hint: '清空输入', run: () => setValue('') }
@@ -194,12 +136,10 @@ export function Composer({
[onNewSession, onOpenSettings, onStop, streaming]
)
// "/" 命令面板:输入以 / 开头且当前词无空格时触发;
// /compact 例外——允许带参数(如「/compact 保留表设计讨论」),带参数时保持面板打开
// "/" 命令面板:输入以 / 开头且当前词无空格时触发
const trimmed = value.trimStart()
const slashExec = trimmed === value ? /^\/(\S*)$/.exec(trimmed) : null
const compactWithArgs = /^\/compact\s+\S/.test(trimmed)
const slashQuery = slashExec ? (slashExec[1] ?? '').toLowerCase() : compactWithArgs ? 'compact' : null
const slashQuery = slashExec ? (slashExec[1] ?? '').toLowerCase() : null
// "@ " 引用文件:末个 @ 词令牌触发
const atMatch = /@([^\s@]*)$/.exec(value)
@@ -428,8 +368,7 @@ export function Composer({
}, [menuOpen])
const closeMenu = (): void => {
if (atMatch) setValue(value.replace(/@[^\s@]*$/, ''))
else if (slashExec) setValue('')
// 带参数的 /compact:仅关闭面板,保留已输入的指令
else if (slashQuery !== null) setValue('')
}
useEffect(() => setActiveIdx(0), [value])
@@ -443,19 +382,6 @@ export function Composer({
}
}, [prefillRef])
// 划选「引用」→ 注入输入框开头(已有草稿顺延到引用块之后),光标落在引用块末尾
useEffect(() => {
if (!pendingQuote) return
setValue((current) => (current.trim() ? `${pendingQuote}${current}` : pendingQuote))
onQuoteConsumed?.()
const el = textareaRef.current
if (el) {
el.focus()
const pos = pendingQuote.length
el.setSelectionRange(pos, pos)
}
}, [pendingQuote, onQuoteConsumed])
// 草稿实时保存(发送后输入框清空,草稿随之清除)
useEffect(() => {
saveDraft(value)
@@ -484,11 +410,6 @@ export function Composer({
const modelBtnRef = useRef<HTMLButtonElement>(null)
const permBtnRef = useRef<HTMLButtonElement>(null)
const usageBtnRef = useRef<HTMLButtonElement>(null)
const thinkingBtnRef = useRef<HTMLButtonElement>(null)
/** 模型菜单:悬浮的 provider 行及其行内 rect;模型子列表悬浮其左侧(null = 未悬浮) */
const [hoveredProvider, setHoveredProvider] = useState<{ id: string; rect: DOMRect } | null>(null)
const hoverGraceRef = useRef<number | null>(null)
const anchorOf = (el: HTMLElement | null): { top: number; left: number; right: number } | null => {
if (!el) return null
@@ -502,7 +423,6 @@ export function Composer({
return
}
setModelPos(anchorOf(modelBtnRef.current))
setHoveredProvider(null)
setModelOpen((open) => {
if (!open && !catalogLoadedRef.current) {
catalogLoadedRef.current = true
@@ -526,40 +446,9 @@ export function Composer({
setPermOpen(true)
}
const toggleUsageCard = (): void => {
if (usageOpen) {
setUsageOpen(false)
return
}
setUsagePos(anchorOf(usageBtnRef.current))
setUsageOpen(true)
}
const toggleThinkingMenu = (): void => {
if (thinkingOpen) {
setThinkingOpen(false)
return
}
setThinkingPos(anchorOf(thinkingBtnRef.current))
setThinkingOpen(true)
}
/** /compact 的参数随命令一起输入(如「/compact 保留数据表设计讨论」) */
const runCompactCommand = (): void => {
const m = /^\/compact(?:\s+([\s\S]+))?$/.exec(trimmed)
const instructions = m?.[1]?.trim()
onCompact(instructions || undefined)
setValue('')
}
const pickMenuItem = (index: number): void => {
if (index < commandItems.length) {
const item = commandItems[index]
if (item.key === 'local-compact') {
runCompactCommand()
textareaRef.current?.focus()
return
}
setValue('')
textareaRef.current?.focus()
if (item.insert) {
@@ -673,28 +562,6 @@ export function Composer({
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? []
// 服务商行的悬浮跟进:离开行时有 120ms 宽限,让鼠标能跨过缝隙进入悬浮模型列表
const enterProvider = (id: string, el: HTMLElement): void => {
if (hoverGraceRef.current !== null) {
window.clearTimeout(hoverGraceRef.current)
hoverGraceRef.current = null
}
setHoveredProvider({ id, rect: el.getBoundingClientRect() })
}
const leaveProvider = (): void => {
if (hoverGraceRef.current !== null) window.clearTimeout(hoverGraceRef.current)
hoverGraceRef.current = window.setTimeout(() => {
hoverGraceRef.current = null
setHoveredProvider(null)
}, 120)
}
const keepProviderFlyout = (): void => {
if (hoverGraceRef.current !== null) {
window.clearTimeout(hoverGraceRef.current)
hoverGraceRef.current = null
}
}
const hasContentAttachment = attachments.some((a) => (a.data && a.mimeType.startsWith('image/')) || a.text)
const canSend = !disabled && (value.trim().length > 0 || hasContentAttachment)
@@ -866,8 +733,21 @@ export function Composer({
</div>
)}
{/* 输入区整行在上(仿 ZCode),工具行独立在下 */}
<div className="flex flex-col gap-0.5">
<div className="grid grid-cols-[28px_minmax(0,1fr)_auto_auto_28px] items-end gap-x-1 gap-y-1.5">
{/* +:添加附件 */}
<button
type="button"
aria-label="添加附件"
onClick={addAttachments}
disabled={disabled || picking}
title="添加附件(图片/文本文件随消息发送,其余文件以 @路径 引用)"
className="col-start-1 row-start-1 flex size-7 shrink-0 justify-self-start 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">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<textarea
ref={textareaRef}
value={value}
@@ -879,122 +759,71 @@ export function Composer({
disabled={disabled}
placeholder={disabled ? 'Agent 会话未就绪…' : streaming ? '继续输入以排队后续修改…' : '写下消息,/ 命令与技能、@ 引用文件'}
aria-label="Prompt"
className="min-h-7 w-full min-w-0 resize-none bg-transparent px-1.5 py-1.5 text-[13px] leading-[18px] text-ink outline-none [overflow-wrap:anywhere] placeholder:text-ink-3 disabled:cursor-not-allowed"
className="col-start-2 row-start-1 min-h-7 w-full min-w-0 resize-none bg-transparent px-1 py-[5px] text-[13px] leading-[18px] text-ink outline-none [overflow-wrap:anywhere] placeholder:text-ink-3 disabled:cursor-not-allowed"
/>
{/* 工具行:左「+」,右 模型 / 权限 / 发送 */}
<div className="flex items-center gap-1">
{/* +:添加附件 */}
{/* 模型选择 */}
<button
ref={modelBtnRef}
type="button"
aria-expanded={modelOpen}
aria-label="Choose model"
onClick={openModelMenu}
disabled={disabled}
className="col-start-3 row-start-1 flex h-7 shrink-0 items-center gap-1 rounded-[8px] px-1.5 text-[12px] font-medium text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="max-w-[160px] truncate">{model?.name ?? '选择模型'}</span>
<span className="text-ink-3">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</button>
{/* 权限模式(图标) */}
<button
ref={permBtnRef}
type="button"
aria-expanded={permOpen}
aria-label={`权限模式:${permMeta.label}`}
onClick={togglePermMenu}
disabled={disabled}
title={`权限模式:${permMeta.label}(${permMeta.desc})`}
className={`col-start-4 row-start-1 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'
}`}
>
<permMeta.icon size={14} />
</button>
{/* 发送 / 停止 */}
{streaming ? (
<button
type="button"
aria-label="添加附件"
onClick={addAttachments}
disabled={disabled || picking}
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"
aria-label="停止生成"
onClick={onStop}
className="col-start-5 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] bg-ink text-canvas transition-[background-color,color,transform] duration-200 hover:opacity-90 active:scale-[0.94]"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
<div className="min-w-0 flex-1" />
{/* 上下文用量 pill(Usage Card) */}
) : (
<button
ref={usageBtnRef}
type="button"
aria-expanded={usageOpen}
aria-label="上下文用量"
onClick={toggleUsageCard}
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"
aria-label="Send"
onClick={submit}
disabled={!canSend}
className="col-start-5 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94] disabled:cursor-not-allowed"
style={canSend ? { background: 'var(--ink)', color: 'var(--canvas)' } : { background: 'var(--line-strong)', color: 'var(--ink-2)' }}
>
<LuGauge size={13} className={compacting ? 'animate-pulse' : ''} />
{stats?.context?.percent != null ? formatPercent(stats.context.percent) : '上下文'}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
</button>
{/* 模型选择 */}
<button
ref={modelBtnRef}
type="button"
aria-expanded={modelOpen}
aria-label="Choose model"
onClick={openModelMenu}
disabled={disabled}
className="flex h-7 shrink-0 items-center gap-1 rounded-[8px] px-1.5 text-[12px] font-medium text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="max-w-[160px] truncate">{model?.name ?? '选择模型'}</span>
<span className="text-ink-3">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</button>
{/* 思考等级切换器(模型不支持思考时隐藏) */}
{thinkingLevels.length > 0 && (
<button
ref={thinkingBtnRef}
type="button"
aria-expanded={thinkingOpen}
aria-label={`思考等级:${thinkingLevel ? THINKING_LEVEL_LABELS[thinkingLevel]?.label ?? thinkingLevel : '未知'}`}
onClick={toggleThinkingMenu}
title={`思考等级:${thinkingLevel ? THINKING_LEVEL_LABELS[thinkingLevel]?.label ?? thinkingLevel : ''}`}
className={`flex h-7 shrink-0 items-center gap-1 rounded-[8px] px-1.5 text-[12px] font-medium transition-colors duration-150 hover:bg-hover disabled:cursor-not-allowed disabled:opacity-50 ${
thinkingLevel && thinkingLevel !== 'off' ? 'text-accent-ink' : 'text-ink-2 hover:text-ink'
}`}
>
<LuBrain size={13} />
<span>{thinkingLevel ? THINKING_LEVEL_LABELS[thinkingLevel]?.label ?? thinkingLevel : '思考'}</span>
</button>
)}
{/* 权限模式(图标) */}
<button
ref={permBtnRef}
type="button"
aria-expanded={permOpen}
aria-label={`权限模式:${permMeta.label}`}
onClick={togglePermMenu}
disabled={disabled}
title={`权限模式:${permMeta.label}`}
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:text-ink-2'
}`}
>
<permMeta.icon size={14} />
</button>
{/* 发送 / 停止 */}
{streaming ? (
<button
type="button"
aria-label="停止生成"
onClick={onStop}
className="flex size-7 shrink-0 items-center justify-center rounded-[8px] bg-ink text-canvas transition-[background-color,color,transform] duration-200 hover:opacity-90 active:scale-[0.94]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
) : (
<button
type="button"
aria-label="Send"
onClick={submit}
disabled={!canSend}
className="flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94] disabled:cursor-not-allowed"
style={canSend ? { background: 'var(--ink)', color: 'var(--canvas)' } : { background: 'var(--line-strong)', color: 'var(--ink-2)' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
</button>
)}
</div>
)}
</div>
</div>
</div>
{/* 命令 / 技能 / 文件 菜单(portal 覆盖层,向上弹出) */}
{menuOpen && menuPos && (
@@ -1084,95 +913,41 @@ export function Composer({
</ComposerMenu>
)}
{/* 思考等级菜单(portal,向上弹出,右缘对齐) */}
{thinkingOpen && thinkingPos && (
<ComposerMenu anchor={thinkingPos} align="right" onClose={() => setThinkingOpen(false)}>
<div className="w-72 p-1.5">
{[...thinkingLevels]
.sort((a, b) => THINKING_LEVEL_ORDER.indexOf(a) - THINKING_LEVEL_ORDER.indexOf(b))
.map((level) => {
const meta = THINKING_LEVEL_LABELS[level] ?? { label: level, desc: '' }
const active = level === thinkingLevel
return (
<button
key={level}
type="button"
onClick={() => {
onSetThinkingLevel(level)
setThinkingOpen(false)
}}
className={`flex w-full items-start gap-2.5 rounded-control px-2.5 py-2 text-left transition-colors hover:bg-hover ${
active ? 'bg-hover' : ''
}`}
>
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center text-ink-2">
<LuBrain size={13} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] font-medium text-ink">{meta.label}</span>
{meta.desc && <span className="block text-[11.5px] leading-4 text-ink-3">{meta.desc}</span>}
</span>
{active && <LuCheck size={14} className="mt-1 shrink-0 text-accent-ink" />}
</button>
)
})}
</div>
</ComposerMenu>
)}
{/* Usage Card(portal,向上弹出) */}
{usageOpen && usagePos && (
<UsageCard
anchor={usagePos}
stats={stats}
compacting={compacting}
autoCompactionEnabled={autoCompactionEnabled}
onClose={() => setUsageOpen(false)}
onCompact={() => {
onCompact()
setUsageOpen(false)
}}
onAbortCompaction={() => {
onAbortCompaction()
setUsageOpen(false)
}}
onToggleAutoCompaction={onToggleAutoCompaction}
/>
)}
{/* 模型选择菜单(portal,向上弹出)。服务商并列独立成行;悬浮某行时
该服务商的模型以悬浮列表出现在左侧(级联子菜单,portal 到 body),
点击模型即切换 */}
{/* 模型选择菜单(portal,向上弹出) */}
{modelOpen && modelPos && (
<ComposerMenu anchor={modelPos} align="right" onClose={() => setModelOpen(false)}>
<div className="w-60 p-1.5" data-model-menu>
{catalogLoading && <div className="px-2.5 py-3 text-[12px] text-ink-3">加载模型中…</div>}
{!catalogLoading && providers.length === 0 && (
<div className="px-2.5 py-3 text-[12px] text-ink-3">没有可用模型,请先在设置中配置密钥</div>
)}
{providers.map((p) => {
const inUse = model?.provider === p.id
const hovered = hoveredProvider?.id === p.id
return (
<div
key={p.id}
onMouseEnter={(e) => enterProvider(p.id, e.currentTarget)}
onMouseLeave={leaveProvider}
>
<button
type="button"
className={`flex w-full items-center gap-2 rounded-control px-2.5 py-2 text-left transition-colors hover:bg-hover ${
hovered ? 'bg-hover' : ''
}`}
>
<span className="min-w-0 flex-1 truncate text-[13px] text-ink">{p.name}</span>
{inUse && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
<LuChevronRight size={12} className="shrink-0 text-ink-3" />
</button>
<div className="w-72">
<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 && providers.length === 0 && (
<div className="px-2.5 py-3 text-[12px] text-ink-3">没有可用模型,请先在设置中配置密钥</div>
)}
{providers.map((provider) => (
<div key={provider.id}>
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-medium uppercase tracking-wide text-ink-3">
{provider.name}
</div>
{provider.models.map((m) => {
const selected = model?.provider === provider.id && model?.id === m.id
return (
<button
key={`${provider.id}/${m.id}`}
type="button"
onClick={() => {
onSetModel(provider.id, m.id)
setModelOpen(false)
}}
className="flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors hover:bg-hover"
>
<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 className="mt-1 border-t border-line pt-1">
))}
</div>
<div className="border-t border-line p-1.5">
<button
type="button"
onClick={() => {
@@ -1187,50 +962,6 @@ export function Composer({
</div>
</ComposerMenu>
)}
{/* 悬浮模型列表:悬浮服务商行时出现在其左侧(portal 到 body,
ComposerMenu 的 overflow-hidden 会裁剪子元素) */}
{modelOpen &&
hoveredProvider &&
(() => {
const p = providers.find((x) => x.id === hoveredProvider.id)
if (!p) return null
return createPortal(
<div
data-model-flyout
className="fixed z-40 w-64 overflow-hidden rounded-card border border-line bg-surface shadow-overlay"
style={{
right: window.innerWidth - hoveredProvider.rect.left + 8,
bottom: window.innerHeight - hoveredProvider.rect.bottom,
maxHeight: 336
}}
onMouseEnter={keepProviderFlyout}
onMouseLeave={leaveProvider}
>
<div className="max-h-[330px] overflow-y-auto p-1.5">
{p.models.map((m) => {
const selected = model?.provider === p.id && model?.id === m.id
return (
<button
key={`${p.id}/${m.id}`}
type="button"
onClick={() => {
onSetModel(p.id, m.id)
setModelOpen(false)
setHoveredProvider(null)
}}
className="flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors hover:bg-hover"
>
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink">{m.name}</span>
{selected && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
</button>
)
})}
</div>
</div>,
document.body
)
})()}
</div>
</div>
</div>
@@ -1,8 +1,6 @@
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkCjkFriendly from 'remark-cjk-friendly'
import remarkCjkFriendlyGfmStrikethrough from 'remark-cjk-friendly-gfm-strikethrough'
import {
useLLMOutput,
type BlockMatch,
@@ -23,7 +21,6 @@ import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import { bundledLanguages, type BundledLanguage } from 'shiki/langs'
import { bundledThemes } from 'shiki/themes'
import { CodeBlock, type ShikiLine } from './CodeBlock'
import { useDarkTheme } from '../lib/theme'
/**
* Markdown 渲染:基于 llm-ui(github.com/richardgill/llm-ui)做块级解析 ——
@@ -33,8 +30,7 @@ import { useDarkTheme } from '../lib/theme'
* 代码块用 Shiki 做真实语法高亮(按需加载语言),保留本项目 CodeBlock 的行号/复制/diff 视图。
*/
/** Shiki 高亮器:双主题注册(浅色/深色随 .dark 切换,切换时按块重新取词元),
* grammar 在运行时按需动态加载 */
/** Shiki 高亮器:github-light 主题 + 常用语言(grammar 在运行时按需动态加载) */
const HIGHLIGHT_LANGS = [
'typescript',
'javascript',
@@ -65,7 +61,7 @@ const HIGHLIGHT_LANGS = [
]
const highlighterPromise: Promise<HighlighterCore> = createHighlighterCore({
themes: [bundledThemes['github-light'], bundledThemes['github-dark']],
themes: [bundledThemes['github-light']],
langs: HIGHLIGHT_LANGS.map((name) => bundledLanguages[name as BundledLanguage]),
engine: createOnigurumaEngine(import('shiki/wasm'))
})
@@ -98,111 +94,43 @@ const LANG_ALIAS: Record<string, string> = {
plaintext: ''
}
/** 行内处理:反引号代码 span 内不动;连续 2+ 个 ~ 保留(删除线标记),单个 ~ 转义为字面量 */
function escapeSingleTildeInline(line: string): string {
let out = ''
let i = 0
while (i < line.length) {
const ch = line[i]
if (ch === '`' || ch === '~') {
let j = i
while (j < line.length && line[j] === ch) j++
const run = line.slice(i, j)
if (ch === '`') {
out += run
const rest = line.slice(j)
const close = rest.indexOf(run)
if (close >= 0) {
out += rest.slice(0, close + run.length)
i = j + close + run.length
} else {
out += rest
i = line.length
}
continue
}
out += run.length >= 2 ? run : '\\~'
i = j
continue
}
out += ch
i++
}
return out
}
/** 单个 ~ 在 GFM 里会误触发删除线——中文常用 ~ 表区间(如「6强~7」「周~月」),
* 同段里出现两个就会被连成一条删除线。约定:只有成对的 ~~ 才是删除线,
* 不成对的单 ~ 转义为字面量;围栏代码块与行内代码内一律不动。 */
function neutralizeSingleTilde(text: string): string {
if (!text.includes('~')) return text
const lines = text.split('\n')
let fence: string | null = null
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (fence) {
const close = line.match(/^\s{0,3}(`{3,}|~{3,})\s*$/)
if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null
continue
}
const open = line.match(/^\s{0,3}(`{3,}|~{3,})/)
if (open) {
fence = open[1]
continue
}
lines[i] = escapeSingleTildeInline(line)
}
return lines.join('\n')
}
const mdComponents: Components = {
p({ children }) {
return <p className="my-4 leading-normal">{children}</p>
return <p className="leading-normal">{children}</p>
},
/** h1/h2 带强调条(对齐参考渲染),h3 起按层级递减,保证标题有可辨识的层级 */
h1({ children }) {
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>
)
return <h3 className="mb-1 mt-3 text-[17px] font-semibold text-ink first:mt-0">{children}</h3>
},
h2({ children }) {
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>
)
return <h4 className="mb-1 mt-3 text-[15.5px] font-semibold text-ink first:mt-0">{children}</h4>
},
h3({ children }) {
return <h5 className="mb-1.5 mt-4 text-[15px] font-semibold text-ink first:mt-0">{children}</h5>
return <h5 className="mb-1 mt-2.5 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h5>
},
h4({ children }) {
return <h6 className="mb-1.5 mt-4 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h6>
return <h6 className="mb-1 mt-2.5 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h6>
},
h5: ({ children }) => <h6 className="mb-1.5 mt-4 text-[14px] font-semibold text-ink">{children}</h6>,
h6: ({ children }) => <h6 className="mb-1.5 mt-4 text-[13.5px] font-semibold text-ink-2">{children}</h6>,
h5: ({ children }) => <h6 className="mb-1 mt-2.5 text-[14px] font-semibold text-ink">{children}</h6>,
h6: ({ children }) => <h6 className="mb-1 mt-2.5 text-[13.5px] font-semibold text-ink-2">{children}</h6>,
ul({ children }) {
return <ul className="my-4 list-disc space-y-2 pl-5 marker:text-ink-3">{children}</ul>
return <ul className="my-2 list-disc space-y-1.5 pl-5 marker:text-ink-3">{children}</ul>
},
ol({ children }) {
return <ol className="my-4 list-decimal space-y-2 pl-5 marker:text-ink-3">{children}</ol>
return <ol className="my-2 list-decimal space-y-1.5 pl-5 marker:text-ink-3">{children}</ol>
},
li({ children }) {
return <li className="leading-normal">{children}</li>
},
blockquote({ children }) {
return (
<blockquote className="my-4 border-l-2 border-line pl-3 text-[13.5px] italic text-ink-3">
<blockquote className="my-3 border-l-2 border-line pl-3 text-[13.5px] italic text-ink-3">
{children}
</blockquote>
)
},
table({ children }) {
return (
<div className="my-4 overflow-x-auto">
<div className="my-3 overflow-x-auto">
<table className="w-full border-collapse text-[13.5px]">{children}</table>
</div>
)
@@ -241,7 +169,7 @@ const mdComponents: Components = {
},
pre({ children }) {
return (
<pre className="my-4 overflow-x-auto rounded-card bg-inset p-3 text-ink-2">{children}</pre>
<pre className="my-3 overflow-x-auto rounded-card bg-inset p-3 text-ink-2">{children}</pre>
)
},
a({ children, href }) {
@@ -263,22 +191,16 @@ const mdComponents: Components = {
return <del className="text-ink-3">{children}</del>
},
img({ src, alt }) {
return <img src={src} alt={alt} className="my-3 max-w-full rounded-card border border-line" />
return <img src={src} alt={alt} className="my-2 max-w-full rounded-card border border-line" />
},
hr() {
return <hr className="my-4 border-line" />
return <hr className="my-3 border-line" />
}
}
const MarkdownComponent: LLMOutputComponent = ({ blockMatch }) => (
<div className="space-y-5 text-base text-ink">
{/* remark-cjk-friendly:CommonMark 的 flanking 规则不认 CJK——`**` 紧邻
* 汉字与全角标点(如 出自**《书名》**)时无法开合加粗,解析层修复;
* 删除线同理(GFM ~~ 的伴随插件) */}
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkCjkFriendly, remarkCjkFriendlyGfmStrikethrough]}
components={mdComponents}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{blockMatch.output}
</ReactMarkdown>
</div>
@@ -290,9 +212,6 @@ const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => {
const source = code ?? ''
const rawLang = (language ?? '').toLowerCase()
const shikiLang = LANG_ALIAS[rawLang] ?? (HIGHLIGHT_LANGS.includes(rawLang) ? rawLang : '')
// 主题随深浅色切换:切换时 effect 重跑,可见代码块重新取词元
const dark = useDarkTheme()
const shikiTheme = dark ? 'github-dark' : 'github-light'
const [tokens, setTokens] = useState<ShikiLine[] | null>(null)
useEffect(() => {
@@ -305,7 +224,7 @@ const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => {
.then((highlighter) => {
if (!alive) return
try {
const result = highlighter.codeToTokens(source, { lang: shikiLang, theme: shikiTheme })
const result = highlighter.codeToTokens(source, { lang: shikiLang, theme: 'github-light' })
setTokens(result.tokens)
} catch {
setTokens(null)
@@ -315,7 +234,7 @@ const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => {
return () => {
alive = false
}
}, [source, shikiLang, shikiTheme])
}, [source, shikiLang])
return (
<div className="my-2.5">
@@ -342,12 +261,11 @@ function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode {
return <Component blockMatch={blockMatch} />
}
function MarkdownBase({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode {
const safeText = useMemo(() => neutralizeSingleTilde(text), [text])
export function Markdown({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode {
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
const { blockMatches } = useLLMOutput({
llmOutput: safeText,
llmOutput: text,
isStreamFinished: !streaming,
fallbackBlock: markdownFallback,
blocks: [codeBlock]
@@ -361,7 +279,3 @@ function MarkdownBase({ text, streaming = false }: { text: string; streaming?: b
</>
)
}
/** memo:流式 delta 会让整份消息列表重渲染,历史消息的 text/streaming 不变,
* 直接跳过 react-markdown 的整棵 vdom diff —— 这是流式卡顿的大头。 */
export const Markdown = memo(MarkdownBase)
@@ -1,14 +1,11 @@
import { memo, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
import { animateCollapse, animateExpand } from '../lib/anim'
import { Markdown } from './Markdown'
import { Loading } from './Loading'
import { StreamingText } from './StreamingText'
import { Thinking, thinkingRows } from './Thinking'
import { ToolChips, type ToolCallView } from './ToolChips'
import { useEntrance } from '../hooks/useEntrance'
import { LuFoldVertical, LuGitFork, LuHistory, LuLoaderCircle, LuUndo2 } from 'react-icons/lu'
import { formatTokens } from '../lib/utils'
interface ContentBlock {
type?: string
@@ -31,19 +28,7 @@ function readBlocks(raw: unknown): ContentBlock[] {
return []
}
/** 折叠态紧凑行用的最新思考行(取最后一个非空行,超长截断) */
function lastThinkingLine(text: string): string {
const lines = text.split('\n')
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim()
if (line) return line.length > 120 ? `${line.slice(0, 120)}…` : line
}
return ''
}
/** 工作计时:working=true 起表,结束后冻结读数。
* working 中途抖动(pi 轮次间隙 streaming/turnActive 抖动)不锁存:
* 恢复 true 时解除冻结,继续从起点计时。 */
/** 工作计时:working=true 起表,结束后冻结读数 */
function useWorkingSeconds(working: boolean): string {
const startRef = useRef<number | null>(null)
const [finalSecs, setFinalSecs] = useState<number | null>(null)
@@ -57,10 +42,6 @@ function useWorkingSeconds(working: boolean): string {
}, [working])
useEffect(() => {
if (working && finalSecs !== null) {
setFinalSecs(null)
return
}
if (!working && startRef.current !== null && finalSecs === null) {
setFinalSecs(Math.max(1, Math.round((Date.now() - startRef.current) / 1000)))
}
@@ -78,293 +59,143 @@ function useWorkingSeconds(working: boolean): string {
}
/** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。
* 划分规则:回合结束(turnActive=false,agent_settled 之后)后,最后一轮的正文
* = 最终答案(始终可见);这轮里的其余一切 —— 思考、工具行、以及干活过程中模型
* 穿插写的叙述性正文 —— 全部归入「过程」,样式与普通正文一致,按时序穿插在
* 思考/工具行之间,折叠时随过程一起收起。
* 回合进行中不做答案/过程划分(包括工具执行与自动压缩的静默间隙——pi 会在一个
* 回合内发出多次 agent_start/agent_end,streaming 会中途抖动,划分跟着抖就会
* 出现正文在答案区与过程区之间来回跳的闪现);正在流式的消息始终渲染在底部
* 作为答案的雏形,它结束、下一轮开始后即「沉」入过程区。 */
/** 结论底部的常驻复制按钮:不随流式抖动挂载/卸载(以前每个正文段各挂一个,
* 会随回合内 streaming 抖动反复闪现),复制内容始终取当前结论文本。 */
function CopyAnswerButton({ text }: { text: string }): ReactNode {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) return
const t = setTimeout(() => setCopied(false), 1600)
return () => clearTimeout(t)
}, [copied])
const disabled = !text.trim()
return (
<button
type="button"
aria-label={copied ? '已复制' : '复制回答'}
title={copied ? '已复制' : '复制回答'}
disabled={disabled}
onClick={() => {
void navigator.clipboard.writeText(text).then(() => setCopied(true))
}}
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"
>
{copied ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="12" height="12" rx="2.5" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
)
}
function AssistantTurnBase({
* 划分规则:最后一轮的正文 = 最终答案(始终可见);这轮里的其余一切 ——
* 思考、工具行、以及干活过程中模型穿插写的叙述性正文 —— 全部归入「过程」,
* 样式与普通正文一致,按时序穿插在思考/工具行之间,折叠时随过程一起收起。
* 流式中:正在流的消息就是答案的雏形,思考/正文按真实到达顺序分段渲染;
* 它结束、下一轮开始后即「沉」入过程区。 */
export function AssistantTurn({
messages,
tools,
streaming,
turnActive,
pendingApprovals,
scrollPinnedRef,
forking = false,
onFork,
forkEntryId,
onForkAt
pendingApprovals
}: {
messages: UiMessage[]
tools: Record<string, ToolState>
streaming: boolean
turnActive: boolean
pendingApprovals: ApprovalRequest[]
/** 聊天滚动容器的吸底标记(ChatView 持有):回合结束时只在用户位于底部
* 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */
scrollPinnedRef?: { current: boolean }
/** 分叉进行中 */
forking?: boolean
/** 会话末尾分叉(只有最后一个回合透传) */
onFork?: () => void
/** 历史回合的分叉锚点:本回合之后第一条用户消息的 entryId
* (配合 onForkAt 以 'before' 分叉,新会话包含到本回合答案为止) */
forkEntryId?: string
onForkAt?: (entryId: string) => void
}): ReactNode {
// ---- 第一遍:块级描述符(只做类型判断与字符串引用收集,不拆行、不建 JSX) ----
// 长会话渐进补齐时每个批次会挂载几十个历史回合,若在首遍就执行
// thinkingRows 拆行 / Markdown 解析 / ToolChips 行构建,批次依旧很重。
// 重活推迟到「过程区确实要显示」(contentMounted)时再做。
type ProcItem =
| { k: 'think'; text: string }
| { k: 'text'; text: string }
| { k: 'tool'; id?: string; name: string }
const proc: ProcItem[] = []
const processNodes: ReactNode[] = []
const answerNodes: ReactNode[] = []
const labelCount = new Map<string, number>()
let calls = 0
let thinking = 0
let callsAndThinking: ToolCallView[] = []
let thinkingTexts: string[] = []
let stream: UiMessage['stream'] | null = null
let answerText = ''
/** 连续的思考/工具行作为一组裸行铺出(组内保留顺序),不缩进 */
const flushRows = (): void => {
if (!callsAndThinking.length && !thinkingTexts.length) return
const group = { calls: callsAndThinking, thinkingTexts }
callsAndThinking = []
thinkingTexts = []
processNodes.push(
<div key={`rows-${processNodes.length}`} className="my-1.5">
<ToolChips calls={group.calls} thinking={group.thinkingTexts} />
</div>
)
}
/** 消费一条已完成消息:asAnswer=false 时正文作为过程叙述留在过程区 */
const consume = (raw: unknown, asAnswer: boolean): void => {
for (const block of readBlocks(raw)) {
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
thinkingTexts.push(thinkingText)
thinking += 1
continue
}
if (block.type === 'text' && block.text) {
flushRows()
const node = (
<div key={`${asAnswer ? 'a' : 'p'}-${processNodes.length + answerNodes.length}`} className="my-3">
<Markdown text={block.text} />
</div>
)
if (asAnswer) answerNodes.push(node)
else processNodes.push(node)
continue
}
if (block.type === 'toolCall') {
const name = block.name ?? 'tool'
const tool = block.id ? tools[block.id] : undefined
if (!tool) {
const pending = pendingApprovals.find((a) => a.toolName === name)
if (pending) {
callsAndThinking.push({ id: block.id, name, pendingApproval: { args: pending.args } })
calls += 1
continue
}
}
const dup = labelCount.get(name) ?? 0
labelCount.set(name, dup + 1)
callsAndThinking.push({ id: block.id, name, tool })
calls += 1
}
}
}
// 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。
// 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。
// stream 不存在时,最后一条已完成消息的正文是答案。
const streamPos = messages.findIndex((m) => m.stream)
const settled = !turnActive
for (let idx = 0; idx < messages.length; idx++) {
const message = messages[idx]
if (message.stream) {
stream = message.stream
continue
}
const asAnswer = settled && streamPos === -1 && idx === messages.length - 1
for (const block of readBlocks(message.raw)) {
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
proc.push({ k: 'think', text: thinkingText })
thinking += 1
continue
}
if (block.type === 'text' && block.text) {
if (asAnswer) {
answerText = answerText ? `${answerText}\n\n${block.text}` : block.text
answerNodes.push(
<div key={`a-${answerNodes.length}`} className="my-3">
<Markdown text={block.text} />
</div>
)
} else {
proc.push({ k: 'text', text: block.text })
}
continue
}
if (block.type === 'toolCall') {
proc.push({ k: 'tool', id: block.id, name: block.name ?? 'tool' })
calls += 1
}
}
consume(message.raw, streamPos === -1 && idx === messages.length - 1)
}
flushRows()
const streamHasThinking = stream
? stream.segments.some((s) => s.kind === 'thinking' && s.text.trim().length > 0)
: false
const hasProcess = calls > 0 || thinking > 0 || streamHasThinking
// 回合是否仍在进行:turnActive/streaming 在 pi 的轮次间隙(工具执行、自动压缩、
// 排队续跑)都会中途抖动,流式占位还在(stream 非空)也代表内容仍在到达——
// 任一为真都按「进行中」处理,绝不能只看 turnActive(抖一下就会误折叠整个回合)
const live = turnActive || streaming || Boolean(stream)
// 折叠状态:历史回合默认收起,进行中的回合默认展开;结算(live 由真转假)后
// 延迟一小段才自动收起,抖动恢复即取消;且仅当用户位于聊天底部时才收起——
// 上翻阅读时折叠会猛拽滚动位置,保持展开。因抖动被自动收起的回合在 live 恢复
// 时自动重新展开;用户手动收起的不参与自动展开。
const [collapsed, setCollapsed] = useState<boolean>(() => !live && hasProcess)
// 过程内容懒挂载:历史回合默认收起,其思考/工具内容在用户首次展开前不渲染
// (折叠只是视觉收起,子树照常挂载的话长会话切换仍要全量解析)。
// live 回合从一开始就可见,挂载内容;一旦挂载过就保持(再次折叠走 GSAP
// 高度动画需要内容在场,且 Thinking 面板内部滚动状态不重置)。
const [contentMounted, setContentMounted] = useState<boolean>(() => live)
const autoCollapsedRef = useRef(false)
const prevLiveRef = useRef(live)
// 折叠状态:历史回合默认收起,流式回合默认展开;结束自动收起,可手动切换
const [collapsed, setCollapsed] = useState<boolean>(() => !streaming && hasProcess)
const prevStreaming = useRef(streaming)
useEffect(() => {
const prev = prevLiveRef.current
prevLiveRef.current = live
if (prev && !live) {
const timer = window.setTimeout(() => {
if (scrollPinnedRef?.current !== false) {
autoCollapsedRef.current = true
setCollapsed(true)
}
}, 600)
return () => window.clearTimeout(timer)
}
if (!prev && live && autoCollapsedRef.current) {
autoCollapsedRef.current = false
setContentMounted(true)
setCollapsed(false)
}
}, [live, scrollPinnedRef])
if (prevStreaming.current && !streaming) setCollapsed(true)
prevStreaming.current = streaming
}, [streaming])
// ---- 第二遍:按需物化过程区(仅在内容已挂载时执行重活) ----
const processNodes: ReactNode[] = []
if (contentMounted) {
/** 连续的工具行作为一组裸行铺出(组内保留顺序),不缩进 */
let pendingCalls: ToolCallView[] = []
const flushRows = (): void => {
if (!pendingCalls.length) return
const group = pendingCalls
pendingCalls = []
processNodes.push(
<div key={`rows-${processNodes.length}`} className="my-1.5">
<ToolChips calls={group} />
</div>
)
}
for (const item of proc) {
if (item.k === 'think') {
// 已完成的思考以展开的 Thinking 面板留在过程区——与流式形态一致
flushRows()
processNodes.push(
<div key={`think-${processNodes.length}`} className="mb-3">
<Thinking rows={thinkingRows(item.text)} streaming={false} />
</div>
)
} else if (item.k === 'text') {
// 干活过程中模型穿插写的叙述性正文,样式与普通正文一致
flushRows()
processNodes.push(
<div key={`p-${processNodes.length}`} className="my-3">
<Markdown text={item.text} />
</div>
)
} else {
const tool = item.id ? tools[item.id] : undefined
if (!tool) {
const pending = pendingApprovals.find((a) => a.toolName === item.name)
if (pending) {
pendingCalls.push({ id: item.id, name: item.name, pendingApproval: { args: pending.args } })
continue
}
}
pendingCalls.push({ id: item.id, name: item.name, tool })
}
}
flushRows()
}
// 过程区折叠/展开:GSAP 高度 + 透明度补间(可中断续接)。内联高度完全由
// 动画层持有,React 不渲染 style,避免重渲染时清掉补间中的值。
// 首帧直接落位(历史回合默认收起),不播动画。
const processRef = useRef<HTMLDivElement | null>(null)
const prevCollapsedRef = useRef<boolean | null>(null)
useLayoutEffect(() => {
const el = processRef.current
if (!el) return
const effective = collapsed && hasProcess
const prev = prevCollapsedRef.current
prevCollapsedRef.current = effective
if (prev === null) {
if (effective) {
el.style.height = '0px'
el.style.opacity = '0'
el.style.pointerEvents = 'none'
}
return
}
if (prev === effective) return
if (effective) animateCollapse(el)
else animateExpand(el)
}, [collapsed, hasProcess])
const workedFor = useWorkingSeconds(turnActive || streaming)
const workedFor = useWorkingSeconds(streaming)
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
// 流式分段:思考段属过程(过程区折叠时隐藏,由头部下方的紧凑行代为展示
// 最新一行),正文段属答案;均按真实时序渲染。思考块保持挂载(仅 display:none),
// 折叠/展开往返不重置内部计时的滚动状态。
// 流式分段:思考段属过程(折叠时隐藏),正文段属答案;均按真实时序渲染
const showStreamThinking = !collapsed || !hasProcess
const streamNodes: ReactNode[] = []
let visibleThinkingRows = 0
if (stream) {
const segments = stream.segments
// 光标只挂在最后一个非空正文段末尾(CSS ::after 附加),而非每个正文段一个
let lastTextSeg = -1
segments.forEach((s, i) => {
if (s.kind === 'text' && s.text) lastTextSeg = i
})
segments.forEach((seg, i) => {
stream.segments.forEach((seg, i) => {
if (seg.kind === 'thinking') {
if (!showStreamThinking) return
const rows = thinkingRows(seg.text)
if (rows.length === 0) return
// 只有「最后一段且之后没有正文」才算仍在流式;否则一旦 pi 在回合内
// 分多轮发出(静默间隙 streaming 抖动),已完成段会被误判回流式态
const isLast = i === segments.length - 1
const followedByText = segments.some((s, k) => k > i && s.kind === 'text' && s.text.length > 0)
visibleThinkingRows += rows.length
streamNodes.push(
<div key={`seg-${i}`} className={collapsed && hasProcess ? 'hidden' : 'mb-3'}>
<Thinking rows={rows} streaming={streaming && isLast && !followedByText} />
<div key={`seg-${i}`} className="mb-3">
<Thinking rows={rows} streaming={streaming} />
</div>
)
return
}
if (seg.text) {
answerText = answerText ? `${answerText}\n\n${seg.text}` : seg.text
streamNodes.push(
<StreamingText key={`seg-${i}`} streaming={streaming && i === lastTextSeg}>
<StreamingText key={`seg-${i}`} streaming={streaming} text={seg.text}>
<Markdown text={seg.text} streaming={streaming} />
</StreamingText>
)
}
})
}
// 过程区折叠时的「正在思考」紧凑行:展示最新一行思考(ZCode 式),随流式刷新
// 折叠时思考不可见 → 头部提示「思考中…」(仅当前正在流思考段时)
const lastSeg = stream?.segments[stream.segments.length - 1]
const compactThinking =
collapsed && hasProcess && stream && lastSeg?.kind === 'thinking' ? lastThinkingLine(lastSeg.text) : null
// 复制操作行一旦出现就保持挂载:回合内多轮流转会让 answerText 在「流式段」与
// 「已完成消息」两个来源间短暂换源(瞬间为空),不锁存会闪现
const [hasAnswer, setHasAnswer] = useState(answerText.trim().length > 0)
if (answerText.trim() && !hasAnswer) setHasAnswer(true)
const hiddenThinking =
collapsed && hasProcess && Boolean(stream && lastSeg?.kind === 'thinking' && lastSeg.text.trim())
return (
<div className="min-w-0 max-w-full">
@@ -373,11 +204,7 @@ function AssistantTurnBase({
<button
type="button"
aria-expanded={!collapsed}
onClick={() => {
autoCollapsedRef.current = false
setContentMounted(true)
setCollapsed((v) => !v)
}}
onClick={() => setCollapsed((v) => !v)}
className="-mx-1.5 flex h-7 w-fit items-center gap-1.5 rounded-control px-1.5 text-[12.5px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<svg
@@ -389,218 +216,57 @@ function AssistantTurnBase({
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-transform duration-500"
className="transition-transform"
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
>
<path d="M6 9l6 6 6-6" />
</svg>
<span className="tabular-nums">{headerLabel}</span>
<span className="tabular-nums">
{headerLabel}
{hiddenThinking ? ',思考中…' : ''}
</span>
</button>
</div>
)}
{/* 折叠时的实时思考预览:仅展示最新一行,随流式滚动前进 */}
{compactThinking !== null && (
<div className="flex min-w-0 items-center gap-2 py-1.5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--ink-3)" className="shrink-0">
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
</svg>
<span
className="bg-clip-text whitespace-nowrap text-[12.5px] font-medium text-transparent"
style={{
backgroundImage: 'linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)',
backgroundSize: '200% 100%',
animation: 'shimmer-text 1.4s linear infinite'
}}
>
正在思考
</span>
{compactThinking && (
<span className="min-w-0 truncate text-[12.5px] text-ink-3">· {compactThinking}</span>
)}
</div>
)}
{/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect)。
未展开过的历史回合不挂载内容(懒渲染),首展时随本组件 state 更新入树 */}
<div ref={processRef} aria-hidden={collapsed && hasProcess} className="overflow-hidden">
{contentMounted ? processNodes : null}
</div>
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
{answerNodes}
{streamNodes.length > 0 && <div>{streamNodes}</div>}
{stream && streamNodes.length === 0 && !(collapsed && hasProcess) && (
<Loading label="思考中" variant="Dots" />
)}
{/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */}
{turnActive && !stream && <Loading label="处理中" variant="Dots" />}
{/* 结论底部的操作行:复制 + 会话分叉。末回合走会话末尾分叉(onFork),
历史回合以「本回合之后第一条用户消息」为锚点(forkEntryId + onForkAt),
新会话都包含到本回合答案为止。常驻不闪现。 */}
{(hasAnswer || answerText.trim().length > 0 || onFork || (forkEntryId && onForkAt)) && (
<div className="mt-1.5 flex items-center gap-0.5">
<CopyAnswerButton text={answerText} />
{(onFork || (forkEntryId && onForkAt)) && (
<button
type="button"
aria-label="从此处分叉为新会话"
title="从此处分叉为新会话"
disabled={forking}
onClick={() => {
if (onFork) onFork()
else if (forkEntryId && onForkAt) onForkAt(forkEntryId)
}}
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2 disabled:cursor-default disabled:opacity-40"
>
{forking ? (
<LuLoaderCircle size={15} className="animate-spin" />
) : (
<LuGitFork size={15} className="rotate-180" />
)}
</button>
)}
</div>
)}
</div>
)
}
/** 回溯菜单:从这条用户消息切回会话树上的旧节点,可选为被离开的分支生成分支摘要 */
function RewindMenu({ entryId, onRewind }: { entryId: string; onRewind?: (entryId: string, summarize: boolean) => void }): ReactNode {
const [open, setOpen] = useState(false)
if (!onRewind) return null
return (
<div className="relative">
<button
type="button"
aria-label="回溯到此处"
title="回溯到这条消息"
onClick={() => setOpen((v) => !v)}
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2"
>
<LuHistory size={13} />
</button>
{open && (
{/* 展开态:过程(思考/工具/叙述正文按时序穿插)+ 最终答案;收起态:只留最终答案 */}
{!collapsed || !hasProcess ? (
<>
<div className="fixed inset-0 z-20" onClick={() => setOpen(false)} />
<div className="absolute bottom-full right-0 z-30 mb-1 w-60 overflow-hidden rounded-card border border-line bg-surface p-1 shadow-overlay">
<button
type="button"
onClick={() => {
setOpen(false)
onRewind(entryId, true)
}}
className="flex w-full items-start gap-2.5 rounded-control px-2.5 py-2 text-left transition-colors hover:bg-hover"
>
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center text-ink-2">
<LuFoldVertical size={13} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] font-medium text-ink">生成摘要并回溯</span>
<span className="block text-[11.5px] leading-4 text-ink-3">被离开的分支会总结为分支摘要,上下文不丢失</span>
</span>
</button>
<button
type="button"
onClick={() => {
setOpen(false)
onRewind(entryId, false)
}}
className="flex w-full items-start gap-2.5 rounded-control px-2.5 py-2 text-left transition-colors hover:bg-hover"
>
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center text-ink-2">
<LuUndo2 size={13} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] font-medium text-ink">直接回溯</span>
<span className="block text-[11.5px] leading-4 text-ink-3">不生成分支摘要</span>
</span>
</button>
</div>
{processNodes}
{answerNodes}
{streamNodes.length > 0 && <div>{streamNodes}</div>}
{stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && (
<Loading label="思考中" variant="Dots" />
)}
</>
) : (
<>
{answerNodes}
{streamNodes.length > 0 && <div>{streamNodes}</div>}
</>
)}
</div>
)
}
/** 压缩 / 分支摘要通知行(pi 会话时间线中的 compactionSummary / branchSummary) */
function SystemNotice({ message, animate = true }: { message: UiMessage; animate?: boolean }): ReactNode | null {
const ref = useEntrance<HTMLDivElement>(animate)
const raw = (message.raw ?? {}) as { summary?: unknown; tokensBefore?: unknown }
const summary = typeof raw.summary === 'string' ? raw.summary : ''
const [expanded, setExpanded] = useState(false)
const isCompaction = message.role === 'compactionSummary'
const tokensBefore = typeof raw.tokensBefore === 'number' ? raw.tokensBefore : undefined
return (
<div ref={ref} className="flex justify-center">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
title={summary || undefined}
className={`flex min-w-0 max-w-[85%] flex-col items-center gap-1 rounded-[10px] border border-line px-4 py-2 text-center transition-colors hover:bg-hover ${
expanded ? 'bg-surface' : ''
}`}
>
<span className="flex items-center gap-1.5 text-[12px] text-ink-3">
{isCompaction ? <LuFoldVertical size={12} /> : <LuHistory size={12} />}
{isCompaction ? '上下文已压缩' : '分支摘要'}
{isCompaction && tokensBefore ? ` · 此前约 ${formatTokens(tokensBefore)} tokens 已归档为摘要` : ''}
{summary && !expanded && <span className="text-ink-3/70">(点击展开)</span>}
</span>
{expanded && summary && (
<span className="max-h-48 w-full overflow-y-auto whitespace-pre-wrap text-left text-[12px] leading-5 text-ink-2">
{summary}
</span>
)}
</button>
</div>
)
}
function MessageItemBase({
export function MessageItem({
message,
tools,
streaming,
turnActive,
pendingApprovals = [],
scrollPinnedRef,
forkEntryId,
rewinding,
onRewind,
animate = true
pendingApprovals = []
}: {
message: UiMessage
tools: Record<string, ToolState>
streaming: boolean
turnActive: boolean
pendingApprovals?: ApprovalRequest[]
scrollPinnedRef?: { current: boolean }
/** 对应会话树上的回溯点(用户消息按序对齐 forkPoints) */
forkEntryId?: string
/** 分支回溯进行中(禁用回溯入口) */
rewinding?: boolean
onRewind?: (entryId: string, summarize: boolean) => void
/** 渐进填充历史时不播入场动画 */
animate?: boolean
}): ReactNode | null {
const ref = useEntrance<HTMLDivElement>(animate)
if (message.role === 'compactionSummary' || message.role === 'branchSummary') {
return <SystemNotice message={message} animate={animate} />
}
const ref = useEntrance<HTMLDivElement>()
if (message.role === 'user') {
const blocks = readBlocks(message.raw)
const text = blocks.map((b) => b.text ?? '').join('') || ''
return (
<div ref={ref} className="group/msg flex items-center justify-end gap-1.5">
{forkEntryId && !rewinding && (
<div className="opacity-0 transition-opacity duration-100 group-hover/msg:opacity-100">
<RewindMenu entryId={forkEntryId} onRewind={onRewind} />
</div>
)}
<div ref={ref} className="flex justify-end">
<div className="max-w-[85%] rounded-[10px] bg-inset px-4 py-3 text-base whitespace-pre-wrap leading-normal text-ink">
{text}
</div>
@@ -611,14 +277,7 @@ function MessageItemBase({
if (message.role === 'assistant') {
return (
<div ref={ref} className="min-w-0 max-w-full">
<AssistantTurn
messages={[message]}
tools={tools}
streaming={streaming}
turnActive={turnActive}
pendingApprovals={pendingApprovals}
scrollPinnedRef={scrollPinnedRef}
/>
<AssistantTurn messages={[message]} tools={tools} streaming={streaming} pendingApprovals={pendingApprovals} />
</div>
)
}
@@ -626,71 +285,3 @@ function MessageItemBase({
// 其他角色(custom / notification 等)暂不渲染
return null
}
// ---- memo ----
// ChatView 在每次流式刷新(delta 合批约 40ms 一次)都会重跑 renderTurns,
// 重建整棵消息列表的 React 元素。没有 memo 时所有历史回合都会跟着重渲染
// (重跑块扫描、重建 ToolChips 行、diff Markdown vdom)——长会话下流式
// 也会越写越卡。比较器按「本回合实际引用的东西」判断:消息对象按引用相等
// (reducer 只替换变更的消息),tools 只比较本回合 toolCall 引用到的条目。
/** messages 数组按元素引用相等(renderTurns 每次 slice 出新数组,引用比较无意义) */
function sameMessages(prev: UiMessage[], next: UiMessage[]): boolean {
if (prev === next) return true
if (prev.length !== next.length) return false
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== next[i]) return false
}
return true
}
/** tools 记录中本回合引用的条目是否有变化(未引用的变化不需要重渲染) */
function referencedToolChanged(
prevTools: Record<string, ToolState>,
nextTools: Record<string, ToolState>,
messages: UiMessage[]
): boolean {
for (const m of messages) {
for (const block of readBlocks(m.raw)) {
if (block.type === 'toolCall' && block.id && prevTools[block.id] !== nextTools[block.id]) return true
}
}
return false
}
function sameApprovals(prev: ApprovalRequest[], next: ApprovalRequest[]): boolean {
if (prev === next) return true
if (prev.length === 0 && next.length === 0) return true
return false
}
function sameTurnProps(
prev: ComponentProps<typeof AssistantTurnBase>,
next: ComponentProps<typeof AssistantTurnBase>
): boolean {
if (prev.streaming !== next.streaming || prev.turnActive !== next.turnActive) return false
if (prev.forking !== next.forking || prev.onFork !== next.onFork) return false
if (prev.forkEntryId !== next.forkEntryId || prev.onForkAt !== next.onForkAt) return false
if (!sameApprovals(prev.pendingApprovals, next.pendingApprovals)) return false
if (!sameMessages(prev.messages, next.messages)) return false
if (prev.tools !== next.tools && referencedToolChanged(prev.tools, next.tools, next.messages)) return false
return true
}
function sameItemProps(
prev: ComponentProps<typeof MessageItemBase>,
next: ComponentProps<typeof MessageItemBase>
): boolean {
if (prev.message !== next.message) return false
if (prev.streaming !== next.streaming || prev.turnActive !== next.turnActive) return false
if (prev.forkEntryId !== next.forkEntryId || prev.rewinding !== next.rewinding) return false
if (prev.onRewind !== next.onRewind || prev.animate !== next.animate) return false
if (!sameApprovals(prev.pendingApprovals ?? [], next.pendingApprovals ?? [])) return false
if (prev.tools !== next.tools && next.message.role === 'assistant') {
if (referencedToolChanged(prev.tools, next.tools, [next.message])) return false
}
return true
}
export const AssistantTurn = memo(AssistantTurnBase, sameTurnProps)
export const MessageItem = memo(MessageItemBase, sameItemProps)
@@ -1,30 +1,43 @@
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
import { LuCheck, LuCopy, LuQuote } from 'react-icons/lu'
import { LuCircleHelp, LuScissors, LuSmile, LuSpellCheck, LuWandSparkles } from 'react-icons/lu'
/** 选区操作条。
/** 选区操作条(beautifului.dev Selection Actions)。
* 在 containerRef 内划选文本后,于选区上方浮出胶囊工具条:
* 「引用」把选中文本注入输入框开头(blockquote 形式),「复制」写入剪贴板;
* Esc / 滚动 / 点击后收起。 */
* 「描述修改」输入框 + 解释/改进,更多按钮展开 精简/语气/语法;
* 输入内容后出现发送钮。预设动作与自定义指令都经 onApply 交给上层。 */
const PRESET_MAIN: { label: string; icon: ReactNode }[] = [
{ label: '解释', icon: <LuCircleHelp size={14} strokeWidth={1.8} /> },
{ label: '改进', icon: <LuWandSparkles size={14} strokeWidth={1.8} /> }
]
const PRESET_MORE: { label: string; icon: ReactNode }[] = [
{ label: '精简', icon: <LuScissors size={14} strokeWidth={1.8} /> },
{ label: '语气', icon: <LuSmile size={14} strokeWidth={1.8} /> },
{ label: '语法', icon: <LuSpellCheck size={14} strokeWidth={1.8} /> }
]
const pillButton =
'inline-flex items-center justify-center select-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none text-ink hover:bg-hover h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1 shrink-0'
export function SelectionActions({
containerRef,
onQuote
onApply
}: {
containerRef: RefObject<HTMLElement | null>
onQuote: (selection: string) => void
onApply: (instruction: string, selection: string) => void
}): ReactNode {
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
const [selection, setSelection] = useState('')
const [copied, setCopied] = useState(false)
const [instruction, setInstruction] = useState('')
const [moreOpen, setMoreOpen] = useState(false)
const pillRef = useRef<HTMLDivElement>(null)
const dismiss = (): void => {
setAnchor(null)
setSelection('')
setCopied(false)
setInstruction('')
setMoreOpen(false)
}
// 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起)
@@ -81,19 +94,19 @@ export function SelectionActions({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [anchor])
const quote = (): void => {
const apply = (label: string): void => {
if (!selection) return
onQuote(selection)
onApply(label, selection)
dismiss()
window.getSelection()?.removeAllRanges()
}
const copy = (): void => {
if (!selection) return
void navigator.clipboard.writeText(selection).then(() => {
setCopied(true)
window.setTimeout(dismiss, 900)
})
const submitInstruction = (): void => {
const text = instruction.trim()
if (!text || !selection) return
onApply(`${text}(针对以下选中内容)`, selection)
dismiss()
window.getSelection()?.removeAllRanges()
}
const visible = anchor !== null
@@ -109,20 +122,81 @@ export function SelectionActions({
pointerEvents: visible ? undefined : 'none'
}}
>
<div className="flex h-9 w-fit items-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 shadow-overlay">
<button type="button" className={pillButton} onClick={quote}>
<LuQuote size={14} strokeWidth={1.8} />
引用
</button>
<span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
<button type="button" className={pillButton} onClick={copy}>
{copied ? (
<LuCheck size={14} strokeWidth={2} className="text-accent-ink" />
) : (
<LuCopy size={14} strokeWidth={1.8} />
)}
{copied ? '已复制' : '复制'}
</button>
<div className="flex h-9 w-fit max-w-[calc(100vw-48px)] items-center justify-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 font-sans font-normal text-ink shadow-overlay">
{/* 描述修改输入框(常显,站点默认态) */}
<div className="flex w-fit shrink-0 items-center justify-center gap-0.5 overflow-hidden" style={{ maxWidth: 152 }}>
<form
className="flex h-7 shrink-0 items-center"
style={{ width: 152 }}
onSubmit={(e) => {
e.preventDefault()
submitInstruction()
}}
>
<input
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
placeholder="描述修改…"
aria-label="描述修改"
className="h-7 w-full bg-transparent pr-2.5 pl-3 text-[12.5px] text-ink outline-none placeholder:text-ink-3"
/>
</form>
</div>
{/* 分隔线 */}
<span className="mx-1 h-4 w-px shrink-0 bg-line-strong" />
{/* 预设动作 + 更多 */}
<div className="flex min-w-0 items-center gap-0.5 overflow-hidden">
{PRESET_MAIN.map((action) => (
<button key={action.label} type="button" className={pillButton} onClick={() => apply(action.label)}>
{action.icon}
{action.label}
</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>
)
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import type { JSX, RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import {
LuBot,
LuBrain,
LuCheck,
LuCpu,
LuFolderOpen,
@@ -10,17 +12,29 @@ import {
LuPlus,
LuPuzzle,
LuSearch,
LuServer,
LuSettings2,
LuSparkles,
LuTerminal,
LuTextQuote,
LuWebhook,
LuX
} from 'react-icons/lu'
import type { IconType } from 'react-icons'
import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState, ThemeMode } from '@shared/types'
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState } from '@shared/types'
import { THEME_PRESETS, applyThemeColor } from '../lib/theme'
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands'
type Tab =
| 'general'
| 'model'
| 'prompt'
| 'memory'
| 'subagent'
| 'plugins'
| 'mcp'
| 'skills'
| 'commands'
| 'hooks'
/** 左侧分组导航(参考 ZCode 设置页:基础设置 / Agent 能力) */
const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: IconType }> }> = [
@@ -35,24 +49,36 @@ const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: I
{
group: 'Agent 能力',
items: [
{ id: 'memory', label: '记忆', icon: LuBrain },
{ id: 'subagent', label: '子智能体', icon: LuBot },
{ id: 'plugins', label: '插件', icon: LuPuzzle },
{ id: 'mcp', label: 'MCP 服务器', icon: LuServer },
{ id: 'skills', label: '技能', icon: LuSparkles },
{ id: 'commands', label: '命令', icon: LuTerminal }
{ id: 'commands', label: '命令', icon: LuTerminal },
{ id: 'hooks', label: '钩子', icon: LuWebhook }
]
}
]
const PLACEHOLDER: Record<string, string> = {
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。'
memory: '让 Agent 在会话之间记住偏好与事实,越用越懂你的店铺。',
subagent: '为主流程配置可调度的子智能体,分头完成检索、写作与校验。',
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。',
mcp: '接入 MCP 服务器,把外部工具与数据源挂载到 Agent。',
hooks: '在会话生命周期的关键节点上挂载自动化钩子。'
}
const TAB_META: Record<Tab, { title: string; desc: string }> = {
general: { title: '常规', desc: '主题色等外观偏好。' },
model: { title: '模型设置', desc: '' },
prompt: { title: '系统提示词', desc: '当前生效的 pi 原生系统提示词(含技能清单注入),只读。' },
prompt: { title: '系统提示词', desc: '覆盖 Agent 的系统提示词,保存后重建运行时。' },
memory: { title: '记忆', desc: PLACEHOLDER.memory },
subagent: { title: '子智能体', desc: PLACEHOLDER.subagent },
plugins: { title: '插件', desc: PLACEHOLDER.plugins },
mcp: { title: 'MCP 服务器', desc: PLACEHOLDER.mcp },
skills: { title: '技能', desc: 'SKILL.md 技能包,Agent 自动按需加载。' },
commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' }
commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' },
hooks: { title: '钩子', desc: PLACEHOLDER.hooks }
}
function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.Element {
@@ -62,7 +88,7 @@ function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.El
if (provider.isOAuth) {
return <span className="rounded-control bg-accent-tint px-1.5 py-0.5 text-[10px] text-accent-ink">OAuth</span>
}
return <span className="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">未配置</span>
return <span className="rounded-control bg-zinc-100 px-1.5 py-0.5 text-[10px] text-zinc-500">未配置</span>
}
function formatContext(n: number): string {
@@ -73,12 +99,12 @@ function formatContext(n: number): string {
function ComingSoon({ icon: Icon, label }: { icon: IconType; label: string }): ReactNode {
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-card border border-dashed border-line py-16 text-center">
<span className="flex size-10 items-center justify-center rounded-full bg-hover text-ink-3">
<div className="flex flex-col items-center justify-center gap-2 rounded-card border border-dashed border-zinc-200 py-16 text-center">
<span className="flex size-10 items-center justify-center rounded-full bg-zinc-100 text-zinc-400">
<Icon size={18} />
</span>
<div className="text-[13px] font-medium text-ink-2">{label}</div>
<div className="text-[12px] text-ink-3">该能力正在建设中,敬请期待</div>
<div className="text-[13px] font-medium text-zinc-600">{label}</div>
<div className="text-[12px] text-zinc-400">该能力正在建设中,敬请期待</div>
</div>
)
}
@@ -105,18 +131,15 @@ function ResourcePanel({
}: {
loading: boolean
items: ResourceItem[]
/** 当前作用域的资源目录(可多个,如技能的 ~/.pi/agent/skills 与 ~/.agents/skills) */
dirs: { user: string[]; project: string[] }
dirs: { user: string; project: string }
emptyTitle: string
/** 与 dirs 一一对应的短标签(展示用;完整路径放 tooltip) */
hints: { user: string[]; project: string[] }
hints: { user: string; project: string }
onOpen: (path: string) => void
onOpenDir: (path: string) => void
}): ReactNode {
const [scope, setScope] = useState<'user' | 'project'>('user')
const scoped = items.filter((i) => i.scope === scope)
const scopedDirs = (scope === 'user' ? dirs.user : dirs.project).filter(Boolean)
const scopedHints = scope === 'user' ? hints.user : hints.project
const dir = scope === 'user' ? dirs.user : dirs.project
return (
<div className="flex min-h-0 flex-col">
@@ -135,30 +158,23 @@ function ResourcePanel({
</button>
))}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{scopedDirs.map((d, i) => (
<button
key={d || i}
type="button"
title={d}
onClick={() => onOpenDir(d)}
className="flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 font-mono text-[10.5px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
>
<LuFolderOpen size={12} className="shrink-0" />
{scopedHints[i] ?? '打开目录'}
</button>
))}
</div>
{dir && (
<button
type="button"
title={dir}
onClick={() => onOpenDir(dir)}
className="ml-auto flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 text-[11.5px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
>
<LuFolderOpen size={12} className="shrink-0" />
打开目录
</button>
)}
</div>
{!loading && scoped.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-zinc-200 py-12 text-center">
<div className="text-[13px] font-medium text-zinc-600">{emptyTitle}</div>
{scopedHints.map((h) => (
<div key={h} className="font-mono text-[11.5px] text-zinc-400">
{h}
</div>
))}
<div className="text-[12px] text-zinc-400">{hints[scope]}</div>
</div>
) : (
<div className="space-y-1.5">
@@ -168,25 +184,25 @@ function ResourcePanel({
type="button"
title={`${item.filePath}(点击打开)`}
onClick={() => onOpen(item.filePath)}
className="block w-full rounded-card border border-line px-3 py-2.5 text-left transition-colors hover:border-line-strong hover:bg-inset"
className="block w-full rounded-card border border-zinc-200 px-3 py-2.5 text-left transition-colors hover:border-zinc-300 hover:bg-zinc-50"
>
<div className="flex items-center gap-2">
<span className="font-mono text-[12.5px] font-medium text-ink">{item.title}</span>
<span className="font-mono text-[12.5px] font-medium text-zinc-900">{item.title}</span>
{(item.badges ?? []).map((b) => (
<span
key={b.text}
className={`rounded-control px-1.5 py-0.5 text-[10px] ${
b.tone === 'accent' ? 'bg-accent-tint text-accent-ink' : 'bg-orange-tint text-orange'
b.tone === 'accent' ? 'bg-accent-tint text-accent-ink' : 'bg-amber-50 text-amber-600'
}`}
>
{b.text}
</span>
))}
<span className="ml-auto min-w-0 truncate font-mono text-[10.5px] text-ink-3">
<span className="ml-auto min-w-0 truncate font-mono text-[10.5px] text-zinc-400">
{item.hint || item.filePath}
</span>
</div>
{item.desc && <div className="mt-1 line-clamp-2 text-[12px] leading-5 text-ink-3">{item.desc}</div>}
{item.desc && <div className="mt-1 line-clamp-2 text-[12px] leading-5 text-zinc-500">{item.desc}</div>}
</button>
))}
</div>
@@ -219,7 +235,7 @@ const CUSTOM_API_OPTIONS: Array<{ value: string; label: string }> = [
]
const customInputCls =
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-ink-3'
'w-full rounded-control border border-zinc-300 bg-white px-2.5 py-1.5 text-[12px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500'
/** 模型设置:搜索/过滤 + @tanstack/react-virtual 虚拟列表(供应商多、模型上千时滚动不卡顿)。
* 卡片视觉按行拼装:provider 头行圆角上盖、模型行接左右边框、末行圆角下盖,行尾留组间距。
@@ -330,17 +346,17 @@ function ModelSettings({
switch (row.kind) {
case 'provider':
return (
<div className="rounded-t-[14px] border border-b-0 border-line bg-surface px-3 pb-2 pt-3">
<div className="rounded-t-[14px] border border-b-0 border-zinc-200 bg-white px-3 pb-2 pt-3">
<div className="flex items-center gap-2">
<span className="text-[12.5px] font-medium text-ink">{row.provider.name}</span>
<span className="text-[12.5px] font-medium text-zinc-800">{row.provider.name}</span>
<ProviderBadge provider={row.provider} />
{customProviders.some((c) => c.id === row.provider.id) && (
<span className="rounded-control bg-accent-tint px-1.5 py-0.5 text-[10px] text-accent-ink">自定义</span>
<span className="rounded-control bg-violet-100 px-1.5 py-0.5 text-[10px] text-violet-600">自定义</span>
)}
<button
type="button"
onClick={() => onEditKey(row.provider.id)}
className="ml-auto flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
className="ml-auto flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
>
<LuKeyRound size={11} />
{row.provider.hasApiKey ? '更换密钥' : '设置密钥'}
@@ -350,7 +366,7 @@ function ModelSettings({
type="button"
disabled={busy}
onClick={() => onLogout(row.provider.id)}
className="flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-ink-3 transition-colors hover:bg-red-tint hover:text-red"
className="flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-red-50 hover:text-red-600"
>
<LuLogOut size={11} />
移除
@@ -361,7 +377,7 @@ function ModelSettings({
type="button"
disabled={busy}
onClick={() => onDeleteCustom(row.provider.id)}
className="flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-ink-3 transition-colors hover:bg-red-tint hover:text-red"
className="flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-red-50 hover:text-red-600"
>
<LuX size={11} />
删除
@@ -372,20 +388,20 @@ function ModelSettings({
)
case 'key':
return (
<div className="border-x border-line bg-inset px-3 py-2">
<div className="flex items-center gap-2 rounded-control border border-line bg-surface p-2">
<div className="border-x border-zinc-200 bg-zinc-50 px-3 py-2">
<div className="flex items-center gap-2 rounded-control border border-zinc-200 bg-white p-2">
<input
type="password"
value={keyValue}
onChange={(e) => onKeyChange(e.target.value)}
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"
placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)"
className="flex-1 rounded-control border border-zinc-300 bg-white px-2.5 py-1.5 font-mono text-[12px] text-zinc-900 outline-none focus:border-zinc-500"
/>
<button
type="button"
disabled={busy || !keyValue.trim()}
onClick={() => onSaveKey(row.provider.id)}
className="flex items-center gap-1 rounded-control bg-ink px-2.5 py-1.5 text-[12px] text-page transition-opacity hover:opacity-90 disabled:opacity-50"
className="flex items-center gap-1 rounded-control bg-zinc-900 px-2.5 py-1.5 text-[12px] text-white transition-colors hover:bg-zinc-700 disabled:opacity-50"
>
<LuCheck size={12} />
保存
@@ -393,7 +409,7 @@ function ModelSettings({
<button
type="button"
onClick={onKeyCancel}
className="rounded-control px-2 py-1.5 text-[12px] text-ink-3 hover:bg-hover"
className="rounded-control px-2 py-1.5 text-[12px] text-zinc-500 hover:bg-zinc-100"
>
取消
</button>
@@ -403,25 +419,25 @@ function ModelSettings({
case 'model': {
const selected = row.provider.id === currentProvider && row.model.id === currentModel
return (
<div className={`border-line px-1.5 ${row.last ? 'border-x border-b pb-1.5' : 'border-x'}`}>
<div className={`border-zinc-200 px-1.5 ${row.last ? 'border-x border-b pb-1.5' : 'border-x'}`}>
<button
type="button"
disabled={busy}
onClick={() => onSelectModel(row.provider.id, row.model)}
className={`flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors ${
selected ? 'bg-accent-tint ring-1 ring-accent' : 'hover:bg-hover'
selected ? 'bg-accent-tint ring-1 ring-accent' : 'hover:bg-zinc-100'
}`}
>
{selected ? (
<LuCheck size={13} className="shrink-0 text-accent-ink" />
) : (
<span className="h-[13px] w-[13px] shrink-0 rounded-full border border-line-strong" />
<span className="h-[13px] w-[13px] shrink-0 rounded-full border border-zinc-300" />
)}
<span className={`text-[12.5px] ${selected ? 'font-medium text-ink' : 'text-ink-2'}`}>
<span className={`text-[12.5px] ${selected ? 'font-medium text-zinc-900' : 'text-zinc-700'}`}>
{row.model.name}
</span>
<span className="font-mono text-[10.5px] text-ink-3">{row.model.id}</span>
<span className="ml-auto flex items-center gap-2 text-[10.5px] text-ink-3">
<span className="font-mono text-[10.5px] text-zinc-400">{row.model.id}</span>
<span className="ml-auto flex items-center gap-2 text-[10.5px] text-zinc-400">
{row.model.reasoning && <span>推理</span>}
<span>{formatContext(row.model.contextWindow)} ctx</span>
</span>
@@ -431,7 +447,7 @@ function ModelSettings({
}
case 'empty':
return (
<div className="rounded-b-[14px] border border-t-0 border-line px-3 pb-1.5 pt-0.5 text-[11.5px] text-ink-3">
<div className="rounded-b-[14px] border border-t-0 border-zinc-200 px-3 pb-1.5 pt-0.5 text-[11.5px] text-zinc-400">
配置密钥后显示可用模型
</div>
)
@@ -442,16 +458,16 @@ function ModelSettings({
<div className="flex min-h-0 flex-col">
<div className="flex items-center gap-2 pb-3">
<div className="relative flex-1">
<LuSearch size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3" />
<LuSearch size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="搜索模型或提供商"
className="w-full rounded-control border border-line-strong bg-surface py-1.5 pl-8 pr-3 text-[12.5px] text-ink outline-none placeholder:text-ink-3 focus:border-ink-3"
className="w-full rounded-control border border-zinc-300 bg-white py-1.5 pl-8 pr-3 text-[12.5px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500"
/>
</div>
{loading && <span className="text-[11px] text-ink-3">加载中…</span>}
<label className="flex shrink-0 cursor-pointer select-none items-center gap-1.5 text-[11.5px] text-ink-3 hover:text-ink-2">
{loading && <span className="text-[11px] text-zinc-400">加载中…</span>}
<label className="flex shrink-0 cursor-pointer select-none items-center gap-1.5 text-[11.5px] text-zinc-500 hover:text-zinc-700">
<input
type="checkbox"
checked={onlyConfigured}
@@ -465,7 +481,7 @@ function ModelSettings({
onClick={() =>
setCustomForm({ id: '', name: '', baseUrl: '', apiKey: '', api: 'openai-completions', modelsText: '' })
}
className="flex shrink-0 items-center gap-1 rounded-control border border-line bg-surface px-2 py-1 text-[11.5px] text-ink-2 transition-colors hover:border-line-strong hover:bg-inset hover:text-ink"
className="flex shrink-0 items-center gap-1 rounded-control border border-zinc-200 bg-white px-2 py-1 text-[11.5px] text-zinc-600 transition-colors hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-800"
>
<LuPlus size={12} />
自定义 API
@@ -473,10 +489,10 @@ function ModelSettings({
</div>
{customForm && (
<div className="mb-3 rounded-card border border-line bg-inset/70 p-3">
<div className="mb-3 rounded-card border border-zinc-200 bg-zinc-50/70 p-3">
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">ID(唯一标识)</span>
<span className="text-[11px] text-zinc-500">ID(唯一标识)</span>
<input
value={customForm.id}
onChange={(e) => setCustomForm({ ...customForm, id: e.target.value })}
@@ -486,7 +502,7 @@ function ModelSettings({
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">显示名称(可空)</span>
<span className="text-[11px] text-zinc-500">显示名称(可空)</span>
<input
value={customForm.name}
onChange={(e) => setCustomForm({ ...customForm, name: e.target.value })}
@@ -495,7 +511,7 @@ function ModelSettings({
/>
</label>
<label className="col-span-2 flex flex-col gap-1">
<span className="text-[11px] text-ink-3">API 地址</span>
<span className="text-[11px] text-zinc-500">API 地址</span>
<input
value={customForm.baseUrl}
onChange={(e) => setCustomForm({ ...customForm, baseUrl: e.target.value })}
@@ -505,7 +521,7 @@ function ModelSettings({
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">API Key</span>
<span className="text-[11px] text-zinc-500">API Key</span>
<input
type="password"
value={customForm.apiKey}
@@ -515,7 +531,7 @@ function ModelSettings({
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">协议</span>
<span className="text-[11px] text-zinc-500">协议</span>
<select
value={customForm.api}
onChange={(e) => setCustomForm({ ...customForm, api: e.target.value })}
@@ -529,7 +545,7 @@ function ModelSettings({
</select>
</label>
<label className="col-span-2 flex flex-col gap-1">
<span className="text-[11px] text-ink-3">模型 ID(逗号或换行分隔)</span>
<span className="text-[11px] text-zinc-500">模型 ID(逗号或换行分隔)</span>
<textarea
value={customForm.modelsText}
onChange={(e) => setCustomForm({ ...customForm, modelsText: e.target.value })}
@@ -544,7 +560,7 @@ function ModelSettings({
<button
type="button"
onClick={() => setCustomForm(null)}
className="rounded-control px-2.5 py-1.5 text-[12px] text-ink-3 transition-colors hover:bg-hover"
className="rounded-control px-2.5 py-1.5 text-[12px] text-zinc-500 transition-colors hover:bg-zinc-100"
>
取消
</button>
@@ -552,7 +568,7 @@ function ModelSettings({
type="button"
disabled={busy || !customForm.id.trim() || !customForm.baseUrl.trim() || !customForm.apiKey.trim()}
onClick={submitCustom}
className="flex items-center gap-1 rounded-control bg-ink px-3 py-1.5 text-[12px] text-page transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
className="flex items-center gap-1 rounded-control bg-zinc-900 px-3 py-1.5 text-[12px] text-white transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<LuCheck size={12} />
保存
@@ -562,7 +578,7 @@ function ModelSettings({
)}
{rows.length === 0 ? (
<div className="py-10 text-center text-[12.5px] text-ink-3">
<div className="py-10 text-center text-[12.5px] text-zinc-400">
{loading ? '加载中…' : '没有匹配的模型'}
</div>
) : (
@@ -606,9 +622,8 @@ export function SettingsModal({
const [keyProvider, setKeyProvider] = useState<string | null>(null)
const [keyValue, setKeyValue] = useState('')
const [prompt, setPrompt] = useState('')
const [promptLoading, setPromptLoading] = useState(false)
const [promptDirty, setPromptDirty] = useState(false)
const [theme, setTheme] = useState('default')
const [themeMode, setThemeMode] = useState<ThemeMode>('system')
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
const [resources, setResources] = useState<ResourceDetails | null>(null)
@@ -690,25 +705,15 @@ export function SettingsModal({
void window.api
.getSettings()
.then((s) => {
setPrompt(s.systemPrompt)
setPromptDirty(false)
setTheme(s.themeColor ?? 'default')
setThemeMode(s.themeMode ?? 'system')
})
.catch(() => undefined)
setNotice(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
// 系统提示词页:进入时读取当前生效的 pi 原生提示词(只读)
useEffect(() => {
if (!open || tab !== 'prompt') return
setPromptLoading(true)
window.api
.getSystemPrompt()
.then(setPrompt)
.catch(() => setPrompt(''))
.finally(() => setPromptLoading(false))
}, [open, tab])
// 技能 / 命令页:进入时加载详情
useEffect(() => {
if (!open || (tab !== 'skills' && tab !== 'commands')) return
@@ -738,15 +743,15 @@ export function SettingsModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/25 backdrop-blur-[2px]" onClick={onClose}>
<div
className="flex h-[640px] w-[920px] max-w-[94vw] overflow-hidden rounded-card border border-line bg-surface shadow-overlay"
className="flex h-[640px] w-[920px] max-w-[94vw] overflow-hidden rounded-card border border-zinc-200 bg-white shadow-overlay"
style={{ animation: 'pop-in 200ms cubic-bezier(0.23,1,0.32,1) both' }}
onClick={(e) => e.stopPropagation()}
>
{/* 左侧:分组导航 */}
<nav className="w-[196px] shrink-0 overflow-y-auto border-r border-line bg-inset/70 p-2.5">
<nav className="w-[196px] shrink-0 overflow-y-auto border-r border-zinc-200 bg-zinc-50/70 p-2.5">
{NAV.map((group) => (
<div key={group.group} className="mb-2">
<div className="px-2 pb-1 pt-2 text-[11px] font-medium text-ink-3">{group.group}</div>
<div className="px-2 pb-1 pt-2 text-[11px] font-medium text-zinc-400">{group.group}</div>
{group.items.map(({ id, label, icon: Icon }) => (
<button
key={id}
@@ -754,11 +759,11 @@ export function SettingsModal({
onClick={() => setTab(id)}
className={`flex h-8 w-full items-center gap-2 rounded-control px-2 text-left text-[12.5px] transition-colors ${
tab === id
? 'bg-hover-2/80 font-medium text-ink'
: 'text-ink-2 hover:bg-hover hover:text-ink'
? 'bg-zinc-200/80 font-medium text-zinc-900'
: 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-800'
}`}
>
<Icon size={14} className={tab === id ? 'text-ink-2' : 'text-ink-3'} />
<Icon size={14} className={tab === id ? 'text-zinc-700' : 'text-zinc-400'} />
{label}
</button>
))}
@@ -770,13 +775,13 @@ export function SettingsModal({
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-start gap-3 px-6 pb-3 pt-5">
<div className="min-w-0 flex-1">
<div className="text-[16px] font-semibold text-ink">{meta.title}</div>
{meta.desc && <div className="mt-0.5 text-[12px] leading-5 text-ink-3">{meta.desc}</div>}
<div className="text-[16px] font-semibold text-zinc-900">{meta.title}</div>
{meta.desc && <div className="mt-0.5 text-[12px] leading-5 text-zinc-500">{meta.desc}</div>}
</div>
<button
type="button"
onClick={onClose}
className="rounded-control p-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
className="rounded-control p-1.5 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
>
<LuX size={16} />
</button>
@@ -786,29 +791,8 @@ export function SettingsModal({
{tab === 'general' && (
<div className="flex flex-col gap-6">
<div>
<div className="text-[12.5px] font-medium text-ink-2">外观</div>
<div className="mt-1 text-[11.5px] text-ink-3">深浅色模式;跟随系统时随系统设置自动切换,即时生效。</div>
<div className="mt-2.5 flex w-fit items-center rounded-control border border-line bg-hover p-0.5">
{THEME_MODES.map((m) => (
<button
key={m.id}
type="button"
onClick={() => {
setThemeMode(m.id)
void window.api.setThemeMode(m.id).catch(() => undefined)
}}
className={`h-7 rounded-[6px] px-3 text-[12px] transition-colors ${
themeMode === m.id ? 'bg-surface font-medium text-ink shadow-card' : 'text-ink-3 hover:text-ink-2'
}`}
>
{m.label}
</button>
))}
</div>
</div>
<div>
<div className="text-[12.5px] font-medium text-ink-2">主题色</div>
<div className="mt-1 text-[11.5px] text-ink-3">影响链接、选中态、焦点环等强调色,点击即时生效。</div>
<div className="text-[12.5px] font-medium text-zinc-700">主题色</div>
<div className="mt-1 text-[11.5px] text-zinc-400">影响链接、选中态、焦点环等强调色,点击即时生效。</div>
<div className="mt-3 flex flex-wrap items-center gap-3">
{THEME_PRESETS.map((p) => (
<button
@@ -822,9 +806,7 @@ export function SettingsModal({
void window.api.setThemeColor(p.id).catch(() => undefined)
}}
className={`flex size-8 items-center justify-center rounded-full transition-transform hover:scale-105 ${
theme === p.id
? 'ring-2 ring-ink ring-offset-2 ring-offset-surface'
: 'ring-1 ring-line-strong'
theme === p.id ? 'ring-2 ring-zinc-900 ring-offset-2' : 'ring-1 ring-zinc-200'
}`}
style={{ background: p.accent }}
>
@@ -838,8 +820,34 @@ export function SettingsModal({
{tab === 'prompt' && (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto whitespace-pre-wrap rounded-card border border-line bg-inset p-3.5 font-mono text-[12px] leading-6 text-ink-2">
{promptLoading ? '载入中…' : prompt || '(空)'}
<textarea
value={prompt}
onChange={(e) => {
setPrompt(e.target.value)
setPromptDirty(true)
}}
spellCheck={false}
placeholder={'例如:你是店铺运营助手,负责热点词分析、商品文案生成与上架流程编排…'}
className="min-h-0 flex-1 resize-none rounded-card border border-zinc-300 bg-white p-3.5 font-mono text-[12.5px] leading-6 text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500"
/>
<div className="mt-3 flex items-center justify-end">
<button
type="button"
disabled={busy || !promptDirty}
onClick={() => {
if (streaming) {
setNotice({ kind: 'err', text: 'Agent 正在回复中,请先停止或等待完成后再保存(保存会重建运行时)' })
return
}
run('系统提示词已保存,运行时已重建', () => window.api.setSystemPrompt(prompt), () =>
setPromptDirty(false)
)
}}
className="flex items-center gap-1.5 rounded-control bg-zinc-900 px-3.5 py-1.5 text-[12.5px] text-white transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<LuCheck size={13} />
保存并应用
</button>
</div>
</div>
)}
@@ -894,14 +902,11 @@ export function SettingsModal({
filePath: s.filePath
}))}
dirs={{
user: [resources?.dirs.globalSkills ?? '', resources?.dirs.globalAgentsSkills ?? ''],
project: [resources?.dirs.projectSkills ?? '', resources?.dirs.projectAgentsSkills ?? '']
user: resources?.dirs.globalSkills ?? '',
project: resources?.dirs.projectSkills ?? ''
}}
emptyTitle="暂无技能"
hints={{
user: ['~/.pi/agent/skills', '~/.agents/skills'],
project: ['工作区/.pi/skills', '工作区/.agents/skills']
}}
hints={{ user: '~/.pi/agent/skills', project: '工作区/.pi/skills' }}
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
onOpenDir={(path) => {
window.api.openDirectory(path).catch((err: unknown) =>
@@ -923,11 +928,11 @@ export function SettingsModal({
filePath: c.filePath
}))}
dirs={{
user: [resources?.dirs.globalCommands ?? ''],
project: [resources?.dirs.projectCommands ?? '']
user: resources?.dirs.globalCommands ?? '',
project: resources?.dirs.projectCommands ?? ''
}}
emptyTitle="暂无自定义命令"
hints={{ user: ['~/.pi/agent/prompts'], project: ['工作区/.pi/prompts'] }}
hints={{ user: '~/.pi/agent/prompts', project: '工作区/.pi/prompts' }}
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
onOpenDir={(path) => {
window.api.openDirectory(path).catch((err: unknown) =>
@@ -948,7 +953,7 @@ export function SettingsModal({
className={`border-t px-6 py-2 text-[12px] ${
notice.kind === 'ok'
? 'border-green bg-green-tint text-green'
: 'border-red/40 bg-red-tint text-red'
: 'border-red-200 bg-red-50 text-red-700'
}`}
>
{notice.text}
@@ -1,332 +0,0 @@
import { useEffect, useRef, useState, type ComponentType, type JSX, type ReactNode } from 'react'
import { LuChevronDown, LuPlus, LuX } from 'react-icons/lu'
/**
* 右侧通用标签面板(TabPanel,ZCode 式)。
* 面板承载所有标签的通用机制:标签条、˅ 标签列表下拉(逐个关闭、收起面板)、
* 标签右键菜单(关闭/关闭其他)、空态「打开标签页」宫格、左缘宽度拖拽。
* 具体标签(浏览器等)通过 tabs 传入内容,与面板机制完全解耦;
* 新增标签类型只需在宿主的标签注册表里登记,面板零改动。
*/
const PANEL_MIN_WIDTH = 380
const PANEL_MAX_WIDTH = 760
export type PanelTabIcon = ComponentType<{ size?: number; className?: string }>
/** 一个已打开的面板标签 */
export interface PanelTab {
id: string
title: string
icon: PanelTabIcon
content: ReactNode
}
/** 可打开的标签类型(空态宫格用) */
export interface PanelTabOption {
id: string
title: string
icon: PanelTabIcon
}
interface SidePanelProps {
tabs: PanelTab[]
activeId: string | null
/** 无打开标签时,空态宫格展示的可选类型 */
available: PanelTabOption[]
width: number
onWidthChange: (width: number) => void
onSelectTab: (id: string) => void
onCloseTab: (id: string) => void
onOpenTab: (id: string) => void
/** 收起整个面板(下拉菜单项,与标题栏开关等效) */
onCollapse: () => void
/** 下拉/右键菜单开合回调(宿主需暂停原生视图等,避免菜单被原生层覆盖) */
onOverlayChange?: (open: boolean) => void
}
export function SidePanel({
tabs,
activeId,
available,
width,
onWidthChange,
onSelectTab,
onCloseTab,
onOpenTab,
onCollapse,
onOverlayChange
}: SidePanelProps): JSX.Element {
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const [listOpen, setListOpen] = useState(false)
const [plusOpen, setPlusOpen] = useState(false)
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; tabId: string } | null>(null)
// 面板宽度拖拽(左边缘把手)
useEffect(() => {
const onMove = (e: PointerEvent): void => {
if (!dragRef.current) return
const delta = dragRef.current.startX - e.clientX
onWidthChange(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragRef.current.startWidth + delta)))
}
const onUp = (): void => {
dragRef.current = null
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
}, [onWidthChange])
const startResize = (e: React.PointerEvent): void => {
dragRef.current = { startX: e.clientX, startWidth: width }
}
// 菜单打开时 Esc 关闭
useEffect(() => {
if (!listOpen && !plusOpen && !ctxMenu) return
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
setListOpen(false)
setPlusOpen(false)
setCtxMenu(null)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [listOpen, plusOpen, ctxMenu])
// 覆盖层开合上报(宿主借此暂停原生视图)。卸载必须归位:面板在菜单开着时
// 被收起(˅ 列表「收起面板」与 setListOpen 同批提交,归位 effect 不再执行),
// 宿主的 suspended 会永久卡 true,下次展开面板时原生视图被隐藏成一片空白。
// cleanup 在依赖变化时也会短暂触发 false→当前值,同一提交内批处理合并不闪烁。
useEffect(() => {
onOverlayChange?.(listOpen || plusOpen || ctxMenu !== null)
return () => onOverlayChange?.(false)
}, [listOpen, plusOpen, ctxMenu, onOverlayChange])
const current = tabs.find((t) => t.id === activeId) ?? tabs[tabs.length - 1] ?? null
return (
<aside
className="relative flex h-full shrink-0 flex-col border-l border-line bg-inset"
style={{ width }}
aria-label="侧边面板"
>
{/* 左边缘拖拽把手 */}
<div
className="absolute inset-y-0 left-0 z-10 w-1 cursor-col-resize hover:bg-accent/40"
onPointerDown={startResize}
role="separator"
aria-orientation="vertical"
/>
{tabs.length > 0 && (
<div className="relative flex h-8 shrink-0 items-center gap-1 border-b border-line bg-surface px-1.5">
{/* 面板 tag 本身即下拉按钮(与网页 tab 条的 ˅ 列表按钮形态区分) */}
<div className="relative shrink-0">
<button
type="button"
aria-expanded={listOpen}
onClick={() => setListOpen((v) => !v)}
onContextMenu={(e) => {
e.preventDefault()
setCtxMenu({
x: Math.min(e.clientX, window.innerWidth - 170),
y: Math.min(e.clientY, window.innerHeight - 110),
tabId: current?.id ?? ''
})
}}
className={`flex h-7 items-center gap-1.5 rounded-md border px-2 text-[12px] transition-colors ${
listOpen
? 'border-accent/50 bg-accent-tint/40 text-accent-ink'
: 'border-line bg-surface text-ink-2 hover:border-accent/40 hover:text-ink'
}`}
>
{current && (
<>
<current.icon size={12} className="shrink-0" />
<span className="min-w-0 max-w-32 truncate">{current.title}</span>
</>
)}
<LuChevronDown
size={11}
className={`shrink-0 text-ink-3 transition-transform ${listOpen ? 'rotate-180' : ''}`}
/>
</button>
{listOpen && (
<>
<div className="fixed inset-0 z-20" onClick={() => setListOpen(false)} />
<div className="absolute left-0 top-8 z-30 w-56 rounded-lg border border-line bg-surface py-1.5 shadow-overlay">
<div className="px-3 pb-1 pt-1 text-[11px] text-ink-3">打开的tab</div>
{tabs.map((tab) => (
<div
key={tab.id}
onClick={() => {
onSelectTab(tab.id)
setListOpen(false)
}}
className="group flex cursor-default items-center gap-2 px-2 py-1.5 hover:bg-hover"
>
<tab.icon size={12} className="shrink-0 text-ink-3" />
<span className="min-w-0 flex-1 truncate text-[12px] text-ink-2">{tab.title}</span>
<button
type="button"
aria-label={`关闭${tab.title}标签`}
onClick={(e) => {
e.stopPropagation()
onCloseTab(tab.id)
}}
className="shrink-0 rounded p-0.5 text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2"
>
<LuX size={11} />
</button>
</div>
))}
<div className="my-1 border-t border-line-soft" />
<button
type="button"
onClick={() => {
setListOpen(false)
onCollapse()
}}
className="w-full px-3 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover"
>
收起面板
</button>
</div>
</>
)}
</div>
{/* 新增标签:列出注册表中的类型,已打开的置灰 */}
{available.length > 0 && (
<div className="relative shrink-0">
<button
type="button"
aria-label="打开新的标签"
title="打开新的标签"
onClick={() => setPlusOpen((v) => !v)}
className={`flex size-7 shrink-0 items-center justify-center rounded transition-colors ${
plusOpen ? 'bg-hover text-ink-2' : 'text-ink-3 hover:bg-hover hover:text-ink-2'
}`}
>
<LuPlus size={13} />
</button>
{plusOpen && (
<>
<div className="fixed inset-0 z-20" onClick={() => setPlusOpen(false)} />
<div className="absolute left-0 top-8 z-30 w-40 rounded-lg border border-line bg-surface py-1 shadow-overlay">
{available.map(({ id, title, icon: Icon }) => {
const opened = tabs.some((t) => t.id === id)
return (
<button
key={id}
type="button"
disabled={opened}
onClick={() => {
setPlusOpen(false)
onOpenTab(id)
}}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover disabled:cursor-default disabled:text-ink-3 disabled:hover:bg-transparent"
>
<Icon size={12} className="shrink-0" />
{title}
{opened && <span className="ml-auto text-[10px] text-ink-3">已打开</span>}
</button>
)
})}
</div>
</>
)}
</div>
)}
{ctxMenu && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => setCtxMenu(null)}
onContextMenu={(e) => {
e.preventDefault()
setCtxMenu(null)
}}
/>
<div
className="fixed z-40 w-40 rounded-lg border border-line bg-surface py-1 shadow-overlay"
style={{ left: ctxMenu.x, top: ctxMenu.y }}
>
<button
type="button"
onClick={() => {
onCloseTab(ctxMenu.tabId)
setCtxMenu(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover"
>
<LuX size={11} className="shrink-0" />
关闭标签页
</button>
<button
type="button"
disabled={tabs.length <= 1}
onClick={() => {
for (const t of tabs) {
if (t.id !== ctxMenu.tabId) onCloseTab(t.id)
}
setCtxMenu(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover disabled:cursor-default disabled:text-ink-3 disabled:hover:bg-transparent"
>
<LuX size={11} className="shrink-0" />
关闭其他标签页
</button>
</div>
</>
)}
</div>
)}
{tabs.length === 0 ? (
<EmptyState available={available} onOpen={onOpenTab} />
) : (
<div className="relative min-h-0 flex-1">{current?.content ?? null}</div>
)}
</aside>
)
}
/** 空态:无任何标签时的「打开标签页」宫格(随可用类型展示) */
function EmptyState({
available,
onOpen
}: {
available: PanelTabOption[]
onOpen: (id: string) => void
}): JSX.Element {
return (
<div className="flex h-full min-w-0 flex-1 flex-col items-center justify-center gap-6 px-8">
<div className="text-center">
<h2 className="text-[16px] font-medium text-ink-2">打开标签页</h2>
<p className="mt-1.5 text-[12px] text-ink-3">选择要在侧边面板中打开的标签。</p>
</div>
<div className="grid grid-cols-2 gap-3">
{available.map(({ id, title, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => onOpen(id)}
className="flex h-[76px] w-[112px] flex-col items-center justify-center gap-2 rounded-xl border border-line bg-surface text-ink-3 transition-colors hover:border-accent/40 hover:bg-accent-tint/50 hover:text-accent-ink"
>
<Icon size={18} />
<span className="text-[12px]">{title}</span>
</button>
))}
</div>
</div>
)
}
@@ -3,7 +3,6 @@ import { createPortal } from 'react-dom'
import {
LuCheck,
LuChevronDown,
LuMessagesSquare,
LuPencil,
LuPlus,
LuSearch,
@@ -15,8 +14,7 @@ import {
import { LuPanelLeft } from 'react-icons/lu'
import { Button } from '@/components/atoms/Button'
import { GlideMenu } from '@/components/GlideMenu'
import { PluginIcon } from '@/plugins/PluginPageFrame'
import type { PluginInfo, SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
import type { SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
/** 可折叠工作区导航(beautifului.dev Sidebar Nav)。
* 224px ↔ 56px 宽度过渡,文案随折叠淡出;导航行与会话行共用一个
@@ -80,87 +78,6 @@ function RowActionPortal({
)
}
/** 折叠态会话切换浮层:锚定 rail 图标向右弹出,类浏览器标签切换(仅切换,不管理)。
* portal 到 body(aside overflow-hidden 会裁剪内联浮层);点外部 / Esc / 选中即关。 */
function SessionSwitcherPortal({
anchor,
sessions,
activeSessionId,
onOpenSession,
onClose
}: {
anchor: { top: number; right: number }
sessions: SessionMeta[]
activeSessionId?: string
onOpenSession: (path: string) => void
onClose: () => void
}): ReactNode {
const closeRef = useRef(onClose)
closeRef.current = onClose
useEffect(() => {
const close = (event: PointerEvent): void => {
const target = event.target as Element
if (target.closest('[data-session-flyout]') || target.closest('[data-session-toggle]')) return
closeRef.current()
}
const esc = (event: KeyboardEvent): void => {
if (event.key === 'Escape') closeRef.current()
}
document.addEventListener('pointerdown', close)
document.addEventListener('keydown', esc)
return () => {
document.removeEventListener('pointerdown', close)
document.removeEventListener('keydown', esc)
}
}, [])
const top = Math.max(8, Math.min(anchor.top, window.innerHeight - 220))
const listMaxHeight = Math.max(160, window.innerHeight - top - 52)
return createPortal(
<div
data-session-flyout
className="fixed z-50 w-64 rounded-[14px] bg-surface p-1.5 shadow-overlay"
style={{
left: anchor.right + 12,
top,
animation: 'pop-in 180ms cubic-bezier(0.23,1,0.32,1) both',
transformOrigin: 'top left'
}}
>
<div className="px-2 pb-1 pt-1 text-[11px] font-medium text-ink-3">会话</div>
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
{sessions.length === 0 ? (
<div className="px-3 py-6 text-center text-[12px] text-ink-3">暂无历史会话</div>
) : (
sessions.map((s) => {
const active = s.id === activeSessionId
return (
<button
key={s.id}
type="button"
title={sessionTitle(s)}
onClick={() => {
onOpenSession(s.path)
onClose()
}}
className={`flex h-8 w-full items-center gap-2 rounded-[7px] px-2 text-left transition-colors ${
active ? 'bg-hover-2 text-ink' : 'text-ink-2 hover:bg-hover'
}`}
>
<span className={`min-w-0 flex-1 truncate text-[13px] ${active ? 'font-medium' : 'font-normal'}`}>
{sessionTitle(s)}
</span>
{active && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
</button>
)
})
)}
</div>
</div>,
document.body
)
}
export function SidebarNav({
sessionState,
sessions,
@@ -172,11 +89,7 @@ export function SidebarNav({
onOpenSettings,
workspaces,
activeWorkspace,
onOpenWorkspace,
plugins,
activeView,
onOpenPlugin,
chatBadge
onOpenWorkspace
}: {
sessionState: SessionState | null
sessions: SessionMeta[]
@@ -189,13 +102,6 @@ export function SidebarNav({
workspaces: WorkspaceMeta[]
activeWorkspace?: WorkspaceMeta
onOpenWorkspace: (path: string) => void
/** 侧栏插件入口(已排序:order 升序,核心入口之后) */
plugins: PluginInfo[]
/** 当前视图('chat' 或插件 id;插件入口高亮用) */
activeView: string
onOpenPlugin: (id: string) => void
/** 代理活动徽标(插件视图期间聊天列隐藏,会话区头提示 待审批>出错>流式) */
chatBadge?: { approvals: number; error: boolean; streaming: boolean }
}): ReactNode {
const [collapsed, setCollapsed] = useState(() => {
try {
@@ -216,8 +122,6 @@ export function SidebarNav({
const [renameValue, setRenameValue] = useState('')
const [wsMenuOpen, setWsMenuOpen] = useState(false)
const [expandedWs, setExpandedWs] = useState<Set<string>>(new Set())
/** 折叠态会话切换浮层:锚点(rail 图标顶缘/右缘),null = 关闭 */
const [sessionSwitcher, setSessionSwitcher] = useState<{ top: number; right: number } | null>(null)
const renameInputRef = useRef<HTMLInputElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -235,7 +139,6 @@ export function SidebarNav({
useEffect(() => {
if (collapsed) setWsMenuOpen(false)
else setSessionSwitcher(null)
}, [collapsed])
useEffect(() => {
@@ -419,7 +322,7 @@ export function SidebarNav({
</>
)}
{/* 主导航(glide hover):核心入口 + 插件入口 */}
{/* 主导航(glide hover) */}
<GlideMenu className="gap-px" highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
<button type="button" data-row onClick={onNewSession} className={navRowClass}>
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2">
@@ -433,129 +336,72 @@ export function SidebarNav({
</span>
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">搜索</span>
</button>
{plugins.map((plugin) => {
const active = activeView === plugin.manifest.id
return (
<button
type="button"
key={plugin.manifest.id}
data-row
title={plugin.manifest.description || plugin.manifest.name}
onClick={() => onOpenPlugin(plugin.manifest.id)}
className={`${navRowClass} ${active ? 'bg-hover-2 text-ink' : ''}`}
>
<span className="flex size-5 shrink-0 items-center justify-center">
<PluginIcon svg={plugin.manifest.icon} size={18} />
</span>
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">
{plugin.manifest.name}
</span>
</button>
)
})}
</GlideMenu>
{/* 会话列表区 */}
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
<div className="relative mx-2 mb-1 h-8">
<div className="sidebar-copy absolute inset-0">
<button
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>
{/* 插件视图期间聊天列隐藏:会话区头徽标提示代理活动(待审批>出错>流式) */}
{chatBadge && activeView !== 'chat' && (chatBadge.approvals > 0 || chatBadge.error || chatBadge.streaming) && (
<span
title={
chatBadge.approvals > 0
? `${chatBadge.approvals} 个待审批`
: chatBadge.error
? 'Agent 出错/重试'
: 'Agent 回复中'
}
className={`ml-0.5 inline-block size-[7px] shrink-0 rounded-full ${
chatBadge.approvals > 0
? 'animate-pulse bg-orange'
: chatBadge.error
? 'bg-red'
: 'bg-accent'
}`}
/>
)}
</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>
{/* 折叠态:会话切换入口(类浏览器标签切换,仅切换)。
收窄到 rail 可见的 36px(与 nav 图标钮同宽),否则图标会被 aside 裁剪 */}
<div className="sidebar-copy relative mx-2 mb-1 h-8">
<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]"
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"
>
<LuMessagesSquare size={18} strokeWidth={2} />
<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
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>
{chatsOpen && !collapsed && (
{chatsOpen && (
<GlideMenu highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
{filtered.length === 0 ? (
<div className="sidebar-copy mx-2 mb-1 rounded-[8px] border border-dashed border-line px-3 py-4 text-center text-[12px] leading-5 text-ink-3">
@@ -570,23 +416,12 @@ export function SidebarNav({
data-row
title={sessionTitle(s)}
onClick={() => onOpenSession(s.path)}
className={`${navRowClass} group/row cursor-pointer ${
active ? 'bg-hover-2 text-ink' : 'text-ink-2'
}`}
className={`${navRowClass} group/row cursor-pointer ${active ? 'text-ink' : 'text-ink-2'}`}
>
{/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px。
激活行除灰底外再用字重区分(激活加粗 / 非激活常规) */}
<span
className={`sidebar-copy min-w-0 flex-1 truncate text-[14px] ${
active ? 'font-medium' : 'font-normal'
}`}
>
{sessionTitle(s)}
</span>
{/* 悬停操作:quiet 图标钮,无底板。行内流式布局(非绝对定位):
悬停时标题自动收窄让位,按钮不越出行、不遮相邻行的文字;
收起侧栏时随 sidebar-copy 一并淡出并禁用点击 */}
<span className="sidebar-copy hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
{/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px */}
<span className="sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium">{sessionTitle(s)}</span>
{/* 悬停浮动操作:quiet 图标钮,无底板,贴近官方 primitive-icon-button */}
<span className="absolute right-0.5 hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
{active && (
<button
type="button"
@@ -628,17 +463,6 @@ export function SidebarNav({
)}
</div>
{/* 折叠态会话切换浮层(portal 到 body) */}
{sessionSwitcher && (
<SessionSwitcherPortal
anchor={sessionSwitcher}
sessions={sessions}
activeSessionId={activeSessionId}
onOpenSession={onOpenSession}
onClose={() => setSessionSwitcher(null)}
/>
)}
{/* 行内操作浮层:锚定触发按钮,官方 WorkspaceMenu 形态(pop-in + overlay 阴影) */}
{actionMenu && (
<RowActionPortal rect={actionMenu.rect} onClose={() => setActionMenu(null)}>
@@ -1,25 +1,99 @@
import type { ReactNode } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
/** 流式回答容器(beautifului.dev Streaming Text)。
* 正文随 delta 增长;光标由 CSS(globals.css 的 .streaming-body 规则)以 ::after
* 附加到最后一个文本块最后一行文字之后,流式结束后随 data-streaming 属性切换消失,
* 不再以独立 span 追加在块级内容之后(那会让光标孤零零落在新的一行上)。
* 复制操作由 AssistantTurn 统一常驻在结论底部——本组件不再自带操作行:
* 以前每个正文段各挂一个、随回合内 streaming 抖动反复出现/消失(闪现)。 */
/** 流式回答(beautifului.dev Streaming Text)。
* 正文随 delta 增长并带竖线光标;流式结束后光标消失、
* 操作行淡入(复制);followUps 由调用方提供时逐条列出可点。
* 站点演示的 refresh/点赞等操作在本应用无后端,未接入。 */
export function StreamingText({
children,
streaming
streaming,
text,
followUps,
onFollowUp
}: {
/** 正文内容(通常为 <Markdown/>) */
children?: ReactNode
streaming: boolean
/** 已渲染的纯文本,用于复制;为空时不显示操作行 */
text?: string
followUps?: string[]
onFollowUp?: (text: string) => void
}): ReactNode {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) return
const t = setTimeout(() => setCopied(false), 1600)
return () => clearTimeout(t)
}, [copied])
const copy = (): void => {
void navigator.clipboard.writeText(text ?? '').then(() => setCopied(true))
}
const done = !streaming && Boolean(text)
return (
<div
className="streaming-body min-w-0 text-base leading-normal text-ink"
data-streaming={streaming ? 'true' : 'false'}
>
{children}
<div className="min-w-0">
<div className="text-base leading-normal text-ink">
{children}
<span
aria-hidden={!streaming}
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink align-baseline"
style={{
animation: 'fade-in 150ms ease-out both',
opacity: streaming ? undefined : 0,
transition: 'opacity 400ms ease'
}}
/>
</div>
{/* 操作行:流式结束后淡入 */}
<div
className="mt-2 flex items-center gap-0.5 transition-opacity duration-[400ms]"
style={{ opacity: done ? 1 : 0, pointerEvents: done ? undefined : 'none' }}
>
<button
type="button"
aria-label={copied ? '已复制' : '复制回答'}
onClick={copy}
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"
>
{copied ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="12" height="12" rx="2.5" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
</div>
{/* Follow-ups:完成后的跟进问题列表 */}
{done && followUps && followUps.length > 0 && (
<div className="mt-2.5" style={{ animation: 'fade-in 400ms ease both' }}>
<p className="text-[12px] font-medium text-ink-2">继续问</p>
<div className="mt-0.5 flex flex-col">
{followUps.map((q) => (
<button
key={q}
type="button"
onClick={() => onFollowUp?.(q)}
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors duration-100 hover:bg-hover-2"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M9 10l-5 5 5 5" />
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
</svg>
{q}
</button>
))}
</div>
</div>
)}
</div>
)
}
@@ -1,20 +1,13 @@
import { useState } from 'react'
import { useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
/** 可展开的思考轨迹。
* 默认收起:表头展示「状态词 + 开头第一行内容」式预览,点击展开完整轨迹,
* 再点收起;流式中表头标签保持 shimmer。
* 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填):
* 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时
* 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明
* 再逐个浮现),视觉上等于整段思考收起又重新展开,每个工具轮都会重演;
* 历史轨迹默认随过程区收起,看不到行入场,交错动画没有保留价值。
* 不限高:过程区在回合结束后整体折叠,内部限高只会在轮次交接时造成
* 面板可见缩短。rows 由真实思考文本逐行喂入。 */
/** 可展开的思考轨迹(beautifului.dev Thinking · Reasoning 形态)。
* 流式期间自动展开并 shimmer「思考中…」;结束后收起为「思考过程」,
* 点击表头可手动展开/收起。rows 由真实思考文本逐行喂入。 */
export function Thinking({
rows,
streaming,
activeLabel = '正在思考',
activeLabel = '思考中…',
doneLabel = '思考过程'
}: {
rows: { primary: string }[]
@@ -22,22 +15,29 @@ export function Thinking({
activeLabel?: string
doneLabel?: string
}): ReactNode {
// 默认收起(不想默认展开思考内容);用户点击后以手动状态为准
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
const expanded = manualExpanded ?? false
const expanded = manualExpanded ?? streaming
const firstLine = rows.length > 0 ? rows[0].primary : ''
const statusWord = streaming ? activeLabel : doneLabel
const suffix = expanded ? '' : firstLine
const traceRef = useRef<HTMLDivElement>(null)
const [lineHeight, setLineHeight] = useState(0)
useLayoutEffect(() => {
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight)
}, [rows, expanded])
return (
<div className="flex w-full flex-col">
{/* 表头:状态词 +(收起时)首行预览 + 旋转箭头 */}
<div
className="flex w-full max-w-95 flex-col"
style={{
minHeight: expanded ? Math.min(60 + rows.length * 28, 220) : undefined,
transition: 'min-height 400ms ease'
}}
>
{/* 表头:状态词 + 旋转箭头 */}
<button
type="button"
aria-expanded={expanded}
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"
onClick={() => setManualExpanded((current) => !(current ?? streaming))}
className="-mx-1.5 flex w-fit 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)'}>
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
@@ -45,26 +45,23 @@ export function Thinking({
<span role="status" className="contents">
{streaming ? (
<span
className="bg-clip-text whitespace-nowrap text-[13px] font-medium text-transparent"
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
style={{
backgroundImage: 'linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)',
backgroundSize: '200% 100%',
animation: 'shimmer-text 1.4s linear infinite'
}}
>
{statusWord}
{activeLabel}
</span>
) : (
<span
className="whitespace-nowrap text-[13px] font-medium text-ink-2"
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
style={{ animation: 'fade-in 350ms ease-out both' }}
>
{statusWord}
{doneLabel}
</span>
)}
{suffix && (
<span className="min-w-0 truncate text-[12.5px] text-ink-3">· {suffix}</span>
)}
</span>
<svg
width="14"
@@ -75,14 +72,14 @@ export function Thinking({
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 transition-transform"
className="transition-transform"
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0)' }}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{/* 展开轨迹:不限高完整展示全部思考(收起由外层过程区承担) */}
{/* 展开轨迹:左侧 hairline + 逐行 fade-up */}
<div
className="grid transition-[grid-template-rows,opacity]"
style={{
@@ -91,23 +88,23 @@ export function Thinking({
transitionTimingFunction: 'ease'
}}
>
<div className="min-h-0 overflow-hidden">
<div className="mt-1 ml-[5px] pl-4">
<div className="relative">
<span aria-hidden className="absolute bottom-0 left-[3px] top-0 w-px bg-line" />
<div className="flex flex-col gap-1 py-1">
{rows.map((row, i) => (
<div
key={i}
className="flex min-h-7 w-full items-start gap-2 rounded-control px-1.5 py-0.5 text-left"
style={{ animation: 'fade-up 250ms ease' }}
>
<span className="min-w-0 text-[12.5px] leading-relaxed break-words text-ink-2">
{row.primary}
</span>
</div>
))}
</div>
<div className="overflow-hidden">
<div className="relative mt-1 ml-[5px] pl-4">
<span
aria-hidden
className="absolute left-[3px] w-px bg-line"
style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: 'height 400ms ease' }}
/>
<div ref={traceRef} className="flex flex-col gap-1 py-1">
{rows.map((row, i) => (
<div
key={`${i}-${row.primary.slice(0, 12)}`}
className="flex min-h-7 w-full items-start gap-2 rounded-control px-1.5 py-0.5 text-left"
style={{ animation: `fade-up 400ms ease ${Math.min(i, 8) * 120}ms both` }}
>
<span className="min-w-0 text-[12.5px] leading-relaxed break-words text-ink-2">{row.primary}</span>
</div>
))}
</div>
</div>
</div>
@@ -116,11 +113,11 @@ export function Thinking({
)
}
/** 思考文本 → 行数组(空行剔除,内容不截断;过长由容器的滚动条承担) */
/** 思考文本 → 行数组(空行剔除,超长行截断) */
export function thinkingRows(text: string): { primary: string }[] {
return text
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((line) => ({ primary: line }))
.map((line) => ({ primary: line.length > 300 ? `${line.slice(0, 300)}…` : line }))
}
@@ -1,52 +1,26 @@
import { useEffect, useState, type JSX, type ReactNode } from 'react'
import { LuPanelRight } from 'react-icons/lu'
import { useEffect, useState, type JSX } from 'react'
/**
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
* 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」);
* title 之后可挂 extra 槽(插件状态胶囊);右侧自绘窗口控制按钮(最小化/
* 最大化还原/关闭),高度 40px。不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
* 整条是拖拽区;左上角展示当前会话名称(无名称时显示「新会话」);
* 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
* 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
*/
export function TitleBar({
title,
panelOpen,
onTogglePanel,
extra
}: {
title?: string
panelOpen: boolean
onTogglePanel: () => void
/** 标题右侧插槽(插件状态胶囊等;聊天/插件视图下均可见) */
extra?: ReactNode
}): JSX.Element {
export function TitleBar({ title }: { title?: string }): JSX.Element {
const name = title?.trim()
const [maximized, setMaximized] = useState(false)
useEffect(() => window.api.onWindowState((maximized) => setMaximized(maximized)), [])
return (
<div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-line bg-surface pl-4 [-webkit-app-region:drag]">
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div>
{extra}
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
<button
type="button"
aria-label={panelOpen ? '关闭侧边面板' : '打开侧边面板'}
title="侧边面板"
onClick={onTogglePanel}
className={`flex h-7 w-[30px] items-center justify-center rounded transition-colors ${
panelOpen ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:bg-hover hover:text-ink-2'
}`}
>
<LuPanelRight size={14} />
</button>
</div>
<div className="flex h-full shrink-0 [-webkit-app-region:no-drag]">
<div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-zinc-200 bg-white pl-4 [-webkit-app-region:drag]">
<div className="min-w-0 truncate text-[12.5px] font-medium text-zinc-800">{name || '新会话'}</div>
<div className="ml-auto flex h-full shrink-0 [-webkit-app-region:no-drag]">
<button
type="button"
aria-label="最小化"
onClick={() => void window.api.minimize()}
className="flex h-full w-[46px] items-center justify-center text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
className="flex h-full w-[46px] items-center justify-center text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M0 5h10" stroke="currentColor" strokeWidth="1" />
@@ -56,7 +30,7 @@ export function TitleBar({
type="button"
aria-label={maximized ? '还原' : '最大化'}
onClick={() => void window.api.toggleMaximize()}
className="flex h-full w-[46px] items-center justify-center text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
className="flex h-full w-[46px] items-center justify-center text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
>
{maximized ? (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
@@ -72,7 +46,7 @@ export function TitleBar({
type="button"
aria-label="关闭"
onClick={() => void window.api.close()}
className="flex h-full w-[46px] items-center justify-center text-ink-3 transition-colors hover:bg-[#e81123] hover:text-page"
className="flex h-full w-[46px] items-center justify-center text-zinc-500 transition-colors hover:bg-[#e81123] hover:text-white"
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M0 0l10 10M10 0L0 10" stroke="currentColor" strokeWidth="1" />
@@ -198,13 +198,9 @@ function buildRow(view: ToolCallView, dupIndex: number): ToolRow {
)
const resultText = extractResultText(tool.result)
if (resultText) {
// 完整输出,不逐行截断;过长由展开区的滚动条承担,仅在行数超限时兜底
const lines = resultText.split('\n')
const maxLines = 400
for (const line of lines.slice(0, maxLines)) detail.push({ text: line, tone: 'plain' })
if (lines.length > maxLines) {
detail.push({ text: `…(共 ${lines.length} 行,已截断)`, tone: 'plain' })
}
const lines = resultText.split('\n').slice(0, 10)
for (const line of lines) detail.push({ text: line.slice(0, 160), tone: 'plain' })
if (resultText.split('\n').length > 10) detail.push({ text: '…(结果已截断)', tone: 'plain' })
}
}
const args = tool?.args ?? view.pendingApproval?.args
@@ -241,7 +237,7 @@ function buildThinkingRows(texts: string[]): ToolRow[] {
file: '',
dir: '',
chip: lines[0].length > 60 ? `${lines[0].slice(0, 60)}…` : lines[0],
detail: lines.map((line) => ({ text: line, tone: 'plain' as const })),
detail: lines.slice(0, 12).map((line) => ({ text: line.slice(0, 160), tone: 'plain' as const })),
tool: undefined,
pending: false
})
@@ -340,24 +336,22 @@ export function ToolChips({ calls = [], thinking = [], className = '' }: {
{row.icon !== 'think' && <StatusMark tool={row.tool} pending={row.pending} />}
</button>
{/* 展开明细:完整内容不截断,过长时在滚动区里查看;收起时懒渲染不占 DOM */}
{/* 展开明细 */}
<div
className="grid transition-[grid-template-rows,opacity]"
style={{ gridTemplateRows: rowOpen ? '1fr' : '0fr', opacity: rowOpen ? 1 : 0, transitionTimingFunction: 'ease' }}
>
<div className="min-h-0 overflow-hidden">
{rowOpen && (
<div className="mt-0.5 mb-1 ml-2 flex max-h-[320px] flex-col gap-0.5 overflow-y-auto border-l border-line py-0.5 pl-3.5">
{row.detail.map((line, lineIndex) => (
<span
key={`${lineIndex}-${line.text.slice(0, 12)}`}
className={`whitespace-pre-wrap break-words text-[11.5px] leading-[1.6] ${row.icon === 'think' ? '' : 'font-mono'} ${DETONE_CLASS[line.tone ?? 'plain']}`}
>
{line.text}
</span>
))}
</div>
)}
<div className="mt-0.5 mb-1 ml-2 flex flex-col gap-0.5 border-l border-line py-0.5 pl-3.5">
{row.detail.map((line, lineIndex) => (
<span
key={`${lineIndex}-${line.text.slice(0, 12)}`}
className={`truncate text-[11.5px] leading-[1.6] ${row.icon === 'think' ? '' : 'font-mono'} ${DETONE_CLASS[line.tone ?? 'plain']}`}
>
{line.text}
</span>
))}
</div>
</div>
</div>
</div>
@@ -1,177 +0,0 @@
import { createPortal } from 'react-dom'
import type { JSX } from 'react'
import { LuLoaderCircle, LuX } from 'react-icons/lu'
import type { ContextBreakdownEntry, SessionStatsInfo } from '@shared/types'
import { formatPercent, formatTokens } from '../lib/utils'
/**
* Usage Card(逐项复刻 ZCode 上下文容量卡):
* - 上下文容量:估算 token / 窗口(百分比)+ 单色进度条
* - 类目清单:消息 / 自定义工具 / 系统工具 / 系统提示词 / 技能 / 其他,右对齐占比
* - 平均缓存命中率:单行 label + 百分比
* - 底部:自动压缩开关在左,立即压缩在右
*/
/** ZCode 卡片配色:条与圆点为蓝色系 */
const BLUE = '#4e7ef3'
const BAR_TRACK = 'color-mix(in srgb, #4e7ef3 14%, transparent)'
/** 每个类目占比 = 该类 token / 上下文中估算总量(各行相加 ≈ 100%,与 ZCode 一致) */
function shareOf(entry: ContextBreakdownEntry, total: number): number {
return total > 0 ? (entry.tokens / total) * 100 : 0
}
export function UsageCard({
anchor,
stats,
compacting,
autoCompactionEnabled,
onClose,
onCompact,
onAbortCompaction,
onToggleAutoCompaction
}: {
/** Composer 工具行锚点(bottom/left/right,向上弹出) */
anchor: { top: number; left: number; right: number }
stats: SessionStatsInfo | null
compacting: boolean
autoCompactionEnabled: boolean
onClose: () => void
onCompact: () => void
onAbortCompaction: () => void
onToggleAutoCompaction: (enabled: boolean) => void
}): JSX.Element {
const context = stats?.context
const total = context?.tokens ?? null
const window_ = context?.contextWindow ?? 0
const percent = context?.percent ?? null
const breakdown = context?.breakdown ?? []
const estimatedTotal = breakdown.reduce((sum, b) => sum + b.tokens, 0)
const cacheRate = stats?.cacheHitRate
const cacheTokens = stats?.cacheTokens
return createPortal(
<>
<div className="fixed inset-0 z-30" onClick={onClose} />
<div
className="fixed z-40 w-[400px] overflow-hidden rounded-card border border-line bg-surface shadow-overlay"
style={{
bottom: window.innerHeight - anchor.top + 8,
left: Math.max(8, anchor.left)
}}
>
{/* 上下文容量 + 单色进度条 */}
<div className="px-5 pt-4">
<div className="flex items-baseline justify-between">
<span className="text-[13px] font-medium text-ink">上下文容量</span>
<span className="text-[12.5px] tabular-nums text-ink-2">
{total !== null ? formatTokens(total) : '—'}
{window_ > 0 ? ` / ${formatTokens(window_)}` : ''}
{percent !== null ? `(${formatPercent(percent)})` : ''}
</span>
</div>
<div className="mt-2.5 h-[5px] w-full overflow-hidden rounded-full" style={{ background: BAR_TRACK }}>
{percent !== null && percent > 0 && (
<div
className="h-full rounded-full transition-[width] duration-300"
style={{ width: `${Math.min(100, percent)}%`, background: BLUE }}
/>
)}
</div>
</div>
{/* 类目清单:圆点 + 标签 + 右对齐占比(悬浮显示估算 token) */}
<div className="flex flex-col gap-[7px] px-5 pb-4 pt-3.5">
{estimatedTotal > 0 ? (
breakdown.map((b) => (
<div
key={b.key}
title={`${b.label} ≈ ${formatTokens(b.tokens)} tokens`}
className="flex min-w-0 items-center gap-2.5 text-[12.5px]"
>
<span className="size-[7px] shrink-0 rounded-full" style={{ background: BLUE }} />
<span className="shrink-0 text-ink-2">{b.label}</span>
<span className="min-w-0 flex-1 text-right tabular-nums text-ink-2">
{formatPercent(shareOf(b, estimatedTotal))}
</span>
</div>
))
) : (
<div className="py-0.5 text-[12.5px] text-ink-3">发送第一条消息后显示用量分布</div>
)}
</div>
{/* 平均缓存命中率 */}
<div className="border-t border-line px-5 py-3">
<div
className="flex items-baseline justify-between"
title={
cacheTokens
? `命中 ${formatTokens(cacheTokens.read)} · 写入 ${formatTokens(cacheTokens.write)} · 未命中 ${formatTokens(cacheTokens.input)}`
: undefined
}
>
<span className="text-[12.5px] text-ink-2">平均缓存命中率</span>
<span className="text-[12.5px] font-medium tabular-nums text-ink">
{cacheRate !== undefined ? formatPercent(cacheRate) : '—'}
</span>
</div>
</div>
{/* 底部:自动压缩开关(左) · 立即压缩(右) */}
<div className="flex items-center justify-between border-t border-line px-5 py-3">
<button
type="button"
role="switch"
aria-checked={autoCompactionEnabled}
onClick={() => onToggleAutoCompaction(!autoCompactionEnabled)}
title="接近窗口上限时自动压缩历史"
className="flex items-center gap-2 text-[12px] text-ink-2 transition-colors hover:text-ink"
>
<span
className={`relative h-4 w-7 rounded-full transition-colors duration-150 ${
autoCompactionEnabled ? 'bg-accent-ink' : 'bg-line-strong'
}`}
>
<span
className={`absolute top-0.5 size-3 rounded-full bg-surface shadow-sm transition-all duration-150 ${
autoCompactionEnabled ? 'left-3.5' : 'left-0.5'
}`}
/>
</span>
自动压缩
</button>
{compacting ? (
<div className="flex items-center gap-2">
<span className="flex items-center gap-1 text-[12px] text-ink-2">
<LuLoaderCircle size={12} className="animate-spin" />
压缩中
</span>
<button
type="button"
onClick={onAbortCompaction}
className="flex h-7 items-center gap-1 rounded-[7px] border border-line bg-surface px-2.5 text-[12px] text-ink-2 transition-colors hover:bg-hover hover:text-ink"
>
<LuX size={12} />
停止
</button>
</div>
) : (
<button
type="button"
onClick={onCompact}
disabled={total === null || total === 0}
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"
>
立即压缩
</button>
)}
</div>
</div>
</>,
document.body
)
}
@@ -1,561 +0,0 @@
import { useEffect, useMemo, useRef, useState, type JSX } from 'react'
import {
LuArrowLeft,
LuArrowRight,
LuChevronDown,
LuCircleAlert,
LuDownload,
LuGlobe,
LuPlus,
LuRotateCw,
LuX
} from 'react-icons/lu'
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types'
import { BrowserSlot } from './BrowserSlot'
/**
* 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/导入浮层)。
* 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView
* (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。
* 任何全屏浮层(设置/搜索/导入)出现时必须卸载 Slot 隐藏原生视图。
*/
interface BrowserPanelProps {
state: BrowserUiState | null
/** 全屏浮层(设置/搜索)打开时暂停原生视图,避免其盖住浮层 */
suspended: boolean
onNavigate: (input: string) => void
onGoBack: () => void
onGoForward: () => void
onReload: () => void
/** 新建页签;传 url 时直接打开该地址(「最近关闭的标签页」恢复) */
onNewTab: (url?: string) => void
onSelectTab: (tabId: string) => void
onCloseTab: (tabId: string) => void
/** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */
onCloseAllTabs: () => void
onAcknowledgeRisk: () => void
onDetectSources: () => Promise<BrowserSource[]>
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
}
export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
const { state, suspended } = props
const [address, setAddress] = useState('')
const [importOpen, setImportOpen] = useState(false)
const [tabListOpen, setTabListOpen] = useState(false)
const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null)
const activeTab = useMemo(
() => state?.tabs.find((t) => t.tabId === state.activeTabId) ?? null,
[state]
)
// 地址栏跟随激活页 URL(用户正在输入时不打扰)
const addressRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (document.activeElement !== addressRef.current) {
setAddress(activeTab?.url ?? '')
}
}, [activeTab?.url])
// 菜单打开时 Esc 关闭
useEffect(() => {
if (!tabListOpen && !tabMenu) return
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
setTabListOpen(false)
setTabMenu(null)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [tabListOpen, tabMenu])
const submitAddress = (): void => {
const value = address.trim()
if (value) props.onNavigate(value)
addressRef.current?.blur()
}
const riskBanner = state && !state.riskAcknowledged
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上)
const overlayOpen = importOpen || tabListOpen || tabMenu !== null
// 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单)
const hideCause = suspended || overlayOpen
// 冻结帧:让位前先截当前页面画面,隐藏期间以快照占位——视觉上等于菜单浮在
// 页面之上。直接白屏占位会让整页「消失」,看起来像浏览器故障(此前报告的
// 「展开标签列表时页面区域空白」即由此而来)。顺序:先截帧(视图仍可见),
// 下一拍再隐藏;恢复时先挂回视图、快照被盖住后再清,全程不闪白。
const [frozen, setFrozen] = useState<string | null>(null)
const [slotHidden, setSlotHidden] = useState(false)
useEffect(() => {
const tabId = activeTab?.tabId
if (!tabId) {
setSlotHidden(false)
setFrozen(null)
return
}
let alive = true
if (hideCause && !slotHidden) {
void window.api
.browserCaptureTab(tabId)
.then((dataUrl) => {
if (alive && dataUrl) setFrozen(dataUrl)
})
.catch(() => undefined)
.finally(() => {
if (alive) setSlotHidden(true)
})
return () => {
alive = false
}
}
if (!hideCause && slotHidden) {
setSlotHidden(false)
const timer = window.setTimeout(() => {
if (alive) setFrozen(null)
}, 200)
return () => {
alive = false
clearTimeout(timer)
}
}
return () => {
alive = false
}
}, [hideCause, slotHidden, activeTab?.tabId])
return (
<div className="flex h-full min-w-0 flex-1 flex-col bg-inset" aria-label="内置浏览器">
{/* 页签栏:˅ 列表(打开/最近关闭)+ 页签(右键菜单)+ 新建 */}
<div className="flex h-9 shrink-0 items-center gap-1 border-b border-line bg-surface px-1.5">
<div className="relative shrink-0">
<button
type="button"
aria-label="标签页列表"
aria-expanded={tabListOpen}
title="标签页列表"
onClick={() => setTabListOpen((v) => !v)}
className={`flex size-7 items-center justify-center rounded transition-colors ${
tabListOpen ? 'bg-hover text-ink-2' : 'text-ink-3 hover:bg-hover hover:text-ink-2'
}`}
>
<LuChevronDown size={13} />
</button>
{tabListOpen && (
<>
<div className="fixed inset-0 z-20" onClick={() => setTabListOpen(false)} />
<div className="absolute left-0 top-9 z-30 max-h-[320px] w-60 overflow-y-auto rounded-lg border border-line bg-surface py-1.5 shadow-overlay">
<div className="px-3 pb-1 pt-1 text-[11px] text-ink-3">打开的标签页</div>
{(state?.tabs ?? []).length === 0 && (
<div className="px-3 py-1 text-[11.5px] text-ink-3">无打开的标签页</div>
)}
{(state?.tabs ?? []).map((tab) => (
<div
key={tab.tabId}
onClick={() => props.onSelectTab(tab.tabId)}
className="group flex cursor-default items-center gap-2 px-2 py-1.5 hover:bg-hover"
>
{tab.loading ? (
<LuRotateCw size={12} className="shrink-0 animate-spin text-ink-3" />
) : (
<LuGlobe size={12} className="shrink-0 text-ink-3" />
)}
<span className="min-w-0 flex-1 truncate text-[12px] text-ink-2">
{tab.title || tab.url || '新标签页'}
</span>
<button
type="button"
aria-label="关闭标签页"
onClick={(e) => {
e.stopPropagation()
props.onCloseTab(tab.tabId)
}}
className="shrink-0 rounded p-0.5 text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2"
>
<LuX size={11} />
</button>
</div>
))}
{(state?.tabs ?? []).length > 0 && (
<button
type="button"
onClick={() => {
props.onCloseAllTabs()
setTabListOpen(false)
}}
className="mt-0.5 flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-[12px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<LuX size={11} className="shrink-0" />
关闭全部标签页
</button>
)}
{(state?.recentClosed?.length ?? 0) > 0 && (
<>
<div className="my-1 border-t border-line-soft" />
<div className="px-3 pb-1 text-[11px] text-ink-3">最近关闭的标签页</div>
{state?.recentClosed.map((item, i) => (
<button
key={`${i}-${item.url}`}
type="button"
onClick={() => {
setTabListOpen(false)
void Promise.resolve(props.onNewTab(item.url)).catch(() => undefined)
}}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-hover"
>
<LuGlobe size={12} className="shrink-0 text-ink-3" />
<span className="min-w-0 flex-1 truncate text-[12px] text-ink-2">{item.title}</span>
</button>
))}
</>
)}
</div>
</>
)}
</div>
<div className="flex h-full min-w-0 flex-1 items-center gap-1 overflow-x-auto">
{(state?.tabs ?? []).map((tab) => (
<button
key={tab.tabId}
type="button"
onClick={() => props.onSelectTab(tab.tabId)}
onContextMenu={(e) => {
e.preventDefault()
setTabMenu({
x: Math.min(e.clientX, window.innerWidth - 170),
y: Math.min(e.clientY, window.innerHeight - 110),
tabId: tab.tabId
})
}}
className={`group flex h-7 max-w-44 min-w-20 shrink-0 items-center gap-1 rounded px-2 text-left text-[11px] transition-colors ${
tab.tabId === state?.activeTabId
? 'bg-accent-tint text-accent-ink'
: 'text-ink-3 hover:bg-hover hover:text-ink-2'
}`}
>
{tab.loading && <LuRotateCw size={10} className="shrink-0 animate-spin" />}
<span className="min-w-0 flex-1 truncate">{tab.title || tab.url || '新标签页'}</span>
<span
role="button"
tabIndex={-1}
aria-label="关闭标签页"
onClick={(e) => {
e.stopPropagation()
props.onCloseTab(tab.tabId)
}}
className="hidden shrink-0 rounded p-0.5 hover:bg-hover-2 group-hover:block"
>
<LuX size={10} />
</span>
</button>
))}
<button
type="button"
aria-label="新建标签页"
onClick={() => props.onNewTab()}
disabled={Boolean(riskBanner)}
className="flex size-7 shrink-0 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2 disabled:opacity-40"
>
<LuPlus size={13} />
</button>
</div>
{/* 页签右键菜单 */}
{tabMenu && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => setTabMenu(null)}
onContextMenu={(e) => {
e.preventDefault()
setTabMenu(null)
}}
/>
<div
className="fixed z-40 w-40 rounded-lg border border-line bg-surface py-1 shadow-overlay"
style={{ left: tabMenu.x, top: tabMenu.y }}
>
<button
type="button"
onClick={() => {
props.onCloseTab(tabMenu.tabId)
setTabMenu(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover"
>
<LuX size={11} className="shrink-0" />
关闭标签页
</button>
<button
type="button"
disabled={(state?.tabs.length ?? 0) <= 1}
onClick={() => {
for (const t of state?.tabs ?? []) {
if (t.tabId !== tabMenu.tabId) props.onCloseTab(t.tabId)
}
setTabMenu(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover disabled:cursor-default disabled:text-ink-3 disabled:hover:bg-transparent"
>
<LuX size={11} className="shrink-0" />
关闭其他标签页
</button>
</div>
</>
)}
</div>
{/* 工具条 */}
<div className="flex h-10 shrink-0 items-center gap-1 border-b border-line bg-surface px-1.5">
<button
type="button"
aria-label="后退"
onClick={props.onGoBack}
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<LuArrowLeft size={14} />
</button>
<button
type="button"
aria-label="前进"
onClick={props.onGoForward}
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<LuArrowRight size={14} />
</button>
<button
type="button"
aria-label="刷新"
onClick={props.onReload}
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<LuRotateCw size={13} />
</button>
<div className="mx-1 flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded border border-line bg-inset px-2 focus-within:border-accent focus-within:bg-surface">
<LuGlobe size={11} className="shrink-0 text-ink-3" />
<input
ref={addressRef}
value={address}
onChange={(e) => setAddress(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitAddress()
if (e.key === 'Escape') addressRef.current?.blur()
}}
placeholder="输入网址或搜索…"
spellCheck={false}
className="min-w-0 flex-1 bg-transparent text-[12px] text-ink-2 outline-none placeholder:text-ink-3"
/>
</div>
<button
type="button"
onClick={() => setImportOpen(true)}
title="导入本机浏览器登录态"
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<LuDownload size={13} />
</button>
</div>
{/* 风险告知(首次使用门禁,Agent 与用户操作都会被拦截直到确认) */}
{riskBanner && (
<div className="flex shrink-0 items-start gap-2 border-b border-orange/40 bg-orange-tint px-3 py-2.5 text-[11.5px] leading-relaxed text-orange">
<LuCircleAlert size={14} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<p>
Agent 将以你的身份操作网页。平台可能将自动化行为识别为异常,导致验证码、限流或账号风控;请勿在受管浏览器中登录支付等高敏感账号。
</p>
<button
type="button"
onClick={props.onAcknowledgeRisk}
className="mt-1.5 rounded bg-orange px-2.5 py-1 text-[11px] font-medium text-page transition-opacity hover:opacity-90"
>
我已了解,开始使用
</button>
</div>
</div>
)}
{/* 页面区域:原生 WebContentsView 对齐到这里的矩形。
* 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白 */}
<div className="relative min-h-0 flex-1 bg-page">
{activeTab && !slotHidden ? (
<BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} />
) : activeTab ? (
frozen ? (
<img src={frozen} alt="" aria-hidden className="h-full w-full object-cover" draggable={false} />
) : (
<div className="h-full bg-surface" />
)
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<LuGlobe size={28} className="text-ink-3" />
{riskBanner && <p className="text-[12px] text-ink-3">确认上方风险告知后即可打开网页</p>}
</div>
)}
</div>
{/* Agent 操作轨迹 */}
{state?.trace && !importOpen && (
<div className="shrink-0 truncate border-t border-line bg-surface px-3 py-1.5 text-[10.5px] text-ink-3">
{state.trace}
</div>
)}
{/* 配置导入浮层(覆盖面板,出现时原生视图已隐藏) */}
{importOpen && (
<ImportOverlay
state={state}
onClose={() => setImportOpen(false)}
onDetectSources={props.onDetectSources}
onImportProfile={props.onImportProfile}
/>
)}
</div>
)
}
// ---- 配置导入浮层 ----
function ImportOverlay({
state,
onClose,
onDetectSources,
onImportProfile
}: {
state: BrowserUiState | null
onClose: () => void
onDetectSources: () => Promise<BrowserSource[]>
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
}): JSX.Element {
const [sources, setSources] = useState<BrowserSource[] | null>(null)
const [selected, setSelected] = useState<{ sourceId: string; profileDir: string } | null>(null)
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<BrowserImportResult | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
onDetectSources()
.then((list) => {
setSources(list)
const first = list[0]
if (first?.profiles[0]) setSelected({ sourceId: first.id, profileDir: first.profiles[0].dir })
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
}, [onDetectSources])
const runImport = (): void => {
if (!selected) return
setBusy(true)
setError(null)
setResult(null)
onImportProfile(selected.sourceId, selected.profileDir)
.then(setResult)
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false))
}
return (
<div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]">
<div className="flex h-10 shrink-0 items-center justify-between border-b border-line px-3">
<div className="text-[12.5px] font-medium text-ink-2">导入本机浏览器登录态</div>
<button
type="button"
aria-label="关闭"
onClick={onClose}
className="flex size-6 items-center justify-center rounded text-ink-3 hover:bg-hover hover:text-ink-2"
>
<LuX size={13} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3 text-[12px] text-ink-2">
<p className="mb-3 leading-relaxed text-ink-3">
选择本机浏览器的一个配置(Profile),把其中各网站的登录态(Cookie / Local Storage)导入到内置受管浏览器。
导入前请<span className="font-medium text-ink-2">完全退出</span>该浏览器,并关闭本面板的所有标签页。
</p>
{state?.imported && (
<div className="mb-3 rounded border border-line bg-inset px-2.5 py-2 text-[11.5px] text-ink-3">
当前导入来源:{state.imported.browser} · {state.imported.profile}
{state.imported.cookies ? '(含 Cookie)' : ''}
</div>
)}
{sources === null && !error && <p className="py-6 text-center text-ink-3">正在检测本机浏览器…</p>}
{sources?.length === 0 && <p className="py-6 text-center text-ink-3">未检测到可导入的本机浏览器(支持 Chrome / Edge / Brave / Chromium)</p>}
<div className="flex flex-col gap-2">
{sources?.map((source) => (
<div key={source.id} className="rounded border border-line">
<div className="flex items-center gap-1.5 px-2.5 py-2 text-[12px] font-medium text-ink-2">
<LuChevronDown size={12} className="text-ink-3" />
{source.name}
<span className="text-[10.5px] font-normal text-ink-3">({source.profiles.length} 个配置)</span>
</div>
<div className="flex flex-col gap-1 px-2 pb-2">
{source.profiles.map((profile) => {
const active = selected?.sourceId === source.id && selected.profileDir === profile.dir
return (
<button
key={profile.dir}
type="button"
onClick={() => setSelected({ sourceId: source.id, profileDir: profile.dir })}
className={`flex items-center justify-between rounded px-2.5 py-1.5 text-left text-[11.5px] transition-colors ${
active ? 'bg-accent-tint text-accent-ink' : 'text-ink-2 hover:bg-hover'
}`}
>
<span className="min-w-0 truncate">
{profile.name}
<span className="ml-1.5 text-[10.5px] text-ink-3">{profile.dir}</span>
</span>
{active && <span className="shrink-0 text-[10px]">已选择</span>}
</button>
)
})}
</div>
</div>
))}
</div>
{error && (
<div className="mt-3 rounded border border-red/40 bg-red-tint px-2.5 py-2 text-[11.5px] text-red">{error}</div>
)}
{result && (
<div
className={`mt-3 rounded border px-2.5 py-2 text-[11.5px] leading-relaxed ${
result.ok ? 'border-green-200 bg-green-tint text-green' : 'border-red/40 bg-red-tint text-red'
}`}
>
{result.message}
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-line px-3 py-2.5">
<button
type="button"
onClick={onClose}
className="rounded px-3 py-1.5 text-[12px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
关闭
</button>
<button
type="button"
disabled={!selected || busy}
onClick={runImport}
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90 disabled:opacity-40"
>
{busy ? '导入中…' : '导入登录态'}
</button>
</div>
</div>
)
}
@@ -1,81 +0,0 @@
import * as React from 'react'
/**
* 受管浏览器页面的 DOM 占位符。
*
* 原生 WebContentsView 由主进程挂载在窗口 contentView 上,天然盖在所有 DOM 之上,
* CSS z-index 无法反转。这里量出占位矩形(ResizeObserver + 窗口 resize),经 IPC
* 同步给主进程做 setBounds,让原生视图精确对齐到面板布局。
*
* revision 全局单调递增:快速切换 Tab 时旧 Slot 晚到的 IPC 不会覆盖新挂载的视图。
*/
let revisionCounter = 0
function nextRevision(): number {
// 以 Date.now() 为底:修订号必须跨模块热替换/整页重载单调递增——主进程
// 不随渲染层重载,各标签页还记着旧 lastLayoutRevision,纯自增计数器在
// 重载后归零会被 revision <= last 拒绝,原生视图从此收不到显示指令(页面空白)
revisionCounter = Math.max(revisionCounter + 1, Date.now())
return revisionCounter
}
export function BrowserSlot({ tabId }: { tabId: string }): React.ReactElement {
const ref = React.useRef<HTMLDivElement>(null)
React.useLayoutEffect(() => {
const element = ref.current
if (!element) return
let frame = 0
const commit = (visible: boolean): void => {
const rect = element.getBoundingClientRect()
void window.api.browserSetLayout({
tabId,
visible: visible && rect.width > 4 && rect.height > 4,
revision: nextRevision(),
bounds: {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height)
}
})
}
const publish = (immediate = false): void => {
if (frame) cancelAnimationFrame(frame)
if (immediate) {
frame = 0
commit(true)
return
}
frame = requestAnimationFrame(() => {
frame = 0
commit(true)
})
}
const observer = new ResizeObserver(() => publish())
observer.observe(element)
window.addEventListener('resize', onWindowResize)
// 挂载立即上报(不等一帧),避免快速切换时原生视图停留在隐藏状态
publish(true)
function onWindowResize(): void {
publish()
}
return () => {
observer.disconnect()
window.removeEventListener('resize', onWindowResize)
if (frame) cancelAnimationFrame(frame)
void window.api.browserSetLayout({
tabId,
visible: false,
revision: nextRevision(),
bounds: { x: 0, y: 0, width: 0, height: 0 }
})
}
}, [tabId])
return <div ref={ref} className="h-full w-full bg-white" aria-label="受管浏览器页面" />
}
+21 -202
View File
@@ -1,17 +1,5 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import type {
AgentUiEvent,
ApprovalRequest,
ForkPoint,
NavigateTreeOptions,
PermissionMode,
SendImage,
SessionMeta,
SessionState,
SessionStatsInfo,
ThinkingLevel,
WorkspaceMeta
} from '@shared/types'
import type { AgentUiEvent, ApprovalRequest, PermissionMode, SendImage, SessionMeta, SessionState, SessionStatsInfo, WorkspaceMeta } from '@shared/types'
export type { ApprovalRequest }
@@ -30,16 +18,6 @@ export interface SettledApproval {
timedOut: boolean
}
/** 压缩通知(compaction_end 后展示于聊天顶部,自动消退) */
export interface CompactionNotice {
reason: 'manual' | 'threshold' | 'overflow'
aborted: boolean
summary?: string
tokensBefore?: number
errorMessage?: string
at: number
}
export interface UiMessage {
key: string
role: string
@@ -55,10 +33,6 @@ interface AgentUiState {
messages: UiMessage[]
tools: Record<string, ToolState>
streaming: boolean
/** 回合是否仍在进行。与 streaming 的区别:pi 在一个回合内可能因自动压缩/重试
* 续跑而发出多次 agent_start/agent_end,streaming 会随之中途抖动;
* turnActive 只在 agent_settled(整个回合真正结束)时归位。 */
turnActive: boolean
queue: { steering: string[]; followUp: string[] }
error: string | null
sessionError: string | null
@@ -68,23 +42,17 @@ interface AgentUiState {
ready: boolean
approvals: ApprovalRequest[]
settledApprovals: SettledApproval[]
/** 正在压缩上下文(手动/阈值/溢出) */
compacting: boolean
/** 最近一次压缩的结果通知(null = 已展示/已过时) */
compactionNotice: CompactionNotice | null
}
type Action =
| { type: 'event'; event: AgentUiEvent }
| { type: 'optimistic-user'; key: string; text: string }
| { type: 'clear-error' }
| { type: 'clear-compaction-notice' }
const initialState: AgentUiState = {
messages: [],
tools: {},
streaming: false,
turnActive: false,
queue: { steering: [], followUp: [] },
error: null,
sessionError: null,
@@ -93,9 +61,7 @@ const initialState: AgentUiState = {
retry: null,
ready: false,
approvals: [],
settledApprovals: [],
compacting: false,
compactionNotice: null
settledApprovals: []
}
let keyCounter = 0
@@ -111,14 +77,13 @@ function readRole(message: unknown): string {
return 'unknown'
}
/** 历史消息(会话切换/恢复时)映射为 UI 消息。
* compactionSummary / branchSummary 保留为通知行(压缩与分支摘要在时间线中可见)。 */
/** 历史消息(会话切换/恢复时)映射为 UI 消息 */
function historyToMessages(messages: unknown[]): UiMessage[] {
const result: UiMessage[] = []
for (const raw of messages) {
const role = readRole(raw)
if (role === 'toolResult') continue
if (role === 'user' || role === 'assistant' || role === 'compactionSummary' || role === 'branchSummary') {
if (role === 'user' || role === 'assistant') {
result.push({ key: nextKey('h'), role, raw })
}
}
@@ -166,9 +131,6 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
if (action.type === 'clear-error') {
return { ...state, error: null }
}
if (action.type === 'clear-compaction-notice') {
return { ...state, compactionNotice: null }
}
const event = action.event
switch (event.type) {
@@ -185,22 +147,18 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
error: null,
retry: null,
streaming: false,
turnActive: false,
queue: { steering: [], followUp: [] },
stats: null,
compacting: false,
compactionNotice: null,
sessionState: event.state,
messages: historyToMessages(event.messages),
tools: historyToTools(event.messages)
}
case 'agent_start':
return { ...state, streaming: true, turnActive: true, error: null, retry: null }
return { ...state, streaming: true, error: null, retry: null }
case 'agent_end':
// agent_end 不代表回合结束:自动压缩/重试会以 agent_start 续跑同一回合
return { ...state, streaming: false }
case 'agent_settled':
return { ...state, streaming: false, turnActive: false, retry: null }
return { ...state, streaming: false, retry: null }
case 'message_start': {
// 助手消息开流式占位;用户消息在发送时已乐观插入,此处不重复
if (event.role === 'assistant') {
@@ -233,18 +191,12 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
case 'message_end': {
const role = readRole(event.message)
const messages = [...state.messages]
// 回填最近的流式占位——仅限 assistant:占位只为 assistant 的 message_start
// 创建,若不校验角色,迟到/交错的 user、toolResult message_end 会把占位
// 误替换掉(内容丢失、回合分组被非 assistant 消息切断,过程区误判收起)
if (role === 'assistant') {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].stream) {
messages[i] = { key: messages[i].key, role, raw: event.message }
return { ...state, messages }
}
// 回填最近的流式占位(assistant)
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].stream) {
messages[i] = { key: messages[i].key, role, raw: event.message }
return { ...state, messages }
}
messages.push({ key: nextKey('a'), role: 'assistant', raw: event.message })
return { ...state, messages }
}
if (role === 'user') {
// 若最后一条是本地乐观插入的用户消息,用服务端版本回填
@@ -255,7 +207,14 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
}
return state
}
// toolResult 等其他角色:已在 ToolCard 中通过 tool_end 展示,不独立成消息
if (role === 'toolResult') {
// 工具结果已在 ToolCard 中通过 tool_end 展示,跳过独立消息
return state
}
if (role === 'assistant') {
messages.push({ key: nextKey('a'), role: 'assistant', raw: event.message })
return { ...state, messages }
}
return state
}
case 'tool_start': {
@@ -311,32 +270,6 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
}
case 'stats':
return { ...state, stats: event.stats }
case 'thinking_level_changed': {
if (!state.sessionState) return state
return {
...state,
sessionState: { ...state.sessionState, thinkingLevel: event.level }
}
}
case 'compaction_start':
return { ...state, compacting: true }
case 'compaction_end': {
// 压缩替换了上下文历史:就地换血消息列表(含 compactionSummary 通知行),
// 但不动 streaming/turnActive —— 自动压缩发生在回合中途,回合还会续跑
return {
...state,
compacting: false,
messages: historyToMessages(event.messages),
compactionNotice: {
reason: event.reason,
aborted: event.aborted,
summary: event.summary,
tokensBefore: event.tokensBefore,
errorMessage: event.errorMessage,
at: Date.now()
}
}
}
default:
return state
}
@@ -346,12 +279,6 @@ export interface UseAgent {
state: AgentUiState
sessions: SessionMeta[]
workspaces: WorkspaceMeta[]
/** 回溯点(当前分支上的用户消息,按序与时间线中的用户消息一一对应) */
forkPoints: ForkPoint[]
/** 分支回溯(含分支摘要生成)进行中 */
navigating: boolean
/** 会话末尾分叉(pi fork)进行中 */
forking: boolean
send: (text: string, images?: SendImage[]) => void
stop: () => void
clearError: () => void
@@ -365,28 +292,12 @@ export interface UseAgent {
setPermissionMode: (mode: PermissionMode) => void
createWorkspace: (name: string) => void
openWorkspace: (path: string) => void
setThinkingLevel: (level: ThinkingLevel) => void
compact: (instructions?: string) => void
abortCompaction: () => void
setAutoCompaction: (enabled: boolean) => void
dismissCompactionNotice: () => void
navigateTree: (entryId: string, options?: NavigateTreeOptions) => void
/** 在会话末尾分叉出新会话(pi fork,原会话保留) */
forkSession: () => void
/** 从锚点分叉:'at' 含该条为止 / 'before' 到其父条目为止 */
forkSessionAt: (entryId: string, position: 'at' | 'before') => void
}
export function useAgent(): UseAgent {
const [state, dispatch] = useReducer(reducer, initialState)
const [sessions, setSessions] = useState<SessionMeta[]>([])
const [workspaces, setWorkspaces] = useState<WorkspaceMeta[]>([])
/** 回溯点(当前分支上的用户消息,按序),随会话内容刷新 */
const [forkPoints, setForkPoints] = useState<ForkPoint[]>([])
/** 分支回溯进行中(含分支摘要生成) */
const [navigating, setNavigating] = useState(false)
/** 会话末尾分叉进行中 */
const [forking, setForking] = useState(false)
const refreshWorkspaces = useCallback((): void => {
void window.api
@@ -408,13 +319,6 @@ export function useAgent(): UseAgent {
.then(setSessions)
.catch(() => undefined)
}
// 回溯点与会话内容同步:切换/压缩/回溯后重取
if (event.type === 'session_switched' || event.type === 'compaction_end') {
void window.api
.listForkPoints()
.then(setForkPoints)
.catch(() => undefined)
}
})
return unsubscribe
}, [])
@@ -531,77 +435,11 @@ export function useAgent(): UseAgent {
[dispatchError, refreshSessions, refreshWorkspaces]
)
// ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ----
const setThinkingLevel = useCallback(
(level: ThinkingLevel): void => {
window.api.setThinkingLevel(level).catch(dispatchError)
},
[dispatchError]
)
const compact = useCallback(
(instructions?: string): void => {
window.api.compactSession(instructions).catch(dispatchError)
},
[dispatchError]
)
const abortCompaction = useCallback((): void => {
window.api.abortCompaction().catch(() => undefined)
}, [])
const setAutoCompaction = useCallback((enabled: boolean): void => {
window.api.setAutoCompaction(enabled).catch(() => undefined)
}, [])
const dismissCompactionNotice = useCallback((): void => {
dispatch({ type: 'clear-compaction-notice' })
}, [])
/** 回溯到某个用户消息;summarize=true 时为被离开的分支生成分支摘要 */
const navigateTree = useCallback(
(entryId: string, options?: NavigateTreeOptions): void => {
setNavigating(true)
window.api
.navigateTree(entryId, options)
.then(() => window.api.listForkPoints())
.then(setForkPoints)
.catch(dispatchError)
.finally(() => setNavigating(false))
},
[dispatchError]
)
/** 在会话末尾分叉出新会话(pi fork:新会话文件,原会话保留);成功后 session_switched 自动切换 */
const forkSession = useCallback((): void => {
setForking(true)
window.api
.forkSession()
.catch(dispatchError)
.finally(() => setForking(false))
}, [dispatchError])
/** 从会话树上的锚点分叉出新会话('at' 含该条为止 / 'before' 到其父条目为止,即包含前面整个回合) */
const forkSessionAt = useCallback(
(entryId: string, position: 'at' | 'before'): void => {
setForking(true)
window.api
.forkSessionAt(entryId, position)
.catch(dispatchError)
.finally(() => setForking(false))
},
[dispatchError]
)
return useMemo(
() => ({
state,
sessions,
workspaces,
forkPoints,
navigating,
forking,
send,
stop,
clearError,
@@ -614,23 +452,12 @@ export function useAgent(): UseAgent {
resolveApproval,
setPermissionMode,
createWorkspace,
openWorkspace,
setThinkingLevel,
compact,
abortCompaction,
setAutoCompaction,
dismissCompactionNotice,
navigateTree,
forkSession,
forkSessionAt
openWorkspace
}),
[
state,
sessions,
workspaces,
forkPoints,
navigating,
forking,
send,
stop,
clearError,
@@ -643,15 +470,7 @@ export function useAgent(): UseAgent {
resolveApproval,
setPermissionMode,
createWorkspace,
openWorkspace,
setThinkingLevel,
compact,
abortCompaction,
setAutoCompaction,
dismissCompactionNotice,
navigateTree,
forkSession,
forkSessionAt
openWorkspace
]
)
}
@@ -1,57 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types'
/**
* 内置受管浏览器面板状态与操作。
* 状态由主进程 BrowserController 广播(browser:event),这里只做订阅与动作封装。
*/
export function useBrowser(): {
state: BrowserUiState | null
navigate: (input: string) => void
goBack: () => void
goForward: () => void
reload: () => void
newTab: () => void
selectTab: (tabId: string) => void
closeTab: (tabId: string) => void
closeBrowser: () => void
acknowledgeRisk: () => void
detectSources: () => Promise<BrowserSource[]>
importProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
} {
const [state, setState] = useState<BrowserUiState | null>(null)
useEffect(() => {
void window.api.browserGetState().then(setState).catch(() => undefined)
return window.api.onBrowserEvent((event) => {
if (event.type === 'state') setState(event.state)
})
}, [])
const dispatchError = useCallback((err: unknown): void => {
console.error('[browser]', err instanceof Error ? err.message : String(err))
}, [])
const wrap = useCallback(
<A,>(fn: (...args: A[]) => Promise<unknown>) =>
(...args: A[]): void => {
void fn(...args).catch(dispatchError)
},
[dispatchError]
)
return {
state,
navigate: wrap((input: string) => window.api.browserNavigate(input)),
goBack: wrap(() => window.api.browserGoBack()),
goForward: wrap(() => window.api.browserGoForward()),
reload: wrap(() => window.api.browserReload()),
newTab: wrap((url?: string) => window.api.browserNewTab(url)),
selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)),
closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)),
closeBrowser: wrap(() => window.api.browserClose()),
acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk()),
detectSources: () => window.api.browserDetectSources(),
importProfile: (sourceId: string, profileDir: string) => window.api.browserImportProfile(sourceId, profileDir)
}
}
@@ -1,17 +1,26 @@
import { useEffect, useRef } from 'react'
import type { RefObject } from 'react'
import { animateEntrance } from '../lib/anim'
/** 流式消息的入场动画(GSAP),仅挂载时执行一次。
* animate=false 供历史消息渐进填充时跳过动画——切会话时批量挂载的旧消息
* 不需要逐条淡入,几十个并发 tween 会造成可感知的卡顿。取挂载时刻的值,
* 之后 animate 变化不追溯触发。 */
export function useEntrance<T extends HTMLElement>(animate = true): RefObject<T | null> {
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
const ref = useRef<T | null>(null)
const animateRef = useRef(animate)
useEffect(() => {
const el = ref.current
if (el && animateRef.current) animateEntrance(el)
if (!el) return
let tween: { kill(): void } | null = null
let cancelled = false
void import('gsap').then((gsapModule) => {
if (cancelled) return
tween = gsapModule.gsap.fromTo(
el,
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', clearProps: 'transform' }
)
})
return () => {
cancelled = true
tween?.kill()
}
}, [])
return ref
}
-84
View File
@@ -1,84 +0,0 @@
/** 共享动画模块(GSAP 懒加载单例)。
* 原则:transform/opacity 小动画交给 CSS 合成器;GSAP 负责需要中断衔接、
* 按内容测量的补间(消息入场、回合过程区折叠/展开)。
* 全部遵循系统「减弱动态效果」(prefers-reduced-motion)。 */
type Gsap = (typeof import('gsap'))['gsap']
let gsapPromise: Promise<Gsap> | null = null
function loadGsap(): Promise<Gsap> {
gsapPromise ??= import('gsap').then((m) => {
m.gsap.defaults({ overwrite: 'auto' })
return m.gsap
})
return gsapPromise
}
function reducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
/** 消息入场:上移淡入,仅挂载时一次 */
export function animateEntrance(el: HTMLElement): void {
if (reducedMotion()) return
void loadGsap().then((gsap) => {
gsap.fromTo(
el,
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', clearProps: 'transform' }
)
})
}
/** 收起:高度 → 0、透明度 → 0。先同步落起始帧再异步补间,中间不闪帧;
* 从当前高度续接(可中断重入,快速反复点击也顺滑)。 */
export function animateCollapse(el: HTMLElement): void {
el.style.pointerEvents = ''
if (reducedMotion()) {
el.style.height = '0px'
el.style.opacity = '0'
el.style.pointerEvents = 'none'
return
}
const from = el.offsetHeight
el.style.height = `${from}px`
void loadGsap().then((gsap) => {
gsap.killTweensOf(el)
gsap.to(el, {
height: 0,
opacity: 0,
duration: 0.5,
ease: 'power2.inOut',
onComplete: () => {
el.style.pointerEvents = 'none'
}
})
})
}
/** 展开:高度 0 → 内容实际高度,完成后复位 auto,后续内容增长不被裁剪 */
export function animateExpand(el: HTMLElement): void {
el.style.height = 'auto'
el.style.pointerEvents = ''
const to = el.offsetHeight
if (reducedMotion()) {
el.style.height = ''
el.style.opacity = ''
return
}
el.style.height = '0px'
void loadGsap().then((gsap) => {
gsap.killTweensOf(el)
gsap.to(el, {
height: to,
opacity: 1,
duration: 0.5,
ease: 'power2.inOut',
onComplete: () => {
el.style.height = ''
el.style.opacity = ''
}
})
})
}
+2 -67
View File
@@ -2,44 +2,21 @@ import type {
AgentUiEvent,
AppSettings,
AttachmentInfo,
BrowserImportResult,
BrowserSource,
BrowserUiEvent,
BrowserUiState,
ChatResources,
CustomProviderConfig,
ForkPoint,
ModelCatalog,
ModelInfo,
NavigateTreeOptions,
PermissionMode,
PluginInfo,
PluginsUiState,
PluginViewChange,
ResourceDetails,
SendImage,
SessionMeta,
SessionState,
SessionStatsInfo,
ThinkingLevel,
ThemeMode,
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
export type {
AgentUiEvent,
AppSettings,
ModelCatalog,
ModelInfo,
SessionMeta,
SessionState,
SessionStatsInfo,
BrowserImportResult,
BrowserSource,
BrowserUiEvent,
BrowserUiState
}
export type { AgentUiEvent, AppSettings, ModelCatalog, ModelInfo, SessionMeta, SessionState, SessionStatsInfo }
declare global {
interface Window {
@@ -47,9 +24,6 @@ declare global {
minimize: () => Promise<void>
toggleMaximize: () => Promise<void>
close: () => Promise<void>
/** 界面缩放:factor 为相对倍率(如 1.1 放大 / 0.9 缩小),限制 50%–200% */
zoom: (factor: number) => void
zoomReset: () => void
onWindowState: (callback: (maximized: boolean) => void) => () => void
send: (text: string, images?: SendImage[]) => Promise<void>
steer: (text: string) => Promise<void>
@@ -80,50 +54,11 @@ declare global {
setModel: (providerId: string, modelId: string) => Promise<void>
setApiKey: (providerId: string, apiKey: string) => Promise<void>
logoutProvider: (providerId: string) => Promise<void>
setThinkingLevel: (level: ThinkingLevel) => Promise<void>
compactSession: (instructions?: string) => Promise<{ summary: string; tokensBefore: number }>
abortCompaction: () => Promise<void>
setAutoCompaction: (enabled: boolean) => Promise<void>
listForkPoints: () => Promise<ForkPoint[]>
navigateTree: (entryId: string, options?: NavigateTreeOptions) => Promise<void>
forkSession: () => Promise<void>
forkSessionAt: (entryId: string, position: 'at' | 'before') => Promise<void>
getSettings: () => Promise<AppSettings>
getSystemPrompt: () => Promise<string>
setSystemPrompt: (text: string) => Promise<void>
setThemeColor: (id: string) => Promise<void>
setThemeMode: (mode: ThemeMode) => Promise<void>
resolveApproval: (id: string, approved: boolean) => Promise<void>
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
browserGetState: () => Promise<BrowserUiState>
browserNavigate: (input: string) => Promise<BrowserUiState>
browserGoBack: () => Promise<BrowserUiState>
browserGoForward: () => Promise<BrowserUiState>
browserReload: () => Promise<BrowserUiState>
browserNewTab: (url?: string) => Promise<BrowserUiState>
browserSelectTab: (tabId: string) => Promise<BrowserUiState>
browserCloseTab: (tabId?: string) => Promise<BrowserUiState>
browserClose: () => Promise<BrowserUiState>
browserCaptureTab: (tabId?: string) => Promise<string>
browserAcknowledgeRisk: () => Promise<void>
browserSetLayout: (layout: {
tabId: string
visible: boolean
revision: number
bounds: { x: number; y: number; width: number; height: number }
}) => Promise<void>
browserDetectSources: () => Promise<BrowserSource[]>
browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void
// ---- 插件系统 ----
pluginsList: () => Promise<PluginInfo[]>
pluginsUiState: () => Promise<PluginsUiState>
pluginsSetEnabled: (id: string, enabled: boolean) => Promise<void>
pluginsReload: () => Promise<void>
pluginsOpenView: (view: string, params?: unknown) => Promise<void>
pluginsSetStatus: (pluginId: string, itemId: string, item: unknown) => Promise<void>
onPluginsListChanged: (callback: () => void) => () => void
onPluginsUiState: (callback: (state: PluginsUiState) => void) => () => void
onPluginsView: (callback: (change: PluginViewChange) => void) => () => void
}
}
}
+5 -47
View File
@@ -4,9 +4,6 @@
* accent-ink 为加深版(保证链接/可交互文本 AA 对比度),tint 为低透明度选中底。
*/
import { useEffect, useState } from 'react'
import type { ThemeMode } from '@shared/types'
export interface ThemePreset {
id: string
label: string
@@ -26,14 +23,7 @@ export const THEME_PRESETS: ThemePreset[] = [
{ id: 'black', label: '墨黑', accent: '#3a3a3f', ink: '#17171a', tint: 'rgba(23, 23, 26, 0.08)' }
]
export const THEME_MODES: Array<{ id: ThemeMode; label: string }> = [
{ id: 'light', label: '浅色' },
{ id: 'dark', label: '深色' },
{ id: 'system', label: '跟随系统' }
]
/** 应用主题预设(undefined/未知 id = 恢复默认,移除内联覆盖)。
* 变化时派发 aso-theme-changed(插件页桥据此把新 accent 下发给 iframe 页) */
/** 应用主题预设(undefined/未知 id = 恢复默认,移除内联覆盖) */
export function applyThemeColor(id: string | undefined): void {
const preset = THEME_PRESETS.find((p) => p.id === id)
const root = document.documentElement
@@ -41,41 +31,9 @@ export function applyThemeColor(id: string | undefined): void {
root.style.removeProperty('--accent')
root.style.removeProperty('--accent-ink')
root.style.removeProperty('--accent-tint')
} else {
root.style.setProperty('--accent', preset.accent)
root.style.setProperty('--accent-ink', preset.ink)
root.style.setProperty('--accent-tint', preset.tint)
return
}
window.dispatchEvent(new CustomEvent('aso-theme-changed'))
}
/**
* 深浅色模式应用:主进程把用户选择写入 Electron nativeTheme.themeSource,
* 渲染层的 prefers-color-scheme 媒体查询随之反映(light/dark/system 三种语义)。
* 这里监听媒体查询切换 <html> 的 .dark 类——beautifului.css 的 .dark 令牌块
* 接管全部设计令牌,组件无需感知模式。返回清理函数。
*/
export function watchColorScheme(): () => void {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const apply = (): void => {
document.documentElement.classList.toggle('dark', mq.matches)
}
apply()
mq.addEventListener('change', apply)
return () => {
mq.removeEventListener('change', apply)
document.documentElement.classList.remove('dark')
}
}
/** 当前是否处于深色(组件级感知:shiki 双主题、需要单独适配的局部样式等) */
export function useDarkTheme(): boolean {
const [dark, setDark] = useState<boolean>(() => window.matchMedia('(prefers-color-scheme: dark)').matches)
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = (): void => setDark(mq.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
return dark
root.style.setProperty('--accent', preset.accent)
root.style.setProperty('--accent-ink', preset.ink)
root.style.setProperty('--accent-tint', preset.tint)
}
@@ -4,22 +4,3 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
/** token 数中文格式化:1.2万 / 3.3万(ZCode 风格),万以下用千分位 */
export function formatTokens(n: number): string {
if (!Number.isFinite(n)) return '0'
if (n >= 1e8) return `${stripTrailingZero(n / 1e8)}亿`
if (n >= 1e4) return `${stripTrailingZero(n / 1e4)}万`
return n.toLocaleString('en-US')
}
function stripTrailingZero(v: number): string {
return v.toFixed(1).replace(/\.0$/, '')
}
/** 百分比展示(ZCode 卡片风格):一位小数按需保留 —— 91.2%、4%、0.4%、28.4% */
export function formatPercent(p: number): string {
const clamped = Math.max(0, Math.min(100, p))
const rounded = Math.round(clamped * 10) / 10
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}%`
}
@@ -1,36 +0,0 @@
import { Component, type ReactNode } from 'react'
/** 插件视图错误边界:单个插件崩溃显示占位 + 返回聊天,不拖垮整个应用 */
export class PluginErrorBoundary extends Component<
{ pluginName: string; children: ReactNode; onBack: () => void },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error): { error: Error } {
return { error }
}
componentDidCatch(error: Error): void {
console.error('[plugins] 视图渲染错误:', error)
}
render(): ReactNode {
if (this.state.error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="text-[14px] font-medium text-red">插件「{this.props.pluginName}」渲染出错</div>
<div className="max-w-md break-all text-[12px] leading-5 text-ink-3">{this.state.error.message}</div>
<button
type="button"
onClick={this.props.onBack}
className="rounded-control border border-line px-3 py-1.5 text-[12.5px] text-ink-2 transition-colors hover:bg-hover"
>
返回聊天
</button>
</div>
)
}
return this.props.children
}
}
@@ -1,196 +0,0 @@
/**
* 外置插件页宿主(iframe + 桥宿主侧)。
* - src 走 plugin://<id>/<page>?aso_t=<token>,token 变化即整页重载(热重载预览)
* - 桥安全:消息来源必须同时满足 source === iframe.contentWindow 且
* origin === 'plugin://<id>'(特权协议 standard+secure 下 iframe 有干净 origin)
* - invoke 白名单:getState / send / getSettings(其余一律拒绝)
* - 主题连续性:握手与主题变化时下发 { dark, accent }(accent 取自当前计算样式)
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { PluginInfo } from '@shared/types'
import { HARNESS_BRIDGE_VERSION, type PluginToHostMessage } from '@aso/plugin-sdk/client'
/** 插件页可调用的宿主 API 白名单(window.api 的受控子集) */
const INVOKE_ALLOWLIST = {
getState: () => window.api.getState(),
send: (text: string, images?: { mimeType: string; data: string }[]) => window.api.send(text, images),
getSettings: () => window.api.getSettings()
} as const
function currentTheme(): { dark: boolean; accent?: string } {
const root = document.documentElement
let accent = getComputedStyle(root).getPropertyValue('--accent').trim()
if (accent) accent = accent.trim()
return { dark: root.classList.contains('dark'), accent: accent || undefined }
}
/** 去除 svg 字符串里的事件属性与 script(插件签名前的最小防线) */
function sanitizeSvg(svg: string): string {
return svg
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
}
export function PluginIcon({
svg,
size = 18,
className
}: {
svg?: string
size?: number
className?: string
}): ReactNode {
if (!svg) {
return (
<span
aria-hidden
className={`block rounded-[5px] border border-line-strong ${className ?? ''}`}
style={{ width: size - 6, height: size - 6 }}
/>
)
}
return (
<span
aria-hidden
className={`flex shrink-0 items-center justify-center text-ink-2 [&_svg]:h-[1em] [&_svg]:w-[1em] ${className ?? ''}`}
style={{ fontSize: size, lineHeight: 1 }}
dangerouslySetInnerHTML={{ __html: sanitizeSvg(svg) }}
/>
)
}
export function PluginPageFrame({
plugin,
params,
reloadToken = 0,
onOpenView
}: {
plugin: PluginInfo
/** 深链参数:握手后下发;存活期间变化即时下发 harness:deep-link */
params?: unknown
reloadToken?: number
/** 插件页发起的 openView(含 'chat' / 其他插件 id),由 App 切换视图 */
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [loaded, setLoaded] = useState(false)
const handshake = useRef(false)
const lastDeliveredParams = useRef<unknown>(undefined)
const paramsRef = useRef<unknown>(undefined)
paramsRef.current = params
const page = plugin.manifest.entry?.page || 'index.html'
const src = useMemo(
() => `plugin://${plugin.manifest.id}/${page}?aso_t=${reloadToken}`,
[plugin.manifest.id, page, reloadToken]
)
const post = (msg: unknown): void => {
iframeRef.current?.contentWindow?.postMessage(msg, `plugin://${plugin.manifest.id}`)
}
// 深链参数:握手后首次下发 + 存活期间变化即时下发
useEffect(() => {
if (!handshake.current) return
if (lastDeliveredParams.current === params) return
lastDeliveredParams.current = params
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:deep-link', params })
}, [params, plugin.manifest.id])
// 桥:接收插件页消息(source + origin 双重校验)
useEffect(() => {
const expectedOrigin = `plugin://${plugin.manifest.id}`
const onMessage = (event: MessageEvent): void => {
if (event.source !== iframeRef.current?.contentWindow) return
if (event.origin !== expectedOrigin) return
const msg = event.data as PluginToHostMessage | undefined
if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string' || msg.v !== HARNESS_BRIDGE_VERSION) return
switch (msg.type) {
case 'plugin:hello': {
handshake.current = true
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:hello', contract: '0.1.0' })
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:theme', ...currentTheme() })
if (paramsRef.current !== undefined) {
lastDeliveredParams.current = paramsRef.current
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:deep-link', params: paramsRef.current })
}
break
}
case 'plugin:open-view':
onOpenView(msg.view, msg.params)
break
case 'plugin:set-status':
void window.api.pluginsSetStatus(plugin.manifest.id, msg.id, msg.item)
break
case 'plugin:invoke': {
const fn = (INVOKE_ALLOWLIST as Record<string, (...args: unknown[]) => unknown>)[msg.method]
if (!fn) {
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:invoke-result', seq: msg.seq, ok: false, error: `方法不在白名单: ${msg.method}` })
break
}
Promise.resolve()
.then(() => fn(...msg.args))
.then((value) =>
post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:invoke-result', seq: msg.seq, ok: true, value: value ?? null })
)
.catch((err: unknown) =>
post({
v: HARNESS_BRIDGE_VERSION,
type: 'harness:invoke-result',
seq: msg.seq,
ok: false,
error: err instanceof Error ? err.message : String(err)
})
)
break
}
default:
break
}
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [plugin.manifest.id, onOpenView])
// 主题变化(深浅色 / accent)下发
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const push = (): void => {
if (handshake.current) post({ v: HARNESS_BRIDGE_VERSION, type: 'harness:theme', ...currentTheme() })
}
media.addEventListener('change', push)
window.addEventListener('aso-theme-changed', push)
return () => {
media.removeEventListener('change', push)
window.removeEventListener('aso-theme-changed', push)
}
}, [plugin.manifest.id])
// 换 src(热重载)时重置握手/加载态
useEffect(() => {
handshake.current = false
lastDeliveredParams.current = undefined
setLoaded(false)
}, [src])
return (
<div className="relative min-h-0 w-full flex-1">
<iframe
ref={iframeRef}
title={plugin.manifest.name}
src={src}
onLoad={() => setLoaded(true)}
// 无 allow-top-navigation(插件页不能劫持宿主导航);allow-same-origin
// 保留 plugin://<id> 干净 origin 供桥校验
sandbox="allow-scripts allow-forms allow-popups allow-same-origin"
className="h-full w-full border-0 bg-page"
/>
{!loaded && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-[12px] text-ink-3">
加载插件页…
</div>
)}
</div>
)
}
@@ -1,51 +0,0 @@
/**
* 插件状态胶囊(TitleBar B2 槽位):数据驱动渲染主进程广播的状态条目。
* 点击条目 = 执行其深链(openView)。聊天视图与插件视图下均可见。
*/
import type { ReactNode } from 'react'
import type { PluginStatusEntry } from '@shared/types'
const TONE_CLASS: Record<string, string> = {
default: 'border-line text-ink-2',
accent: 'border-accent/40 text-accent-ink bg-accent-tint',
warn: 'border-orange/40 text-orange bg-orange-tint',
danger: 'border-red/40 text-red bg-red-tint'
}
export function PluginStatusCapsules({
entries,
onOpenView
}: {
entries: PluginStatusEntry[]
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
if (entries.length === 0) return null
return (
<div className="ml-3 flex min-w-0 items-center gap-1.5 [-webkit-app-region:no-drag]">
{entries.slice(0, 4).map(({ pluginId, itemId, item }) => {
const tone = item.tone ?? 'default'
const clickable = Boolean(item.openView)
return (
<button
key={`${pluginId}:${itemId}`}
type="button"
title={item.text}
onClick={() => {
if (item.openView) onOpenView(item.openView.view, item.openView.params)
}}
className={`max-w-44 shrink-0 truncate rounded-full border px-2.5 py-0.5 text-[11px] leading-[18px] transition-transform ${
TONE_CLASS[tone] ?? TONE_CLASS.default
} ${clickable ? 'hover:opacity-80 active:scale-[0.97]' : 'cursor-default'}`}
>
{item.text}
</button>
)
})}
{entries.length > 4 && (
<span className="shrink-0 rounded-full border border-line px-2 py-0.5 text-[11px] leading-[18px] text-ink-3">
+{entries.length - 4}
</span>
)}
</div>
)
}
@@ -1,7 +0,0 @@
/**
* 内置插件的宿主树组件注册表(componentKey → React 组件)。
* 仅内置插件可用(外置插件 UI 只能走 iframe 页);浏览器面板(M3)在此登记。
*/
import type { ComponentType } from 'react'
export const BUILTIN_PLUGIN_COMPONENTS: Record<string, ComponentType> = {}
@@ -1,62 +0,0 @@
/**
* 渲染层插件状态 hooks:插件列表 + 内联 UI 快照(单一事实源在主进程,事件驱动同步)。
*/
import { useEffect, useState } from 'react'
import type { PluginInfo, PluginsUiState } from '@shared/types'
const EMPTY_UI: PluginsUiState = { status: [], toolbar: [] }
export function usePluginList(): PluginInfo[] {
const [plugins, setPlugins] = useState<PluginInfo[]>([])
useEffect(() => {
let alive = true
const refresh = (): void => {
window.api
.pluginsList()
.then((list) => {
if (alive) setPlugins(list)
})
.catch(() => undefined)
}
refresh()
const off = window.api.onPluginsListChanged(refresh)
return () => {
alive = false
off()
}
}, [])
return plugins
}
export function usePluginsUiState(): PluginsUiState {
const [state, setState] = useState<PluginsUiState>(EMPTY_UI)
useEffect(() => {
let alive = true
const refresh = (): void => {
window.api
.pluginsUiState()
.then((snapshot) => {
if (alive) setState(snapshot)
})
.catch(() => undefined)
}
refresh()
const off = window.api.onPluginsUiState(setState)
return () => {
alive = false
off()
}
}, [])
return state
}
/** 仅返回已启用且有侧栏入口的插件(入口排序:order 升序,其次 id) */
export function sidebarPlugins(plugins: PluginInfo[]): PluginInfo[] {
return plugins
.filter((p) => p.enabled && (p.hasComponent || p.hasPage))
.sort(
(a, b) =>
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) ||
a.manifest.id.localeCompare(b.manifest.id)
)
}
@@ -312,15 +312,6 @@ a:focus-visible .animated-underline::after {
width: 36px;
}
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
.sidebar-collapsed-only {
display: none;
}
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
display: flex;
}
/* ── shared keyframes ─────────────────────────────────── */
@keyframes shimmer-text {
@@ -52,16 +52,12 @@ body,
}
}
/* 流式输出光标:以 ::after 附加到流式正文最后一个文本块的最后一行文字之后
(inline 随文,而不是孤零零落在新的一行),结束后随容器 data-streaming 消失 */
.streaming-body[data-streaming='true'] > *:last-child > :is(p, h1, h2, h3, h4, h5, h6):last-child::after,
.streaming-body[data-streaming='true'] > *:last-child > :is(ul, ol):last-child > li:last-child::after {
content: '';
.stream-caret {
display: inline-block;
width: 2px;
height: 1em;
margin-left: 3px;
vertical-align: -0.15em;
width: 0.5em;
height: 1.05em;
margin-left: 2px;
vertical-align: text-bottom;
background: var(--ink);
border-radius: 1px;
animation: caret-blink 0.9s steps(1) infinite;
+4 -196
View File
@@ -19,16 +19,6 @@ export interface SessionState {
workspace?: string
/** 权限模式(Composer 权限 pill / 审批行为) */
permissionMode?: PermissionMode
/** 当前思考等级(pi ThinkingLevel) */
thinkingLevel?: ThinkingLevel
/** 当前模型可用的思考等级(供切换器渲染) */
thinkingLevels?: ThinkingLevel[]
/** 当前模型是否支持思考 */
supportsThinking?: boolean
/** 正在压缩上下文 / 生成分支摘要 */
isCompacting?: boolean
/** 自动压缩(阈值/溢出触发)是否开启 */
autoCompactionEnabled?: boolean
}
/** 工作区 = 一个本地目录(agent 的工作目录,会话按目录隔离) */
@@ -37,56 +27,11 @@ export interface WorkspaceMeta {
path: string
}
/** 思考等级(pi:off 关闭推理,max 最高) */
export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
/** 上下文按类目的用量拆分(估算值,chars/4 启发式,与 pi 一致)。
* 类目对齐 ZCode 卡片:消息 / 自定义工具 / 系统工具 / 系统提示词 / 技能 / 其他 */
export interface ContextBreakdownEntry {
key: 'messages' | 'customTools' | 'builtinTools' | 'systemPrompt' | 'skills' | 'other'
label: string
tokens: number
}
/** 上下文容量(Usage Card 数据源,来自 session.getContextUsage + 消息拆分估算) */
export interface ContextUsageInfo {
/** 上下文 token 估算;null = 暂不可知(如刚压缩完、下一次响应前) */
tokens: number | null
contextWindow: number
/** 占上下文窗口百分比 0-100;tokens 未知时为 null */
percent: number | null
/** 上一轮 LLM 响应的输出 token(本次响应预留量),取不到时省略 */
lastOutputTokens?: number
breakdown: ContextBreakdownEntry[]
}
export interface SessionStatsInfo {
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }
cost: number
/** 上下文占用百分比 0-100,取不到时省略 */
contextPercent?: number
/** 上下文容量详情(Usage Card) */
context?: ContextUsageInfo
/** 缓存命中率 0-100:cacheRead / (input + cacheRead + cacheWrite),无数据时省略 */
cacheHitRate?: number
/** 会话累计的缓存相关 token(命中率明细展示) */
cacheTokens?: { read: number; write: number; input: number }
/** 工具调用统计(成功率按 toolResult 是否出错计) */
toolStats?: { calls: number; successRate: number }
}
/** 会话树上的可回溯点(用户消息,与当前分支上的用户消息按顺序一一对应) */
export interface ForkPoint {
entryId: string
text: string
}
/** 分支回溯选项 */
export interface NavigateTreeOptions {
/** true = 为被离开的分支生成分支摘要(pi branch summarization) */
summarize?: boolean
/** 摘要的自定义指令(summarize 时生效) */
customInstructions?: string
}
/** 会话列表项(SessionManager.list 的映射) */
@@ -139,6 +84,8 @@ export interface CustomProviderConfig {
/** 应用级设置(存 userData/aso-settings.json) */
export interface AppSettings {
/** 覆盖 pi 默认系统提示词(空串 = 不覆盖) */
systemPrompt: string
/** 当前工作区目录(缺省 = 默认工作区 Default) */
workspace?: string
/** 通过目录选择器添加的根目录之外的工作区 */
@@ -147,13 +94,8 @@ export interface AppSettings {
permissionMode?: PermissionMode
/** 主题色预设 id(缺省 default,见 renderer lib/theme.ts) */
themeColor?: string
/** 深浅色模式(缺省 system 跟随系统;经 Electron nativeTheme 生效) */
themeMode?: ThemeMode
}
/** 深浅色模式:light / dark / system(跟随系统) */
export type ThemeMode = 'light' | 'dark' | 'system'
/** 权限模式:confirm 变更前确认 / full 完全访问 */
export type PermissionMode = 'confirm' | 'full'
@@ -209,14 +151,7 @@ export interface CommandDetail {
export interface ResourceDetails {
skills: SkillDetail[]
commands: CommandDetail[]
dirs: {
globalSkills: string
globalAgentsSkills: string
projectSkills: string
projectAgentsSkills: string
globalCommands: string
projectCommands: string
}
dirs: { globalSkills: string; projectSkills: string; globalCommands: string; projectCommands: string }
}
/** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */
@@ -247,78 +182,10 @@ export interface SendImage {
* 转发到渲染进程的 agent 事件。
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
*/
// ===== 内置受管浏览器 =====
/** 浏览器标签页(主进程 → 渲染层面板) */
export interface BrowserTabInfo {
tabId: string
url: string
title: string
loading: boolean
}
/** 最近关闭的浏览器页签(页签栏「最近关闭的标签页」用) */
export interface BrowserClosedTab {
title: string
url: string
}
/** 已导入的本地浏览器配置摘要(面板展示用) */
export interface BrowserImportInfo {
browser: string
profile: string
at: string
cookies: boolean
localStorage: boolean
note?: string
}
/** 浏览器面板完整状态(事件与查询共用同一结构) */
export interface BrowserUiState {
/** 是否存在受管浏览器会话(有标签页即视为打开) */
open: boolean
/** 风险告知是否已确认(首次使用门禁) */
riskAcknowledged: boolean
tabs: BrowserTabInfo[]
activeTabId: string | null
/** 最近关闭的页签(新的在前,最多 10 条) */
recentClosed: BrowserClosedTab[]
/** 最近一次 Agent 浏览器操作描述(面板底部轨迹) */
trace: string | null
imported?: BrowserImportInfo | null
}
export type BrowserUiEvent = { type: 'state'; state: BrowserUiState }
/** 本地浏览器(Chromium 系)下的一个 profile */
export interface BrowserProfileMeta {
/** profile 目录名(Default / Profile 1 …) */
dir: string
/** 展示名(Preferences 里的名称或目录名) */
name: string
}
/** 检测到的可导入浏览器来源 */
export interface BrowserSource {
id: string
name: string
userDataDir: string
profiles: BrowserProfileMeta[]
}
/** 导入结果(主进程返回;cookieNote 说明跳过原因) */
export interface BrowserImportResult {
ok: boolean
cookies: boolean
localStorage: boolean
cookieNote?: string
message: string
}
export type AgentUiEvent =
| { type: 'session_ready'; state: SessionState }
| { type: 'session_error'; message: string }
| { type: 'session_switched'; state: SessionState; messages: unknown[] }
| { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record<string, unknown> }
| { type: 'state'; state: SessionState }
| { type: 'approval_request'; request: ApprovalRequest }
| { type: 'approval_settled'; id: string; approved: boolean; timedOut: boolean }
@@ -336,62 +203,3 @@ export type AgentUiEvent =
| { type: 'error'; message: string }
| { type: 'session_info'; name?: string }
| { type: 'stats'; stats: SessionStatsInfo }
| { type: 'thinking_level_changed'; level: ThinkingLevel }
| { type: 'compaction_start'; reason: 'manual' | 'threshold' | 'overflow' }
| {
type: 'compaction_end'
reason: 'manual' | 'threshold' | 'overflow'
aborted: boolean
/** 压缩摘要文本(aborted/失败时省略) */
summary?: string
/** 压缩前的上下文 token(用于展示释放量) */
tokensBefore?: number
errorMessage?: string
/** 压缩后的最新消息列表(含 compactionSummary 通知行,替代整次 session_switched) */
messages: unknown[]
}
// ===== 插件系统(契约类型来自 @aso/plugin-sdk) =====
import type { PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
/** 插件条目(管理页/侧栏入口数据源) */
export interface PluginInfo {
manifest: PluginManifest
source: 'builtin' | 'external'
enabled: boolean
/** 有主进程工厂(pi 工具/事件/内联 UI 注册能力) */
hasMain: boolean
/** 有 iframe 插件页 */
hasPage: boolean
/** 有宿主树组件(仅内置;外置遮蔽需 replacesNative 声明) */
hasComponent: boolean
/** 装载/激活错误(不影响其他插件) */
error?: string
}
/** 状态胶囊条目(主进程单一事实源广播) */
export interface PluginStatusEntry {
pluginId: string
itemId: string
item: StatusItem
}
/** 工具栏按钮条目 */
export interface PluginToolbarEntry {
pluginId: string
buttonId: string
button: ToolbarButtonDef
}
/** 内联 UI 快照(plugins:uiState 事件与查询共用) */
export interface PluginsUiState {
status: PluginStatusEntry[]
toolbar: PluginToolbarEntry[]
}
/** 深链指令(主进程 openView / 插件页桥 → 渲染层切换视图) */
export interface PluginViewChange {
view: string
params?: unknown
}
-12
View File
@@ -67,15 +67,3 @@
- ✅ Electron 桌面端(本仓库 `apps/desktop`)
- ⛔ NestJS 后端、Ant Design Pro 后台、PostgreSQL —— 未设计,不创建、不实现
- 业务插件中依赖服务器 API 的部分,先留接口占位(不依赖后端即可运行的本地能力可先行)
## 5. D7 动态插件系统(两层能力模型)
harness 收敛为「壳 + 聊天 + 插件系统」,业务功能以插件交付(详见 docs/plugins.md):
- **两层能力**:内置插件(代码注册,宿主树组件 + 第一方工厂 + 全量宿主能力,浏览器面板即首个实例)/ 外置插件(`userData/plugins/`,iframe 隔离页 + 动态 import 的 main.mjs + 仅 SDK 桥接 API)。同 id 高 semver 胜、外置可遮蔽内置(组件级需 `replacesNative` 声明)→ **内置插件也能在线热更而不发安装包**。
- **pi 原生契约**:插件工厂收到 `{ pi, ui, storage }`——`pi` 为真实 `ExtensionAPI`,工具/事件/命令/消息走 `pi.registerTool` 等原生路径(harness 挂一个聚合 extension 扇出);`ui` 为 Electron 版槽位(对位 pi TUI 的 `ctx.ui.*`):侧栏入口/整页视图/面板 tab/状态胶囊/深链,内联 UI 一律数据驱动。
- **iframe 隔离的动机**:外置 UI 与宿主无 React 实例共享问题、插件崩溃不拖垮宿主、热重载 = 换 src;代价(跨桥交互、主题靠 tokens.css 连续)已接受。`plugin://`/`peer://` 特权协议 + CSP `frame-src plugin:` + 路径穿越防护 + origin/source 双重校验。
- **SDK 即 npm 包**(`packages/plugin-sdk`,Obsidian 模式):类型 + `definePlugin` + 页面桥 client(postMessage 白名单 invoke)+ hooks;仓库根 pnpm workspace(apps/* + packages/*)。
- **在线更新(M5)**:索引检查 → 下载 → ed25519 验签(公钥钉 harness)→ minContract 兼容检查 → 原子替换 → 热重载;自动/手动并存。
- **pi 原生目录(M7)**:pi resource-loader 默认已加载 `~/.pi/agent/extensions`(事实),M7 补开关与可见性管控。
-115
View File
@@ -1,115 +0,0 @@
# 插件系统(harness 插件契约)
> 状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap)
> 契约版本:`HARNESS_CONTRACT_VERSION = 0.1.0`(`src/main/plugins/plugin-host.ts`;manifest.minContract 与之比对,不满足拒载)
## 两层能力模型
| | 内置插件 | 外置插件 |
|---|---|---|
| 位置 | 代码注册(`src/main/plugins/builtin/index.ts`),UI 组件编译进宿主 bundle | `userData/plugins/<id>/`(manifest.json + 页面资产 + main.mjs) |
| UI 形态 | 宿主树 React 组件(`renderer/src/plugins/builtin-components.ts`,key=插件 id) | iframe 页 `plugin://<id>/<entry.page>`(自带 React,物理隔离) |
| main 侧 | 第一方工厂(不动态 import) | 动态 `import()`(cache-bust 重载) |
| 宿主能力 | 全量(BrowserSlot/原生视图协调/window.api) | 仅 SDK 桥接 API(postMessage,白名单 invoke) |
| 信任 | 随包分发,免签名 | 签名校验(M5 接入;当前未签名也可装载——TODO) |
| 管理 | 同一份 manifest/生命周期/管理页(可禁用) | 同左 + zip 导入/在线更新(M4/M5) |
同 id 冲突:高 semver 胜、平级外置胜;**外置遮蔽"组件级"内置必须声明 `replacesNative: true`**(能力降级声明,防原生视图能力被页面级外置静默顶掉)。
## manifest.json
```json
{
"id": "hotwords", // ^[a-z][a-z0-9-]*$
"name": "热点词",
"version": "1.2.0", // semver(x.y.z)
"minContract": "0.1.0",
"description": "…",
"icon": "<svg…/>", // 侧栏/面板图标(svg 字符串;宿主清除事件属性与 script)
"order": 10, // 侧栏排序(只在插件间;核心入口恒在前)
"panel": true, // 在右侧 SidePanel 出现一个 tab
"entry": { "page": "index.html", "main": "main.mjs" }, // 内置插件省略 entry
"replacesNative": false,
"updateURL": "https://…/index.json"
}
```
## 主进程插件契约(`@aso/plugin-sdk` 主入口)
```ts
import { definePlugin } from '@aso/plugin-sdk'
export default definePlugin((api) => {
// pi 原生扩展 API:工具/事件/命令/消息(语义与 pi CLI 扩展一致)
api.pi.registerTool(myTool)
api.pi.on('agent_end', () => { /* … */ })
// harness UI 槽位(数据驱动)
api.ui.setStatusItem('task', { text: '任务 3/5', tone: 'accent', openView: { view: 'hotwords', params: { id: 1 } } })
api.ui.registerToolbarButton({ id: 'gen', title: '生成标题', openView: { view: 'hotwords' } })
api.ui.openView('hotwords', { tab: 2 }) // 深链打开插件页
// 插件私有 KV(userData/aso-plugin-storage.json,按 pluginId 隔离)
api.storage.set('lastQuery', 'xxx')
return () => { /* dispose:宿主在 session_shutdown / 禁用 / 重载时调用 */ }
})
```
实现:harness 在 pi `extensionFactories` 挂**一个聚合 extension**,工厂内创建真实 `ExtensionAPI` 上下文后扇出给所有已启用插件——工具注册走 pi 原生路径(提示词注入、审批拦截、事件时序由 pi 管)。
## 插件页(iframe)契约(`@aso/plugin-sdk/client`)
```ts
import { getHarnessClient, useHarnessTheme, useDeepLinkParams } from '@aso/plugin-sdk/client'
const harness = getHarnessClient('hotwords')
harness.openView('chat') // 深链返回聊天/其他插件
harness.setStatus('task', { text: '进行中' }) // 状态胶囊(写主进程单一事实源)
harness.invoke('getState') // 白名单:getState / send / getSettings
useDeepLinkParams('hotwords') // openView 携带的参数(含挂起重放)
useHarnessTheme('hotwords') // { dark, accent }(深浅色/accent 变化推送)
```
- 通信:postMessage(页 → `window.parent`;宿主 → iframe.contentWindow)
- 安全:宿主校验 `event.source === iframe.contentWindow` **且** `event.origin === 'plugin://<id>'`;sandbox 无 `allow-top-navigation`;iframe 页**无** window.api 直达
- 主题连续性:插件页 `<link rel="stylesheet" href="peer://assets/tokens.css">`(宿主 beautifului.css 的 token 表,深浅色由 `.dark` 类 + 下发的 accent 生效);建议插件自带 Tailwind 按同 token 编译
## UI 槽位总览(对位 pi 的 ctx.ui)
| 槽位 | 机制 | 状态 |
|---|---|---|
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
| 插件整页 Main | 内置组件级 / 外置 iframe;聊天列 display:none 保活 | ✅ |
| 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件或 iframe) | ✅ |
| 状态胶囊(B2) | `setStatusItem` 数据驱动,TitleBar 渲染,两种视图均可见 | ✅ |
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
| 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 |
| 聊天区浮动胶囊(B3) | 与 B2 同源数据,折叠/展开双态 | ⏳ 下版本 |
## 交互基线
- 整页替换:点插件入口 → 主列(聊天)隐藏保活;TitleBar 常驻(显示插件名 + 窗口控制 + 胶囊)
- 返回聊天:点会话/新会话/Ctrl+N(全局);Esc 只关弹窗、**不**切视图
- Ctrl+K / Ctrl+, 仅聊天视图;切回聊天自动重新吸底(ChatView `visible`)
- 侧栏"会话"区头徽标:插件视图期间提示代理活动(待审批 > 出错 > 流式中)
- 重载:主进程 `plugins:reload`(重扫 manifest + 重导入 main.mjs + 清内联 UI + 重建 runtime);渲染层收到列表变化后 iframe 换 src 整页重载(= pi `/reload` 等效)
## 安全清单
- `plugin://` 特权协议:路径解析后必须落在该插件目录内(防穿越);`peer://` 仅暴露白名单资产(tokens.css)
- CSP:宿主页只加 `frame-src plugin:`(iframe 方案不动 script-src)
- 外置 main.mjs 全权运行于主进程(与 pi 扩展同信任级)——**签名校验前,仅自行放置的插件目录可装载**(M5 后强制)
## pi 原生目录(M7,未实现)
事实:pi resource-loader **默认自动加载** `~/.pi/agent/extensions/*.ts` 与 `<cwd>/.pi/extensions/*.ts`(`noExtensions` 可关)——即本应用当前已在运行用户为 pi CLI 安装的扩展(逻辑部分;其 TUI UI 调用按 pi 非 TUI 模式语义降级)。M7 计划:设置开关(默认关 → `noExtensions`)+ 管理页可见/单禁 + 参与统一重载。
## 开发工作流(外置插件,M6 模板落地后)
```
packages/plugin-template → npm i(workspace 引 @aso/plugin-sdk)
页面:vite build --watch → dist/ # 改即重编
宿主:开发者模式开 fs.watch → 自动 plugins:reload → iframe 换 src
```
+1 -22
View File
@@ -11,28 +11,7 @@
| v0.3.0 | 模型与设置 | 模型选择、Provider/API key 管理(写入 ~/.pi auth)、系统提示词自定义、连通性测试 |
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
| v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 |
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B3/B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
---
## v0.8.0 — 动态插件系统(M0–M3)
**交付**:
- 仓库根 pnpm workspace(apps/* + packages/*)+ `@aso/plugin-sdk`(类型 + definePlugin + 页面桥 client/hooks;源码形态被 workspace 消费,无构建步骤)
- 主进程 `PluginHost`:内置(代码注册)+ 外置(`userData/plugins/`,manifest 校验/契约版本检查/main.mjs 动态 import)双目录合并(高 semver 胜、外置遮蔽组件级内置需 replacesNative);聚合 InlineExtension 扇出真实 pi `ExtensionAPI`;内联 UI(状态胶囊/工具栏按钮)主进程单一事实源 + 事件广播;插件 KV 存储;`plugin://`(路径穿越防护)/`peer://`(tokens.css)特权协议;CSP 加 `frame-src plugin:`;插件启停/重载联动 runtime 重建
- 渲染层:插件列表/内联 UI hooks;`PluginPageFrame`(iframe + 桥宿主侧:origin+source 双校验、白名单 invoke、主题下发、深链参数、热重载换 src);`PluginErrorBoundary`;`PluginStatusCapsules`(TitleBar 槽,两视图可见);App 视图切换(activeView{view,params}、聊天列 display:none 保活、Ctrl+N 全局/Ctrl+K,Ctrl+, 仅聊天、插件禁用自动回落);SidebarNav 插件入口 + 会话区头代理活动徽标(待审批>出错>流式);SidePanel 零改动承载插件 tab
- 浏览器面板 → 内置插件:browserTools 迁 `pi.registerTool`(customTools 摘除)、面板 tab 按启用态门控、状态胶囊示范("浏览器 N 页")
- 验证:typecheck/build 通过(含 SDK 打包、tokens.css 构建期拷贝);dev 启动截图核验(聊天布局无回归、浏览器面板正常、无报错)
- 关键事实澄清:pi resource-loader **默认自动加载** `~/.pi/agent/extensions`——M7(原生目录管控)从"建设加载"降为"开关+可见性"
**自省**(v0.8.0 → 指导 M4+):
1. **外置插件尚未强制签名**——当前 `userData/plugins/` 直接装载(代码里已留 TODO);M5 验签落地前不对外分发任何外置插件。
2. **B3/B4/B5 槽位数据已通、渲染未接**——状态胶囊已在 TitleBar 生效;Composer 工具行按钮、聊天流围栏块元组件(data-table 等)、聊天区浮动胶囊排 M4 同批或下版本。
3. **切换回聊天未自动聚焦输入框**——保活后焦点丢失,需 Composer 暴露 focus 入口(data 属性 + App effect)。
4. **插件管理 UI 仍占位**——SettingsModal"插件"tab 转正(列表/启停/重载/导入)排 M4。
5. **TS7(typescript 7 native)增量缓存坑**——改 `declare global` 后 typecheck 可能读到旧程序,删 `out/tsconfig.{node,web}/` 下 tsbuildinfo 即恢复。
6. **workspace 迁移副作用**——根 workspace 化后 electron postinstall 偶发不跑("Electron uninstall"),手动 `node node_modules/.pnpm/electron@*/node_modules/electron/install.js` 修复;新克隆/重装后注意。
| 未来 | | electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、CDP 插件、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
---
-11
View File
@@ -1,11 +0,0 @@
{
"name": "@aso/harness",
"private": true,
"version": "0.7.0",
"description": "Inkreach Agent Harness(壳 + 聊天 + 插件系统;业务功能以插件形式交付)",
"scripts": {
"typecheck": "pnpm -r --if-present typecheck",
"build": "pnpm -r --if-present build",
"dev": "pnpm --dir apps/desktop dev"
}
}
-30
View File
@@ -1,30 +0,0 @@
{
"name": "@aso/plugin-sdk",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Inkreach harness 插件契约:清单/主进程工厂类型 + definePlugin + 插件页桥接 client(主入口给 main.mjs 作者,./client 给 iframe 页作者)",
"exports": {
".": "./src/index.ts",
"./client": "./src/page.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.0"
},
"peerDependencies": {
"react": ">=19"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"devDependencies": {
"@types/react": "19.2.18",
"react": "19.2.8",
"typescript": "7.0.2"
}
}
-158
View File
@@ -1,158 +0,0 @@
/**
* 插件页(iframe)侧桥接 client。
* 用法(插件页入口):`import { getHarnessClient } from '@aso/plugin-sdk/client'`
* 通信:postMessage —— 页 → window.parent;宿主 → iframe.contentWindow。
* 宿主严格校验消息来源 origin(plugin://<id>);invoke 仅暴露白名单方法。
*/
import type { OpenViewAction, StatusItem } from './status'
/** 桥协议版本(宿主与插件页握手比对;不匹配时宿主拒绝 invoke) */
export const HARNESS_BRIDGE_VERSION = 1
/** 宿主 → 插件页 */
export type HostToPluginMessage =
| { v: number; type: 'harness:hello'; contract: string }
| { v: number; type: 'harness:theme'; dark: boolean; accent?: string }
| { v: number; type: 'harness:deep-link'; params?: unknown }
| { v: number; type: 'harness:agent'; event: HarnessAgentEvent }
| { v: number; type: 'harness:invoke-result'; seq: number; ok: true; value: unknown }
| { v: number; type: 'harness:invoke-result'; seq: number; ok: false; error: string }
/** 插件页 → 宿主 */
export type PluginToHostMessage =
| { v: number; type: 'plugin:hello'; pluginId: string }
| { v: number; type: 'plugin:open-view'; view: string; params?: unknown }
| { v: number; type: 'plugin:set-status'; id: string; item: StatusItem | null }
| { v: number; type: 'plugin:invoke'; seq: number; method: string; args: unknown[] }
/** 宿主中继的 agent 事件(宽松类型:具体事件形状由宿主版本决定,插件页防御性读取) */
export interface HarnessAgentEvent {
type: string
[key: string]: unknown
}
/** invoke 白名单(宿主桥逐方法放行;后续按需扩充) */
export interface HarnessInvokeMap {
getState(): unknown
send(text: string, images?: { mimeType: string; data: string }[]): void
getSettings(): unknown
}
export interface HarnessTheme {
dark: boolean
accent?: string
}
export interface HarnessClient {
/** 握手完成(收到 harness:hello)后 resolve */
ready: Promise<void>
/** 宿主契约版本(ready 后可用) */
contractVersion(): string
/** 深链打开视图 */
openView(view: string, params?: unknown): void
/** 写/清一条状态胶囊(item=null 移除) */
setStatus(id: string, item: StatusItem | null): void
/** 深链参数:页面被 openView 打开/更新时回调(含挂起期间的一次重放) */
onDeepLink(cb: (params: unknown) => void): () => void
/** 主题变化(深浅色/accent) */
onTheme(cb: (theme: HarnessTheme) => void): () => void
/** agent 事件中继(宿主挑选的子集:state/agent_start/agent_end/stats…) */
onAgentEvent(cb: (event: HarnessAgentEvent) => void): () => void
/** 调用宿主白名单 API */
invoke<M extends keyof HarnessInvokeMap>(
method: M,
...args: Parameters<HarnessInvokeMap[M]>
): Promise<Awaited<ReturnType<HarnessInvokeMap[M]>>>
}
type DeepLinkCb = (params: unknown) => void
type ThemeCb = (theme: HarnessTheme) => void
type AgentCb = (event: HarnessAgentEvent) => void
let cached: HarnessClient | null = null
/** 获取桥接 client(单例;首个调用即开始握手) */
export function getHarnessClient(pluginId: string): HarnessClient {
if (cached) return cached
let contract = ''
let resolveReady: (() => void) | null = null
const ready = new Promise<void>((resolve) => {
resolveReady = resolve
})
const deepLinkCbs = new Set<DeepLinkCb>()
const themeCbs = new Set<ThemeCb>()
const agentCbs = new Set<AgentCb>()
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
let seq = 0
window.addEventListener('message', (event: MessageEvent) => {
const msg = event.data as HostToPluginMessage | undefined
if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string' || !msg.type.startsWith('harness:')) return
switch (msg.type) {
case 'harness:hello':
contract = msg.contract
resolveReady?.()
resolveReady = null
break
case 'harness:theme':
for (const cb of themeCbs) cb({ dark: msg.dark, accent: msg.accent })
break
case 'harness:deep-link':
for (const cb of deepLinkCbs) cb(msg.params)
break
case 'harness:agent':
for (const cb of agentCbs) cb(msg.event)
break
case 'harness:invoke-result': {
const waiter = pending.get(msg.seq)
if (!waiter) break
pending.delete(msg.seq)
if (msg.ok) waiter.resolve(msg.value)
else waiter.reject(new Error(msg.error))
break
}
default:
break
}
})
const post = (msg: PluginToHostMessage): void => {
// 宿主 origin 随运行模式不同(file:// 或 dev server),用 '*' 发送;
// 方向安全由宿主侧的 origin 白名单校验保证
window.parent?.postMessage({ ...msg, v: HARNESS_BRIDGE_VERSION }, '*')
}
const invokeRaw = (method: string, ...args: unknown[]): Promise<unknown> =>
new Promise((resolve, reject) => {
const id = ++seq
pending.set(id, { resolve, reject })
post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:invoke', seq: id, method, args })
})
post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:hello', pluginId })
cached = {
ready,
contractVersion: () => contract,
openView: (view, params) => post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:open-view', view, params }),
setStatus: (id, item) => post({ v: HARNESS_BRIDGE_VERSION, type: 'plugin:set-status', id, item }),
onDeepLink(cb) {
deepLinkCbs.add(cb)
return () => void deepLinkCbs.delete(cb)
},
onTheme(cb) {
themeCbs.add(cb)
return () => void themeCbs.delete(cb)
},
onAgentEvent(cb) {
agentCbs.add(cb)
return () => void agentCbs.delete(cb)
},
invoke: invokeRaw as HarnessClient['invoke']
}
return cached
}
export type { OpenViewAction, StatusItem }
-47
View File
@@ -1,47 +0,0 @@
/**
* 插件页 React hooks(peer react,可选使用)。
* import { useHarnessTheme, useDeepLinkParams } from '@aso/plugin-sdk/client'
*/
import { useEffect, useState } from 'react'
import { getHarnessClient, type HarnessAgentEvent, type HarnessTheme } from './client'
/** 当前主题(握手完成前为 null) */
export function useHarnessTheme(pluginId: string): HarnessTheme | null {
const [theme, setTheme] = useState<HarnessTheme | null>(null)
useEffect(() => {
const client = getHarnessClient(pluginId)
let latest: HarnessTheme | null = null
const off = client.onTheme((t) => {
latest = t
setTheme(t)
})
void client.ready.then(() => setTheme(latest))
return off
}, [pluginId])
return theme
}
/** 最近一次深链参数(页面被 openView 打开/更新时变化;无深链为 undefined) */
export function useDeepLinkParams(pluginId: string): unknown {
const [params, setParams] = useState<unknown>(undefined)
useEffect(() => {
const client = getHarnessClient(pluginId)
const off = client.onDeepLink(setParams)
return off
}, [pluginId])
return params
}
/** 订阅宿主中继的 agent 事件(handler 变化不重订阅) */
export function useAgentEvent(pluginId: string, handler: (event: HarnessAgentEvent) => void): void {
useEffect(() => {
const client = getHarnessClient(pluginId)
const ref = { handler }
const off = client.onAgentEvent((event) => ref.handler(event))
return () => {
ref.handler = () => undefined
off()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginId])
}
-7
View File
@@ -1,7 +0,0 @@
/**
* @aso/plugin-sdk 主入口(插件 main.mjs / 内置插件工厂使用)。
* 插件页(iframe)请改用 '@aso/plugin-sdk/client' 子入口(不含 pi SDK 类型依赖路径)。
*/
export * from './manifest'
export * from './status'
export * from './plugin'
-37
View File
@@ -1,37 +0,0 @@
/**
* 插件清单类型(插件根目录 manifest.json,内置/外置共用一份契约)。
* 本模块不得引入 pi SDK 或 React 类型——它同时被主进程与插件页消费。
*/
/** 插件 id 命名规则:小写字母开头,小写字母/数字/连字符 */
export const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9-]*$/
export interface PluginManifest {
/** 唯一 id(内置/外置同 id 时高 semver 胜、平级外置胜) */
id: string
name: string
/** semver;在线更新与遮蔽判定依据 */
version: string
/** 最低 harness 契约版本(semver),不满足拒载 */
minContract: string
description?: string
/** 侧栏入口图标:svg 字符串;缺省用宿主默认字形 */
icon?: string
/** 侧栏入口排序(升序,只在插件之间排序;核心入口恒在前) */
order?: number
/** 在右侧 SidePanel 出现为一个 tab */
panel?: boolean
/** 外置插件入口(相对插件根的路径)。
* 内置插件留空:UI 组件与 main 工厂编译进宿主 bundle。 */
entry?: {
/** 插件页入口(iframe 加载 `plugin://<id>/<page>`) */
page?: string
/** 主进程模块(动态 import,默认导出 definePlugin 工厂) */
main?: string
}
/** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明,
* 防止带原生视图能力的内置被页面级外置静默顶掉) */
replacesNative?: boolean
/** 更新索引地址(JSON;格式见 docs/plugins.md) */
updateURL?: string
}
-3
View File
@@ -1,3 +0,0 @@
/** '@aso/plugin-sdk/client' 子入口:插件页(iframe)桥接 client + hooks */
export * from './client'
export * from './hooks'
-44
View File
@@ -1,44 +0,0 @@
/**
* 主进程侧插件契约(main.mjs / 内置插件工厂共用)。
* 生命周期对齐 pi 原生扩展:工厂函数收到 api,返回 dispose(可选)。
*/
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
import type { PluginManifest } from './manifest'
import type { StatusItem, ToolbarButtonDef } from './status'
/** 插件私有 KV 存储(主进程落盘 userData/aso-plugin-storage.json,按 pluginId 命名空间隔离) */
export interface PluginStorage {
get<T>(key: string): T | undefined
set(key: string, value: unknown): void
delete(key: string): void
}
/** harness 侧 UI 能力(Electron 版槽位,语义对位 pi TUI 的 ctx.ui.*)。
* 内联槽位全部数据驱动;插件页内 UI 由插件页自理,不经此 API。 */
export interface HarnessUiApi {
/** 打开视图('chat' 或插件 id),携可选深链参数 */
openView(view: string, params?: unknown): void
/** 写/更新一条状态胶囊(按 id 键控;插件 dispose 时宿主自动清理其全部条目) */
setStatusItem(id: string, item: StatusItem): void
removeStatusItem(id: string): void
/** 注册输入区工具行按钮(按 id 键控;dispose 自动清理) */
registerToolbarButton(button: ToolbarButtonDef): void
removeToolbarButton(id: string): void
}
/** 插件工厂收到的完整 API */
export interface HarnessPluginApi {
/** pi 原生扩展 API:工具/事件/命令/消息全走这里(pi.registerTool 等,语义与 pi CLI 扩展一致) */
pi: ExtensionAPI
ui: HarnessUiApi
storage: PluginStorage
manifest: PluginManifest
}
/** 插件工厂:同步或异步返回 dispose */
export type HarnessPluginFactory = (api: HarnessPluginApi) => void | (() => void) | Promise<void | (() => void)>
/** 身份函数:为插件作者提供完整类型推断 */
export function definePlugin(factory: HarnessPluginFactory): HarnessPluginFactory {
return factory
}
-29
View File
@@ -1,29 +0,0 @@
/**
* 内联槽位的数据类型(B2 状态胶囊 / B5 工具栏按钮)。
* 契约原则:内联 UI 一律"数据 + 深链",不注册组件——外置插件只有
* iframe 页有能力渲染自己的 React;宿主统一按数据渲染这些小 UI。
*/
/** 深链:点击行为 = 打开某个视图('chat' 或插件 id)并携带参数 */
export interface OpenViewAction {
view: string
params?: unknown
}
/** 状态胶囊条目(TitleBar 胶囊区 / 聊天区浮动胶囊共用一份数据) */
export interface StatusItem {
text: string
tone?: 'default' | 'accent' | 'warn' | 'danger'
/** 点击行为:深链;缺省不可点击 */
openView?: OpenViewAction
}
/** 输入区工具行按钮 */
export interface ToolbarButtonDef {
id: string
title: string
/** svg 字符串(16-20px 线条风格,宿主按 currentColor 渲染) */
icon?: string
/** 点击行为:深链;缺省不可点击 */
openView?: OpenViewAction
}
-15
View File
@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
-6499
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -1,10 +0,0 @@
packages:
- apps/*
- packages/*
onlyBuiltDependencies:
- '@google/genai'
- electron
- electron-winstaller
- esbuild
- protobufjs