Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
665f848c93 | ||
|
|
200b9877ec | ||
|
|
ad1f9e76e5 | ||
|
|
9998bb7a0c | ||
|
|
d2fd6807aa | ||
|
|
8866438005 | ||
|
|
ef07c2db2c | ||
|
|
f1cbe764bb | ||
|
|
6098cf678a | ||
|
|
7103675db8 | ||
|
|
1edaf17623 | ||
|
|
823dd26a96 | ||
|
|
aadd3adb2e | ||
|
|
1c8e9816fe | ||
|
|
835b910d42 | ||
|
|
f77942ee2b | ||
|
|
4ed31a34fc | ||
|
|
8e4b5496e8 | ||
|
|
2ad1743e10 | ||
|
|
8349db9947 | ||
|
|
3f0a2a975c | ||
|
|
538c25ab37 | ||
|
|
71c91563e8 | ||
|
|
8b94d42930 | ||
|
|
52f93ddc84 | ||
|
|
a245952417 | ||
|
|
d2381488a5 | ||
|
|
fdb5fe2ade | ||
|
|
e8cca122f8 | ||
|
|
903d393e09 | ||
|
|
0bae7cdc22 | ||
|
|
cafb6f34de | ||
|
|
8216d70fcf | ||
|
|
4c5a379ca1 | ||
|
|
ddecaef4ef | ||
|
|
8df8a0e3d1 | ||
|
|
46813c23cd | ||
|
|
367f3f5c5b | ||
|
|
2b69a9aa13 | ||
|
|
2622487279 | ||
|
|
bb0aedd27c | ||
|
|
b9bb72ada9 | ||
|
|
3f9d79ca30 | ||
|
|
24df36a9b4 | ||
|
|
e7da0f912e | ||
|
|
adf3c3e118 | ||
|
|
2ea60a4b73 | ||
|
|
c2f9092c8c | ||
|
|
45cd2b8af8 | ||
|
|
0b199c485b | ||
|
|
a18fa55d4e | ||
|
|
8cf5f408f6 | ||
|
|
8d2730b3ec | ||
|
|
37b0462836 | ||
|
|
55fe7d1024 | ||
|
|
d6e38f391a | ||
|
|
c584508b94 | ||
|
|
f3dcd3f597 | ||
|
|
9074d15deb | ||
|
|
d1cee518fa | ||
|
|
55f9838bcb | ||
|
|
7565b8676f | ||
|
|
54b99a1a58 | ||
|
|
29eb566059 | ||
|
|
eafbe8c15d |
@@ -1,10 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
git workspace 位于 `.worktrees` 之下,可能看到:
|
||||
|
||||
- release 分支:发布分支,用户能够使用的
|
||||
- harness 分支:仅保留 harness 功能
|
||||
- harness-feature 分支:harness-feature-{功能名称}
|
||||
- develop-feature 分支:develop-feature-{功能名称}
|
||||
|
||||
根目录一般位于 main 或者 develop 分支上
|
||||
@@ -1,7 +1,27 @@
|
||||
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: {
|
||||
@@ -45,7 +65,20 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
plugins: [react(), tailwindcss()],
|
||||
// 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, '../..')] }
|
||||
},
|
||||
optimizeDeps: {
|
||||
// sdk 是源码形态(exports 指向 .ts/.tsx),必须走源码 transform 而非预打包
|
||||
exclude: ['@aso/plugin-sdk']
|
||||
},
|
||||
plugins: [react(), tailwindcss(), copyTokensCss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src/renderer/src'),
|
||||
|
||||
Generated
-5624
File diff suppressed because it is too large
Load Diff
@@ -30,12 +30,15 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 回合事件时序冒烟:验证 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)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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)))
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 受管浏览器的 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'])
|
||||
|
||||
/** browser_find_text 单次返回的候选上限(DOM 直查不受快照配额约束,巨页防爆量) */
|
||||
export const MAX_BROWSER_FIND_TEXT_RESULTS = 40
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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 ? '若登录态未生效,重启应用后重试。' : ''
|
||||
}`
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
import { app, BrowserWindow, ipcMain, Menu, shell } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, Menu, nativeTheme, protocol, 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'
|
||||
import { getLogger, initLogger } from './logger'
|
||||
|
||||
initLogger()
|
||||
const log = getLogger('main')
|
||||
|
||||
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')
|
||||
|
||||
@@ -12,6 +26,7 @@ export const RENDERER_DIST = join(process.env.APP_ROOT, 'renderer')
|
||||
const APP_ICON = join(app.getAppPath(), 'build', 'icon.ico')
|
||||
|
||||
const agentService = registerIpc()
|
||||
log.info('main init', { version: app.getVersion(), packaged: app.isPackaged, devServer: VITE_DEV_SERVER_URL ?? '-' })
|
||||
|
||||
/** 窗口控制(自绘标题栏按钮走这三个 IPC;不使用系统 WCO,
|
||||
* 否则 overlay 的原生白色背景会盖住标题栏分隔线) */
|
||||
@@ -31,13 +46,16 @@ 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: '#ffffff',
|
||||
backgroundColor: dark ? '#212121' : '#ffffff',
|
||||
title: 'Inkreach Agent',
|
||||
// 窗口/任务栏图标(Windows/Linux;macOS 打包时由 build 配置提供 icns)
|
||||
...(existsSync(APP_ICON) ? { icon: APP_ICON } : {}),
|
||||
@@ -51,6 +69,11 @@ 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)
|
||||
@@ -63,17 +86,28 @@ async function createWindow(): Promise<void> {
|
||||
}
|
||||
})
|
||||
|
||||
// ready-to-show 在 Electron 中可能不止一次触发(实测:回合中途首次挂载受管浏览器
|
||||
// WebContentsView 后会再次发出)。会话初始化只做一次——但失败后放开门闩,
|
||||
// 让后续的重复触发承担重试职责(该事件至少会再来一次)。
|
||||
let sessionInitialized = false
|
||||
win.on('ready-to-show', () => {
|
||||
win.show()
|
||||
if (sessionInitialized) return
|
||||
sessionInitialized = true
|
||||
// pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现
|
||||
agentService.ensureReady().catch((err) => {
|
||||
console.error('[main] pi session init failed:', err)
|
||||
agentService.ensureReady().catch((err: unknown) => {
|
||||
sessionInitialized = false
|
||||
log.error('pi session init failed', { err })
|
||||
})
|
||||
})
|
||||
|
||||
// 窗口关闭时未决审批一律拒绝,避免 agent 永久挂起
|
||||
// 受管浏览器的原生 WebContentsView 挂在主窗口 contentView 上
|
||||
getBrowserController().setOwnerWindow(win)
|
||||
|
||||
// 窗口关闭时未决审批一律拒绝,避免 agent 永久挂起;受管浏览器同步清理
|
||||
win.on('closed', () => {
|
||||
agentService.approvals.settleAll(false)
|
||||
getBrowserController().handleWindowClosed()
|
||||
})
|
||||
|
||||
// 外部链接交给系统默认浏览器,不在应用内开新窗口
|
||||
@@ -82,15 +116,17 @@ async function createWindow(): Promise<void> {
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// 渲染层日志/加载失败转发到主进程 stdout,便于排查白屏/黑屏问题
|
||||
// 渲染层日志(含插件 renderer 模块 / iframe 页的 console)统一归档到日志文件
|
||||
win.webContents.on('console-message', (details) => {
|
||||
console.log(`[renderer:${details.level}] ${details.message} (${details.sourceId}:${details.lineNumber})`)
|
||||
const level = details.level === 'error' ? 'error' : details.level === 'warning' ? 'warn' : 'debug'
|
||||
if (level === 'debug' && !VITE_DEV_SERVER_URL) return
|
||||
log[level]('renderer console', { message: details.message, source: `${details.sourceId}:${details.lineNumber}` })
|
||||
})
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
console.log(`[renderer:load-fail] ${code} ${desc} ${url}`)
|
||||
log.error('renderer load failed', { code, desc, url })
|
||||
})
|
||||
win.webContents.on('render-process-gone', (_e, details) => {
|
||||
console.log(`[renderer:gone] ${details.reason}`)
|
||||
log.error('renderer gone', { reason: details.reason })
|
||||
})
|
||||
if (process.env.ASO_DEVTOOLS === '1') {
|
||||
win.webContents.openDevTools({ mode: 'detach' })
|
||||
@@ -121,6 +157,14 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.setAppUserModelId('com.inkreach.agent')
|
||||
}
|
||||
Menu.setApplicationMenu(null)
|
||||
log.info('app ready')
|
||||
|
||||
// 插件宿主:扫描/装载 + 协议映射 + 插件启停后重建 agent 运行时
|
||||
const pluginHost = getPluginHost()
|
||||
pluginHost.init()
|
||||
pluginHost.registerProtocols()
|
||||
pluginHost.setRuntimeRebuilder(() => agentService.rebuildRuntime())
|
||||
|
||||
void createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
+265
-40
@@ -1,7 +1,30 @@
|
||||
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
||||
import { mkdirSync, readFileSync } 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'
|
||||
import { getLogger } from './logger'
|
||||
|
||||
const log = getLogger('ipc')
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/** ipcMain.handle 追踪包装:成功 debug(含耗时)、失败 error(含通道/耗时)——Agent 自诊断的数据源之一 */
|
||||
function handle(channel: string, listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any): void {
|
||||
ipcMain.handle(channel, async (event, ...args: any[]) => {
|
||||
const started = Date.now()
|
||||
try {
|
||||
const result = await listener(event, ...args)
|
||||
log.debug('ipc ok', { channel, ms: Date.now() - started })
|
||||
return result
|
||||
} catch (err) {
|
||||
log.error('ipc failed', { channel, ms: Date.now() - started, err })
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* 注册 IPC 通道并把 agent 事件转发到所有渲染窗口。
|
||||
@@ -18,7 +41,7 @@ export function registerIpc(): AgentService {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:send', (_event, text: unknown, images: unknown) => {
|
||||
handle('agent:send', (_event, text: unknown, images: unknown) => {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new Error('empty prompt')
|
||||
}
|
||||
@@ -34,49 +57,49 @@ export function registerIpc(): AgentService {
|
||||
return service.send(text, sendImages)
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:steer', (_event, text: unknown) => {
|
||||
handle('agent:steer', (_event, text: unknown) => {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new Error('empty steer text')
|
||||
}
|
||||
return service.steer(text)
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:stop', () => service.stop())
|
||||
handle('agent:stop', () => service.stop())
|
||||
|
||||
ipcMain.handle('agent:state', () =>
|
||||
handle('agent:state', () =>
|
||||
service.getState().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
// ---- 会话管理 ----
|
||||
ipcMain.handle('sessions:list', () =>
|
||||
handle('sessions:list', () =>
|
||||
service.listSessions().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('sessions:new', () =>
|
||||
handle('sessions:new', () =>
|
||||
service.newSession().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('sessions:open', (_event, path: unknown) => {
|
||||
handle('sessions:open', (_event, path: unknown) => {
|
||||
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path')
|
||||
return service.openSession(path).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('sessions:rename', (_event, name: unknown) => {
|
||||
handle('sessions:rename', (_event, name: unknown) => {
|
||||
if (typeof name !== 'string' || !name.trim()) throw new Error('invalid session name')
|
||||
return service.renameSession(name.trim()).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('sessions:delete', (_event, path: unknown) => {
|
||||
handle('sessions:delete', (_event, path: unknown) => {
|
||||
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path')
|
||||
return service.deleteSession(path).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
@@ -89,12 +112,12 @@ export function registerIpc(): AgentService {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
|
||||
ipcMain.handle('models:catalog', () => pass(() => service.getModelCatalog()))
|
||||
handle('models:catalog', () => pass(() => service.getModelCatalog()))
|
||||
|
||||
// ---- 自定义 API 提供商(models.json)----
|
||||
ipcMain.handle('models:custom:list', () => service.listCustomProviders())
|
||||
handle('models:custom:list', () => service.listCustomProviders())
|
||||
|
||||
ipcMain.handle('models:custom:save', (_event, config: unknown) => {
|
||||
handle('models:custom:save', (_event, config: unknown) => {
|
||||
if (typeof config !== 'object' || config === null) throw new Error('invalid custom provider')
|
||||
const c = config as Record<string, unknown>
|
||||
if (
|
||||
@@ -117,56 +140,114 @@ export function registerIpc(): AgentService {
|
||||
return pass(() => service.saveCustomProvider(normalized))
|
||||
})
|
||||
|
||||
ipcMain.handle('models:custom:delete', (_event, id: unknown) => {
|
||||
handle('models:custom:delete', (_event, id: unknown) => {
|
||||
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid provider id')
|
||||
return pass(() => service.deleteCustomProvider(id))
|
||||
})
|
||||
|
||||
ipcMain.handle('models:set', (_event, providerId: unknown, modelId: unknown) => {
|
||||
handle('models:set', (_event, providerId: unknown, modelId: unknown) => {
|
||||
if (typeof providerId !== 'string' || typeof modelId !== 'string') throw new Error('invalid model')
|
||||
return pass(() => service.setModel(providerId, modelId))
|
||||
})
|
||||
|
||||
ipcMain.handle('auth:setKey', (_event, providerId: unknown, apiKey: unknown) => {
|
||||
handle('auth:setKey', (_event, providerId: unknown, apiKey: unknown) => {
|
||||
if (typeof providerId !== 'string' || typeof apiKey !== 'string' || !apiKey.trim()) {
|
||||
throw new Error('invalid api key')
|
||||
}
|
||||
return pass(() => service.setApiKey(providerId, apiKey.trim()))
|
||||
})
|
||||
|
||||
ipcMain.handle('auth:logout', (_event, providerId: unknown) => {
|
||||
handle('auth:logout', (_event, providerId: unknown) => {
|
||||
if (typeof providerId !== 'string') throw new Error('invalid provider')
|
||||
return pass(() => service.logoutProvider(providerId))
|
||||
})
|
||||
|
||||
// ---- 应用设置 ----
|
||||
ipcMain.handle('settings:get', () => service.getSettings())
|
||||
|
||||
ipcMain.handle('settings:setPrompt', (_event, text: unknown) => {
|
||||
if (typeof text !== 'string') throw new Error('invalid prompt')
|
||||
return pass(() => service.setSystemPrompt(text))
|
||||
// ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ----
|
||||
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('settings:setTheme', (_event, id: unknown) => {
|
||||
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))
|
||||
})
|
||||
|
||||
handle('session:compact:abort', () => pass(() => service.abortCompaction()))
|
||||
|
||||
handle('session:autoCompact', (_event, enabled: unknown) => {
|
||||
if (typeof enabled !== 'boolean') throw new Error('invalid flag')
|
||||
return pass(() => service.setAutoCompactionEnabled(enabled))
|
||||
})
|
||||
|
||||
handle('session:forkPoints', () => pass(() => service.listForkPoints()))
|
||||
|
||||
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,原会话保留)
|
||||
handle('session:fork', () => pass(() => service.forkSession()))
|
||||
|
||||
// 从会话树上的锚点分叉为新会话('at' 含该条为止 / 'before' 到其父条目为止)
|
||||
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))
|
||||
})
|
||||
|
||||
// ---- 应用设置 ----
|
||||
handle('settings:get', () => service.getSettings())
|
||||
|
||||
// 当前生效的系统提示词(pi 原生 + 技能注入,只读展示)
|
||||
handle('settings:getSystemPrompt', () => service.getSystemPrompt())
|
||||
|
||||
handle('settings:setTheme', (_event, id: unknown) => {
|
||||
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid theme id')
|
||||
service.setThemeColor(id.trim())
|
||||
})
|
||||
|
||||
handle('settings:setThemeMode', (_event, mode: unknown) => {
|
||||
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') throw new Error('invalid theme mode')
|
||||
service.setThemeMode(mode)
|
||||
})
|
||||
|
||||
// 启用/禁用单个技能或命令(重建运行时使发现阶段过滤生效)
|
||||
handle('settings:setResourceEnabled', (_event, kind: unknown, name: unknown, enabled: unknown) => {
|
||||
if (kind !== 'skill' && kind !== 'command') throw new Error('invalid resource kind')
|
||||
if (typeof name !== 'string' || !name.trim() || name.length > 200) throw new Error('invalid resource name')
|
||||
if (typeof enabled !== 'boolean') throw new Error('invalid enabled flag')
|
||||
return service.setResourceEnabled(kind, name, enabled)
|
||||
})
|
||||
|
||||
// ---- 工作区 ----
|
||||
ipcMain.handle('workspaces:list', () =>
|
||||
handle('workspaces:list', () =>
|
||||
service.listWorkspaces().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaces:create', (_event, name: unknown) => {
|
||||
handle('workspaces:create', (_event, name: unknown) => {
|
||||
if (typeof name !== 'string' || !name.trim()) throw new Error('invalid workspace name')
|
||||
return service.createWorkspace(name).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('workspaces:open', (_event, path: unknown) => {
|
||||
handle('workspaces:open', (_event, path: unknown) => {
|
||||
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid workspace path')
|
||||
return service.openWorkspace(path).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
@@ -174,7 +255,7 @@ export function registerIpc(): AgentService {
|
||||
})
|
||||
|
||||
// 调起系统目录选择器;取消返回 null
|
||||
ipcMain.handle('workspaces:browse', async () => {
|
||||
handle('workspaces:browse', async () => {
|
||||
const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
title: '选择工作区目录',
|
||||
@@ -186,14 +267,14 @@ export function registerIpc(): AgentService {
|
||||
})
|
||||
|
||||
// ---- 工具审批 ----
|
||||
ipcMain.handle('approval:resolve', (_event, id: unknown, approved: unknown) => {
|
||||
handle('approval:resolve', (_event, id: unknown, approved: unknown) => {
|
||||
if (typeof id !== 'string' || typeof approved !== 'boolean') throw new Error('invalid approval')
|
||||
// settle 内部经 onSettle 订阅发出 approval_settled,此处无需重复广播
|
||||
service.approvals.settle(id, approved)
|
||||
service.broadcastEvent({ type: 'approval_settled', id, approved, timedOut: false })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
ipcMain.handle('app:versions', () => ({
|
||||
handle('app:versions', () => ({
|
||||
app: process.env.npm_package_version ?? '0.0.0',
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
@@ -202,38 +283,38 @@ export function registerIpc(): AgentService {
|
||||
}))
|
||||
|
||||
// ---- Chatbox 资源(/ 命令 + 技能、@ 工作区文件、+ 附件)----
|
||||
ipcMain.handle('chat:resources', () =>
|
||||
handle('chat:resources', () =>
|
||||
service.listChatResources().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('queue:set', (_event, steering: unknown, followUp: unknown) => {
|
||||
handle('queue:set', (_event, steering: unknown, followUp: unknown) => {
|
||||
const norm = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
return service.setQueue(norm(steering), norm(followUp))
|
||||
})
|
||||
|
||||
ipcMain.handle('permissions:set', (_event, mode: unknown) => {
|
||||
handle('permissions:set', (_event, mode: unknown) => {
|
||||
if (mode !== 'confirm' && mode !== 'full') throw new Error('invalid permission mode')
|
||||
service.setPermissionMode(mode)
|
||||
})
|
||||
|
||||
// ---- 设置页:技能 / 命令详情 ----
|
||||
ipcMain.handle('resources:details', () =>
|
||||
handle('resources:details', () =>
|
||||
service.listResourceDetails().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
// 在系统文件管理器中打开(技能/命令来源文件所在目录)
|
||||
ipcMain.handle('shell:openPath', (_event, target: unknown) => {
|
||||
handle('shell:openPath', (_event, target: unknown) => {
|
||||
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
|
||||
return shell.openPath(target)
|
||||
})
|
||||
|
||||
// 打开资源目录:不存在时先创建(skills/prompts 目录通常尚未建立)
|
||||
ipcMain.handle('shell:openDirectory', (_event, target: unknown) => {
|
||||
handle('shell:openDirectory', (_event, target: unknown) => {
|
||||
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
|
||||
try {
|
||||
mkdirSync(target, { recursive: true })
|
||||
@@ -246,16 +327,160 @@ export function registerIpc(): AgentService {
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('files:list', () =>
|
||||
handle('files:list', () =>
|
||||
service.listWorkspaceFiles().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('attachments:pick', async (event) => {
|
||||
handle('attachments:pick', async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getAllWindows()[0]
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
handle('browser:getState', () => browserPass(() => getBrowserController().getState()))
|
||||
handle('browser:navigate', (_e, input: unknown) =>
|
||||
browserPass(() => getBrowserController().navigateDisplay(typeof input === 'string' ? input : ''))
|
||||
)
|
||||
handle('browser:goBack', () => browserPass(() => getBrowserController().goBack()))
|
||||
handle('browser:goForward', () => browserPass(() => getBrowserController().goForward()))
|
||||
handle('browser:reload', () => browserPass(() => getBrowserController().reload()))
|
||||
handle('browser:newTab', (_e, url: unknown) =>
|
||||
browserPass(() => getBrowserController().newTab(typeof url === 'string' && url ? url : undefined, false))
|
||||
)
|
||||
handle('browser:selectTab', (_e, tabId: unknown) =>
|
||||
browserPass(() => getBrowserController().selectTab(typeof tabId === 'string' ? tabId : ''))
|
||||
)
|
||||
handle('browser:closeTab', (_e, tabId: unknown) =>
|
||||
browserPass(() => getBrowserController().closeTab(typeof tabId === 'string' ? tabId : undefined))
|
||||
)
|
||||
handle('browser:close', () => browserPass(() => getBrowserController().close()))
|
||||
// 渲染层冻结帧:菜单弹出需暂时隐藏原生视图前,截当前页面画面做占位
|
||||
handle('browser:captureTab', (_e, tabId: unknown) =>
|
||||
browserPass(() => getBrowserController().captureTabDataUrl(typeof tabId === 'string' && tabId ? tabId : undefined))
|
||||
)
|
||||
handle('browser:acknowledgeRisk', () => browserPass(() => getBrowserController().acknowledgeRisk()))
|
||||
// 渲染层 BrowserSlot 量出的占位矩形(原生 WebContentsView 对齐到 DOM 布局)
|
||||
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 } }))
|
||||
})
|
||||
|
||||
// ---- 本地浏览器配置导入 ----
|
||||
handle('browser:detectSources', () => browserPass(() => detectBrowserSources()))
|
||||
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())
|
||||
})
|
||||
|
||||
handle('plugins:list', () => getPluginHost().list())
|
||||
handle('plugins:uiState', () => getPluginHost().uiState())
|
||||
// 外置插件来源目录(设置页「打开目录」)
|
||||
handle('plugins:dirs', () => getPluginHost().pluginDirs())
|
||||
// 外置插件渲染层模块源码(渲染层以 Blob URL import,绕开 dev http 源对 plugin:// 的 CORS 限制)
|
||||
handle('plugins:readRenderer', (_e, id: unknown) => {
|
||||
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id')
|
||||
return getPluginHost().readRendererSource(id)
|
||||
})
|
||||
// ---- 插件设置表单(声明式 schema;值持久化与动作回推在 plugin-host) ----
|
||||
handle('plugins:settingsSchemas', () => getPluginHost().settingsSchemasList())
|
||||
handle('plugins:settingsValues', () => getPluginHost().settingsValuesList())
|
||||
handle('plugins:setSettingValue', (_e, id: unknown, key: unknown, value: unknown) => {
|
||||
if (typeof id !== 'string' || typeof key !== 'string' || !key) throw new Error('invalid setting value')
|
||||
getPluginHost().setSettingValue(id, key, value)
|
||||
})
|
||||
handle('plugins:setSettingsNav', (_e, id: unknown, on: unknown) => {
|
||||
if (typeof id !== 'string' || typeof on !== 'boolean') throw new Error('invalid settings nav toggle')
|
||||
return getPluginHost().setSettingsNav(id, on)
|
||||
})
|
||||
handle('plugins:settingsAction', (_e, id: unknown, key: unknown) => {
|
||||
if (typeof id !== 'string' || typeof key !== 'string') throw new Error('invalid settings action')
|
||||
getPluginHost().settingsAction(id, key)
|
||||
})
|
||||
// path/image 控件的系统选择框
|
||||
handle('plugins:pickPath', async (event, mode: unknown) => {
|
||||
if (mode !== 'file' && mode !== 'directory' && mode !== 'image') throw new Error('invalid path mode')
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
const filters =
|
||||
mode === 'image'
|
||||
? [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico', 'avif'] }]
|
||||
: undefined
|
||||
const options = { filters }
|
||||
const result =
|
||||
mode === 'directory'
|
||||
? await (win ? dialog.showOpenDialog(win, { ...options, properties: ['openDirectory'] }) : dialog.showOpenDialog({ ...options, properties: ['openDirectory'] }))
|
||||
: await (win ? dialog.showOpenDialog(win, { ...options, properties: ['openFile'] }) : dialog.showOpenDialog({ ...options, properties: ['openFile'] }))
|
||||
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0]
|
||||
})
|
||||
// image 控件缩略图:渲染层(dev 是 http 源)不能直接 <img src="file://">,回传 data URL
|
||||
handle('plugins:readImage', (_e, path: unknown) => {
|
||||
if (typeof path !== 'string' || !path || !/\.(png|jpe?g|gif|webp|bmp|svg|ico|avif)$/i.test(path)) return null
|
||||
try {
|
||||
const buf = readFileSync(path)
|
||||
if (buf.length > 5 * 1024 * 1024) return null
|
||||
const ext = path.split('.').pop()!.toLowerCase()
|
||||
return `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${buf.toString('base64')}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
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)
|
||||
})
|
||||
handle('plugins:reload', () => getPluginHost().reload())
|
||||
handle('plugins:openView', (_e, view: unknown, params: unknown) => {
|
||||
if (typeof view !== 'string' || !view) throw new Error('invalid view')
|
||||
getPluginHost().openView(view, params)
|
||||
})
|
||||
// 插件页(iframe)经渲染层桥转发来的状态胶囊写请求;pluginId 键控防越权
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 统一日志基座(主进程):
|
||||
* - 结构化行:`<ISO时间> <LEVEL> [<scope>] <message> k=v ...`,文件按天滚动
|
||||
* (userData/logs/main-YYYY-MM-DD.log,保留 14 天)
|
||||
* - 级别:dev 默认 debug、打包默认 info;ASO_LOG_LEVEL=debug|info|warn|error 可覆盖
|
||||
* - 子 logger:getLogger(scope) —— 各模块用固定 scope(plugin-host / agent-service / ipc / renderer /
|
||||
* browser),插件日志经 plugin-host 注入 scope=plugin 且带 pluginId 字段
|
||||
* - 全局兜底:uncaughtException / unhandledRejection / will-quit 落盘
|
||||
*
|
||||
* Agent 自诊断约定:日志文件路径固定,pi 侧可直接读文件排查(见 docs/plugins/06-self-diagnosis.md)。
|
||||
*/
|
||||
import { app } from 'electron'
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
|
||||
const RETAIN_DAYS = 14
|
||||
const MAX_LINE = 8 * 1024
|
||||
|
||||
let level: LogLevel = 'info'
|
||||
let logDir: string | null = null
|
||||
/** 当天文件名(含日期)缓存;跨天自动切换 */
|
||||
let fileDay = ''
|
||||
let fileReady = false
|
||||
/** 文件不可用时保留最近日志(进程内仍可读) */
|
||||
const memoryTail: string[] = []
|
||||
|
||||
function resolveLevel(): LogLevel {
|
||||
const env = process.env.ASO_LOG_LEVEL
|
||||
if (env === 'debug' || env === 'info' || env === 'warn' || env === 'error') return env
|
||||
return app.isPackaged ? 'info' : 'debug'
|
||||
}
|
||||
|
||||
function ensureFile(now: Date): string | null {
|
||||
const day = now.toISOString().slice(0, 10)
|
||||
if (fileReady && fileDay === day && logDir) return join(logDir, `main-${day}.log`)
|
||||
try {
|
||||
if (!logDir) logDir = join(app.getPath('userData'), 'logs')
|
||||
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
||||
pruneOldLogs(logDir, day)
|
||||
fileDay = day
|
||||
fileReady = true
|
||||
return join(logDir, `main-${day}.log`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function pruneOldLogs(dir: string, today: string): void {
|
||||
try {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const m = /^main-(\d{4}-\d{2}-\d{2})\.log$/.exec(name)
|
||||
if (!m) continue
|
||||
const ageDays = (Date.now() - statSync(join(dir, name)).mtimeMs) / 86_400_000
|
||||
if (m[1] !== today && (ageDays > RETAIN_DAYS || m[1] > today)) unlinkSync(join(dir, name))
|
||||
}
|
||||
} catch {
|
||||
// 清理失败不影响写日志
|
||||
}
|
||||
}
|
||||
|
||||
function fmtValue(v: unknown): string {
|
||||
if (v === undefined) return 'undefined'
|
||||
if (v === null) return 'null'
|
||||
if (v instanceof Error) return v.stack ? v.stack.replace(/\s+/g, ' ').slice(0, 600) : v.message
|
||||
if (typeof v === 'object') {
|
||||
try {
|
||||
return JSON.stringify(v).slice(0, 400)
|
||||
} catch {
|
||||
return '[object]'
|
||||
}
|
||||
}
|
||||
return String(v).slice(0, 400)
|
||||
}
|
||||
|
||||
function write(level_: LogLevel, scope: string, message: string, kv?: Record<string, unknown>): void {
|
||||
if (LEVEL_ORDER[level_] < LEVEL_ORDER[level]) return
|
||||
const now = new Date()
|
||||
const parts = [
|
||||
now.toISOString(),
|
||||
level_.toUpperCase().padEnd(5),
|
||||
`[${scope}]`,
|
||||
message.slice(0, 500)
|
||||
]
|
||||
if (kv) {
|
||||
for (const [k, v] of Object.entries(kv)) parts.push(`${k}=${fmtValue(v)}`)
|
||||
}
|
||||
const line = parts.join(' ').slice(0, MAX_LINE)
|
||||
|
||||
// 控制台镜像(dev 终端可读)
|
||||
// eslint-disable-next-line no-console
|
||||
;(level_ === 'error' ? console.error : level_ === 'warn' ? console.warn : console.log)(line)
|
||||
|
||||
memoryTail.push(line)
|
||||
if (memoryTail.length > 500) memoryTail.shift()
|
||||
const file = ensureFile(now)
|
||||
if (file) {
|
||||
try {
|
||||
appendFileSync(file, line + '\n', 'utf8')
|
||||
} catch {
|
||||
fileReady = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(message: string, kv?: Record<string, unknown>): void
|
||||
info(message: string, kv?: Record<string, unknown>): void
|
||||
warn(message: string, kv?: Record<string, unknown>): void
|
||||
error(message: string, kv?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
export function getLogger(scope: string): Logger {
|
||||
return {
|
||||
debug: (message, kv) => write('debug', scope, message, kv),
|
||||
info: (message, kv) => write('info', scope, message, kv),
|
||||
warn: (message, kv) => write('warn', scope, message, kv),
|
||||
error: (message, kv) => write('error', scope, message, kv)
|
||||
}
|
||||
}
|
||||
|
||||
/** 最近日志(内存尾部;诊断 IPC 预留) */
|
||||
export function recentLines(count = 100): string[] {
|
||||
return memoryTail.slice(-count)
|
||||
}
|
||||
|
||||
export function initLogger(): void {
|
||||
level = resolveLevel()
|
||||
const log = getLogger('logger')
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.error('uncaughtException', { err })
|
||||
})
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log.error('unhandledRejection', { err: reason })
|
||||
})
|
||||
app.on('will-quit', () => {
|
||||
log.info('app quit')
|
||||
})
|
||||
log.info('logger init', { level, userData: app.getPath('userData') })
|
||||
}
|
||||
@@ -1,30 +1,43 @@
|
||||
import { getLogger } from '../logger'
|
||||
const log = getLogger('agent-service')
|
||||
|
||||
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,
|
||||
getPowerShellConfig,
|
||||
SessionManager,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
type AgentSessionRuntime,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
type SessionInfo
|
||||
type PromptTemplate,
|
||||
type SessionInfo,
|
||||
type Skill
|
||||
} from '@earendil-works/pi-coding-agent'
|
||||
import { ApprovalService, createApprovalExtension } from './approval'
|
||||
import { demoTools, TOOLS_REQUIRING_APPROVAL } from '../tools/demo-tools'
|
||||
import { EXTERNAL_PLUGINS_ENABLED, getPluginHost } from '../plugins/plugin-host'
|
||||
import { browserToolNames } from '../tools/browser-tools'
|
||||
import type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
AttachmentInfo,
|
||||
ChatResources,
|
||||
CommandDetail,
|
||||
ContextBreakdownEntry,
|
||||
ContextUsageInfo,
|
||||
CustomProviderConfig,
|
||||
ForkPoint,
|
||||
ModelCatalog,
|
||||
ModelInfo,
|
||||
NavigateTreeOptions,
|
||||
PermissionMode,
|
||||
ResourceDetails,
|
||||
SendImage,
|
||||
@@ -32,6 +45,8 @@ import type {
|
||||
SessionState,
|
||||
SessionStatsInfo,
|
||||
SkillDetail,
|
||||
ThinkingLevel,
|
||||
ThemeMode,
|
||||
WorkspaceFile,
|
||||
WorkspaceMeta
|
||||
} from '@shared/types'
|
||||
@@ -39,6 +54,44 @@ 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', 'powershell', 'write', 'edit'])
|
||||
|
||||
/** 资源禁用清单(disabledSkills/disabledCommands)的白名单式解析:仅保留非空字符串并去重 */
|
||||
function parseNameList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return [...new Set(value.filter((n): n is string => typeof n === 'string' && n.trim().length > 0).map((n) => n.trim()))]
|
||||
}
|
||||
|
||||
/** Windows 优先 PowerShell:探测结果进程内缓存(getPowerShellConfig 会 spawnSync where 探测,
|
||||
* 且平台与安装情况不会在应用运行中变化)。探测失败保持 bash(Git Bash)不动。 */
|
||||
let powershellProbe: boolean | null = null
|
||||
|
||||
/** win32 且存在 pwsh/powershell 可执行文件时,把会话的默认 bash 工具换成 powershell,
|
||||
* 模型的 shell 调用走原生 PowerShell 而非 Git Bash。幂等:无 bash 或已换过则原样返回。
|
||||
* 注意不能用 options.tools 传入(那会变成工具白名单,把插件注册的工具全部过滤掉),
|
||||
* 也不能只传四个基础名调 setActiveToolsByName(那会把已激活的扩展工具一并挤掉),
|
||||
* 所以按当前激活名单做替换。 */
|
||||
function preferPowerShell(session: AgentSession): void {
|
||||
if (process.platform !== 'win32') return
|
||||
if (powershellProbe === null) {
|
||||
try {
|
||||
getPowerShellConfig()
|
||||
powershellProbe = true
|
||||
} catch {
|
||||
powershellProbe = false
|
||||
}
|
||||
}
|
||||
if (!powershellProbe) return
|
||||
const names = session.getActiveToolNames()
|
||||
if (!names.includes('bash') || names.includes('powershell')) return
|
||||
session.setActiveToolsByName(names.map((name) => (name === 'bash' ? 'powershell' : name)))
|
||||
}
|
||||
|
||||
/**
|
||||
* pi 会话运行时的主进程封装:
|
||||
* - 基于 AgentSessionRuntime 支持会话新建/切换(自动重挂事件订阅)
|
||||
@@ -53,14 +106,27 @@ export class AgentService {
|
||||
private flushTimer: NodeJS.Timeout | null = null
|
||||
private attached: AgentSession | null = null
|
||||
private unsubscribeAttached: (() => void) | null = null
|
||||
private settings: AppSettings = { systemPrompt: '' }
|
||||
private settings: AppSettings = {}
|
||||
/** 当前工作区目录(agent cwd),会话按目录隔离 */
|
||||
private workspaceDir = ''
|
||||
private approvalsRegistered = false
|
||||
/** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */
|
||||
private permissionMode: PermissionMode = 'confirm'
|
||||
/** 技能清单 token 估算缓存(随工作区失效) */
|
||||
private skillsEstimate: { cwd: string; value: number } | null = null
|
||||
/** pi 全量发现的技能/命令快照(skillsOverride/promptsOverride 在每次 loader reload 时写入)。
|
||||
* 被禁用的资源在 loader 里已过滤掉,设置页靠这份快照展示"已禁用"状态。 */
|
||||
private resourceSnapshotSkills: Skill[] = []
|
||||
private resourceSnapshotPrompts: PromptTemplate[] = []
|
||||
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')
|
||||
@@ -82,7 +148,6 @@ 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')
|
||||
@@ -90,10 +155,13 @@ export class AgentService {
|
||||
// 历史版本存过 autoEdit/plan,统一收敛回 confirm
|
||||
permissionMode:
|
||||
raw.permissionMode === 'full' ? 'full' : 'confirm',
|
||||
themeColor: typeof raw.themeColor === 'string' ? raw.themeColor : undefined
|
||||
themeColor: typeof raw.themeColor === 'string' ? raw.themeColor : undefined,
|
||||
themeMode: raw.themeMode === 'light' || raw.themeMode === 'dark' ? raw.themeMode : 'system',
|
||||
disabledSkills: parseNameList(raw.disabledSkills),
|
||||
disabledCommands: parseNameList(raw.disabledCommands)
|
||||
}
|
||||
} catch {
|
||||
this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
|
||||
this.settings = { permissionMode: this.permissionMode, themeMode: 'system' }
|
||||
}
|
||||
this.permissionMode = this.settings.permissionMode ?? 'confirm'
|
||||
this.initWorkspaces()
|
||||
@@ -105,7 +173,7 @@ export class AgentService {
|
||||
try {
|
||||
mkdirSync(this.defaultWorkspaceDir, { recursive: true })
|
||||
} catch (err) {
|
||||
console.error('[agent-service] init workspaces failed:', err)
|
||||
log.error('init workspaces failed:', { err: err })
|
||||
}
|
||||
const saved = this.settings.workspace
|
||||
this.workspaceDir = saved && existsSync(saved) ? saved : this.defaultWorkspaceDir
|
||||
@@ -129,7 +197,7 @@ export class AgentService {
|
||||
seen.add(path.toLowerCase())
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[agent-service] list workspaces failed:', err)
|
||||
log.error('list workspaces failed:', { err: err })
|
||||
}
|
||||
// 目录选择器添加的根目录之外的工作区
|
||||
for (const p of this.settings.customWorkspaces ?? []) {
|
||||
@@ -174,14 +242,15 @@ export class AgentService {
|
||||
this.settings.workspace = resolved
|
||||
this.saveSettings()
|
||||
this.workspaceDir = resolved
|
||||
await this.rebuildRuntime()
|
||||
// 工作区切换:新 cwd 的最近会话才是目标(不做原会话恢复)
|
||||
await this.rebuildRuntime({ restoreSession: false })
|
||||
}
|
||||
|
||||
private saveSettings(): void {
|
||||
try {
|
||||
writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2), 'utf8')
|
||||
} catch (err) {
|
||||
console.error('[agent-service] save settings failed:', err)
|
||||
log.error('save settings failed:', { err: err })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,17 +259,12 @@ export class AgentService {
|
||||
return () => this.listeners.delete(fn)
|
||||
}
|
||||
|
||||
/** 供 IPC 层主动广播事件(如审批结果同步到所有窗口) */
|
||||
broadcastEvent(event: AgentUiEvent): void {
|
||||
this.emit(event)
|
||||
}
|
||||
|
||||
private emit(event: AgentUiEvent): void {
|
||||
for (const fn of this.listeners) {
|
||||
try {
|
||||
fn(event)
|
||||
} catch (err) {
|
||||
console.error('[agent-service] listener error:', err)
|
||||
log.error('listener error:', { err: err })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +295,8 @@ export class AgentService {
|
||||
const cwd = this.cwd
|
||||
const agentDir = getAgentDir()
|
||||
this.settings = this.loadSettings()
|
||||
// 项目级第三方插件目录随工作区同步(.pi/extensions;目录变更即重扫描插件列表)
|
||||
getPluginHost().setProjectExtensionsDir(EXTERNAL_PLUGINS_ENABLED ? cwd : null)
|
||||
// 审批拦截只需注册一次(create 会随工作区切换多次执行)
|
||||
if (!this.approvalsRegistered) {
|
||||
this.approvalsRegistered = true
|
||||
@@ -246,10 +312,24 @@ 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)
|
||||
]
|
||||
createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode),
|
||||
// 插件聚合 extension:全部已启用插件在同一真实 ExtensionAPI 上下文里激活
|
||||
getPluginHost().createAggregatedExtension()
|
||||
],
|
||||
// 技能/命令启停:发现阶段按 AppSettings 过滤(被禁资源不进系统提示词与 / 命令面板)。
|
||||
// 全量结果先快照给设置页(被禁项在 loader 结果里不可见);闭包在每次 reload 重跑,
|
||||
// 工作区切换/重建运行时后快照随之刷新。
|
||||
skillsOverride: (base) => {
|
||||
this.resourceSnapshotSkills = base.skills
|
||||
const disabled = new Set(this.settings.disabledSkills ?? [])
|
||||
return { skills: base.skills.filter((s) => !disabled.has(s.name)), diagnostics: base.diagnostics }
|
||||
},
|
||||
promptsOverride: (base) => {
|
||||
this.resourceSnapshotPrompts = base.prompts
|
||||
const disabled = new Set(this.settings.disabledCommands ?? [])
|
||||
return { prompts: base.prompts.filter((p) => !disabled.has(p.name)), diagnostics: base.diagnostics }
|
||||
}
|
||||
}
|
||||
})
|
||||
const initialServices = await buildServices(cwd, agentDir)
|
||||
@@ -268,9 +348,12 @@ export class AgentService {
|
||||
const result = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
customTools: demoTools
|
||||
sessionStartEvent
|
||||
// 业务工具(browserTools 等)由插件经 pi.registerTool 注册(聚合 extension),
|
||||
// 此处不再传 customTools
|
||||
})
|
||||
// 会话创建是唯一收口点:新建/切换/分叉/重载都会经过这里,统一应用 Windows PowerShell 偏好
|
||||
preferPowerShell(result.session)
|
||||
return { ...result, services, diagnostics: services.diagnostics }
|
||||
}
|
||||
|
||||
@@ -296,36 +379,15 @@ 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)),
|
||||
activeTools: this.collectToolStates(session)
|
||||
messages: session.messages.map((m) => toCloneable(m))
|
||||
})
|
||||
}
|
||||
|
||||
/** 从历史消息中恢复工具状态(用于切换会话后 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
|
||||
// 切换/回溯后立刻带上一次统计(上下文容量、缓存命中率),Usage Card 无需等第一回合
|
||||
void this.emitStats()
|
||||
}
|
||||
|
||||
async ensureReady(): Promise<SessionState> {
|
||||
@@ -366,6 +428,8 @@ 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({
|
||||
@@ -406,6 +470,27 @@ 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
|
||||
}
|
||||
@@ -420,6 +505,7 @@ export class AgentService {
|
||||
name: String(model.name ?? model.id ?? '')
|
||||
}
|
||||
: undefined
|
||||
const thinkingLevels = session.supportsThinking() ? session.getAvailableThinkingLevels() : []
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
sessionName: session.sessionName,
|
||||
@@ -428,19 +514,186 @@ export class AgentService {
|
||||
activeTools: session.getActiveToolNames(),
|
||||
messageCount: session.messages.length,
|
||||
workspace: this.cwd,
|
||||
permissionMode: this.permissionMode
|
||||
permissionMode: this.permissionMode,
|
||||
thinkingLevel: session.thinkingLevel,
|
||||
thinkingLevels,
|
||||
supportsThinking: thinkingLevels.length > 0,
|
||||
isCompacting: session.isCompacting,
|
||||
autoCompactionEnabled: session.autoCompactionEnabled
|
||||
}
|
||||
}
|
||||
|
||||
/** 上下文容量详情:getContextUsage + 按 ZCode 卡片类目的 token 估算(chars/4,与 pi 一致)。
|
||||
* 类目:消息 / 自定义工具 / 系统工具 / 系统提示词 / 技能 / 其他;
|
||||
* 技能块注入在系统提示词内,从提示词估算中拆出(不重复计数)。 */
|
||||
private buildContextInfo(session: AgentSession): ContextUsageInfo {
|
||||
const agg = this.aggregateSession(session)
|
||||
const breakdown: ContextBreakdownEntry[] = [
|
||||
{ key: 'messages', label: '消息', tokens: agg.breakdown.get('messages') ?? 0 },
|
||||
{ key: 'customTools', label: '自定义工具', tokens: agg.breakdown.get('customTools') ?? 0 },
|
||||
{ key: 'builtinTools', label: '系统工具', tokens: agg.breakdown.get('builtinTools') ?? 0 },
|
||||
{ key: 'systemPrompt', label: '系统提示词', tokens: 0 },
|
||||
{ key: 'skills', label: '技能', tokens: 0 },
|
||||
{ key: 'other', label: '其他', tokens: agg.breakdown.get('other') ?? 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
|
||||
|
||||
const usage = session.getContextUsage()
|
||||
return {
|
||||
tokens: usage && typeof usage.tokens === 'number' ? usage.tokens : null,
|
||||
contextWindow: usage?.contextWindow ?? 0,
|
||||
percent: usage && typeof usage.percent === 'number' ? usage.percent : null,
|
||||
...(agg.lastOutputTokens !== undefined ? { lastOutputTokens: agg.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
|
||||
}
|
||||
|
||||
// ---- 会话统计增量聚合 ----
|
||||
// emitStats 每条 assistant 消息落地都会触发;长会话下每次全量遍历估算
|
||||
// token 是 O(全部消息)。消息列表正常只追加:以「已聚合前缀长度 + 前缀
|
||||
// 末条消息引用」为锚点,每次只聚合新增尾部;压缩/回溯/会话切换/运行时
|
||||
// 重建后消息对象引用必变(或长度回退),锚点失配即自动整体重算。
|
||||
|
||||
private sessionAgg: {
|
||||
count: number
|
||||
anchor: unknown
|
||||
cacheInput: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
toolCalls: number
|
||||
toolErrors: number
|
||||
/** 消息侧 breakdown(systemPrompt/skills 在外层另算) */
|
||||
breakdown: Map<string, number>
|
||||
lastOutputTokens: number | undefined
|
||||
} | null = null
|
||||
|
||||
private aggregateSession(session: AgentSession) {
|
||||
const messages = session.messages
|
||||
let agg = this.sessionAgg
|
||||
if (
|
||||
agg === null ||
|
||||
messages.length < agg.count ||
|
||||
(agg.count > 0 && messages[agg.count - 1] !== agg.anchor)
|
||||
) {
|
||||
agg = {
|
||||
count: 0,
|
||||
anchor: null,
|
||||
cacheInput: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
toolCalls: 0,
|
||||
toolErrors: 0,
|
||||
breakdown: new Map([
|
||||
['messages', 0],
|
||||
['customTools', 0],
|
||||
['builtinTools', 0],
|
||||
['other', 0]
|
||||
]),
|
||||
lastOutputTokens: undefined
|
||||
}
|
||||
}
|
||||
for (let i = agg.count; i < messages.length; i++) {
|
||||
const m = messages[i] as {
|
||||
role?: string
|
||||
toolName?: unknown
|
||||
content?: unknown
|
||||
isError?: unknown
|
||||
usage?: { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown }
|
||||
}
|
||||
const bump = (key: string): void => {
|
||||
agg!.breakdown.set(key, (agg!.breakdown.get(key) ?? 0) + estimateMessageTokens(messages[i]))
|
||||
}
|
||||
switch (m.role) {
|
||||
case 'assistant': {
|
||||
bump('messages')
|
||||
if (m.usage) {
|
||||
agg.cacheInput += Number(m.usage.input ?? 0) || 0
|
||||
agg.cacheRead += Number(m.usage.cacheRead ?? 0) || 0
|
||||
agg.cacheWrite += Number(m.usage.cacheWrite ?? 0) || 0
|
||||
if (typeof m.usage.output === 'number') agg.lastOutputTokens = m.usage.output
|
||||
}
|
||||
if (Array.isArray(m.content)) {
|
||||
for (const block of m.content as Array<{ type?: unknown }>) {
|
||||
if (block && typeof block === 'object' && block.type === 'toolCall') agg.toolCalls += 1
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'user':
|
||||
bump('messages')
|
||||
break
|
||||
case 'toolResult': {
|
||||
if (m.isError === true) agg.toolErrors += 1
|
||||
bump(
|
||||
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
|
||||
)
|
||||
break
|
||||
}
|
||||
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
|
||||
case 'bashExecution':
|
||||
case 'custom':
|
||||
case 'compactionSummary':
|
||||
case 'branchSummary':
|
||||
bump('other')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
agg.count = messages.length
|
||||
agg.anchor = messages.length > 0 ? messages[messages.length - 1] : null
|
||||
this.sessionAgg = agg
|
||||
return agg
|
||||
}
|
||||
|
||||
private async emitStats(): Promise<void> {
|
||||
try {
|
||||
const runtime = await this.getRuntime()
|
||||
const stats = runtime.session.getSessionStats()
|
||||
const usage = runtime.session.getContextUsage()
|
||||
const session = runtime.session
|
||||
const stats = session.getSessionStats()
|
||||
const usage = session.getContextUsage()
|
||||
const agg = this.aggregateSession(session)
|
||||
// 缓存命中率:读缓存 / 全部提示 token(input + cacheRead + cacheWrite),
|
||||
// 按本会话所有 assistant 响应聚合,0 表示会话内尚无任何响应
|
||||
const denominator = agg.cacheInput + agg.cacheRead + agg.cacheWrite
|
||||
const info: SessionStatsInfo = {
|
||||
tokens: { ...stats.tokens },
|
||||
cost: stats.cost,
|
||||
contextPercent: usage && typeof usage.percent === 'number' ? Math.round(usage.percent) : undefined
|
||||
contextPercent: usage && typeof usage.percent === 'number' ? Math.round(usage.percent) : undefined,
|
||||
context: this.buildContextInfo(session),
|
||||
...(denominator > 0
|
||||
? {
|
||||
cacheHitRate: Math.round((agg.cacheRead / denominator) * 100),
|
||||
cacheTokens: { read: agg.cacheRead, write: agg.cacheWrite, input: agg.cacheInput }
|
||||
}
|
||||
: {}),
|
||||
...(agg.toolCalls > 0
|
||||
? {
|
||||
toolStats: {
|
||||
calls: agg.toolCalls,
|
||||
successRate: Math.round(((agg.toolCalls - agg.toolErrors) / agg.toolCalls) * 100)
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
this.emit({ type: 'stats', stats: info })
|
||||
} catch {
|
||||
@@ -481,18 +734,11 @@ export class AgentService {
|
||||
|
||||
async listChatResources(): Promise<ChatResources> {
|
||||
const runtime = await this.getRuntime()
|
||||
let skills: ChatResources['skills'] = []
|
||||
try {
|
||||
const loaded = loadSkills({
|
||||
cwd: this.cwd,
|
||||
agentDir: getAgentDir(),
|
||||
skillPaths: [],
|
||||
includeDefaults: true
|
||||
})
|
||||
skills = loaded.skills.map((s) => ({ name: s.name, description: s.description ?? '' }))
|
||||
} catch (err) {
|
||||
console.error('[agent-service] load skills failed:', err)
|
||||
}
|
||||
// 与系统提示词同源:resource loader 的技能发现(含 ~/.agents/skills 与工作区 .agents/skills)
|
||||
const skills = runtime.session.resourceLoader.getSkills().skills.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description
|
||||
}))
|
||||
const commands = runtime.session.promptTemplates.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
@@ -501,53 +747,77 @@ export class AgentService {
|
||||
return { commands, skills }
|
||||
}
|
||||
|
||||
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新) */
|
||||
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。
|
||||
* 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。
|
||||
* 数据源用 override 写入的全量快照:被禁资源在会话里已过滤,只有快照还看得到。 */
|
||||
async listResourceDetails(): Promise<ResourceDetails> {
|
||||
const agentDir = getAgentDir()
|
||||
const dirs = {
|
||||
globalSkills: join(agentDir, 'skills'),
|
||||
globalAgentsSkills: join(homedir(), '.agents', 'skills'),
|
||||
projectSkills: join(this.cwd, '.pi', 'skills'),
|
||||
projectAgentsSkills: join(this.cwd, '.agents', 'skills'),
|
||||
globalCommands: join(agentDir, 'prompts'),
|
||||
projectCommands: join(this.cwd, '.pi', 'prompts')
|
||||
}
|
||||
let skills: SkillDetail[] = []
|
||||
const disabledSkills = new Set(this.settings.disabledSkills ?? [])
|
||||
const disabledCommands = new Set(this.settings.disabledCommands ?? [])
|
||||
const skills: SkillDetail[] = []
|
||||
try {
|
||||
const loaded = loadSkills({
|
||||
cwd: this.cwd,
|
||||
agentDir,
|
||||
skillPaths: [],
|
||||
includeDefaults: true
|
||||
})
|
||||
skills = loaded.skills.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description ?? '',
|
||||
filePath: s.filePath,
|
||||
scope: s.sourceInfo?.scope === 'user' ? 'user' : 'project',
|
||||
disableModelInvocation: Boolean(s.disableModelInvocation)
|
||||
}))
|
||||
const runtime = await this.getRuntime()
|
||||
const all = this.resourceSnapshotSkills.length > 0 ? this.resourceSnapshotSkills : runtime.session.resourceLoader.getSkills().skills
|
||||
for (const s of all) {
|
||||
skills.push({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
filePath: s.filePath,
|
||||
// 与 resource loader 的默认归类一致:无来源信息时按用户级
|
||||
scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user',
|
||||
disableModelInvocation: Boolean(s.disableModelInvocation),
|
||||
disabled: disabledSkills.has(s.name)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[agent-service] load skill details failed:', err)
|
||||
log.error('load skill details failed:', { err: err })
|
||||
}
|
||||
const commands: CommandDetail[] = []
|
||||
try {
|
||||
const runtime = await this.getRuntime()
|
||||
const all =
|
||||
this.resourceSnapshotPrompts.length > 0 ? this.resourceSnapshotPrompts : runtime.session.promptTemplates
|
||||
commands.push(
|
||||
...runtime.session.promptTemplates.map((t) => ({
|
||||
...all.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
argumentHint: t.argumentHint || undefined,
|
||||
filePath: t.filePath,
|
||||
scope: t.sourceInfo?.scope === 'user' ? ('user' as const) : ('project' as const)
|
||||
scope: t.sourceInfo?.scope === 'user' ? ('user' as const) : ('project' as const),
|
||||
disabled: disabledCommands.has(t.name)
|
||||
}))
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[agent-service] list command details failed:', err)
|
||||
log.error('list command details failed:', { err: err })
|
||||
}
|
||||
skills.sort((a, b) => a.name.localeCompare(b.name))
|
||||
commands.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return { skills, commands, dirs }
|
||||
}
|
||||
|
||||
/** 启用/禁用单个技能或命令:持久化到 AppSettings 后重建运行时(发现阶段过滤随之生效) */
|
||||
async setResourceEnabled(kind: 'skill' | 'command', name: string, enabled: boolean): Promise<void> {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) throw new Error('资源名不能为空')
|
||||
const field = kind === 'skill' ? ('disabledSkills' as const) : ('disabledCommands' as const)
|
||||
const next = new Set(this.settings[field] ?? [])
|
||||
if (enabled) next.delete(trimmed)
|
||||
else next.add(trimmed)
|
||||
this.settings = { ...this.settings, [field]: [...next].sort((a, b) => a.localeCompare(b)) }
|
||||
this.saveSettings()
|
||||
await this.rebuildRuntime()
|
||||
const session = this.runtime?.session
|
||||
if (session) this.emit({ type: 'state', state: this.buildState(session) })
|
||||
}
|
||||
|
||||
// ---- 工作区文件(@ 引用)----
|
||||
|
||||
async listWorkspaceFiles(): Promise<WorkspaceFile[]> {
|
||||
@@ -615,7 +885,7 @@ export class AgentService {
|
||||
}
|
||||
return { name, path, mimeType, data: readFileSync(path).toString('base64') }
|
||||
} catch (err) {
|
||||
console.error('[agent-service] read attachment failed:', err)
|
||||
log.error('read attachment failed:', { err: err })
|
||||
return { name, path, mimeType: '', data: '' }
|
||||
}
|
||||
}
|
||||
@@ -630,7 +900,7 @@ export class AgentService {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[agent-service] read attachment failed:', err)
|
||||
log.error('read attachment failed:', { err: err })
|
||||
}
|
||||
return { name, path, mimeType: '', data: '' }
|
||||
}
|
||||
@@ -643,6 +913,9 @@ 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()
|
||||
}
|
||||
|
||||
@@ -798,6 +1071,12 @@ export class AgentService {
|
||||
const mr = runtime.session.modelRuntime
|
||||
const credentials = await mr.listCredentials()
|
||||
const credSet = new Set(credentials.map((c) => `${c.providerId}:${c.type}`))
|
||||
// 自定义提供商的 apiKey 存在 models.json(不进凭据存储),有 key 即视为已配置
|
||||
const customKeyed = new Set(
|
||||
Object.entries(this.readModelsConfig().providers)
|
||||
.filter(([, v]) => Boolean((v as { apiKey?: unknown })?.apiKey))
|
||||
.map(([id]) => id)
|
||||
)
|
||||
const providers = mr
|
||||
.getProviders()
|
||||
.map((p) => {
|
||||
@@ -805,7 +1084,7 @@ export class AgentService {
|
||||
return {
|
||||
id: pid,
|
||||
name: String(p.name ?? pid),
|
||||
hasApiKey: credSet.has(`${pid}:api_key`),
|
||||
hasApiKey: credSet.has(`${pid}:api_key`) || customKeyed.has(pid),
|
||||
isOAuth: credSet.has(`${pid}:oauth`),
|
||||
models: mr
|
||||
.getModels(pid)
|
||||
@@ -854,17 +1133,110 @@ 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 }
|
||||
}
|
||||
|
||||
/** 更新系统提示词后重建运行时(continueRecent 重新挂载当前最近会话,历史保留) */
|
||||
async setSystemPrompt(text: string): Promise<void> {
|
||||
this.settings = { ...this.settings, systemPrompt: text }
|
||||
/** 当前生效的系统提示词(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 }
|
||||
this.saveSettings()
|
||||
await this.rebuildRuntime()
|
||||
}
|
||||
|
||||
/** 切换权限模式:即时生效(审批扩展每笔工具调用实时读取),并广播给渲染层 */
|
||||
@@ -878,25 +1250,59 @@ export class AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 主题色偏好(纯外观,仅持久化;渲染层负责应用) */
|
||||
setThemeColor(id: string): void {
|
||||
this.settings = { ...this.settings, themeColor: id }
|
||||
this.saveSettings()
|
||||
}
|
||||
|
||||
private async rebuildRuntime(): Promise<void> {
|
||||
/** 重建运行时(插件重载/启停、工作区切换时调用,extensionFactories 重跑)。
|
||||
* restoreSession=true(插件重载):重建对渲染层尽量透明——
|
||||
* - 进行中的回合先经 abort 正常结算(事件照常送达,渲染层自行收口),
|
||||
* 再退订/销毁旧运行时——dispose 不含 abort,直接销毁会留下悬空请求;
|
||||
* - 重建走 continueRecent(最近会话):用户开着的不是它时切回原会话;
|
||||
* 原会话是未落盘的空新会话时退回空会话,避免静默接管别的会话;
|
||||
* - 会话未变化时只推轻量 state,不广播 session_switched(该事件语义
|
||||
* 保留给真正的会话变更,全量换血会重挂载渲染层全部消息)。
|
||||
* restoreSession=false(工作区切换):新 cwd 的最近会话即目标状态,
|
||||
* 直接全量广播切换。 */
|
||||
async rebuildRuntime(options?: { restoreSession?: boolean }): Promise<void> {
|
||||
const restoreSession = options?.restoreSession !== false
|
||||
const old = this.runtime
|
||||
this.runtime = null
|
||||
this.initPromise = null
|
||||
if (old?.session.isStreaming) {
|
||||
await old.session.abort().catch(() => undefined)
|
||||
}
|
||||
// abort 结算会落盘当前回合,未持久会话的文件路径此时才物化——在其后读取
|
||||
const previousPath = old?.session.sessionFile ?? null
|
||||
this.unsubscribeAttached?.()
|
||||
this.unsubscribeAttached = null
|
||||
this.attached = null
|
||||
if (old) {
|
||||
await old.dispose().catch((err: unknown) => {
|
||||
console.error('[agent-service] runtime dispose error:', err)
|
||||
log.error('runtime dispose error:', { err: err })
|
||||
})
|
||||
}
|
||||
await this.ensureReady()
|
||||
let runtime: AgentSessionRuntime
|
||||
try {
|
||||
runtime = await this.getRuntime()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
this.emit({ type: 'session_error', message })
|
||||
throw err
|
||||
}
|
||||
if (!restoreSession) {
|
||||
this.emitSwitched(runtime.session)
|
||||
return
|
||||
}
|
||||
const restored = runtime.session.sessionFile ?? ''
|
||||
if (previousPath) {
|
||||
if (!samePath(restored, previousPath) && existsSync(previousPath)) {
|
||||
// 被拽到了最近会话:切回用户原本打开的会话(rebind 发 session_switched,真实变更)
|
||||
await runtime.switchSession(previousPath)
|
||||
return
|
||||
}
|
||||
} else if (runtime.session.messages.length > 0) {
|
||||
// 原会话是未落盘的空新会话:不接管最近会话,回到空会话
|
||||
await runtime.newSession()
|
||||
return
|
||||
}
|
||||
this.emit({ type: 'state', state: this.buildState(runtime.session) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,15 +1337,35 @@ function samePath(a: string, b: string): boolean {
|
||||
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
|
||||
}
|
||||
|
||||
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
|
||||
/** 事件消息对象转为可结构化克隆的普通对象(剥离函数/循环引用等不可克隆值)。
|
||||
* structuredClone 远快于 JSON 往返——切换会话时整段历史都要过一遍这里,
|
||||
* 长上下文下 JSON 序列化+反序列化是主进程最重的同步开销。 */
|
||||
function toCloneable(value: unknown): unknown {
|
||||
if (value === null || typeof value !== 'object') return value ?? null
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value ?? null))
|
||||
return structuredClone(value)
|
||||
} catch {
|
||||
return null
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
} catch {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 浏览器插件(内置,组件级: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: '受管浏览器'
|
||||
},
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 内置插件注册表(编译进宿主 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]
|
||||
@@ -0,0 +1,792 @@
|
||||
/**
|
||||
* 插件宿主(主进程):
|
||||
* - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle
|
||||
* - 外置插件:userData/plugins/<id>、项目 .pi/extensions/<id>、全局 ~/.pi/agent/extensions/<id>
|
||||
* (均为 manifest.json + 可选 main.mjs / entry.renderer / entry.page)
|
||||
* - 同 id 高 semver 胜、平级先扫者胜(userData > 项目 > 全局);外置遮蔽组件级内置需
|
||||
* manifest.replacesNative
|
||||
* - 聚合为一个 InlineExtension 挂进 pi extensionFactories:插件工厂收到
|
||||
* { pi, ui, storage, manifest },pi 为真实 ExtensionAPI(工具/事件/命令走原生路径)
|
||||
* - 内联 UI(状态胶囊/工具栏按钮)数据存主进程(单一事实源),变更广播渲染层
|
||||
* - plugin:// 协议把插件目录映射给 iframe 页与渲染层 renderer 模块(含路径穿越防护)
|
||||
* - entry.renderer(渲染层 React 组件模块)在渲染层经动态 import 装载,React 用宿主实例
|
||||
*
|
||||
* 外置装载是 build 级开关:EXTERNAL_PLUGINS_ENABLED——开发版(未打包)开启,
|
||||
* 用户版(打包)关闭(M5 ed25519 验签 + 开发者模式落地后再放开;QA 可用
|
||||
* ASO_EXTERNAL_PLUGINS=1 豁免)。关闭时仅内置插件生效。
|
||||
*
|
||||
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
|
||||
* ed25519 验签 + 开发者模式豁免)。
|
||||
*/
|
||||
import { app, BrowserWindow, net, protocol } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
||||
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
|
||||
import type {
|
||||
HarnessPluginFactory,
|
||||
PluginManifest,
|
||||
PluginSettingsSchema,
|
||||
PluginSettingValues,
|
||||
PluginSettingsSchemas,
|
||||
PluginSettingsValuesMap,
|
||||
StatusItem,
|
||||
ToolbarButtonDef
|
||||
} from '@aso/plugin-sdk'
|
||||
import { withTimeout } from '@aso/plugin-sdk'
|
||||
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
||||
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
|
||||
import { getLogger } from '../logger'
|
||||
|
||||
const log = getLogger('plugin-host')
|
||||
const pluginLog = getLogger('plugin')
|
||||
|
||||
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
||||
export const HARNESS_CONTRACT_VERSION = '0.2.0'
|
||||
|
||||
/** 插件激活限时(异步工厂的兜底超时):挂死的插件不得阻塞 runtime 重建主流程 */
|
||||
export const PLUGIN_ACTIVATION_TIMEOUT_MS = 10_000
|
||||
|
||||
/** 外置插件装载开关(build 级):开发版开启、用户版关闭;ASO_EXTERNAL_PLUGINS=1 强制开启 */
|
||||
export const EXTERNAL_PLUGINS_ENABLED = !app.isPackaged || process.env.ASO_EXTERNAL_PLUGINS === '1'
|
||||
|
||||
interface ResolvedPlugin {
|
||||
manifest: PluginManifest
|
||||
source: 'builtin' | 'external'
|
||||
/** 外置来源目录类别(管理页分组展示用) */
|
||||
origin?: 'user' | 'project' | 'global'
|
||||
/** 外置插件根目录(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>()
|
||||
/** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */
|
||||
private projectExtensionsDir: string | null = null
|
||||
/** 插件设置:声明 schema(激活时重新声明)/ 用户改过的值 / 一级导航开关 */
|
||||
private settingsSchemas = new Map<string, PluginSettingsSchema>()
|
||||
private settingsValues = new Map<string, PluginSettingValues>()
|
||||
private settingsNavSet = new Set<string>()
|
||||
/** 设置页动作/值变更的激活期回调(每次插件激活时重置) */
|
||||
private settingsActions = new Map<string, Set<(key: string) => void>>()
|
||||
private settingsListeners = new Map<string, Set<(values: PluginSettingValues) => void>>()
|
||||
private valuesSaveTimer: NodeJS.Timeout | null = null
|
||||
/** 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')
|
||||
}
|
||||
|
||||
private get valuesFile(): string {
|
||||
return join(app.getPath('userData'), 'aso-plugin-values.json')
|
||||
}
|
||||
|
||||
// ---- 生命周期 ----
|
||||
|
||||
init(): void {
|
||||
try {
|
||||
mkdirSync(this.externalRoot, { recursive: true })
|
||||
} catch {
|
||||
// 已存在或创建失败都继续(扫描时再报)
|
||||
}
|
||||
log.info('plugin host init', {
|
||||
externalRoot: this.externalRoot,
|
||||
externalLoadingEnabled: EXTERNAL_PLUGINS_ENABLED
|
||||
})
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as { disabled?: unknown; settingsNav?: unknown }
|
||||
if (Array.isArray(raw.disabled)) this.disabled = new Set(raw.disabled.filter((d): d is string => typeof d === 'string'))
|
||||
if (Array.isArray(raw.settingsNav))
|
||||
this.settingsNavSet = new Set(raw.settingsNav.filter((d): d is string => typeof d === 'string'))
|
||||
} catch {
|
||||
// 无设置文件 = 全部启用
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(this.valuesFile, 'utf8')) as Record<string, unknown>
|
||||
for (const [pid, kv] of Object.entries(raw)) {
|
||||
if (kv && typeof kv === 'object' && !Array.isArray(kv)) {
|
||||
this.settingsValues.set(pid, { ...(kv as PluginSettingValues) })
|
||||
}
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
/** 同步项目级扩展目录(工作区切换时变更即重扫描;仅通知 UI,不重建 runtime) */
|
||||
setProjectExtensionsDir(dir: string | null): void {
|
||||
const next = dir ?? null
|
||||
if ((this.projectExtensionsDir ?? null) === next) return
|
||||
this.projectExtensionsDir = next
|
||||
void this.scan()
|
||||
}
|
||||
|
||||
/** 扫描内置注册表 + 外置目录并合并(不动已激活状态;reload 才重建)。
|
||||
* 外置源优先级:userData/plugins > 项目 .pi/extensions > 全局 ~/.pi/agent/extensions。 */
|
||||
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 (EXTERNAL_PLUGINS_ENABLED) {
|
||||
const roots: Array<{ root: string; origin: 'user' | 'project' | 'global' }> = [
|
||||
{ root: this.externalRoot, origin: 'user' }
|
||||
]
|
||||
if (this.projectExtensionsDir) roots.push({ root: this.projectExtensionsDir, origin: 'project' })
|
||||
roots.push({ root: join(getAgentDir(), 'extensions'), origin: 'global' })
|
||||
for (const { root, origin } of roots) {
|
||||
if (!existsSync(root)) continue
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = readdirSync(root, { withFileTypes: true })
|
||||
} catch {
|
||||
entries = []
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const dir = join(root, 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)
|
||||
// 仅严格更高版本可遮蔽;平级先扫者胜
|
||||
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,
|
||||
origin,
|
||||
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
|
||||
})
|
||||
continue
|
||||
}
|
||||
byId.set(manifest.id, { manifest, source: 'external', dir, origin })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.resolved = byId
|
||||
// 清掉已卸载插件的设置 schema(值保留在盘上,重装后自动回来)
|
||||
for (const id of [...this.settingsSchemas.keys()]) {
|
||||
if (!byId.has(id)) {
|
||||
this.settingsSchemas.delete(id)
|
||||
this.dropSettingsCallbacks(id)
|
||||
}
|
||||
}
|
||||
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
|
||||
await this.loadExternalMains()
|
||||
const externalCount = [...byId.values()].filter((p) => p.source === 'external').length
|
||||
log.info('scan complete', {
|
||||
builtin: byId.size - externalCount,
|
||||
external: externalCount,
|
||||
disabled: this.disabled.size,
|
||||
projects: this.projectExtensionsDir ?? '-'
|
||||
})
|
||||
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,
|
||||
renderer: typeof raw.entry.renderer === 'string' ? raw.entry.renderer : 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 默认导出必须是插件工厂函数'
|
||||
log.error('external main invalid export', { pluginId: plugin.manifest.id, file })
|
||||
continue
|
||||
}
|
||||
plugin.externalFactory = mod.default as HarnessPluginFactory
|
||||
plugin.error = undefined
|
||||
log.info('external main loaded', { pluginId: plugin.manifest.id, version: plugin.manifest.version })
|
||||
} catch (err) {
|
||||
plugin.externalFactory = undefined
|
||||
plugin.error = `main 加载失败: ${err instanceof Error ? err.message : String(err)}`
|
||||
log.error('external main load failed', { pluginId: plugin.manifest.id, file, err })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 重新扫描 + 重新导入 + 清 UI 状态 + 重建 runtime(= pi /reload 等效) */
|
||||
async reload(): Promise<void> {
|
||||
log.info('reload begin')
|
||||
this.statusItems.clear()
|
||||
this.toolbarButtons.clear()
|
||||
await this.scan()
|
||||
this.send('plugins:uiState', this.uiState())
|
||||
if (this.rebuildRuntime) await this.rebuildRuntime()
|
||||
log.info('reload done')
|
||||
}
|
||||
|
||||
async setEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
log.info('set plugin enabled', { pluginId: id, enabled })
|
||||
if (enabled) this.disabled.delete(id)
|
||||
else this.disabled.add(id)
|
||||
this.persistPluginPrefs()
|
||||
await this.reload()
|
||||
}
|
||||
|
||||
/** 设置入口是否固定到设置页一级导航(仅声明了设置表单的插件可开) */
|
||||
async setSettingsNav(id: string, on: boolean): Promise<void> {
|
||||
const plugin = this.resolved.get(id)
|
||||
if (!plugin || !this.settingsSchemas.has(id)) return
|
||||
log.info('set settings nav', { pluginId: id, on })
|
||||
if (on) this.settingsNavSet.add(id)
|
||||
else this.settingsNavSet.delete(id)
|
||||
this.persistPluginPrefs()
|
||||
this.send('plugins:listChanged', null)
|
||||
}
|
||||
|
||||
private persistPluginPrefs(): void {
|
||||
try {
|
||||
writeFileSync(
|
||||
this.settingsFile,
|
||||
JSON.stringify({ disabled: [...this.disabled], settingsNav: [...this.settingsNavSet] }, null, 2),
|
||||
'utf8'
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('save plugin prefs failed', { err })
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 插件设置表单(声明式 schema + 值持久化 + 动作/变更回推) ----
|
||||
|
||||
/** 激活期回调容器重置(每次插件工厂执行前调用) */
|
||||
private resetSettingsCallbacks(id: string): void {
|
||||
this.settingsActions.delete(id)
|
||||
this.settingsListeners.delete(id)
|
||||
}
|
||||
|
||||
private dropSettingsCallbacks(id: string): void {
|
||||
this.settingsActions.delete(id)
|
||||
this.settingsListeners.delete(id)
|
||||
}
|
||||
|
||||
private settingsSchemasSnapshot(): PluginSettingsSchemas {
|
||||
return Object.fromEntries(this.settingsSchemas)
|
||||
}
|
||||
|
||||
private settingsValuesSnapshot(): PluginSettingsValuesMap {
|
||||
const out: PluginSettingsValuesMap = {}
|
||||
for (const [pid, schema] of this.settingsSchemas) {
|
||||
const stored = this.settingsValues.get(pid) ?? {}
|
||||
// 默认值兜底:未设置过的键回落 schema default
|
||||
const defaults: PluginSettingValues = {}
|
||||
for (const item of schema.items) {
|
||||
if ('default' in item && item.default !== undefined && !(item.key in stored)) defaults[item.key] = item.default
|
||||
}
|
||||
out[pid] = { ...defaults, ...stored }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
settingsSchemasList(): PluginSettingsSchemas {
|
||||
return this.settingsSchemasSnapshot()
|
||||
}
|
||||
|
||||
settingsValuesList(): PluginSettingsValuesMap {
|
||||
return this.settingsValuesSnapshot()
|
||||
}
|
||||
|
||||
setSettingValue(id: string, key: string, value: unknown): void {
|
||||
if (!this.settingsSchemas.has(id)) return
|
||||
const kv = { ...(this.settingsValues.get(id) ?? {}), [key]: value }
|
||||
this.settingsValues.set(id, kv)
|
||||
this.scheduleValuesSave()
|
||||
this.send('plugins:settingsValues', this.settingsValuesSnapshot())
|
||||
for (const fn of this.settingsListeners.get(id) ?? []) {
|
||||
try {
|
||||
fn({ ...(this.settingsValues.get(id) ?? {}) })
|
||||
} catch (err) {
|
||||
log.error('settings listener error', { pluginId: id, err })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settingsAction(id: string, key: string): void {
|
||||
log.debug('settings action', { pluginId: id, key })
|
||||
for (const fn of this.settingsActions.get(id) ?? []) {
|
||||
try {
|
||||
fn(key)
|
||||
} catch (err) {
|
||||
log.error('settings action error', { pluginId: id, key, err })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleValuesSave(): void {
|
||||
if (this.valuesSaveTimer) return
|
||||
this.valuesSaveTimer = setTimeout(() => {
|
||||
this.valuesSaveTimer = null
|
||||
const doc: Record<string, PluginSettingValues> = {}
|
||||
for (const [pid, kv] of this.settingsValues) doc[pid] = kv
|
||||
try {
|
||||
writeFileSync(this.valuesFile, JSON.stringify(doc, null, 2), 'utf8')
|
||||
} catch (err) {
|
||||
log.error('save values failed', { err })
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ---- 查询 ----
|
||||
|
||||
isEnabled(id: string): boolean {
|
||||
return !this.disabled.has(id)
|
||||
}
|
||||
|
||||
list(): PluginInfo[] {
|
||||
return [...this.resolved.values()]
|
||||
.map((p) => ({
|
||||
manifest: p.manifest,
|
||||
source: p.source,
|
||||
origin: p.origin,
|
||||
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),
|
||||
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
|
||||
hasSettings: (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
||||
settingsNav: this.settingsNavSet.has(p.manifest.id) && (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
||||
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
|
||||
}
|
||||
|
||||
/** 外置插件来源目录(管理页「打开目录」用) */
|
||||
pluginDirs(): { user: string[]; project: string[]; global: string[] } {
|
||||
return {
|
||||
user: [this.externalRoot],
|
||||
project: this.projectExtensionsDir ? [this.projectExtensionsDir] : [],
|
||||
global: [join(getAgentDir(), 'extensions')]
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取外置插件渲染层模块源码。渲染层取回后以 Blob URL import——
|
||||
* dev 下页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦截。 */
|
||||
readRendererSource(id: string): string {
|
||||
if (!EXTERNAL_PLUGINS_ENABLED) throw new Error('第三方插件装载已关闭(用户版构建)')
|
||||
const plugin = this.resolved.get(id)
|
||||
const file =
|
||||
plugin?.source === 'external' && plugin.manifest.entry?.renderer
|
||||
? resolve(plugin.dir!, plugin.manifest.entry.renderer)
|
||||
: null
|
||||
if (!file || !existsSync(file)) throw new Error(`插件 ${id} 未提供渲染层模块`)
|
||||
if (statSync(file).size > 8 * 1024 * 1024) throw new Error('渲染层模块超过 8MB 上限')
|
||||
return readFileSync(file, 'utf8')
|
||||
}
|
||||
|
||||
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(禁用/重载路径);设置 schema 一并撤下(导航项随之消失) */
|
||||
private clearPluginUi(pluginId: string): void {
|
||||
this.statusItems.delete(pluginId)
|
||||
this.toolbarButtons.delete(pluginId)
|
||||
this.settingsSchemas.delete(pluginId)
|
||||
this.dropSettingsCallbacks(pluginId)
|
||||
this.send('plugins:uiState', this.uiState())
|
||||
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
|
||||
}
|
||||
|
||||
// ---- 深链 ----
|
||||
|
||||
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) {
|
||||
log.error('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
|
||||
this.resetSettingsCallbacks(plugin.manifest.id)
|
||||
try {
|
||||
const result = factory(this.buildApi(plugin, pi))
|
||||
const pluginId = plugin.manifest.id
|
||||
if (typeof result === 'function') {
|
||||
disposers.push(result)
|
||||
log.info('plugin activated', { pluginId, source: plugin.source })
|
||||
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
|
||||
// 异步工厂:限时兜底(挂死不拖垮 runtime 重建),reject 必须接住(不得成为 unhandled rejection)
|
||||
void withTimeout(result as Promise<void | (() => void)>, PLUGIN_ACTIVATION_TIMEOUT_MS, `插件 ${pluginId} 激活`)
|
||||
.then((d) => {
|
||||
if (typeof d === 'function') disposers.push(d)
|
||||
log.info('plugin activated', { pluginId, source: plugin.source, async: true })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||||
log.error('plugin activation failed', { pluginId, err })
|
||||
this.clearPluginUi(pluginId)
|
||||
this.send('plugins:listChanged', null)
|
||||
})
|
||||
} else {
|
||||
log.info('plugin activated', { pluginId, source: plugin.source })
|
||||
}
|
||||
} catch (err) {
|
||||
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||||
log.error('plugin activation failed', { pluginId: plugin.manifest.id, err })
|
||||
this.clearPluginUi(plugin.manifest.id)
|
||||
this.send('plugins:listChanged', null)
|
||||
}
|
||||
}
|
||||
pi.on('session_shutdown', () => {
|
||||
for (const dispose of disposers) {
|
||||
try {
|
||||
dispose()
|
||||
} catch (err) {
|
||||
log.error('plugin dispose error', { err })
|
||||
}
|
||||
}
|
||||
disposers.length = 0
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildApi(plugin: ResolvedPlugin, pi: ExtensionAPI) {
|
||||
const id = plugin.manifest.id
|
||||
const actions = (() => {
|
||||
let set = this.settingsActions.get(id)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.settingsActions.set(id, set)
|
||||
}
|
||||
return set
|
||||
})()
|
||||
const listeners = (() => {
|
||||
let set = this.settingsListeners.get(id)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.settingsListeners.set(id, set)
|
||||
}
|
||||
return set
|
||||
})()
|
||||
return {
|
||||
pi,
|
||||
manifest: plugin.manifest,
|
||||
storage: this.storageFor(id),
|
||||
log: {
|
||||
debug: (message: string, kv?: Record<string, unknown>) => pluginLog.debug(message, { pluginId: id, ...kv }),
|
||||
info: (message: string, kv?: Record<string, unknown>) => pluginLog.info(message, { pluginId: id, ...kv }),
|
||||
warn: (message: string, kv?: Record<string, unknown>) => pluginLog.warn(message, { pluginId: id, ...kv }),
|
||||
error: (message: string, kv?: Record<string, unknown>) => pluginLog.error(message, { pluginId: id, ...kv })
|
||||
},
|
||||
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),
|
||||
registerSettings: (schema: PluginSettingsSchema) => {
|
||||
if (!schema || !Array.isArray(schema.items)) return
|
||||
if (schema.items.length === 0) this.settingsSchemas.delete(id)
|
||||
else this.settingsSchemas.set(id, schema)
|
||||
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
|
||||
this.send('plugins:listChanged', null)
|
||||
},
|
||||
getSettingValues: (): PluginSettingValues => {
|
||||
const schema = this.settingsSchemas.get(id)
|
||||
const stored = this.settingsValues.get(id) ?? {}
|
||||
const defaults: PluginSettingValues = {}
|
||||
if (schema) {
|
||||
for (const item of schema.items) {
|
||||
if ('default' in item && item.default !== undefined && !(item.key in stored)) defaults[item.key] = item.default
|
||||
}
|
||||
}
|
||||
return { ...defaults, ...stored }
|
||||
},
|
||||
onSettingsChange: (listener: (values: PluginSettingValues) => void) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
onSettingsAction: (listener: (key: string) => void) => {
|
||||
actions.add(listener)
|
||||
return () => actions.delete(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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())
|
||||
// .mjs/.js 必须以 JS MIME 返回——渲染层动态 import(ESM 模块加载)强校验 content-type
|
||||
const lower = file.toLowerCase()
|
||||
const contentType =
|
||||
lower.endsWith('.mjs') || lower.endsWith('.js')
|
||||
? 'text/javascript'
|
||||
: (res.headers.get('content-type') ?? 'application/octet-stream')
|
||||
// no-store:热重载换 src 即取新文件,避免缓存妨碍开发预览
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'content-type': contentType,
|
||||
'cache-control': 'no-store'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('plugin:// 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 }
|
||||
@@ -0,0 +1,334 @@
|
||||
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;
|
||||
* 4. 目标不在 AX 快照里(纯 div/span + JS 点击的元素,如商品规格/容量按钮)时,
|
||||
* 用 browser_find_text 按可见文本或 CSS 选择器在 DOM 中定位,拿到 ref 后照常操作;
|
||||
* 5. 一切定位手段都失效(canvas、封闭 shadow DOM)才 browser_screenshot + browser_click_point。
|
||||
*/
|
||||
|
||||
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_find_text',
|
||||
label: '按文本定位元素',
|
||||
description:
|
||||
'按可见文本或 CSS 选择器在页面 DOM 中定位元素并返回新 ref(text 与 selector 二选一)。不依赖无障碍树,专治 observe/find 快照里缺失的纯 div/span 可点击元素(商品规格、容量按钮、自定义控件)。text 匹配元素自身的直接文本(空白归一后精确匹配;contains=true 改为包含)。多个命中时不发 ref,返回候选列表(含 rect/上下文),带 index 重调以选定。命中并注册 ref 时作废旧 ref。支持开放 shadow DOM 与同源 iframe(iframe 内元素只能看候选,点击需改用坐标)。注意:快照配额会挤掉普通文本节点,即使 observe 看似完整,找不到目标时也应优先改用本工具。',
|
||||
promptSnippet: 'browser_find_text: 按文本/选择器定位(AX 快照缺失时用)',
|
||||
parameters: Type.Object({
|
||||
text: Type.Optional(Type.String({ description: '元素自身可见文本,如 "256GB"' })),
|
||||
selector: Type.Optional(Type.String({ description: 'CSS 选择器(文本重复或目标无文本时用)' })),
|
||||
contains: Type.Optional(Type.Boolean({ description: '文本包含匹配(默认精确匹配)' })),
|
||||
index: Type.Optional(Type.Number({ minimum: 1, description: '多个候选时指定第几个(1 起,来自上次返回的候选列表)' })),
|
||||
tabId: TAB_ID_PARAM
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const result = await getBrowserController().findText(
|
||||
{ text: params.text, selector: params.selector, contains: params.contains, index: params.index },
|
||||
params.tabId
|
||||
)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result) }], details: result }
|
||||
}
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
name: 'browser_click',
|
||||
label: '点击元素',
|
||||
description: '点击最新 observe/find/find_text 结果中的指定 ref。页面会短暂高亮目标。点击导致的重渲染会让旧 ref 失效,操作后需重新观察。',
|
||||
promptSnippet: 'browser_click: 点击 ref',
|
||||
parameters: Type.Object({ ref: Type.String({ description: '元素引用(来自 observe/find/find_text)' }), 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_click_point',
|
||||
label: '坐标点击(兜底)',
|
||||
description:
|
||||
'点击最近一次 browser_screenshot 图像上的像素坐标 (x, y)。仅当 observe/find/find_text 都无法定位目标时的最后手段(canvas、封闭 shadow DOM、混淆页面)。点击前必须先截最新图;坐标越界会直接报错。',
|
||||
promptSnippet: 'browser_click_point: 截图坐标兜底点击(最后手段)',
|
||||
parameters: Type.Object({
|
||||
x: Type.Number({ minimum: 0, description: '截图图像上的 x 像素' }),
|
||||
y: Type.Number({ minimum: 0, description: '截图图像上的 y 像素' }),
|
||||
tabId: TAB_ID_PARAM
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const state = await getBrowserController().clickPoint(params.x, params.y, params.tabId)
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ clicked: [params.x, params.y], url: state.tabs.find((t) => t.tabId === state.activeTabId)?.url }) }], details: 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))
|
||||
@@ -1,77 +0,0 @@
|
||||
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]
|
||||
@@ -1,25 +1,46 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { contextBridge, ipcRenderer, webFrame } 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'
|
||||
import type { PluginSettingsSchemas, PluginSettingsValuesMap } from '@aso/plugin-sdk'
|
||||
|
||||
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)
|
||||
@@ -61,9 +82,23 @@ 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'),
|
||||
setSystemPrompt: (text: string): Promise<void> => ipcRenderer.invoke('settings:setPrompt', text),
|
||||
getSystemPrompt: (): Promise<string> => ipcRenderer.invoke('settings:getSystemPrompt'),
|
||||
setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id),
|
||||
setThemeMode: (mode: ThemeMode): Promise<void> => ipcRenderer.invoke('settings:setThemeMode', mode),
|
||||
setResourceEnabled: (kind: 'skill' | 'command', name: string, enabled: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('settings:setResourceEnabled', kind, name, enabled),
|
||||
resolveApproval: (id: string, approved: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('approval:resolve', id, approved),
|
||||
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
|
||||
@@ -72,6 +107,95 @@ 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'),
|
||||
pluginsDirs: (): Promise<{ user: string[]; project: string[]; global: string[] }> =>
|
||||
ipcRenderer.invoke('plugins:dirs'),
|
||||
pluginsReadRenderer: (id: string): Promise<string> => ipcRenderer.invoke('plugins:readRenderer', id),
|
||||
pluginsSettingsSchemas: (): Promise<PluginSettingsSchemas> => ipcRenderer.invoke('plugins:settingsSchemas'),
|
||||
pluginsSettingsValues: (): Promise<PluginSettingsValuesMap> => ipcRenderer.invoke('plugins:settingsValues'),
|
||||
pluginsSetSettingValue: (id: string, key: string, value: unknown): Promise<void> =>
|
||||
ipcRenderer.invoke('plugins:setSettingValue', id, key, value),
|
||||
pluginsSetSettingsNav: (id: string, on: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('plugins:setSettingsNav', id, on),
|
||||
pluginsSettingsAction: (id: string, key: string): Promise<void> =>
|
||||
ipcRenderer.invoke('plugins:settingsAction', id, key),
|
||||
pluginsPickPath: (mode: 'file' | 'directory' | 'image'): Promise<string | null> =>
|
||||
ipcRenderer.invoke('plugins:pickPath', mode),
|
||||
pluginsReadImage: (path: string): Promise<string | null> => ipcRenderer.invoke('plugins:readImage', path),
|
||||
onPluginsSettingsSchemas: (callback: (schemas: PluginSettingsSchemas) => void): (() => void) => {
|
||||
const listener = (_e: Electron.IpcRendererEvent, schemas: PluginSettingsSchemas): void => callback(schemas)
|
||||
ipcRenderer.on('plugins:settingsSchemas', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('plugins:settingsSchemas', listener)
|
||||
}
|
||||
},
|
||||
onPluginsSettingsValues: (callback: (values: PluginSettingsValuesMap) => void): (() => void) => {
|
||||
const listener = (_e: Electron.IpcRendererEvent, values: PluginSettingsValuesMap): void => callback(values)
|
||||
ipcRenderer.on('plugins:settingsValues', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('plugins:settingsValues', listener)
|
||||
}
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'"
|
||||
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' plugin: blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self' plugin:"
|
||||
/>
|
||||
<title>Inkreach Agent</title>
|
||||
</head>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { JSX, ComponentType } from 'react'
|
||||
import { LuGlobe } from 'react-icons/lu'
|
||||
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'
|
||||
@@ -8,13 +10,85 @@ import { BottomSlot } from './components/BottomSlot'
|
||||
import { Composer } from './components/Composer'
|
||||
import { SettingsModal } from './components/SettingsModal'
|
||||
import { Search } from './components/Search'
|
||||
import { applyThemeColor } from './lib/theme'
|
||||
import { BrowserPanel } from './components/browser/BrowserPanel'
|
||||
import { SidePanel, type PanelTabIcon } from './components/SidePanel'
|
||||
import { PluginPageFrame } from './plugins/PluginPageFrame'
|
||||
import { PluginIconValue } from './plugins/plugin-icons'
|
||||
import {
|
||||
useExternalPluginComponents,
|
||||
usePluginRendererLoader,
|
||||
usePluginRenderStatus,
|
||||
retryPluginRenderer
|
||||
} from './plugins/renderer-host'
|
||||
import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui'
|
||||
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 }
|
||||
}
|
||||
|
||||
/** 外置 renderer 槽位的统一等待/失败态:装载中显示 LoadingState,失败显示 ErrorState + 重试 */
|
||||
function PluginRendererSlot({ pluginId }: { pluginId: string }): JSX.Element {
|
||||
const status = usePluginRenderStatus(pluginId)
|
||||
if (!status || status.state !== 'error') return <LoadingState className="h-full" />
|
||||
return (
|
||||
<ErrorState
|
||||
className="h-full"
|
||||
title="插件界面装载失败"
|
||||
description={status.message}
|
||||
onRetry={() => retryPluginRenderer(pluginId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const {
|
||||
state,
|
||||
sessions,
|
||||
workspaces,
|
||||
forkPoints,
|
||||
navigating,
|
||||
forking,
|
||||
send,
|
||||
stop,
|
||||
clearError,
|
||||
@@ -26,32 +100,181 @@ export default function App(): JSX.Element {
|
||||
resolveApproval,
|
||||
setPermissionMode,
|
||||
refreshSessions,
|
||||
openWorkspace
|
||||
openWorkspace,
|
||||
setThinkingLevel,
|
||||
compact,
|
||||
abortCompaction,
|
||||
setAutoCompaction,
|
||||
dismissCompactionNotice,
|
||||
navigateTree,
|
||||
forkSession,
|
||||
forkSessionAt
|
||||
} = 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 externalComponents = useExternalPluginComponents()
|
||||
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 || p.hasRenderer)),
|
||||
[plugins]
|
||||
)
|
||||
/** 热重载令牌:变化即让插件页 iframe 换 src 整页重载(手动重载/开发期 watch 触发) */
|
||||
const [pluginReloadToken, setPluginReloadToken] = useState(0)
|
||||
// 外置插件 renderer 模块装载(entry.renderer → 宿主内 React 组件;仅开发版)
|
||||
usePluginRendererLoader(plugins, pluginReloadToken)
|
||||
|
||||
/** 打开视图('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 && !plugin.hasRenderer)) 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+, 设置 / Esc 关闭弹窗
|
||||
// 全局快捷键:Ctrl+N 新会话(全局,切回聊天)/ Ctrl+K 搜索 / Ctrl+, 设置(后两者仅聊天视图)
|
||||
// Ctrl+= / Ctrl+- 界面缩放(Ctrl+0 复位)/ Esc 关闭弹窗(不切视图,回聊天走侧栏点击)
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
const inChat = activeViewRef.current.view === 'chat'
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
||||
e.preventDefault()
|
||||
newSession()
|
||||
} else if (e.ctrlKey && e.key.toLowerCase() === 'k') {
|
||||
handleNewSession()
|
||||
} else if (inChat && e.ctrlKey && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
setSearchOpen(true)
|
||||
} else if (e.ctrlKey && e.key === ',') {
|
||||
} else if (inChat && 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)
|
||||
@@ -59,16 +282,26 @@ export default function App(): JSX.Element {
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [newSession])
|
||||
}, [handleNewSession])
|
||||
|
||||
const handlePickSuggestion = (text: string): void => {
|
||||
prefillRef.current = text
|
||||
}
|
||||
|
||||
// 选区操作(解释/改进/精简/自定义指令):把指令 + 引用发给 Agent
|
||||
const handleApplySelection = (instruction: string, selection: string): void => {
|
||||
send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`)
|
||||
}
|
||||
// 划选「引用」:选中文本以 blockquote 形式注入输入框开头(保留已有草稿)
|
||||
const [pendingQuote, setPendingQuote] = useState<string | null>(null)
|
||||
const handleQuoteSelection = useCallback((selection: string) => {
|
||||
setPendingQuote(`> ${selection}\n\n`)
|
||||
}, [])
|
||||
const consumePendingQuote = useCallback(() => setPendingQuote(null), [])
|
||||
|
||||
// 回溯入口(稳定引用:MessageItem memo 依赖它,内联箭头每次渲染都会失效 memo)
|
||||
const handleRewind = useCallback(
|
||||
(entryId: string, summarize: boolean): void => {
|
||||
navigateTree(entryId, { summarize })
|
||||
},
|
||||
[navigateTree]
|
||||
)
|
||||
|
||||
// 标题栏会话名:优先当前会话名,其次会话列表里的名称/首条消息
|
||||
const activeMeta = state.sessionState?.sessionId
|
||||
@@ -80,13 +313,96 @@ 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 icon = p.manifest.icon
|
||||
const Icon = ({ size, className }: { size?: number; className?: string }): JSX.Element => (
|
||||
<PluginIconValue icon={icon} 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 []
|
||||
const PanelComponent = plugin.hasRenderer ? externalComponents.get(id)?.panel : undefined
|
||||
return [
|
||||
{
|
||||
id,
|
||||
title: plugin.manifest.name,
|
||||
icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon),
|
||||
content: PanelComponent ? (
|
||||
<PanelComponent />
|
||||
) : plugin.hasRenderer ? (
|
||||
<PluginRendererSlot pluginId={id} />
|
||||
) : plugin.hasPage ? (
|
||||
<PluginPageFrame plugin={plugin} onOpenView={handleOpenView} reloadToken={pluginReloadToken} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[12px] text-ink-3">插件未提供页面</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
})
|
||||
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:内置组件 / 外置 renderer 注册组件 / 外置 iframe 页)
|
||||
const activePlugin: PluginInfo | undefined =
|
||||
activeView.view === 'chat' ? undefined : pluginById.get(activeView.view)
|
||||
const ActivePluginComponent: ComponentType | undefined = activePlugin
|
||||
? activePlugin.hasComponent
|
||||
? BUILTIN_PLUGIN_COMPONENTS[activeView.view]
|
||||
: externalComponents.get(activeView.view)?.view
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full bg-page text-ink antialiased">
|
||||
<SidebarNav
|
||||
sessionState={state.sessionState}
|
||||
sessions={sessions}
|
||||
onNewSession={newSession}
|
||||
onOpenSession={openSession}
|
||||
onNewSession={handleNewSession}
|
||||
onOpenSession={handleOpenSession}
|
||||
onRenameSession={renameSession}
|
||||
onDeleteSession={deleteSession}
|
||||
onOpenSearch={() => setSearchOpen(true)}
|
||||
@@ -94,52 +410,138 @@ 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={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}
|
||||
<TitleBar
|
||||
title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name}
|
||||
panelOpen={sideOpen}
|
||||
onTogglePanel={toggleSide}
|
||||
extra={<PluginStatusCapsules entries={pluginsUi.status} onOpenView={handleOpenView} />}
|
||||
/>
|
||||
<BottomSlot
|
||||
approvals={state.approvals}
|
||||
onResolve={resolveApproval}
|
||||
composer={
|
||||
<Composer
|
||||
<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}
|
||||
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}
|
||||
onSend={send}
|
||||
onStop={stop}
|
||||
onNewSession={newSession}
|
||||
onSetModel={setModel}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onPermissionMode={setPermissionMode}
|
||||
prefillRef={prefillRef}
|
||||
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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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.hasRenderer ? (
|
||||
<PluginRendererSlot pluginId={activeView.view} />
|
||||
) : 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={newSession}
|
||||
onNewSession={handleNewSession}
|
||||
onRefreshSessions={refreshSessions}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenSession={openSession}
|
||||
onOpenSession={handleOpenSession}
|
||||
/>
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
|
||||
@@ -90,17 +90,23 @@ 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(
|
||||
() => () => {
|
||||
@@ -110,7 +116,7 @@ export function ApprovalCard({
|
||||
)
|
||||
|
||||
const settle = (approved: boolean, note: string): void => {
|
||||
if (settled.current) return
|
||||
if (settledNote) return
|
||||
settled.current = true
|
||||
setSent(note)
|
||||
if (submitTimer.current) clearTimeout(submitTimer.current)
|
||||
@@ -118,7 +124,7 @@ export function ApprovalCard({
|
||||
}
|
||||
|
||||
const pick = (index: number): void => {
|
||||
if (sent) return
|
||||
if (settledNote) return
|
||||
setPicked(index)
|
||||
// radio 自动前进:480ms 后提交(官方 single-choice 行为)
|
||||
if (submitTimer.current) clearTimeout(submitTimer.current)
|
||||
@@ -127,8 +133,8 @@ export function ApprovalCard({
|
||||
}, 480)
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
const ok = sent === '已允许执行'
|
||||
if (settledNote) {
|
||||
const ok = settledNote === '已允许执行'
|
||||
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
|
||||
@@ -147,7 +153,7 @@ export function ApprovalCard({
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
{sent}
|
||||
{settledNote}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,41 +1,18 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { ApprovalRequest } from '@shared/types'
|
||||
import { ApprovalCard } from './ApprovalCard'
|
||||
import { ApprovalCard, type ApprovalOutcome } from './ApprovalCard'
|
||||
|
||||
/**
|
||||
* 底部插槽(抽屉):审批卡出现时从 Chatbox 位置升起替换输入框,
|
||||
* 结算后输入框滑回。两行用 grid-template-rows 0fr↔1fr 过渡做真实高度动画,
|
||||
* 交叉淡入淡出;Composer 始终挂载(草稿不丢)。已结算的卡片以结算胶囊态
|
||||
* 多停留约一秒再撤出,避免「啪」一下消失。
|
||||
* 底部插槽(审批抽屉):审批请求出现时,卡片从输入框上方(版面中部)像抽屉
|
||||
* 一样滑出,输入框保留可见可输入(Composer 始终挂载,草稿不丢)。
|
||||
* 一次只展示一张:队列中的后续请求在前一张结算后接入;结算后卡片以结果
|
||||
* 胶囊态停留约一秒,再下滑淡出收起。升起/收起均为 CSS 过渡。
|
||||
*/
|
||||
|
||||
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)'
|
||||
type Phase = 'pending' | 'settled' | 'closing' | 'hidden'
|
||||
|
||||
/** 跟踪从 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
|
||||
}
|
||||
const SETTLED_MS = 900
|
||||
const CLOSE_MS = 240
|
||||
|
||||
export function BottomSlot({
|
||||
approvals,
|
||||
@@ -46,48 +23,80 @@ export function BottomSlot({
|
||||
onResolve: (id: string, approved: boolean) => void
|
||||
composer: ReactNode
|
||||
}): ReactNode {
|
||||
const leaving = useLeavingApprovals(approvals)
|
||||
const showApproval = approvals.length > 0 || leaving.length > 0
|
||||
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'
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Chatbox 行:审批升起时收起(高度 0 + 下沉淡出) */}
|
||||
<div style={{ display: 'grid', gridTemplateRows: showApproval ? '0fr' : '1fr', transition: ROWS_TRANSITION }}>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<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
|
||||
style={{
|
||||
opacity: showApproval ? 0 : 1,
|
||||
transform: showApproval ? 'translateY(10px)' : 'none',
|
||||
transition: FADE_TRANSITION,
|
||||
pointerEvents: showApproval ? 'none' : undefined
|
||||
}}
|
||||
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
|
||||
}
|
||||
>
|
||||
{composer}
|
||||
<ApprovalCard request={shown} outcome={outcomesRef.current.get(shown.id)} onResolve={handleResolve} />
|
||||
</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,7 +1,9 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { LuRotateCw, LuTriangleAlert } from 'react-icons/lu'
|
||||
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
|
||||
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 { AssistantTurn, MessageItem } from './MessageItem'
|
||||
import { SelectionActions } from './SelectionActions'
|
||||
|
||||
@@ -11,6 +13,33 @@ 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 (
|
||||
@@ -33,38 +62,101 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
|
||||
}
|
||||
|
||||
/** 连续 assistant 消息(pi 每个工具轮次一条)合并为一个回合渲染,
|
||||
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。 */
|
||||
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。
|
||||
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
|
||||
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序),
|
||||
* startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。
|
||||
* animate=false(正在补齐历史)时挂载的消息不播入场动画。
|
||||
* 分叉入口:onFork(会话末尾)只给延伸到列表末尾的最后一个回合;
|
||||
* 其余回合用「本回合之后第一条用户消息」的 entryId 走 onForkAt(entryId,'before')
|
||||
* ——新会话包含到本回合答案为止。 */
|
||||
function renderTurns(
|
||||
messages: UiMessage[],
|
||||
tools: Record<string, ToolState>,
|
||||
streaming: boolean,
|
||||
pendingApprovals: ApprovalRequest[]
|
||||
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
|
||||
): 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 自动收起后
|
||||
// 续跑又展开,形成展开/收起反复横跳(慢工具/长会话反复压缩尤甚)。
|
||||
// 任何 user 消息(含本地乐观插入)都是硬边界——否则本地用户消息尾随时,
|
||||
// 前一个历史回合会被误激活展开(实测捕获:h- 前缀历史回合 false->true)。
|
||||
let liveEnd = j
|
||||
while (liveEnd < messages.length && messages[liveEnd].role !== 'user') {
|
||||
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}
|
||||
streaming={streaming && isLive}
|
||||
turnActive={turnActive && isLive}
|
||||
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}
|
||||
streaming={streaming && i === messages.length - 1}
|
||||
turnActive={turnActive && i === messages.length - 1}
|
||||
pendingApprovals={pendingApprovals}
|
||||
scrollPinnedRef={pinnedRef}
|
||||
forkEntryId={forkEntryId}
|
||||
rewinding={rewinding}
|
||||
onRewind={onRewind}
|
||||
animate={animate}
|
||||
/>
|
||||
)
|
||||
i += 1
|
||||
@@ -73,32 +165,116 @@ 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,
|
||||
onApplySelection
|
||||
onQuoteSelection,
|
||||
visible = true
|
||||
}: {
|
||||
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
|
||||
onApplySelection: (instruction: string, selection: string) => void
|
||||
onQuoteSelection: (selection: string) => void
|
||||
/** 聊天列是否可见(切到插件视图时 display:none 保活;隐藏期滚动测量为 0,切回重新吸底) */
|
||||
visible?: boolean
|
||||
}): 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
|
||||
@@ -109,52 +285,148 @@ export function ChatView({
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && pinnedRef.current) {
|
||||
if (!el || !pinnedRef.current) return
|
||||
// rAF 合帧:流式 delta 一帧内多次触发时只吸底一次,
|
||||
// 且读 scrollHeight 的强制布局每帧至多一次
|
||||
const raf = requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [messages, tools, streaming])
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [messages, tools, streaming, visible])
|
||||
|
||||
// 压缩结果通知 8 秒后自动消退
|
||||
useEffect(() => {
|
||||
if (!compactionNotice) return
|
||||
const timer = window.setTimeout(onDismissCompactionNotice, 8000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [compactionNotice, onDismissCompactionNotice])
|
||||
|
||||
const hasContent = messages.length > 0
|
||||
// 分叉入口挂在每个回合的复制按钮旁:回合进行中/压缩/回溯时收起
|
||||
const canFork = !turnActive && !streaming && !compacting && !navigating
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{(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>
|
||||
</>
|
||||
) : (
|
||||
<div className="break-all">{error}</div>
|
||||
)}
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{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" />
|
||||
) : (
|
||||
<LuFoldVertical size={14} className="mt-0.5 shrink-0 text-accent-ink" />
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{renderTurns(messages, tools, streaming, pendingApprovals)}
|
||||
{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
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full">
|
||||
@@ -163,8 +435,8 @@ export function ChatView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 划选消息文本后浮出的操作条 */}
|
||||
<SelectionActions containerRef={scrollRef} onApply={onApplySelection} />
|
||||
{/* 划选消息文本后浮出的操作条:引用进输入框 / 复制 */}
|
||||
<SelectionActions containerRef={scrollRef} onQuote={onQuoteSelection} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { JSX, RefObject } from 'react'
|
||||
import { LuCheck, LuFileText, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
|
||||
import type { AttachmentInfo, ChatResources, ModelCatalog, ModelInfo, PermissionMode, WorkspaceFile } from '@shared/types'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Prompt Bar(beautifului.dev #prompt-bar):
|
||||
* 14px 圆角容器 + 五列网格(+/输入/模型/权限/发送),focus 边框加深。
|
||||
* Prompt Bar(beautifului.dev #prompt-bar,布局仿 ZCode Chatbox):
|
||||
* 14px 圆角容器,输入区整行在上,工具行在下(左「+」、右模型/权限/发送),
|
||||
* focus 边框加深。
|
||||
* "/" 命令面板:应用内动作 + pi 文件命令模板 + /skill: 技能(发送时由 SDK 展开);
|
||||
* "@" 引用工作区文件;"+" 添加附件(图片为真实 ImageContent,文本文件内容注入,其余 @path 引用);
|
||||
* 支持直接粘贴截图(clipboard 图片 → 附件)。
|
||||
@@ -76,6 +88,18 @@ 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,
|
||||
@@ -84,13 +108,24 @@ export function Composer({
|
||||
permissionMode = 'confirm',
|
||||
sessionKey,
|
||||
sessionMessageCount,
|
||||
stats,
|
||||
thinkingLevel,
|
||||
thinkingLevels = [],
|
||||
compacting,
|
||||
autoCompactionEnabled = true,
|
||||
onSend,
|
||||
onStop,
|
||||
onNewSession,
|
||||
onSetModel,
|
||||
onOpenSettings,
|
||||
onPermissionMode,
|
||||
prefillRef
|
||||
onSetThinkingLevel,
|
||||
onCompact,
|
||||
onAbortCompaction,
|
||||
onToggleAutoCompaction,
|
||||
prefillRef,
|
||||
pendingQuote = null,
|
||||
onQuoteConsumed
|
||||
}: {
|
||||
streaming: boolean
|
||||
/** 排队消息(steering 先于 followUp 送达) */
|
||||
@@ -102,20 +137,42 @@ 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[]>([])
|
||||
@@ -129,6 +186,7 @@ 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('') }
|
||||
@@ -136,10 +194,12 @@ export function Composer({
|
||||
[onNewSession, onOpenSettings, onStop, streaming]
|
||||
)
|
||||
|
||||
// "/" 命令面板:输入以 / 开头且当前词无空格时触发
|
||||
// "/" 命令面板:输入以 / 开头且当前词无空格时触发;
|
||||
// /compact 例外——允许带参数(如「/compact 保留表设计讨论」),带参数时保持面板打开
|
||||
const trimmed = value.trimStart()
|
||||
const slashExec = trimmed === value ? /^\/(\S*)$/.exec(trimmed) : null
|
||||
const slashQuery = slashExec ? (slashExec[1] ?? '').toLowerCase() : null
|
||||
const compactWithArgs = /^\/compact\s+\S/.test(trimmed)
|
||||
const slashQuery = slashExec ? (slashExec[1] ?? '').toLowerCase() : compactWithArgs ? 'compact' : null
|
||||
|
||||
// "@ " 引用文件:末个 @ 词令牌触发
|
||||
const atMatch = /@([^\s@]*)$/.exec(value)
|
||||
@@ -368,7 +428,8 @@ export function Composer({
|
||||
}, [menuOpen])
|
||||
const closeMenu = (): void => {
|
||||
if (atMatch) setValue(value.replace(/@[^\s@]*$/, ''))
|
||||
else if (slashQuery !== null) setValue('')
|
||||
else if (slashExec) setValue('')
|
||||
// 带参数的 /compact:仅关闭面板,保留已输入的指令
|
||||
}
|
||||
|
||||
useEffect(() => setActiveIdx(0), [value])
|
||||
@@ -382,6 +443,19 @@ export function Composer({
|
||||
}
|
||||
}, [prefillRef])
|
||||
|
||||
// 划选「引用」→ 注入输入框开头(已有草稿顺延到引用块之后),光标落在引用块末尾
|
||||
useEffect(() => {
|
||||
if (!pendingQuote) return
|
||||
setValue((current) => (current.trim() ? `${pendingQuote}${current}` : pendingQuote))
|
||||
onQuoteConsumed?.()
|
||||
const el = textareaRef.current
|
||||
if (el) {
|
||||
el.focus()
|
||||
const pos = pendingQuote.length
|
||||
el.setSelectionRange(pos, pos)
|
||||
}
|
||||
}, [pendingQuote, onQuoteConsumed])
|
||||
|
||||
// 草稿实时保存(发送后输入框清空,草稿随之清除)
|
||||
useEffect(() => {
|
||||
saveDraft(value)
|
||||
@@ -410,6 +484,11 @@ 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
|
||||
@@ -423,6 +502,7 @@ export function Composer({
|
||||
return
|
||||
}
|
||||
setModelPos(anchorOf(modelBtnRef.current))
|
||||
setHoveredProvider(null)
|
||||
setModelOpen((open) => {
|
||||
if (!open && !catalogLoadedRef.current) {
|
||||
catalogLoadedRef.current = true
|
||||
@@ -446,9 +526,40 @@ 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) {
|
||||
@@ -562,6 +673,28 @@ export function Composer({
|
||||
|
||||
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
|
||||
const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? []
|
||||
|
||||
// 服务商行的悬浮跟进:离开行时有 120ms 宽限,让鼠标能跨过缝隙进入悬浮模型列表
|
||||
const enterProvider = (id: string, el: HTMLElement): void => {
|
||||
if (hoverGraceRef.current !== null) {
|
||||
window.clearTimeout(hoverGraceRef.current)
|
||||
hoverGraceRef.current = null
|
||||
}
|
||||
setHoveredProvider({ id, rect: el.getBoundingClientRect() })
|
||||
}
|
||||
const leaveProvider = (): void => {
|
||||
if (hoverGraceRef.current !== null) window.clearTimeout(hoverGraceRef.current)
|
||||
hoverGraceRef.current = window.setTimeout(() => {
|
||||
hoverGraceRef.current = null
|
||||
setHoveredProvider(null)
|
||||
}, 120)
|
||||
}
|
||||
const keepProviderFlyout = (): void => {
|
||||
if (hoverGraceRef.current !== null) {
|
||||
window.clearTimeout(hoverGraceRef.current)
|
||||
hoverGraceRef.current = null
|
||||
}
|
||||
}
|
||||
const hasContentAttachment = attachments.some((a) => (a.data && a.mimeType.startsWith('image/')) || a.text)
|
||||
const canSend = !disabled && (value.trim().length > 0 || hasContentAttachment)
|
||||
|
||||
@@ -733,21 +866,8 @@ export function Composer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{/* 输入区整行在上(仿 ZCode),工具行独立在下 */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
@@ -759,71 +879,122 @@ export function Composer({
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? 'Agent 会话未就绪…' : streaming ? '继续输入以排队后续修改…' : '写下消息,/ 命令与技能、@ 引用文件'}
|
||||
aria-label="Prompt"
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
|
||||
{/* 模型选择 */}
|
||||
<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 ? (
|
||||
{/* 工具行:左「+」,右 模型 / 权限 / 发送 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* +:添加附件 */}
|
||||
<button
|
||||
type="button"
|
||||
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]"
|
||||
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"
|
||||
>
|
||||
<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 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>
|
||||
) : (
|
||||
|
||||
<div className="min-w-0 flex-1" />
|
||||
|
||||
{/* 上下文用量 pill(Usage Card) */}
|
||||
<button
|
||||
ref={usageBtnRef}
|
||||
type="button"
|
||||
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)' }}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<LuGauge size={13} className={compacting ? 'animate-pulse' : ''} />
|
||||
{stats?.context?.percent != null ? formatPercent(stats.context.percent) : '上下文'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 模型选择 */}
|
||||
<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>
|
||||
|
||||
{/* 命令 / 技能 / 文件 菜单(portal 覆盖层,向上弹出) */}
|
||||
{menuOpen && menuPos && (
|
||||
@@ -913,41 +1084,95 @@ export function Composer({
|
||||
</ComposerMenu>
|
||||
)}
|
||||
|
||||
{/* 模型选择菜单(portal,向上弹出) */}
|
||||
{/* 思考等级菜单(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),
|
||||
点击模型即切换 */}
|
||||
{modelOpen && modelPos && (
|
||||
<ComposerMenu anchor={modelPos} align="right" onClose={() => setModelOpen(false)}>
|
||||
<div className="w-72">
|
||||
<div className="max-h-80 overflow-y-auto p-1.5">
|
||||
{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 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>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-line p-1.5">
|
||||
)
|
||||
})}
|
||||
<div className="mt-1 border-t border-line pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -962,6 +1187,50 @@ export function Composer({
|
||||
</div>
|
||||
</ComposerMenu>
|
||||
)}
|
||||
|
||||
{/* 悬浮模型列表:悬浮服务商行时出现在其左侧(portal 到 body,
|
||||
ComposerMenu 的 overflow-hidden 会裁剪子元素) */}
|
||||
{modelOpen &&
|
||||
hoveredProvider &&
|
||||
(() => {
|
||||
const p = providers.find((x) => x.id === hoveredProvider.id)
|
||||
if (!p) return null
|
||||
return createPortal(
|
||||
<div
|
||||
data-model-flyout
|
||||
className="fixed z-40 w-64 overflow-hidden rounded-card border border-line bg-surface shadow-overlay"
|
||||
style={{
|
||||
right: window.innerWidth - hoveredProvider.rect.left + 8,
|
||||
bottom: window.innerHeight - hoveredProvider.rect.bottom,
|
||||
maxHeight: 336
|
||||
}}
|
||||
onMouseEnter={keepProviderFlyout}
|
||||
onMouseLeave={leaveProvider}
|
||||
>
|
||||
<div className="max-h-[330px] overflow-y-auto p-1.5">
|
||||
{p.models.map((m) => {
|
||||
const selected = model?.provider === p.id && model?.id === m.id
|
||||
return (
|
||||
<button
|
||||
key={`${p.id}/${m.id}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSetModel(p.id, m.id)
|
||||
setModelOpen(false)
|
||||
setHoveredProvider(null)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink">{m.name}</span>
|
||||
{selected && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkCjkFriendly from 'remark-cjk-friendly'
|
||||
import remarkCjkFriendlyGfmStrikethrough from 'remark-cjk-friendly-gfm-strikethrough'
|
||||
import {
|
||||
useLLMOutput,
|
||||
type BlockMatch,
|
||||
@@ -21,6 +23,7 @@ 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)做块级解析 ——
|
||||
@@ -30,7 +33,8 @@ import { CodeBlock, type ShikiLine } from './CodeBlock'
|
||||
* 代码块用 Shiki 做真实语法高亮(按需加载语言),保留本项目 CodeBlock 的行号/复制/diff 视图。
|
||||
*/
|
||||
|
||||
/** Shiki 高亮器:github-light 主题 + 常用语言(grammar 在运行时按需动态加载) */
|
||||
/** Shiki 高亮器:双主题注册(浅色/深色随 .dark 切换,切换时按块重新取词元),
|
||||
* grammar 在运行时按需动态加载 */
|
||||
const HIGHLIGHT_LANGS = [
|
||||
'typescript',
|
||||
'javascript',
|
||||
@@ -61,7 +65,7 @@ const HIGHLIGHT_LANGS = [
|
||||
]
|
||||
|
||||
const highlighterPromise: Promise<HighlighterCore> = createHighlighterCore({
|
||||
themes: [bundledThemes['github-light']],
|
||||
themes: [bundledThemes['github-light'], bundledThemes['github-dark']],
|
||||
langs: HIGHLIGHT_LANGS.map((name) => bundledLanguages[name as BundledLanguage]),
|
||||
engine: createOnigurumaEngine(import('shiki/wasm'))
|
||||
})
|
||||
@@ -94,43 +98,111 @@ 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="leading-normal">{children}</p>
|
||||
return <p className="my-4 leading-normal">{children}</p>
|
||||
},
|
||||
/** h1/h2 带强调条(对齐参考渲染),h3 起按层级递减,保证标题有可辨识的层级 */
|
||||
h1({ children }) {
|
||||
return <h3 className="mb-1 mt-3 text-[17px] font-semibold text-ink first:mt-0">{children}</h3>
|
||||
return (
|
||||
<h3 className="mt-5 mb-2 flex items-center gap-2 text-[18px] font-semibold text-ink first:mt-0">
|
||||
<span aria-hidden className="h-[16px] w-[3px] shrink-0 rounded-full bg-accent" />
|
||||
<span className="min-w-0">{children}</span>
|
||||
</h3>
|
||||
)
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h4 className="mb-1 mt-3 text-[15.5px] font-semibold text-ink first:mt-0">{children}</h4>
|
||||
return (
|
||||
<h4 className="mt-5 mb-2 flex items-center gap-2 text-[17px] font-semibold text-ink first:mt-0">
|
||||
<span aria-hidden className="h-[15px] w-[3px] shrink-0 rounded-full bg-accent" />
|
||||
<span className="min-w-0">{children}</span>
|
||||
</h4>
|
||||
)
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h5 className="mb-1 mt-2.5 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h5>
|
||||
return <h5 className="mb-1.5 mt-4 text-[15px] font-semibold text-ink first:mt-0">{children}</h5>
|
||||
},
|
||||
h4({ children }) {
|
||||
return <h6 className="mb-1 mt-2.5 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h6>
|
||||
return <h6 className="mb-1.5 mt-4 text-[14.5px] font-semibold text-ink first:mt-0">{children}</h6>
|
||||
},
|
||||
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>,
|
||||
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>,
|
||||
ul({ children }) {
|
||||
return <ul className="my-2 list-disc space-y-1.5 pl-5 marker:text-ink-3">{children}</ul>
|
||||
return <ul className="my-4 list-disc space-y-2 pl-5 marker:text-ink-3">{children}</ul>
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ol className="my-2 list-decimal space-y-1.5 pl-5 marker:text-ink-3">{children}</ol>
|
||||
return <ol className="my-4 list-decimal space-y-2 pl-5 marker:text-ink-3">{children}</ol>
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="leading-normal">{children}</li>
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="my-3 border-l-2 border-line pl-3 text-[13.5px] italic text-ink-3">
|
||||
<blockquote className="my-4 border-l-2 border-line pl-3 text-[13.5px] italic text-ink-3">
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13.5px]">{children}</table>
|
||||
</div>
|
||||
)
|
||||
@@ -169,7 +241,7 @@ const mdComponents: Components = {
|
||||
},
|
||||
pre({ children }) {
|
||||
return (
|
||||
<pre className="my-3 overflow-x-auto rounded-card bg-inset p-3 text-ink-2">{children}</pre>
|
||||
<pre className="my-4 overflow-x-auto rounded-card bg-inset p-3 text-ink-2">{children}</pre>
|
||||
)
|
||||
},
|
||||
a({ children, href }) {
|
||||
@@ -191,16 +263,22 @@ const mdComponents: Components = {
|
||||
return <del className="text-ink-3">{children}</del>
|
||||
},
|
||||
img({ src, alt }) {
|
||||
return <img src={src} alt={alt} className="my-2 max-w-full rounded-card border border-line" />
|
||||
return <img src={src} alt={alt} className="my-3 max-w-full rounded-card border border-line" />
|
||||
},
|
||||
hr() {
|
||||
return <hr className="my-3 border-line" />
|
||||
return <hr className="my-4 border-line" />
|
||||
}
|
||||
}
|
||||
|
||||
const MarkdownComponent: LLMOutputComponent = ({ blockMatch }) => (
|
||||
<div className="space-y-5 text-base text-ink">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{/* remark-cjk-friendly:CommonMark 的 flanking 规则不认 CJK——`**` 紧邻
|
||||
* 汉字与全角标点(如 出自**《书名》**)时无法开合加粗,解析层修复;
|
||||
* 删除线同理(GFM ~~ 的伴随插件) */}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkCjkFriendly, remarkCjkFriendlyGfmStrikethrough]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{blockMatch.output}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -212,6 +290,9 @@ 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(() => {
|
||||
@@ -224,7 +305,7 @@ const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => {
|
||||
.then((highlighter) => {
|
||||
if (!alive) return
|
||||
try {
|
||||
const result = highlighter.codeToTokens(source, { lang: shikiLang, theme: 'github-light' })
|
||||
const result = highlighter.codeToTokens(source, { lang: shikiLang, theme: shikiTheme })
|
||||
setTokens(result.tokens)
|
||||
} catch {
|
||||
setTokens(null)
|
||||
@@ -234,7 +315,7 @@ const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => {
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [source, shikiLang])
|
||||
}, [source, shikiLang, shikiTheme])
|
||||
|
||||
return (
|
||||
<div className="my-2.5">
|
||||
@@ -261,11 +342,12 @@ function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode {
|
||||
return <Component blockMatch={blockMatch} />
|
||||
}
|
||||
|
||||
export function Markdown({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode {
|
||||
function MarkdownBase({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode {
|
||||
const safeText = useMemo(() => neutralizeSingleTilde(text), [text])
|
||||
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
|
||||
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
|
||||
const { blockMatches } = useLLMOutput({
|
||||
llmOutput: text,
|
||||
llmOutput: safeText,
|
||||
isStreamFinished: !streaming,
|
||||
fallbackBlock: markdownFallback,
|
||||
blocks: [codeBlock]
|
||||
@@ -279,3 +361,7 @@ export function Markdown({ text, streaming = false }: { text: string; streaming?
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** memo:流式 delta 会让整份消息列表重渲染,历史消息的 text/streaming 不变,
|
||||
* 直接跳过 react-markdown 的整棵 vdom diff —— 这是流式卡顿的大头。 */
|
||||
export const Markdown = memo(MarkdownBase)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, 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, LuTriangleAlert, LuUndo2 } from 'react-icons/lu'
|
||||
import { formatTokens } from '../lib/utils'
|
||||
|
||||
interface ContentBlock {
|
||||
type?: string
|
||||
@@ -28,7 +31,42 @@ function readBlocks(raw: unknown): ContentBlock[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/** 工作计时:working=true 起表,结束后冻结读数 */
|
||||
/** 折叠态紧凑行用的最新思考行(取最后一个非空行,超长截断) */
|
||||
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 ''
|
||||
}
|
||||
|
||||
/** 消息级错误(stopReason:'error',如 API 402 余额耗尽/限流):无此错误返回空串 */
|
||||
function readMessageError(raw: unknown): string {
|
||||
if (!raw || typeof raw !== 'object') return ''
|
||||
const m = raw as { stopReason?: unknown; errorMessage?: unknown }
|
||||
if (m.stopReason !== 'error' || typeof m.errorMessage !== 'string' || !m.errorMessage.trim()) return ''
|
||||
return m.errorMessage
|
||||
}
|
||||
|
||||
/** 提炼错误文案的可读部分:'402: {"message":"Insufficient Balance",…}' → '402: Insufficient Balance' */
|
||||
function readableMessageError(err: string): string {
|
||||
const match = err.match(/^(\d{3}):\s*([\s\S]*)$/)
|
||||
const body = match ? match[2] : err
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { message?: unknown }
|
||||
if (parsed && typeof parsed.message === 'string' && parsed.message) {
|
||||
return match ? `${match[1]}: ${parsed.message}` : parsed.message
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 体,保持原文
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
/** 工作计时:working=true 起表,结束后冻结读数。
|
||||
* working 中途抖动(pi 轮次间隙 streaming/turnActive 抖动)不锁存:
|
||||
* 恢复 true 时解除冻结,继续从起点计时。 */
|
||||
function useWorkingSeconds(working: boolean): string {
|
||||
const startRef = useRef<number | null>(null)
|
||||
const [finalSecs, setFinalSecs] = useState<number | null>(null)
|
||||
@@ -42,6 +80,10 @@ 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)))
|
||||
}
|
||||
@@ -59,143 +101,298 @@ function useWorkingSeconds(working: boolean): string {
|
||||
}
|
||||
|
||||
/** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。
|
||||
* 划分规则:最后一轮的正文 = 最终答案(始终可见);这轮里的其余一切 ——
|
||||
* 思考、工具行、以及干活过程中模型穿插写的叙述性正文 —— 全部归入「过程」,
|
||||
* 样式与普通正文一致,按时序穿插在思考/工具行之间,折叠时随过程一起收起。
|
||||
* 流式中:正在流的消息就是答案的雏形,思考/正文按真实到达顺序分段渲染;
|
||||
* 它结束、下一轮开始后即「沉」入过程区。 */
|
||||
export function AssistantTurn({
|
||||
* 划分规则:回合结束(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({
|
||||
messages,
|
||||
tools,
|
||||
streaming,
|
||||
pendingApprovals
|
||||
turnActive,
|
||||
pendingApprovals,
|
||||
scrollPinnedRef,
|
||||
forking = false,
|
||||
onFork,
|
||||
forkEntryId,
|
||||
onForkAt
|
||||
}: {
|
||||
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 {
|
||||
const processNodes: 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 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
|
||||
|
||||
/** 连续的思考/工具行作为一组裸行铺出(组内保留顺序),不缩进 */
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
let answerText = ''
|
||||
// 消息级错误(API 失败等):回合内最后一条错误保留展示,否则错误轮会渲染成空白
|
||||
let messageError = ''
|
||||
|
||||
// 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。
|
||||
// stream 不存在时,最后一条已完成消息的正文是答案。
|
||||
// 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。
|
||||
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
|
||||
}
|
||||
consume(message.raw, streamPos === -1 && idx === messages.length - 1)
|
||||
const msgErr = readMessageError(message.raw)
|
||||
if (msgErr) messageError = msgErr
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
flushRows()
|
||||
|
||||
const streamHasThinking = stream
|
||||
? stream.segments.some((s) => s.kind === 'thinking' && s.text.trim().length > 0)
|
||||
: false
|
||||
const hasProcess = calls > 0 || thinking > 0 || streamHasThinking
|
||||
|
||||
// 折叠状态:历史回合默认收起,流式回合默认展开;结束自动收起,可手动切换
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => !streaming && hasProcess)
|
||||
const prevStreaming = useRef(streaming)
|
||||
useEffect(() => {
|
||||
if (prevStreaming.current && !streaming) setCollapsed(true)
|
||||
prevStreaming.current = streaming
|
||||
}, [streaming])
|
||||
// 回合是否仍在进行:turnActive/streaming 在 pi 的轮次间隙(工具执行、自动压缩、
|
||||
// 排队续跑)都会中途抖动,流式占位还在(stream 非空)也代表内容仍在到达——
|
||||
// 任一为真都按「进行中」处理,绝不能只看 turnActive(抖一下就会误折叠整个回合)
|
||||
const live = turnActive || streaming || Boolean(stream)
|
||||
|
||||
const workedFor = useWorkingSeconds(streaming)
|
||||
// 折叠状态:历史回合默认收起,进行中的回合默认展开;结算(live 由真转假)后
|
||||
// 延迟一小段才自动收起,抖动恢复即取消;且仅当用户位于聊天底部时才收起——
|
||||
// 上翻阅读时折叠会猛拽滚动位置,保持展开。因抖动被自动收起的回合在 live 恢复
|
||||
// 时自动重新展开;用户手动收起的不参与自动展开。
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => !live && hasProcess)
|
||||
// 过程内容懒挂载:历史回合默认收起,其思考/工具内容在用户首次展开前不渲染
|
||||
// (折叠只是视觉收起,子树照常挂载的话长会话切换仍要全量解析)。
|
||||
// live 回合从一开始就可见,挂载内容;一旦挂载过就保持(再次折叠走 GSAP
|
||||
// 高度动画需要内容在场,且 Thinking 面板内部滚动状态不重置)。
|
||||
const [contentMounted, setContentMounted] = useState<boolean>(() => live)
|
||||
// 用户手动收起过的回合不再自动展开;自动收起(结算/技术性重挂载)不受此限
|
||||
const userCollapsedRef = useRef(false)
|
||||
const prevLiveRef = useRef(live)
|
||||
useEffect(() => {
|
||||
const prev = prevLiveRef.current
|
||||
prevLiveRef.current = live
|
||||
if (prev && !live) {
|
||||
const timer = window.setTimeout(() => {
|
||||
if (scrollPinnedRef?.current !== false) {
|
||||
setCollapsed(true)
|
||||
}
|
||||
}, 600)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
// live 恢复时重新展开(含重挂载场景:初始收起的回合在回合续跑后自愈展开,
|
||||
// 不依赖随重挂载丢失的 ref 状态);用户手动收起过的除外
|
||||
if (!prev && live && !userCollapsedRef.current) {
|
||||
setContentMounted(true)
|
||||
setCollapsed(false)
|
||||
}
|
||||
}, [live, scrollPinnedRef])
|
||||
|
||||
// ---- 第二遍:按需物化过程区(仅在内容已挂载时执行重活) ----
|
||||
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 headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
|
||||
|
||||
// 流式分段:思考段属过程(折叠时隐藏),正文段属答案;均按真实时序渲染
|
||||
const showStreamThinking = !collapsed || !hasProcess
|
||||
// 流式分段:思考段属过程(过程区折叠时隐藏,由头部下方的紧凑行代为展示
|
||||
// 最新一行),正文段属答案;均按真实时序渲染。思考块保持挂载(仅 display:none),
|
||||
// 折叠/展开往返不重置内部计时的滚动状态。
|
||||
const streamNodes: ReactNode[] = []
|
||||
let visibleThinkingRows = 0
|
||||
if (stream) {
|
||||
stream.segments.forEach((seg, i) => {
|
||||
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) => {
|
||||
if (seg.kind === 'thinking') {
|
||||
if (!showStreamThinking) return
|
||||
const rows = thinkingRows(seg.text)
|
||||
if (rows.length === 0) return
|
||||
visibleThinkingRows += rows.length
|
||||
// 只有「最后一段且之后没有正文」才算仍在流式;否则一旦 pi 在回合内
|
||||
// 分多轮发出(静默间隙 streaming 抖动),已完成段会被误判回流式态
|
||||
const isLast = i === segments.length - 1
|
||||
const followedByText = segments.some((s, k) => k > i && s.kind === 'text' && s.text.length > 0)
|
||||
streamNodes.push(
|
||||
<div key={`seg-${i}`} className="mb-3">
|
||||
<Thinking rows={rows} streaming={streaming} />
|
||||
<div key={`seg-${i}`} className={collapsed && hasProcess ? 'hidden' : 'mb-3'}>
|
||||
<Thinking rows={rows} streaming={streaming && isLast && !followedByText} />
|
||||
</div>
|
||||
)
|
||||
return
|
||||
}
|
||||
if (seg.text) {
|
||||
answerText = answerText ? `${answerText}\n\n${seg.text}` : seg.text
|
||||
streamNodes.push(
|
||||
<StreamingText key={`seg-${i}`} streaming={streaming} text={seg.text}>
|
||||
<StreamingText key={`seg-${i}`} streaming={streaming && i === lastTextSeg}>
|
||||
<Markdown text={seg.text} streaming={streaming} />
|
||||
</StreamingText>
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 折叠时思考不可见 → 头部提示「思考中…」(仅当前正在流思考段时)
|
||||
// 过程区折叠时的「正在思考」紧凑行:展示最新一行思考(ZCode 式),随流式刷新
|
||||
const lastSeg = stream?.segments[stream.segments.length - 1]
|
||||
const hiddenThinking =
|
||||
collapsed && hasProcess && Boolean(stream && lastSeg?.kind === 'thinking' && lastSeg.text.trim())
|
||||
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)
|
||||
|
||||
return (
|
||||
<div className="min-w-0 max-w-full">
|
||||
@@ -204,7 +401,11 @@ export function AssistantTurn({
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
onClick={() => {
|
||||
userCollapsedRef.current = true
|
||||
setContentMounted(true)
|
||||
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
|
||||
@@ -216,57 +417,231 @@ export function AssistantTurn({
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="transition-transform"
|
||||
className="transition-transform duration-500"
|
||||
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
<span className="tabular-nums">
|
||||
{headerLabel}
|
||||
{hiddenThinking ? ',思考中…' : ''}
|
||||
</span>
|
||||
<span className="tabular-nums">{headerLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 展开态:过程(思考/工具/叙述正文按时序穿插)+ 最终答案;收起态:只留最终答案 */}
|
||||
{!collapsed || !hasProcess ? (
|
||||
<>
|
||||
{processNodes}
|
||||
{answerNodes}
|
||||
{streamNodes.length > 0 && <div>{streamNodes}</div>}
|
||||
{stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && (
|
||||
<Loading label="思考中" variant="Dots" />
|
||||
{/* 折叠时的实时思考预览:仅展示最新一行,随流式滚动前进 */}
|
||||
{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" />}
|
||||
|
||||
{/* 消息级错误(stopReason:'error',如 API 402 余额耗尽 / 限流):常驻可见,
|
||||
不随过程区折叠——否则失败轮在界面上就是一片空白,无从排查 */}
|
||||
{messageError && (
|
||||
<div className="mt-2 flex items-start gap-2.5 rounded-control border border-red/40 bg-red-tint px-3.5 py-2.5 text-[12.5px] text-red">
|
||||
<LuTriangleAlert size={14} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 font-medium">模型请求失败</div>
|
||||
<div className="break-all text-red/80">{readableMessageError(messageError)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 结论底部的操作行:复制 + 会话分叉。末回合走会话末尾分叉(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 && (
|
||||
<>
|
||||
{answerNodes}
|
||||
{streamNodes.length > 0 && <div>{streamNodes}</div>}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageItem({
|
||||
/** 压缩 / 分支摘要通知行(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({
|
||||
message,
|
||||
tools,
|
||||
streaming,
|
||||
pendingApprovals = []
|
||||
turnActive,
|
||||
pendingApprovals = [],
|
||||
scrollPinnedRef,
|
||||
forkEntryId,
|
||||
rewinding,
|
||||
onRewind,
|
||||
animate = true
|
||||
}: {
|
||||
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>()
|
||||
const ref = useEntrance<HTMLDivElement>(animate)
|
||||
|
||||
if (message.role === 'compactionSummary' || message.role === 'branchSummary') {
|
||||
return <SystemNotice message={message} animate={animate} />
|
||||
}
|
||||
|
||||
if (message.role === 'user') {
|
||||
const blocks = readBlocks(message.raw)
|
||||
const text = blocks.map((b) => b.text ?? '').join('') || ''
|
||||
return (
|
||||
<div ref={ref} className="flex justify-end">
|
||||
<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 className="max-w-[85%] rounded-[10px] bg-inset px-4 py-3 text-base whitespace-pre-wrap leading-normal text-ink">
|
||||
{text}
|
||||
</div>
|
||||
@@ -277,7 +652,14 @@ export function MessageItem({
|
||||
if (message.role === 'assistant') {
|
||||
return (
|
||||
<div ref={ref} className="min-w-0 max-w-full">
|
||||
<AssistantTurn messages={[message]} tools={tools} streaming={streaming} pendingApprovals={pendingApprovals} />
|
||||
<AssistantTurn
|
||||
messages={[message]}
|
||||
tools={tools}
|
||||
streaming={streaming}
|
||||
turnActive={turnActive}
|
||||
pendingApprovals={pendingApprovals}
|
||||
scrollPinnedRef={scrollPinnedRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -285,3 +667,71 @@ export function MessageItem({
|
||||
// 其他角色(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,43 +1,30 @@
|
||||
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
|
||||
import { LuCircleHelp, LuScissors, LuSmile, LuSpellCheck, LuWandSparkles } from 'react-icons/lu'
|
||||
import { LuCheck, LuCopy, LuQuote } from 'react-icons/lu'
|
||||
|
||||
/** 选区操作条(beautifului.dev Selection Actions)。
|
||||
/** 选区操作条。
|
||||
* 在 containerRef 内划选文本后,于选区上方浮出胶囊工具条:
|
||||
* 「描述修改」输入框 + 解释/改进,更多按钮展开 精简/语气/语法;
|
||||
* 输入内容后出现发送钮。预设动作与自定义指令都经 onApply 交给上层。 */
|
||||
|
||||
const PRESET_MAIN: { label: string; icon: ReactNode }[] = [
|
||||
{ label: '解释', icon: <LuCircleHelp size={14} strokeWidth={1.8} /> },
|
||||
{ label: '改进', icon: <LuWandSparkles size={14} strokeWidth={1.8} /> }
|
||||
]
|
||||
|
||||
const PRESET_MORE: { label: string; icon: ReactNode }[] = [
|
||||
{ label: '精简', icon: <LuScissors size={14} strokeWidth={1.8} /> },
|
||||
{ label: '语气', icon: <LuSmile size={14} strokeWidth={1.8} /> },
|
||||
{ label: '语法', icon: <LuSpellCheck size={14} strokeWidth={1.8} /> }
|
||||
]
|
||||
* 「引用」把选中文本注入输入框开头(blockquote 形式),「复制」写入剪贴板;
|
||||
* Esc / 滚动 / 点击后收起。 */
|
||||
|
||||
const pillButton =
|
||||
'inline-flex items-center justify-center select-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none text-ink hover:bg-hover h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1 shrink-0'
|
||||
|
||||
export function SelectionActions({
|
||||
containerRef,
|
||||
onApply
|
||||
onQuote
|
||||
}: {
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
onApply: (instruction: string, selection: string) => void
|
||||
onQuote: (selection: string) => void
|
||||
}): ReactNode {
|
||||
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selection, setSelection] = useState('')
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const pillRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const dismiss = (): void => {
|
||||
setAnchor(null)
|
||||
setSelection('')
|
||||
setInstruction('')
|
||||
setMoreOpen(false)
|
||||
setCopied(false)
|
||||
}
|
||||
|
||||
// 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起)
|
||||
@@ -94,19 +81,19 @@ export function SelectionActions({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [anchor])
|
||||
|
||||
const apply = (label: string): void => {
|
||||
const quote = (): void => {
|
||||
if (!selection) return
|
||||
onApply(label, selection)
|
||||
onQuote(selection)
|
||||
dismiss()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
const submitInstruction = (): void => {
|
||||
const text = instruction.trim()
|
||||
if (!text || !selection) return
|
||||
onApply(`${text}(针对以下选中内容)`, selection)
|
||||
dismiss()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
const copy = (): void => {
|
||||
if (!selection) return
|
||||
void navigator.clipboard.writeText(selection).then(() => {
|
||||
setCopied(true)
|
||||
window.setTimeout(dismiss, 900)
|
||||
})
|
||||
}
|
||||
|
||||
const visible = anchor !== null
|
||||
@@ -122,81 +109,20 @@ export function SelectionActions({
|
||||
pointerEvents: visible ? undefined : 'none'
|
||||
}}
|
||||
>
|
||||
<div className="flex h-9 w-fit max-w-[calc(100vw-48px)] items-center justify-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 font-sans font-normal text-ink shadow-overlay">
|
||||
{/* 描述修改输入框(常显,站点默认态) */}
|
||||
<div className="flex w-fit shrink-0 items-center justify-center gap-0.5 overflow-hidden" style={{ maxWidth: 152 }}>
|
||||
<form
|
||||
className="flex h-7 shrink-0 items-center"
|
||||
style={{ width: 152 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submitInstruction()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={instruction}
|
||||
onChange={(e) => setInstruction(e.target.value)}
|
||||
placeholder="描述修改…"
|
||||
aria-label="描述修改"
|
||||
className="h-7 w-full bg-transparent pr-2.5 pl-3 text-[12.5px] text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<span className="mx-1 h-4 w-px shrink-0 bg-line-strong" />
|
||||
|
||||
{/* 预设动作 + 更多 */}
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-hidden">
|
||||
{PRESET_MAIN.map((action) => (
|
||||
<button key={action.label} type="button" className={pillButton} onClick={() => apply(action.label)}>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</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 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>
|
||||
</div>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
import { useEffect, useRef, useState, type ComponentType, type JSX, type ReactNode } from 'react'
|
||||
import { LuChevronDown, LuPlus, LuX } from 'react-icons/lu'
|
||||
|
||||
/**
|
||||
* 右侧通用标签面板(TabPanel,ZCode 式)。
|
||||
* 面板承载所有标签的通用机制:平铺标签条(所有打开的 tab 一起展示,点击切换,
|
||||
* 悬停显示关闭、右键菜单:关闭/关闭其他/收起面板)、+ 新增标签、
|
||||
* 空态「打开标签页」宫格、左缘宽度拖拽。
|
||||
* 具体标签(浏览器等)通过 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 [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 (!plusOpen && !ctxMenu) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
setPlusOpen(false)
|
||||
setCtxMenu(null)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [plusOpen, ctxMenu])
|
||||
|
||||
// 覆盖层开合上报(宿主借此暂停原生视图)。卸载必须归位:面板在菜单开着时
|
||||
// 被收起(与 setCtxMenu/setPlusOpen 同批提交,归位 effect 不再执行),
|
||||
// 宿主的 suspended 会永久卡 true,下次展开面板时原生视图被隐藏成一片空白。
|
||||
// cleanup 在依赖变化时也会短暂触发 false→当前值,同一提交内批处理合并不闪烁。
|
||||
useEffect(() => {
|
||||
onOverlayChange?.(plusOpen || ctxMenu !== null)
|
||||
return () => onOverlayChange?.(false)
|
||||
}, [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">
|
||||
{/* 标签条:所有打开的 tab 平铺展示(浏览器式);超出宽度横向滚动 */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === current?.id
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
aria-current={active || undefined}
|
||||
onClick={() => onSelectTab(tab.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setCtxMenu({
|
||||
x: Math.min(e.clientX, window.innerWidth - 170),
|
||||
y: Math.min(e.clientY, window.innerHeight - 140),
|
||||
tabId: tab.id
|
||||
})
|
||||
}}
|
||||
className={`group flex h-7 shrink-0 cursor-default select-none items-center gap-1.5 rounded-md px-2 text-[12px] transition-colors ${
|
||||
active ? 'bg-inset text-ink' : 'text-ink-3 hover:bg-hover hover:text-ink-2'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={12} className="shrink-0" />
|
||||
<span className="min-w-0 max-w-32 truncate">{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 ${
|
||||
active ? '' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<LuX size={11} />
|
||||
</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 right-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 className="my-1 border-t border-line-soft" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCtxMenu(null)
|
||||
onCollapse()
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<LuChevronDown size={11} className="shrink-0" />
|
||||
收起面板
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tabs.length === 0 ? (
|
||||
<EmptyState available={available} onOpen={onOpenTab} />
|
||||
) : (
|
||||
<div className="relative min-h-0 flex-1 bg-page">{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,6 +3,7 @@ import { createPortal } from 'react-dom'
|
||||
import {
|
||||
LuCheck,
|
||||
LuChevronDown,
|
||||
LuMessagesSquare,
|
||||
LuPencil,
|
||||
LuPlus,
|
||||
LuSearch,
|
||||
@@ -14,7 +15,8 @@ import {
|
||||
import { LuPanelLeft } from 'react-icons/lu'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
import { GlideMenu } from '@/components/GlideMenu'
|
||||
import type { SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
|
||||
import { PluginIconValue } from '@/plugins/plugin-icons'
|
||||
import type { PluginInfo, SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
|
||||
|
||||
/** 可折叠工作区导航(beautifului.dev Sidebar Nav)。
|
||||
* 224px ↔ 56px 宽度过渡,文案随折叠淡出;导航行与会话行共用一个
|
||||
@@ -78,6 +80,87 @@ function RowActionPortal({
|
||||
)
|
||||
}
|
||||
|
||||
/** 折叠态会话切换浮层:锚定 rail 图标向右弹出,类浏览器标签切换(仅切换,不管理)。
|
||||
* portal 到 body(aside overflow-hidden 会裁剪内联浮层);点外部 / Esc / 选中即关。 */
|
||||
function SessionSwitcherPortal({
|
||||
anchor,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onOpenSession,
|
||||
onClose
|
||||
}: {
|
||||
anchor: { top: number; right: number }
|
||||
sessions: SessionMeta[]
|
||||
activeSessionId?: string
|
||||
onOpenSession: (path: string) => void
|
||||
onClose: () => void
|
||||
}): ReactNode {
|
||||
const closeRef = useRef(onClose)
|
||||
closeRef.current = onClose
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent): void => {
|
||||
const target = event.target as Element
|
||||
if (target.closest('[data-session-flyout]') || target.closest('[data-session-toggle]')) return
|
||||
closeRef.current()
|
||||
}
|
||||
const esc = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') closeRef.current()
|
||||
}
|
||||
document.addEventListener('pointerdown', close)
|
||||
document.addEventListener('keydown', esc)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', close)
|
||||
document.removeEventListener('keydown', esc)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const top = Math.max(8, Math.min(anchor.top, window.innerHeight - 220))
|
||||
const listMaxHeight = Math.max(160, window.innerHeight - top - 52)
|
||||
return createPortal(
|
||||
<div
|
||||
data-session-flyout
|
||||
className="fixed z-50 w-64 rounded-[14px] bg-surface p-1.5 shadow-overlay"
|
||||
style={{
|
||||
left: anchor.right + 12,
|
||||
top,
|
||||
animation: 'pop-in 180ms cubic-bezier(0.23,1,0.32,1) both',
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
<div className="px-2 pb-1 pt-1 text-[11px] font-medium text-ink-3">会话</div>
|
||||
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-[12px] text-ink-3">暂无历史会话</div>
|
||||
) : (
|
||||
sessions.map((s) => {
|
||||
const active = s.id === activeSessionId
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
title={sessionTitle(s)}
|
||||
onClick={() => {
|
||||
onOpenSession(s.path)
|
||||
onClose()
|
||||
}}
|
||||
className={`flex h-8 w-full items-center gap-2 rounded-[7px] px-2 text-left transition-colors ${
|
||||
active ? 'bg-hover-2 text-ink' : 'text-ink-2 hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
<span className={`min-w-0 flex-1 truncate text-[13px] ${active ? 'font-medium' : 'font-normal'}`}>
|
||||
{sessionTitle(s)}
|
||||
</span>
|
||||
{active && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarNav({
|
||||
sessionState,
|
||||
sessions,
|
||||
@@ -89,7 +172,11 @@ export function SidebarNav({
|
||||
onOpenSettings,
|
||||
workspaces,
|
||||
activeWorkspace,
|
||||
onOpenWorkspace
|
||||
onOpenWorkspace,
|
||||
plugins,
|
||||
activeView,
|
||||
onOpenPlugin,
|
||||
chatBadge
|
||||
}: {
|
||||
sessionState: SessionState | null
|
||||
sessions: SessionMeta[]
|
||||
@@ -102,6 +189,13 @@ 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 {
|
||||
@@ -122,6 +216,8 @@ export function SidebarNav({
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [wsMenuOpen, setWsMenuOpen] = useState(false)
|
||||
const [expandedWs, setExpandedWs] = useState<Set<string>>(new Set())
|
||||
/** 折叠态会话切换浮层:锚点(rail 图标顶缘/右缘),null = 关闭 */
|
||||
const [sessionSwitcher, setSessionSwitcher] = useState<{ top: number; right: number } | null>(null)
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -139,6 +235,7 @@ export function SidebarNav({
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed) setWsMenuOpen(false)
|
||||
else setSessionSwitcher(null)
|
||||
}, [collapsed])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -322,7 +419,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">
|
||||
@@ -336,72 +433,129 @@ 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 text-ink-2">
|
||||
<PluginIconValue icon={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="sidebar-copy relative mx-2 mb-1 h-8">
|
||||
<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>
|
||||
</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"
|
||||
/>
|
||||
<div className="relative mx-2 mb-1 h-8">
|
||||
<div className="sidebar-copy absolute inset-0">
|
||||
<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]"
|
||||
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"
|
||||
>
|
||||
<LuX size={16} strokeWidth={2} />
|
||||
<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 裁剪 */}
|
||||
<button
|
||||
type="button"
|
||||
data-session-toggle
|
||||
aria-label="切换会话"
|
||||
title="切换会话"
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setSessionSwitcher((v) => (v ? null : { top: rect.top, right: rect.right }))
|
||||
}}
|
||||
className="sidebar-collapsed-only absolute left-0 top-0 h-8 w-9 items-center justify-center rounded-[8px] text-ink-2 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96] [-webkit-app-region:no-drag]"
|
||||
>
|
||||
<LuMessagesSquare size={18} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{chatsOpen && (
|
||||
{chatsOpen && !collapsed && (
|
||||
<GlideMenu highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="sidebar-copy mx-2 mb-1 rounded-[8px] border border-dashed border-line px-3 py-4 text-center text-[12px] leading-5 text-ink-3">
|
||||
@@ -416,12 +570,23 @@ export function SidebarNav({
|
||||
data-row
|
||||
title={sessionTitle(s)}
|
||||
onClick={() => onOpenSession(s.path)}
|
||||
className={`${navRowClass} group/row cursor-pointer ${active ? 'text-ink' : 'text-ink-2'}`}
|
||||
className={`${navRowClass} group/row cursor-pointer ${
|
||||
active ? 'bg-hover-2 text-ink' : 'text-ink-2'
|
||||
}`}
|
||||
>
|
||||
{/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px */}
|
||||
<span className="sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium">{sessionTitle(s)}</span>
|
||||
{/* 悬停浮动操作:quiet 图标钮,无底板,贴近官方 primitive-icon-button */}
|
||||
<span className="absolute right-0.5 hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
|
||||
{/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px。
|
||||
激活行除灰底外再用字重区分(激活加粗 / 非激活常规) */}
|
||||
<span
|
||||
className={`sidebar-copy min-w-0 flex-1 truncate text-[14px] ${
|
||||
active ? 'font-medium' : 'font-normal'
|
||||
}`}
|
||||
>
|
||||
{sessionTitle(s)}
|
||||
</span>
|
||||
{/* 悬停操作:quiet 图标钮,无底板。行内流式布局(非绝对定位):
|
||||
悬停时标题自动收窄让位,按钮不越出行、不遮相邻行的文字;
|
||||
收起侧栏时随 sidebar-copy 一并淡出并禁用点击 */}
|
||||
<span className="sidebar-copy hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
|
||||
{active && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -463,6 +628,17 @@ export function SidebarNav({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 折叠态会话切换浮层(portal 到 body) */}
|
||||
{sessionSwitcher && (
|
||||
<SessionSwitcherPortal
|
||||
anchor={sessionSwitcher}
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onOpenSession={onOpenSession}
|
||||
onClose={() => setSessionSwitcher(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 行内操作浮层:锚定触发按钮,官方 WorkspaceMenu 形态(pop-in + overlay 阴影) */}
|
||||
{actionMenu && (
|
||||
<RowActionPortal rect={actionMenu.rect} onClose={() => setActionMenu(null)}>
|
||||
|
||||
@@ -1,99 +1,25 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 流式回答(beautifului.dev Streaming Text)。
|
||||
* 正文随 delta 增长并带竖线光标;流式结束后光标消失、
|
||||
* 操作行淡入(复制);followUps 由调用方提供时逐条列出可点。
|
||||
* 站点演示的 refresh/点赞等操作在本应用无后端,未接入。 */
|
||||
/** 流式回答容器(beautifului.dev Streaming Text)。
|
||||
* 正文随 delta 增长;光标由 CSS(globals.css 的 .streaming-body 规则)以 ::after
|
||||
* 附加到最后一个文本块最后一行文字之后,流式结束后随 data-streaming 属性切换消失,
|
||||
* 不再以独立 span 追加在块级内容之后(那会让光标孤零零落在新的一行上)。
|
||||
* 复制操作由 AssistantTurn 统一常驻在结论底部——本组件不再自带操作行:
|
||||
* 以前每个正文段各挂一个、随回合内 streaming 抖动反复出现/消失(闪现)。 */
|
||||
export function StreamingText({
|
||||
children,
|
||||
streaming,
|
||||
text,
|
||||
followUps,
|
||||
onFollowUp
|
||||
streaming
|
||||
}: {
|
||||
/** 正文内容(通常为 <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="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
|
||||
className="streaming-body min-w-0 text-base leading-normal text-ink"
|
||||
data-streaming={streaming ? 'true' : 'false'}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 可展开的思考轨迹(beautifului.dev Thinking · Reasoning 形态)。
|
||||
* 流式期间自动展开并 shimmer「思考中…」;结束后收起为「思考过程」,
|
||||
* 点击表头可手动展开/收起。rows 由真实思考文本逐行喂入。 */
|
||||
/** 可展开的思考轨迹。
|
||||
* 默认收起:表头展示「状态词 + 开头第一行内容」式预览,点击展开完整轨迹,
|
||||
* 再点收起;流式中表头标签保持 shimmer。
|
||||
* 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填):
|
||||
* 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时
|
||||
* 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明
|
||||
* 再逐个浮现),视觉上等于整段思考收起又重新展开,每个工具轮都会重演;
|
||||
* 历史轨迹默认随过程区收起,看不到行入场,交错动画没有保留价值。
|
||||
* 不限高:过程区在回合结束后整体折叠,内部限高只会在轮次交接时造成
|
||||
* 面板可见缩短。rows 由真实思考文本逐行喂入。 */
|
||||
export function Thinking({
|
||||
rows,
|
||||
streaming,
|
||||
activeLabel = '思考中…',
|
||||
activeLabel = '正在思考',
|
||||
doneLabel = '思考过程'
|
||||
}: {
|
||||
rows: { primary: string }[]
|
||||
@@ -15,29 +22,22 @@ export function Thinking({
|
||||
activeLabel?: string
|
||||
doneLabel?: string
|
||||
}): ReactNode {
|
||||
// 默认收起(不想默认展开思考内容);用户点击后以手动状态为准
|
||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
|
||||
const expanded = manualExpanded ?? streaming
|
||||
const expanded = manualExpanded ?? false
|
||||
|
||||
const traceRef = useRef<HTMLDivElement>(null)
|
||||
const [lineHeight, setLineHeight] = useState(0)
|
||||
useLayoutEffect(() => {
|
||||
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight)
|
||||
}, [rows, expanded])
|
||||
const firstLine = rows.length > 0 ? rows[0].primary : ''
|
||||
const statusWord = streaming ? activeLabel : doneLabel
|
||||
const suffix = expanded ? '' : firstLine
|
||||
|
||||
return (
|
||||
<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'
|
||||
}}
|
||||
>
|
||||
{/* 表头:状态词 + 旋转箭头 */}
|
||||
<div className="flex w-full flex-col">
|
||||
{/* 表头:状态词 +(收起时)首行预览 + 旋转箭头 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
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"
|
||||
onClick={() => setManualExpanded((current) => !(current ?? false))}
|
||||
className="-mx-1.5 flex w-fit min-w-0 max-w-full items-center gap-2 rounded-control px-1.5 py-1 transition-colors hover:bg-hover-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={streaming ? 'var(--ink-2)' : 'var(--ink-3)'}>
|
||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
|
||||
@@ -45,23 +45,26 @@ export function Thinking({
|
||||
<span role="status" className="contents">
|
||||
{streaming ? (
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
|
||||
className="bg-clip-text whitespace-nowrap text-[13px] 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'
|
||||
}}
|
||||
>
|
||||
{activeLabel}
|
||||
{statusWord}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
|
||||
className="whitespace-nowrap text-[13px] font-medium text-ink-2"
|
||||
style={{ animation: 'fade-in 350ms ease-out both' }}
|
||||
>
|
||||
{doneLabel}
|
||||
{statusWord}
|
||||
</span>
|
||||
)}
|
||||
{suffix && (
|
||||
<span className="min-w-0 truncate text-[12.5px] text-ink-3">· {suffix}</span>
|
||||
)}
|
||||
</span>
|
||||
<svg
|
||||
width="14"
|
||||
@@ -72,14 +75,14 @@ export function Thinking({
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="transition-transform"
|
||||
className="shrink-0 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={{
|
||||
@@ -88,23 +91,23 @@ export function Thinking({
|
||||
transitionTimingFunction: 'ease'
|
||||
}}
|
||||
>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,11 +116,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.length > 300 ? `${line.slice(0, 300)}…` : line }))
|
||||
.map((line) => ({ primary: line }))
|
||||
}
|
||||
|
||||
@@ -1,26 +1,52 @@
|
||||
import { useEffect, useState, type JSX } from 'react'
|
||||
import { useEffect, useState, type JSX, type ReactNode } from 'react'
|
||||
import { LuPanelRight } from 'react-icons/lu'
|
||||
|
||||
/**
|
||||
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
|
||||
* 整条是拖拽区;左上角展示当前会话名称(无名称时显示「新会话」);
|
||||
* 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
|
||||
* 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
||||
* 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」);
|
||||
* title 之后可挂 extra 槽(插件状态胶囊);右侧自绘窗口控制按钮(最小化/
|
||||
* 最大化还原/关闭),高度 40px。不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
||||
*/
|
||||
export function TitleBar({ title }: { title?: string }): JSX.Element {
|
||||
export function TitleBar({
|
||||
title,
|
||||
panelOpen,
|
||||
onTogglePanel,
|
||||
extra
|
||||
}: {
|
||||
title?: string
|
||||
panelOpen: boolean
|
||||
onTogglePanel: () => void
|
||||
/** 标题右侧插槽(插件状态胶囊等;聊天/插件视图下均可见) */
|
||||
extra?: ReactNode
|
||||
}): 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-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]">
|
||||
<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-3 flex shrink-0 items-center [-webkit-app-region:no-drag]">{extra}</div>}
|
||||
<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]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="最小化"
|
||||
onClick={() => void window.api.minimize()}
|
||||
className="flex h-full w-[46px] items-center justify-center text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
className="flex h-full w-[46px] items-center justify-center text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||
<path d="M0 5h10" stroke="currentColor" strokeWidth="1" />
|
||||
@@ -30,7 +56,7 @@ export function TitleBar({ title }: { title?: string }): JSX.Element {
|
||||
type="button"
|
||||
aria-label={maximized ? '还原' : '最大化'}
|
||||
onClick={() => void window.api.toggleMaximize()}
|
||||
className="flex h-full w-[46px] items-center justify-center text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
className="flex h-full w-[46px] items-center justify-center text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
||||
>
|
||||
{maximized ? (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||
@@ -46,7 +72,7 @@ export function TitleBar({ title }: { title?: string }): JSX.Element {
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={() => void window.api.close()}
|
||||
className="flex h-full w-[46px] items-center justify-center text-zinc-500 transition-colors hover:bg-[#e81123] hover:text-white"
|
||||
className="flex h-full w-[46px] items-center justify-center text-ink-3 transition-colors hover:bg-[#e81123] hover:text-page"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||
<path d="M0 0l10 10M10 0L0 10" stroke="currentColor" strokeWidth="1" />
|
||||
|
||||
@@ -105,7 +105,7 @@ function extractResultText(result: unknown): string {
|
||||
function toolMeta(name: string): { icon: keyof typeof STEP_ICONS; label: string } {
|
||||
if (/^read|get|list|查|读|搜/i.test(name)) return { icon: 'read', label: '读取' }
|
||||
if (/^write|^edit|^multiedit|写|改|删|建|核/i.test(name)) return { icon: 'write', label: '编辑' }
|
||||
if (/^bash|^shell|^terminal|终端/i.test(name)) return { icon: 'run', label: '终端' }
|
||||
if (/^bash|^shell|^terminal|^powershell|终端/i.test(name)) return { icon: 'run', label: '终端' }
|
||||
if (/read|get|search|query|fetch|list|查|读|搜/i.test(name)) return { icon: 'read', label: '读取' }
|
||||
if (/write|edit|update|create|delete|patch|save|写|改|删|建|核/i.test(name)) return { icon: 'write', label: '编辑' }
|
||||
return { icon: 'run', label: name }
|
||||
@@ -198,9 +198,13 @@ function buildRow(view: ToolCallView, dupIndex: number): ToolRow {
|
||||
)
|
||||
const resultText = extractResultText(tool.result)
|
||||
if (resultText) {
|
||||
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 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 args = tool?.args ?? view.pendingApproval?.args
|
||||
@@ -237,7 +241,7 @@ function buildThinkingRows(texts: string[]): ToolRow[] {
|
||||
file: '',
|
||||
dir: '',
|
||||
chip: lines[0].length > 60 ? `${lines[0].slice(0, 60)}…` : lines[0],
|
||||
detail: lines.slice(0, 12).map((line) => ({ text: line.slice(0, 160), tone: 'plain' as const })),
|
||||
detail: lines.map((line) => ({ text: line, tone: 'plain' as const })),
|
||||
tool: undefined,
|
||||
pending: false
|
||||
})
|
||||
@@ -336,22 +340,24 @@ 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">
|
||||
<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>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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="受管浏览器页面" />
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
|
||||
import type { AgentUiEvent, ApprovalRequest, PermissionMode, SendImage, SessionMeta, SessionState, SessionStatsInfo, WorkspaceMeta } from '@shared/types'
|
||||
import type {
|
||||
AgentUiEvent,
|
||||
ApprovalRequest,
|
||||
ForkPoint,
|
||||
NavigateTreeOptions,
|
||||
PermissionMode,
|
||||
SendImage,
|
||||
SessionMeta,
|
||||
SessionState,
|
||||
SessionStatsInfo,
|
||||
ThinkingLevel,
|
||||
WorkspaceMeta
|
||||
} from '@shared/types'
|
||||
|
||||
export type { ApprovalRequest }
|
||||
|
||||
@@ -18,6 +30,16 @@ 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
|
||||
@@ -33,6 +55,10 @@ 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
|
||||
@@ -42,17 +68,23 @@ 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,
|
||||
@@ -61,7 +93,9 @@ const initialState: AgentUiState = {
|
||||
retry: null,
|
||||
ready: false,
|
||||
approvals: [],
|
||||
settledApprovals: []
|
||||
settledApprovals: [],
|
||||
compacting: false,
|
||||
compactionNotice: null
|
||||
}
|
||||
|
||||
let keyCounter = 0
|
||||
@@ -77,13 +111,14 @@ function readRole(message: unknown): string {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** 历史消息(会话切换/恢复时)映射为 UI 消息 */
|
||||
/** 历史消息(会话切换/恢复时)映射为 UI 消息。
|
||||
* compactionSummary / branchSummary 保留为通知行(压缩与分支摘要在时间线中可见)。 */
|
||||
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') {
|
||||
if (role === 'user' || role === 'assistant' || role === 'compactionSummary' || role === 'branchSummary') {
|
||||
result.push({ key: nextKey('h'), role, raw })
|
||||
}
|
||||
}
|
||||
@@ -131,6 +166,9 @@ 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) {
|
||||
@@ -140,25 +178,34 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
return { ...state, ready: true, sessionState: event.state }
|
||||
case 'session_error':
|
||||
return { ...state, sessionError: event.message }
|
||||
case 'session_switched':
|
||||
case 'session_switched': {
|
||||
// 同一会话的技术性重发(初始化重放等)不得打断进行中的回合:保留回合
|
||||
// 状态,仅刷新会话数据与历史。真正的切换(新会话/切换/分叉)会话 id
|
||||
// 不同,走完整重置。
|
||||
const sameSession = state.sessionState?.sessionId === event.state.sessionId
|
||||
return {
|
||||
...state,
|
||||
ready: true,
|
||||
error: null,
|
||||
retry: null,
|
||||
streaming: false,
|
||||
queue: { steering: [], followUp: [] },
|
||||
streaming: sameSession ? state.streaming : false,
|
||||
turnActive: sameSession ? state.turnActive : false,
|
||||
queue: sameSession ? state.queue : { steering: [], followUp: [] },
|
||||
stats: null,
|
||||
compacting: sameSession ? state.compacting : false,
|
||||
compactionNotice: sameSession ? state.compactionNotice : null,
|
||||
sessionState: event.state,
|
||||
messages: historyToMessages(event.messages),
|
||||
tools: historyToTools(event.messages)
|
||||
}
|
||||
}
|
||||
case 'agent_start':
|
||||
return { ...state, streaming: true, error: null, retry: null }
|
||||
return { ...state, streaming: true, turnActive: true, error: null, retry: null }
|
||||
case 'agent_end':
|
||||
// agent_end 不代表回合结束:自动压缩/重试会以 agent_start 续跑同一回合
|
||||
return { ...state, streaming: false }
|
||||
case 'agent_settled':
|
||||
return { ...state, streaming: false, retry: null }
|
||||
return { ...state, streaming: false, turnActive: false, retry: null }
|
||||
case 'message_start': {
|
||||
// 助手消息开流式占位;用户消息在发送时已乐观插入,此处不重复
|
||||
if (event.role === 'assistant') {
|
||||
@@ -191,12 +238,18 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
case 'message_end': {
|
||||
const role = readRole(event.message)
|
||||
const messages = [...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 }
|
||||
// 回填最近的流式占位——仅限 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 }
|
||||
}
|
||||
}
|
||||
messages.push({ key: nextKey('a'), role: 'assistant', raw: event.message })
|
||||
return { ...state, messages }
|
||||
}
|
||||
if (role === 'user') {
|
||||
// 若最后一条是本地乐观插入的用户消息,用服务端版本回填
|
||||
@@ -207,14 +260,7 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
||||
}
|
||||
return state
|
||||
}
|
||||
if (role === 'toolResult') {
|
||||
// 工具结果已在 ToolCard 中通过 tool_end 展示,跳过独立消息
|
||||
return state
|
||||
}
|
||||
if (role === 'assistant') {
|
||||
messages.push({ key: nextKey('a'), role: 'assistant', raw: event.message })
|
||||
return { ...state, messages }
|
||||
}
|
||||
// toolResult 等其他角色:已在 ToolCard 中通过 tool_end 展示,不独立成消息
|
||||
return state
|
||||
}
|
||||
case 'tool_start': {
|
||||
@@ -270,6 +316,32 @@ 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
|
||||
}
|
||||
@@ -279,6 +351,12 @@ 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
|
||||
@@ -292,12 +370,28 @@ 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
|
||||
@@ -319,6 +413,13 @@ 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
|
||||
}, [])
|
||||
@@ -435,11 +536,77 @@ 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,
|
||||
@@ -452,12 +619,23 @@ export function useAgent(): UseAgent {
|
||||
resolveApproval,
|
||||
setPermissionMode,
|
||||
createWorkspace,
|
||||
openWorkspace
|
||||
openWorkspace,
|
||||
setThinkingLevel,
|
||||
compact,
|
||||
abortCompaction,
|
||||
setAutoCompaction,
|
||||
dismissCompactionNotice,
|
||||
navigateTree,
|
||||
forkSession,
|
||||
forkSessionAt
|
||||
}),
|
||||
[
|
||||
state,
|
||||
sessions,
|
||||
workspaces,
|
||||
forkPoints,
|
||||
navigating,
|
||||
forking,
|
||||
send,
|
||||
stop,
|
||||
clearError,
|
||||
@@ -470,7 +648,15 @@ export function useAgent(): UseAgent {
|
||||
resolveApproval,
|
||||
setPermissionMode,
|
||||
createWorkspace,
|
||||
openWorkspace
|
||||
openWorkspace,
|
||||
setThinkingLevel,
|
||||
compact,
|
||||
abortCompaction,
|
||||
setAutoCompaction,
|
||||
dismissCompactionNotice,
|
||||
navigateTree,
|
||||
forkSession,
|
||||
forkSessionAt
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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,26 +1,17 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import { animateEntrance } from '../lib/anim'
|
||||
|
||||
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
|
||||
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
|
||||
/** 流式消息的入场动画(GSAP),仅挂载时执行一次。
|
||||
* animate=false 供历史消息渐进填充时跳过动画——切会话时批量挂载的旧消息
|
||||
* 不需要逐条淡入,几十个并发 tween 会造成可感知的卡顿。取挂载时刻的值,
|
||||
* 之后 animate 变化不追溯触发。 */
|
||||
export function useEntrance<T extends HTMLElement>(animate = true): RefObject<T | null> {
|
||||
const ref = useRef<T | null>(null)
|
||||
const animateRef = useRef(animate)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
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()
|
||||
}
|
||||
if (el && animateRef.current) animateEntrance(el)
|
||||
}, [])
|
||||
return ref
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** 共享动画模块(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,21 +2,45 @@ 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'
|
||||
import type { PluginSettingsSchemas, PluginSettingsValuesMap } from '@aso/plugin-sdk'
|
||||
|
||||
export type { AgentUiEvent, AppSettings, ModelCatalog, ModelInfo, SessionMeta, SessionState, SessionStatsInfo }
|
||||
export type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
ModelCatalog,
|
||||
ModelInfo,
|
||||
SessionMeta,
|
||||
SessionState,
|
||||
SessionStatsInfo,
|
||||
BrowserImportResult,
|
||||
BrowserSource,
|
||||
BrowserUiEvent,
|
||||
BrowserUiState
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -24,6 +48,9 @@ declare global {
|
||||
minimize: () => Promise<void>
|
||||
toggleMaximize: () => Promise<void>
|
||||
close: () => Promise<void>
|
||||
/** 界面缩放:factor 为相对倍率(如 1.1 放大 / 0.9 缩小),限制 50%–200% */
|
||||
zoom: (factor: number) => void
|
||||
zoomReset: () => void
|
||||
onWindowState: (callback: (maximized: boolean) => void) => () => void
|
||||
send: (text: string, images?: SendImage[]) => Promise<void>
|
||||
steer: (text: string) => Promise<void>
|
||||
@@ -54,11 +81,62 @@ 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>
|
||||
setSystemPrompt: (text: string) => Promise<void>
|
||||
getSystemPrompt: () => Promise<string>
|
||||
setThemeColor: (id: string) => Promise<void>
|
||||
setThemeMode: (mode: ThemeMode) => Promise<void>
|
||||
setResourceEnabled: (kind: 'skill' | 'command', name: string, enabled: boolean) => 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>
|
||||
pluginsDirs: () => Promise<{ user: string[]; project: string[]; global: string[] }>
|
||||
pluginsReadRenderer: (id: string) => Promise<string>
|
||||
pluginsSettingsSchemas: () => Promise<PluginSettingsSchemas>
|
||||
pluginsSettingsValues: () => Promise<PluginSettingsValuesMap>
|
||||
pluginsSetSettingValue: (id: string, key: string, value: unknown) => Promise<void>
|
||||
pluginsSetSettingsNav: (id: string, on: boolean) => Promise<void>
|
||||
pluginsSettingsAction: (id: string, key: string) => Promise<void>
|
||||
pluginsPickPath: (mode: 'file' | 'directory' | 'image') => Promise<string | null>
|
||||
pluginsReadImage: (path: string) => Promise<string | null>
|
||||
onPluginsSettingsSchemas: (callback: (schemas: PluginSettingsSchemas) => void) => () => void
|
||||
onPluginsSettingsValues: (callback: (values: PluginSettingsValuesMap) => void) => () => void
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 渲染层全局错误采集:window.onerror / unhandledrejection / 未处理的 React 边界外错误
|
||||
* 统一走 console.error,由宿主 console-message 桥归档进日志文件(main/index.ts)。
|
||||
* Agent 自诊断依赖:任何渲染层异常都必须能在 userData/logs/ 里看到。
|
||||
*/
|
||||
window.addEventListener('error', (e) => {
|
||||
// 资源加载失败(img/script)没有 error 对象,也一并记录
|
||||
const detail = e.error instanceof Error ? `${e.error.stack ?? e.error.message}` : `${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`
|
||||
console.error(`[window.onerror] ${detail}`)
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const reason = e.reason instanceof Error ? (e.reason.stack ?? e.reason.message) : String(e.reason)
|
||||
console.error(`[unhandledrejection] ${reason}`)
|
||||
})
|
||||
@@ -4,6 +4,9 @@
|
||||
* accent-ink 为加深版(保证链接/可交互文本 AA 对比度),tint 为低透明度选中底。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ThemeMode } from '@shared/types'
|
||||
|
||||
export interface ThemePreset {
|
||||
id: string
|
||||
label: string
|
||||
@@ -23,7 +26,14 @@ export const THEME_PRESETS: ThemePreset[] = [
|
||||
{ id: 'black', label: '墨黑', accent: '#3a3a3f', ink: '#17171a', tint: 'rgba(23, 23, 26, 0.08)' }
|
||||
]
|
||||
|
||||
/** 应用主题预设(undefined/未知 id = 恢复默认,移除内联覆盖) */
|
||||
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 页) */
|
||||
export function applyThemeColor(id: string | undefined): void {
|
||||
const preset = THEME_PRESETS.find((p) => p.id === id)
|
||||
const root = document.documentElement
|
||||
@@ -31,9 +41,41 @@ export function applyThemeColor(id: string | undefined): void {
|
||||
root.style.removeProperty('--accent')
|
||||
root.style.removeProperty('--accent-ink')
|
||||
root.style.removeProperty('--accent-tint')
|
||||
return
|
||||
} else {
|
||||
root.style.setProperty('--accent', preset.accent)
|
||||
root.style.setProperty('--accent-ink', preset.ink)
|
||||
root.style.setProperty('--accent-tint', preset.tint)
|
||||
}
|
||||
root.style.setProperty('--accent', preset.accent)
|
||||
root.style.setProperty('--accent-ink', preset.ink)
|
||||
root.style.setProperty('--accent-tint', preset.tint)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,3 +4,22 @@ 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)}%`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/globals.css'
|
||||
import './lib/error-report'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 外置插件页宿主(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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 插件状态胶囊(TitleBar B2 槽位):数据驱动渲染主进程广播的状态条目。
|
||||
* 顶栏胶囊只是紧凑状态条;点击行为分三档:
|
||||
* 1. 声明了 detail → 展开宿主渲染的详情面板(ZCode 式:头部标题+关闭、
|
||||
* 分组列表、行尾彩色数值;行内数据再负责深链)
|
||||
* 2. 声明了 openView → 执行深链
|
||||
* 3. 都没有 → 纯展示
|
||||
* 聊天视图与插件视图下均可见。面板点击外部/Esc 关闭。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { LuX } from 'react-icons/lu'
|
||||
import type { PluginStatusEntry } from '@shared/types'
|
||||
import { PluginIconValue } from './plugin-icons'
|
||||
|
||||
/** 胶囊前导色点:tone 的安静表达(胶囊底保持中性,不整块染色) */
|
||||
export const TONE_DOT_CLASS: Record<string, string> = {
|
||||
default: 'bg-ink-3',
|
||||
accent: 'bg-accent',
|
||||
success: 'bg-green',
|
||||
warn: 'bg-orange',
|
||||
danger: 'bg-red'
|
||||
}
|
||||
|
||||
/** 行尾数值的着色(ZCode 式:+76 绿 / -40 红 的纯色文本,不用底色块) */
|
||||
const TRAILING_TONE_CLASS: Record<string, string> = {
|
||||
default: 'text-ink-3',
|
||||
accent: 'text-accent',
|
||||
success: 'text-green',
|
||||
warn: 'text-orange',
|
||||
danger: 'text-red'
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情面板(ZCode Git 工具式):头部标题 + 关闭,分组列表;
|
||||
* 行 = 图标 + 标签(+次级说明)+ 行尾彩色数值。direction 控制从胶囊下方/上方弹出。
|
||||
*/
|
||||
export function StatusPopover({
|
||||
entry,
|
||||
onOpenView,
|
||||
onClose,
|
||||
direction = 'down'
|
||||
}: {
|
||||
entry: PluginStatusEntry
|
||||
onOpenView: (view: string, params?: unknown) => void
|
||||
onClose: () => void
|
||||
direction?: 'down' | 'up'
|
||||
}): ReactNode {
|
||||
const { item } = entry
|
||||
return (
|
||||
<div
|
||||
className={`absolute right-0 z-50 w-80 overflow-hidden rounded-card border border-line bg-surface shadow-overlay ${
|
||||
direction === 'up' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 py-2 pl-3 pr-1.5">
|
||||
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{item.detail?.title ?? item.text}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
className="shrink-0 rounded-control p-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
||||
>
|
||||
<LuX size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto overscroll-contain border-t border-line px-1.5 py-1.5">
|
||||
{item.detail?.sections.map((section, si) => (
|
||||
<div key={si} className={si > 0 ? 'mt-1' : ''}>
|
||||
{section.title && (
|
||||
<div className="px-2 pb-1 pt-1.5 text-[11px] font-medium text-ink-2">{section.title}</div>
|
||||
)}
|
||||
{section.rows.map((row, ri) => {
|
||||
const clickable = Boolean(row.openView)
|
||||
const content = (
|
||||
<>
|
||||
{row.icon && <PluginIconValue icon={row.icon} size={14} className="shrink-0 text-ink-2" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] leading-4 text-ink">{row.label}</span>
|
||||
{row.detail && <span className="mt-0.5 block truncate text-[11px] leading-4 text-ink-3">{row.detail}</span>}
|
||||
</span>
|
||||
{row.trailing && (
|
||||
<span className={`shrink-0 text-[11.5px] font-medium ${TRAILING_TONE_CLASS[row.tone ?? 'default']}`}>
|
||||
{row.trailing}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return clickable ? (
|
||||
<button
|
||||
key={ri}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (row.openView) onOpenView(row.openView.view, row.openView.params)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-control px-2 py-2 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div key={ri} className="flex w-full items-center gap-2 rounded-control px-2 py-2">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginStatusCapsules({
|
||||
entries,
|
||||
onOpenView
|
||||
}: {
|
||||
entries: PluginStatusEntry[]
|
||||
onOpenView: (view: string, params?: unknown) => void
|
||||
}): ReactNode {
|
||||
const [openKey, setOpenKey] = useState<string | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 弹层开着时:点外部 / Esc 关闭;条目消失(插件停用/重载)也收起
|
||||
useEffect(() => {
|
||||
if (!openKey) return
|
||||
const onDown = (e: MouseEvent): void => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpenKey(null)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setOpenKey(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [openKey])
|
||||
|
||||
// 条目集合变化(停用/重载/删除)时收起弹层;按 key 序列比较,避免引用变化误关
|
||||
const entryKeys = entries.map((e) => `${e.pluginId}:${e.itemId}`).join(',')
|
||||
useEffect(() => {
|
||||
setOpenKey(null)
|
||||
}, [entryKeys])
|
||||
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div ref={rootRef} className="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 key = `${pluginId}:${itemId}`
|
||||
const expandable = Boolean(item.detail)
|
||||
const clickable = expandable || Boolean(item.openView)
|
||||
const open = openKey === key
|
||||
return (
|
||||
<div key={key} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
title={item.text}
|
||||
onClick={() => {
|
||||
if (item.detail) setOpenKey(open ? null : key)
|
||||
else if (item.openView) onOpenView(item.openView.view, item.openView.params)
|
||||
}}
|
||||
className={`flex max-w-44 shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface px-2 py-0.5 text-[11px] leading-[18px] text-ink-2 transition-colors ${
|
||||
clickable ? 'cursor-pointer hover:border-line-strong hover:text-ink' : 'cursor-default'
|
||||
} ${open ? 'border-line-strong text-ink' : ''}`}
|
||||
>
|
||||
<span aria-hidden className={`size-1.5 shrink-0 rounded-full ${TONE_DOT_CLASS[tone] ?? TONE_DOT_CLASS.default}`} />
|
||||
{item.icon ? <PluginIconValue icon={item.icon} size={12} className="shrink-0" /> : null}
|
||||
<span className="truncate">{item.text}</span>
|
||||
</button>
|
||||
{open && item.detail && (
|
||||
<StatusPopover entry={{ pluginId, itemId, item }} onOpenView={onOpenView} onClose={() => setOpenKey(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 内置插件的宿主树组件注册表(componentKey → React 组件)。
|
||||
* 仅内置插件可用(外置插件 UI 只能走 iframe 页);浏览器面板(M3)在此登记。
|
||||
*/
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
export const BUILTIN_PLUGIN_COMPONENTS: Record<string, ComponentType> = {}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 插件图标规范:manifest.icon / 状态胶囊 icon 统一填 **lucide 图标名**
|
||||
* (PascalCase 如 "Globe",kebab-case "layout-grid" 亦可),宿主经 react-icons/lu
|
||||
* 白名单渲染——线条风格、粗细、圆角与宿主完全一致(DESIGN.md 图标规范)。
|
||||
* 旧版内联 `<svg>` 字符串仍兼容(sanitizeSvg 防线继续生效);
|
||||
* 名单之外或缺失的名字一律回落拼图图标,杜绝来历不明的自绘图形。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IconType } from 'react-icons'
|
||||
import {
|
||||
LuActivity,
|
||||
LuAppWindow,
|
||||
LuArchive,
|
||||
LuAtom,
|
||||
LuAward,
|
||||
LuBadgeCheck,
|
||||
LuBan,
|
||||
LuBell,
|
||||
LuBlocks,
|
||||
LuBookOpen,
|
||||
LuBot,
|
||||
LuBoxes,
|
||||
LuBriefcase,
|
||||
LuCamera,
|
||||
LuCalendar,
|
||||
LuChartPie,
|
||||
LuChartBar,
|
||||
LuCheck,
|
||||
LuCircleCheck,
|
||||
LuCircle,
|
||||
LuCircleDot,
|
||||
LuClock,
|
||||
LuCloud,
|
||||
LuCode,
|
||||
LuCompass,
|
||||
LuCpu,
|
||||
LuDatabase,
|
||||
LuHardDrive,
|
||||
LuDownload,
|
||||
LuEye,
|
||||
LuEyeOff,
|
||||
LuFile,
|
||||
LuFileText,
|
||||
LuFilter,
|
||||
LuFlag,
|
||||
LuFolder,
|
||||
LuFolderOpen,
|
||||
LuGitBranch,
|
||||
LuGitCommitHorizontal,
|
||||
LuGithub,
|
||||
LuGitPullRequest,
|
||||
LuGlobe,
|
||||
LuHammer,
|
||||
LuHash,
|
||||
LuHeadphones,
|
||||
LuHouse,
|
||||
LuImage,
|
||||
LuInbox,
|
||||
LuKeyboard,
|
||||
LuLayers,
|
||||
LuLayoutGrid,
|
||||
LuLink,
|
||||
LuListChecks,
|
||||
LuListTodo,
|
||||
LuLock,
|
||||
LuMail,
|
||||
LuMap,
|
||||
LuMessageCircle,
|
||||
LuMic,
|
||||
LuMonitor,
|
||||
LuMoon,
|
||||
LuMusic,
|
||||
LuPackage,
|
||||
LuPalette,
|
||||
LuPaperclip,
|
||||
LuPenLine,
|
||||
LuPhone,
|
||||
LuPlay,
|
||||
LuPlug,
|
||||
LuPlugZap,
|
||||
LuPuzzle,
|
||||
LuRocket,
|
||||
LuSave,
|
||||
LuScan,
|
||||
LuSearch,
|
||||
LuServer,
|
||||
LuSettings,
|
||||
LuSettings2,
|
||||
LuShare2,
|
||||
LuShield,
|
||||
LuShoppingCart,
|
||||
LuSlidersHorizontal,
|
||||
LuSparkles,
|
||||
LuSquareStack,
|
||||
LuStar,
|
||||
LuSun,
|
||||
LuTable,
|
||||
LuTag,
|
||||
LuTarget,
|
||||
LuTerminal,
|
||||
LuThumbsUp,
|
||||
LuTimer,
|
||||
LuTrash2,
|
||||
LuTrendingUp,
|
||||
LuUpload,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
LuVideo,
|
||||
LuWallet,
|
||||
LuWebhook,
|
||||
LuWrench,
|
||||
LuZap
|
||||
} from 'react-icons/lu'
|
||||
import { PluginIcon } from './PluginPageFrame'
|
||||
|
||||
/** 宿主核准的 lucide 图标白名单(manifest.icon / 胶囊 icon 可填的名字) */
|
||||
export const PLUGIN_LUCIDE_ICONS = {
|
||||
Activity: LuActivity,
|
||||
AppWindow: LuAppWindow,
|
||||
Archive: LuArchive,
|
||||
Atom: LuAtom,
|
||||
Award: LuAward,
|
||||
BadgeCheck: LuBadgeCheck,
|
||||
Ban: LuBan,
|
||||
Bell: LuBell,
|
||||
Blocks: LuBlocks,
|
||||
BookOpen: LuBookOpen,
|
||||
Bot: LuBot,
|
||||
Boxes: LuBoxes,
|
||||
Briefcase: LuBriefcase,
|
||||
Camera: LuCamera,
|
||||
Calendar: LuCalendar,
|
||||
ChartPie: LuChartPie,
|
||||
ChartBar: LuChartBar,
|
||||
Check: LuCheck,
|
||||
CircleCheck: LuCircleCheck,
|
||||
Circle: LuCircle,
|
||||
CircleDot: LuCircleDot,
|
||||
Clock: LuClock,
|
||||
Cloud: LuCloud,
|
||||
Code: LuCode,
|
||||
Compass: LuCompass,
|
||||
Cpu: LuCpu,
|
||||
Database: LuDatabase,
|
||||
HardDrive: LuHardDrive,
|
||||
Download: LuDownload,
|
||||
Eye: LuEye,
|
||||
EyeOff: LuEyeOff,
|
||||
File: LuFile,
|
||||
FileText: LuFileText,
|
||||
Filter: LuFilter,
|
||||
Flag: LuFlag,
|
||||
Folder: LuFolder,
|
||||
FolderOpen: LuFolderOpen,
|
||||
GitBranch: LuGitBranch,
|
||||
GitCommit: LuGitCommitHorizontal,
|
||||
Github: LuGithub,
|
||||
GitPullRequest: LuGitPullRequest,
|
||||
Globe: LuGlobe,
|
||||
Hammer: LuHammer,
|
||||
Hash: LuHash,
|
||||
Headphones: LuHeadphones,
|
||||
House: LuHouse,
|
||||
Image: LuImage,
|
||||
Inbox: LuInbox,
|
||||
Keyboard: LuKeyboard,
|
||||
Layers: LuLayers,
|
||||
LayoutGrid: LuLayoutGrid,
|
||||
Link: LuLink,
|
||||
ListChecks: LuListChecks,
|
||||
ListTodo: LuListTodo,
|
||||
Lock: LuLock,
|
||||
Mail: LuMail,
|
||||
Map: LuMap,
|
||||
MessageCircle: LuMessageCircle,
|
||||
Mic: LuMic,
|
||||
Monitor: LuMonitor,
|
||||
Moon: LuMoon,
|
||||
Music: LuMusic,
|
||||
Package: LuPackage,
|
||||
Palette: LuPalette,
|
||||
Paperclip: LuPaperclip,
|
||||
PenLine: LuPenLine,
|
||||
Phone: LuPhone,
|
||||
Play: LuPlay,
|
||||
Plug: LuPlug,
|
||||
PlugZap: LuPlugZap,
|
||||
Puzzle: LuPuzzle,
|
||||
Rocket: LuRocket,
|
||||
Save: LuSave,
|
||||
Scan: LuScan,
|
||||
Search: LuSearch,
|
||||
Server: LuServer,
|
||||
Settings: LuSettings,
|
||||
Settings2: LuSettings2,
|
||||
Share2: LuShare2,
|
||||
Shield: LuShield,
|
||||
ShoppingCart: LuShoppingCart,
|
||||
SlidersHorizontal: LuSlidersHorizontal,
|
||||
Sparkles: LuSparkles,
|
||||
SquareStack: LuSquareStack,
|
||||
Star: LuStar,
|
||||
Sun: LuSun,
|
||||
Table: LuTable,
|
||||
Tag: LuTag,
|
||||
Target: LuTarget,
|
||||
Terminal: LuTerminal,
|
||||
ThumbsUp: LuThumbsUp,
|
||||
Timer: LuTimer,
|
||||
Trash2: LuTrash2,
|
||||
TrendingUp: LuTrendingUp,
|
||||
Upload: LuUpload,
|
||||
User: LuUser,
|
||||
Users: LuUsers,
|
||||
Video: LuVideo,
|
||||
Wallet: LuWallet,
|
||||
Webhook: LuWebhook,
|
||||
Wrench: LuWrench,
|
||||
Zap: LuZap
|
||||
} satisfies Record<string, IconType>
|
||||
|
||||
/** kebab/snake/全小写 → PascalCase;已是 Pascal 的名字原样通过 */
|
||||
function toPascal(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.replace(/[-_\s]+(.)/g, (_, c: string) => c.toUpperCase())
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
/** 按名字查白名单图标;未命中返回 null */
|
||||
export function lucideIconByName(name: string): IconType | null {
|
||||
const key = toPascal(name)
|
||||
return (PLUGIN_LUCIDE_ICONS as Record<string, IconType>)[key] ?? null
|
||||
}
|
||||
|
||||
/** 图标槽位统一渲染:lucide 名(规范用法)/ 内联 svg(旧兼容)/ 缺省与未知名回落拼图图标 */
|
||||
export function PluginIconValue({ icon, size = 16, className }: { icon?: string; size?: number; className?: string }): ReactNode {
|
||||
const v = icon?.trim()
|
||||
if (v && v.startsWith('<svg')) return <PluginIcon svg={v} size={size} className={className} />
|
||||
const Icon = (v ? lucideIconByName(v) : null) ?? LuPuzzle
|
||||
return <Icon size={size} className={className} />
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 外置插件渲染层宿主(entry.renderer 动态装载):
|
||||
* - 启动即注入 window.__ASO_PLUGIN_HOST__(宿主 React 单实例——插件组件必须用它创建,
|
||||
* 自带 React 副本会导致 hooks 失效)
|
||||
* - 插件 renderer 模块源码经 IPC(plugins:readRenderer)取回,以 Blob URL 动态 import
|
||||
* (dev 页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦),
|
||||
* 默认导出 mount(ctx) 注册 view/panel 组件;列表/热重载变化即整模块重装
|
||||
* - 仅开发版装载(与主进程 EXTERNAL_PLUGINS_ENABLED 同开关语义;用户版主进程不下发外置条目)
|
||||
* - 性能护栏:读源与 import 均限时 8s(挂死的装载不得无限占位),按插件记录
|
||||
* loading/ready/error 状态供槽位呈现统一等待/失败态;错误隔离到单插件,
|
||||
* 组件渲染期崩溃由 App 层 PluginErrorBoundary 兜底
|
||||
*/
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
import * as React from 'react'
|
||||
import * as jsxRuntime from 'react/jsx-runtime'
|
||||
import { withTimeout } from '@aso/plugin-sdk'
|
||||
import type { PluginHostGlobal, PluginRendererModule } from '@aso/plugin-sdk/renderer'
|
||||
import type { PluginInfo } from '@shared/types'
|
||||
|
||||
/** 单个 renderer 模块读源/装载限时 */
|
||||
const RENDERER_LOAD_TIMEOUT_MS = 8_000
|
||||
|
||||
/** 插件已注册组件(按槽位;缺省 = 未注册该槽位) */
|
||||
interface PluginComponents {
|
||||
view?: ComponentType
|
||||
panel?: ComponentType
|
||||
}
|
||||
|
||||
const registry = new Map<string, PluginComponents>()
|
||||
const listeners = new Set<() => void>()
|
||||
let version = 0
|
||||
|
||||
function upsert(pluginId: string, patch: PluginComponents): void {
|
||||
registry.set(pluginId, { ...registry.get(pluginId), ...patch })
|
||||
version++
|
||||
for (const fn of listeners) fn()
|
||||
}
|
||||
|
||||
function remove(pluginId: string): void {
|
||||
if (!registry.delete(pluginId)) return
|
||||
version++
|
||||
for (const fn of listeners) fn()
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
/** 外置插件注册表(version 变化触发重渲染;组件本身从 registry 读取) */
|
||||
export function useExternalPluginComponents(): Map<string, PluginComponents> {
|
||||
useSyncExternalStore(subscribe, () => version)
|
||||
return registry
|
||||
}
|
||||
|
||||
// ---- 装载状态(loading/ready/error,按插件;槽位据此呈现统一等待/失败态) ----
|
||||
|
||||
export type PluginRenderStatus = { state: 'loading' } | { state: 'ready' } | { state: 'error'; message: string }
|
||||
|
||||
const renderStatus = new Map<string, PluginRenderStatus>()
|
||||
const statusListeners = new Set<() => void>()
|
||||
let statusVersion = 0
|
||||
|
||||
function setStatus(pluginId: string, status: PluginRenderStatus): void {
|
||||
renderStatus.set(pluginId, status)
|
||||
statusVersion++
|
||||
for (const fn of statusListeners) fn()
|
||||
}
|
||||
|
||||
function deleteStatus(pluginId: string): void {
|
||||
if (!renderStatus.delete(pluginId)) return
|
||||
statusVersion++
|
||||
for (const fn of statusListeners) fn()
|
||||
}
|
||||
|
||||
function subscribeStatus(fn: () => void): () => void {
|
||||
statusListeners.add(fn)
|
||||
return () => statusListeners.delete(fn)
|
||||
}
|
||||
|
||||
/** 某插件 renderer 的当前装载状态(未跟踪 = undefined,视为 loading) */
|
||||
export function usePluginRenderStatus(pluginId: string): PluginRenderStatus | undefined {
|
||||
useSyncExternalStore(subscribeStatus, () => statusVersion)
|
||||
return renderStatus.get(pluginId)
|
||||
}
|
||||
|
||||
// ---- 重试:槽位失败态的重试按钮触发整轮重装 ----
|
||||
|
||||
let retryTick = 0
|
||||
|
||||
/** 重试装载某插件的 renderer(清掉 error 态并重跑装载循环) */
|
||||
export function retryPluginRenderer(pluginId: string): void {
|
||||
setStatus(pluginId, { state: 'loading' })
|
||||
retryTick++
|
||||
for (const fn of statusListeners) fn()
|
||||
}
|
||||
|
||||
function usePluginRendererRetryTick(): number {
|
||||
return useSyncExternalStore(subscribeStatus, () => retryTick)
|
||||
}
|
||||
|
||||
// 宿主全局:插件构建期把 react / react/jsx-runtime 别名到这里的实例(见 docs/plugins.md)
|
||||
;(window as unknown as Record<'__ASO_PLUGIN_HOST__', PluginHostGlobal | undefined>).__ASO_PLUGIN_HOST__ = {
|
||||
react: React,
|
||||
jsxRuntime
|
||||
}
|
||||
|
||||
/**
|
||||
* 外置插件 renderer 模块装载循环:插件列表 / 热重载令牌 / 重试信号变化即重装。
|
||||
* 源码经 IPC 取回后以 Blob URL import——dev 下页面源是 http://localhost,
|
||||
* 直接 import(plugin://) 会被 CORS 拦截;Blob 模块须自包含(相对导入无基路径)。
|
||||
* 不在列表(禁用/卸载/关闭开关)或主进程侧报错的插件被移除,其组件随之卸载。
|
||||
*/
|
||||
export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: number): void {
|
||||
const retryToken = usePluginRendererRetryTick()
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return
|
||||
let cancelled = false
|
||||
const load = async (): Promise<void> => {
|
||||
const keep = new Set<string>()
|
||||
for (const plugin of plugins) {
|
||||
const file = plugin.manifest.entry?.renderer
|
||||
if (!plugin.enabled || plugin.source !== 'external' || plugin.error || !file) continue
|
||||
const id = plugin.manifest.id
|
||||
keep.add(id)
|
||||
setStatus(id, { state: 'loading' })
|
||||
try {
|
||||
const code = await withTimeout(window.api.pluginsReadRenderer(id), RENDERER_LOAD_TIMEOUT_MS, `读取 ${id} renderer 源码`)
|
||||
if (cancelled) return
|
||||
const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))
|
||||
try {
|
||||
const mod = (await withTimeout(
|
||||
import(/* @vite-ignore */ url),
|
||||
RENDERER_LOAD_TIMEOUT_MS,
|
||||
`装载 ${id} renderer 模块`
|
||||
)) as { default?: unknown }
|
||||
if (cancelled) return
|
||||
const mount = mod.default
|
||||
if (typeof mount !== 'function') throw new Error('renderer 模块默认导出必须是 mount(ctx) 函数')
|
||||
;(mount as PluginRendererModule)({
|
||||
manifest: plugin.manifest,
|
||||
react: React,
|
||||
registerView: (component) => upsert(id, { view: component }),
|
||||
registerPanel: (component) => upsert(id, { panel: component })
|
||||
})
|
||||
setStatus(id, { state: 'ready' })
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[plugins] ${id} renderer 装载失败:`, err)
|
||||
setStatus(id, { state: 'error', message })
|
||||
remove(id)
|
||||
}
|
||||
}
|
||||
for (const id of [...registry.keys()]) {
|
||||
if (!keep.has(id)) {
|
||||
remove(id)
|
||||
deleteStatus(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [plugins, reloadToken, retryToken])
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 渲染层插件状态 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 || p.hasRenderer))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) ||
|
||||
a.manifest.id.localeCompare(b.manifest.id)
|
||||
)
|
||||
}
|
||||
@@ -312,6 +312,15 @@ a:focus-visible .animated-underline::after {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
|
||||
.sidebar-collapsed-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ── shared keyframes ─────────────────────────────────── */
|
||||
|
||||
@keyframes shimmer-text {
|
||||
|
||||
@@ -52,12 +52,16 @@ body,
|
||||
}
|
||||
}
|
||||
|
||||
.stream-caret {
|
||||
/* 流式输出光标:以 ::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: '';
|
||||
display: inline-block;
|
||||
width: 0.5em;
|
||||
height: 1.05em;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
margin-left: 3px;
|
||||
vertical-align: -0.15em;
|
||||
background: var(--ink);
|
||||
border-radius: 1px;
|
||||
animation: caret-blink 0.9s steps(1) infinite;
|
||||
|
||||
@@ -19,6 +19,16 @@ export interface SessionState {
|
||||
workspace?: string
|
||||
/** 权限模式(Composer 权限 pill / 审批行为) */
|
||||
permissionMode?: PermissionMode
|
||||
/** 当前思考等级(pi ThinkingLevel) */
|
||||
thinkingLevel?: ThinkingLevel
|
||||
/** 当前模型可用的思考等级(供切换器渲染) */
|
||||
thinkingLevels?: ThinkingLevel[]
|
||||
/** 当前模型是否支持思考 */
|
||||
supportsThinking?: boolean
|
||||
/** 正在压缩上下文 / 生成分支摘要 */
|
||||
isCompacting?: boolean
|
||||
/** 自动压缩(阈值/溢出触发)是否开启 */
|
||||
autoCompactionEnabled?: boolean
|
||||
}
|
||||
|
||||
/** 工作区 = 一个本地目录(agent 的工作目录,会话按目录隔离) */
|
||||
@@ -27,11 +37,56 @@ 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 的映射) */
|
||||
@@ -84,8 +139,6 @@ export interface CustomProviderConfig {
|
||||
|
||||
/** 应用级设置(存 userData/aso-settings.json) */
|
||||
export interface AppSettings {
|
||||
/** 覆盖 pi 默认系统提示词(空串 = 不覆盖) */
|
||||
systemPrompt: string
|
||||
/** 当前工作区目录(缺省 = 默认工作区 Default) */
|
||||
workspace?: string
|
||||
/** 通过目录选择器添加的根目录之外的工作区 */
|
||||
@@ -94,8 +147,17 @@ export interface AppSettings {
|
||||
permissionMode?: PermissionMode
|
||||
/** 主题色预设 id(缺省 default,见 renderer lib/theme.ts) */
|
||||
themeColor?: string
|
||||
/** 深浅色模式(缺省 system 跟随系统;经 Electron nativeTheme 生效) */
|
||||
themeMode?: ThemeMode
|
||||
/** 已禁用的技能名清单(技能按 name 加载去重,name 即唯一标识;加载阶段整体过滤) */
|
||||
disabledSkills?: string[]
|
||||
/** 已禁用的命令(prompt 模板)名清单 */
|
||||
disabledCommands?: string[]
|
||||
}
|
||||
|
||||
/** 深浅色模式:light / dark / system(跟随系统) */
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
|
||||
/** 权限模式:confirm 变更前确认 / full 完全访问 */
|
||||
export type PermissionMode = 'confirm' | 'full'
|
||||
|
||||
@@ -135,6 +197,8 @@ export interface SkillDetail {
|
||||
scope: ResourceScope
|
||||
/** true = 不进入系统提示词,仅可通过 /skill:name 显式调用 */
|
||||
disableModelInvocation: boolean
|
||||
/** true = 已被用户禁用(加载阶段过滤,不进会话) */
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/** 设置页命令条目(pi prompt template,含来源文件路径) */
|
||||
@@ -145,13 +209,22 @@ export interface CommandDetail {
|
||||
argumentHint?: string
|
||||
filePath: string
|
||||
scope: ResourceScope
|
||||
/** true = 已被用户禁用(加载阶段过滤,不进会话) */
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/** 设置页技能/命令总览:条目 + 各资源目录(不存在时为空串) */
|
||||
export interface ResourceDetails {
|
||||
skills: SkillDetail[]
|
||||
commands: CommandDetail[]
|
||||
dirs: { globalSkills: string; projectSkills: string; globalCommands: string; projectCommands: string }
|
||||
dirs: {
|
||||
globalSkills: string
|
||||
globalAgentsSkills: string
|
||||
projectSkills: string
|
||||
projectAgentsSkills: string
|
||||
globalCommands: string
|
||||
projectCommands: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */
|
||||
@@ -182,10 +255,78 @@ 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[]; activeTools: Record<string, unknown> }
|
||||
| { type: 'session_switched'; state: SessionState; messages: unknown[] }
|
||||
| { type: 'state'; state: SessionState }
|
||||
| { type: 'approval_request'; request: ApprovalRequest }
|
||||
| { type: 'approval_settled'; id: string; approved: boolean; timedOut: boolean }
|
||||
@@ -203,3 +344,70 @@ 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'
|
||||
/** 外置来源目录类别(仅 external):user=应用外置目录 / project=项目 .pi/extensions / global=全局扩展目录 */
|
||||
origin?: 'user' | 'project' | 'global'
|
||||
enabled: boolean
|
||||
/** 有主进程工厂(pi 工具/事件/内联 UI 注册能力) */
|
||||
hasMain: boolean
|
||||
/** 有 iframe 插件页 */
|
||||
hasPage: boolean
|
||||
/** 有宿主树组件(仅内置;外置遮蔽需 replacesNative 声明) */
|
||||
hasComponent: boolean
|
||||
/** 有渲染层模块(entry.renderer,宿主动态 import 后注册宿主内 React 组件) */
|
||||
hasRenderer: boolean
|
||||
/** 已声明设置表单(api.ui.registerSettings;空 schema 视为无) */
|
||||
hasSettings: boolean
|
||||
/** 设置入口已固定到设置页一级导航(用户开关;仅 hasSettings 时可开) */
|
||||
settingsNav: 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
|
||||
}
|
||||
|
||||
@@ -67,3 +67,15 @@
|
||||
- ✅ 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 补开关与可见性管控。
|
||||
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
# 插件系统(harness 插件契约)
|
||||
|
||||
> **教程入口**:[docs/plugins/README.md](./plugins/README.md) —— L0 第一个插件 → L1 工具命令 → L2 设置 → L3 UI 槽位 → L4 React 组件 → L5 自诊断;视觉规范 [DESIGN.md](./plugins/DESIGN.md);脚手架 [packages/plugin-template](../packages/plugin-template/)。本文件是契约全集(参考手册)。
|
||||
>
|
||||
> 状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap)
|
||||
> 契约版本:`HARNESS_CONTRACT_VERSION = 0.2.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": "Globe", // 侧栏/面板/胶囊图标:lucide 图标名(白名单见 DESIGN.md;kebab 亦可);兼容旧 svg 字符串;未知名回落拼图图标
|
||||
"order": 10, // 侧栏排序(只在插件间;核心入口恒在前)
|
||||
"panel": true, // 在右侧 SidePanel 出现一个 tab
|
||||
"entry": { "page": "index.html", "main": "main.mjs", "renderer": "renderer.mjs" }, // 内置插件省略 entry
|
||||
"replacesNative": false,
|
||||
"updateURL": "https://…/index.json"
|
||||
}
|
||||
```
|
||||
|
||||
外置插件源(`EXTERNAL_PLUGINS_ENABLED` 开发版开/用户版关,`ASO_EXTERNAL_PLUGINS=1` 豁免):`userData/plugins/<id>/` > 项目 `.pi/extensions/<id>/` > 全局 `~/.pi/agent/extensions/<id>/`。渲染层模块文件**不要命名为 index.ts/index.js**——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载。
|
||||
|
||||
## 主进程插件契约(`@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 编译
|
||||
|
||||
## 渲染层模块契约(`@aso/plugin-sdk/renderer`)
|
||||
|
||||
外置插件以 React 组件形态进入宿主界面(与内置同槽位),不再是 iframe 物理隔离:
|
||||
|
||||
```jsonc
|
||||
// manifest.json
|
||||
{ "id": "demo-ui", "panel": true, "entry": { "renderer": "renderer.mjs" } }
|
||||
```
|
||||
|
||||
```js
|
||||
// renderer.mjs —— 宿主渲染层动态 import(plugin://<id>/renderer.mjs) 后调用默认导出
|
||||
export default function mount(ctx) {
|
||||
ctx.registerView(MyViewComponent) // 整页视图(侧栏入口 → 主内容区)
|
||||
ctx.registerPanel(MyPanelComponent) // 面板 tab 内容(需 panel: true)
|
||||
}
|
||||
```
|
||||
|
||||
- **单实例约束**:组件必须用 `ctx.react`(宿主 React,即 `window.__ASO_PLUGIN_HOST__.react`)创建;插件自带 React 副本会导致 hooks 全部失效
|
||||
- JSX 工程构建配方(esbuild,react 别名到宿主实例的 shim):
|
||||
|
||||
```sh
|
||||
esbuild renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
||||
--jsx=automatic \
|
||||
--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
|
||||
```
|
||||
|
||||
```js
|
||||
// shims/react.js —— 构建期别名目标
|
||||
const R = window.__ASO_PLUGIN_HOST__.react
|
||||
export default R
|
||||
export const { useState, useEffect, useRef, useMemo, useCallback, createContext,
|
||||
useContext, Fragment, memo, forwardRef, createElement } = R
|
||||
```
|
||||
|
||||
```js
|
||||
// shims/jsx-runtime.js
|
||||
const J = window.__ASO_PLUGIN_HOST__.jsxRuntime
|
||||
export const { jsx, jsxs, Fragment } = J
|
||||
```
|
||||
|
||||
- 装载时机与热重载:插件列表变化(扫描/启停/`plugins:reload`)即按 `?t=` cache-bust 重装;被禁用/移除的插件注册随之清除
|
||||
- 门控:仅开发版装载(`import.meta.env.DEV` + 主进程 `EXTERNAL_PLUGINS_ENABLED`)
|
||||
- 性能护栏:读源与 import 各限时 8s,超时/失败由宿主槽位呈现统一等待/失败态(ErrorState + 重试,见 `plugins/renderer-host`);渲染期崩溃由 PluginErrorBoundary 兜底
|
||||
|
||||
## 公共 UI 组件库与异步工具(`@aso/plugin-sdk/ui` · `withTimeout`/`withRetry`)
|
||||
|
||||
- **`./ui` 子入口**:宿主视觉规范的代码化——`Button`/`Input`/`Select`/`Switch`/`Card`/`SettingRow`/`Badge`
|
||||
+ 三态组件 `LoadingState`/`ErrorState`/`EmptyState`/`Spinner`。仅 renderer 组件槽位可用
|
||||
(渲染在宿主 DOM,复用宿主样式表);组件目录与三态铁律见 [docs/plugins/07-ui-components.md](./plugins/07-ui-components.md)
|
||||
- **异步工具(主入口导出)**:`withTimeout(promise, ms, label?)`、`withRetry(fn, { retries, delayMs, timeoutMs, onRetry })`、
|
||||
`TimeoutError`——插件内一切可能挂起的异步(网络/子进程/文件轮询)必须自带超时与重试
|
||||
- **宿主兜底**:插件激活限时 10s(异步工厂超时/reject 即标记插件失败,不拖垮 runtime 重建);
|
||||
renderer 装载限时 8s
|
||||
- **机器关卡**:`pnpm plugin:check <插件目录>` 校验契约完整性、同步 IO、console.*、
|
||||
React 单实例、hex 色值、三态使用等(error 级不通过即 exit 1),规则见 [docs/plugins/08-performance.md](./plugins/08-performance.md)
|
||||
|
||||
## 插件设置(声明式 schema,`api.ui.registerSettings`)
|
||||
|
||||
插件在 main.mjs 里声明配置表单,宿主负责渲染/持久化/回推——schema 是**纯数据**(不可含函数/React 元素):
|
||||
|
||||
```js
|
||||
// main.mjs
|
||||
api.ui.registerSettings({
|
||||
title: '我的插件设置',
|
||||
items: [
|
||||
{ type: 'string', key: 'token', label: 'API Token', password: true },
|
||||
{ type: 'boolean', key: 'enabled', label: '启用增强', default: true },
|
||||
{ type: 'select', key: 'mode', label: '模式', options: [{ value: 'fast', label: '快速' }], default: 'fast' },
|
||||
{ type: 'path', key: 'dir', label: '数据目录', mode: 'directory' },
|
||||
{ type: 'image', key: 'logo', label: 'Logo' },
|
||||
{ type: 'button', key: 'test', label: '测试连接', variant: 'primary' }
|
||||
]
|
||||
})
|
||||
|
||||
api.ui.getSettingValues() // 当前值(schema default 已合并)
|
||||
api.ui.onSettingsChange((v) => {}) // 用户修改设置后实时推送
|
||||
api.ui.onSettingsAction((key) => {}) // button 条目点击回调(key = 按钮项 key)
|
||||
```
|
||||
|
||||
- **标准控件**(`PluginSettingItem`,按 `type` 判别):`string`(含 password)/ `text` 多行 / `number` / `boolean`(switch)/ `select` / `multiSelect` / `slider` / `color` / `path`(file|directory)/ `image`(选择+缩略图)/ `button`(动作)/ `info` / `divider`
|
||||
- **持久化**:值存 `userData/aso-plugin-values.json`(按插件隔离);`getSettingValues` 合并 schema 默认值
|
||||
- **一级入口**:有配置项(items 非空)的插件,在插件页行内出现「一级入口」开关;打开后设置页左侧「Agent 能力」组追加以插件名命名的导航项,点击进二级配置页。items 为空/未声明 → 无开关、无导航项
|
||||
- 值变更经 `plugins:settingsValues` 广播:设置页与插件主进程两侧同步
|
||||
|
||||
## UI 槽位总览(对位 pi 的 ctx.ui)
|
||||
|
||||
| 槽位 | 机制 | 状态 |
|
||||
|---|---|---|
|
||||
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
|
||||
| 插件整页 Main | 内置组件级 / 外置 renderer 组件 / 外置 iframe;聊天列 display:none 保活 | ✅ |
|
||||
| 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件 / 外置 renderer 组件 / iframe) | ✅ |
|
||||
| 状态胶囊(B2) | `setStatusItem` 数据驱动,TitleBar 渲染,两种视图均可见;顶栏为紧凑状态条,点击可展开宿主渲染的详情弹层(`detail`:分组/行/徽标/行内深链) | ✅ |
|
||||
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
|
||||
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
|
||||
| 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 |
|
||||
| 聊天区浮动胶囊(B3) | 独立浮动态暂缓——顶栏胶囊的详情面板(ZCode 式)已覆盖同场景;数据同源(`setStatusItem`) | ⏳ |
|
||||
|
||||
## 交互基线
|
||||
|
||||
- 整页替换:点插件入口 → 主列(聊天)隐藏保活;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 plugin:`(renderer 模块动态 import);.mjs/.js 强制 JS MIME
|
||||
- 外置装载为 **build 级开关**(`EXTERNAL_PLUGINS_ENABLED`:开发版开/用户版关,`ASO_EXTERNAL_PLUGINS=1` 豁免);外置 main.mjs 全权运行于主进程(与 pi 扩展同信任级)——**签名校验前,仅自行放置的插件目录可装载**(M5 后强制)
|
||||
- 外置 renderer 模块以渲染层特权运行(无 iframe 沙箱):同样受上述 build 开关门控;组件崩溃不越出 ErrorBoundary
|
||||
|
||||
## pi 原生目录(M7)
|
||||
|
||||
事实:pi resource-loader **默认自动加载** `~/.pi/agent/extensions/*.ts` 与 `<cwd>/.pi/extensions/* /index.ts`(`noExtensions` 可关)——即本应用当前已在运行用户为 pi CLI 安装的扩展(逻辑部分;其 TUI UI 调用按 pi 非 TUI 模式语义降级)。
|
||||
|
||||
已落地:harness 把同一批目录当作**一等外置插件源**——目录内含 harness `manifest.json` 的子目录获得完整插件待遇(main.mjs 工厂 / entry.renderer React 组件 / entry.page iframe / 面板 tab / 管理页开关)。pi 只自动发现 index.ts/index.js,harness 约定入口为 main.mjs/renderer.mjs,因此**不会被 pi 双重加载**;纯 pi 扩展(index.ts)仍由 pi 原生装载、不参与 harness UI。项目级目录随工作区切换同步(`setProjectExtensionsDir`)。未做:项目目录的 project-trust 门控(与 pi 侧对齐)、设置页按源展示。
|
||||
|
||||
## 开发工作流(外置插件,M6 模板落地后)
|
||||
|
||||
```
|
||||
packages/plugin-template → npm i(workspace 引 @aso/plugin-sdk)
|
||||
页面:vite build --watch → dist/ # 改即重编
|
||||
宿主:开发者模式开 fs.watch → 自动 plugins:reload → iframe 换 src
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# L0 · 第一个插件
|
||||
|
||||
> 目标:10 分钟内拥有一个被宿主装载、可在日志里看到自己痕迹的最小插件。不需要构建工具。
|
||||
|
||||
## 1. 建目录
|
||||
|
||||
放在**全局**插件目录(所有工作区可见;项目级放 `<工作区>/.pi/extensions/`,见 README):
|
||||
|
||||
```
|
||||
~/.pi/agent/extensions/hello/
|
||||
manifest.json
|
||||
main.mjs
|
||||
```
|
||||
|
||||
> Windows 下即 `C:\Users\<你>\.pi\agent\extensions\hello\`。
|
||||
|
||||
## 2. manifest.json
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "hello",
|
||||
"name": "Hello",
|
||||
"version": "0.1.0",
|
||||
"minContract": "0.1.0",
|
||||
"description": "我的第一个插件"
|
||||
}
|
||||
```
|
||||
|
||||
字段规则:`id` 必须匹配 `^[a-z][a-z0-9-]*$`;`version` 是 semver;`minContract` 是要求的宿主契约版本(当前 `0.2.0`,填 `0.1.0` 即可向后兼容)。全字段见 [../plugins.md](../plugins.md#manifestjson)。
|
||||
|
||||
## 3. main.mjs
|
||||
|
||||
```js
|
||||
// 默认导出一个工厂:宿主激活插件时调用,返回的函数在禁用/重载时被调用
|
||||
export default function definePlugin(api) {
|
||||
api.log.info('hello activated', { version: api.manifest.version })
|
||||
|
||||
// 演示:往 Agent 可见的目录里注册一个极简工具
|
||||
api.pi.registerTool({
|
||||
name: 'hello_now',
|
||||
description: '返回当前时间的问候语',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
api.log.debug('hello_now called')
|
||||
return { output: `Hello! 现在是 ${new Date().toISOString()}` }
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
api.log.info('hello disposed')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `api` 的完整形状(pi / ui / storage / log / manifest)见 [../plugins.md](../plugins.md#主进程插件契约aso-plugin-sdk-主入口)。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
1. 重启应用(`pnpm dev`;扫描发生在启动与插件重载时)
|
||||
2. 打开 设置 → Agent 能力 → 插件 → **全局** 作用域,应看到「Hello v0.1.0」,开关为「已启用」
|
||||
3. 打开日志文件 `userData/logs/main-YYYY-MM-DD.log`,应能搜到:
|
||||
|
||||
```
|
||||
<时间> INFO [plugin-host] external main loaded pluginId=hello version=0.1.0
|
||||
<时间> INFO [plugin-host] plugin activated pluginId=hello source=external
|
||||
<时间> INFO [plugin] hello activated pluginId=hello version=0.1.0
|
||||
```
|
||||
|
||||
## 5. 改代码后如何生效
|
||||
|
||||
- 改 `main.mjs`:设置 → 插件页任意开关触发重载,或重启应用(宿主以 cache-bust 重新 import)
|
||||
- 改 `manifest.json`:同上;`id` 变了等于新插件
|
||||
|
||||
## 常见装载失败
|
||||
|
||||
| 现象(日志里搜) | 原因 |
|
||||
|---|---|
|
||||
| `external main load failed` | main.mjs 语法错误 / import 了不存在的模块 |
|
||||
| `external main invalid export` | 忘了 `export default`,或默认导出不是函数 |
|
||||
| `需要契约版本 >=` | manifest.minContract 高于当前宿主契约(0.2.0) |
|
||||
| 插件页全局作用域为空 | 目录放错(注意是 `extensions` 复数);或用户版构建(装载关闭) |
|
||||
|
||||
---
|
||||
|
||||
**下一篇**:[L1 工具与命令](./02-tools-and-commands.md)——把真正的能力交给 Agent。
|
||||
@@ -0,0 +1,91 @@
|
||||
# L1 · 工具与命令
|
||||
|
||||
> 前置:[L0](./01-hello-plugin.md)。`api.pi` 就是 pi 的原生 `ExtensionAPI`——pi CLI 扩展能做的这里都能做,语义一致。
|
||||
|
||||
## 1. 注册一个带参数的工具
|
||||
|
||||
工具是 Agent 的「手」:注册后自动进入系统提示词与工具列表,受审批拦截管理。
|
||||
|
||||
```js
|
||||
export default function definePlugin(api) {
|
||||
api.pi.registerTool({
|
||||
name: 'dice_roll',
|
||||
description: '掷一个 N 面骰子,返回点数',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sides: { type: 'number', description: '面数,默认 6' }
|
||||
}
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const sides = Number(params.sides) || 6
|
||||
const value = 1 + Math.floor(Math.random() * sides)
|
||||
api.log.info('dice rolled', { sides, value })
|
||||
return { output: String(value), metadata: { sides, value } }
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
- `description` 会注入系统提示词,写清楚「什么时候该用这个工具」,Agent 的调用准确率取决于它
|
||||
- `execute` 返回 `{ output: string }`——output 是喂回模型的内容,保持精炼
|
||||
- 工具名全局唯一,用 `<插件名>_<动作>` 前缀避免撞名
|
||||
|
||||
## 2. 注册斜杠命令
|
||||
|
||||
命令是用户的「入口」:聊天框输入 `/` 出现,回车触发。
|
||||
|
||||
```js
|
||||
export default function definePlugin(api) {
|
||||
api.pi.registerCommand({
|
||||
name: 'dice',
|
||||
description: '掷骰子',
|
||||
handler: async (ctx, args) => {
|
||||
const sides = Number(args) || 6
|
||||
ctx.notify(`🎲 ${1 + Math.floor(Math.random() * sides)} / ${sides}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 订阅事件
|
||||
|
||||
在 Agent 生命周期钩子上挂逻辑(发消息前注入、工具调用前后处理等):
|
||||
|
||||
```js
|
||||
export default function definePlugin(api) {
|
||||
const off = api.pi.on('before_agent_start', (event) => {
|
||||
api.log.debug('agent starting', { prompt: event.prompt?.slice(0, 50) })
|
||||
})
|
||||
// 返回的取消函数放进 dispose,宿主在禁用/重载时调用
|
||||
return () => off()
|
||||
}
|
||||
```
|
||||
|
||||
常用事件:`before_agent_start` / `agent_start` / `tool_call` / `tool_result` / `before_provider_request` / `agent_end`。全集见 pi 的 `ExtensionAPI` 类型(`@earendil-works/pi-coding-agent`)。
|
||||
|
||||
## 4. 与插件私有存储配合
|
||||
|
||||
需要跨回合记住状态时用 `api.storage`(按插件隔离,落盘 `aso-plugin-storage.json`):
|
||||
|
||||
```js
|
||||
let count = api.storage.get('count') ?? 0
|
||||
api.storage.set('count', ++count)
|
||||
```
|
||||
|
||||
## 5. 验证
|
||||
|
||||
1. 重载插件(开关切换或重启)
|
||||
2. 日志确认 `plugin activated`
|
||||
3. 聊天框输入 `/dice` 回车 → 收到点数;对 Agent 说「掷个 20 面骰」→ 观察它调用 `dice_roll`
|
||||
4. 日志中应出现 `dice rolled sides=20 value=…`
|
||||
|
||||
## 排障
|
||||
|
||||
- 工具没出现在 Agent 里:日志搜 `plugin activated` 确认激活成功;`registerTool` 抛错会记为 `plugin activation failed`
|
||||
- 命令没出现在 `/` 面板:命令名冲突(重名被去重),换名再试
|
||||
|
||||
---
|
||||
|
||||
**上一篇** [L0](./01-hello-plugin.md) · **下一篇**:[L2 设置表单](./03-settings.md)——让用户能配置你的插件。
|
||||
@@ -0,0 +1,90 @@
|
||||
# L2 · 设置表单
|
||||
|
||||
> 前置:[L0](./01-hello-plugin.md)。声明式 schema:插件声明「要什么配置」,宿主负责渲染、持久化、实时回推。不用写任何 UI 代码。
|
||||
|
||||
## 1. 声明设置
|
||||
|
||||
```js
|
||||
export default function definePlugin(api) {
|
||||
api.ui.registerSettings({
|
||||
items: [
|
||||
{ type: 'string', key: 'apiKey', label: 'API Token', password: true, placeholder: 'sk-...' },
|
||||
{ type: 'boolean', key: 'verbose', label: '详细日志', default: false },
|
||||
{
|
||||
type: 'select', key: 'endpoint', label: '接入点', default: 'prod',
|
||||
options: [
|
||||
{ value: 'prod', label: '生产' },
|
||||
{ value: 'staging', label: '预发' }
|
||||
]
|
||||
},
|
||||
{ type: 'button', key: 'test', label: '测试连接', variant: 'primary' }
|
||||
]
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
保存后(开关切换或重启):
|
||||
- 设置 → 插件 → **全局/项目** 作用域里,该插件行内出现 ⚙ 按钮(打开配置页)和「一级入口」开关
|
||||
- **items 为空或不声明 → 两个控件都不出现**(这正是"留空则无一级入口按钮"的规则)
|
||||
|
||||
## 2. 一级入口
|
||||
|
||||
行内「一级入口」开关打开后,设置弹窗左侧导航「Agent 能力」组末尾出现以插件名命名的入口,点击进入该插件的独立配置页。用户随时可以关掉。
|
||||
|
||||
## 3. 读写值
|
||||
|
||||
```js
|
||||
const values = api.ui.getSettingValues() // { apiKey: '...', verbose: false, endpoint: 'prod' }
|
||||
// schema 里的 default 已合并——没被用户改过的键直接拿默认值
|
||||
|
||||
api.ui.onSettingsChange((next) => { // 用户在设置页改值后实时推送
|
||||
values = next
|
||||
api.log.info('settings changed', { endpoint: next.endpoint })
|
||||
})
|
||||
|
||||
api.ui.onSettingsAction((key) => { // button 条目点击(key = 按钮项 key)
|
||||
if (key === 'test') {
|
||||
const ok = values.apiKey ? '✅' : '❌ 缺少 API Token'
|
||||
api.ui.setStatusItem('test', { text: `连接测试 ${ok}`, tone: 'accent' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
值持久化在 `userData/aso-plugin-values.json`(按插件隔离),重装插件后仍在。
|
||||
|
||||
## 4. 13 种标准控件
|
||||
|
||||
| type | 说明 | 关键字段 |
|
||||
|---|---|---|
|
||||
| `string` | 单行输入 | `password` 掩码、`placeholder` |
|
||||
| `text` | 多行文本 | `rows` |
|
||||
| `number` | 数字 | `min` / `max` / `step` |
|
||||
| `boolean` | 开关 | — |
|
||||
| `select` | 下拉单选 | `options[{value,label}]` |
|
||||
| `multiSelect` | 多选(chip 组) | `options` |
|
||||
| `slider` | 滑条 | `min` / `max` 必填、`step` |
|
||||
| `color` | 颜色(#rrggbb) | — |
|
||||
| `path` | 文件/目录选择 | `mode: 'file' \| 'directory'` |
|
||||
| `image` | 图片选择 + 缩略图 | — |
|
||||
| `button` | 动作按钮 | `variant: 'primary' \| 'ghost'` |
|
||||
| `info` | 静态说明 | — |
|
||||
| `divider` | 分割线/小节标题 | `label?` |
|
||||
|
||||
全部条目支持 `label` / `description` / `default`(button/info/divider 除外)。
|
||||
|
||||
## 5. 设计约束(为什么是数据不是代码)
|
||||
|
||||
- schema 是**纯数据**:不允许函数/React 元素——宿主跨进程渲染并持久化快照
|
||||
- 需要动态选项(比如扫描出来的设备列表)?在激活时计算好再 `registerSettings`,每次激活都会重新声明
|
||||
- 需要复杂交互 UI?那不是设置的职责——走 [L4 React 组件](./05-renderer-components.md)
|
||||
|
||||
## 6. 验证
|
||||
|
||||
1. 重载插件 → 插件页该行出现 ⚙ 与「一级入口」
|
||||
2. 打开配置页改一个值 → 日志出现 `settings changed`(`onSettingsChange` 生效)
|
||||
3. 点按钮 → 状态胶囊出现(动作回调生效)
|
||||
4. `userData/aso-plugin-values.json` 里能看到写入的键值
|
||||
|
||||
---
|
||||
|
||||
**上一篇** [L1](./02-tools-and-commands.md) · **下一篇**:[L3 UI 槽位](./04-ui-slots.md)——状态胶囊、面板、视图与 iframe 页。
|
||||
@@ -0,0 +1,118 @@
|
||||
# L3 · UI 槽位:胶囊、工具栏、视图、面板、iframe 页
|
||||
|
||||
> 前置:[L0](./01-hello-plugin.md)。宿主开放 5 个数据驱动 UI 槽位 + 1 个 iframe 页形态。**数据驱动**意味着插件只提交数据,渲染与样式由宿主统一负责——这是 UI 一致性的第一道保障(自定义视觉见 [DESIGN.md](./DESIGN.md) 与 [L4](./05-renderer-components.md))。
|
||||
|
||||
## 1. 状态胶囊(TitleBar 顶部)
|
||||
|
||||
顶栏胶囊是**紧凑状态条**,点击行为三档:
|
||||
|
||||
```js
|
||||
// ① 纯展示
|
||||
api.ui.setStatusItem('conn', { text: '已连接 · 3 任务' })
|
||||
|
||||
// ② 点击深链
|
||||
api.ui.setStatusItem('conn', {
|
||||
text: '已连接 · 3 任务',
|
||||
tone: 'accent', // default | accent | warn | danger
|
||||
icon: '<svg .../>', // 可选,12px 线条风
|
||||
openView: { view: 'my-plugin' } // 点击跳到插件整页视图
|
||||
})
|
||||
|
||||
// ③ 点击展开详情弹层(声明了 detail 时优先于 openView)
|
||||
api.ui.setStatusItem('tasks', {
|
||||
text: '任务 2/5',
|
||||
tone: 'accent',
|
||||
detail: {
|
||||
title: '任务概览', // 缺省用胶囊文本
|
||||
sections: [
|
||||
{
|
||||
title: '进行中', // 分组标题可省略
|
||||
rows: [
|
||||
{ label: '构建索引', detail: '已完成 60%', trailing: '2/5', tone: 'accent' },
|
||||
{ label: '清理临时文件' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
rows: [{ label: '打开任务面板', openView: { view: 'my-plugin' } }] // 行点击深链
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
api.ui.removeStatusItem('tasks')
|
||||
```
|
||||
|
||||
弹层由宿主统一渲染(分组标题 / 行 / 右侧徽标 / 深链),样式与宿主一致——插件只提交
|
||||
数据;点击弹层外部或 Esc 关闭;dispose 时宿主自动清理本插件全部条目。
|
||||
|
||||
## 2. 工具栏按钮(输入区工具行)
|
||||
|
||||
```js
|
||||
api.ui.registerToolbarButton({
|
||||
id: 'open',
|
||||
title: '打开我的面板',
|
||||
icon: '<svg .../>', // 16-20px 线条风,currentColor
|
||||
openView: { view: 'my-plugin' }
|
||||
})
|
||||
```
|
||||
|
||||
## 3. 整页视图 + 侧栏入口
|
||||
|
||||
manifest 声明 `icon` / `order` 后,插件自动出现在侧栏(有 UI 能力时);任何槽位用 `openView` 深链:
|
||||
|
||||
```js
|
||||
api.ui.openView('my-plugin', { section: 'detail' }) // params 透传给插件 UI
|
||||
api.ui.openView('chat') // 回到聊天
|
||||
```
|
||||
|
||||
## 4. 面板 tab(右侧 SidePanel)
|
||||
|
||||
manifest 加 `"panel": true`,插件在右侧面板获得一个 tab。
|
||||
|
||||
## 5. iframe 插件页(entry.page)
|
||||
|
||||
需要**完全自定义的 Web UI** 且不想构建 React 时用。物理隔离(sandbox iframe),经桥通信:
|
||||
|
||||
```
|
||||
<插件目录>/index.html + 页面 JS
|
||||
manifest: "entry": { "page": "index.html" }
|
||||
```
|
||||
|
||||
页面侧用 SDK 桥(`@aso/plugin-sdk/client`):
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import { getHarnessClient, useHarnessTheme, useDeepLinkParams } from './sdk-client.mjs'
|
||||
const harness = getHarnessClient('my-plugin')
|
||||
harness.openView('chat') // 深链
|
||||
harness.setStatus('state', { text: '就绪' })
|
||||
harness.invoke('getState') // 白名单:getState / send / getSettings
|
||||
</script>
|
||||
```
|
||||
|
||||
**样式统一(强制)**:插件页 `<head>` 里必须引宿主 token 表,并用自己的 Tailwind 按同样 token 名编译——
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="peer://assets/tokens.css" />
|
||||
```
|
||||
|
||||
主题(深浅色/accent)由宿主推送(`useHarnessTheme`),禁止自行探测。详细规则见 [DESIGN.md](./DESIGN.md#iframe-插件页)。
|
||||
|
||||
## 6. 安全边界速记
|
||||
|
||||
| 形态 | 代码位置 | 能力 |
|
||||
|---|---|---|
|
||||
| main.mjs | 主进程 | 全权(与 pi 扩展同信任级) |
|
||||
| renderer.mjs(L4) | 渲染层 | 宿主 React + window.api 受控面 |
|
||||
| iframe 页 | 沙箱 iframe | 仅白名单桥(getState/send/getSettings/setStatus/openView) |
|
||||
|
||||
## 7. 验证
|
||||
|
||||
1. 状态胶囊:`setStatusItem` 后 TitleBar 立即出现,`removeStatusItem` 消失
|
||||
2. 面板 tab:`panel: true` + 任一 UI 形态 → 右侧面板出现 tab
|
||||
3. iframe 页:改页面文件 → 插件页开关触发重载 → iframe 换 src 整页刷新
|
||||
4. 全部 UI 状态在日志可查(`plugin activated` 之后你在 main.mjs 里的 `api.log` 输出)
|
||||
|
||||
---
|
||||
|
||||
**上一篇** [L2](./03-settings.md) · **下一篇**:[L4 React 组件](./05-renderer-components.md)——宿主内真 React 组件。
|
||||
@@ -0,0 +1,96 @@
|
||||
# L4 · 宿主内 React 组件(entry.renderer)
|
||||
|
||||
> 前置:[L3](./04-ui-slots.md)。iframe 页是隔离的「贴进去的网页」;`entry.renderer` 则是**真正的宿主内 React 组件**——与内置插件同槽位、同观感、同主题。这是外置插件的最高 UI 形态。
|
||||
|
||||
## 1. 声明
|
||||
|
||||
```json
|
||||
{ "id": "my-plugin", "panel": true, "entry": { "renderer": "renderer.mjs" } }
|
||||
```
|
||||
|
||||
> 文件名**不要叫 index.ts/index.js**——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载。
|
||||
|
||||
## 2. renderer.mjs 契约
|
||||
|
||||
宿主动态 import 后调用默认导出 `mount(ctx)`:
|
||||
|
||||
```js
|
||||
export default function mount(ctx) {
|
||||
// ctx.react 是宿主的 React 单实例——必须用它创建组件(自带 React 副本会让 hooks 全部失效)
|
||||
const R = ctx.react
|
||||
|
||||
function Panel() {
|
||||
const [n, setN] = R.useState(0)
|
||||
return R.createElement('div', { className: 'p-3 space-y-2' },
|
||||
R.createElement('div', { className: 'text-[12.5px] text-ink' }, `点击 ${n} 次`),
|
||||
R.createElement('button', {
|
||||
className: 'rounded-control border border-line-strong px-2.5 py-1 text-[11.5px] text-ink hover:bg-hover',
|
||||
onClick: () => setN(n + 1)
|
||||
}, '+1')
|
||||
)
|
||||
}
|
||||
|
||||
ctx.registerPanel(Panel) // 右侧面板 tab 内容(需 manifest.panel: true)
|
||||
ctx.registerView(Panel) // 整页视图(侧栏入口 → 主内容区);两个都注册也可以
|
||||
}
|
||||
```
|
||||
|
||||
## 3. JSX 工程构建配方(推荐)
|
||||
|
||||
手写 `createElement` 只适合玩具;真实插件用 JSX + esbuild,构建时把 `react` 别名到**宿主全局实例**:
|
||||
|
||||
```sh
|
||||
esbuild renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
||||
--jsx=automatic \
|
||||
--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js \
|
||||
--alias:@aso/plugin-sdk=<sdk源码路径>/index.ts --alias:@aso/plugin-sdk/ui=<sdk源码路径>/ui.tsx
|
||||
```
|
||||
|
||||
> 最后两条 alias 只在用到 `@aso/plugin-sdk`(withTimeout 等)或 `./ui` 组件库时需要;
|
||||
> sdk 以源码形态发布(exports 指向 .ts/.tsx),esbuild 无法按包名解析,指路径即可。
|
||||
|
||||
```js
|
||||
// shims/react.js —— 从宿主全局取单例
|
||||
const R = window.__ASO_PLUGIN_HOST__.react
|
||||
export default R
|
||||
export const { useState, useEffect, useRef, useMemo, useCallback, createContext,
|
||||
useContext, Fragment, memo, forwardRef, createElement } = R
|
||||
```
|
||||
|
||||
```js
|
||||
// shims/jsx-runtime.js
|
||||
export const { jsx, jsxs, Fragment } = window.__ASO_PLUGIN_HOST__.jsxRuntime
|
||||
```
|
||||
|
||||
`packages/plugin-template/` 已内置这套配置,复制即用。
|
||||
|
||||
## 4. 硬性约束
|
||||
|
||||
| 约束 | 原因 |
|
||||
|---|---|
|
||||
| React 必须来自 `ctx.react` / 宿主全局 | 双 React 实例 → hooks 全部报错 |
|
||||
| 模块**自包含**(单文件 bundle) | 宿主以 Blob URL 装载,相对导入没有基路径 |
|
||||
| 不 import 裸包名(react 等) | 同上;一切依赖打进 bundle(react 走别名除外) |
|
||||
| 单文件 ≤ 8MB | 超限拒绝装载 |
|
||||
| 仅开发版装载 | 用户版第三方装载关闭(`ASO_EXTERNAL_PLUGINS=1` 豁免) |
|
||||
|
||||
## 5. 样式
|
||||
|
||||
组件直接跑在宿主树里——**宿主的 Tailwind 类与 token 全部可用**。标准模式已代码化:优先 `import { Button, SettingRow, LoadingState, ErrorState, EmptyState, Switch } from '@aso/plugin-sdk/ui'`(组件目录与三态铁律见 [L6](./07-ui-components.md)),不要手写;遵守 [DESIGN.md](./DESIGN.md) 的类名词汇,禁止硬编码色值/阴影。
|
||||
|
||||
## 6. 生命周期
|
||||
|
||||
- 装载:插件列表变化(启停/重载/启动)即重装;`mount` 同步执行注册
|
||||
- 卸载:插件禁用/移除 → 注册的组件自动从宿主卸载
|
||||
- 性能护栏:读源与 import 各限时 8s——超时/失败时槽位呈现宿主统一「装载失败」ErrorState(带重试),见 [L7](./08-performance.md)
|
||||
- 崩溃隔离:渲染错误被 `PluginErrorBoundary` 捕获,不影响宿主
|
||||
|
||||
## 7. 验证
|
||||
|
||||
1. 重载插件 → 插件页该行出现 ⚙(若已声明设置)/侧栏入口/面板 tab
|
||||
2. 控制台无 `[plugins] … renderer 装载失败`
|
||||
3. 点击组件内按钮,state 正常更新(hooks 生效 = 单实例约束满足)
|
||||
|
||||
---
|
||||
|
||||
**上一篇** [L3](./04-ui-slots.md) · **下一篇**:[L6 公共组件库](./07-ui-components.md)——别手写样式,宿主把标准模式做成了组件。
|
||||
@@ -0,0 +1,84 @@
|
||||
# L5 · 自诊断:日志规范与 Agent 自迭代闭环
|
||||
|
||||
> 本项目的愿景是「Agent 写插件、Agent 排障、Agent 迭代」。达成的前提是**一切可观测**:宿主日志、插件日志、渲染层错误都落盘且结构化。这一篇讲日志从哪来、到哪去、Agent 怎么用它排障。
|
||||
|
||||
## 1. 日志在哪
|
||||
|
||||
```
|
||||
userData/logs/main-YYYY-MM-DD.log ← 唯一日志文件,按天滚动,保留 14 天
|
||||
```
|
||||
|
||||
Windows:`C:\Users\<你>\AppData\Roaming\@aso\desktop\logs\`
|
||||
(Agent 侧:pi 的 read/grep 工具可直接读这个路径——排障第一步永远是读它。)
|
||||
|
||||
级别:dev 默认 `debug`,打包版默认 `info`;环境变量 `ASO_LOG_LEVEL=debug|info|warn|error` 覆盖。
|
||||
|
||||
## 2. 行格式
|
||||
|
||||
```
|
||||
<ISO时间> <LEVEL> [<scope>] <message> k=v k=v
|
||||
2026-09-10T10:21:33.120Z INFO [plugin-host] plugin activated pluginId=demo-ui source=external
|
||||
2026-09-10T10:21:33.451Z INFO [plugin] settings changed pluginId=demo-ui endpoint=staging
|
||||
2026-09-10T10:21:40.002Z ERROR [ipc] ipc failed channel=plugins:setEnabled ms=1203 err=Error: runtime rebuild failed
|
||||
```
|
||||
|
||||
scope 一览:`main` / `plugin-host` / `plugin`(所有插件日志,带 `pluginId` 字段)/ `agent-service` / `browser` / `ipc` / `renderer` / `logger`。
|
||||
|
||||
## 3. 插件怎么写日志:`api.log.*`
|
||||
|
||||
```js
|
||||
export default function definePlugin(api) {
|
||||
const log = api.log // 自动带 pluginId 字段,进同一文件
|
||||
|
||||
api.log.info('activation payload', { tools: 3, mode: 'standard' })
|
||||
try {
|
||||
risky()
|
||||
} catch (err) {
|
||||
api.log.error('risky step failed', { step: 'connect', err }) // err 会被序列化成 stack
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**埋点规范**(Agent 排障依赖这些惯例):
|
||||
- 激活时:`info` 一条「我装了什么」(能力清单/配置摘要)
|
||||
- 关键决策与外部调用:`info` / `debug` + 结果字段
|
||||
- 一切 catch:`error` + `err` 字段——**禁止吞错不记**
|
||||
- message 用英文短句、kv 用结构化字段(便于 grep `pluginId=xxx`)
|
||||
|
||||
## 4. 宿主替你记录的(不用自己写)
|
||||
|
||||
| 事件 | scope | 级别 |
|
||||
|---|---|---|
|
||||
| 插件扫描/装载/激活/禁用/重载全链路 | plugin-host | info |
|
||||
| main.mjs 加载失败 / 激活失败 | plugin-host | error |
|
||||
| 设置值变更 / 一级入口切换 / 按钮动作 | plugin-host | info/debug |
|
||||
| 全部 73 个 IPC 通道调用(成功 debug 含耗时,失败 error) | ipc | debug/error |
|
||||
| Agent 回合开始/结束/结算、运行时重建 | agent-service | info/error |
|
||||
| 渲染层 console(含插件 renderer 模块与 iframe 页)、白屏、崩溃 | renderer | debug/error |
|
||||
| 进程级 uncaughtException / unhandledRejection | logger | error |
|
||||
|
||||
## 5. Agent 排障闭环(自迭代的核心流程)
|
||||
|
||||
```
|
||||
发现问题(用户反馈 / 日志 error)
|
||||
↓ 读 userData/logs/main-<今天>.log
|
||||
↓ grep pluginId=<自己的id> 与 ERROR
|
||||
↓ 对照本文第 4 节判断:装载失败?激活抛错?IPC 超时?渲染崩溃?
|
||||
↓ 改插件代码(L0-L4 的对应章节)
|
||||
↓ 触发重载(设置页开关 / 重启)→ 再读日志验证新一次 plugin activated
|
||||
↓ 循环直到日志干净
|
||||
```
|
||||
|
||||
写插件时请遵守:**每个可能失败的操作都有日志、每条 error 都能定位到步骤**。这是 Agent 能替你维护插件的最低要求。
|
||||
|
||||
## 6. 快速自查清单
|
||||
|
||||
- [ ] 装载了吗:`external main loaded pluginId=<id>`
|
||||
- [ ] 激活了吗:`plugin activated pluginId=<id>`
|
||||
- [ ] 有报错吗:`ERROR` + `pluginId=<id>`
|
||||
- [ ] IPC 通吗:`ipc failed channel=…` 是宿主问题还是插件问题
|
||||
- [ ] 渲染崩了吗:`renderer console` 中 `[window.onerror]` / `[unhandledrejection]`
|
||||
|
||||
---
|
||||
|
||||
**上一篇** [L4](./05-renderer-components.md) · 回到 [索引](./README.md) · 契约全集 [../plugins.md](../plugins.md) · 视觉规范 [DESIGN.md](./DESIGN.md)
|
||||
@@ -0,0 +1,83 @@
|
||||
# L6 · 公共 UI 组件库(@aso/plugin-sdk/ui)
|
||||
|
||||
> 前置:[L4 React 组件](./05-renderer-components.md)。约 15 分钟。
|
||||
> 目标:不写一行样式代码,做出与宿主完全一致的界面;等待/失败/空态不再自造。
|
||||
|
||||
## 为什么必须用它
|
||||
|
||||
[DESIGN.md](./DESIGN.md) 定了「长什么样」,组件库把它变成「直接拿来的代码」:
|
||||
|
||||
- **token 同源**:组件内部只用宿主已核验的类词汇(`text-ink-3`、`rounded-control`…)
|
||||
与 `var(--token)` 内联变量,深浅色自动跟随宿主主题
|
||||
- **三态统一**:等待 `LoadingState` / 失败 `ErrorState`(可重试)/ 空 `EmptyState`,
|
||||
任何插件的加载体验与宿主一致
|
||||
- **零依赖**:`./ui` 是 sdk 源码子路径,随 esbuild 一起打进 renderer.mjs,
|
||||
不引任何第三方 UI 库(shadcn/ui 的思路——无运行时、复制即用——由宿主承担)
|
||||
|
||||
## 导入与构建
|
||||
|
||||
```tsx
|
||||
import { Button, LoadingState, ErrorState, EmptyState, SettingRow, Switch } from '@aso/plugin-sdk/ui'
|
||||
```
|
||||
|
||||
构建命令与 L4 完全相同——`ui` 随 sdk 源码一起被打包,react 别名照旧:
|
||||
|
||||
```bash
|
||||
esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
||||
--jsx=automatic --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
|
||||
```
|
||||
|
||||
## 组件目录
|
||||
|
||||
| 组件 | 用途 | 要点 |
|
||||
|---|---|---|
|
||||
| `Spinner` | 行内旋转指示 | `size` 默认 14px |
|
||||
| `LoadingState` | 区块等待态 | `label` 可换文案,默认「加载中…」 |
|
||||
| `ErrorState` | 区块失败态 | `title`/`description`(错误消息)、`onRetry` 提供即显示重试按钮 |
|
||||
| `EmptyState` | 区块空态 | `title` + `description` + `action`(如「去设置」按钮) |
|
||||
| `Button` | 按钮 | `variant: 'primary' \| 'ghost' \| 'danger'`;`loading` 置灰并显示 Spinner,防重复提交 |
|
||||
| `Input` / `Textarea` | 输入 | 与宿主设置页同款外观 |
|
||||
| `Select` | 下拉 | `options: { value, label }[]`,原生 select |
|
||||
| `Switch` | 开关 | 受控 `checked`/`onCheckedChange`,或非受控 `defaultChecked` |
|
||||
| `Card` | 卡片容器 | `rounded-card` + line 边框 |
|
||||
| `SettingRow` | 设置行 | label 左 / 控件右(DESIGN §4 标准模式) |
|
||||
| `Badge` | 徽标 | `tone: 'neutral' \| 'accent' \| 'danger'`,版本/来源/状态元信息 |
|
||||
|
||||
全部组件接受 `className` 追加布局类(flex/间距/尺寸),**不接受**颜色/字号覆写——
|
||||
那两条路(hex、新字号)被 [L7 检验器](./08-performance.md)拦截。
|
||||
|
||||
## 三态铁律
|
||||
|
||||
任何异步区块的状态机:**loading → data | error | empty**,四选一,不允许第五种
|
||||
(白屏、裸文本「加载中」、无反馈的失败)。模板(plugin-template)是完整示范:
|
||||
|
||||
```tsx
|
||||
{error !== null ? (
|
||||
<ErrorState title="数据加载失败" description={error} onRetry={() => setTick((t) => t + 1)} />
|
||||
) : items === null ? (
|
||||
<LoadingState label="正在拉取数据…" />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState title="暂无数据" description="检查数据源后点击重新加载" />
|
||||
) : (
|
||||
items.map((it) => <SettingRow key={it} label={it} />)
|
||||
)}
|
||||
```
|
||||
|
||||
配套铁律:异步调用必须 `withTimeout`/`withRetry`(见 L7),否则 loading 态可能永远挂着。
|
||||
|
||||
## 宿主兜底
|
||||
|
||||
renderer 模块的读源与 import 宿主各限时 8 秒:超时或失败时,插件槽位自动呈现宿主侧
|
||||
「插件界面装载失败」ErrorState(带重试按钮);组件渲染期崩溃由 PluginErrorBoundary
|
||||
兜底。也就是说**装载失败不需要插件自己处理**,插件只需管好自己组件内部的异步。
|
||||
|
||||
## 边界
|
||||
|
||||
- 仅 renderer 组件槽位可用(渲染在宿主 DOM,复用宿主样式表);iframe 插件页是物理
|
||||
隔离文档,请按 [DESIGN.md](./DESIGN.md) §4 的 HTML 模式自行实现
|
||||
- 需要新形态时:先用现有组件拼;确实不够,在 DESIGN.md 约束内组合 token,
|
||||
并把新形态沉淀回团队公共代码,而不是留在单个插件里
|
||||
|
||||
---
|
||||
|
||||
**上一篇** [L5 自诊断](./06-self-diagnosis.md) · **下一篇**:[L7 性能与检验](./08-performance.md)——超时、重试与机器关卡。
|
||||
@@ -0,0 +1,100 @@
|
||||
# L7 · 性能与检验(plugin:check)
|
||||
|
||||
> 前置:L0–L6。约 20 分钟。
|
||||
> 目标:插件百花齐放,但**绝不卡宿主、绝不风格跑偏**;发布前有机器关卡把关。
|
||||
|
||||
## 性能铁律
|
||||
|
||||
### 1. 主进程不阻塞(检验器:error)
|
||||
|
||||
`main.mjs` 运行在宿主主进程。任何同步 IO——`fs` 的 `*Sync`、`execSync`、
|
||||
`spawnSync`——都会卡住**整个应用**的事件循环(聊天流、其他插件全部冻结)。
|
||||
一律用 `node:fs/promises` 与异步子进程:
|
||||
|
||||
```js
|
||||
// ❌ const text = fs.readFileSync(path, 'utf8')
|
||||
// ✅
|
||||
import { readFile } from 'node:fs/promises'
|
||||
const text = await readFile(path, 'utf8')
|
||||
```
|
||||
|
||||
### 2. 激活限时 10 秒(宿主强制)
|
||||
|
||||
插件工厂若返回 Promise,宿主限时 10s;超时或 reject 即把插件标记为失败
|
||||
(插件管理页可见红字),绝不无限等待拖垮 runtime 重建。因此:
|
||||
|
||||
- 激活路径只做「注册」级别的工作,重活(预取数据、预热连接)放到工具首次调用
|
||||
- 自己 `catch` 并 `api.log.error`——被宿主兜住意味着插件没跑起来
|
||||
|
||||
### 3. renderer 装载限时 8 秒(宿主强制)
|
||||
|
||||
读源与 import 各限时 8s,超时/失败由宿主呈现统一失败态(重试按钮);
|
||||
组件渲染期崩溃由 PluginErrorBoundary 兜底。插件只需管好组件内部的异步。
|
||||
|
||||
### 4. 自己的异步自己兜(sdk 提供 `withTimeout` / `withRetry`)
|
||||
|
||||
先于宿主超时并自行恢复,而不是等宿主判死:
|
||||
|
||||
```js
|
||||
import { withTimeout, withRetry, TimeoutError } from '@aso/plugin-sdk'
|
||||
|
||||
// 网络请求:3s 超时,失败退避重试 2 次(300ms → 600ms)
|
||||
const data = await withRetry(() => callApi(), { retries: 2, delayMs: 300, timeoutMs: 3000 })
|
||||
|
||||
// 单次限时
|
||||
const cfg = await withTimeout(loadConfig(), 2000, '加载配置')
|
||||
try {
|
||||
await risky()
|
||||
} catch (err) {
|
||||
if (err instanceof TimeoutError) { /* 降级路径 */ }
|
||||
throw err
|
||||
}
|
||||
```
|
||||
|
||||
注意:`withTimeout` 只是竞速,底层操作不会被取消——有 `AbortController` 的
|
||||
调用方请把 signal 接到底层,再用它兜底计时。
|
||||
|
||||
### 5. 渲染不卡顿
|
||||
|
||||
长列表分页/虚拟化,不要一次渲染数万节点;重计算放进工具(Agent 侧)或
|
||||
`setTimeout(fn, 0)` 分帧,不占渲染主线程。
|
||||
|
||||
## 三态呈现(检验器:warn)
|
||||
|
||||
任何异步 UI 必须是四态之一:`LoadingState` / `ErrorState` / `EmptyState` / 数据。
|
||||
`alert`/`confirm`/`prompt` 是阻塞式同步对话框,禁止(用 `Button` + 状态组件替代)。
|
||||
详见 [L6](./07-ui-components.md)。
|
||||
|
||||
## 样式一致性(检验器:error)
|
||||
|
||||
- 只用公共组件库 + [DESIGN.md](./DESIGN.md) token;硬编码 hex 色值是 error
|
||||
(`#fff`/`#ffffff` 白字除外)
|
||||
- renderer.mjs 必须经宿主 shim 使用单实例 React(`__ASO_PLUGIN_HOST__`)——
|
||||
自带 React 副本 = hooks 全部失效
|
||||
|
||||
## 日志(检验器:error)
|
||||
|
||||
`main.mjs` 禁 `console.*`,统一 `api.log.*`——Agent 自诊断闭环见 [L5](./06-self-diagnosis.md)。
|
||||
|
||||
## 检验器:`pnpm plugin:check <插件目录>`
|
||||
|
||||
发布前必跑;Agent 自迭代时,**通过检验器是完成定义(DoD)的一部分**。
|
||||
|
||||
| 级别 | 检查项 |
|
||||
|---|---|
|
||||
| error | manifest 缺失/不可解析;id/name/version(semver) 缺失;minContract 高于宿主契约;entry 指向的文件不存在;renderer.mjs > 8MB / main.mjs > 2MB;main.mjs 同步 IO;main.mjs `console.*`;renderer.mjs 未引用 `__ASO_PLUGIN_HOST__`;任何源码硬编码 hex 色值 |
|
||||
| warn | 缺 minContract;renderer.mjs > 300KB(疑似打包自带 React);注册 view/panel 但未用三态组件;`alert/confirm/prompt`;entry.renderer 产物缺失但有 src/renderer.tsx(未构建) |
|
||||
|
||||
宿主契约版本从 `plugin-host.ts` 源码提取(单一事实源),检验器与宿主永不会各执一词。
|
||||
|
||||
## 一张自查表
|
||||
|
||||
- [ ] `pnpm plugin:check <目录>` 0 error
|
||||
- [ ] main.mjs 无同步 IO,激活 10s 内完成
|
||||
- [ ] 一切异步有超时与重试,catch 后 `api.log` + `ErrorState`
|
||||
- [ ] 长列表不整批渲染
|
||||
- [ ] 深浅色各看一眼(token 正确时自动成立)
|
||||
|
||||
---
|
||||
|
||||
**上一篇** [L6 公共组件库](./07-ui-components.md) · 返回 [教程索引](./README.md)
|
||||
@@ -0,0 +1,136 @@
|
||||
# DESIGN.md · 插件视觉一致性规范
|
||||
|
||||
> 读者是**写 UI 的 Agent/开发者**。目标:任何来源的插件,界面观感与宿主完全一致。
|
||||
> 规则分两类:**强制**(可机器校验/一眼可辨)与**默认**(无充分理由不得偏离)。
|
||||
> 契约问题看 [../plugins.md](../plugins.md);本文件只管"长什么样"。
|
||||
|
||||
## 0. 两条铁律
|
||||
|
||||
1. **色值只允许来自 token**。任何 `#hex`/`rgb()` 硬编码(规范中列出的除外)都算违规;语义永远用 token 名表达(`text-ink-3` 而不是 `text-gray-400`)。
|
||||
2. **不发明新组件形态**。先查本文 §4 标准模式;确需新形态时,用 §1 的 token 组合拼装,不引入新色/新圆角/新阴影。
|
||||
|
||||
## 1. Token 表(单一事实源:`peer://assets/tokens.css` = 宿主 beautifului.css)
|
||||
|
||||
### 表面(背景)
|
||||
|
||||
| token | 意图 |
|
||||
|---|---|
|
||||
| `--page` | 页面最底层背景 |
|
||||
| `--surface` | 卡片/面板表面 |
|
||||
| `--inset` | 内嵌区域(输入框底、预览底、代码块底) |
|
||||
| `--hover` / `--hover-2` | 悬停态一级/二级 |
|
||||
|
||||
### 文字
|
||||
|
||||
| token | 意图 |
|
||||
|---|---|
|
||||
| `--ink` | 主文字(标题、关键值) |
|
||||
| `--ink-2` | 次级文字(正文说明) |
|
||||
| `--ink-3` | 弱化文字(占位、提示、元信息、路径) |
|
||||
|
||||
### 边线
|
||||
|
||||
| token | 意图 |
|
||||
|---|---|
|
||||
| `--line` | 常规边框(卡片、列表分隔) |
|
||||
| `--line-strong` | 强边框(输入框、可点击描边按钮) |
|
||||
| `--line-soft` | 极弱分隔 |
|
||||
|
||||
### 强调与语义
|
||||
|
||||
| token | 意图 |
|
||||
|---|---|
|
||||
| `--accent` / `--accent-ink` / `--accent-tint` | 品牌强调(主按钮、选中、链接) |
|
||||
| `--green` / `--green-tint` | 成功/正常 |
|
||||
| `--orange` / `--orange-tint` | 警示 |
|
||||
| `--red` / `--red-tint` | 错误/危险 |
|
||||
|
||||
### 阴影(全部是描边式,禁止投影类阴影)
|
||||
|
||||
`--shadow-hairline`(发丝线)· `--shadow-btn`(按钮)· `--shadow-card`(卡片)· `--shadow-raised`(浮起)· `--shadow-overlay`(弹层)· `--shadow-inset-field`(内嵌输入)
|
||||
|
||||
### 动效
|
||||
|
||||
`--duration-fast/base/slow`(统一 400ms)+ `--ease-standard`。悬停/切换用 `transition-colors` 即可。
|
||||
|
||||
## 2. 类名词汇(宿主 Tailwind 映射,renderer 组件直接用)
|
||||
|
||||
背景 `bg-page / bg-surface / bg-inset / bg-hover / bg-accent / bg-accent/10` ·
|
||||
文字 `text-ink / text-ink-2 / text-ink-3 / text-accent / text-red` ·
|
||||
边框 `border-line / border-line-strong / border-dashed border-line` ·
|
||||
圆角 `rounded-card`(卡片/输入块)`rounded-control`(按钮/输入框/徽标)。
|
||||
|
||||
## 3. 字号与间距(默认)
|
||||
|
||||
- 字号阶:`10px`(徽标)/ `11px`(辅助按钮)/ `11.5px`(次要按钮、说明)/ `12px`(正文说明) / `12.5px`(行标题/正文)/ `16px`(页面标题)。**不使用其他字号**;行高 `leading-4/5`。
|
||||
- 间距基数为 4px:卡片内边距 `px-3 py-2.5`,区块间 `space-y-1.5`,小节间 `pt-3`。
|
||||
- 图标一律 **lucide**(宿主白名单 `plugin-icons.tsx`,约 100 个常用名):`manifest.icon` / 状态胶囊 `icon` 填 lucide 图标名(如 `"Globe"`,kebab-case 亦可),12–16px、`currentColor` 由宿主保证;**禁止自绘 svg**(旧内联 svg 仅对存量插件兼容),未知名回落拼图图标
|
||||
|
||||
## 4. 标准模式(复制即用)
|
||||
|
||||
> **renderer 组件优先用组件库**:本节模式已在 `@aso/plugin-sdk/ui` 代码化
|
||||
> (`Button`/`SettingRow`/`EmptyState`/`LoadingState`/`ErrorState`/`Badge`…,
|
||||
> 见 [L6](./07-ui-components.md)),直接 import,不要手写。
|
||||
> 下面的 HTML 形态留给 iframe 插件页作者。
|
||||
|
||||
### 设置行(label 左 / 控件右)
|
||||
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[12.5px] text-ink">标题</div>
|
||||
<div class="mt-0.5 text-[11px] leading-4 text-ink-3">说明文字</div>
|
||||
</div>
|
||||
<!-- 控件 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### 按钮
|
||||
|
||||
```html
|
||||
<!-- primary -->
|
||||
<button class="rounded-control bg-accent px-3 py-1.5 text-[11.5px] font-medium text-white hover:opacity-90">主操作</button>
|
||||
<!-- ghost -->
|
||||
<button class="rounded-control border border-line-strong px-3 py-1.5 text-[11.5px] text-ink hover:bg-hover">次要操作</button>
|
||||
```
|
||||
|
||||
### 徽标
|
||||
|
||||
```html
|
||||
<span class="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">全局 v0.1.0</span>
|
||||
```
|
||||
|
||||
### 空态
|
||||
|
||||
```html
|
||||
<div class="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center">
|
||||
<div class="text-[13px] font-medium text-ink-2">暂无数据</div>
|
||||
<div class="text-[11.5px] text-ink-3">怎么产生的提示</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 开关(switch)
|
||||
|
||||
宿主规格:`h-5 w-9`(行内)/ `h-6 w-10`(表单级),滑块 `size-4/5 rounded-full bg-white shadow`,开 `bg-accent`、关 `bg-line-strong`。
|
||||
|
||||
## 5. iframe 插件页附加规则
|
||||
|
||||
- `<head>` 必须引入 `<link rel="stylesheet" href="peer://assets/tokens.css" />`,并用同名 token 编译自己的 Tailwind(token 名 → 变量:`page/ink/ink-2/ink-3/line/line-strong/accent…`)
|
||||
- 主题跟随宿主推送(`useHarnessTheme`),**禁止** `prefers-color-scheme` 自测
|
||||
- 与宿主的视觉差异应趋近于零:同 token + 同类名词汇 + 同字号阶
|
||||
|
||||
## 6. renderer 组件附加规则
|
||||
|
||||
- 类名直接用宿主词汇(§2),无需引样式;**优先 import 公共组件库**([L6](./07-ui-components.md)),不要手写标准模式
|
||||
- React 必须来自 `ctx.react`(见 [L4](./05-renderer-components.md)),否则 hooks 失效
|
||||
- 禁止操作宿主 DOM(document 全局查询/改样式),一切交互限制在自己的组件子树
|
||||
- 机器关卡:`pnpm plugin:check <插件目录>`(hex 色值 / React 副本 / 三态缺失等,见 [L7](./08-performance.md))
|
||||
|
||||
## 7. 自检清单(提发布前)
|
||||
|
||||
- [ ] `pnpm plugin:check <插件目录>` 0 error(机器关卡,见 [L7](./08-performance.md))
|
||||
- [ ] 无硬编码色值/阴影/圆角(铁律 1)
|
||||
- [ ] 字号都在 §3 的阶上
|
||||
- [ ] 深浅色都检查过(切主题看一眼;token 正确时自动成立)
|
||||
- [ ] 复用了 §4 模式(renderer 直接用组件库)而非新造形态
|
||||
- [ ] iframe 页引了 tokens.css 且无 `prefers-color-scheme`
|
||||
@@ -0,0 +1,36 @@
|
||||
# 插件开发教程(渐进式)
|
||||
|
||||
> 面向「写插件的人(含 Agent)」。从零到高级共 8 篇,每篇 10-30 分钟,逐篇递进、可独立验证。
|
||||
> 契约细节(类型/字段全集)见 [../plugins.md](../plugins.md);UI 规范见 [DESIGN.md](./DESIGN.md)。
|
||||
|
||||
## 学习路径
|
||||
|
||||
| 篇 | 内容 | 产出 |
|
||||
|---|---|---|
|
||||
| [L0 第一个插件](./01-hello-plugin.md) | manifest + main.mjs,装载/启停/日志 | 侧栏可见、日志可查的最小插件 |
|
||||
| [L1 工具与命令](./02-tools-and-commands.md) | 给 Agent 注册工具、斜杠命令、事件 | Agent 能力扩展 |
|
||||
| [L2 设置表单](./03-settings.md) | 声明式 schema、13 种标准控件、一级入口 | 用户可配置的插件 |
|
||||
| [L3 UI 槽位](./04-ui-slots.md) | 状态胶囊/工具栏按钮/视图/面板 tab/iframe 页 | 界面存在感 |
|
||||
| [L4 React 组件](./05-renderer-components.md) | entry.renderer:宿主内 React 组件 + JSX 构建配方 | 一等公民级 UI |
|
||||
| [L5 自诊断](./06-self-diagnosis.md) | 日志规范、排障闭环、Agent 自迭代约定 | 可被 Agent 维护的插件 |
|
||||
| [L6 公共组件库](./07-ui-components.md) | @aso/plugin-sdk/ui:统一三态(等待/失败/空)与基础控件 | 与宿主零差异的 UI |
|
||||
| [L7 性能与检验](./08-performance.md) | 主线程不阻塞、超时重试、plugin:check 机器关卡 | 可发布、不卡宿主的插件 |
|
||||
|
||||
## 30 秒总览
|
||||
|
||||
```
|
||||
<插件目录>/ 三个可选位置(按优先级):
|
||||
manifest.json 1. userData/plugins/<id>/ (应用托管)
|
||||
main.mjs (可选) 2. <工作区>/.pi/extensions/<id>/ (项目级)
|
||||
renderer.mjs (可选) 3. ~/.pi/agent/extensions/<id>/ (全局)
|
||||
index.html (可选)
|
||||
```
|
||||
|
||||
- **main.mjs**:Agent 能力(工具/命令/事件)+ 声明设置表单 + 内联 UI 数据
|
||||
- **renderer.mjs**:宿主内 React 组件(需构建,见 L4)
|
||||
- **index.html**:iframe 插件页(物理隔离,桥接见 L3)
|
||||
|
||||
## 环境要求
|
||||
|
||||
- 第三方插件装载默认**仅开发版开启**(打包版关闭,`ASO_EXTERNAL_PLUGINS=1` 可豁免)
|
||||
- 日志文件:`userData/logs/main-YYYY-MM-DD.log`(排查问题的第一入口,见 L5)
|
||||
+23
-1
@@ -11,7 +11,29 @@
|
||||
| v0.3.0 | 模型与设置 | 模型选择、Provider/API key 管理(写入 ~/.pi auth)、系统提示词自定义、连通性测试 |
|
||||
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
|
||||
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
|
||||
| 未来 | | electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、CDP 插件、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
|
||||
| v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 |
|
||||
| v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、状态胶囊详情面板(ZCode Git 工具式:头部+分组+行尾彩色数值,B3 浮动态并入顶栏胶囊)、图标统一 lucide 白名单、面板平铺标签条 |
|
||||
| 未来 | | 插件管理与 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` 修复;新克隆/重装后注意。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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",
|
||||
"plugin:check": "node scripts/check-plugin.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"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",
|
||||
"./renderer": "./src/renderer.ts",
|
||||
"./ui": "./src/ui.tsx"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 插件异步工具:超时与重试(零依赖,主进程 main.mjs 与 renderer 通用)。
|
||||
*
|
||||
* 性能铁律(docs/plugins/08-performance.md):插件内一切可能挂起/失败的异步操作
|
||||
* (网络请求、子进程、文件轮询…)都应经 withTimeout / withRetry 包裹。宿主对插件
|
||||
* 激活(10s)与 renderer 装载(8s)有兜底超时——超时即判失败,不会无限等;
|
||||
* 插件自己的异步调用应先于宿主超时并自行恢复,而不是被宿主判死。
|
||||
*/
|
||||
|
||||
/** 超时错误(err.name === 'TimeoutError',可据此与真实业务失败区分) */
|
||||
export class TimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'TimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 给 Promise 加超时;超时后以 TimeoutError 拒绝。
|
||||
* 注意:底层操作不会被取消(Promise 无 cancel 语义),有 AbortController 的调用方
|
||||
* 应自行把 signal 接到底层,再用本函数兜底计时。
|
||||
*/
|
||||
export function withTimeout<T>(promise: Promise<T>, ms: number, label?: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new TimeoutError(`${label ?? '操作'} 超时(${ms}ms)`))
|
||||
}, ms)
|
||||
})
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export interface RetryOptions {
|
||||
/** 失败后的额外重试次数(总尝试 = retries + 1),默认 2 */
|
||||
retries?: number
|
||||
/** 首次重试前的等待,默认 300ms;之后指数退避(×2) */
|
||||
delayMs?: number
|
||||
/** 单次尝试的超时上限(ms),经 withTimeout 计时 */
|
||||
timeoutMs?: number
|
||||
/** 每次重试前的回调(记日志/上报用) */
|
||||
onRetry?: (err: unknown, attempt: number, nextDelayMs: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 带超时与指数退避的重试:fn 返回 reject 或超时都算失败,重试耗尽后抛最后一次的错误。
|
||||
*/
|
||||
export async function withRetry<T>(fn: (attempt: number) => Promise<T>, options: RetryOptions = {}): Promise<T> {
|
||||
const retries = options.retries ?? 2
|
||||
const delayMs = options.delayMs ?? 300
|
||||
let lastErr: unknown
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const run = fn(attempt)
|
||||
return options.timeoutMs !== undefined ? await withTimeout(run, options.timeoutMs, `第 ${attempt + 1} 次尝试`) : await run
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
if (attempt === retries) break
|
||||
const nextDelay = delayMs * 2 ** attempt
|
||||
options.onRetry?.(err, attempt + 1, nextDelay)
|
||||
await sleep(nextDelay)
|
||||
}
|
||||
}
|
||||
throw lastErr
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 插件页(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 }
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 插件页 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])
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @aso/plugin-sdk 主入口(插件 main.mjs / 内置插件工厂使用)。
|
||||
* 插件页(iframe)请改用 '@aso/plugin-sdk/client' 子入口(不含 pi SDK 类型依赖路径)。
|
||||
*/
|
||||
export * from './manifest'
|
||||
export * from './settings'
|
||||
export * from './async'
|
||||
export * from './status'
|
||||
export * from './plugin'
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 插件清单类型(插件根目录 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
|
||||
/** 侧栏入口图标:lucide 图标名(如 "Globe",白名单见 docs/plugins/DESIGN.md);兼容旧内联 svg 字符串;缺省/未知名回落拼图图标 */
|
||||
icon?: string
|
||||
/** 侧栏入口排序(升序,只在插件之间排序;核心入口恒在前) */
|
||||
order?: number
|
||||
/** 在右侧 SidePanel 出现为一个 tab */
|
||||
panel?: boolean
|
||||
/** 外置插件入口(相对插件根的路径)。
|
||||
* 内置插件留空:UI 组件与 main 工厂编译进宿主 bundle。 */
|
||||
entry?: {
|
||||
/** 插件页入口(iframe 加载 `plugin://<id>/<page>`) */
|
||||
page?: string
|
||||
/** 主进程模块(动态 import,默认导出 definePlugin 工厂) */
|
||||
main?: string
|
||||
/** 渲染层模块(宿主动态 import `plugin://<id>/<renderer>`,
|
||||
* 默认导出 mount(ctx);React 组件用宿主实例(ctx.react),见 renderer 契约。
|
||||
* 命名勿用 index.ts/index.js——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载) */
|
||||
renderer?: string
|
||||
}
|
||||
/** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明,
|
||||
* 防止带原生视图能力的内置被页面级外置静默顶掉) */
|
||||
replacesNative?: boolean
|
||||
/** 更新索引地址(JSON;格式见 docs/plugins.md) */
|
||||
updateURL?: string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** '@aso/plugin-sdk/client' 子入口:插件页(iframe)桥接 client + hooks */
|
||||
export * from './client'
|
||||
export * from './hooks'
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 主进程侧插件契约(main.mjs / 内置插件工厂共用)。
|
||||
* 生命周期对齐 pi 原生扩展:工厂函数收到 api,返回 dispose(可选)。
|
||||
*/
|
||||
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
||||
import type { PluginManifest } from './manifest'
|
||||
import type { PluginSettingsSchema, PluginSettingValues } from './settings'
|
||||
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
|
||||
}
|
||||
|
||||
/** 插件日志通道:宿主注入,写入统一日志文件(userData/logs/,带 pluginId 字段)。
|
||||
* Agent 自诊断依赖该通道——插件应当把装载/激活/关键决策与失败都记到这里。 */
|
||||
export interface PluginLogger {
|
||||
debug(message: string, kv?: Record<string, unknown>): void
|
||||
info(message: string, kv?: Record<string, unknown>): void
|
||||
warn(message: string, kv?: Record<string, unknown>): void
|
||||
error(message: string, kv?: Record<string, unknown>): 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
|
||||
/** 声明插件设置表单(声明式 schema;items 为空 = 无配置页,一级入口开关随之不出现)。
|
||||
* 值由宿主持久化并广播;每次插件激活时重新声明(重载后生效)。 */
|
||||
registerSettings(schema: PluginSettingsSchema): void
|
||||
/** 读取当前设置值(schema 默认值已合并) */
|
||||
getSettingValues(): PluginSettingValues
|
||||
/** 设置值变化推送(用户在设置页修改后实时触发) */
|
||||
onSettingsChange(listener: (values: PluginSettingValues) => void): () => void
|
||||
/** 设置页按钮条目点击回调(key = 按钮项的 key) */
|
||||
onSettingsAction(listener: (key: string) => void): () => void
|
||||
}
|
||||
|
||||
/** 插件工厂收到的完整 API */
|
||||
export interface HarnessPluginApi {
|
||||
/** pi 原生扩展 API:工具/事件/命令/消息全走这里(pi.registerTool 等,语义与 pi CLI 扩展一致) */
|
||||
pi: ExtensionAPI
|
||||
ui: HarnessUiApi
|
||||
storage: PluginStorage
|
||||
/** 结构化日志(scope=plugin,自动带 pluginId;error 级别会进日志文件) */
|
||||
log: PluginLogger
|
||||
manifest: PluginManifest
|
||||
}
|
||||
|
||||
/** 插件工厂:同步或异步返回 dispose */
|
||||
export type HarnessPluginFactory = (api: HarnessPluginApi) => void | (() => void) | Promise<void | (() => void)>
|
||||
|
||||
/** 身份函数:为插件作者提供完整类型推断 */
|
||||
export function definePlugin(factory: HarnessPluginFactory): HarnessPluginFactory {
|
||||
return factory
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 渲染层模块契约(entry.renderer,宿主渲染层动态 import 后调用默认导出 mount)。
|
||||
* 与 main.mjs 契约并列:main 管 Agent 能力(pi),renderer 管宿主内 React UI。
|
||||
*
|
||||
* 单实例约束:组件必须使用 ctx.react(= 宿主 React)创建,插件自带 React 副本
|
||||
* 会导致 hooks 全部失效。JSX 工程的构建配方见 docs/plugins.md(esbuild react 别名 shim)。
|
||||
*/
|
||||
import type { ComponentType } from 'react'
|
||||
import type { PluginManifest } from './manifest'
|
||||
|
||||
/** 渲染层可用的注册槽位(每个插件各槽位一个组件,重复注册覆盖) */
|
||||
export interface PluginRendererSlots {
|
||||
/** 整页视图(侧栏入口点击后的主内容区,与内置组件同槽位) */
|
||||
registerView(component: ComponentType): void
|
||||
/** 右侧 SidePanel tab 内容(需 manifest.panel: true) */
|
||||
registerPanel(component: ComponentType): void
|
||||
}
|
||||
|
||||
export interface PluginRendererContext extends PluginRendererSlots {
|
||||
manifest: PluginManifest
|
||||
/** 宿主 React 命名空间(window.__ASO_PLUGIN_HOST__.react 同一份实例) */
|
||||
react: unknown
|
||||
}
|
||||
|
||||
/** renderer.mjs 默认导出:mount(ctx),同步执行注册后返回 */
|
||||
export type PluginRendererModule = (ctx: PluginRendererContext) => void
|
||||
|
||||
/** 身份函数:为插件作者提供完整类型推断 */
|
||||
export function defineRenderer(module: PluginRendererModule): PluginRendererModule {
|
||||
return module
|
||||
}
|
||||
|
||||
/** 宿主注入到 window.__ASO_PLUGIN_HOST__ 的全局(插件构建期 react 别名 shim 消费) */
|
||||
export interface PluginHostGlobal {
|
||||
react: unknown
|
||||
/** react/jsx-runtime(esbuild --jsx=automatic 时 shim 消费) */
|
||||
jsxRuntime: unknown
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 插件设置契约(声明式 schema):插件在 main.mjs 里经 api.ui.registerSettings(schema)
|
||||
* 声明配置项,宿主负责渲染表单、持久化值(userData/aso-plugin-values.json,按插件隔离),
|
||||
* 并把变更实时推回插件(ui.onSettingsChange)。按钮类条目的点击经 ui.onSettingsAction
|
||||
* 回到插件主进程侧执行。
|
||||
*
|
||||
* schema 是纯数据(不可含函数/React 元素),保证主进程可持久化快照、渲染层零信任渲染。
|
||||
*/
|
||||
import type { PluginManifest } from './manifest'
|
||||
|
||||
/** 文件/目录选择模式(path / image 共用) */
|
||||
export type PluginPathMode = 'file' | 'directory' | 'image'
|
||||
|
||||
/** 单个配置项(discriminated union on type) */
|
||||
export type PluginSettingItem =
|
||||
| {
|
||||
/** 单行输入框;password: true 时掩码显示 */
|
||||
type: 'string'
|
||||
key: string
|
||||
label: string
|
||||
placeholder?: string
|
||||
password?: boolean
|
||||
default?: string
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 多行文本 */
|
||||
type: 'text'
|
||||
key: string
|
||||
label: string
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
default?: string
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 数字输入 */
|
||||
type: 'number'
|
||||
key: string
|
||||
label: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
default?: number
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 开关 */
|
||||
type: 'boolean'
|
||||
key: string
|
||||
label: string
|
||||
default?: boolean
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 下拉单选 */
|
||||
type: 'select'
|
||||
key: string
|
||||
label: string
|
||||
options: Array<{ value: string; label: string }>
|
||||
default?: string
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 多选(checkbox 组) */
|
||||
type: 'multiSelect'
|
||||
key: string
|
||||
label: string
|
||||
options: Array<{ value: string; label: string }>
|
||||
default?: string[]
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 滑条(min/max 必填) */
|
||||
type: 'slider'
|
||||
key: string
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
default?: number
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 颜色选择(#rrggbb) */
|
||||
type: 'color'
|
||||
key: string
|
||||
label: string
|
||||
default?: string
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 路径选择(文件 / 目录) */
|
||||
type: 'path'
|
||||
key: string
|
||||
label: string
|
||||
mode: Extract<PluginPathMode, 'file' | 'directory'>
|
||||
placeholder?: string
|
||||
default?: string
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 图片选择(选择文件 + 缩略图预览) */
|
||||
type: 'image'
|
||||
key: string
|
||||
label: string
|
||||
default?: string
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 动作按钮:点击回调到插件(ui.onSettingsAction,key 为本项 key) */
|
||||
type: 'button'
|
||||
key: string
|
||||
label: string
|
||||
/** primary 实心 / ghost 描边(缺省 ghost) */
|
||||
variant?: 'primary' | 'ghost'
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 静态说明文字(无 key,不产生配置值) */
|
||||
type: 'info'
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
| {
|
||||
/** 分割线(label 可作为小节标题) */
|
||||
type: 'divider'
|
||||
label?: string
|
||||
}
|
||||
|
||||
/** 插件设置 schema:items 为空数组 = 无配置页(一级入口开关随之不出现) */
|
||||
export interface PluginSettingsSchema {
|
||||
/** 配置页标题(缺省用插件名) */
|
||||
title?: string
|
||||
items: PluginSettingItem[]
|
||||
}
|
||||
|
||||
/** 渲染层/插件共用的设置值快照(defaults 已合并) */
|
||||
export type PluginSettingValues = Record<string, unknown>
|
||||
|
||||
/** 插件设置清单快照(管理页/导航消费) */
|
||||
export type PluginSettingsSchemas = Record<string, PluginSettingsSchema>
|
||||
export type PluginSettingsValuesMap = Record<string, PluginSettingValues>
|
||||
|
||||
/** 从 schema 提取默认值(宿主合并进 getSettingValues 返回) */
|
||||
export function settingsDefaults(schema: PluginSettingsSchema): PluginSettingValues {
|
||||
const out: PluginSettingValues = {}
|
||||
for (const item of schema.items) {
|
||||
if ('default' in item && item.default !== undefined) out[item.key] = item.default
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 设置页身份信息(导航项渲染用) */
|
||||
export interface PluginSettingsMeta {
|
||||
manifest: Pick<PluginManifest, 'id' | 'name' | 'icon' | 'description'>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 内联槽位的数据类型(B2 状态胶囊 / B5 工具栏按钮)。
|
||||
* 契约原则:内联 UI 一律"数据 + 深链",不注册组件——外置插件只有
|
||||
* iframe 页有能力渲染自己的 React;宿主统一按数据渲染这些小 UI。
|
||||
*/
|
||||
|
||||
/** 深链:点击行为 = 打开某个视图('chat' 或插件 id)并携带参数 */
|
||||
export interface OpenViewAction {
|
||||
view: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
/** 徽标/胶囊共用色调语义 */
|
||||
export type StatusTone = 'default' | 'accent' | 'success' | 'warn' | 'danger'
|
||||
|
||||
/** 详情弹层里的一行(数据驱动;openView 缺省为纯展示行) */
|
||||
export interface StatusDetailRow {
|
||||
/** svg 字符串(14px 线条风格,currentColor) */
|
||||
icon?: string
|
||||
label: string
|
||||
/** 次级说明(label 下方小字) */
|
||||
detail?: string
|
||||
/** 右侧徽标文本(如 +76 -40、7/7、✓) */
|
||||
trailing?: string
|
||||
/** 徽标色调,缺省 default */
|
||||
tone?: StatusTone
|
||||
/** 整行点击的深链;缺省不可点击 */
|
||||
openView?: OpenViewAction
|
||||
}
|
||||
|
||||
/** 详情弹层分组 */
|
||||
export interface StatusDetailSection {
|
||||
/** 分组标题(如「Git 工具」「计划」);缺省无标题 */
|
||||
title?: string
|
||||
rows: StatusDetailRow[]
|
||||
}
|
||||
|
||||
/** 状态胶囊的展开详情:点击胶囊弹出宿主渲染的分组列表弹层。
|
||||
* 声明了 detail 的胶囊,点击 = 展开弹层(行内 openView 负责深链);
|
||||
* 未声明的胶囊保持原行为:点击 = item.openView。 */
|
||||
export interface StatusDetail {
|
||||
/** 弹层标题;缺省用胶囊文本 */
|
||||
title?: string
|
||||
sections: StatusDetailSection[]
|
||||
}
|
||||
|
||||
/** 状态胶囊条目(TitleBar 胶囊区 / 聊天区浮动胶囊共用一份数据) */
|
||||
export interface StatusItem {
|
||||
text: string
|
||||
/** svg 字符串(12-14px 线条风格,currentColor;缺省纯文字胶囊) */
|
||||
icon?: string
|
||||
tone?: StatusTone
|
||||
/** 点击行为:深链;有 detail 时被弹层取代;两者都缺省不可点击 */
|
||||
openView?: OpenViewAction
|
||||
/** 点击展开的详情弹层(数据驱动,宿主统一渲染) */
|
||||
detail?: StatusDetail
|
||||
}
|
||||
|
||||
/** 输入区工具行按钮 */
|
||||
export interface ToolbarButtonDef {
|
||||
id: string
|
||||
title: string
|
||||
/** svg 字符串(16-20px 线条风格,宿主按 currentColor 渲染) */
|
||||
icon?: string
|
||||
/** 点击行为:深链;缺省不可点击 */
|
||||
openView?: OpenViewAction
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 插件公共 UI 组件库(@aso/plugin-sdk/ui)——宿主视觉规范的代码化。
|
||||
*
|
||||
* 仅限 renderer 组件槽位使用(组件渲染在宿主 DOM 里,直接复用宿主样式表);
|
||||
* iframe 插件页是物理隔离文档,请参考 DESIGN.md §4 的 HTML 模式自行实现。
|
||||
*
|
||||
* 样式来源只有两类:
|
||||
* - 宿主已生成的 Tailwind 类(DESIGN.md §2 类名词汇,全部经过存在性核验)
|
||||
* - 语义 token 的内联变量(var(--accent) 等,用于宿主类词汇未覆盖的场合)
|
||||
* 因此深浅主题自动跟随宿主,禁止出现硬编码色值。
|
||||
*
|
||||
* 等待/失败/空态铁律(docs/plugins/08-performance.md §4):一切异步区块必须呈现
|
||||
* LoadingState / ErrorState / EmptyState 三态之一,禁止裸文本或无反馈。
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import type { ButtonHTMLAttributes, ComponentProps, InputHTMLAttributes, ReactElement, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'
|
||||
|
||||
/** 拼接类名(过滤 falsy;轻量版 cn,不引依赖) */
|
||||
export function cn(...classes: Array<string | false | null | undefined>): string {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
// ---- 等待态 ----
|
||||
|
||||
export interface SpinnerProps {
|
||||
/** 直径 px,默认 14 */
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 旋转指示器:强调色弧 + 弱色圆环 */
|
||||
export function Spinner({ size = 14, className }: SpinnerProps): ReactElement {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label="加载中"
|
||||
className={cn('inline-block shrink-0 animate-spin rounded-full border-line-strong', className)}
|
||||
style={{ width: size, height: size, borderWidth: 2, borderTopColor: 'var(--accent)' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface LoadingStateProps {
|
||||
/** 默认「加载中…」 */
|
||||
label?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 区块级等待态:任何异步内容就绪前的统一占位 */
|
||||
export function LoadingState({ label = '加载中…', className }: LoadingStateProps): ReactElement {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-2 py-10 text-center', className)}>
|
||||
<Spinner />
|
||||
<div className="text-[11.5px] text-ink-3">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 失败态 ----
|
||||
|
||||
export interface ErrorStateProps {
|
||||
/** 默认「出错了」 */
|
||||
title?: ReactNode
|
||||
/** 错误详情(错误消息、排查建议),保留换行 */
|
||||
description?: ReactNode
|
||||
/** 提供则显示重试按钮 */
|
||||
onRetry?: () => void
|
||||
retryLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 区块级失败态:错误必须可见、可重试,禁止静默吞掉 */
|
||||
export function ErrorState({ title = '出错了', description, onRetry, retryLabel = '重试', className }: ErrorStateProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col items-center justify-center gap-1.5 rounded-card border border-line bg-red/10 px-6 py-10 text-center', className)}
|
||||
role="alert"
|
||||
>
|
||||
<div className="text-[12.5px] font-medium text-ink">{title}</div>
|
||||
{description !== undefined && description !== null && description !== '' && (
|
||||
<div className="max-w-full whitespace-pre-wrap break-words text-[11.5px] leading-4 text-ink-3">{description}</div>
|
||||
)}
|
||||
{onRetry && (
|
||||
<Button variant="ghost" size="sm" className="mt-2" onClick={onRetry}>
|
||||
{retryLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 空态 ----
|
||||
|
||||
export interface EmptyStateProps {
|
||||
title: ReactNode
|
||||
/** 怎么产生的提示(如何让它不再为空) */
|
||||
description?: ReactNode
|
||||
/** 可选动作(如「新建」「去设置」) */
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 区块级空态(DESIGN.md §4 标准模式的组件化) */
|
||||
export function EmptyState({ title, description, action, className }: EmptyStateProps): ReactElement {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center', className)}>
|
||||
<div className="text-[13px] font-medium text-ink-2">{title}</div>
|
||||
{description !== undefined && description !== null && description !== '' && (
|
||||
<div className="max-w-full break-words text-[11.5px] text-ink-3">{description}</div>
|
||||
)}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 按钮 ----
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** primary=强调实心 / ghost=描边(默认)/ danger=危险描边 */
|
||||
variant?: 'primary' | 'ghost' | 'danger'
|
||||
/** sm=辅助按钮 11px / md=标准 11.5px(默认) */
|
||||
size?: 'sm' | 'md'
|
||||
/** true 时禁用并显示旋转指示器(异步提交防重复点击) */
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function Button({ variant = 'ghost', size = 'md', loading = false, disabled, className, children, ...rest }: ButtonProps): ReactElement {
|
||||
const variantCls =
|
||||
variant === 'primary'
|
||||
? 'bg-accent text-white hover:opacity-90'
|
||||
: variant === 'danger'
|
||||
? 'border border-line-strong text-red hover:bg-red/10'
|
||||
: 'border border-line-strong text-ink hover:bg-hover'
|
||||
const sizeCls = size === 'sm' ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-[11.5px]'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-1.5 rounded-control font-medium transition-colors disabled:pointer-events-none disabled:opacity-50',
|
||||
variantCls,
|
||||
sizeCls,
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{loading && <Spinner size={size === 'sm' ? 10 : 12} />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 输入控件 ----
|
||||
|
||||
/** 单行输入(设置行/表单通用;与宿主设置页同款) */
|
||||
export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>): ReactElement {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'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 disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>): ReactElement {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'w-full resize-y rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] leading-4 text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
options: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
/** 下拉选择(原生 select,宿主输入框同款外观) */
|
||||
export function Select({ options, className, ...rest }: SelectProps): ReactElement {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SwitchProps {
|
||||
/** 受控值;不传则内部自持(非受控) */
|
||||
checked?: boolean
|
||||
defaultChecked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
disabled?: boolean
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
/** 开关(DESIGN.md §4 规格:h-5 w-9,滑块 size-4,开 accent / 关 line-strong) */
|
||||
export function Switch({ checked, defaultChecked, onCheckedChange, disabled, 'aria-label': ariaLabel }: SwitchProps): ReactElement {
|
||||
const [inner, setInner] = useState(defaultChecked ?? false)
|
||||
const isControlled = checked !== undefined
|
||||
const value = isControlled ? checked : inner
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const next = !value
|
||||
if (!isControlled) setInner(next)
|
||||
onCheckedChange?.(next)
|
||||
}}
|
||||
className={cn(
|
||||
'relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:pointer-events-none disabled:opacity-50',
|
||||
value ? 'bg-accent' : 'bg-line-strong'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 size-4 rounded-full bg-white shadow transition-transform"
|
||||
style={{ transform: `translateX(${value ? 16 : 2}px)` }}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 布局与展示 ----
|
||||
|
||||
/** 卡片容器(rounded-card + line 边框;内边距由内容自理) */
|
||||
export function Card({ className, children, ...rest }: ComponentProps<'div'>): ReactElement {
|
||||
return (
|
||||
<div className={cn('rounded-card border border-line bg-surface', className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SettingRowProps {
|
||||
label: ReactNode
|
||||
/** 次级说明(控件左侧标题下方) */
|
||||
description?: ReactNode
|
||||
/** 右侧控件(Switch / Input / Button / Select…) */
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 设置行(DESIGN.md §4 标准模式:label 左 / 控件右) */
|
||||
export function SettingRow({ label, description, children, className }: SettingRowProps): ReactElement {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5', className)}>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12.5px] text-ink">{label}</div>
|
||||
{description !== undefined && description !== null && description !== '' && (
|
||||
<div className="mt-0.5 break-words text-[11px] leading-4 text-ink-3">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface BadgeProps {
|
||||
/** neutral=灰 / accent=强调 / danger=危险 */
|
||||
tone?: 'neutral' | 'accent' | 'danger'
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** 徽标(版本、来源、状态等元信息) */
|
||||
export function Badge({ tone = 'neutral', className, children }: BadgeProps): ReactElement {
|
||||
const toneCls = tone === 'accent' ? 'bg-accent/10 text-accent' : tone === 'danger' ? 'bg-red/10 text-red' : 'bg-hover text-ink-3'
|
||||
return <span className={cn('rounded-control px-1.5 py-0.5 text-[10px]', toneCls, className)}>{children}</span>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# plugin-template · 插件脚手架
|
||||
|
||||
复制本目录到插件位置(改目录名 = 改前先改 manifest.id),改掉 `id`/`name`/`description` 即完成最小定制。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
manifest.json 清单(id/name/version/minContract 必改)
|
||||
main.mjs 主进程侧:Agent 能力 + 设置声明 + 日志埋点(范例齐备)
|
||||
src/renderer.tsx 宿主内 React 组件源码(JSX;公共组件库 + 三态示范)
|
||||
renderer.mjs 预构建产物(开箱即用;改了源码需重新构建)
|
||||
shims/ 构建期把 react 别名到宿主单例(勿改)
|
||||
```
|
||||
|
||||
## 构建渲染层组件
|
||||
|
||||
```sh
|
||||
esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
||||
--jsx=automatic \
|
||||
--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js \
|
||||
--alias:@aso/plugin-sdk=../../packages/plugin-sdk/src/index.ts \
|
||||
--alias:@aso/plugin-sdk/ui=../../packages/plugin-sdk/src/ui.tsx
|
||||
```
|
||||
|
||||
(esbuild 可用 `pnpm dlx esbuild@<版本>` 或任意 ≥0.17 版本;`--alias` 需 ≥0.17。
|
||||
两条 sdk alias 指向仓库内源码——本目录复制到别处后,改成 sdk 源码的实际路径即可。)
|
||||
不需要 UI 时:删除 `entry.renderer` 与 `src/`、`shims/`、`renderer.mjs`,只保留 main.mjs。
|
||||
|
||||
## 放置位置(三选一,按优先级)
|
||||
|
||||
1. `userData/plugins/<id>/`(应用托管目录)
|
||||
2. `<工作区>/.pi/extensions/<id>/`(项目级)
|
||||
3. `~/.pi/agent/extensions/<id>/`(全局)
|
||||
|
||||
第三方装载仅开发版开启(`ASO_EXTERNAL_PLUGINS=1` 豁免)。
|
||||
|
||||
## 验证
|
||||
|
||||
1. `pnpm plugin:check <本目录>` → 0 error(性能与样式机器关卡,见 L7)
|
||||
2. 重启或开关重载 → 设置 → 插件 → 对应作用域出现插件
|
||||
3. 日志 `userData/logs/main-*.log` 有 `plugin activated pluginId=my-plugin`
|
||||
4. 对 Agent 说"ping 一下我的插件" → 触发 `my_plugin_ping`
|
||||
5. 面板/侧栏出现 React 组件:等待/数据/开关/重载齐全(三态示范,hooks 生效)
|
||||
|
||||
## 下一步
|
||||
|
||||
- 教程索引:`docs/plugins/README.md`(L0→L7)
|
||||
- 公共组件库与三态铁律:`docs/plugins/07-ui-components.md`(renderer.tsx 即示范)
|
||||
- 性能与检验器:`docs/plugins/08-performance.md`
|
||||
- 视觉规范:`docs/plugins/DESIGN.md`(提发布前过一遍自检清单)
|
||||
- 契约全集:`docs/plugins.md`
|
||||
@@ -0,0 +1,59 @@
|
||||
// 主进程侧:Agent 能力(pi 工具/命令/事件)+ 设置声明 + 内联 UI。
|
||||
// 日志规范见 docs/plugins/06-self-diagnosis.md:激活时 info 一条能力清单,一切 catch 记 error。
|
||||
import { definePlugin } from '@aso/plugin-sdk'
|
||||
|
||||
export default definePlugin((api) => {
|
||||
const log = api.log
|
||||
log.info('activated', { manifest: api.manifest.version })
|
||||
|
||||
// ---- 设置(用户可配置;值为宿主持久化) ----
|
||||
api.ui.registerSettings({
|
||||
items: [
|
||||
{ type: 'string', key: 'greeting', label: '问候语', default: 'Hello', placeholder: 'Hello' },
|
||||
{ type: 'boolean', key: 'verbose', label: '详细日志', default: false }
|
||||
]
|
||||
})
|
||||
|
||||
let values = api.ui.getSettingValues()
|
||||
api.ui.onSettingsChange((next) => {
|
||||
values = next
|
||||
log.info('settings changed', { greeting: next.greeting })
|
||||
})
|
||||
api.ui.onSettingsAction((key) => {
|
||||
log.info('settings action', { key })
|
||||
})
|
||||
|
||||
// ---- Agent 工具示例 ----
|
||||
api.pi.registerTool({
|
||||
name: 'my_plugin_ping',
|
||||
description: '返回问候语(示例工具:把这段换成真正的能力)',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
log.debug('ping called', { verbose: values.verbose })
|
||||
return { output: `${values.greeting ?? 'Hello'} from my-plugin` }
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 状态胶囊示范:顶栏紧凑状态条,点击展开详情弹层(数据驱动,宿主统一渲染) ----
|
||||
api.ui.setStatusItem('summary', {
|
||||
text: '示例 1/2',
|
||||
tone: 'accent',
|
||||
detail: {
|
||||
sections: [
|
||||
{
|
||||
rows: [
|
||||
{ label: '示例任务', detail: '把这段换成真正的状态数据', trailing: '进行中', tone: 'accent' },
|
||||
{ label: '打开面板', openView: { view: api.manifest.id } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 面板/视图组件在 renderer.mjs(JSX 构建见 README);无 UI 需求可整段去掉 ----
|
||||
log.info('ready', { hasRenderer: true })
|
||||
|
||||
return () => {
|
||||
log.info('disposed')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "my-plugin",
|
||||
"name": "我的插件",
|
||||
"version": "0.1.0",
|
||||
"minContract": "0.2.0",
|
||||
"description": "TODO:一句话描述插件做什么",
|
||||
"order": 50,
|
||||
"panel": true,
|
||||
"icon": "Puzzle",
|
||||
"entry": {
|
||||
"main": "main.mjs",
|
||||
"renderer": "renderer.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// shims/react.js
|
||||
var R = window.__ASO_PLUGIN_HOST__.react;
|
||||
var {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
createContext,
|
||||
useContext,
|
||||
Fragment,
|
||||
memo,
|
||||
forwardRef,
|
||||
createElement
|
||||
} = R;
|
||||
|
||||
// ../plugin-sdk/src/async.ts
|
||||
var TimeoutError = class extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
};
|
||||
function withTimeout(promise, ms, label) {
|
||||
let timer;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new TimeoutError(`${label ?? "\u64CD\u4F5C"} \u8D85\u65F6\uFF08${ms}ms\uFF09`));
|
||||
}, ms);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
// shims/jsx-runtime.js
|
||||
var { jsx, jsxs, Fragment: Fragment2 } = window.__ASO_PLUGIN_HOST__.jsxRuntime;
|
||||
|
||||
// ../plugin-sdk/src/ui.tsx
|
||||
function cn(...classes) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
function Spinner({ size = 14, className }) {
|
||||
return /* @__PURE__ */ jsx(
|
||||
"span",
|
||||
{
|
||||
role: "status",
|
||||
"aria-label": "\u52A0\u8F7D\u4E2D",
|
||||
className: cn("inline-block shrink-0 animate-spin rounded-full border-line-strong", className),
|
||||
style: { width: size, height: size, borderWidth: 2, borderTopColor: "var(--accent)" }
|
||||
}
|
||||
);
|
||||
}
|
||||
function LoadingState({ label = "\u52A0\u8F7D\u4E2D\u2026", className }) {
|
||||
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center justify-center gap-2 py-10 text-center", className), children: [
|
||||
/* @__PURE__ */ jsx(Spinner, {}),
|
||||
/* @__PURE__ */ jsx("div", { className: "text-[11.5px] text-ink-3", children: label })
|
||||
] });
|
||||
}
|
||||
function ErrorState({ title = "\u51FA\u9519\u4E86", description, onRetry, retryLabel = "\u91CD\u8BD5", className }) {
|
||||
return /* @__PURE__ */ jsxs(
|
||||
"div",
|
||||
{
|
||||
className: cn("flex flex-col items-center justify-center gap-1.5 rounded-card border border-line bg-red/10 px-6 py-10 text-center", className),
|
||||
role: "alert",
|
||||
children: [
|
||||
/* @__PURE__ */ jsx("div", { className: "text-[12.5px] font-medium text-ink", children: title }),
|
||||
description !== void 0 && description !== null && description !== "" && /* @__PURE__ */ jsx("div", { className: "max-w-full whitespace-pre-wrap break-words text-[11.5px] leading-4 text-ink-3", children: description }),
|
||||
onRetry && /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", className: "mt-2", onClick: onRetry, children: retryLabel })
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
function EmptyState({ title, description, action, className }) {
|
||||
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center", className), children: [
|
||||
/* @__PURE__ */ jsx("div", { className: "text-[13px] font-medium text-ink-2", children: title }),
|
||||
description !== void 0 && description !== null && description !== "" && /* @__PURE__ */ jsx("div", { className: "max-w-full break-words text-[11.5px] text-ink-3", children: description }),
|
||||
action && /* @__PURE__ */ jsx("div", { className: "mt-2", children: action })
|
||||
] });
|
||||
}
|
||||
function Button({ variant = "ghost", size = "md", loading = false, disabled, className, children, ...rest }) {
|
||||
const variantCls = variant === "primary" ? "bg-accent text-white hover:opacity-90" : variant === "danger" ? "border border-line-strong text-red hover:bg-red/10" : "border border-line-strong text-ink hover:bg-hover";
|
||||
const sizeCls = size === "sm" ? "px-2 py-1 text-[11px]" : "px-3 py-1.5 text-[11.5px]";
|
||||
return /* @__PURE__ */ jsxs(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
disabled: disabled || loading,
|
||||
className: cn(
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-control font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
|
||||
variantCls,
|
||||
sizeCls,
|
||||
className
|
||||
),
|
||||
...rest,
|
||||
children: [
|
||||
loading && /* @__PURE__ */ jsx(Spinner, { size: size === "sm" ? 10 : 12 }),
|
||||
children
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
function Switch({ checked, defaultChecked, onCheckedChange, disabled, "aria-label": ariaLabel }) {
|
||||
const [inner, setInner] = useState(defaultChecked ?? false);
|
||||
const isControlled = checked !== void 0;
|
||||
const value = isControlled ? checked : inner;
|
||||
return /* @__PURE__ */ jsx(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
role: "switch",
|
||||
"aria-checked": value,
|
||||
"aria-label": ariaLabel,
|
||||
disabled,
|
||||
onClick: () => {
|
||||
const next = !value;
|
||||
if (!isControlled) setInner(next);
|
||||
onCheckedChange?.(next);
|
||||
},
|
||||
className: cn(
|
||||
"relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:pointer-events-none disabled:opacity-50",
|
||||
value ? "bg-accent" : "bg-line-strong"
|
||||
),
|
||||
children: /* @__PURE__ */ jsx(
|
||||
"span",
|
||||
{
|
||||
className: "absolute top-0.5 size-4 rounded-full bg-white shadow transition-transform",
|
||||
style: { transform: `translateX(${value ? 16 : 2}px)` }
|
||||
}
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
function SettingRow({ label, description, children, className }) {
|
||||
return /* @__PURE__ */ jsxs("div", { className: cn("flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5", className), children: [
|
||||
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
||||
/* @__PURE__ */ jsx("div", { className: "text-[12.5px] text-ink", children: label }),
|
||||
description !== void 0 && description !== null && description !== "" && /* @__PURE__ */ jsx("div", { className: "mt-0.5 break-words text-[11px] leading-4 text-ink-3", children: description })
|
||||
] }),
|
||||
children
|
||||
] });
|
||||
}
|
||||
|
||||
// src/renderer.tsx
|
||||
function fetchItems() {
|
||||
return withTimeout(Promise.resolve(["\u6761\u76EE\u4E00", "\u6761\u76EE\u4E8C", "\u6761\u76EE\u4E09"]), 3e3, "\u62C9\u53D6\u6570\u636E");
|
||||
}
|
||||
function mount(ctx) {
|
||||
function Panel() {
|
||||
const [items, setItems] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [verbose, setVerbose] = useState(false);
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setItems(null);
|
||||
setError(null);
|
||||
fetchItems().then((data) => {
|
||||
if (alive) setItems(data);
|
||||
}).catch((err) => {
|
||||
if (alive) setError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [tick]);
|
||||
return /* @__PURE__ */ jsxs("div", { className: "flex h-full flex-col gap-2 overflow-y-auto p-3", children: [
|
||||
/* @__PURE__ */ jsx("div", { className: "text-[12.5px] text-ink", children: "\u6211\u7684\u63D2\u4EF6\u9762\u677F" }),
|
||||
error !== null ? /* @__PURE__ */ jsx(ErrorState, { title: "\u6570\u636E\u52A0\u8F7D\u5931\u8D25", description: error, onRetry: () => setTick((t) => t + 1) }) : items === null ? /* @__PURE__ */ jsx(LoadingState, { label: "\u6B63\u5728\u62C9\u53D6\u6570\u636E\u2026" }) : items.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { title: "\u6682\u65E0\u6570\u636E", description: "\u68C0\u67E5\u6570\u636E\u6E90\u540E\u70B9\u51FB\u91CD\u65B0\u52A0\u8F7D" }) : items.map((it) => /* @__PURE__ */ jsx(SettingRow, { label: it, description: "\u793A\u4F8B\u6761\u76EE" }, it)),
|
||||
/* @__PURE__ */ jsx(SettingRow, { label: "\u8BE6\u7EC6\u6A21\u5F0F", description: "\u5F00\u542F\u540E main \u4FA7\u8F93\u51FA\u66F4\u8BE6\u7EC6\u7684\u65E5\u5FD7", children: /* @__PURE__ */ jsx(Switch, { checked: verbose, onCheckedChange: setVerbose, "aria-label": "\u8BE6\u7EC6\u6A21\u5F0F" }) }),
|
||||
/* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", className: "self-start", onClick: () => setTick((t) => t + 1), children: "\u91CD\u65B0\u52A0\u8F7D" })
|
||||
] });
|
||||
}
|
||||
ctx.registerPanel(Panel);
|
||||
ctx.registerView(Panel);
|
||||
}
|
||||
export {
|
||||
mount as default
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// 构建期别名目标:react/jsx-runtime → 宿主全局单例(esbuild --jsx=automatic)
|
||||
export const { jsx, jsxs, Fragment } = window.__ASO_PLUGIN_HOST__.jsxRuntime
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// 构建期别名目标:react → 宿主全局单例(双 React 实例会导致 hooks 失效)
|
||||
const R = window.__ASO_PLUGIN_HOST__.react
|
||||
export default R
|
||||
export const {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
createContext,
|
||||
useContext,
|
||||
Fragment,
|
||||
memo,
|
||||
forwardRef,
|
||||
createElement
|
||||
} = R
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 渲染层组件源码(JSX)。构建:
|
||||
* esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
|
||||
* --jsx=automatic --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
|
||||
* 产物 renderer.mjs 是自包含单文件,放回插件目录即被宿主装载。
|
||||
*
|
||||
* UI 一律用 @aso/plugin-sdk/ui 公共组件:token 与宿主同源、深浅色自动跟随,
|
||||
* 等待/失败/空态统一呈现(规范见 docs/plugins/07-ui-components.md 与 DESIGN.md)。
|
||||
* 一切异步操作都要有超时(withTimeout/withRetry 见 '@aso/plugin-sdk'),
|
||||
* 区块三态:LoadingState → ErrorState(可重试) / EmptyState / 数据。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { withTimeout } from '@aso/plugin-sdk'
|
||||
import { Button, EmptyState, ErrorState, LoadingState, SettingRow, Switch } from '@aso/plugin-sdk/ui'
|
||||
|
||||
/** 模拟异步数据源:换成真实 IPC / 请求时保持「必超时、必 catch」的形状 */
|
||||
function fetchItems(): Promise<string[]> {
|
||||
return withTimeout(Promise.resolve(['条目一', '条目二', '条目三']), 3000, '拉取数据')
|
||||
}
|
||||
|
||||
export default function mount(ctx) {
|
||||
function Panel() {
|
||||
const [items, setItems] = useState(null) // null=加载中
|
||||
const [error, setError] = useState(null)
|
||||
const [verbose, setVerbose] = useState(false)
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setItems(null)
|
||||
setError(null)
|
||||
fetchItems()
|
||||
.then((data) => {
|
||||
if (alive) setItems(data)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (alive) setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [tick])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 overflow-y-auto p-3">
|
||||
<div className="text-[12.5px] text-ink">我的插件面板</div>
|
||||
|
||||
{error !== null ? (
|
||||
<ErrorState title="数据加载失败" description={error} onRetry={() => setTick((t) => t + 1)} />
|
||||
) : items === null ? (
|
||||
<LoadingState label="正在拉取数据…" />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState title="暂无数据" description="检查数据源后点击重新加载" />
|
||||
) : (
|
||||
items.map((it) => <SettingRow key={it} label={it} description="示例条目" />)
|
||||
)}
|
||||
|
||||
<SettingRow label="详细模式" description="开启后 main 侧输出更详细的日志">
|
||||
<Switch checked={verbose} onCheckedChange={setVerbose} aria-label="详细模式" />
|
||||
</SettingRow>
|
||||
|
||||
<Button variant="ghost" size="sm" className="self-start" onClick={() => setTick((t) => t + 1)}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ctx.registerPanel(Panel)
|
||||
ctx.registerView(Panel)
|
||||
}
|
||||
Generated
+6499
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@google/genai'
|
||||
- electron
|
||||
- electron-winstaller
|
||||
- esbuild
|
||||
- protobufjs
|
||||
Reference in New Issue
Block a user