Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6507948f01 | ||
|
|
d8c1097b9d | ||
|
|
038339f2fe | ||
|
|
403b3b3ca1 | ||
|
|
c117385d01 | ||
|
|
a052b6a2c4 | ||
|
|
781a6c8beb | ||
|
|
ad16ed7239 | ||
|
|
1326f9b672 | ||
|
|
335a08454a | ||
|
|
9b451c8c25 | ||
|
|
1cb2b554c0 | ||
|
|
e958784d10 | ||
|
|
5ee00bd2c4 | ||
|
|
1a7d79739d | ||
|
|
b0a59cdadf | ||
|
|
a95b775120 | ||
|
|
60fc9b6f4f | ||
|
|
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
-1
@@ -10,5 +10,5 @@ release/
|
|||||||
preview-shots/
|
preview-shots/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
capture-log.txt
|
capture-log.txt
|
||||||
scripts/
|
|
||||||
preview/
|
preview/
|
||||||
|
"tmp-foundation.css"
|
||||||
|
|||||||
@@ -1,12 +1,50 @@
|
|||||||
|
import { copyFileSync, mkdirSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
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)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建期把插件存储 worker(免打包纯 ESM,只依赖 node 内置模块)拷到 out/main/ 旁:
|
||||||
|
* 主进程按 import.meta.url 定位它——必须与主进程产物同目录 */
|
||||||
|
function copyStorageWorker(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'aso-copy-storage-worker',
|
||||||
|
closeBundle() {
|
||||||
|
const src = resolve(__dirname, 'src/main/plugins/storage-worker.mjs')
|
||||||
|
const outDir = resolve(__dirname, 'out/main')
|
||||||
|
try {
|
||||||
|
mkdirSync(outDir, { recursive: true })
|
||||||
|
copyFileSync(src, resolve(outDir, 'storage-worker.mjs'))
|
||||||
|
} catch (err) {
|
||||||
|
this.warn(`storage-worker.mjs 拷贝失败: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
// pi SDK 为纯 ESM 包,主进程输出 ESM(Electron ≥28 支持)
|
// pi SDK 为纯 ESM 包,主进程输出 ESM(Electron ≥28 支持)
|
||||||
plugins: [externalizeDepsPlugin()],
|
plugins: [externalizeDepsPlugin(), copyStorageWorker()],
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
// electron 保持内置模块导入;pi 全家桶运行时从 node_modules 以 ESM 加载,
|
// electron 保持内置模块导入;pi 全家桶运行时从 node_modules 以 ESM 加载,
|
||||||
@@ -45,7 +83,20 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer: {
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src/renderer/src'),
|
'@': 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-dom": "19.2.8",
|
||||||
"react-icons": "5.7.0",
|
"react-icons": "5.7.0",
|
||||||
"react-markdown": "^10.1.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",
|
"remark-gfm": "^4.0.1",
|
||||||
"shadow-plugin": "2.1.0",
|
"shadow-plugin": "2.1.0",
|
||||||
"shiki": "^1.29.2",
|
"shiki": "^1.29.2",
|
||||||
"tailwind-merge": "3.6.0"
|
"tailwind-merge": "3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@aso/plugin-sdk": "workspace:*",
|
||||||
"@tailwindcss/vite": "4.3.3",
|
"@tailwindcss/vite": "4.3.3",
|
||||||
"@types/node": "26.4.1",
|
"@types/node": "26.4.1",
|
||||||
"@types/react": "19.2.18",
|
"@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 { existsSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { registerIpc } from './ipc'
|
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, '..')
|
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 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')
|
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 APP_ICON = join(app.getAppPath(), 'build', 'icon.ico')
|
||||||
|
|
||||||
const agentService = registerIpc()
|
const agentService = registerIpc()
|
||||||
|
log.info('main init', { version: app.getVersion(), packaged: app.isPackaged, devServer: VITE_DEV_SERVER_URL ?? '-' })
|
||||||
|
|
||||||
/** 窗口控制(自绘标题栏按钮走这三个 IPC;不使用系统 WCO,
|
/** 窗口控制(自绘标题栏按钮走这三个 IPC;不使用系统 WCO,
|
||||||
* 否则 overlay 的原生白色背景会盖住标题栏分隔线) */
|
* 否则 overlay 的原生白色背景会盖住标题栏分隔线) */
|
||||||
@@ -31,13 +46,16 @@ function registerWindowControls(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createWindow(): Promise<void> {
|
async function createWindow(): Promise<void> {
|
||||||
|
// 窗口底色跟随深浅色(加载/resize 时不闪白);构造函数里已按持久化偏好设置过
|
||||||
|
// nativeTheme.themeSource,这里用实际生效值
|
||||||
|
const dark = nativeTheme.shouldUseDarkColors
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 840,
|
height: 840,
|
||||||
minWidth: 960,
|
minWidth: 960,
|
||||||
minHeight: 640,
|
minHeight: 640,
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: dark ? '#212121' : '#ffffff',
|
||||||
title: 'Inkreach Agent',
|
title: 'Inkreach Agent',
|
||||||
// 窗口/任务栏图标(Windows/Linux;macOS 打包时由 build 配置提供 icns)
|
// 窗口/任务栏图标(Windows/Linux;macOS 打包时由 build 配置提供 icns)
|
||||||
...(existsSync(APP_ICON) ? { icon: APP_ICON } : {}),
|
...(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())
|
const sendWinState = (): void => win.webContents.send('win:state', win.isMaximized())
|
||||||
win.on('maximize', sendWinState)
|
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.on('ready-to-show', () => {
|
||||||
win.show()
|
win.show()
|
||||||
|
if (sessionInitialized) return
|
||||||
|
sessionInitialized = true
|
||||||
// pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现
|
// pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现
|
||||||
agentService.ensureReady().catch((err) => {
|
agentService.ensureReady().catch((err: unknown) => {
|
||||||
console.error('[main] pi session init failed:', err)
|
sessionInitialized = false
|
||||||
|
log.error('pi session init failed', { err })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 窗口关闭时未决审批一律拒绝,避免 agent 永久挂起
|
// 受管浏览器的原生 WebContentsView 挂在主窗口 contentView 上
|
||||||
|
getBrowserController().setOwnerWindow(win)
|
||||||
|
|
||||||
|
// 窗口关闭时未决审批一律拒绝,避免 agent 永久挂起;受管浏览器同步清理
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
agentService.approvals.settleAll(false)
|
agentService.approvals.settleAll(false)
|
||||||
|
getBrowserController().handleWindowClosed()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 外部链接交给系统默认浏览器,不在应用内开新窗口
|
// 外部链接交给系统默认浏览器,不在应用内开新窗口
|
||||||
@@ -82,15 +116,17 @@ async function createWindow(): Promise<void> {
|
|||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 渲染层日志/加载失败转发到主进程 stdout,便于排查白屏/黑屏问题
|
// 渲染层日志(含插件 renderer 模块 / iframe 页的 console)统一归档到日志文件
|
||||||
win.webContents.on('console-message', (details) => {
|
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) => {
|
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) => {
|
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') {
|
if (process.env.ASO_DEVTOOLS === '1') {
|
||||||
win.webContents.openDevTools({ mode: 'detach' })
|
win.webContents.openDevTools({ mode: 'detach' })
|
||||||
@@ -121,6 +157,14 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
app.setAppUserModelId('com.inkreach.agent')
|
app.setAppUserModelId('com.inkreach.agent')
|
||||||
}
|
}
|
||||||
Menu.setApplicationMenu(null)
|
Menu.setApplicationMenu(null)
|
||||||
|
log.info('app ready')
|
||||||
|
|
||||||
|
// 插件宿主:扫描/装载 + 协议映射 + 插件启停后重建 agent 运行时
|
||||||
|
const pluginHost = getPluginHost()
|
||||||
|
pluginHost.init()
|
||||||
|
pluginHost.registerProtocols()
|
||||||
|
pluginHost.setRuntimeRebuilder(() => agentService.rebuildRuntime())
|
||||||
|
|
||||||
void createWindow()
|
void createWindow()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
|||||||
+275
-40
@@ -1,7 +1,30 @@
|
|||||||
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
||||||
import { mkdirSync } from 'node:fs'
|
import { mkdirSync, readFileSync } from 'node:fs'
|
||||||
import { userInfo } from 'node:os'
|
import { userInfo } from 'node:os'
|
||||||
import { AgentService } from './pi/agent-service'
|
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 事件转发到所有渲染窗口。
|
* 注册 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()) {
|
if (typeof text !== 'string' || !text.trim()) {
|
||||||
throw new Error('empty prompt')
|
throw new Error('empty prompt')
|
||||||
}
|
}
|
||||||
@@ -34,49 +57,49 @@ export function registerIpc(): AgentService {
|
|||||||
return service.send(text, sendImages)
|
return service.send(text, sendImages)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('agent:steer', (_event, text: unknown) => {
|
handle('agent:steer', (_event, text: unknown) => {
|
||||||
if (typeof text !== 'string' || !text.trim()) {
|
if (typeof text !== 'string' || !text.trim()) {
|
||||||
throw new Error('empty steer text')
|
throw new Error('empty steer text')
|
||||||
}
|
}
|
||||||
return service.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) => {
|
service.getState().catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
throw new Error(err instanceof Error ? err.message : String(err))
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- 会话管理 ----
|
// ---- 会话管理 ----
|
||||||
ipcMain.handle('sessions:list', () =>
|
handle('sessions:list', () =>
|
||||||
service.listSessions().catch((err: unknown) => {
|
service.listSessions().catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
throw new Error(err instanceof Error ? err.message : String(err))
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle('sessions:new', () =>
|
handle('sessions:new', () =>
|
||||||
service.newSession().catch((err: unknown) => {
|
service.newSession().catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
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')
|
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path')
|
||||||
return service.openSession(path).catch((err: unknown) => {
|
return service.openSession(path).catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
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')
|
if (typeof name !== 'string' || !name.trim()) throw new Error('invalid session name')
|
||||||
return service.renameSession(name.trim()).catch((err: unknown) => {
|
return service.renameSession(name.trim()).catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
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')
|
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path')
|
||||||
return service.deleteSession(path).catch((err: unknown) => {
|
return service.deleteSession(path).catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
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))
|
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)----
|
// ---- 自定义 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')
|
if (typeof config !== 'object' || config === null) throw new Error('invalid custom provider')
|
||||||
const c = config as Record<string, unknown>
|
const c = config as Record<string, unknown>
|
||||||
if (
|
if (
|
||||||
@@ -117,56 +140,114 @@ export function registerIpc(): AgentService {
|
|||||||
return pass(() => service.saveCustomProvider(normalized))
|
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')
|
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid provider id')
|
||||||
return pass(() => service.deleteCustomProvider(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')
|
if (typeof providerId !== 'string' || typeof modelId !== 'string') throw new Error('invalid model')
|
||||||
return pass(() => service.setModel(providerId, modelId))
|
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()) {
|
if (typeof providerId !== 'string' || typeof apiKey !== 'string' || !apiKey.trim()) {
|
||||||
throw new Error('invalid api key')
|
throw new Error('invalid api key')
|
||||||
}
|
}
|
||||||
return pass(() => service.setApiKey(providerId, apiKey.trim()))
|
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')
|
if (typeof providerId !== 'string') throw new Error('invalid provider')
|
||||||
return pass(() => service.logoutProvider(providerId))
|
return pass(() => service.logoutProvider(providerId))
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- 应用设置 ----
|
// ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ----
|
||||||
ipcMain.handle('settings:get', () => service.getSettings())
|
handle('thinking:set', (_event, level: unknown) => {
|
||||||
|
if (typeof level !== 'string') throw new Error('invalid thinking level')
|
||||||
ipcMain.handle('settings:setPrompt', (_event, text: unknown) => {
|
return pass(() => service.setThinkingLevel(level as never))
|
||||||
if (typeof text !== 'string') throw new Error('invalid prompt')
|
|
||||||
return pass(() => service.setSystemPrompt(text))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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')
|
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid theme id')
|
||||||
service.setThemeColor(id.trim())
|
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) => {
|
service.listWorkspaces().catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
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')
|
if (typeof name !== 'string' || !name.trim()) throw new Error('invalid workspace name')
|
||||||
return service.createWorkspace(name).catch((err: unknown) => {
|
return service.createWorkspace(name).catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
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')
|
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid workspace path')
|
||||||
return service.openWorkspace(path).catch((err: unknown) => {
|
return service.openWorkspace(path).catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
throw new Error(err instanceof Error ? err.message : String(err))
|
||||||
@@ -174,7 +255,7 @@ export function registerIpc(): AgentService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 调起系统目录选择器;取消返回 null
|
// 调起系统目录选择器;取消返回 null
|
||||||
ipcMain.handle('workspaces:browse', async () => {
|
handle('workspaces:browse', async () => {
|
||||||
const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
|
const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
|
||||||
const result = await dialog.showOpenDialog(win, {
|
const result = await dialog.showOpenDialog(win, {
|
||||||
title: '选择工作区目录',
|
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')
|
if (typeof id !== 'string' || typeof approved !== 'boolean') throw new Error('invalid approval')
|
||||||
|
// settle 内部经 onSettle 订阅发出 approval_settled,此处无需重复广播
|
||||||
service.approvals.settle(id, approved)
|
service.approvals.settle(id, approved)
|
||||||
service.broadcastEvent({ type: 'approval_settled', id, approved, timedOut: false })
|
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('app:versions', () => ({
|
handle('app:versions', () => ({
|
||||||
app: process.env.npm_package_version ?? '0.0.0',
|
app: process.env.npm_package_version ?? '0.0.0',
|
||||||
electron: process.versions.electron,
|
electron: process.versions.electron,
|
||||||
node: process.versions.node,
|
node: process.versions.node,
|
||||||
@@ -202,38 +283,38 @@ export function registerIpc(): AgentService {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// ---- Chatbox 资源(/ 命令 + 技能、@ 工作区文件、+ 附件)----
|
// ---- Chatbox 资源(/ 命令 + 技能、@ 工作区文件、+ 附件)----
|
||||||
ipcMain.handle('chat:resources', () =>
|
handle('chat:resources', () =>
|
||||||
service.listChatResources().catch((err: unknown) => {
|
service.listChatResources().catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
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[] =>
|
const norm = (value: unknown): string[] =>
|
||||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||||
return service.setQueue(norm(steering), norm(followUp))
|
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')
|
if (mode !== 'confirm' && mode !== 'full') throw new Error('invalid permission mode')
|
||||||
service.setPermissionMode(mode)
|
service.setPermissionMode(mode)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- 设置页:技能 / 命令详情 ----
|
// ---- 设置页:技能 / 命令详情 ----
|
||||||
ipcMain.handle('resources:details', () =>
|
handle('resources:details', () =>
|
||||||
service.listResourceDetails().catch((err: unknown) => {
|
service.listResourceDetails().catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
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')
|
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
|
||||||
return shell.openPath(target)
|
return shell.openPath(target)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 打开资源目录:不存在时先创建(skills/prompts 目录通常尚未建立)
|
// 打开资源目录:不存在时先创建(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')
|
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
|
||||||
try {
|
try {
|
||||||
mkdirSync(target, { recursive: true })
|
mkdirSync(target, { recursive: true })
|
||||||
@@ -246,16 +327,170 @@ export function registerIpc(): AgentService {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('files:list', () =>
|
handle('files:list', () =>
|
||||||
service.listWorkspaceFiles().catch((err: unknown) => {
|
service.listWorkspaceFiles().catch((err: unknown) => {
|
||||||
throw new Error(err instanceof Error ? err.message : String(err))
|
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]
|
const win = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getAllWindows()[0]
|
||||||
return service.pickAttachments(win ?? undefined)
|
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:openDataDir', (_e, id: unknown) => {
|
||||||
|
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id')
|
||||||
|
return getPluginHost().openPluginDataDir(id)
|
||||||
|
})
|
||||||
|
// 清除插件全部存储数据(设置页二次确认后调用;连接关闭 + 删数据目录)
|
||||||
|
handle('plugins:clearData', (_e, id: unknown) => {
|
||||||
|
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id')
|
||||||
|
return getPluginHost().clearPluginData(id)
|
||||||
|
})
|
||||||
|
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
|
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 { app, dialog, shell } from 'electron'
|
||||||
|
import { nativeTheme } from 'electron'
|
||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
import {
|
import {
|
||||||
createAgentSessionRuntime,
|
createAgentSessionRuntime,
|
||||||
createAgentSessionServices,
|
createAgentSessionServices,
|
||||||
createAgentSessionFromServices,
|
createAgentSessionFromServices,
|
||||||
|
estimateTokens,
|
||||||
getAgentDir,
|
getAgentDir,
|
||||||
loadSkills,
|
getPowerShellConfig,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type AgentSession,
|
type AgentSession,
|
||||||
type AgentSessionEvent,
|
type AgentSessionEvent,
|
||||||
type AgentSessionRuntime,
|
type AgentSessionRuntime,
|
||||||
type CreateAgentSessionRuntimeFactory,
|
type CreateAgentSessionRuntimeFactory,
|
||||||
type SessionInfo
|
type PromptTemplate,
|
||||||
|
type SessionInfo,
|
||||||
|
type Skill
|
||||||
} from '@earendil-works/pi-coding-agent'
|
} from '@earendil-works/pi-coding-agent'
|
||||||
import { ApprovalService, createApprovalExtension } from './approval'
|
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 {
|
import type {
|
||||||
AgentUiEvent,
|
AgentUiEvent,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AttachmentInfo,
|
AttachmentInfo,
|
||||||
ChatResources,
|
ChatResources,
|
||||||
CommandDetail,
|
CommandDetail,
|
||||||
|
ContextBreakdownEntry,
|
||||||
|
ContextUsageInfo,
|
||||||
CustomProviderConfig,
|
CustomProviderConfig,
|
||||||
|
ForkPoint,
|
||||||
ModelCatalog,
|
ModelCatalog,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
|
NavigateTreeOptions,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
ResourceDetails,
|
ResourceDetails,
|
||||||
SendImage,
|
SendImage,
|
||||||
@@ -32,6 +45,8 @@ import type {
|
|||||||
SessionState,
|
SessionState,
|
||||||
SessionStatsInfo,
|
SessionStatsInfo,
|
||||||
SkillDetail,
|
SkillDetail,
|
||||||
|
ThinkingLevel,
|
||||||
|
ThemeMode,
|
||||||
WorkspaceFile,
|
WorkspaceFile,
|
||||||
WorkspaceMeta
|
WorkspaceMeta
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
@@ -39,6 +54,44 @@ import type {
|
|||||||
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
|
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
|
||||||
const DELTA_FLUSH_MS = 40
|
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 会话运行时的主进程封装:
|
* pi 会话运行时的主进程封装:
|
||||||
* - 基于 AgentSessionRuntime 支持会话新建/切换(自动重挂事件订阅)
|
* - 基于 AgentSessionRuntime 支持会话新建/切换(自动重挂事件订阅)
|
||||||
@@ -53,14 +106,27 @@ export class AgentService {
|
|||||||
private flushTimer: NodeJS.Timeout | null = null
|
private flushTimer: NodeJS.Timeout | null = null
|
||||||
private attached: AgentSession | null = null
|
private attached: AgentSession | null = null
|
||||||
private unsubscribeAttached: (() => void) | null = null
|
private unsubscribeAttached: (() => void) | null = null
|
||||||
private settings: AppSettings = { systemPrompt: '' }
|
private settings: AppSettings = {}
|
||||||
/** 当前工作区目录(agent cwd),会话按目录隔离 */
|
/** 当前工作区目录(agent cwd),会话按目录隔离 */
|
||||||
private workspaceDir = ''
|
private workspaceDir = ''
|
||||||
private approvalsRegistered = false
|
private approvalsRegistered = false
|
||||||
/** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */
|
/** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */
|
||||||
private permissionMode: PermissionMode = 'confirm'
|
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()
|
readonly approvals = new ApprovalService()
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// 设置同步读入 + 应用持久化的深浅色偏好(nativeTheme 会影响渲染层
|
||||||
|
// prefers-color-scheme 与原生控件配色;窗口创建前生效可避免首帧闪错色)
|
||||||
|
this.loadSettings()
|
||||||
|
nativeTheme.themeSource = this.settings.themeMode ?? 'system'
|
||||||
|
}
|
||||||
|
|
||||||
/** 工作区根目录:文档/Inkreach,每个子目录 = 一个工作区 */
|
/** 工作区根目录:文档/Inkreach,每个子目录 = 一个工作区 */
|
||||||
get workspacesRoot(): string {
|
get workspacesRoot(): string {
|
||||||
return join(app.getPath('documents'), 'Inkreach')
|
return join(app.getPath('documents'), 'Inkreach')
|
||||||
@@ -82,7 +148,6 @@ export class AgentService {
|
|||||||
try {
|
try {
|
||||||
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<AppSettings>
|
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<AppSettings>
|
||||||
this.settings = {
|
this.settings = {
|
||||||
systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : '',
|
|
||||||
workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined,
|
workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined,
|
||||||
customWorkspaces: Array.isArray(raw.customWorkspaces)
|
customWorkspaces: Array.isArray(raw.customWorkspaces)
|
||||||
? raw.customWorkspaces.filter((p): p is string => typeof p === 'string')
|
? raw.customWorkspaces.filter((p): p is string => typeof p === 'string')
|
||||||
@@ -90,10 +155,13 @@ export class AgentService {
|
|||||||
// 历史版本存过 autoEdit/plan,统一收敛回 confirm
|
// 历史版本存过 autoEdit/plan,统一收敛回 confirm
|
||||||
permissionMode:
|
permissionMode:
|
||||||
raw.permissionMode === 'full' ? 'full' : 'confirm',
|
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 {
|
} catch {
|
||||||
this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
|
this.settings = { permissionMode: this.permissionMode, themeMode: 'system' }
|
||||||
}
|
}
|
||||||
this.permissionMode = this.settings.permissionMode ?? 'confirm'
|
this.permissionMode = this.settings.permissionMode ?? 'confirm'
|
||||||
this.initWorkspaces()
|
this.initWorkspaces()
|
||||||
@@ -105,7 +173,7 @@ export class AgentService {
|
|||||||
try {
|
try {
|
||||||
mkdirSync(this.defaultWorkspaceDir, { recursive: true })
|
mkdirSync(this.defaultWorkspaceDir, { recursive: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[agent-service] init workspaces failed:', err)
|
log.error('init workspaces failed:', { err: err })
|
||||||
}
|
}
|
||||||
const saved = this.settings.workspace
|
const saved = this.settings.workspace
|
||||||
this.workspaceDir = saved && existsSync(saved) ? saved : this.defaultWorkspaceDir
|
this.workspaceDir = saved && existsSync(saved) ? saved : this.defaultWorkspaceDir
|
||||||
@@ -129,7 +197,7 @@ export class AgentService {
|
|||||||
seen.add(path.toLowerCase())
|
seen.add(path.toLowerCase())
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[agent-service] list workspaces failed:', err)
|
log.error('list workspaces failed:', { err: err })
|
||||||
}
|
}
|
||||||
// 目录选择器添加的根目录之外的工作区
|
// 目录选择器添加的根目录之外的工作区
|
||||||
for (const p of this.settings.customWorkspaces ?? []) {
|
for (const p of this.settings.customWorkspaces ?? []) {
|
||||||
@@ -174,14 +242,15 @@ export class AgentService {
|
|||||||
this.settings.workspace = resolved
|
this.settings.workspace = resolved
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
this.workspaceDir = resolved
|
this.workspaceDir = resolved
|
||||||
await this.rebuildRuntime()
|
// 工作区切换:新 cwd 的最近会话才是目标(不做原会话恢复)
|
||||||
|
await this.rebuildRuntime({ restoreSession: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveSettings(): void {
|
private saveSettings(): void {
|
||||||
try {
|
try {
|
||||||
writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2), 'utf8')
|
writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2), 'utf8')
|
||||||
} catch (err) {
|
} 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)
|
return () => this.listeners.delete(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 供 IPC 层主动广播事件(如审批结果同步到所有窗口) */
|
|
||||||
broadcastEvent(event: AgentUiEvent): void {
|
|
||||||
this.emit(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
private emit(event: AgentUiEvent): void {
|
private emit(event: AgentUiEvent): void {
|
||||||
for (const fn of this.listeners) {
|
for (const fn of this.listeners) {
|
||||||
try {
|
try {
|
||||||
fn(event)
|
fn(event)
|
||||||
} catch (err) {
|
} 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 cwd = this.cwd
|
||||||
const agentDir = getAgentDir()
|
const agentDir = getAgentDir()
|
||||||
this.settings = this.loadSettings()
|
this.settings = this.loadSettings()
|
||||||
|
// 项目级第三方插件目录随工作区同步(.pi/extensions;目录变更即重扫描插件列表)
|
||||||
|
getPluginHost().setProjectExtensionsDir(EXTERNAL_PLUGINS_ENABLED ? cwd : null)
|
||||||
// 审批拦截只需注册一次(create 会随工作区切换多次执行)
|
// 审批拦截只需注册一次(create 会随工作区切换多次执行)
|
||||||
if (!this.approvalsRegistered) {
|
if (!this.approvalsRegistered) {
|
||||||
this.approvalsRegistered = true
|
this.approvalsRegistered = true
|
||||||
@@ -246,10 +312,24 @@ export class AgentService {
|
|||||||
cwd: targetCwd,
|
cwd: targetCwd,
|
||||||
agentDir: targetAgentDir,
|
agentDir: targetAgentDir,
|
||||||
resourceLoaderOptions: {
|
resourceLoaderOptions: {
|
||||||
...(this.settings.systemPrompt.trim() ? { systemPrompt: this.settings.systemPrompt } : {}),
|
|
||||||
extensionFactories: [
|
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)
|
const initialServices = await buildServices(cwd, agentDir)
|
||||||
@@ -268,9 +348,12 @@ export class AgentService {
|
|||||||
const result = await createAgentSessionFromServices({
|
const result = await createAgentSessionFromServices({
|
||||||
services,
|
services,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
sessionStartEvent,
|
sessionStartEvent
|
||||||
customTools: demoTools
|
// 业务工具(browserTools 等)由插件经 pi.registerTool 注册(聚合 extension),
|
||||||
|
// 此处不再传 customTools
|
||||||
})
|
})
|
||||||
|
// 会话创建是唯一收口点:新建/切换/分叉/重载都会经过这里,统一应用 Windows PowerShell 偏好
|
||||||
|
preferPowerShell(result.session)
|
||||||
return { ...result, services, diagnostics: services.diagnostics }
|
return { ...result, services, diagnostics: services.diagnostics }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,36 +379,15 @@ export class AgentService {
|
|||||||
|
|
||||||
private emitSwitched(session: AgentSession): void {
|
private emitSwitched(session: AgentSession): void {
|
||||||
const state = this.buildState(session)
|
const state = this.buildState(session)
|
||||||
|
// 工具状态不再随事件重发:渲染层用 historyToTools 从 messages 自行重建,
|
||||||
|
// 重复克隆一遍全量工具参数/结果是长会话切换卡顿的大头(历史被序列化两次)
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'session_switched',
|
type: 'session_switched',
|
||||||
state,
|
state,
|
||||||
messages: session.messages.map((m) => toCloneable(m)),
|
messages: session.messages.map((m) => toCloneable(m))
|
||||||
activeTools: this.collectToolStates(session)
|
|
||||||
})
|
})
|
||||||
}
|
// 切换/回溯后立刻带上一次统计(上下文容量、缓存命中率),Usage Card 无需等第一回合
|
||||||
|
void this.emitStats()
|
||||||
/** 从历史消息中恢复工具状态(用于切换会话后 ToolCard 的参数/结果展示) */
|
|
||||||
private collectToolStates(session: AgentSession): Record<string, unknown> {
|
|
||||||
const tools: Record<string, unknown> = {}
|
|
||||||
for (const message of session.messages) {
|
|
||||||
const m = message as { role?: string; content?: unknown; toolCallId?: unknown; toolName?: unknown; isError?: unknown }
|
|
||||||
if (m.role !== 'assistant' || !Array.isArray(m.content)) continue
|
|
||||||
for (const block of m.content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>) {
|
|
||||||
if (block.type === 'toolCall' && block.id) {
|
|
||||||
tools[block.id] = { toolName: block.name ?? 'tool', args: toCloneable(block.arguments), status: 'done' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const message of session.messages) {
|
|
||||||
const m = message as { role?: string; toolCallId?: unknown; toolName?: unknown; content?: unknown; isError?: unknown }
|
|
||||||
if (m.role !== 'toolResult' || typeof m.toolCallId !== 'string') continue
|
|
||||||
tools[m.toolCallId] = {
|
|
||||||
...((tools[m.toolCallId] as Record<string, unknown>) ?? { toolName: m.toolName ?? 'tool' }),
|
|
||||||
result: toCloneable(m),
|
|
||||||
status: m.isError ? 'error' : 'done'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tools
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureReady(): Promise<SessionState> {
|
async ensureReady(): Promise<SessionState> {
|
||||||
@@ -366,6 +428,8 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
case 'message_end':
|
case 'message_end':
|
||||||
this.emit({ type: 'message_end', message: toCloneable(event.message) })
|
this.emit({ type: 'message_end', message: toCloneable(event.message) })
|
||||||
|
// 每条 assistant 消息落地都会刷新 usage → 上下文容量/缓存命中率即时更新
|
||||||
|
if (readRole(event.message) === 'assistant') void this.emitStats()
|
||||||
break
|
break
|
||||||
case 'tool_execution_start':
|
case 'tool_execution_start':
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -406,6 +470,27 @@ export class AgentService {
|
|||||||
case 'session_info_changed':
|
case 'session_info_changed':
|
||||||
this.emit({ type: 'session_info', name: event.name })
|
this.emit({ type: 'session_info', name: event.name })
|
||||||
break
|
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:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -420,6 +505,7 @@ export class AgentService {
|
|||||||
name: String(model.name ?? model.id ?? '')
|
name: String(model.name ?? model.id ?? '')
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
const thinkingLevels = session.supportsThinking() ? session.getAvailableThinkingLevels() : []
|
||||||
return {
|
return {
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
sessionName: session.sessionName,
|
sessionName: session.sessionName,
|
||||||
@@ -428,19 +514,186 @@ export class AgentService {
|
|||||||
activeTools: session.getActiveToolNames(),
|
activeTools: session.getActiveToolNames(),
|
||||||
messageCount: session.messages.length,
|
messageCount: session.messages.length,
|
||||||
workspace: this.cwd,
|
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> {
|
private async emitStats(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const runtime = await this.getRuntime()
|
const runtime = await this.getRuntime()
|
||||||
const stats = runtime.session.getSessionStats()
|
const session = runtime.session
|
||||||
const usage = runtime.session.getContextUsage()
|
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 = {
|
const info: SessionStatsInfo = {
|
||||||
tokens: { ...stats.tokens },
|
tokens: { ...stats.tokens },
|
||||||
cost: stats.cost,
|
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 })
|
this.emit({ type: 'stats', stats: info })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -481,18 +734,11 @@ export class AgentService {
|
|||||||
|
|
||||||
async listChatResources(): Promise<ChatResources> {
|
async listChatResources(): Promise<ChatResources> {
|
||||||
const runtime = await this.getRuntime()
|
const runtime = await this.getRuntime()
|
||||||
let skills: ChatResources['skills'] = []
|
// 与系统提示词同源:resource loader 的技能发现(含 ~/.agents/skills 与工作区 .agents/skills)
|
||||||
try {
|
const skills = runtime.session.resourceLoader.getSkills().skills.map((s) => ({
|
||||||
const loaded = loadSkills({
|
name: s.name,
|
||||||
cwd: this.cwd,
|
description: s.description
|
||||||
agentDir: getAgentDir(),
|
}))
|
||||||
skillPaths: [],
|
|
||||||
includeDefaults: true
|
|
||||||
})
|
|
||||||
skills = loaded.skills.map((s) => ({ name: s.name, description: s.description ?? '' }))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[agent-service] load skills failed:', err)
|
|
||||||
}
|
|
||||||
const commands = runtime.session.promptTemplates.map((t) => ({
|
const commands = runtime.session.promptTemplates.map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: t.description ?? '',
|
description: t.description ?? '',
|
||||||
@@ -501,53 +747,77 @@ export class AgentService {
|
|||||||
return { commands, skills }
|
return { commands, skills }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新) */
|
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。
|
||||||
|
* 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。
|
||||||
|
* 数据源用 override 写入的全量快照:被禁资源在会话里已过滤,只有快照还看得到。 */
|
||||||
async listResourceDetails(): Promise<ResourceDetails> {
|
async listResourceDetails(): Promise<ResourceDetails> {
|
||||||
const agentDir = getAgentDir()
|
const agentDir = getAgentDir()
|
||||||
const dirs = {
|
const dirs = {
|
||||||
globalSkills: join(agentDir, 'skills'),
|
globalSkills: join(agentDir, 'skills'),
|
||||||
|
globalAgentsSkills: join(homedir(), '.agents', 'skills'),
|
||||||
projectSkills: join(this.cwd, '.pi', 'skills'),
|
projectSkills: join(this.cwd, '.pi', 'skills'),
|
||||||
|
projectAgentsSkills: join(this.cwd, '.agents', 'skills'),
|
||||||
globalCommands: join(agentDir, 'prompts'),
|
globalCommands: join(agentDir, 'prompts'),
|
||||||
projectCommands: join(this.cwd, '.pi', 'prompts')
|
projectCommands: join(this.cwd, '.pi', 'prompts')
|
||||||
}
|
}
|
||||||
let skills: SkillDetail[] = []
|
const disabledSkills = new Set(this.settings.disabledSkills ?? [])
|
||||||
|
const disabledCommands = new Set(this.settings.disabledCommands ?? [])
|
||||||
|
const skills: SkillDetail[] = []
|
||||||
try {
|
try {
|
||||||
const loaded = loadSkills({
|
const runtime = await this.getRuntime()
|
||||||
cwd: this.cwd,
|
const all = this.resourceSnapshotSkills.length > 0 ? this.resourceSnapshotSkills : runtime.session.resourceLoader.getSkills().skills
|
||||||
agentDir,
|
for (const s of all) {
|
||||||
skillPaths: [],
|
skills.push({
|
||||||
includeDefaults: true
|
|
||||||
})
|
|
||||||
skills = loaded.skills.map((s) => ({
|
|
||||||
name: s.name,
|
name: s.name,
|
||||||
description: s.description ?? '',
|
description: s.description,
|
||||||
filePath: s.filePath,
|
filePath: s.filePath,
|
||||||
scope: s.sourceInfo?.scope === 'user' ? 'user' : 'project',
|
// 与 resource loader 的默认归类一致:无来源信息时按用户级
|
||||||
disableModelInvocation: Boolean(s.disableModelInvocation)
|
scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user',
|
||||||
}))
|
disableModelInvocation: Boolean(s.disableModelInvocation),
|
||||||
|
disabled: disabledSkills.has(s.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[agent-service] load skill details failed:', err)
|
log.error('load skill details failed:', { err: err })
|
||||||
}
|
}
|
||||||
const commands: CommandDetail[] = []
|
const commands: CommandDetail[] = []
|
||||||
try {
|
try {
|
||||||
const runtime = await this.getRuntime()
|
const runtime = await this.getRuntime()
|
||||||
|
const all =
|
||||||
|
this.resourceSnapshotPrompts.length > 0 ? this.resourceSnapshotPrompts : runtime.session.promptTemplates
|
||||||
commands.push(
|
commands.push(
|
||||||
...runtime.session.promptTemplates.map((t) => ({
|
...all.map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: t.description ?? '',
|
description: t.description ?? '',
|
||||||
argumentHint: t.argumentHint || undefined,
|
argumentHint: t.argumentHint || undefined,
|
||||||
filePath: t.filePath,
|
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) {
|
} 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))
|
skills.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
commands.sort((a, b) => a.name.localeCompare(b.name))
|
commands.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
return { skills, commands, dirs }
|
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[]> {
|
async listWorkspaceFiles(): Promise<WorkspaceFile[]> {
|
||||||
@@ -615,7 +885,7 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
return { name, path, mimeType, data: readFileSync(path).toString('base64') }
|
return { name, path, mimeType, data: readFileSync(path).toString('base64') }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[agent-service] read attachment failed:', err)
|
log.error('read attachment failed:', { err: err })
|
||||||
return { name, path, mimeType: '', data: '' }
|
return { name, path, mimeType: '', data: '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,7 +900,7 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[agent-service] read attachment failed:', err)
|
log.error('read attachment failed:', { err: err })
|
||||||
}
|
}
|
||||||
return { name, path, mimeType: '', data: '' }
|
return { name, path, mimeType: '', data: '' }
|
||||||
}
|
}
|
||||||
@@ -643,6 +913,9 @@ export class AgentService {
|
|||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (!this.runtime && !this.initPromise) return
|
if (!this.runtime && !this.initPromise) return
|
||||||
const runtime = await this.getRuntime()
|
const runtime = await this.getRuntime()
|
||||||
|
// 压缩/分支摘要独立于 agent run,abort() 不会打断它们,停止时一并取消
|
||||||
|
runtime.session.abortCompaction()
|
||||||
|
runtime.session.abortBranchSummary()
|
||||||
await runtime.session.abort()
|
await runtime.session.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,6 +1071,12 @@ export class AgentService {
|
|||||||
const mr = runtime.session.modelRuntime
|
const mr = runtime.session.modelRuntime
|
||||||
const credentials = await mr.listCredentials()
|
const credentials = await mr.listCredentials()
|
||||||
const credSet = new Set(credentials.map((c) => `${c.providerId}:${c.type}`))
|
const credSet = new Set(credentials.map((c) => `${c.providerId}:${c.type}`))
|
||||||
|
// 自定义提供商的 apiKey 存在 models.json(不进凭据存储),有 key 即视为已配置
|
||||||
|
const customKeyed = new Set(
|
||||||
|
Object.entries(this.readModelsConfig().providers)
|
||||||
|
.filter(([, v]) => Boolean((v as { apiKey?: unknown })?.apiKey))
|
||||||
|
.map(([id]) => id)
|
||||||
|
)
|
||||||
const providers = mr
|
const providers = mr
|
||||||
.getProviders()
|
.getProviders()
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
@@ -805,7 +1084,7 @@ export class AgentService {
|
|||||||
return {
|
return {
|
||||||
id: pid,
|
id: pid,
|
||||||
name: String(p.name ?? pid),
|
name: String(p.name ?? pid),
|
||||||
hasApiKey: credSet.has(`${pid}:api_key`),
|
hasApiKey: credSet.has(`${pid}:api_key`) || customKeyed.has(pid),
|
||||||
isOAuth: credSet.has(`${pid}:oauth`),
|
isOAuth: credSet.has(`${pid}:oauth`),
|
||||||
models: mr
|
models: mr
|
||||||
.getModels(pid)
|
.getModels(pid)
|
||||||
@@ -854,17 +1133,110 @@ export class AgentService {
|
|||||||
this.emit({ type: 'state', state: this.buildState(runtime.session) })
|
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 {
|
getSettings(): AppSettings {
|
||||||
return { ...this.settings }
|
return { ...this.settings }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新系统提示词后重建运行时(continueRecent 重新挂载当前最近会话,历史保留) */
|
/** 当前生效的系统提示词(pi 原生提示词 + 技能注入,只读展示用) */
|
||||||
async setSystemPrompt(text: string): Promise<void> {
|
async getSystemPrompt(): Promise<string> {
|
||||||
this.settings = { ...this.settings, systemPrompt: text }
|
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()
|
this.saveSettings()
|
||||||
await this.rebuildRuntime()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 切换权限模式:即时生效(审批扩展每笔工具调用实时读取),并广播给渲染层 */
|
/** 切换权限模式:即时生效(审批扩展每笔工具调用实时读取),并广播给渲染层 */
|
||||||
@@ -878,25 +1250,59 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 主题色偏好(纯外观,仅持久化;渲染层负责应用) */
|
/** 重建运行时(插件重载/启停、工作区切换时调用,extensionFactories 重跑)。
|
||||||
setThemeColor(id: string): void {
|
* restoreSession=true(插件重载):重建对渲染层尽量透明——
|
||||||
this.settings = { ...this.settings, themeColor: id }
|
* - 进行中的回合先经 abort 正常结算(事件照常送达,渲染层自行收口),
|
||||||
this.saveSettings()
|
* 再退订/销毁旧运行时——dispose 不含 abort,直接销毁会留下悬空请求;
|
||||||
}
|
* - 重建走 continueRecent(最近会话):用户开着的不是它时切回原会话;
|
||||||
|
* 原会话是未落盘的空新会话时退回空会话,避免静默接管别的会话;
|
||||||
private async rebuildRuntime(): Promise<void> {
|
* - 会话未变化时只推轻量 state,不广播 session_switched(该事件语义
|
||||||
|
* 保留给真正的会话变更,全量换血会重挂载渲染层全部消息)。
|
||||||
|
* restoreSession=false(工作区切换):新 cwd 的最近会话即目标状态,
|
||||||
|
* 直接全量广播切换。 */
|
||||||
|
async rebuildRuntime(options?: { restoreSession?: boolean }): Promise<void> {
|
||||||
|
const restoreSession = options?.restoreSession !== false
|
||||||
const old = this.runtime
|
const old = this.runtime
|
||||||
this.runtime = null
|
this.runtime = null
|
||||||
this.initPromise = 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?.()
|
||||||
this.unsubscribeAttached = null
|
this.unsubscribeAttached = null
|
||||||
this.attached = null
|
this.attached = null
|
||||||
if (old) {
|
if (old) {
|
||||||
await old.dispose().catch((err: unknown) => {
|
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,14 +1337,34 @@ function samePath(a: string, b: string): boolean {
|
|||||||
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
|
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
|
/** 事件消息对象转为可结构化克隆的普通对象(剥离函数/循环引用等不可克隆值)。
|
||||||
|
* structuredClone 远快于 JSON 往返——切换会话时整段历史都要过一遍这里,
|
||||||
|
* 长上下文下 JSON 序列化+反序列化是主进程最重的同步开销。 */
|
||||||
function toCloneable(value: unknown): unknown {
|
function toCloneable(value: unknown): unknown {
|
||||||
|
if (value === null || typeof value !== 'object') return value ?? null
|
||||||
try {
|
try {
|
||||||
return JSON.parse(JSON.stringify(value ?? null))
|
return structuredClone(value)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value))
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消息级 token 估算缓存:消息对象落定后不可变(pi 只追加),按引用缓存。
|
||||||
|
* emitStats 在每条 assistant 消息后都会全量重扫,不缓存的话整个会话是 O(n²) 的重复估算。 */
|
||||||
|
const messageTokenCache = new WeakMap<object, number>()
|
||||||
|
|
||||||
|
function estimateMessageTokens(message: object): number {
|
||||||
|
let tokens = messageTokenCache.get(message)
|
||||||
|
if (tokens === undefined) {
|
||||||
|
tokens = estimateTokens(message as Parameters<typeof estimateTokens>[0])
|
||||||
|
messageTokenCache.set(message, tokens)
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
function readRole(message: unknown): string {
|
function readRole(message: unknown): string {
|
||||||
if (message && typeof message === 'object' && 'role' in message) {
|
if (message && typeof message === 'object' && 'role' in message) {
|
||||||
|
|||||||
@@ -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,995 @@
|
|||||||
|
/**
|
||||||
|
* 插件宿主(主进程):
|
||||||
|
* - 内置插件:代码注册(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, shell } 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,
|
||||||
|
PluginStorageSchema,
|
||||||
|
StatusItem,
|
||||||
|
ToolbarButtonDef
|
||||||
|
} from '@aso/plugin-sdk'
|
||||||
|
import { PLUGIN_ID_PATTERN, withTimeout } from '@aso/plugin-sdk'
|
||||||
|
import type { PluginDepStatus, PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
||||||
|
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
|
||||||
|
import { PluginStorageService } from './plugin-storage'
|
||||||
|
import { getLogger } from '../logger'
|
||||||
|
|
||||||
|
const log = getLogger('plugin-host')
|
||||||
|
const pluginLog = getLogger('plugin')
|
||||||
|
|
||||||
|
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
||||||
|
export const HARNESS_CONTRACT_VERSION = '0.3.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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** semver 范围判定:支持 `*` / `^x.y.z` / `~x.y.z` / `>=x.y.z` / 精确 `x.y.z`(忽略预发布段) */
|
||||||
|
function satisfiesRange(version: string, range: string): boolean {
|
||||||
|
const v = parseSemver(version)
|
||||||
|
if (!v) return false
|
||||||
|
const r = range.trim()
|
||||||
|
if (!r || r === '*') return true
|
||||||
|
const m = /^(>=|\^|~)?\s*(\d+)\.(\d+)\.(\d+)$/.exec(r)
|
||||||
|
if (!m) return false
|
||||||
|
const cmp = compareSemver(version, `${m[2]}.${m[3]}.${m[4]}`)
|
||||||
|
if (m[1] === '^') return cmp >= 0 && v[0] === Number(m[2])
|
||||||
|
if (m[1] === '~') return cmp >= 0 && v[0] === Number(m[2]) && v[1] === Number(m[3])
|
||||||
|
if (m[1] === '>=') return cmp >= 0
|
||||||
|
return cmp === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依赖声明清洗:键须为合法插件 id、值须为受支持的范围语法;不合法条目丢弃(激活期按缺失处理) */
|
||||||
|
function sanitizeDeps(raw: unknown): Record<string, string> | undefined {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const [id, range] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (!PLUGIN_ID_PATTERN.test(id) || typeof range !== 'string') continue
|
||||||
|
const trimmed = range.trim()
|
||||||
|
if (!/^(\*|(>=|\^|~)?\d+\.\d+\.\d+)$/.test(trimmed)) continue
|
||||||
|
out[id] = trimmed
|
||||||
|
}
|
||||||
|
return Object.keys(out).length > 0 ? out : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
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>>()
|
||||||
|
/** 状态条目最近写入时间(悬浮胶囊"最新消息"判定),key = pluginId\0itemId */
|
||||||
|
private statusUpdatedAt = new Map<string, number>()
|
||||||
|
/** 跨插件服务注册表(pluginId → serviceName → impl);每次 runtime 重建时清空重建 */
|
||||||
|
private services = new Map<string, Map<string, unknown>>()
|
||||||
|
/** 已声明的存储 schema(api.storage.declareSchema;管理页 Tooltip 与 Agent 内省数据源) */
|
||||||
|
private storageSchemas = new Map<string, PluginStorageSchema>()
|
||||||
|
/** 插件 SQLite 存储服务(共享 worker;init 时创建) */
|
||||||
|
private storageService: PluginStorageService | 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 valuesFile(): string {
|
||||||
|
return join(app.getPath('userData'), 'aso-plugin-values.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 生命周期 ----
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(this.externalRoot, { recursive: true })
|
||||||
|
} catch {
|
||||||
|
// 已存在或创建失败都继续(扫描时再报)
|
||||||
|
}
|
||||||
|
this.storageService = new PluginStorageService(join(app.getPath('userData'), 'plugin-data'))
|
||||||
|
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 {
|
||||||
|
// 无值文件 = 空
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
dependencies: sanitizeDeps(raw.dependencies),
|
||||||
|
optionalDependencies: sanitizeDeps(raw.optionalDependencies),
|
||||||
|
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()
|
||||||
|
this.statusUpdatedAt.clear()
|
||||||
|
this.services.clear()
|
||||||
|
this.storageSchemas.clear()
|
||||||
|
await this.scan()
|
||||||
|
this.send('plugins:uiState', this.uiState())
|
||||||
|
this.send('plugins:listChanged', null)
|
||||||
|
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,
|
||||||
|
dependencies: this.depStatus(p),
|
||||||
|
storageSchema: this.storageSchemas.get(p.manifest.id),
|
||||||
|
error: p.error
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依赖清单求值(管理页 Tooltip 数据源):必需 + 可选,逐项实时状态 */
|
||||||
|
private depStatus(p: ResolvedPlugin): PluginDepStatus[] {
|
||||||
|
const rows: PluginDepStatus[] = []
|
||||||
|
const add = (rec: Record<string, string> | undefined, required: boolean): void => {
|
||||||
|
for (const [depId, range] of Object.entries(rec ?? {})) {
|
||||||
|
const dep = this.resolved.get(depId)
|
||||||
|
let status: PluginDepStatus['status'] = 'ok'
|
||||||
|
if (!dep) status = 'missing'
|
||||||
|
else if (!this.isEnabled(depId)) status = 'disabled'
|
||||||
|
else if (dep.error) status = 'error'
|
||||||
|
else if (!satisfiesRange(dep.manifest.version, range)) status = 'version'
|
||||||
|
rows.push({ id: depId, range, required, status })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(p.manifest.dependencies, true)
|
||||||
|
add(p.manifest.optionalDependencies, false)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
updatedAt: this.statusUpdatedAt.get(`${pluginId}\0${itemId}`) ?? Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
const stampKey = `${pluginId}\0${itemId}`
|
||||||
|
if (item) {
|
||||||
|
items.set(itemId, item)
|
||||||
|
this.statusUpdatedAt.set(stampKey, Date.now())
|
||||||
|
} else {
|
||||||
|
items.delete(itemId)
|
||||||
|
this.statusUpdatedAt.delete(stampKey)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
for (const key of [...this.statusUpdatedAt.keys()]) {
|
||||||
|
if (key.startsWith(`${pluginId}\0`)) this.statusUpdatedAt.delete(key)
|
||||||
|
}
|
||||||
|
this.settingsSchemas.delete(pluginId)
|
||||||
|
this.dropSettingsCallbacks(pluginId)
|
||||||
|
this.services.delete(pluginId)
|
||||||
|
this.storageSchemas.delete(pluginId)
|
||||||
|
this.send('plugins:uiState', this.uiState())
|
||||||
|
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
|
||||||
|
this.send('plugins:listChanged', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 深链 ----
|
||||||
|
|
||||||
|
openView(view: string, params?: unknown): void {
|
||||||
|
this.send('plugins:view', { view, params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 插件数据目录(设置页「打开数据目录 / 清除数据」) ----
|
||||||
|
|
||||||
|
/** 插件数据目录路径(插件不存在返回 null) */
|
||||||
|
pluginDataDir(id: string): string | null {
|
||||||
|
if (!this.resolved.has(id) || !this.storageService) return null
|
||||||
|
return this.storageService.dataDir(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开插件数据目录(资源管理器中显示;目录不存在则先建) */
|
||||||
|
openPluginDataDir(id: string): boolean {
|
||||||
|
const dir = this.pluginDataDir(id)
|
||||||
|
if (!dir) return false
|
||||||
|
try {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
} catch {
|
||||||
|
// 已存在或创建失败都继续(showItemInFolder 对存在路径展示)
|
||||||
|
}
|
||||||
|
shell.showItemInFolder(dir)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除插件全部存储数据(连接关闭 + 删数据目录;重装后插件从零开始) */
|
||||||
|
async clearPluginData(id: string): Promise<void> {
|
||||||
|
if (!this.resolved.has(id)) throw new Error(`插件 ${id} 不存在`)
|
||||||
|
if (!this.storageService) throw new Error('存储服务未初始化')
|
||||||
|
await this.storageService.clearData(id)
|
||||||
|
log.info('plugin data cleared by user', { pluginId: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 必需依赖静态预检(激活前):返回错误文案,null = 全部就绪。
|
||||||
|
* 软绑定:缺失/停用/版本不符只让本插件激活失败并提示行动,不自动安装、不级联停用。 */
|
||||||
|
private checkRequiredDeps(p: ResolvedPlugin): string | null {
|
||||||
|
const deps = p.manifest.dependencies
|
||||||
|
if (!deps) return null
|
||||||
|
for (const [depId, range] of Object.entries(deps)) {
|
||||||
|
const dep = this.resolved.get(depId)
|
||||||
|
if (!dep) return `缺少依赖插件 ${depId}(${range}),请安装后重载`
|
||||||
|
if (!this.isEnabled(depId)) return `依赖插件 ${depId} 已停用,请先启用`
|
||||||
|
if (dep.error) return `依赖插件 ${depId} 装载失败,无法激活`
|
||||||
|
if (!(dep.builtinFactory ?? dep.externalFactory)) return `依赖插件 ${depId} 未提供 main 工厂`
|
||||||
|
if (!satisfiesRange(dep.manifest.version, range)) {
|
||||||
|
return `依赖插件 ${depId} 需要 ${range},当前 v${dep.manifest.version}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kahn 分层拓扑排序(边 = 必需依赖 → 依赖方):依赖先激活、依赖者后激活,同波并行。
|
||||||
|
* 存在环时给环成员写明环路并返回 null(整环不激活)。 */
|
||||||
|
private topoWaves(plugins: ResolvedPlugin[]): ResolvedPlugin[][] | null {
|
||||||
|
const byId = new Map(plugins.map((p) => [p.manifest.id, p]))
|
||||||
|
const depsOf = new Map<string, string[]>()
|
||||||
|
for (const p of plugins) {
|
||||||
|
depsOf.set(
|
||||||
|
p.manifest.id,
|
||||||
|
Object.keys(p.manifest.dependencies ?? {}).filter((d) => byId.has(d))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// DFS 环检测:环成员集体报错(写明 a → b → a 环路)
|
||||||
|
const state = new Map<string, 1 | 2>()
|
||||||
|
const stack: string[] = []
|
||||||
|
let hasCycle = false
|
||||||
|
const visit = (id: string): void => {
|
||||||
|
const st = state.get(id)
|
||||||
|
if (st === 2) return
|
||||||
|
if (st === 1) {
|
||||||
|
const start = stack.indexOf(id)
|
||||||
|
const cycle = [...stack.slice(start), id].join(' → ')
|
||||||
|
for (const member of stack.slice(start)) {
|
||||||
|
const member2 = byId.get(member)
|
||||||
|
if (member2) member2.error = `循环依赖: ${cycle}`
|
||||||
|
}
|
||||||
|
hasCycle = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.set(id, 1)
|
||||||
|
stack.push(id)
|
||||||
|
for (const d of depsOf.get(id) ?? []) visit(d)
|
||||||
|
stack.pop()
|
||||||
|
state.set(id, 2)
|
||||||
|
}
|
||||||
|
for (const id of byId.keys()) visit(id)
|
||||||
|
if (hasCycle) return null
|
||||||
|
|
||||||
|
const indeg = new Map<string, number>()
|
||||||
|
const dependents = new Map<string, string[]>()
|
||||||
|
for (const [id, deps] of depsOf) {
|
||||||
|
indeg.set(id, deps.length)
|
||||||
|
for (const d of deps) {
|
||||||
|
const arr = dependents.get(d) ?? []
|
||||||
|
arr.push(id)
|
||||||
|
dependents.set(d, arr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let frontier = [...indeg.entries()].filter(([, n]) => n === 0).map(([id]) => id)
|
||||||
|
const waves: ResolvedPlugin[][] = []
|
||||||
|
let remaining = plugins.length
|
||||||
|
while (frontier.length > 0) {
|
||||||
|
waves.push(frontier.map((id) => byId.get(id)!))
|
||||||
|
remaining -= frontier.length
|
||||||
|
const next: string[] = []
|
||||||
|
for (const id of frontier) {
|
||||||
|
for (const dep of dependents.get(id) ?? []) {
|
||||||
|
const n = (indeg.get(dep) ?? 0) - 1
|
||||||
|
indeg.set(dep, n)
|
||||||
|
if (n === 0) next.push(dep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next
|
||||||
|
}
|
||||||
|
// 兜底:理论上环检测已拦;remaining > 0 说明存在未归零节点
|
||||||
|
return remaining > 0 ? null : waves
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 激活单个插件(前置:其全部必需依赖已在本轮 active)。异步工厂限时兜底;失败写 error 并清理。 */
|
||||||
|
private async activatePlugin(
|
||||||
|
p: ResolvedPlugin,
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
disposers: Array<() => void>,
|
||||||
|
active: Set<string>
|
||||||
|
): Promise<void> {
|
||||||
|
const id = p.manifest.id
|
||||||
|
const factory = p.builtinFactory ?? p.externalFactory
|
||||||
|
if (!factory) return
|
||||||
|
for (const depId of Object.keys(p.manifest.dependencies ?? {})) {
|
||||||
|
if (!active.has(depId)) {
|
||||||
|
p.error = `依赖插件 ${depId} 未能激活(详见该插件错误信息)`
|
||||||
|
this.clearPluginUi(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.resetSettingsCallbacks(id)
|
||||||
|
const markActive = (d: unknown): void => {
|
||||||
|
if (typeof d === 'function') disposers.push(d as () => void)
|
||||||
|
active.add(id)
|
||||||
|
log.info('plugin activated', { pluginId: id, source: p.source })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = factory(this.buildApi(p, pi))
|
||||||
|
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
||||||
|
// 异步工厂:限时兜底(挂死不拖垮后续波次),reject 必须接住(不得成为 unhandled rejection)
|
||||||
|
const d = await withTimeout(result as Promise<void | (() => void)>, PLUGIN_ACTIVATION_TIMEOUT_MS, `插件 ${id} 激活`)
|
||||||
|
markActive(d)
|
||||||
|
} else {
|
||||||
|
markActive(result)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
p.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
log.error('plugin activation failed', { pluginId: id, err })
|
||||||
|
this.clearPluginUi(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 挂进 AgentService 的 extensionFactories;拓扑波次激活全部已启用插件 */
|
||||||
|
createAggregatedExtension(): InlineExtension {
|
||||||
|
return {
|
||||||
|
name: 'aso-plugins',
|
||||||
|
factory: (pi: ExtensionAPI) => {
|
||||||
|
const disposers: Array<() => void> = []
|
||||||
|
this.services.clear()
|
||||||
|
/** 本轮激活成功集合(波次推进与依赖方前置判定的依据) */
|
||||||
|
const active = new Set<string>()
|
||||||
|
pi.on('session_shutdown', () => {
|
||||||
|
for (const dispose of disposers) {
|
||||||
|
try {
|
||||||
|
dispose()
|
||||||
|
} catch (err) {
|
||||||
|
log.error('plugin dispose error', { err })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disposers.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const candidates = [...this.resolved.values()].filter(
|
||||||
|
(p) => this.isEnabled(p.manifest.id) && !p.error && (p.builtinFactory ?? p.externalFactory)
|
||||||
|
)
|
||||||
|
// 静态预检:必需依赖不就绪的插件本轮直接判失败
|
||||||
|
let staticFailures = 0
|
||||||
|
for (const p of candidates) {
|
||||||
|
const failed = this.checkRequiredDeps(p)
|
||||||
|
if (failed) {
|
||||||
|
p.error = failed
|
||||||
|
staticFailures++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (staticFailures > 0) this.send('plugins:listChanged', null)
|
||||||
|
// 拓扑波次激活;环成员已在 topoWaves 里写明 error
|
||||||
|
const waves = this.topoWaves(candidates.filter((p) => !p.error))
|
||||||
|
if (!waves || waves.length === 0) {
|
||||||
|
if (!waves) this.send('plugins:listChanged', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
for (const wave of waves) {
|
||||||
|
await Promise.all(wave.map((p) => this.activatePlugin(p, pi, disposers, active)))
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
})()
|
||||||
|
if (!this.storageService) throw new Error('插件存储服务未初始化(宿主 init 未执行)')
|
||||||
|
const storage = this.storageService.forPlugin(id, (schema) => {
|
||||||
|
if (!schema || !Array.isArray(schema.tables)) return
|
||||||
|
this.storageSchemas.set(id, schema)
|
||||||
|
this.send('plugins:listChanged', null)
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
pi,
|
||||||
|
manifest: plugin.manifest,
|
||||||
|
storage,
|
||||||
|
/** 注册跨插件服务(按名键控;重载/rebuild 时随 services.clear() 清空) */
|
||||||
|
service: (name: string, impl: unknown): void => {
|
||||||
|
let svc = this.services.get(id)
|
||||||
|
if (!svc) {
|
||||||
|
svc = new Map()
|
||||||
|
this.services.set(id, svc)
|
||||||
|
}
|
||||||
|
svc.set(name, impl)
|
||||||
|
},
|
||||||
|
/** 取依赖插件的服务对象;依赖声明即访问授权——未声明直接抛错 */
|
||||||
|
requirePlugin: (targetId: string): Record<string, unknown> | undefined => {
|
||||||
|
const required = plugin.manifest.dependencies?.[targetId]
|
||||||
|
const optional = plugin.manifest.optionalDependencies?.[targetId]
|
||||||
|
if (required === undefined && optional === undefined) {
|
||||||
|
throw new Error(`插件 ${id} 未声明对 ${targetId} 的依赖(dependencies / optionalDependencies)`)
|
||||||
|
}
|
||||||
|
const svc = this.services.get(targetId)
|
||||||
|
if (!svc) {
|
||||||
|
if (optional !== undefined) return undefined
|
||||||
|
throw new Error(`依赖插件 ${targetId} 尚未就绪`)
|
||||||
|
}
|
||||||
|
return Object.fromEntries(svc)
|
||||||
|
},
|
||||||
|
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,160 @@
|
|||||||
|
/**
|
||||||
|
* 插件存储服务(契约 0.3.0):每插件一个 SQLite 库(userData/plugin-data/<id>/data.db)。
|
||||||
|
* node:sqlite 的 DatabaseSync 是同步 API——全部连接放在共享 worker 线程里执行
|
||||||
|
* (storage-worker.mjs,免打包纯 ESM),主进程只做异步 RPC,插件查询永不阻塞主进程。
|
||||||
|
*
|
||||||
|
* KV(kv 表)由宿主管理:api.storage.kv 的底层,value JSON 编码;
|
||||||
|
* 插件自建表经 api.storage.db 的 run/query/exec/batch 执行(batch 以事务包裹)。
|
||||||
|
*/
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { rm } from 'node:fs/promises'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { Worker } from 'node:worker_threads'
|
||||||
|
import type { PluginStorage, PluginStorageSchema } from '@aso/plugin-sdk'
|
||||||
|
import { getLogger } from '../logger'
|
||||||
|
|
||||||
|
const log = getLogger('plugin-storage')
|
||||||
|
|
||||||
|
/** 单个存储操作限时:本地 SQLite 正常毫秒级;超时视为 worker 挂死,不让插件无限等待 */
|
||||||
|
const STORAGE_OP_TIMEOUT_MS = 5_000
|
||||||
|
|
||||||
|
interface PendingCall {
|
||||||
|
resolve: (value: unknown) => void
|
||||||
|
reject: (err: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginStorageService {
|
||||||
|
private worker: Worker | null = null
|
||||||
|
private pending = new Map<number, PendingCall>()
|
||||||
|
private reqSeq = 0
|
||||||
|
|
||||||
|
constructor(private dataRoot: string) {}
|
||||||
|
|
||||||
|
/** worker 文件与主进程产物同目录(构建期 copyStorageWorker 拷贝到 out/main/) */
|
||||||
|
private workerPath(): string {
|
||||||
|
return join(dirname(fileURLToPath(import.meta.url)), 'storage-worker.mjs')
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureWorker(): Worker {
|
||||||
|
if (this.worker) return this.worker
|
||||||
|
const file = this.workerPath()
|
||||||
|
if (!existsSync(file)) throw new Error(`存储 worker 缺失: ${file}`)
|
||||||
|
const w = new Worker(file)
|
||||||
|
w.on('message', (msg: { reqId: number; ok: boolean; result?: unknown; error?: string }) => {
|
||||||
|
const call = this.pending.get(msg.reqId)
|
||||||
|
if (!call) return
|
||||||
|
this.pending.delete(msg.reqId)
|
||||||
|
if (msg.ok) call.resolve(msg.result)
|
||||||
|
else call.reject(new Error(msg.error ?? '存储操作失败'))
|
||||||
|
})
|
||||||
|
const failAll = (err: Error): void => {
|
||||||
|
for (const call of this.pending.values()) call.reject(err)
|
||||||
|
this.pending.clear()
|
||||||
|
this.worker = null
|
||||||
|
}
|
||||||
|
w.on('error', (err: Error) => {
|
||||||
|
log.error('storage worker error', { err: err.message })
|
||||||
|
failAll(new Error(`存储 worker 崩溃: ${err.message}`))
|
||||||
|
})
|
||||||
|
w.on('exit', (code) => {
|
||||||
|
if (code !== 0) log.error('storage worker exited', { code })
|
||||||
|
failAll(new Error(`存储 worker 已退出(code=${code})`))
|
||||||
|
})
|
||||||
|
// dataRoot 经 init 下发(worker 不依赖 electron app 路径)
|
||||||
|
this.worker = w
|
||||||
|
this.rawCall(w, 'init', { dataRoot: this.dataRoot }).catch((err) => log.error('storage worker init failed', { err }))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不经 pending 表的内部调用(init 用) */
|
||||||
|
private rawCall(w: Worker, op: string, payload: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reqId = ++this.reqSeq
|
||||||
|
const onMessage = (msg: { reqId: number; ok: boolean; result?: unknown; error?: string }): void => {
|
||||||
|
if (msg.reqId !== reqId) return
|
||||||
|
w.off('message', onMessage)
|
||||||
|
if (msg.ok) resolve(msg.result)
|
||||||
|
else reject(new Error(msg.error ?? '存储操作失败'))
|
||||||
|
}
|
||||||
|
w.on('message', onMessage)
|
||||||
|
w.postMessage({ reqId, op, ...payload })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private call<T>(op: string, payload: Record<string, unknown>): Promise<T> {
|
||||||
|
const w = this.ensureWorker()
|
||||||
|
const reqId = ++this.reqSeq
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (this.pending.delete(reqId)) reject(new Error(`存储操作超时(${op},${STORAGE_OP_TIMEOUT_MS}ms)`))
|
||||||
|
}, STORAGE_OP_TIMEOUT_MS)
|
||||||
|
this.pending.set(reqId, {
|
||||||
|
resolve: (v) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve(v as T)
|
||||||
|
},
|
||||||
|
reject: (e) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
w.postMessage({ reqId, op, ...payload })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 宿主侧管理 ----
|
||||||
|
|
||||||
|
/** 插件数据目录(id 已按 manifest 规则校验,可安全作路径段) */
|
||||||
|
dataDir(pluginId: string): string {
|
||||||
|
return join(this.dataRoot, pluginId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭某插件的连接(清除数据前调用;一般重载无需关闭——连接跨重建复用) */
|
||||||
|
async closePlugin(pluginId: string): Promise<void> {
|
||||||
|
if (!this.worker) return
|
||||||
|
try {
|
||||||
|
await this.call('close', { pluginId })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('close plugin db failed', { pluginId, err: err instanceof Error ? err.message : String(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除插件全部数据:关闭连接 + 删整个数据目录(设置页「清除数据」显式动作) */
|
||||||
|
async clearData(pluginId: string): Promise<void> {
|
||||||
|
await this.closePlugin(pluginId)
|
||||||
|
await rm(this.dataDir(pluginId), { recursive: true, force: true })
|
||||||
|
log.info('plugin data cleared', { pluginId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用退出时收尾(WAL 已逐语句持久化,失败也不阻塞退出) */
|
||||||
|
dispose(): void {
|
||||||
|
const w = this.worker
|
||||||
|
this.worker = null
|
||||||
|
for (const call of this.pending.values()) call.reject(new Error('存储服务已关闭'))
|
||||||
|
this.pending.clear()
|
||||||
|
void w?.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 插件 API(api.storage) ----
|
||||||
|
|
||||||
|
/** 组装某插件的存储 API;onSchema 由宿主提供(收集 declareSchema 声明) */
|
||||||
|
forPlugin(pluginId: string, onSchema: (schema: PluginStorageSchema) => void): PluginStorage {
|
||||||
|
return {
|
||||||
|
kv: {
|
||||||
|
get: (key) => this.call<unknown>('kvGet', { pluginId, key }).then((v) => (v === null ? undefined : (v as never))),
|
||||||
|
set: (key, value) => this.call('kvSet', { pluginId, key, value: value ?? null }),
|
||||||
|
delete: (key) => this.call('kvDelete', { pluginId, key }),
|
||||||
|
keys: () => this.call<string[]>('kvKeys', { pluginId })
|
||||||
|
},
|
||||||
|
db: {
|
||||||
|
exec: (sql) => this.call('exec', { pluginId, sql }),
|
||||||
|
run: (sql, params) => this.call('run', { pluginId, sql, params: params ?? [] }),
|
||||||
|
query: (sql, params) => this.call<Record<string, unknown>[]>('query', { pluginId, sql, params: params ?? [] }),
|
||||||
|
batch: (statements) => this.call('batch', { pluginId, statements })
|
||||||
|
},
|
||||||
|
dir: () => this.dataDir(pluginId),
|
||||||
|
declareSchema: (schema) => onSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 插件存储 worker(免打包纯 ESM,只依赖 node 内置模块;构建期拷到 out/main/ 旁)。
|
||||||
|
* 持有全部插件的 SQLite 连接(node:sqlite DatabaseSync),按消息逐个执行——
|
||||||
|
* 同步 API 全部留在本线程,主进程只做异步 RPC,插件查询永不阻塞主进程事件循环。
|
||||||
|
*
|
||||||
|
* 协议:主进程 → { reqId, op, ...payload };worker → { reqId, ok, result? | error? }。
|
||||||
|
* 首条消息必须是 init(下发 dataRoot,避免 worker 依赖 electron app 路径)。
|
||||||
|
*/
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { parentPort } from 'node:worker_threads'
|
||||||
|
|
||||||
|
/** dataRoot(userData/plugin-data);由 init 消息下发 */
|
||||||
|
let dataRoot = null
|
||||||
|
|
||||||
|
/** pluginId → 已打开连接(跨消息复用;close/clearData 时移除) */
|
||||||
|
const dbs = new Map()
|
||||||
|
|
||||||
|
function openDb(pluginId) {
|
||||||
|
let db = dbs.get(pluginId)
|
||||||
|
if (db) return db
|
||||||
|
// pluginId 已由宿主按 manifest 规则校验(小写字母开头,字母/数字/连字符),可安全作路径段
|
||||||
|
mkdirSync(join(dataRoot, pluginId), { recursive: true })
|
||||||
|
db = new DatabaseSync(join(dataRoot, pluginId, 'data.db'))
|
||||||
|
db.exec('PRAGMA journal_mode = WAL;')
|
||||||
|
// 宿主管理的 KV 表:api.storage.kv 的底层(value JSON 编码)
|
||||||
|
db.exec('CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
|
||||||
|
dbs.set(pluginId, db)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(op, msg) {
|
||||||
|
switch (op) {
|
||||||
|
case 'init':
|
||||||
|
dataRoot = msg.dataRoot
|
||||||
|
return null
|
||||||
|
case 'close': {
|
||||||
|
const db = dbs.get(msg.pluginId)
|
||||||
|
if (db) {
|
||||||
|
dbs.delete(msg.pluginId)
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
case 'kvGet': {
|
||||||
|
const row = openDb(msg.pluginId).prepare('SELECT value FROM kv WHERE key = ?').get(msg.key)
|
||||||
|
return row ? JSON.parse(row.value) : null
|
||||||
|
}
|
||||||
|
case 'kvSet':
|
||||||
|
openDb(msg.pluginId)
|
||||||
|
.prepare('INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.run(msg.key, JSON.stringify(msg.value ?? null))
|
||||||
|
return null
|
||||||
|
case 'kvDelete':
|
||||||
|
openDb(msg.pluginId).prepare('DELETE FROM kv WHERE key = ?').run(msg.key)
|
||||||
|
return null
|
||||||
|
case 'kvKeys':
|
||||||
|
return openDb(msg.pluginId)
|
||||||
|
.prepare('SELECT key FROM kv ORDER BY key')
|
||||||
|
.all()
|
||||||
|
.map((r) => r.key)
|
||||||
|
case 'exec':
|
||||||
|
openDb(msg.pluginId).exec(msg.sql)
|
||||||
|
return null
|
||||||
|
case 'run':
|
||||||
|
openDb(msg.pluginId).prepare(msg.sql).run(...(msg.params ?? []))
|
||||||
|
return null
|
||||||
|
case 'query':
|
||||||
|
return openDb(msg.pluginId).prepare(msg.sql).all(...(msg.params ?? []))
|
||||||
|
case 'batch': {
|
||||||
|
const db = openDb(msg.pluginId)
|
||||||
|
db.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
for (const st of msg.statements ?? []) db.prepare(st.sql).run(...(st.params ?? []))
|
||||||
|
db.exec('COMMIT')
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
db.exec('ROLLBACK')
|
||||||
|
} catch {
|
||||||
|
// 连接已坏时 ROLLBACK 自身会失败——原始错误优先
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`未知存储操作: ${op}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parentPort.on('message', (msg) => {
|
||||||
|
if (!dataRoot && msg.op !== 'init') {
|
||||||
|
parentPort.postMessage({ reqId: msg.reqId, ok: false, error: '存储 worker 未初始化' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parentPort.postMessage({ reqId: msg.reqId, ok: true, result: handle(msg.op, msg) ?? null })
|
||||||
|
} catch (err) {
|
||||||
|
parentPort.postMessage({ reqId: msg.reqId, ok: false, error: err instanceof Error ? err.message : String(err) })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 {
|
import type {
|
||||||
AgentUiEvent,
|
AgentUiEvent,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AttachmentInfo,
|
AttachmentInfo,
|
||||||
|
BrowserImportResult,
|
||||||
|
BrowserSource,
|
||||||
|
BrowserUiEvent,
|
||||||
|
BrowserUiState,
|
||||||
ChatResources,
|
ChatResources,
|
||||||
CustomProviderConfig,
|
CustomProviderConfig,
|
||||||
|
ForkPoint,
|
||||||
ModelCatalog,
|
ModelCatalog,
|
||||||
|
NavigateTreeOptions,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
|
PluginInfo,
|
||||||
|
PluginsUiState,
|
||||||
|
PluginViewChange,
|
||||||
ResourceDetails,
|
ResourceDetails,
|
||||||
SendImage,
|
SendImage,
|
||||||
SessionMeta,
|
SessionMeta,
|
||||||
SessionState,
|
SessionState,
|
||||||
|
ThinkingLevel,
|
||||||
|
ThemeMode,
|
||||||
WorkspaceFile,
|
WorkspaceFile,
|
||||||
WorkspaceMeta
|
WorkspaceMeta
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
|
import type { PluginSettingsSchemas, PluginSettingsValuesMap } from '@aso/plugin-sdk'
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
// 窗口控制(自绘标题栏按钮)
|
// 窗口控制(自绘标题栏按钮)
|
||||||
minimize: (): Promise<void> => ipcRenderer.invoke('win:minimize'),
|
minimize: (): Promise<void> => ipcRenderer.invoke('win:minimize'),
|
||||||
toggleMaximize: (): Promise<void> => ipcRenderer.invoke('win:toggleMaximize'),
|
toggleMaximize: (): Promise<void> => ipcRenderer.invoke('win:toggleMaximize'),
|
||||||
close: (): Promise<void> => ipcRenderer.invoke('win:close'),
|
close: (): Promise<void> => ipcRenderer.invoke('win:close'),
|
||||||
|
|
||||||
|
// 界面缩放(Ctrl+= / Ctrl+- / Ctrl+0),factor 为相对倍率,限制在 50%–200%
|
||||||
|
zoom: (factor: number): void => {
|
||||||
|
const next = Math.min(2, Math.max(0.5, webFrame.getZoomFactor() * factor))
|
||||||
|
webFrame.setZoomFactor(next)
|
||||||
|
},
|
||||||
|
zoomReset: (): void => {
|
||||||
|
webFrame.setZoomFactor(1)
|
||||||
|
},
|
||||||
onWindowState: (callback: (maximized: boolean) => void): (() => void) => {
|
onWindowState: (callback: (maximized: boolean) => void): (() => void) => {
|
||||||
const listener = (_e: Electron.IpcRendererEvent, maximized: boolean): void => callback(maximized)
|
const listener = (_e: Electron.IpcRendererEvent, maximized: boolean): void => callback(maximized)
|
||||||
ipcRenderer.on('win:state', listener)
|
ipcRenderer.on('win:state', listener)
|
||||||
@@ -61,9 +82,23 @@ const api = {
|
|||||||
setApiKey: (providerId: string, apiKey: string): Promise<void> =>
|
setApiKey: (providerId: string, apiKey: string): Promise<void> =>
|
||||||
ipcRenderer.invoke('auth:setKey', providerId, apiKey),
|
ipcRenderer.invoke('auth:setKey', providerId, apiKey),
|
||||||
logoutProvider: (providerId: string): Promise<void> => ipcRenderer.invoke('auth:logout', providerId),
|
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'),
|
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),
|
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> =>
|
resolveApproval: (id: string, approved: boolean): Promise<void> =>
|
||||||
ipcRenderer.invoke('approval:resolve', id, approved),
|
ipcRenderer.invoke('approval:resolve', id, approved),
|
||||||
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
|
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
|
||||||
@@ -72,6 +107,97 @@ const api = {
|
|||||||
return () => {
|
return () => {
|
||||||
ipcRenderer.removeListener('agent:event', listener)
|
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),
|
||||||
|
pluginsOpenDataDir: (id: string): Promise<boolean> => ipcRenderer.invoke('plugins:openDataDir', id),
|
||||||
|
pluginsClearData: (id: string): Promise<void> => ipcRenderer.invoke('plugins:clearData', id),
|
||||||
|
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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
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>
|
<title>Inkreach Agent</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { JSX } from 'react'
|
import type { JSX, ComponentType } from 'react'
|
||||||
|
import { LuGlobe } from 'react-icons/lu'
|
||||||
import { useAgent } from './hooks/useAgent'
|
import { useAgent } from './hooks/useAgent'
|
||||||
|
import { useBrowser } from './hooks/useBrowser'
|
||||||
import { SidebarNav } from './components/SidebarNav'
|
import { SidebarNav } from './components/SidebarNav'
|
||||||
import { TitleBar } from './components/TitleBar'
|
import { TitleBar } from './components/TitleBar'
|
||||||
import { ChatView } from './components/ChatView'
|
import { ChatView } from './components/ChatView'
|
||||||
@@ -8,13 +10,85 @@ import { BottomSlot } from './components/BottomSlot'
|
|||||||
import { Composer } from './components/Composer'
|
import { Composer } from './components/Composer'
|
||||||
import { SettingsModal } from './components/SettingsModal'
|
import { SettingsModal } from './components/SettingsModal'
|
||||||
import { Search } from './components/Search'
|
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 { PluginFloatingCapsule } from './plugins/PluginFloatingCapsule'
|
||||||
|
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 {
|
export default function App(): JSX.Element {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
sessions,
|
sessions,
|
||||||
workspaces,
|
workspaces,
|
||||||
|
forkPoints,
|
||||||
|
navigating,
|
||||||
|
forking,
|
||||||
send,
|
send,
|
||||||
stop,
|
stop,
|
||||||
clearError,
|
clearError,
|
||||||
@@ -26,32 +100,181 @@ export default function App(): JSX.Element {
|
|||||||
resolveApproval,
|
resolveApproval,
|
||||||
setPermissionMode,
|
setPermissionMode,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
openWorkspace
|
openWorkspace,
|
||||||
|
setThinkingLevel,
|
||||||
|
compact,
|
||||||
|
abortCompaction,
|
||||||
|
setAutoCompaction,
|
||||||
|
dismissCompactionNotice,
|
||||||
|
navigateTree,
|
||||||
|
forkSession,
|
||||||
|
forkSessionAt
|
||||||
} = useAgent()
|
} = useAgent()
|
||||||
const prefillRef = useRef<string | null>(null)
|
const prefillRef = useRef<string | null>(null)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = 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(() => {
|
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
|
void window.api
|
||||||
.getSettings()
|
.getSettings()
|
||||||
.then((s) => applyThemeColor(s.themeColor))
|
.then((s) => applyThemeColor(s.themeColor))
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
return stopWatching
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Esc 关闭弹窗
|
// 全局快捷键:Ctrl+N 新会话(全局,切回聊天)/ Ctrl+K 搜索 / Ctrl+, 设置(后两者仅聊天视图)
|
||||||
|
// Ctrl+= / Ctrl+- 界面缩放(Ctrl+0 复位)/ Esc 关闭弹窗(不切视图,回聊天走侧栏点击)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
const inChat = activeViewRef.current.view === 'chat'
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
newSession()
|
handleNewSession()
|
||||||
} else if (e.ctrlKey && e.key.toLowerCase() === 'k') {
|
} else if (inChat && e.ctrlKey && e.key.toLowerCase() === 'k') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSearchOpen(true)
|
setSearchOpen(true)
|
||||||
} else if (e.ctrlKey && e.key === ',') {
|
} else if (inChat && e.ctrlKey && e.key === ',') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSettingsOpen(true)
|
setSettingsOpen(true)
|
||||||
|
} else if (e.ctrlKey && (e.key === '=' || e.key === '+')) {
|
||||||
|
e.preventDefault()
|
||||||
|
window.api.zoom(1.1)
|
||||||
|
} else if (e.ctrlKey && (e.key === '-' || e.key === '_')) {
|
||||||
|
e.preventDefault()
|
||||||
|
window.api.zoom(1 / 1.1)
|
||||||
|
} else if (e.ctrlKey && e.key === '0') {
|
||||||
|
e.preventDefault()
|
||||||
|
window.api.zoomReset()
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setSettingsOpen(false)
|
setSettingsOpen(false)
|
||||||
setSearchOpen(false)
|
setSearchOpen(false)
|
||||||
@@ -59,16 +282,26 @@ export default function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [newSession])
|
}, [handleNewSession])
|
||||||
|
|
||||||
const handlePickSuggestion = (text: string): void => {
|
const handlePickSuggestion = (text: string): void => {
|
||||||
prefillRef.current = text
|
prefillRef.current = text
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选区操作(解释/改进/精简/自定义指令):把指令 + 引用发给 Agent
|
// 划选「引用」:选中文本以 blockquote 形式注入输入框开头(保留已有草稿)
|
||||||
const handleApplySelection = (instruction: string, selection: string): void => {
|
const [pendingQuote, setPendingQuote] = useState<string | null>(null)
|
||||||
send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`)
|
const handleQuoteSelection = useCallback((selection: string) => {
|
||||||
}
|
setPendingQuote(`> ${selection}\n\n`)
|
||||||
|
}, [])
|
||||||
|
const consumePendingQuote = useCallback(() => setPendingQuote(null), [])
|
||||||
|
|
||||||
|
// 回溯入口(稳定引用:MessageItem memo 依赖它,内联箭头每次渲染都会失效 memo)
|
||||||
|
const handleRewind = useCallback(
|
||||||
|
(entryId: string, summarize: boolean): void => {
|
||||||
|
navigateTree(entryId, { summarize })
|
||||||
|
},
|
||||||
|
[navigateTree]
|
||||||
|
)
|
||||||
|
|
||||||
// 标题栏会话名:优先当前会话名,其次会话列表里的名称/首条消息
|
// 标题栏会话名:优先当前会话名,其次会话列表里的名称/首条消息
|
||||||
const activeMeta = state.sessionState?.sessionId
|
const activeMeta = state.sessionState?.sessionId
|
||||||
@@ -80,13 +313,122 @@ export default function App(): JSX.Element {
|
|||||||
activeMeta?.firstMessage?.trim() ||
|
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
|
||||||
|
|
||||||
|
// Agent 调用浏览器工具 → 自动展开侧面板并激活浏览器标签。
|
||||||
|
// 每回合(agent_start)只强制一次:回合中用户手动收起面板不被反复顶开。
|
||||||
|
const browserAutoOpenedRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
return window.api.onEvent((event) => {
|
||||||
|
if (event.type === 'agent_start') {
|
||||||
|
browserAutoOpenedRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.type === 'tool_start' &&
|
||||||
|
event.toolName.startsWith('browser_') &&
|
||||||
|
!browserAutoOpenedRef.current &&
|
||||||
|
browserEnabled
|
||||||
|
) {
|
||||||
|
browserAutoOpenedRef.current = true
|
||||||
|
setSide((s) => ({
|
||||||
|
...s,
|
||||||
|
open: true,
|
||||||
|
tabs: s.tabs.includes('browser') ? s.tabs : [...s.tabs, 'browser'],
|
||||||
|
active: 'browser'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [browserEnabled])
|
||||||
|
|
||||||
|
// 面板标签: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 (
|
return (
|
||||||
<div className="relative flex h-full w-full bg-page text-ink antialiased">
|
<div className="relative flex h-full w-full bg-page text-ink antialiased">
|
||||||
<SidebarNav
|
<SidebarNav
|
||||||
sessionState={state.sessionState}
|
sessionState={state.sessionState}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
onNewSession={newSession}
|
onNewSession={handleNewSession}
|
||||||
onOpenSession={openSession}
|
onOpenSession={handleOpenSession}
|
||||||
onRenameSession={renameSession}
|
onRenameSession={renameSession}
|
||||||
onDeleteSession={deleteSession}
|
onDeleteSession={deleteSession}
|
||||||
onOpenSearch={() => setSearchOpen(true)}
|
onOpenSearch={() => setSearchOpen(true)}
|
||||||
@@ -94,20 +436,54 @@ export default function App(): JSX.Element {
|
|||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
activeWorkspace={workspaces.find((w) => w.path === state.sessionState?.workspace)}
|
activeWorkspace={workspaces.find((w) => w.path === state.sessionState?.workspace)}
|
||||||
onOpenWorkspace={openWorkspace}
|
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">
|
<main className="relative flex min-w-0 flex-1 flex-col">
|
||||||
<TitleBar title={sessionTitle} />
|
<TitleBar
|
||||||
|
title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name}
|
||||||
|
panelOpen={sideOpen}
|
||||||
|
onTogglePanel={toggleSide}
|
||||||
|
/>
|
||||||
|
{/* 悬浮状态胶囊:main 区右上角(侧面板打开时左移让位) */}
|
||||||
|
<div className="absolute top-12 z-40" style={{ right: sideOpen ? sideWidth + 12 : 12 }}>
|
||||||
|
<PluginFloatingCapsule entries={pluginsUi.status} plugins={plugins} onOpenView={handleOpenView} />
|
||||||
|
</div>
|
||||||
|
<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
|
<ChatView
|
||||||
|
visible={activeView.view === 'chat'}
|
||||||
messages={state.messages}
|
messages={state.messages}
|
||||||
tools={state.tools}
|
tools={state.tools}
|
||||||
streaming={state.streaming}
|
streaming={state.streaming}
|
||||||
|
turnActive={state.turnActive}
|
||||||
error={state.error}
|
error={state.error}
|
||||||
retry={state.retry}
|
retry={state.retry}
|
||||||
sessionError={state.sessionError}
|
sessionError={state.sessionError}
|
||||||
pendingApprovals={state.approvals}
|
pendingApprovals={state.approvals}
|
||||||
|
compacting={state.compacting}
|
||||||
|
compactionNotice={state.compactionNotice}
|
||||||
|
navigating={navigating}
|
||||||
|
forking={forking}
|
||||||
|
forkPoints={forkPoints}
|
||||||
onDismissError={clearError}
|
onDismissError={clearError}
|
||||||
|
onDismissCompactionNotice={dismissCompactionNotice}
|
||||||
|
onAbortCompaction={abortCompaction}
|
||||||
|
onRewind={handleRewind}
|
||||||
|
onFork={forkSession}
|
||||||
|
onForkAt={(entryId) => forkSessionAt(entryId, 'before')}
|
||||||
onPickSuggestion={handlePickSuggestion}
|
onPickSuggestion={handlePickSuggestion}
|
||||||
onApplySelection={handleApplySelection}
|
onQuoteSelection={handleQuoteSelection}
|
||||||
/>
|
/>
|
||||||
<BottomSlot
|
<BottomSlot
|
||||||
approvals={state.approvals}
|
approvals={state.approvals}
|
||||||
@@ -121,25 +497,80 @@ export default function App(): JSX.Element {
|
|||||||
permissionMode={state.sessionState?.permissionMode ?? 'confirm'}
|
permissionMode={state.sessionState?.permissionMode ?? 'confirm'}
|
||||||
sessionKey={state.sessionState?.sessionId}
|
sessionKey={state.sessionState?.sessionId}
|
||||||
sessionMessageCount={state.sessionState?.messageCount}
|
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}
|
onSend={send}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
onNewSession={newSession}
|
onNewSession={handleNewSession}
|
||||||
onSetModel={setModel}
|
onSetModel={setModel}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
onPermissionMode={setPermissionMode}
|
onPermissionMode={setPermissionMode}
|
||||||
|
onSetThinkingLevel={setThinkingLevel}
|
||||||
|
onCompact={compact}
|
||||||
|
onAbortCompaction={abortCompaction}
|
||||||
|
onToggleAutoCompaction={setAutoCompaction}
|
||||||
prefillRef={prefillRef}
|
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>
|
</main>
|
||||||
<Search
|
<Search
|
||||||
open={searchOpen}
|
open={searchOpen}
|
||||||
onClose={() => setSearchOpen(false)}
|
onClose={() => setSearchOpen(false)}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
onNewSession={newSession}
|
onNewSession={handleNewSession}
|
||||||
onRefreshSessions={refreshSessions}
|
onRefreshSessions={refreshSessions}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
onOpenSession={openSession}
|
onOpenSession={handleOpenSession}
|
||||||
/>
|
/>
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
open={settingsOpen}
|
open={settingsOpen}
|
||||||
|
|||||||
@@ -90,17 +90,23 @@ function Ico({ path, size = 14, sw = 2 }: { path: ReactNode; size?: number; sw?:
|
|||||||
|
|
||||||
const OPTION_APPROVE = 0
|
const OPTION_APPROVE = 0
|
||||||
|
|
||||||
|
export type ApprovalOutcome = 'approved' | 'rejected'
|
||||||
|
|
||||||
export function ApprovalCard({
|
export function ApprovalCard({
|
||||||
request,
|
request,
|
||||||
|
outcome,
|
||||||
onResolve
|
onResolve
|
||||||
}: {
|
}: {
|
||||||
request: ApprovalRequest
|
request: ApprovalRequest
|
||||||
|
/** 外部已结算(卡片重挂后恢复结果态):给定时直接呈现结果胶囊,交互冻结 */
|
||||||
|
outcome?: ApprovalOutcome
|
||||||
onResolve: (id: string, approved: boolean) => void
|
onResolve: (id: string, approved: boolean) => void
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const [picked, setPicked] = useState<number | null>(null)
|
const [picked, setPicked] = useState<number | null>(null)
|
||||||
const [sent, setSent] = useState<string | null>(null)
|
const [sent, setSent] = useState<string | null>(null)
|
||||||
const submitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const submitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const settled = useRef(false)
|
const settled = useRef(false)
|
||||||
|
const settledNote = sent ?? (outcome === 'approved' ? '已允许执行' : outcome === 'rejected' ? '已拒绝' : null)
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -110,7 +116,7 @@ export function ApprovalCard({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const settle = (approved: boolean, note: string): void => {
|
const settle = (approved: boolean, note: string): void => {
|
||||||
if (settled.current) return
|
if (settledNote) return
|
||||||
settled.current = true
|
settled.current = true
|
||||||
setSent(note)
|
setSent(note)
|
||||||
if (submitTimer.current) clearTimeout(submitTimer.current)
|
if (submitTimer.current) clearTimeout(submitTimer.current)
|
||||||
@@ -118,7 +124,7 @@ export function ApprovalCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pick = (index: number): void => {
|
const pick = (index: number): void => {
|
||||||
if (sent) return
|
if (settledNote) return
|
||||||
setPicked(index)
|
setPicked(index)
|
||||||
// radio 自动前进:480ms 后提交(官方 single-choice 行为)
|
// radio 自动前进:480ms 后提交(官方 single-choice 行为)
|
||||||
if (submitTimer.current) clearTimeout(submitTimer.current)
|
if (submitTimer.current) clearTimeout(submitTimer.current)
|
||||||
@@ -127,8 +133,8 @@ export function ApprovalCard({
|
|||||||
}, 480)
|
}, 480)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sent) {
|
if (settledNote) {
|
||||||
const ok = sent === '已允许执行'
|
const ok = settledNote === '已允许执行'
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center gap-3" style={{ animation: 'pop-in 260ms cubic-bezier(0.23,1,0.32,1) both' }}>
|
<div className="flex w-full items-center gap-3" style={{ animation: 'pop-in 260ms cubic-bezier(0.23,1,0.32,1) both' }}>
|
||||||
<span
|
<span
|
||||||
@@ -147,7 +153,7 @@ export function ApprovalCard({
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{sent}
|
{settledNote}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import type { ApprovalRequest } from '@shared/types'
|
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)'
|
type Phase = 'pending' | 'settled' | 'closing' | 'hidden'
|
||||||
const FADE_TRANSITION = 'opacity 220ms ease, transform 400ms cubic-bezier(0.22, 1, 0.36, 1)'
|
|
||||||
|
|
||||||
/** 跟踪从 requests 里消失的审批:缓存原件让卡片以结算态多停留一拍 */
|
const SETTLED_MS = 900
|
||||||
function useLeavingApprovals(requests: ApprovalRequest[]): ApprovalRequest[] {
|
const CLOSE_MS = 240
|
||||||
const seenRef = useRef(new Map<string, ApprovalRequest>())
|
|
||||||
for (const request of requests) seenRef.current.set(request.id, request)
|
|
||||||
const [leaving, setLeaving] = useState<ApprovalRequest[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const gone: ApprovalRequest[] = []
|
|
||||||
for (const [id, request] of seenRef.current) {
|
|
||||||
if (!requests.some((r) => r.id === id)) gone.push(request)
|
|
||||||
}
|
|
||||||
if (gone.length === 0) return
|
|
||||||
for (const g of gone) seenRef.current.delete(g.id)
|
|
||||||
setLeaving((cur) => [...cur.filter((l) => !gone.some((g) => g.id === l.id)), ...gone])
|
|
||||||
const timers = gone.map((g) =>
|
|
||||||
setTimeout(() => {
|
|
||||||
setLeaving((cur) => cur.filter((l) => l.id !== g.id))
|
|
||||||
}, 1000)
|
|
||||||
)
|
|
||||||
return () => timers.forEach(clearTimeout)
|
|
||||||
}, [requests])
|
|
||||||
|
|
||||||
return leaving
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BottomSlot({
|
export function BottomSlot({
|
||||||
approvals,
|
approvals,
|
||||||
@@ -46,48 +23,80 @@ export function BottomSlot({
|
|||||||
onResolve: (id: string, approved: boolean) => void
|
onResolve: (id: string, approved: boolean) => void
|
||||||
composer: ReactNode
|
composer: ReactNode
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const leaving = useLeavingApprovals(approvals)
|
const [shown, setShown] = useState<ApprovalRequest | null>(null)
|
||||||
const showApproval = approvals.length > 0 || leaving.length > 0
|
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 (
|
return (
|
||||||
<div>
|
<div className="relative">
|
||||||
{/* Chatbox 行:审批升起时收起(高度 0 + 下沉淡出) */}
|
{/* 审批抽屉:悬浮在输入框上方(版面中部),从输入框后缘滑出 */}
|
||||||
<div style={{ display: 'grid', gridTemplateRows: showApproval ? '0fr' : '1fr', transition: ROWS_TRANSITION }}>
|
|
||||||
<div className="min-h-0 overflow-hidden">
|
|
||||||
<div
|
<div
|
||||||
style={{
|
aria-hidden={!visible}
|
||||||
opacity: showApproval ? 0 : 1,
|
className={`pointer-events-none absolute inset-x-0 bottom-full z-20 mb-2 flex justify-center ${visible ? '' : 'invisible'}`}
|
||||||
transform: showApproval ? 'translateY(10px)' : 'none',
|
|
||||||
transition: FADE_TRANSITION,
|
|
||||||
pointerEvents: showApproval ? 'none' : undefined
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{shown && (
|
||||||
|
<div
|
||||||
|
key={shown.id}
|
||||||
|
className="pointer-events-auto w-[380px] max-w-[calc(100vw-48px)]"
|
||||||
|
style={
|
||||||
|
phase === 'pending'
|
||||||
|
? { animation: 'drawer-up 340ms cubic-bezier(0.22, 1, 0.36, 1) both' }
|
||||||
|
: phase === 'closing'
|
||||||
|
? {
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'translateY(10px)',
|
||||||
|
transition: 'opacity 200ms ease, transform 220ms cubic-bezier(0.22, 1, 0.36, 1)'
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ApprovalCard request={shown} outcome={outcomesRef.current.get(shown.id)} onResolve={handleResolve} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{composer}
|
{composer}
|
||||||
</div>
|
<style>{`@keyframes drawer-up { from { opacity: 0; transform: translateY(26px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { LuRotateCw, LuTriangleAlert } from 'react-icons/lu'
|
import { LuFoldVertical, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu'
|
||||||
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
|
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 { AssistantTurn, MessageItem } from './MessageItem'
|
||||||
import { SelectionActions } from './SelectionActions'
|
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 {
|
function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
|
||||||
return (
|
return (
|
||||||
@@ -33,38 +62,101 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 连续 assistant 消息(pi 每个工具轮次一条)合并为一个回合渲染,
|
/** 连续 assistant 消息(pi 每个工具轮次一条)合并为一个回合渲染,
|
||||||
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。 */
|
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。
|
||||||
|
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
|
||||||
|
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序),
|
||||||
|
* startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。
|
||||||
|
* animate=false(正在补齐历史)时挂载的消息不播入场动画。
|
||||||
|
* 分叉入口:onFork(会话末尾)只给延伸到列表末尾的最后一个回合;
|
||||||
|
* 其余回合用「本回合之后第一条用户消息」的 entryId 走 onForkAt(entryId,'before')
|
||||||
|
* ——新会话包含到本回合答案为止。 */
|
||||||
function renderTurns(
|
function renderTurns(
|
||||||
messages: UiMessage[],
|
messages: UiMessage[],
|
||||||
tools: Record<string, ToolState>,
|
tools: Record<string, ToolState>,
|
||||||
streaming: boolean,
|
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[] {
|
): ReactNode[] {
|
||||||
const items: ReactNode[] = []
|
const items: ReactNode[] = []
|
||||||
let i = 0
|
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) {
|
while (i < messages.length) {
|
||||||
const message = messages[i]
|
const message = messages[i]
|
||||||
if (message.role === 'assistant') {
|
if (message.role === 'assistant') {
|
||||||
let j = i + 1
|
let j = i + 1
|
||||||
while (j < messages.length && messages[j].role === 'assistant') j += 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(
|
items.push(
|
||||||
<AssistantTurn
|
<AssistantTurn
|
||||||
key={`${message.key}-turn`}
|
key={`${message.key}-turn`}
|
||||||
messages={messages.slice(i, j)}
|
messages={messages.slice(i, j)}
|
||||||
tools={tools}
|
tools={tools}
|
||||||
streaming={streaming}
|
streaming={streaming && isLive}
|
||||||
|
turnActive={turnActive && isLive}
|
||||||
pendingApprovals={pendingApprovals}
|
pendingApprovals={pendingApprovals}
|
||||||
|
scrollPinnedRef={pinnedRef}
|
||||||
|
forking={forking}
|
||||||
|
onFork={isLive ? onFork : undefined}
|
||||||
|
forkEntryId={turnForkEntryId}
|
||||||
|
onForkAt={onForkAt}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
i = j
|
i = j
|
||||||
} else {
|
} else {
|
||||||
|
// 用户消息按序对齐回溯点;非用户角色(压缩/分支摘要通知行)不占序号
|
||||||
|
const forkEntryId =
|
||||||
|
message.role === 'user' && !message.local ? forkPoints[userMessageIdx++]?.entryId : undefined
|
||||||
items.push(
|
items.push(
|
||||||
<MessageItem
|
<MessageItem
|
||||||
key={message.key}
|
key={message.key}
|
||||||
message={message}
|
message={message}
|
||||||
tools={tools}
|
tools={tools}
|
||||||
streaming={streaming}
|
streaming={streaming && i === messages.length - 1}
|
||||||
|
turnActive={turnActive && i === messages.length - 1}
|
||||||
pendingApprovals={pendingApprovals}
|
pendingApprovals={pendingApprovals}
|
||||||
|
scrollPinnedRef={pinnedRef}
|
||||||
|
forkEntryId={forkEntryId}
|
||||||
|
rewinding={rewinding}
|
||||||
|
onRewind={onRewind}
|
||||||
|
animate={animate}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
i += 1
|
i += 1
|
||||||
@@ -73,32 +165,116 @@ function renderTurns(
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMPACTION_REASON_LABELS: Record<CompactionNotice['reason'], string> = {
|
||||||
|
manual: '手动触发',
|
||||||
|
threshold: '接近窗口上限,自动触发',
|
||||||
|
overflow: '上下文溢出,自动触发'
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatView({
|
export function ChatView({
|
||||||
messages,
|
messages,
|
||||||
tools,
|
tools,
|
||||||
streaming,
|
streaming,
|
||||||
|
turnActive,
|
||||||
error,
|
error,
|
||||||
retry,
|
retry,
|
||||||
sessionError,
|
sessionError,
|
||||||
pendingApprovals,
|
pendingApprovals,
|
||||||
|
compacting,
|
||||||
|
compactionNotice,
|
||||||
|
navigating,
|
||||||
|
forking,
|
||||||
|
forkPoints,
|
||||||
onDismissError,
|
onDismissError,
|
||||||
|
onDismissCompactionNotice,
|
||||||
|
onAbortCompaction,
|
||||||
|
onRewind,
|
||||||
|
onFork,
|
||||||
|
onForkAt,
|
||||||
onPickSuggestion,
|
onPickSuggestion,
|
||||||
onApplySelection
|
onQuoteSelection,
|
||||||
|
visible = true
|
||||||
}: {
|
}: {
|
||||||
messages: UiMessage[]
|
messages: UiMessage[]
|
||||||
tools: Record<string, ToolState>
|
tools: Record<string, ToolState>
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
|
turnActive: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
|
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
|
||||||
sessionError: string | null
|
sessionError: string | null
|
||||||
pendingApprovals: ApprovalRequest[]
|
pendingApprovals: ApprovalRequest[]
|
||||||
|
/** 正在压缩上下文 */
|
||||||
|
compacting: boolean
|
||||||
|
/** 最近一次压缩结果通知(自动消退) */
|
||||||
|
compactionNotice: CompactionNotice | null
|
||||||
|
/** 分支回溯/摘要生成进行中 */
|
||||||
|
navigating: boolean
|
||||||
|
/** 会话末尾分叉(pi fork)进行中 */
|
||||||
|
forking: boolean
|
||||||
|
forkPoints: ForkPoint[]
|
||||||
onDismissError: () => void
|
onDismissError: () => void
|
||||||
|
onDismissCompactionNotice: () => void
|
||||||
|
onAbortCompaction: () => void
|
||||||
|
onRewind: (entryId: string, summarize: boolean) => void
|
||||||
|
/** 在会话末尾分叉出新会话(原会话保留) */
|
||||||
|
onFork: () => void
|
||||||
|
/** 从指定锚点分叉出新会话(含该锚点前的整个回合) */
|
||||||
|
onForkAt: (entryId: string) => void
|
||||||
onPickSuggestion: (text: string) => void
|
onPickSuggestion: (text: string) => void
|
||||||
onApplySelection: (instruction: string, selection: string) => void
|
onQuoteSelection: (selection: string) => void
|
||||||
|
/** 聊天列是否可见(切到插件视图时 display:none 保活;隐藏期滚动测量为 0,切回重新吸底) */
|
||||||
|
visible?: boolean
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const pinnedRef = useRef(true)
|
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 handleScroll = (): void => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
@@ -109,17 +285,31 @@ export function ChatView({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el && pinnedRef.current) {
|
if (!el || !pinnedRef.current) return
|
||||||
|
// rAF 合帧:流式 delta 一帧内多次触发时只吸底一次,
|
||||||
|
// 且读 scrollHeight 的强制布局每帧至多一次
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
el.scrollTop = el.scrollHeight
|
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 hasContent = messages.length > 0
|
||||||
|
// 分叉入口挂在每个回合的复制按钮旁:回合进行中/压缩/回溯时收起
|
||||||
|
const canFork = !turnActive && !streaming && !compacting && !navigating
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="absolute inset-x-4 top-3 z-10 flex flex-col gap-2">
|
||||||
{(error || sessionError) && (
|
{(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">
|
<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" />
|
<LuTriangleAlert size={16} className="mt-0.5 shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{sessionError ? (
|
{sessionError ? (
|
||||||
@@ -142,7 +332,7 @@ export function ChatView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{retry && (
|
{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">
|
<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" />
|
<LuRotateCw size={14} className="animate-spin" />
|
||||||
<span>
|
<span>
|
||||||
请求失败,自动重试 {retry.attempt}/{retry.maxAttempts}
|
请求失败,自动重试 {retry.attempt}/{retry.maxAttempts}
|
||||||
@@ -151,10 +341,92 @@ export function ChatView({
|
|||||||
</div>
|
</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">
|
<div ref={scrollRef} onScroll={handleScroll} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{hasContent ? (
|
{hasContent ? (
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
@@ -163,8 +435,8 @@ export function ChatView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 划选消息文本后浮出的操作条 */}
|
{/* 划选消息文本后浮出的操作条:引用进输入框 / 复制 */}
|
||||||
<SelectionActions containerRef={scrollRef} onApply={onApplySelection} />
|
<SelectionActions containerRef={scrollRef} onQuote={onQuoteSelection} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import type { JSX, RefObject } from 'react'
|
import type { JSX, RefObject } from 'react'
|
||||||
import { LuCheck, LuFileText, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
|
import { LuBrain, LuCheck, LuChevronRight, LuFileText, LuGauge, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
|
||||||
import type { AttachmentInfo, ChatResources, ModelCatalog, ModelInfo, PermissionMode, WorkspaceFile } from '@shared/types'
|
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):
|
* Prompt Bar(beautifului.dev #prompt-bar,布局仿 ZCode Chatbox):
|
||||||
* 14px 圆角容器 + 五列网格(+/输入/模型/权限/发送),focus 边框加深。
|
* 14px 圆角容器,输入区整行在上,工具行在下(左「+」、右模型/权限/发送),
|
||||||
|
* focus 边框加深。
|
||||||
* "/" 命令面板:应用内动作 + pi 文件命令模板 + /skill: 技能(发送时由 SDK 展开);
|
* "/" 命令面板:应用内动作 + pi 文件命令模板 + /skill: 技能(发送时由 SDK 展开);
|
||||||
* "@" 引用工作区文件;"+" 添加附件(图片为真实 ImageContent,文本文件内容注入,其余 @path 引用);
|
* "@" 引用工作区文件;"+" 添加附件(图片为真实 ImageContent,文本文件内容注入,其余 @path 引用);
|
||||||
* 支持直接粘贴截图(clipboard 图片 → 附件)。
|
* 支持直接粘贴截图(clipboard 图片 → 附件)。
|
||||||
@@ -76,6 +88,18 @@ const PERMISSION_MODES: Array<{ id: PermissionMode; label: string; desc: string;
|
|||||||
{ id: 'full', label: '完全访问', desc: '自动执行所有工具调用,不再逐一确认。', icon: LuShieldCheck }
|
{ 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({
|
export function Composer({
|
||||||
streaming,
|
streaming,
|
||||||
queue,
|
queue,
|
||||||
@@ -84,13 +108,24 @@ export function Composer({
|
|||||||
permissionMode = 'confirm',
|
permissionMode = 'confirm',
|
||||||
sessionKey,
|
sessionKey,
|
||||||
sessionMessageCount,
|
sessionMessageCount,
|
||||||
|
stats,
|
||||||
|
thinkingLevel,
|
||||||
|
thinkingLevels = [],
|
||||||
|
compacting,
|
||||||
|
autoCompactionEnabled = true,
|
||||||
onSend,
|
onSend,
|
||||||
onStop,
|
onStop,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onSetModel,
|
onSetModel,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onPermissionMode,
|
onPermissionMode,
|
||||||
prefillRef
|
onSetThinkingLevel,
|
||||||
|
onCompact,
|
||||||
|
onAbortCompaction,
|
||||||
|
onToggleAutoCompaction,
|
||||||
|
prefillRef,
|
||||||
|
pendingQuote = null,
|
||||||
|
onQuoteConsumed
|
||||||
}: {
|
}: {
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
/** 排队消息(steering 先于 followUp 送达) */
|
/** 排队消息(steering 先于 followUp 送达) */
|
||||||
@@ -102,20 +137,42 @@ export function Composer({
|
|||||||
sessionKey?: string
|
sessionKey?: string
|
||||||
/** 当前会话消息数(0 = 新建未发言的空会话) */
|
/** 当前会话消息数(0 = 新建未发言的空会话) */
|
||||||
sessionMessageCount?: number
|
sessionMessageCount?: number
|
||||||
|
/** 会话统计(Usage Card 数据源) */
|
||||||
|
stats: SessionStatsInfo | null
|
||||||
|
/** 当前思考等级 */
|
||||||
|
thinkingLevel?: ThinkingLevel
|
||||||
|
/** 当前模型可用的思考等级(空 = 不支持思考,隐藏切换器) */
|
||||||
|
thinkingLevels?: ThinkingLevel[]
|
||||||
|
/** 正在压缩上下文 */
|
||||||
|
compacting: boolean
|
||||||
|
/** 自动压缩开关 */
|
||||||
|
autoCompactionEnabled?: boolean
|
||||||
onSend: (text: string, images?: { mimeType: string; data: string }[]) => void
|
onSend: (text: string, images?: { mimeType: string; data: string }[]) => void
|
||||||
onStop: () => void
|
onStop: () => void
|
||||||
onNewSession: () => void
|
onNewSession: () => void
|
||||||
onSetModel: (providerId: string, modelId: string) => void
|
onSetModel: (providerId: string, modelId: string) => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
onPermissionMode: (mode: PermissionMode) => void
|
onPermissionMode: (mode: PermissionMode) => void
|
||||||
|
onSetThinkingLevel: (level: ThinkingLevel) => void
|
||||||
|
onCompact: (instructions?: string) => void
|
||||||
|
onAbortCompaction: () => void
|
||||||
|
onToggleAutoCompaction: (enabled: boolean) => void
|
||||||
/** 供空状态建议词点击后填入 */
|
/** 供空状态建议词点击后填入 */
|
||||||
prefillRef: RefObject<string | null>
|
prefillRef: RefObject<string | null>
|
||||||
|
/** 划选「引用」注入的内容(blockquote);注入到输入框开头,保留已有草稿 */
|
||||||
|
pendingQuote?: string | null
|
||||||
|
/** 注入完成后由 Composer 通知上层清空 */
|
||||||
|
onQuoteConsumed?: () => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [modelOpen, setModelOpen] = useState(false)
|
const [modelOpen, setModelOpen] = useState(false)
|
||||||
const [modelPos, setModelPos] = useState<{ top: number; left: number; right: number } | null>(null)
|
const [modelPos, setModelPos] = useState<{ top: number; left: number; right: number } | null>(null)
|
||||||
const [permOpen, setPermOpen] = useState(false)
|
const [permOpen, setPermOpen] = useState(false)
|
||||||
const [permPos, setPermPos] = useState<{ top: number; left: number; right: number } | null>(null)
|
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 [catalog, setCatalog] = useState<ModelCatalog | null>(null)
|
||||||
const [catalogLoading, setCatalogLoading] = useState(false)
|
const [catalogLoading, setCatalogLoading] = useState(false)
|
||||||
const [attachments, setAttachments] = useState<AttachmentInfo[]>([])
|
const [attachments, setAttachments] = useState<AttachmentInfo[]>([])
|
||||||
@@ -129,6 +186,7 @@ export function Composer({
|
|||||||
const localCommands = useMemo<LocalCommand[]>(
|
const localCommands = useMemo<LocalCommand[]>(
|
||||||
() => [
|
() => [
|
||||||
{ id: 'new', label: '/new', hint: '新会话', run: onNewSession },
|
{ id: 'new', label: '/new', hint: '新会话', run: onNewSession },
|
||||||
|
{ id: 'compact', label: '/compact', hint: '压缩上下文(可附指令,如 /compact 专注数据表设计)', run: () => undefined },
|
||||||
{ id: 'settings', label: '/settings', hint: '打开设置', run: onOpenSettings },
|
{ id: 'settings', label: '/settings', hint: '打开设置', run: onOpenSettings },
|
||||||
{ id: 'stop', label: '/stop', hint: '停止生成', run: onStop, enabled: streaming },
|
{ id: 'stop', label: '/stop', hint: '停止生成', run: onStop, enabled: streaming },
|
||||||
{ id: 'clear', label: '/clear', hint: '清空输入', run: () => setValue('') }
|
{ id: 'clear', label: '/clear', hint: '清空输入', run: () => setValue('') }
|
||||||
@@ -136,10 +194,12 @@ export function Composer({
|
|||||||
[onNewSession, onOpenSettings, onStop, streaming]
|
[onNewSession, onOpenSettings, onStop, streaming]
|
||||||
)
|
)
|
||||||
|
|
||||||
// "/" 命令面板:输入以 / 开头且当前词无空格时触发
|
// "/" 命令面板:输入以 / 开头且当前词无空格时触发;
|
||||||
|
// /compact 例外——允许带参数(如「/compact 保留表设计讨论」),带参数时保持面板打开
|
||||||
const trimmed = value.trimStart()
|
const trimmed = value.trimStart()
|
||||||
const slashExec = trimmed === value ? /^\/(\S*)$/.exec(trimmed) : null
|
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)
|
const atMatch = /@([^\s@]*)$/.exec(value)
|
||||||
@@ -368,7 +428,8 @@ export function Composer({
|
|||||||
}, [menuOpen])
|
}, [menuOpen])
|
||||||
const closeMenu = (): void => {
|
const closeMenu = (): void => {
|
||||||
if (atMatch) setValue(value.replace(/@[^\s@]*$/, ''))
|
if (atMatch) setValue(value.replace(/@[^\s@]*$/, ''))
|
||||||
else if (slashQuery !== null) setValue('')
|
else if (slashExec) setValue('')
|
||||||
|
// 带参数的 /compact:仅关闭面板,保留已输入的指令
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => setActiveIdx(0), [value])
|
useEffect(() => setActiveIdx(0), [value])
|
||||||
@@ -382,6 +443,19 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}, [prefillRef])
|
}, [prefillRef])
|
||||||
|
|
||||||
|
// 划选「引用」→ 注入输入框开头(已有草稿顺延到引用块之后),光标落在引用块末尾
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingQuote) return
|
||||||
|
setValue((current) => (current.trim() ? `${pendingQuote}${current}` : pendingQuote))
|
||||||
|
onQuoteConsumed?.()
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (el) {
|
||||||
|
el.focus()
|
||||||
|
const pos = pendingQuote.length
|
||||||
|
el.setSelectionRange(pos, pos)
|
||||||
|
}
|
||||||
|
}, [pendingQuote, onQuoteConsumed])
|
||||||
|
|
||||||
// 草稿实时保存(发送后输入框清空,草稿随之清除)
|
// 草稿实时保存(发送后输入框清空,草稿随之清除)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveDraft(value)
|
saveDraft(value)
|
||||||
@@ -410,6 +484,11 @@ export function Composer({
|
|||||||
|
|
||||||
const modelBtnRef = useRef<HTMLButtonElement>(null)
|
const modelBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
const permBtnRef = 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 => {
|
const anchorOf = (el: HTMLElement | null): { top: number; left: number; right: number } | null => {
|
||||||
if (!el) return null
|
if (!el) return null
|
||||||
@@ -423,6 +502,7 @@ export function Composer({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setModelPos(anchorOf(modelBtnRef.current))
|
setModelPos(anchorOf(modelBtnRef.current))
|
||||||
|
setHoveredProvider(null)
|
||||||
setModelOpen((open) => {
|
setModelOpen((open) => {
|
||||||
if (!open && !catalogLoadedRef.current) {
|
if (!open && !catalogLoadedRef.current) {
|
||||||
catalogLoadedRef.current = true
|
catalogLoadedRef.current = true
|
||||||
@@ -446,9 +526,40 @@ export function Composer({
|
|||||||
setPermOpen(true)
|
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 => {
|
const pickMenuItem = (index: number): void => {
|
||||||
if (index < commandItems.length) {
|
if (index < commandItems.length) {
|
||||||
const item = commandItems[index]
|
const item = commandItems[index]
|
||||||
|
if (item.key === 'local-compact') {
|
||||||
|
runCompactCommand()
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
setValue('')
|
setValue('')
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
if (item.insert) {
|
if (item.insert) {
|
||||||
@@ -562,6 +673,28 @@ export function Composer({
|
|||||||
|
|
||||||
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
|
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
|
||||||
const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? []
|
const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? []
|
||||||
|
|
||||||
|
// 服务商行的悬浮跟进:离开行时有 120ms 宽限,让鼠标能跨过缝隙进入悬浮模型列表
|
||||||
|
const enterProvider = (id: string, el: HTMLElement): void => {
|
||||||
|
if (hoverGraceRef.current !== null) {
|
||||||
|
window.clearTimeout(hoverGraceRef.current)
|
||||||
|
hoverGraceRef.current = null
|
||||||
|
}
|
||||||
|
setHoveredProvider({ id, rect: el.getBoundingClientRect() })
|
||||||
|
}
|
||||||
|
const leaveProvider = (): void => {
|
||||||
|
if (hoverGraceRef.current !== null) window.clearTimeout(hoverGraceRef.current)
|
||||||
|
hoverGraceRef.current = window.setTimeout(() => {
|
||||||
|
hoverGraceRef.current = null
|
||||||
|
setHoveredProvider(null)
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
const keepProviderFlyout = (): void => {
|
||||||
|
if (hoverGraceRef.current !== null) {
|
||||||
|
window.clearTimeout(hoverGraceRef.current)
|
||||||
|
hoverGraceRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
const hasContentAttachment = attachments.some((a) => (a.data && a.mimeType.startsWith('image/')) || a.text)
|
const hasContentAttachment = attachments.some((a) => (a.data && a.mimeType.startsWith('image/')) || a.text)
|
||||||
const canSend = !disabled && (value.trim().length > 0 || hasContentAttachment)
|
const canSend = !disabled && (value.trim().length > 0 || hasContentAttachment)
|
||||||
|
|
||||||
@@ -733,21 +866,8 @@ export function Composer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-[28px_minmax(0,1fr)_auto_auto_28px] items-end gap-x-1 gap-y-1.5">
|
{/* 输入区整行在上(仿 ZCode),工具行独立在下 */}
|
||||||
{/* +:添加附件 */}
|
<div className="flex flex-col gap-0.5">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="添加附件"
|
|
||||||
onClick={addAttachments}
|
|
||||||
disabled={disabled || picking}
|
|
||||||
title="添加附件(图片/文本文件随消息发送,其余文件以 @路径 引用)"
|
|
||||||
className="col-start-1 row-start-1 flex size-7 shrink-0 justify-self-start items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
||||||
<path d="M12 5v14M5 12h14" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -759,9 +879,41 @@ export function Composer({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={disabled ? 'Agent 会话未就绪…' : streaming ? '继续输入以排队后续修改…' : '写下消息,/ 命令与技能、@ 引用文件'}
|
placeholder={disabled ? 'Agent 会话未就绪…' : streaming ? '继续输入以排队后续修改…' : '写下消息,/ 命令与技能、@ 引用文件'}
|
||||||
aria-label="Prompt"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 工具行:左「+」,右 模型 / 权限 / 发送 */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* +:添加附件 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="添加附件"
|
||||||
|
onClick={addAttachments}
|
||||||
|
disabled={disabled || picking}
|
||||||
|
title="添加附件"
|
||||||
|
className="flex size-7 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<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-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"
|
||||||
|
>
|
||||||
|
<LuGauge size={13} className={compacting ? 'animate-pulse' : ''} />
|
||||||
|
{stats?.context?.percent != null ? formatPercent(stats.context.percent) : '上下文'}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* 模型选择 */}
|
{/* 模型选择 */}
|
||||||
<button
|
<button
|
||||||
ref={modelBtnRef}
|
ref={modelBtnRef}
|
||||||
@@ -770,7 +922,7 @@ export function Composer({
|
|||||||
aria-label="Choose model"
|
aria-label="Choose model"
|
||||||
onClick={openModelMenu}
|
onClick={openModelMenu}
|
||||||
disabled={disabled}
|
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"
|
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="max-w-[160px] truncate">{model?.name ?? '选择模型'}</span>
|
||||||
<span className="text-ink-3">
|
<span className="text-ink-3">
|
||||||
@@ -780,6 +932,24 @@ export function Composer({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
ref={permBtnRef}
|
ref={permBtnRef}
|
||||||
@@ -788,8 +958,8 @@ export function Composer({
|
|||||||
aria-label={`权限模式:${permMeta.label}`}
|
aria-label={`权限模式:${permMeta.label}`}
|
||||||
onClick={togglePermMenu}
|
onClick={togglePermMenu}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title={`权限模式:${permMeta.label}(${permMeta.desc})`}
|
title={`权限模式:${permMeta.label}`}
|
||||||
className={`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 ${
|
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||||
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:text-ink-2'
|
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:text-ink-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -802,7 +972,7 @@ export function Composer({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="停止生成"
|
aria-label="停止生成"
|
||||||
onClick={onStop}
|
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]"
|
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">
|
<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" />
|
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||||
@@ -814,7 +984,7 @@ export function Composer({
|
|||||||
aria-label="Send"
|
aria-label="Send"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={!canSend}
|
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"
|
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)' }}
|
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">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
@@ -824,6 +994,7 @@ export function Composer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 命令 / 技能 / 文件 菜单(portal 覆盖层,向上弹出) */}
|
{/* 命令 / 技能 / 文件 菜单(portal 覆盖层,向上弹出) */}
|
||||||
{menuOpen && menuPos && (
|
{menuOpen && menuPos && (
|
||||||
@@ -913,41 +1084,95 @@ export function Composer({
|
|||||||
</ComposerMenu>
|
</ComposerMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 模型选择菜单(portal,向上弹出) */}
|
{/* 思考等级菜单(portal,向上弹出,右缘对齐) */}
|
||||||
{modelOpen && modelPos && (
|
{thinkingOpen && thinkingPos && (
|
||||||
<ComposerMenu anchor={modelPos} align="right" onClose={() => setModelOpen(false)}>
|
<ComposerMenu anchor={thinkingPos} align="right" onClose={() => setThinkingOpen(false)}>
|
||||||
<div className="w-72">
|
<div className="w-72 p-1.5">
|
||||||
<div className="max-h-80 overflow-y-auto p-1.5">
|
{[...thinkingLevels]
|
||||||
{catalogLoading && <div className="px-2.5 py-3 text-[12px] text-ink-3">加载模型中…</div>}
|
.sort((a, b) => THINKING_LEVEL_ORDER.indexOf(a) - THINKING_LEVEL_ORDER.indexOf(b))
|
||||||
{!catalogLoading && providers.length === 0 && (
|
.map((level) => {
|
||||||
<div className="px-2.5 py-3 text-[12px] text-ink-3">没有可用模型,请先在设置中配置密钥</div>
|
const meta = THINKING_LEVEL_LABELS[level] ?? { label: level, desc: '' }
|
||||||
)}
|
const active = level === thinkingLevel
|
||||||
{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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${provider.id}/${m.id}`}
|
key={level}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSetModel(provider.id, m.id)
|
onSetThinkingLevel(level)
|
||||||
setModelOpen(false)
|
setThinkingOpen(false)
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors hover:bg-hover"
|
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="min-w-0 flex-1 truncate text-[12.5px] text-ink">{m.name}</span>
|
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center text-ink-2">
|
||||||
{selected && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
|
<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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</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-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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -962,6 +1187,50 @@ export function Composer({
|
|||||||
</div>
|
</div>
|
||||||
</ComposerMenu>
|
</ComposerMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 悬浮模型列表:悬浮服务商行时出现在其左侧(portal 到 body,
|
||||||
|
ComposerMenu 的 overflow-hidden 会裁剪子元素) */}
|
||||||
|
{modelOpen &&
|
||||||
|
hoveredProvider &&
|
||||||
|
(() => {
|
||||||
|
const p = providers.find((x) => x.id === hoveredProvider.id)
|
||||||
|
if (!p) return null
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
data-model-flyout
|
||||||
|
className="fixed z-40 w-64 overflow-hidden rounded-card border border-line bg-surface shadow-overlay"
|
||||||
|
style={{
|
||||||
|
right: window.innerWidth - hoveredProvider.rect.left + 8,
|
||||||
|
bottom: window.innerHeight - hoveredProvider.rect.bottom,
|
||||||
|
maxHeight: 336
|
||||||
|
}}
|
||||||
|
onMouseEnter={keepProviderFlyout}
|
||||||
|
onMouseLeave={leaveProvider}
|
||||||
|
>
|
||||||
|
<div className="max-h-[330px] overflow-y-auto p-1.5">
|
||||||
|
{p.models.map((m) => {
|
||||||
|
const selected = model?.provider === p.id && model?.id === m.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${p.id}/${m.id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSetModel(p.id, m.id)
|
||||||
|
setModelOpen(false)
|
||||||
|
setHoveredProvider(null)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors hover:bg-hover"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink">{m.name}</span>
|
||||||
|
{selected && <LuCheck size={13} className="shrink-0 text-accent-ink" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,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 ReactMarkdown, { type Components } from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import remarkCjkFriendly from 'remark-cjk-friendly'
|
||||||
|
import remarkCjkFriendlyGfmStrikethrough from 'remark-cjk-friendly-gfm-strikethrough'
|
||||||
import {
|
import {
|
||||||
useLLMOutput,
|
useLLMOutput,
|
||||||
type BlockMatch,
|
type BlockMatch,
|
||||||
@@ -21,6 +23,7 @@ import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
|
|||||||
import { bundledLanguages, type BundledLanguage } from 'shiki/langs'
|
import { bundledLanguages, type BundledLanguage } from 'shiki/langs'
|
||||||
import { bundledThemes } from 'shiki/themes'
|
import { bundledThemes } from 'shiki/themes'
|
||||||
import { CodeBlock, type ShikiLine } from './CodeBlock'
|
import { CodeBlock, type ShikiLine } from './CodeBlock'
|
||||||
|
import { useDarkTheme } from '../lib/theme'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdown 渲染:基于 llm-ui(github.com/richardgill/llm-ui)做块级解析 ——
|
* Markdown 渲染:基于 llm-ui(github.com/richardgill/llm-ui)做块级解析 ——
|
||||||
@@ -30,7 +33,8 @@ import { CodeBlock, type ShikiLine } from './CodeBlock'
|
|||||||
* 代码块用 Shiki 做真实语法高亮(按需加载语言),保留本项目 CodeBlock 的行号/复制/diff 视图。
|
* 代码块用 Shiki 做真实语法高亮(按需加载语言),保留本项目 CodeBlock 的行号/复制/diff 视图。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Shiki 高亮器:github-light 主题 + 常用语言(grammar 在运行时按需动态加载) */
|
/** Shiki 高亮器:双主题注册(浅色/深色随 .dark 切换,切换时按块重新取词元),
|
||||||
|
* grammar 在运行时按需动态加载 */
|
||||||
const HIGHLIGHT_LANGS = [
|
const HIGHLIGHT_LANGS = [
|
||||||
'typescript',
|
'typescript',
|
||||||
'javascript',
|
'javascript',
|
||||||
@@ -61,7 +65,7 @@ const HIGHLIGHT_LANGS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const highlighterPromise: Promise<HighlighterCore> = createHighlighterCore({
|
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]),
|
langs: HIGHLIGHT_LANGS.map((name) => bundledLanguages[name as BundledLanguage]),
|
||||||
engine: createOnigurumaEngine(import('shiki/wasm'))
|
engine: createOnigurumaEngine(import('shiki/wasm'))
|
||||||
})
|
})
|
||||||
@@ -94,43 +98,111 @@ const LANG_ALIAS: Record<string, string> = {
|
|||||||
plaintext: ''
|
plaintext: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 行内处理:反引号代码 span 内不动;连续 2+ 个 ~ 保留(删除线标记),单个 ~ 转义为字面量 */
|
||||||
|
function escapeSingleTildeInline(line: string): string {
|
||||||
|
let out = ''
|
||||||
|
let i = 0
|
||||||
|
while (i < line.length) {
|
||||||
|
const ch = line[i]
|
||||||
|
if (ch === '`' || ch === '~') {
|
||||||
|
let j = i
|
||||||
|
while (j < line.length && line[j] === ch) j++
|
||||||
|
const run = line.slice(i, j)
|
||||||
|
if (ch === '`') {
|
||||||
|
out += run
|
||||||
|
const rest = line.slice(j)
|
||||||
|
const close = rest.indexOf(run)
|
||||||
|
if (close >= 0) {
|
||||||
|
out += rest.slice(0, close + run.length)
|
||||||
|
i = j + close + run.length
|
||||||
|
} else {
|
||||||
|
out += rest
|
||||||
|
i = line.length
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out += run.length >= 2 ? run : '\\~'
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out += ch
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个 ~ 在 GFM 里会误触发删除线——中文常用 ~ 表区间(如「6强~7」「周~月」),
|
||||||
|
* 同段里出现两个就会被连成一条删除线。约定:只有成对的 ~~ 才是删除线,
|
||||||
|
* 不成对的单 ~ 转义为字面量;围栏代码块与行内代码内一律不动。 */
|
||||||
|
function neutralizeSingleTilde(text: string): string {
|
||||||
|
if (!text.includes('~')) return text
|
||||||
|
const lines = text.split('\n')
|
||||||
|
let fence: string | null = null
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
if (fence) {
|
||||||
|
const close = line.match(/^\s{0,3}(`{3,}|~{3,})\s*$/)
|
||||||
|
if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const open = line.match(/^\s{0,3}(`{3,}|~{3,})/)
|
||||||
|
if (open) {
|
||||||
|
fence = open[1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines[i] = escapeSingleTildeInline(line)
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
const mdComponents: Components = {
|
const mdComponents: Components = {
|
||||||
p({ children }) {
|
p({ children }) {
|
||||||
return <p className="leading-normal">{children}</p>
|
return <p className="my-4 leading-normal">{children}</p>
|
||||||
},
|
},
|
||||||
|
/** h1/h2 带强调条(对齐参考渲染),h3 起按层级递减,保证标题有可辨识的层级 */
|
||||||
h1({ children }) {
|
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 }) {
|
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 }) {
|
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 }) {
|
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>,
|
h5: ({ children }) => <h6 className="mb-1.5 mt-4 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>,
|
h6: ({ children }) => <h6 className="mb-1.5 mt-4 text-[13.5px] font-semibold text-ink-2">{children}</h6>,
|
||||||
ul({ children }) {
|
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 }) {
|
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 }) {
|
li({ children }) {
|
||||||
return <li className="leading-normal">{children}</li>
|
return <li className="leading-normal">{children}</li>
|
||||||
},
|
},
|
||||||
blockquote({ children }) {
|
blockquote({ children }) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
table({ children }) {
|
table({ children }) {
|
||||||
return (
|
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>
|
<table className="w-full border-collapse text-[13.5px]">{children}</table>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -169,7 +241,7 @@ const mdComponents: Components = {
|
|||||||
},
|
},
|
||||||
pre({ children }) {
|
pre({ children }) {
|
||||||
return (
|
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 }) {
|
a({ children, href }) {
|
||||||
@@ -191,16 +263,22 @@ const mdComponents: Components = {
|
|||||||
return <del className="text-ink-3">{children}</del>
|
return <del className="text-ink-3">{children}</del>
|
||||||
},
|
},
|
||||||
img({ src, alt }) {
|
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() {
|
hr() {
|
||||||
return <hr className="my-3 border-line" />
|
return <hr className="my-4 border-line" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkdownComponent: LLMOutputComponent = ({ blockMatch }) => (
|
const MarkdownComponent: LLMOutputComponent = ({ blockMatch }) => (
|
||||||
<div className="space-y-5 text-base text-ink">
|
<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}
|
{blockMatch.output}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,6 +290,9 @@ const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => {
|
|||||||
const source = code ?? ''
|
const source = code ?? ''
|
||||||
const rawLang = (language ?? '').toLowerCase()
|
const rawLang = (language ?? '').toLowerCase()
|
||||||
const shikiLang = LANG_ALIAS[rawLang] ?? (HIGHLIGHT_LANGS.includes(rawLang) ? rawLang : '')
|
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)
|
const [tokens, setTokens] = useState<ShikiLine[] | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -224,7 +305,7 @@ const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => {
|
|||||||
.then((highlighter) => {
|
.then((highlighter) => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
try {
|
try {
|
||||||
const result = highlighter.codeToTokens(source, { lang: shikiLang, theme: 'github-light' })
|
const result = highlighter.codeToTokens(source, { lang: shikiLang, theme: shikiTheme })
|
||||||
setTokens(result.tokens)
|
setTokens(result.tokens)
|
||||||
} catch {
|
} catch {
|
||||||
setTokens(null)
|
setTokens(null)
|
||||||
@@ -234,7 +315,7 @@ const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false
|
alive = false
|
||||||
}
|
}
|
||||||
}, [source, shikiLang])
|
}, [source, shikiLang, shikiTheme])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-2.5">
|
<div className="my-2.5">
|
||||||
@@ -261,11 +342,12 @@ function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode {
|
|||||||
return <Component blockMatch={blockMatch} />
|
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),
|
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
|
||||||
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
|
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
|
||||||
const { blockMatches } = useLLMOutput({
|
const { blockMatches } = useLLMOutput({
|
||||||
llmOutput: text,
|
llmOutput: safeText,
|
||||||
isStreamFinished: !streaming,
|
isStreamFinished: !streaming,
|
||||||
fallbackBlock: markdownFallback,
|
fallbackBlock: markdownFallback,
|
||||||
blocks: [codeBlock]
|
blocks: [codeBlock]
|
||||||
@@ -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 type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
|
||||||
|
import { animateCollapse, animateExpand } from '../lib/anim'
|
||||||
import { Markdown } from './Markdown'
|
import { Markdown } from './Markdown'
|
||||||
import { Loading } from './Loading'
|
import { Loading } from './Loading'
|
||||||
import { StreamingText } from './StreamingText'
|
import { StreamingText } from './StreamingText'
|
||||||
import { Thinking, thinkingRows } from './Thinking'
|
import { Thinking, thinkingRows } from './Thinking'
|
||||||
import { ToolChips, type ToolCallView } from './ToolChips'
|
import { ToolChips, type ToolCallView } from './ToolChips'
|
||||||
import { useEntrance } from '../hooks/useEntrance'
|
import { useEntrance } from '../hooks/useEntrance'
|
||||||
|
import { LuFoldVertical, LuGitFork, LuHistory, LuLoaderCircle, LuTriangleAlert, LuUndo2 } from 'react-icons/lu'
|
||||||
|
import { formatTokens } from '../lib/utils'
|
||||||
|
|
||||||
interface ContentBlock {
|
interface ContentBlock {
|
||||||
type?: string
|
type?: string
|
||||||
@@ -28,7 +31,42 @@ function readBlocks(raw: unknown): ContentBlock[] {
|
|||||||
return []
|
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 {
|
function useWorkingSeconds(working: boolean): string {
|
||||||
const startRef = useRef<number | null>(null)
|
const startRef = useRef<number | null>(null)
|
||||||
const [finalSecs, setFinalSecs] = useState<number | null>(null)
|
const [finalSecs, setFinalSecs] = useState<number | null>(null)
|
||||||
@@ -42,6 +80,10 @@ function useWorkingSeconds(working: boolean): string {
|
|||||||
}, [working])
|
}, [working])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (working && finalSecs !== null) {
|
||||||
|
setFinalSecs(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!working && startRef.current !== null && finalSecs === null) {
|
if (!working && startRef.current !== null && finalSecs === null) {
|
||||||
setFinalSecs(Math.max(1, Math.round((Date.now() - startRef.current) / 1000)))
|
setFinalSecs(Math.max(1, Math.round((Date.now() - startRef.current) / 1000)))
|
||||||
}
|
}
|
||||||
@@ -59,152 +101,314 @@ function useWorkingSeconds(working: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。
|
/** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。
|
||||||
* 划分规则:最后一轮的正文 = 最终答案(始终可见);这轮里的其余一切 ——
|
* 划分规则:回合结束(turnActive=false,agent_settled 之后)后,最后一轮的正文
|
||||||
* 思考、工具行、以及干活过程中模型穿插写的叙述性正文 —— 全部归入「过程」,
|
* = 最终答案(始终可见);这轮里的其余一切 —— 思考、工具行、以及干活过程中模型
|
||||||
* 样式与普通正文一致,按时序穿插在思考/工具行之间,折叠时随过程一起收起。
|
* 穿插写的叙述性正文 —— 全部归入「过程」,样式与普通正文一致,按时序穿插在
|
||||||
* 流式中:正在流的消息就是答案的雏形,思考/正文按真实到达顺序分段渲染;
|
* 思考/工具行之间,折叠时随过程一起收起。
|
||||||
* 它结束、下一轮开始后即「沉」入过程区。 */
|
* 回合进行中不做答案/过程划分(包括工具执行与自动压缩的静默间隙——pi 会在一个
|
||||||
export function AssistantTurn({
|
* 回合内发出多次 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,
|
messages,
|
||||||
tools,
|
tools,
|
||||||
streaming,
|
streaming,
|
||||||
pendingApprovals
|
turnActive,
|
||||||
|
pendingApprovals,
|
||||||
|
scrollPinnedRef,
|
||||||
|
forking = false,
|
||||||
|
onFork,
|
||||||
|
forkEntryId,
|
||||||
|
onForkAt
|
||||||
}: {
|
}: {
|
||||||
messages: UiMessage[]
|
messages: UiMessage[]
|
||||||
tools: Record<string, ToolState>
|
tools: Record<string, ToolState>
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
|
turnActive: boolean
|
||||||
pendingApprovals: ApprovalRequest[]
|
pendingApprovals: ApprovalRequest[]
|
||||||
|
/** 聊天滚动容器的吸底标记(ChatView 持有):回合结束时只在用户位于底部
|
||||||
|
* 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */
|
||||||
|
scrollPinnedRef?: { current: boolean }
|
||||||
|
/** 分叉进行中 */
|
||||||
|
forking?: boolean
|
||||||
|
/** 会话末尾分叉(只有最后一个回合透传) */
|
||||||
|
onFork?: () => void
|
||||||
|
/** 历史回合的分叉锚点:本回合之后第一条用户消息的 entryId
|
||||||
|
* (配合 onForkAt 以 'before' 分叉,新会话包含到本回合答案为止) */
|
||||||
|
forkEntryId?: string
|
||||||
|
onForkAt?: (entryId: string) => void
|
||||||
}): ReactNode {
|
}): 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 answerNodes: ReactNode[] = []
|
||||||
const labelCount = new Map<string, number>()
|
|
||||||
let calls = 0
|
let calls = 0
|
||||||
let thinking = 0
|
let thinking = 0
|
||||||
let callsAndThinking: ToolCallView[] = []
|
|
||||||
let thinkingTexts: string[] = []
|
|
||||||
let stream: UiMessage['stream'] | null = null
|
let stream: UiMessage['stream'] | null = null
|
||||||
|
let answerText = ''
|
||||||
/** 连续的思考/工具行作为一组裸行铺出(组内保留顺序),不缩进 */
|
// 消息级错误(API 失败等):回合内最后一条错误保留展示,否则错误轮会渲染成空白
|
||||||
const flushRows = (): void => {
|
let messageError = ''
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。
|
// 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。
|
||||||
// stream 不存在时,最后一条已完成消息的正文是答案。
|
// 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。
|
||||||
const streamPos = messages.findIndex((m) => m.stream)
|
const streamPos = messages.findIndex((m) => m.stream)
|
||||||
|
const settled = !turnActive
|
||||||
for (let idx = 0; idx < messages.length; idx++) {
|
for (let idx = 0; idx < messages.length; idx++) {
|
||||||
const message = messages[idx]
|
const message = messages[idx]
|
||||||
if (message.stream) {
|
if (message.stream) {
|
||||||
stream = message.stream
|
stream = message.stream
|
||||||
continue
|
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
|
const streamHasThinking = stream
|
||||||
? stream.segments.some((s) => s.kind === 'thinking' && s.text.trim().length > 0)
|
? stream.segments.some((s) => s.kind === 'thinking' && s.text.trim().length > 0)
|
||||||
: false
|
: false
|
||||||
const hasProcess = calls > 0 || thinking > 0 || streamHasThinking
|
const hasProcess = calls > 0 || thinking > 0 || streamHasThinking
|
||||||
|
|
||||||
// 折叠状态:历史回合默认收起,流式回合默认展开;结束自动收起,可手动切换
|
// 回合是否仍在进行:turnActive/streaming 在 pi 的轮次间隙(工具执行、自动压缩、
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(() => !streaming && hasProcess)
|
// 排队续跑)都会中途抖动,流式占位还在(stream 非空)也代表内容仍在到达——
|
||||||
const prevStreaming = useRef(streaming)
|
// 任一为真都按「进行中」处理,绝不能只看 turnActive(抖一下就会误折叠整个回合)
|
||||||
useEffect(() => {
|
const live = turnActive || streaming || Boolean(stream)
|
||||||
if (prevStreaming.current && !streaming) setCollapsed(true)
|
|
||||||
prevStreaming.current = streaming
|
|
||||||
}, [streaming])
|
|
||||||
|
|
||||||
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 headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
|
||||||
|
|
||||||
// 流式分段:思考段属过程(折叠时隐藏),正文段属答案;均按真实时序渲染
|
// 流式分段:思考段属过程(过程区折叠时隐藏,由头部下方的紧凑行代为展示
|
||||||
const showStreamThinking = !collapsed || !hasProcess
|
// 最新一行),正文段属答案;均按真实时序渲染。思考块保持挂载(仅 display:none),
|
||||||
|
// 折叠/展开往返不重置内部计时的滚动状态。
|
||||||
const streamNodes: ReactNode[] = []
|
const streamNodes: ReactNode[] = []
|
||||||
let visibleThinkingRows = 0
|
|
||||||
if (stream) {
|
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 (seg.kind === 'thinking') {
|
||||||
if (!showStreamThinking) return
|
|
||||||
const rows = thinkingRows(seg.text)
|
const rows = thinkingRows(seg.text)
|
||||||
if (rows.length === 0) return
|
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(
|
streamNodes.push(
|
||||||
<div key={`seg-${i}`} className="mb-3">
|
<div key={`seg-${i}`} className={collapsed && hasProcess ? 'hidden' : 'mb-3'}>
|
||||||
<Thinking rows={rows} streaming={streaming} />
|
<Thinking rows={rows} streaming={streaming && isLast && !followedByText} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (seg.text) {
|
if (seg.text) {
|
||||||
|
answerText = answerText ? `${answerText}\n\n${seg.text}` : seg.text
|
||||||
streamNodes.push(
|
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} />
|
<Markdown text={seg.text} streaming={streaming} />
|
||||||
</StreamingText>
|
</StreamingText>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 折叠时思考不可见 → 头部提示「思考中…」(仅当前正在流思考段时)
|
// 过程区折叠时的「正在思考」紧凑行:展示最新一行思考(ZCode 式),随流式刷新
|
||||||
const lastSeg = stream?.segments[stream.segments.length - 1]
|
const lastSeg = stream?.segments[stream.segments.length - 1]
|
||||||
const hiddenThinking =
|
const compactThinking =
|
||||||
collapsed && hasProcess && Boolean(stream && lastSeg?.kind === 'thinking' && lastSeg.text.trim())
|
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 (
|
return (
|
||||||
<div className="min-w-0 max-w-full">
|
<div
|
||||||
|
className="min-w-0 max-w-full"
|
||||||
|
style={{ animation: 'fade-up 400ms cubic-bezier(0.23, 1, 0.32, 1) both' }}
|
||||||
|
>
|
||||||
{hasProcess && (
|
{hasProcess && (
|
||||||
<div className="border-b border-line pb-1">
|
<div className="border-b border-line pb-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={!collapsed}
|
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"
|
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
|
<svg
|
||||||
@@ -216,57 +420,231 @@ export function AssistantTurn({
|
|||||||
strokeWidth="2.2"
|
strokeWidth="2.2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="transition-transform"
|
className="transition-transform duration-500"
|
||||||
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
|
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
|
||||||
>
|
>
|
||||||
<path d="M6 9l6 6 6-6" />
|
<path d="M6 9l6 6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">{headerLabel}</span>
|
||||||
{headerLabel}
|
|
||||||
{hiddenThinking ? ',思考中…' : ''}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 展开态:过程(思考/工具/叙述正文按时序穿插)+ 最终答案;收起态:只留最终答案 */}
|
{/* 折叠时的实时思考预览:仅展示最新一行,随流式滚动前进 */}
|
||||||
{!collapsed || !hasProcess ? (
|
{compactThinking !== null && (
|
||||||
<>
|
<div className="flex min-w-0 items-center gap-2 py-1.5">
|
||||||
{processNodes}
|
<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}
|
{answerNodes}
|
||||||
{streamNodes.length > 0 && <div>{streamNodes}</div>}
|
{streamNodes.length > 0 && <div>{streamNodes}</div>}
|
||||||
{stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && (
|
{stream && streamNodes.length === 0 && !(collapsed && hasProcess) && (
|
||||||
<Loading label="思考中" variant="Dots" />
|
<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}
|
<div className="fixed inset-0 z-20" onClick={() => setOpen(false)} />
|
||||||
{streamNodes.length > 0 && <div>{streamNodes}</div>}
|
<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>
|
</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,
|
message,
|
||||||
tools,
|
tools,
|
||||||
streaming,
|
streaming,
|
||||||
pendingApprovals = []
|
turnActive,
|
||||||
|
pendingApprovals = [],
|
||||||
|
scrollPinnedRef,
|
||||||
|
forkEntryId,
|
||||||
|
rewinding,
|
||||||
|
onRewind,
|
||||||
|
animate = true
|
||||||
}: {
|
}: {
|
||||||
message: UiMessage
|
message: UiMessage
|
||||||
tools: Record<string, ToolState>
|
tools: Record<string, ToolState>
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
|
turnActive: boolean
|
||||||
pendingApprovals?: ApprovalRequest[]
|
pendingApprovals?: ApprovalRequest[]
|
||||||
|
scrollPinnedRef?: { current: boolean }
|
||||||
|
/** 对应会话树上的回溯点(用户消息按序对齐 forkPoints) */
|
||||||
|
forkEntryId?: string
|
||||||
|
/** 分支回溯进行中(禁用回溯入口) */
|
||||||
|
rewinding?: boolean
|
||||||
|
onRewind?: (entryId: string, summarize: boolean) => void
|
||||||
|
/** 渐进填充历史时不播入场动画 */
|
||||||
|
animate?: boolean
|
||||||
}): ReactNode | null {
|
}): 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') {
|
if (message.role === 'user') {
|
||||||
const blocks = readBlocks(message.raw)
|
const blocks = readBlocks(message.raw)
|
||||||
const text = blocks.map((b) => b.text ?? '').join('') || ''
|
const text = blocks.map((b) => b.text ?? '').join('') || ''
|
||||||
return (
|
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">
|
<div className="max-w-[85%] rounded-[10px] bg-inset px-4 py-3 text-base whitespace-pre-wrap leading-normal text-ink">
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
@@ -276,8 +654,19 @@ export function MessageItem({
|
|||||||
|
|
||||||
if (message.role === 'assistant') {
|
if (message.role === 'assistant') {
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="min-w-0 max-w-full">
|
<div
|
||||||
<AssistantTurn messages={[message]} tools={tools} streaming={streaming} pendingApprovals={pendingApprovals} />
|
ref={ref}
|
||||||
|
className="min-w-0 max-w-full"
|
||||||
|
style={{ animation: 'fade-up 400ms cubic-bezier(0.23, 1, 0.32, 1) both' }}
|
||||||
|
>
|
||||||
|
<AssistantTurn
|
||||||
|
messages={[message]}
|
||||||
|
tools={tools}
|
||||||
|
streaming={streaming}
|
||||||
|
turnActive={turnActive}
|
||||||
|
pendingApprovals={pendingApprovals}
|
||||||
|
scrollPinnedRef={scrollPinnedRef}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -285,3 +674,71 @@ export function MessageItem({
|
|||||||
// 其他角色(custom / notification 等)暂不渲染
|
// 其他角色(custom / notification 等)暂不渲染
|
||||||
return null
|
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 { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
|
||||||
import { LuCircleHelp, LuScissors, LuSmile, LuSpellCheck, LuWandSparkles } from 'react-icons/lu'
|
import { LuCheck, LuCopy, LuQuote } from 'react-icons/lu'
|
||||||
|
|
||||||
/** 选区操作条(beautifului.dev Selection Actions)。
|
/** 选区操作条。
|
||||||
* 在 containerRef 内划选文本后,于选区上方浮出胶囊工具条:
|
* 在 containerRef 内划选文本后,于选区上方浮出胶囊工具条:
|
||||||
* 「描述修改」输入框 + 解释/改进,更多按钮展开 精简/语气/语法;
|
* 「引用」把选中文本注入输入框开头(blockquote 形式),「复制」写入剪贴板;
|
||||||
* 输入内容后出现发送钮。预设动作与自定义指令都经 onApply 交给上层。 */
|
* Esc / 滚动 / 点击后收起。 */
|
||||||
|
|
||||||
const PRESET_MAIN: { label: string; icon: ReactNode }[] = [
|
|
||||||
{ label: '解释', icon: <LuCircleHelp size={14} strokeWidth={1.8} /> },
|
|
||||||
{ label: '改进', icon: <LuWandSparkles size={14} strokeWidth={1.8} /> }
|
|
||||||
]
|
|
||||||
|
|
||||||
const PRESET_MORE: { label: string; icon: ReactNode }[] = [
|
|
||||||
{ label: '精简', icon: <LuScissors size={14} strokeWidth={1.8} /> },
|
|
||||||
{ label: '语气', icon: <LuSmile size={14} strokeWidth={1.8} /> },
|
|
||||||
{ label: '语法', icon: <LuSpellCheck size={14} strokeWidth={1.8} /> }
|
|
||||||
]
|
|
||||||
|
|
||||||
const pillButton =
|
const pillButton =
|
||||||
'inline-flex items-center justify-center select-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none text-ink hover:bg-hover h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1 shrink-0'
|
'inline-flex items-center justify-center select-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none text-ink hover:bg-hover h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1 shrink-0'
|
||||||
|
|
||||||
export function SelectionActions({
|
export function SelectionActions({
|
||||||
containerRef,
|
containerRef,
|
||||||
onApply
|
onQuote
|
||||||
}: {
|
}: {
|
||||||
containerRef: RefObject<HTMLElement | null>
|
containerRef: RefObject<HTMLElement | null>
|
||||||
onApply: (instruction: string, selection: string) => void
|
onQuote: (selection: string) => void
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
|
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
|
||||||
const [selection, setSelection] = useState('')
|
const [selection, setSelection] = useState('')
|
||||||
const [instruction, setInstruction] = useState('')
|
const [copied, setCopied] = useState(false)
|
||||||
const [moreOpen, setMoreOpen] = useState(false)
|
|
||||||
const pillRef = useRef<HTMLDivElement>(null)
|
const pillRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const dismiss = (): void => {
|
const dismiss = (): void => {
|
||||||
setAnchor(null)
|
setAnchor(null)
|
||||||
setSelection('')
|
setSelection('')
|
||||||
setInstruction('')
|
setCopied(false)
|
||||||
setMoreOpen(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起)
|
// 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起)
|
||||||
@@ -94,19 +81,19 @@ export function SelectionActions({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [anchor])
|
}, [anchor])
|
||||||
|
|
||||||
const apply = (label: string): void => {
|
const quote = (): void => {
|
||||||
if (!selection) return
|
if (!selection) return
|
||||||
onApply(label, selection)
|
onQuote(selection)
|
||||||
dismiss()
|
dismiss()
|
||||||
window.getSelection()?.removeAllRanges()
|
window.getSelection()?.removeAllRanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitInstruction = (): void => {
|
const copy = (): void => {
|
||||||
const text = instruction.trim()
|
if (!selection) return
|
||||||
if (!text || !selection) return
|
void navigator.clipboard.writeText(selection).then(() => {
|
||||||
onApply(`${text}(针对以下选中内容)`, selection)
|
setCopied(true)
|
||||||
dismiss()
|
window.setTimeout(dismiss, 900)
|
||||||
window.getSelection()?.removeAllRanges()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = anchor !== null
|
const visible = anchor !== null
|
||||||
@@ -122,82 +109,21 @@ export function SelectionActions({
|
|||||||
pointerEvents: visible ? undefined : 'none'
|
pointerEvents: visible ? undefined : 'none'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-9 w-fit max-w-[calc(100vw-48px)] items-center justify-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 font-sans font-normal text-ink shadow-overlay">
|
<div className="flex h-9 w-fit items-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 shadow-overlay">
|
||||||
{/* 描述修改输入框(常显,站点默认态) */}
|
<button type="button" className={pillButton} onClick={quote}>
|
||||||
<div className="flex w-fit shrink-0 items-center justify-center gap-0.5 overflow-hidden" style={{ maxWidth: 152 }}>
|
<LuQuote size={14} strokeWidth={1.8} />
|
||||||
<form
|
引用
|
||||||
className="flex h-7 shrink-0 items-center"
|
|
||||||
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>
|
</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" />
|
<span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
|
||||||
<button
|
<button type="button" className={pillButton} onClick={copy}>
|
||||||
type="button"
|
{copied ? (
|
||||||
aria-label="更多操作"
|
<LuCheck size={14} strokeWidth={2} className="text-accent-ink" />
|
||||||
aria-expanded={moreOpen}
|
) : (
|
||||||
onClick={() => setMoreOpen((v) => !v)}
|
<LuCopy size={14} strokeWidth={1.8} />
|
||||||
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]"
|
)}
|
||||||
>
|
{copied ? '已复制' : '复制'}
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 发送钮:输入内容后出现 */}
|
|
||||||
<div
|
|
||||||
className="flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-[400ms]"
|
|
||||||
style={{ maxWidth: instruction.trim() ? 28 : 0, opacity: instruction.trim() ? 1 : 0, transform: instruction.trim() ? 'scale(1)' : 'scale(0.88)', transitionTimingFunction: 'cubic-bezier(0.23,1,0.32,1)' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="发送修改指令"
|
|
||||||
onClick={submitInstruction}
|
|
||||||
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-ink text-surface transition-[opacity,transform] duration-200 active:scale-[0.94]"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" strokeWidth="2.4" fill="none" aria-hidden>
|
|
||||||
<path d="M12 21L12 3M12 3L20.5 11.5M12 3L3.5 11.5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
|||||||
|
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 => {
|
||||||
|
if (!dragRef.current) return
|
||||||
|
dragRef.current = null
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', onMove)
|
||||||
|
window.addEventListener('pointerup', onUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', onMove)
|
||||||
|
window.removeEventListener('pointerup', onUp)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}
|
||||||
|
}, [onWidthChange])
|
||||||
|
|
||||||
|
const startResize = (e: React.PointerEvent): void => {
|
||||||
|
// preventDefault 必须在 pointerdown 上:否则 Chromium 会把它当作文本选择
|
||||||
|
// 手势的锚点,拖动经过聊天区/面板文字时把沿途内容选中;拖拽期间再对
|
||||||
|
// body 全局禁选(双保险,覆盖手势未被拦截的边缘情况),松手恢复
|
||||||
|
e.preventDefault()
|
||||||
|
dragRef.current = { startX: e.clientX, startWidth: width }
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单打开时 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 {
|
import {
|
||||||
LuCheck,
|
LuCheck,
|
||||||
LuChevronDown,
|
LuChevronDown,
|
||||||
|
LuMessagesSquare,
|
||||||
LuPencil,
|
LuPencil,
|
||||||
LuPlus,
|
LuPlus,
|
||||||
LuSearch,
|
LuSearch,
|
||||||
@@ -14,7 +15,8 @@ import {
|
|||||||
import { LuPanelLeft } from 'react-icons/lu'
|
import { LuPanelLeft } from 'react-icons/lu'
|
||||||
import { Button } from '@/components/atoms/Button'
|
import { Button } from '@/components/atoms/Button'
|
||||||
import { GlideMenu } from '@/components/GlideMenu'
|
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)。
|
/** 可折叠工作区导航(beautifului.dev Sidebar Nav)。
|
||||||
* 224px ↔ 56px 宽度过渡,文案随折叠淡出;导航行与会话行共用一个
|
* 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({
|
export function SidebarNav({
|
||||||
sessionState,
|
sessionState,
|
||||||
sessions,
|
sessions,
|
||||||
@@ -89,7 +172,11 @@ export function SidebarNav({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
workspaces,
|
workspaces,
|
||||||
activeWorkspace,
|
activeWorkspace,
|
||||||
onOpenWorkspace
|
onOpenWorkspace,
|
||||||
|
plugins,
|
||||||
|
activeView,
|
||||||
|
onOpenPlugin,
|
||||||
|
chatBadge
|
||||||
}: {
|
}: {
|
||||||
sessionState: SessionState | null
|
sessionState: SessionState | null
|
||||||
sessions: SessionMeta[]
|
sessions: SessionMeta[]
|
||||||
@@ -102,6 +189,13 @@ export function SidebarNav({
|
|||||||
workspaces: WorkspaceMeta[]
|
workspaces: WorkspaceMeta[]
|
||||||
activeWorkspace?: WorkspaceMeta
|
activeWorkspace?: WorkspaceMeta
|
||||||
onOpenWorkspace: (path: string) => void
|
onOpenWorkspace: (path: string) => void
|
||||||
|
/** 侧栏插件入口(已排序:order 升序,核心入口之后) */
|
||||||
|
plugins: PluginInfo[]
|
||||||
|
/** 当前视图('chat' 或插件 id;插件入口高亮用) */
|
||||||
|
activeView: string
|
||||||
|
onOpenPlugin: (id: string) => void
|
||||||
|
/** 代理活动徽标(插件视图期间聊天列隐藏,会话区头提示 待审批>出错>流式) */
|
||||||
|
chatBadge?: { approvals: number; error: boolean; streaming: boolean }
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
try {
|
try {
|
||||||
@@ -122,6 +216,8 @@ export function SidebarNav({
|
|||||||
const [renameValue, setRenameValue] = useState('')
|
const [renameValue, setRenameValue] = useState('')
|
||||||
const [wsMenuOpen, setWsMenuOpen] = useState(false)
|
const [wsMenuOpen, setWsMenuOpen] = useState(false)
|
||||||
const [expandedWs, setExpandedWs] = useState<Set<string>>(new Set())
|
const [expandedWs, setExpandedWs] = useState<Set<string>>(new Set())
|
||||||
|
/** 折叠态会话切换浮层:锚点(rail 图标顶缘/右缘),null = 关闭 */
|
||||||
|
const [sessionSwitcher, setSessionSwitcher] = useState<{ top: number; right: number } | null>(null)
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -139,6 +235,7 @@ export function SidebarNav({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (collapsed) setWsMenuOpen(false)
|
if (collapsed) setWsMenuOpen(false)
|
||||||
|
else setSessionSwitcher(null)
|
||||||
}, [collapsed])
|
}, [collapsed])
|
||||||
|
|
||||||
useEffect(() => {
|
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]">
|
<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}>
|
<button type="button" data-row onClick={onNewSession} className={navRowClass}>
|
||||||
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2">
|
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2">
|
||||||
@@ -336,11 +433,33 @@ export function SidebarNav({
|
|||||||
</span>
|
</span>
|
||||||
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">搜索</span>
|
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">搜索</span>
|
||||||
</button>
|
</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>
|
</GlideMenu>
|
||||||
|
|
||||||
{/* 会话列表区 */}
|
{/* 会话列表区:折叠态 margin 收敛为 1px,让「会话」切换图标与上方
|
||||||
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
|
导航图标保持同一垂直节奏(行高 32 + 间隙 1px 等距) */}
|
||||||
<div className="sidebar-copy relative mx-2 mb-1 h-8">
|
<div className={`min-h-0 flex-1 overflow-y-auto ${collapsed ? 'mt-px' : 'mt-3'}`}>
|
||||||
|
<div className="relative mx-2 mb-1 h-8">
|
||||||
|
<div className="sidebar-copy absolute inset-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setChatsOpen((v) => !v)}
|
onClick={() => setChatsOpen((v) => !v)}
|
||||||
@@ -353,6 +472,25 @@ export function SidebarNav({
|
|||||||
className={`shrink-0 transition-transform duration-150 ${chatsOpen ? '' : '-rotate-90'}`}
|
className={`shrink-0 transition-transform duration-150 ${chatsOpen ? '' : '-rotate-90'}`}
|
||||||
/>
|
/>
|
||||||
<span>会话</span>
|
<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>
|
||||||
|
|
||||||
{/* 内联搜索:放大镜 ↔ 输入框宽度过渡 */}
|
{/* 内联搜索:放大镜 ↔ 输入框宽度过渡 */}
|
||||||
@@ -401,7 +539,24 @@ export function SidebarNav({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chatsOpen && (
|
{/* 折叠态:会话切换入口(类浏览器标签切换,仅切换)。
|
||||||
|
收窄到 rail 可见的 36px(与 nav 图标钮同宽),否则图标会被 aside 裁剪 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-session-toggle
|
||||||
|
aria-label="切换会话"
|
||||||
|
title="切换会话"
|
||||||
|
onClick={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
setSessionSwitcher((v) => (v ? null : { top: rect.top, right: rect.right }))
|
||||||
|
}}
|
||||||
|
className="sidebar-collapsed-only absolute left-0 top-0 h-8 w-9 items-center justify-center rounded-[8px] text-ink-2 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96] [-webkit-app-region:no-drag]"
|
||||||
|
>
|
||||||
|
<LuMessagesSquare size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chatsOpen && !collapsed && (
|
||||||
<GlideMenu highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
|
<GlideMenu highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2" rowSelector="[data-row]">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="sidebar-copy mx-2 mb-1 rounded-[8px] border border-dashed border-line px-3 py-4 text-center text-[12px] leading-5 text-ink-3">
|
<div className="sidebar-copy mx-2 mb-1 rounded-[8px] border border-dashed border-line px-3 py-4 text-center text-[12px] leading-5 text-ink-3">
|
||||||
@@ -416,12 +571,23 @@ export function SidebarNav({
|
|||||||
data-row
|
data-row
|
||||||
title={sessionTitle(s)}
|
title={sessionTitle(s)}
|
||||||
onClick={() => onOpenSession(s.path)}
|
onClick={() => onOpenSession(s.path)}
|
||||||
className={`${navRowClass} group/row cursor-pointer ${active ? 'text-ink' : 'text-ink-2'}`}
|
className={`${navRowClass} group/row cursor-pointer ${
|
||||||
|
active ? 'bg-hover-2 text-ink' : 'text-ink-2'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px */}
|
{/* 官方形态:纯文本单行,收起时随 sidebar-copy 淡出,行宽收为 36px。
|
||||||
<span className="sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium">{sessionTitle(s)}</span>
|
激活行除灰底外再用字重区分(激活加粗 / 非激活常规) */}
|
||||||
{/* 悬停浮动操作:quiet 图标钮,无底板,贴近官方 primitive-icon-button */}
|
<span
|
||||||
<span className="absolute right-0.5 hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
|
className={`sidebar-copy min-w-0 flex-1 truncate text-[14px] ${
|
||||||
|
active ? 'font-medium' : 'font-normal'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sessionTitle(s)}
|
||||||
|
</span>
|
||||||
|
{/* 悬停操作:quiet 图标钮,无底板。行内流式布局(非绝对定位):
|
||||||
|
悬停时标题自动收窄让位,按钮不越出行、不遮相邻行的文字;
|
||||||
|
收起侧栏时随 sidebar-copy 一并淡出并禁用点击 */}
|
||||||
|
<span className="sidebar-copy hidden shrink-0 items-center gap-0.5 group-hover/row:flex">
|
||||||
{active && (
|
{active && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -463,6 +629,17 @@ export function SidebarNav({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 折叠态会话切换浮层(portal 到 body) */}
|
||||||
|
{sessionSwitcher && (
|
||||||
|
<SessionSwitcherPortal
|
||||||
|
anchor={sessionSwitcher}
|
||||||
|
sessions={sessions}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onOpenSession={onOpenSession}
|
||||||
|
onClose={() => setSessionSwitcher(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 行内操作浮层:锚定触发按钮,官方 WorkspaceMenu 形态(pop-in + overlay 阴影) */}
|
{/* 行内操作浮层:锚定触发按钮,官方 WorkspaceMenu 形态(pop-in + overlay 阴影) */}
|
||||||
{actionMenu && (
|
{actionMenu && (
|
||||||
<RowActionPortal rect={actionMenu.rect} onClose={() => setActionMenu(null)}>
|
<RowActionPortal rect={actionMenu.rect} onClose={() => setActionMenu(null)}>
|
||||||
|
|||||||
@@ -1,99 +1,25 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
/** 流式回答(beautifului.dev Streaming Text)。
|
/** 流式回答容器(beautifului.dev Streaming Text)。
|
||||||
* 正文随 delta 增长并带竖线光标;流式结束后光标消失、
|
* 正文随 delta 增长;光标由 CSS(globals.css 的 .streaming-body 规则)以 ::after
|
||||||
* 操作行淡入(复制);followUps 由调用方提供时逐条列出可点。
|
* 附加到最后一个文本块最后一行文字之后,流式结束后随 data-streaming 属性切换消失,
|
||||||
* 站点演示的 refresh/点赞等操作在本应用无后端,未接入。 */
|
* 不再以独立 span 追加在块级内容之后(那会让光标孤零零落在新的一行上)。
|
||||||
|
* 复制操作由 AssistantTurn 统一常驻在结论底部——本组件不再自带操作行:
|
||||||
|
* 以前每个正文段各挂一个、随回合内 streaming 抖动反复出现/消失(闪现)。 */
|
||||||
export function StreamingText({
|
export function StreamingText({
|
||||||
children,
|
children,
|
||||||
streaming,
|
streaming
|
||||||
text,
|
|
||||||
followUps,
|
|
||||||
onFollowUp
|
|
||||||
}: {
|
}: {
|
||||||
/** 正文内容(通常为 <Markdown/>) */
|
/** 正文内容(通常为 <Markdown/>) */
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
/** 已渲染的纯文本,用于复制;为空时不显示操作行 */
|
|
||||||
text?: string
|
|
||||||
followUps?: string[]
|
|
||||||
onFollowUp?: (text: string) => void
|
|
||||||
}): ReactNode {
|
}): 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 (
|
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
|
<div
|
||||||
className="mt-2 flex items-center gap-0.5 transition-opacity duration-[400ms]"
|
className="streaming-body min-w-0 text-base leading-normal text-ink"
|
||||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? undefined : 'none' }}
|
data-streaming={streaming ? 'true' : 'false'}
|
||||||
>
|
>
|
||||||
<button
|
{children}
|
||||||
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { useLayoutEffect, useRef, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
/** 可展开的思考轨迹(beautifului.dev Thinking · Reasoning 形态)。
|
/** 可展开的思考轨迹。
|
||||||
* 流式期间自动展开并 shimmer「思考中…」;结束后收起为「思考过程」,
|
* 默认收起:表头展示「状态词 + 开头第一行内容」式预览,点击展开完整轨迹,
|
||||||
* 点击表头可手动展开/收起。rows 由真实思考文本逐行喂入。 */
|
* 再点收起;流式中表头标签保持 shimmer。
|
||||||
|
* 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填):
|
||||||
|
* 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时
|
||||||
|
* 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明
|
||||||
|
* 再逐个浮现),视觉上等于整段思考收起又重新展开,每个工具轮都会重演;
|
||||||
|
* 历史轨迹默认随过程区收起,看不到行入场,交错动画没有保留价值。
|
||||||
|
* 不限高:过程区在回合结束后整体折叠,内部限高只会在轮次交接时造成
|
||||||
|
* 面板可见缩短。rows 由真实思考文本逐行喂入。 */
|
||||||
export function Thinking({
|
export function Thinking({
|
||||||
rows,
|
rows,
|
||||||
streaming,
|
streaming,
|
||||||
activeLabel = '思考中…',
|
activeLabel = '正在思考',
|
||||||
doneLabel = '思考过程'
|
doneLabel = '思考过程'
|
||||||
}: {
|
}: {
|
||||||
rows: { primary: string }[]
|
rows: { primary: string }[]
|
||||||
@@ -15,29 +22,22 @@ export function Thinking({
|
|||||||
activeLabel?: string
|
activeLabel?: string
|
||||||
doneLabel?: string
|
doneLabel?: string
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
|
// 默认收起(不想默认展开思考内容);用户点击后以手动状态为准
|
||||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
|
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
|
||||||
const expanded = manualExpanded ?? streaming
|
const expanded = manualExpanded ?? false
|
||||||
|
|
||||||
const traceRef = useRef<HTMLDivElement>(null)
|
const firstLine = rows.length > 0 ? rows[0].primary : ''
|
||||||
const [lineHeight, setLineHeight] = useState(0)
|
const statusWord = streaming ? activeLabel : doneLabel
|
||||||
useLayoutEffect(() => {
|
const suffix = expanded ? '' : firstLine
|
||||||
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight)
|
|
||||||
}, [rows, expanded])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex w-full flex-col">
|
||||||
className="flex w-full max-w-95 flex-col"
|
{/* 表头:状态词 +(收起时)首行预览 + 旋转箭头 */}
|
||||||
style={{
|
|
||||||
minHeight: expanded ? Math.min(60 + rows.length * 28, 220) : undefined,
|
|
||||||
transition: 'min-height 400ms ease'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 表头:状态词 + 旋转箭头 */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
onClick={() => setManualExpanded((current) => !(current ?? streaming))}
|
onClick={() => setManualExpanded((current) => !(current ?? false))}
|
||||||
className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1 transition-colors hover:bg-hover-2"
|
className="-mx-1.5 flex w-fit min-w-0 max-w-full items-center gap-2 rounded-control px-1.5 py-1 transition-colors hover:bg-hover-2"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={streaming ? 'var(--ink-2)' : 'var(--ink-3)'}>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill={streaming ? 'var(--ink-2)' : 'var(--ink-3)'}>
|
||||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
|
<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">
|
<span role="status" className="contents">
|
||||||
{streaming ? (
|
{streaming ? (
|
||||||
<span
|
<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={{
|
style={{
|
||||||
backgroundImage: 'linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)',
|
backgroundImage: 'linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)',
|
||||||
backgroundSize: '200% 100%',
|
backgroundSize: '200% 100%',
|
||||||
animation: 'shimmer-text 1.4s linear infinite'
|
animation: 'shimmer-text 1.4s linear infinite'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeLabel}
|
{statusWord}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<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' }}
|
style={{ animation: 'fade-in 350ms ease-out both' }}
|
||||||
>
|
>
|
||||||
{doneLabel}
|
{statusWord}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{suffix && (
|
||||||
|
<span className="min-w-0 truncate text-[12.5px] text-ink-3">· {suffix}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
width="14"
|
width="14"
|
||||||
@@ -72,14 +75,14 @@ export function Thinking({
|
|||||||
strokeWidth="2.2"
|
strokeWidth="2.2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="transition-transform"
|
className="shrink-0 transition-transform"
|
||||||
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0)' }}
|
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0)' }}
|
||||||
>
|
>
|
||||||
<path d="M6 9l6 6 6-6" />
|
<path d="M6 9l6 6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 展开轨迹:左侧 hairline + 逐行 fade-up */}
|
{/* 展开轨迹:不限高完整展示全部思考(收起由外层过程区承担) */}
|
||||||
<div
|
<div
|
||||||
className="grid transition-[grid-template-rows,opacity]"
|
className="grid transition-[grid-template-rows,opacity]"
|
||||||
style={{
|
style={{
|
||||||
@@ -88,21 +91,20 @@ export function Thinking({
|
|||||||
transitionTimingFunction: 'ease'
|
transitionTimingFunction: 'ease'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="min-h-0 overflow-hidden">
|
||||||
<div className="relative mt-1 ml-[5px] pl-4">
|
<div className="mt-1 ml-[5px] pl-4">
|
||||||
<span
|
<div className="relative">
|
||||||
aria-hidden
|
<span aria-hidden className="absolute bottom-0 left-[3px] top-0 w-px bg-line" />
|
||||||
className="absolute left-[3px] w-px bg-line"
|
<div className="flex flex-col gap-1 py-1">
|
||||||
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) => (
|
{rows.map((row, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${i}-${row.primary.slice(0, 12)}`}
|
key={i}
|
||||||
className="flex min-h-7 w-full items-start gap-2 rounded-control px-1.5 py-0.5 text-left"
|
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` }}
|
style={{ animation: 'fade-up 250ms ease' }}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 text-[12.5px] leading-relaxed break-words text-ink-2">{row.primary}</span>
|
<span className="min-w-0 text-[12.5px] leading-relaxed break-words text-ink-2">
|
||||||
|
{row.primary}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -110,14 +112,15 @@ export function Thinking({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 思考文本 → 行数组(空行剔除,超长行截断) */
|
/** 思考文本 → 行数组(空行剔除,内容不截断;过长由容器的滚动条承担) */
|
||||||
export function thinkingRows(text: string): { primary: string }[] {
|
export function thinkingRows(text: string): { primary: string }[] {
|
||||||
return text
|
return text
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((l) => l.trim())
|
.map((l) => l.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((line) => ({ primary: line.length > 300 ? `${line.slice(0, 300)}…` : line }))
|
.map((line) => ({ primary: line }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,49 @@
|
|||||||
import { useEffect, useState, type JSX } from 'react'
|
import { useEffect, useState, type JSX } from 'react'
|
||||||
|
import { LuPanelRight } from 'react-icons/lu'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
|
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
|
||||||
* 整条是拖拽区;左上角展示当前会话名称(无名称时显示「新会话」);
|
* 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」);
|
||||||
* 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
|
* 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
|
||||||
* 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
* 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
||||||
|
* (插件悬浮状态胶囊不再挂这里:改悬浮于 main 区域右上角。)
|
||||||
*/
|
*/
|
||||||
export function TitleBar({ title }: { title?: string }): JSX.Element {
|
export function TitleBar({
|
||||||
|
title,
|
||||||
|
panelOpen,
|
||||||
|
onTogglePanel
|
||||||
|
}: {
|
||||||
|
title?: string
|
||||||
|
panelOpen: boolean
|
||||||
|
onTogglePanel: () => void
|
||||||
|
}): JSX.Element {
|
||||||
const name = title?.trim()
|
const name = title?.trim()
|
||||||
const [maximized, setMaximized] = useState(false)
|
const [maximized, setMaximized] = useState(false)
|
||||||
|
|
||||||
useEffect(() => window.api.onWindowState((maximized) => setMaximized(maximized)), [])
|
useEffect(() => window.api.onWindowState((maximized) => setMaximized(maximized)), [])
|
||||||
|
|
||||||
return (
|
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="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-zinc-800">{name || '新会话'}</div>
|
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div>
|
||||||
<div className="ml-auto flex h-full shrink-0 [-webkit-app-region:no-drag]">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="最小化"
|
aria-label="最小化"
|
||||||
onClick={() => void window.api.minimize()}
|
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>
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
<path d="M0 5h10" stroke="currentColor" strokeWidth="1" />
|
<path d="M0 5h10" stroke="currentColor" strokeWidth="1" />
|
||||||
@@ -30,7 +53,7 @@ export function TitleBar({ title }: { title?: string }): JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={maximized ? '还原' : '最大化'}
|
aria-label={maximized ? '还原' : '最大化'}
|
||||||
onClick={() => void window.api.toggleMaximize()}
|
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 ? (
|
{maximized ? (
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
@@ -46,7 +69,7 @@ export function TitleBar({ title }: { title?: string }): JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
onClick={() => void window.api.close()}
|
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>
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
<path d="M0 0l10 10M10 0L0 10" stroke="currentColor" strokeWidth="1" />
|
<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 } {
|
function toolMeta(name: string): { icon: keyof typeof STEP_ICONS; label: string } {
|
||||||
if (/^read|get|list|查|读|搜/i.test(name)) return { icon: 'read', label: '读取' }
|
if (/^read|get|list|查|读|搜/i.test(name)) return { icon: 'read', label: '读取' }
|
||||||
if (/^write|^edit|^multiedit|写|改|删|建|核/i.test(name)) return { icon: 'write', 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 (/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: '编辑' }
|
if (/write|edit|update|create|delete|patch|save|写|改|删|建|核/i.test(name)) return { icon: 'write', label: '编辑' }
|
||||||
return { icon: 'run', label: name }
|
return { icon: 'run', label: name }
|
||||||
@@ -198,9 +198,13 @@ function buildRow(view: ToolCallView, dupIndex: number): ToolRow {
|
|||||||
)
|
)
|
||||||
const resultText = extractResultText(tool.result)
|
const resultText = extractResultText(tool.result)
|
||||||
if (resultText) {
|
if (resultText) {
|
||||||
const lines = resultText.split('\n').slice(0, 10)
|
// 完整输出,不逐行截断;过长由展开区的滚动条承担,仅在行数超限时兜底
|
||||||
for (const line of lines) detail.push({ text: line.slice(0, 160), tone: 'plain' })
|
const lines = resultText.split('\n')
|
||||||
if (resultText.split('\n').length > 10) detail.push({ text: '…(结果已截断)', tone: 'plain' })
|
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
|
const args = tool?.args ?? view.pendingApproval?.args
|
||||||
@@ -237,7 +241,7 @@ function buildThinkingRows(texts: string[]): ToolRow[] {
|
|||||||
file: '',
|
file: '',
|
||||||
dir: '',
|
dir: '',
|
||||||
chip: lines[0].length > 60 ? `${lines[0].slice(0, 60)}…` : lines[0],
|
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,
|
tool: undefined,
|
||||||
pending: false
|
pending: false
|
||||||
})
|
})
|
||||||
@@ -336,22 +340,24 @@ export function ToolChips({ calls = [], thinking = [], className = '' }: {
|
|||||||
{row.icon !== 'think' && <StatusMark tool={row.tool} pending={row.pending} />}
|
{row.icon !== 'think' && <StatusMark tool={row.tool} pending={row.pending} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 展开明细 */}
|
{/* 展开明细:完整内容不截断,过长时在滚动区里查看;收起时懒渲染不占 DOM */}
|
||||||
<div
|
<div
|
||||||
className="grid transition-[grid-template-rows,opacity]"
|
className="grid transition-[grid-template-rows,opacity]"
|
||||||
style={{ gridTemplateRows: rowOpen ? '1fr' : '0fr', opacity: rowOpen ? 1 : 0, transitionTimingFunction: 'ease' }}
|
style={{ gridTemplateRows: rowOpen ? '1fr' : '0fr', opacity: rowOpen ? 1 : 0, transitionTimingFunction: 'ease' }}
|
||||||
>
|
>
|
||||||
<div className="min-h-0 overflow-hidden">
|
<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">
|
{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) => (
|
{row.detail.map((line, lineIndex) => (
|
||||||
<span
|
<span
|
||||||
key={`${lineIndex}-${line.text.slice(0, 12)}`}
|
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']}`}
|
className={`whitespace-pre-wrap break-words text-[11.5px] leading-[1.6] ${row.icon === 'think' ? '' : 'font-mono'} ${DETONE_CLASS[line.tone ?? 'plain']}`}
|
||||||
>
|
>
|
||||||
{line.text}
|
{line.text}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,29 +4,33 @@ import { ButtonHTMLAttributes } from "react";
|
|||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/* 设计规范:矩形控件统一 5px 圆角(rounded-control),rounded-full 仅限
|
/* beautifului.dev 原版 Button:Pill 胶囊形是应用的核心按钮语言。显式对称
|
||||||
* icon 圆钮/状态点/头像。动效走全局默认 400ms + ease(@theme 注入)。 */
|
* padding(不锁高度)保证上下留白永远相等;filled 变体带一道 1px 内高光;
|
||||||
|
* 按压回缩 0.96、150ms ease-out。 */
|
||||||
|
|
||||||
|
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
`inline-flex items-center justify-center font-medium select-none
|
`inline-flex items-center justify-center font-medium select-none
|
||||||
transition-[transform,background-color,opacity]
|
transition-[transform,background-color,opacity] duration-150 ease-out
|
||||||
active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none`,
|
active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
primary: "bg-ink text-canvas hover:opacity-90",
|
primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
|
||||||
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
|
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
|
||||||
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
|
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
|
||||||
accent: "bg-accent text-white hover:bg-accent-ink",
|
accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
|
||||||
success: "bg-green text-white hover:brightness-95",
|
success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
|
||||||
/* transparent until hovered — for dense toolbars/action rows */
|
/* transparent until hovered — for dense toolbars/action rows */
|
||||||
quiet: "text-ink hover:bg-hover",
|
quiet: "text-ink hover:bg-hover",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
/* compact toolbar control — fixed height, lighter weight */
|
/* compact toolbar pill — fixed height, lighter weight */
|
||||||
xs: "h-7 rounded-control px-2.5 text-[12px] font-normal leading-none gap-1",
|
xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
|
||||||
/* canonical action button */
|
/* canonical action pill — 27px tall, roomy sides */
|
||||||
sm: "h-[27px] px-3 text-[13px] leading-none rounded-control gap-1.5",
|
sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
|
||||||
md: "px-4 py-[9px] text-sm leading-none rounded-control gap-2",
|
md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: "secondary", size: "md" },
|
defaultVariants: { variant: "secondary", size: "md" },
|
||||||
|
|||||||
@@ -0,0 +1,562 @@
|
|||||||
|
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 对齐到这里的矩形。
|
||||||
|
* 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白。
|
||||||
|
* 左内缩 4px:原生视图盖住一切 DOM,必须让出侧面板左缘的拖拽把手 */}
|
||||||
|
<div className="relative min-h-0 flex-1 bg-page pl-1">
|
||||||
|
{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 { 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 }
|
export type { ApprovalRequest }
|
||||||
|
|
||||||
@@ -18,6 +30,16 @@ export interface SettledApproval {
|
|||||||
timedOut: boolean
|
timedOut: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 压缩通知(compaction_end 后展示于聊天顶部,自动消退) */
|
||||||
|
export interface CompactionNotice {
|
||||||
|
reason: 'manual' | 'threshold' | 'overflow'
|
||||||
|
aborted: boolean
|
||||||
|
summary?: string
|
||||||
|
tokensBefore?: number
|
||||||
|
errorMessage?: string
|
||||||
|
at: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface UiMessage {
|
export interface UiMessage {
|
||||||
key: string
|
key: string
|
||||||
role: string
|
role: string
|
||||||
@@ -33,6 +55,10 @@ interface AgentUiState {
|
|||||||
messages: UiMessage[]
|
messages: UiMessage[]
|
||||||
tools: Record<string, ToolState>
|
tools: Record<string, ToolState>
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
|
/** 回合是否仍在进行。与 streaming 的区别:pi 在一个回合内可能因自动压缩/重试
|
||||||
|
* 续跑而发出多次 agent_start/agent_end,streaming 会随之中途抖动;
|
||||||
|
* turnActive 只在 agent_settled(整个回合真正结束)时归位。 */
|
||||||
|
turnActive: boolean
|
||||||
queue: { steering: string[]; followUp: string[] }
|
queue: { steering: string[]; followUp: string[] }
|
||||||
error: string | null
|
error: string | null
|
||||||
sessionError: string | null
|
sessionError: string | null
|
||||||
@@ -42,17 +68,23 @@ interface AgentUiState {
|
|||||||
ready: boolean
|
ready: boolean
|
||||||
approvals: ApprovalRequest[]
|
approvals: ApprovalRequest[]
|
||||||
settledApprovals: SettledApproval[]
|
settledApprovals: SettledApproval[]
|
||||||
|
/** 正在压缩上下文(手动/阈值/溢出) */
|
||||||
|
compacting: boolean
|
||||||
|
/** 最近一次压缩的结果通知(null = 已展示/已过时) */
|
||||||
|
compactionNotice: CompactionNotice | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: 'event'; event: AgentUiEvent }
|
| { type: 'event'; event: AgentUiEvent }
|
||||||
| { type: 'optimistic-user'; key: string; text: string }
|
| { type: 'optimistic-user'; key: string; text: string }
|
||||||
| { type: 'clear-error' }
|
| { type: 'clear-error' }
|
||||||
|
| { type: 'clear-compaction-notice' }
|
||||||
|
|
||||||
const initialState: AgentUiState = {
|
const initialState: AgentUiState = {
|
||||||
messages: [],
|
messages: [],
|
||||||
tools: {},
|
tools: {},
|
||||||
streaming: false,
|
streaming: false,
|
||||||
|
turnActive: false,
|
||||||
queue: { steering: [], followUp: [] },
|
queue: { steering: [], followUp: [] },
|
||||||
error: null,
|
error: null,
|
||||||
sessionError: null,
|
sessionError: null,
|
||||||
@@ -61,7 +93,9 @@ const initialState: AgentUiState = {
|
|||||||
retry: null,
|
retry: null,
|
||||||
ready: false,
|
ready: false,
|
||||||
approvals: [],
|
approvals: [],
|
||||||
settledApprovals: []
|
settledApprovals: [],
|
||||||
|
compacting: false,
|
||||||
|
compactionNotice: null
|
||||||
}
|
}
|
||||||
|
|
||||||
let keyCounter = 0
|
let keyCounter = 0
|
||||||
@@ -77,13 +111,14 @@ function readRole(message: unknown): string {
|
|||||||
return 'unknown'
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 历史消息(会话切换/恢复时)映射为 UI 消息 */
|
/** 历史消息(会话切换/恢复时)映射为 UI 消息。
|
||||||
|
* compactionSummary / branchSummary 保留为通知行(压缩与分支摘要在时间线中可见)。 */
|
||||||
function historyToMessages(messages: unknown[]): UiMessage[] {
|
function historyToMessages(messages: unknown[]): UiMessage[] {
|
||||||
const result: UiMessage[] = []
|
const result: UiMessage[] = []
|
||||||
for (const raw of messages) {
|
for (const raw of messages) {
|
||||||
const role = readRole(raw)
|
const role = readRole(raw)
|
||||||
if (role === 'toolResult') continue
|
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 })
|
result.push({ key: nextKey('h'), role, raw })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +166,9 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
if (action.type === 'clear-error') {
|
if (action.type === 'clear-error') {
|
||||||
return { ...state, error: null }
|
return { ...state, error: null }
|
||||||
}
|
}
|
||||||
|
if (action.type === 'clear-compaction-notice') {
|
||||||
|
return { ...state, compactionNotice: null }
|
||||||
|
}
|
||||||
|
|
||||||
const event = action.event
|
const event = action.event
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -140,25 +178,34 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
return { ...state, ready: true, sessionState: event.state }
|
return { ...state, ready: true, sessionState: event.state }
|
||||||
case 'session_error':
|
case 'session_error':
|
||||||
return { ...state, sessionError: event.message }
|
return { ...state, sessionError: event.message }
|
||||||
case 'session_switched':
|
case 'session_switched': {
|
||||||
|
// 同一会话的技术性重发(初始化重放等)不得打断进行中的回合:保留回合
|
||||||
|
// 状态,仅刷新会话数据与历史。真正的切换(新会话/切换/分叉)会话 id
|
||||||
|
// 不同,走完整重置。
|
||||||
|
const sameSession = state.sessionState?.sessionId === event.state.sessionId
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
ready: true,
|
ready: true,
|
||||||
error: null,
|
error: null,
|
||||||
retry: null,
|
retry: null,
|
||||||
streaming: false,
|
streaming: sameSession ? state.streaming : false,
|
||||||
queue: { steering: [], followUp: [] },
|
turnActive: sameSession ? state.turnActive : false,
|
||||||
|
queue: sameSession ? state.queue : { steering: [], followUp: [] },
|
||||||
stats: null,
|
stats: null,
|
||||||
|
compacting: sameSession ? state.compacting : false,
|
||||||
|
compactionNotice: sameSession ? state.compactionNotice : null,
|
||||||
sessionState: event.state,
|
sessionState: event.state,
|
||||||
messages: historyToMessages(event.messages),
|
messages: historyToMessages(event.messages),
|
||||||
tools: historyToTools(event.messages)
|
tools: historyToTools(event.messages)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
case 'agent_start':
|
case 'agent_start':
|
||||||
return { ...state, streaming: true, error: null, retry: null }
|
return { ...state, streaming: true, turnActive: true, error: null, retry: null }
|
||||||
case 'agent_end':
|
case 'agent_end':
|
||||||
|
// agent_end 不代表回合结束:自动压缩/重试会以 agent_start 续跑同一回合
|
||||||
return { ...state, streaming: false }
|
return { ...state, streaming: false }
|
||||||
case 'agent_settled':
|
case 'agent_settled':
|
||||||
return { ...state, streaming: false, retry: null }
|
return { ...state, streaming: false, turnActive: false, retry: null }
|
||||||
case 'message_start': {
|
case 'message_start': {
|
||||||
// 助手消息开流式占位;用户消息在发送时已乐观插入,此处不重复
|
// 助手消息开流式占位;用户消息在发送时已乐观插入,此处不重复
|
||||||
if (event.role === 'assistant') {
|
if (event.role === 'assistant') {
|
||||||
@@ -191,13 +238,19 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
case 'message_end': {
|
case 'message_end': {
|
||||||
const role = readRole(event.message)
|
const role = readRole(event.message)
|
||||||
const messages = [...state.messages]
|
const messages = [...state.messages]
|
||||||
// 回填最近的流式占位(assistant)
|
// 回填最近的流式占位——仅限 assistant:占位只为 assistant 的 message_start
|
||||||
|
// 创建,若不校验角色,迟到/交错的 user、toolResult message_end 会把占位
|
||||||
|
// 误替换掉(内容丢失、回合分组被非 assistant 消息切断,过程区误判收起)
|
||||||
|
if (role === 'assistant') {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
if (messages[i].stream) {
|
if (messages[i].stream) {
|
||||||
messages[i] = { key: messages[i].key, role, raw: event.message }
|
messages[i] = { key: messages[i].key, role, raw: event.message }
|
||||||
return { ...state, messages }
|
return { ...state, messages }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
messages.push({ key: nextKey('a'), role: 'assistant', raw: event.message })
|
||||||
|
return { ...state, messages }
|
||||||
|
}
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
// 若最后一条是本地乐观插入的用户消息,用服务端版本回填
|
// 若最后一条是本地乐观插入的用户消息,用服务端版本回填
|
||||||
const last = messages[messages.length - 1]
|
const last = messages[messages.length - 1]
|
||||||
@@ -207,14 +260,7 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
}
|
}
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
if (role === 'toolResult') {
|
// toolResult 等其他角色:已在 ToolCard 中通过 tool_end 展示,不独立成消息
|
||||||
// 工具结果已在 ToolCard 中通过 tool_end 展示,跳过独立消息
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
if (role === 'assistant') {
|
|
||||||
messages.push({ key: nextKey('a'), role: 'assistant', raw: event.message })
|
|
||||||
return { ...state, messages }
|
|
||||||
}
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
case 'tool_start': {
|
case 'tool_start': {
|
||||||
@@ -270,6 +316,32 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
}
|
}
|
||||||
case 'stats':
|
case 'stats':
|
||||||
return { ...state, stats: event.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:
|
default:
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
@@ -279,6 +351,12 @@ export interface UseAgent {
|
|||||||
state: AgentUiState
|
state: AgentUiState
|
||||||
sessions: SessionMeta[]
|
sessions: SessionMeta[]
|
||||||
workspaces: WorkspaceMeta[]
|
workspaces: WorkspaceMeta[]
|
||||||
|
/** 回溯点(当前分支上的用户消息,按序与时间线中的用户消息一一对应) */
|
||||||
|
forkPoints: ForkPoint[]
|
||||||
|
/** 分支回溯(含分支摘要生成)进行中 */
|
||||||
|
navigating: boolean
|
||||||
|
/** 会话末尾分叉(pi fork)进行中 */
|
||||||
|
forking: boolean
|
||||||
send: (text: string, images?: SendImage[]) => void
|
send: (text: string, images?: SendImage[]) => void
|
||||||
stop: () => void
|
stop: () => void
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
@@ -292,12 +370,28 @@ export interface UseAgent {
|
|||||||
setPermissionMode: (mode: PermissionMode) => void
|
setPermissionMode: (mode: PermissionMode) => void
|
||||||
createWorkspace: (name: string) => void
|
createWorkspace: (name: string) => void
|
||||||
openWorkspace: (path: 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 {
|
export function useAgent(): UseAgent {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState)
|
const [state, dispatch] = useReducer(reducer, initialState)
|
||||||
const [sessions, setSessions] = useState<SessionMeta[]>([])
|
const [sessions, setSessions] = useState<SessionMeta[]>([])
|
||||||
const [workspaces, setWorkspaces] = useState<WorkspaceMeta[]>([])
|
const [workspaces, setWorkspaces] = useState<WorkspaceMeta[]>([])
|
||||||
|
/** 回溯点(当前分支上的用户消息,按序),随会话内容刷新 */
|
||||||
|
const [forkPoints, setForkPoints] = useState<ForkPoint[]>([])
|
||||||
|
/** 分支回溯进行中(含分支摘要生成) */
|
||||||
|
const [navigating, setNavigating] = useState(false)
|
||||||
|
/** 会话末尾分叉进行中 */
|
||||||
|
const [forking, setForking] = useState(false)
|
||||||
|
|
||||||
const refreshWorkspaces = useCallback((): void => {
|
const refreshWorkspaces = useCallback((): void => {
|
||||||
void window.api
|
void window.api
|
||||||
@@ -319,6 +413,13 @@ export function useAgent(): UseAgent {
|
|||||||
.then(setSessions)
|
.then(setSessions)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
// 回溯点与会话内容同步:切换/压缩/回溯后重取
|
||||||
|
if (event.type === 'session_switched' || event.type === 'compaction_end') {
|
||||||
|
void window.api
|
||||||
|
.listForkPoints()
|
||||||
|
.then(setForkPoints)
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [])
|
}, [])
|
||||||
@@ -435,11 +536,77 @@ export function useAgent(): UseAgent {
|
|||||||
[dispatchError, refreshSessions, refreshWorkspaces]
|
[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(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
state,
|
state,
|
||||||
sessions,
|
sessions,
|
||||||
workspaces,
|
workspaces,
|
||||||
|
forkPoints,
|
||||||
|
navigating,
|
||||||
|
forking,
|
||||||
send,
|
send,
|
||||||
stop,
|
stop,
|
||||||
clearError,
|
clearError,
|
||||||
@@ -452,12 +619,23 @@ export function useAgent(): UseAgent {
|
|||||||
resolveApproval,
|
resolveApproval,
|
||||||
setPermissionMode,
|
setPermissionMode,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
openWorkspace
|
openWorkspace,
|
||||||
|
setThinkingLevel,
|
||||||
|
compact,
|
||||||
|
abortCompaction,
|
||||||
|
setAutoCompaction,
|
||||||
|
dismissCompactionNotice,
|
||||||
|
navigateTree,
|
||||||
|
forkSession,
|
||||||
|
forkSessionAt
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
state,
|
state,
|
||||||
sessions,
|
sessions,
|
||||||
workspaces,
|
workspaces,
|
||||||
|
forkPoints,
|
||||||
|
navigating,
|
||||||
|
forking,
|
||||||
send,
|
send,
|
||||||
stop,
|
stop,
|
||||||
clearError,
|
clearError,
|
||||||
@@ -470,7 +648,15 @@ export function useAgent(): UseAgent {
|
|||||||
resolveApproval,
|
resolveApproval,
|
||||||
setPermissionMode,
|
setPermissionMode,
|
||||||
createWorkspace,
|
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 { useEffect, useRef } from 'react'
|
||||||
import type { RefObject } from 'react'
|
import type { RefObject } from 'react'
|
||||||
|
import { animateEntrance } from '../lib/anim'
|
||||||
|
|
||||||
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
|
/** 流式消息的入场动画(GSAP),仅挂载时执行一次。
|
||||||
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
|
* animate=false 供历史消息渐进填充时跳过动画——切会话时批量挂载的旧消息
|
||||||
|
* 不需要逐条淡入,几十个并发 tween 会造成可感知的卡顿。取挂载时刻的值,
|
||||||
|
* 之后 animate 变化不追溯触发。 */
|
||||||
|
export function useEntrance<T extends HTMLElement>(animate = true): RefObject<T | null> {
|
||||||
const ref = useRef<T | null>(null)
|
const ref = useRef<T | null>(null)
|
||||||
|
const animateRef = useRef(animate)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (!el) return
|
if (el && animateRef.current) animateEntrance(el)
|
||||||
let tween: { kill(): void } | null = null
|
|
||||||
let cancelled = false
|
|
||||||
void import('gsap').then((gsapModule) => {
|
|
||||||
if (cancelled) return
|
|
||||||
tween = gsapModule.gsap.fromTo(
|
|
||||||
el,
|
|
||||||
{ opacity: 0, y: 10 },
|
|
||||||
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', clearProps: 'transform' }
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
tween?.kill()
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
return ref
|
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,
|
AgentUiEvent,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AttachmentInfo,
|
AttachmentInfo,
|
||||||
|
BrowserImportResult,
|
||||||
|
BrowserSource,
|
||||||
|
BrowserUiEvent,
|
||||||
|
BrowserUiState,
|
||||||
ChatResources,
|
ChatResources,
|
||||||
CustomProviderConfig,
|
CustomProviderConfig,
|
||||||
|
ForkPoint,
|
||||||
ModelCatalog,
|
ModelCatalog,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
|
NavigateTreeOptions,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
|
PluginInfo,
|
||||||
|
PluginsUiState,
|
||||||
|
PluginViewChange,
|
||||||
ResourceDetails,
|
ResourceDetails,
|
||||||
SendImage,
|
SendImage,
|
||||||
SessionMeta,
|
SessionMeta,
|
||||||
SessionState,
|
SessionState,
|
||||||
SessionStatsInfo,
|
SessionStatsInfo,
|
||||||
|
ThinkingLevel,
|
||||||
|
ThemeMode,
|
||||||
WorkspaceFile,
|
WorkspaceFile,
|
||||||
WorkspaceMeta
|
WorkspaceMeta
|
||||||
} from '@shared/types'
|
} 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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -24,6 +48,9 @@ declare global {
|
|||||||
minimize: () => Promise<void>
|
minimize: () => Promise<void>
|
||||||
toggleMaximize: () => Promise<void>
|
toggleMaximize: () => Promise<void>
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
|
/** 界面缩放:factor 为相对倍率(如 1.1 放大 / 0.9 缩小),限制 50%–200% */
|
||||||
|
zoom: (factor: number) => void
|
||||||
|
zoomReset: () => void
|
||||||
onWindowState: (callback: (maximized: boolean) => void) => () => void
|
onWindowState: (callback: (maximized: boolean) => void) => () => void
|
||||||
send: (text: string, images?: SendImage[]) => Promise<void>
|
send: (text: string, images?: SendImage[]) => Promise<void>
|
||||||
steer: (text: string) => Promise<void>
|
steer: (text: string) => Promise<void>
|
||||||
@@ -54,11 +81,64 @@ declare global {
|
|||||||
setModel: (providerId: string, modelId: string) => Promise<void>
|
setModel: (providerId: string, modelId: string) => Promise<void>
|
||||||
setApiKey: (providerId: string, apiKey: string) => Promise<void>
|
setApiKey: (providerId: string, apiKey: string) => Promise<void>
|
||||||
logoutProvider: (providerId: 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>
|
getSettings: () => Promise<AppSettings>
|
||||||
setSystemPrompt: (text: string) => Promise<void>
|
getSystemPrompt: () => Promise<string>
|
||||||
setThemeColor: (id: string) => Promise<void>
|
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>
|
resolveApproval: (id: string, approved: boolean) => Promise<void>
|
||||||
onEvent: (callback: (event: AgentUiEvent) => void) => () => 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>
|
||||||
|
pluginsOpenDataDir: (id: string) => Promise<boolean>
|
||||||
|
pluginsClearData: (id: string) => 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 为低透明度选中底。
|
* accent-ink 为加深版(保证链接/可交互文本 AA 对比度),tint 为低透明度选中底。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { ThemeMode } from '@shared/types'
|
||||||
|
|
||||||
export interface ThemePreset {
|
export interface ThemePreset {
|
||||||
id: string
|
id: string
|
||||||
label: 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)' }
|
{ 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 {
|
export function applyThemeColor(id: string | undefined): void {
|
||||||
const preset = THEME_PRESETS.find((p) => p.id === id)
|
const preset = THEME_PRESETS.find((p) => p.id === id)
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
@@ -31,9 +41,41 @@ export function applyThemeColor(id: string | undefined): void {
|
|||||||
root.style.removeProperty('--accent')
|
root.style.removeProperty('--accent')
|
||||||
root.style.removeProperty('--accent-ink')
|
root.style.removeProperty('--accent-ink')
|
||||||
root.style.removeProperty('--accent-tint')
|
root.style.removeProperty('--accent-tint')
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
root.style.setProperty('--accent', preset.accent)
|
root.style.setProperty('--accent', preset.accent)
|
||||||
root.style.setProperty('--accent-ink', preset.ink)
|
root.style.setProperty('--accent-ink', preset.ink)
|
||||||
root.style.setProperty('--accent-tint', preset.tint)
|
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 {
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
return twMerge(clsx(inputs))
|
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 ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './styles/globals.css'
|
import './styles/globals.css'
|
||||||
|
import './lib/error-report'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<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,329 @@
|
|||||||
|
/**
|
||||||
|
* 悬浮状态胶囊(B3 槽位,复刻 ZCode「最小化药丸 ⇄ 展开面板」二态):
|
||||||
|
* - 所有插件共用一个胶囊,数据同源 plugins:uiState(api.ui.setStatusItem);
|
||||||
|
* 挂在 main 区域右上角悬浮(侧面板打开时自动左移让位,由挂载点控制)
|
||||||
|
* - 宿主为每条状态记录 updatedAt:最小化药丸展示「插件最新的消息」
|
||||||
|
* (tone 点 + 插件图标 + 文本 + 总数角标),点击展开面板
|
||||||
|
* - 展开面板:插件区域头 = 插件名(第一个区域的右侧是收回药丸的 ⤡);
|
||||||
|
* 区域内 detail 的**分组是手风琴**——默认收起只显示「标题 + 行尾徽标」,
|
||||||
|
* 点击展开 rows(ZCode 式:计划 ›);无标题分组直接平铺 rows
|
||||||
|
* - 面板高度固定,内容溢出滚动;点击面板外部 / Esc 收回最小化
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { LuChevronDown, LuChevronRight, LuMinimize2 } from 'react-icons/lu'
|
||||||
|
import type { PluginInfo, PluginStatusEntry } from '@shared/types'
|
||||||
|
import type { StatusDetailRow } from '@aso/plugin-sdk'
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收起区域记忆(跨启动) */
|
||||||
|
const SECTION_STORAGE_KEY = 'aso.floatcapsule.collapsed.v1'
|
||||||
|
|
||||||
|
function loadCollapsedSections(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(SECTION_STORAGE_KEY) ?? '[]')
|
||||||
|
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 明细行(数据驱动:图标 + 标签 + 次级说明 + 行尾彩色数值,行内可深链) */
|
||||||
|
function StatusRows({
|
||||||
|
rows,
|
||||||
|
onOpenView
|
||||||
|
}: {
|
||||||
|
rows: StatusDetailRow[]
|
||||||
|
onOpenView: (view: string, params?: unknown) => void
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{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-1.5 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-1.5">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginFloatingCapsule({
|
||||||
|
entries,
|
||||||
|
plugins,
|
||||||
|
onOpenView
|
||||||
|
}: {
|
||||||
|
entries: PluginStatusEntry[]
|
||||||
|
plugins: PluginInfo[]
|
||||||
|
onOpenView: (view: string, params?: unknown) => void
|
||||||
|
}): ReactNode {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [collapsedAreas, setCollapsedAreas] = useState<string[]>(loadCollapsedSections)
|
||||||
|
/** 分组手风琴(展开的 section 键 pluginId:itemId:idx;默认收起,会话内记忆) */
|
||||||
|
const [openSections, setOpenSections] = useState<Set<string>>(() => new Set())
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(collapsedAreas))
|
||||||
|
}, [collapsedAreas])
|
||||||
|
|
||||||
|
// 面板开着时:点击外部 / Esc 收回最小化
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onDown = (e: MouseEvent): void => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// 全部条目消失(插件停用/重载)时收回最小化
|
||||||
|
if (entries.length === 0) return null
|
||||||
|
|
||||||
|
// updatedAt 最新在前(最新消息 = 排序头)
|
||||||
|
const sorted = [...entries].sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
const latest = sorted[0]
|
||||||
|
const latestPlugin = plugins.find((p) => p.manifest.id === latest.pluginId)
|
||||||
|
|
||||||
|
// 按插件分组,组序沿用最新在前
|
||||||
|
const byPlugin = new Map<string, PluginStatusEntry[]>()
|
||||||
|
for (const entry of sorted) {
|
||||||
|
const arr = byPlugin.get(entry.pluginId) ?? []
|
||||||
|
arr.push(entry)
|
||||||
|
byPlugin.set(entry.pluginId, arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleArea = (pluginId: string): void => {
|
||||||
|
setCollapsedAreas((prev) =>
|
||||||
|
prev.includes(pluginId) ? prev.filter((id) => id !== pluginId) : [...prev, pluginId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const toggleSection = (key: string): void => {
|
||||||
|
setOpenSections((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="插件状态(点击展开)"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="flex h-7 max-w-[88px] shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface px-2.5 text-[11.5px] text-ink-2 shadow-card transition-colors hover:border-line-strong hover:text-ink"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`size-1.5 shrink-0 rounded-full ${TONE_DOT_CLASS[latest.item.tone ?? 'default'] ?? TONE_DOT_CLASS.default}`}
|
||||||
|
/>
|
||||||
|
{latestPlugin && <PluginIconValue icon={latestPlugin.manifest.icon} size={12} className="shrink-0" />}
|
||||||
|
<span className="truncate">{latest.item.text}</span>
|
||||||
|
{sorted.length > 1 && (
|
||||||
|
<span className="shrink-0 rounded-full bg-hover px-1.5 text-[10px] leading-4 text-ink-3">{sorted.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative shrink-0">
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1.5 flex w-80 flex-col overflow-hidden rounded-card border border-line bg-surface text-left shadow-overlay">
|
||||||
|
{/* 最大高度封顶,高度随内容自适应;溢出才滚动 */}
|
||||||
|
<div className="max-h-[min(480px,58vh)] overflow-y-auto overscroll-contain px-1.5 py-1.5">
|
||||||
|
{[...byPlugin.entries()].map(([pluginId, pluginEntries], areaIdx) => {
|
||||||
|
const plugin = plugins.find((p) => p.manifest.id === pluginId)
|
||||||
|
const areaCollapsed = collapsedAreas.includes(pluginId)
|
||||||
|
return (
|
||||||
|
<div key={pluginId} className={areaIdx > 0 ? 'mt-1 border-t border-line pt-1' : ''}>
|
||||||
|
{/* 区域头 = 插件名(收拢在左侧);⤡ 收起按钮贴面板最右缘(仅第一个区域) */}
|
||||||
|
<div className="flex w-full items-center gap-0.5 px-1 py-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={!areaCollapsed}
|
||||||
|
onClick={() => toggleArea(pluginId)}
|
||||||
|
className="flex min-w-0 items-center gap-1.5 rounded-control px-1.5 py-1 text-left transition-colors hover:bg-hover"
|
||||||
|
>
|
||||||
|
{plugin ? (
|
||||||
|
<PluginIconValue icon={plugin.manifest.icon} size={14} className="shrink-0 text-ink-2" />
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0 truncate text-[12.5px] font-medium text-ink">
|
||||||
|
{plugin?.manifest.name ?? pluginId}
|
||||||
|
</span>
|
||||||
|
<LuChevronDown
|
||||||
|
size={12}
|
||||||
|
aria-hidden
|
||||||
|
className={`shrink-0 text-ink-3 transition-transform duration-150 ${areaCollapsed ? '-rotate-90' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{areaIdx === 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="收起到悬浮胶囊"
|
||||||
|
title="收起"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="ml-auto shrink-0 rounded-control p-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
||||||
|
>
|
||||||
|
<LuMinimize2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!areaCollapsed && (
|
||||||
|
<div className="pb-1">
|
||||||
|
{pluginEntries.map((entry) => {
|
||||||
|
const clickable = Boolean(entry.item.openView)
|
||||||
|
return (
|
||||||
|
<div key={entry.itemId}>
|
||||||
|
{entry.item.detail ? (
|
||||||
|
// 有详情:分组手风琴(默认收起,标题行 + 行尾徽标;无标题分组平铺)
|
||||||
|
entry.item.detail.sections.map((section, si) => {
|
||||||
|
if (!section.title) {
|
||||||
|
return <StatusRows key={si} rows={section.rows} onOpenView={onOpenView} />
|
||||||
|
}
|
||||||
|
const key = `${pluginId}:${entry.itemId}:${si}`
|
||||||
|
const expanded = openSections.has(key)
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => toggleSection(key)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">
|
||||||
|
{section.title}
|
||||||
|
</span>
|
||||||
|
{section.trailing && (
|
||||||
|
<span
|
||||||
|
className={`shrink-0 text-[11.5px] font-medium ${
|
||||||
|
TRAILING_TONE_CLASS[section.trailingTone ?? 'default']
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{section.trailing}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<LuChevronRight
|
||||||
|
size={12}
|
||||||
|
aria-hidden
|
||||||
|
className={`shrink-0 text-ink-3 transition-transform duration-150 ${
|
||||||
|
expanded ? 'rotate-90' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="pb-1 pl-1">
|
||||||
|
<StatusRows rows={section.rows} onOpenView={onOpenView} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
// 纯文本条目行
|
||||||
|
clickable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (entry.item.openView) onOpenView(entry.item.openView.view, entry.item.openView.params)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`size-1.5 shrink-0 rounded-full ${
|
||||||
|
TONE_DOT_CLASS[entry.item.tone ?? 'default'] ?? TONE_DOT_CLASS.default
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{entry.item.icon ? (
|
||||||
|
<PluginIconValue icon={entry.item.icon} size={13} className="shrink-0 text-ink-2" />
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">
|
||||||
|
{entry.item.text}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full items-center gap-2 rounded-control px-2 py-1.5">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`size-1.5 shrink-0 rounded-full ${
|
||||||
|
TONE_DOT_CLASS[entry.item.tone ?? 'default'] ?? TONE_DOT_CLASS.default
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{entry.item.icon ? (
|
||||||
|
<PluginIconValue icon={entry.item.icon} size={13} className="shrink-0 text-ink-2" />
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">
|
||||||
|
{entry.item.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,115 +1,127 @@
|
|||||||
/* Beautiful UI foundation — design tokens 按项目设计规范(OpenAI 浅色体系)重映射。
|
/* Beautiful UI foundation — design tokens 与 beautifului.dev 原版 foundation.css
|
||||||
* 规范要点:浅色界面;accent #8e8ea0(muted purple-gray);正文近黑;8px 间距网格;
|
* 对齐(MIT):冷调近白画布、白色卡片、hairline 边框、「ring + 柔和分层阴影」
|
||||||
* 圆角统一 5px(圆形仅限 icon 按钮/状态点/头像);动效统一 400ms + ease;
|
* 的层级体系、蓝色 accent、克制语义色。亮暗两套完整 token,切换 = 一次重绘。
|
||||||
* 平面层级:surface 之间用 hairline 边框分层,仅浮层(modal/popover/审批卡)允许一层中性软阴影。
|
* 圆角体系:chip 6 / control 8 / card 10 / window 14 / pill 999。
|
||||||
* 层级语义:--accent 负责焦点环/选区/选中态/装饰 tint;--accent-ink(加深版)负责
|
* 动效:强缓动曲线(--ease-out-strong 等)+ pill 控件 150ms ease-out。 */
|
||||||
* 链接与可交互文本(保证 AA 对比度);--ink-3 是弱化文本的 AA 安全灰。 */
|
|
||||||
@import "shadow-plugin/unprefixed";
|
@import "shadow-plugin/unprefixed";
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────────────────
|
/* ──────────────────────────────────────────────────────────
|
||||||
* OpenAI light — design tokens
|
* Beautiful UI — design tokens
|
||||||
|
* Cool near-white canvas, white cards, hairline borders, layered
|
||||||
|
* single-digit-opacity shadows, a neutral ink ramp, and semantic
|
||||||
|
* color used sparingly as a condiment.
|
||||||
* ────────────────────────────────────────────────────────── */
|
* ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* motion — 规范统一值 */
|
/* motion — pill 控件/悬停反馈走 150ms ease-out;大位移动效用强曲线变量 */
|
||||||
--duration-fast: 400ms;
|
--duration-fast: 150ms;
|
||||||
--duration-base: 400ms;
|
--duration-base: 220ms;
|
||||||
--duration-slow: 400ms;
|
--duration-slow: 360ms;
|
||||||
--ease-standard: ease;
|
--ease-standard: ease-out;
|
||||||
|
|
||||||
/* surfaces — 中性灰阶,白底为主 */
|
/* surfaces — cool, blue-tinted neutrals */
|
||||||
--page: #ffffff;
|
--page: oklch(0.985 0.001 286.376);
|
||||||
--canvas: #ffffff;
|
--canvas: oklch(0.961 0.002 247.84);
|
||||||
--surface: #ffffff;
|
--surface: oklch(1 0 0);
|
||||||
--inset: #f4f4f5;
|
--inset: oklch(0.979 0.002 247.839);
|
||||||
--hover: #f0f0f1;
|
--hover: oklch(0.97 0.002 247.839);
|
||||||
--hover-2: #e6e6e8;
|
--hover-2: oklch(0.933 0.003 247.86);
|
||||||
|
|
||||||
/* ink ramp — 正文近黑(规范 #000 的 a11y 修正版),弱化层为 AA 安全灰 */
|
/* ink ramp */
|
||||||
--ink: #0d0d0d;
|
--ink: oklch(0.247 0.006 258.361);
|
||||||
--ink-2: #5d5d5d;
|
--ink-2: oklch(0.506 0.01 264.477);
|
||||||
--ink-3: #75757f;
|
--ink-3: oklch(0.695 0.009 264.505);
|
||||||
|
|
||||||
/* borders — 实色 hairline,不用 alpha */
|
/* borders — solid and crisp, not alpha */
|
||||||
--line: #ececec;
|
--line: oklch(0.946 0.003 264.542);
|
||||||
--line-strong: #dcdce0;
|
--line-strong: oklch(0.912 0.005 258.326);
|
||||||
--line-soft: #f4f4f5;
|
--line-soft: oklch(0.966 0.002 264.542);
|
||||||
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
|
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
|
||||||
--field: #f4f4f5;
|
--field: oklch(0.961 0.001 286.375);
|
||||||
--stripe: rgba(13, 13, 13, 0.04);
|
--stripe: oklch(0.405 0 0 / 0.075);
|
||||||
--stripe-bg: #f7f7f8;
|
--stripe-bg: oklch(0.97 0 0);
|
||||||
|
|
||||||
/* accent — 规范主色 #8e8ea0 及其加深可交互版 */
|
/* accent */
|
||||||
--accent: #8e8ea0;
|
--accent: oklch(0.626 0.205 254.947);
|
||||||
--accent-ink: #707084;
|
--accent-ink: oklch(0.556 0.187 255.617);
|
||||||
--accent-tint: rgba(142, 142, 160, 0.12);
|
--accent-tint: oklch(0.96 0.019 252.878);
|
||||||
|
|
||||||
/* semantic — 唯一允许的彩色例外(状态语义),已降饱和融入中性界面 */
|
/* semantic */
|
||||||
--green: #2e7d5b;
|
--green: oklch(0.603 0.155 150.883);
|
||||||
--green-tint: #e9f3ee;
|
--green-tint: oklch(0.958 0.017 159.118);
|
||||||
--orange: #b45309;
|
--orange: oklch(0.689 0.179 49.902);
|
||||||
--orange-tint: #faf1e3;
|
--orange-tint: oklch(0.964 0.021 67.581);
|
||||||
--red: #b91c1c;
|
--red: oklch(0.621 0.192 23.042);
|
||||||
--red-tint: #fbecec;
|
--red-tint: oklch(0.956 0.017 17.462);
|
||||||
|
|
||||||
/* chart overlays */
|
/* chart overlays — neutral system colors with enough contrast on either theme */
|
||||||
--tooltip-bg: #2a2a2e;
|
--tooltip-bg: oklch(0.272 0.008 264.435);
|
||||||
--tooltip-fg: #f7f7f8;
|
--tooltip-fg: oklch(0.976 0.002 247.839);
|
||||||
--tooltip-muted: #a5a5af;
|
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||||
--tooltip-border: #454549;
|
--tooltip-border: oklch(0.356 0.007 264.474);
|
||||||
|
|
||||||
/* shadows — 平面优先:hairline 分层;仅浮层保留一层中性软阴影(文档化例外) */
|
/* shadows — solid hairline ring + shadow-plugin's smooth layered stacks
|
||||||
|
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
|
||||||
|
* consistently across the app. */
|
||||||
--shadow-hairline: 0 0 0 1px var(--line);
|
--shadow-hairline: 0 0 0 1px var(--line);
|
||||||
--shadow-btn: 0 0 0 1px var(--line-strong);
|
--shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
|
||||||
--shadow-card: 0 0 0 1px var(--line);
|
--shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
|
||||||
--shadow-raised: 0 0 0 1px var(--line-strong);
|
--shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
|
||||||
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 12px 32px rgba(13, 13, 13, 0.08);
|
--shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
|
||||||
--shadow-inset-field: inset 0 0 0 1px var(--line-strong);
|
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--page: #212121;
|
--page: oklch(0.209 0.004 264.477);
|
||||||
--canvas: #212121;
|
--canvas: oklch(0.231 0.004 264.487);
|
||||||
--surface: #2f2f2f;
|
--surface: oklch(0.26 0.006 271.191);
|
||||||
--inset: #181818;
|
--inset: oklch(0.243 0.004 264.492);
|
||||||
--hover: #2f2f2f;
|
--hover: oklch(0.289 0.006 271.22);
|
||||||
--hover-2: #424242;
|
--hover-2: oklch(0.318 0.007 274.747);
|
||||||
|
|
||||||
--ink: #ececec;
|
--ink: oklch(0.964 0.002 247.839);
|
||||||
--ink-2: #b4b4b4;
|
--ink-2: oklch(0.731 0.008 260.731);
|
||||||
--ink-3: #8e8ea0;
|
--ink-3: oklch(0.541 0.01 264.484);
|
||||||
|
|
||||||
--line: #383838;
|
--line: oklch(0.308 0.006 258.354);
|
||||||
--line-strong: #4a4a4a;
|
--line-strong: oklch(0.356 0.007 264.474);
|
||||||
--line-soft: #2a2a2a;
|
--line-soft: oklch(0.278 0.006 258.354);
|
||||||
--field: #2f2f2f;
|
--field: oklch(0.293 0.006 271.223);
|
||||||
--stripe: rgba(255, 255, 255, 0.05);
|
--stripe: oklch(1 0 0 / 0.055);
|
||||||
--stripe-bg: #262626;
|
--stripe-bg: oklch(0.226 0.004 264.485);
|
||||||
|
|
||||||
--accent: #a3a3b5;
|
--accent: oklch(0.68 0.173 253.301);
|
||||||
--accent-ink: #bcbccc;
|
--accent-ink: oklch(0.788 0.113 248.33);
|
||||||
--accent-tint: rgba(142, 142, 160, 0.18);
|
--accent-tint: oklch(0.68 0.173 253.301 / 0.16);
|
||||||
|
|
||||||
--green: #6fbf95;
|
--green: oklch(0.705 0.154 153.814);
|
||||||
--green-tint: rgba(111, 191, 149, 0.15);
|
--green-tint: oklch(0.705 0.154 153.814 / 0.14);
|
||||||
--orange: #d19a55;
|
--orange: oklch(0.746 0.156 55.642);
|
||||||
--orange-tint: rgba(209, 154, 85, 0.15);
|
--orange-tint: oklch(0.746 0.156 55.642 / 0.14);
|
||||||
--red: #e07a70;
|
--red: oklch(0.666 0.18 21.433);
|
||||||
--red-tint: rgba(224, 122, 112, 0.15);
|
--red-tint: oklch(0.666 0.18 21.433 / 0.14);
|
||||||
|
|
||||||
--tooltip-bg: #171717;
|
--tooltip-bg: oklch(0.182 0.004 264.459);
|
||||||
--tooltip-fg: #ececec;
|
--tooltip-fg: oklch(0.964 0.002 247.839);
|
||||||
--tooltip-muted: #8e8ea0;
|
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||||
--tooltip-border: #383838;
|
--tooltip-border: oklch(0.308 0.006 258.354);
|
||||||
|
|
||||||
--shadow-hairline: 0 0 0 1px var(--line);
|
--shadow-hairline: 0 0 0 1px var(--line);
|
||||||
--shadow-btn: 0 0 0 1px var(--line-strong);
|
/* Elevation rings go to a subtle *light* hairline in dark mode: the ring
|
||||||
--shadow-card: 0 0 0 1px var(--line);
|
* paints over the surface behind the element, so a low-alpha white edge
|
||||||
--shadow-raised: 0 0 0 1px var(--line-strong);
|
* dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
|
||||||
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 16px 40px rgba(0, 0, 0, 0.45);
|
* graduated by how far the surface floats. */
|
||||||
--shadow-inset-field: inset 0 0 0 1px var(--line-strong);
|
--shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
|
||||||
|
--shadow-card:
|
||||||
|
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
|
||||||
|
0 2px 6px oklch(0 0 0 / 0.2);
|
||||||
|
--shadow-raised:
|
||||||
|
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
|
||||||
|
--shadow-overlay:
|
||||||
|
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
|
||||||
|
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -146,20 +158,20 @@
|
|||||||
|
|
||||||
/* type 沿用 globals.css 中的 --font-sans / --font-mono,不覆盖 */
|
/* type 沿用 globals.css 中的 --font-sans / --font-mono,不覆盖 */
|
||||||
|
|
||||||
/* radii — 规范统一 5px;rounded-full 仅限 icon 圆钮/状态点/头像 */
|
/* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
|
||||||
--radius-chip: 5px;
|
--radius-chip: 6px;
|
||||||
--radius-control: 5px;
|
--radius-control: 8px;
|
||||||
--radius-card: 5px;
|
--radius-card: 10px;
|
||||||
--radius-window: 5px;
|
--radius-window: 14px;
|
||||||
|
|
||||||
/* easing — 规范统一 ease;保留旧变量名以兼容 primitives */
|
/* easing — strong curves; built-ins are too weak */
|
||||||
--ease-out-strong: ease;
|
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
--ease-in-out-strong: ease;
|
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
|
||||||
--ease-link: ease;
|
--ease-link: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
/* motion — Tailwind transition-* 工具类的全局默认:400ms + ease */
|
/* motion — pill 控件/悬停反馈的全局默认:150ms ease-out(原版手感) */
|
||||||
--default-transition-duration: 400ms;
|
--default-transition-duration: 150ms;
|
||||||
--default-transition-timing-function: ease;
|
--default-transition-timing-function: ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── base ─────────────────────────────────────────────── */
|
/* ── base ─────────────────────────────────────────────── */
|
||||||
@@ -170,6 +182,43 @@ html {
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--ink);
|
||||||
|
font-feature-settings: "cv11", "ss01";
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--accent-tint);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* headings balance; descriptions avoid orphans */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* underlines clear descenders */
|
||||||
|
a {
|
||||||
|
text-underline-position: from-font;
|
||||||
|
text-decoration-skip-ink: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* theme switches swap every token at once — freeze transitions so the flip
|
||||||
|
* is one clean repaint instead of hundreds of mismatched color fades */
|
||||||
|
.theme-switching *,
|
||||||
|
.theme-switching *::before,
|
||||||
|
.theme-switching *::after {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tailwind v4 resets buttons to cursor:default — anything
|
/* Tailwind v4 resets buttons to cursor:default — anything
|
||||||
* clickable must invite the click */
|
* clickable must invite the click */
|
||||||
button:not(:disabled),
|
button:not(:disabled),
|
||||||
@@ -207,7 +256,7 @@ select:focus-visible,
|
|||||||
background: currentColor;
|
background: currentColor;
|
||||||
transform: scaleX(0);
|
transform: scaleX(0);
|
||||||
transform-origin: left center;
|
transform-origin: left center;
|
||||||
transition: transform var(--duration-base) var(--ease-standard);
|
transition: transform 280ms var(--ease-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover .animated-underline::after,
|
a:hover .animated-underline::after,
|
||||||
@@ -216,23 +265,24 @@ a:focus-visible .animated-underline::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── primitive spacing system ─────────────────────────────
|
/* ── primitive spacing system ─────────────────────────────
|
||||||
* 8px 网格上的共享间距。
|
* Shared optical offsets for small cards. Use these
|
||||||
|
* instead of hand-tuning 10/12/14px per component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.primitive-card-pad {
|
.primitive-card-pad {
|
||||||
padding: 8px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primitive-card-bar {
|
.primitive-card-bar {
|
||||||
padding: 8px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primitive-card-footer {
|
.primitive-card-footer {
|
||||||
padding: 8px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primitive-table-cell {
|
.primitive-table-cell {
|
||||||
padding: 8px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primitive-icon-button {
|
.primitive-icon-button {
|
||||||
@@ -244,74 +294,6 @@ a:focus-visible .animated-underline::after {
|
|||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── sidebar nav(beautifului.dev Sidebar Nav,官方组件级 CSS)──
|
|
||||||
* 收起态靠行宽切换保证图标对齐:.sidebar-row 固定 208px,
|
|
||||||
* data-sidebar-collapsed 时收为 36px(size-5 图标 + px-2 ×2),
|
|
||||||
* 恰好落在 52px 轨道正中;glide 高亮层同步收窄。
|
|
||||||
* --sidebar-copy-duration / --sidebar-copy-offset / --sidebar-easing
|
|
||||||
* 由 <aside data-sidebar-collapsed> 上注入。
|
|
||||||
*/
|
|
||||||
.sidebar-row {
|
|
||||||
width: 208px;
|
|
||||||
transition-duration: var(--sidebar-copy-duration, 180ms);
|
|
||||||
transition-timing-function: var(--sidebar-easing, ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-copy {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
transition:
|
|
||||||
opacity var(--sidebar-copy-duration, 180ms) ease-out,
|
|
||||||
transform var(--sidebar-copy-duration, 180ms) var(--sidebar-easing, ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-logo,
|
|
||||||
.sidebar-collapse-control,
|
|
||||||
.sidebar-expand-control {
|
|
||||||
transition-duration: var(--sidebar-copy-duration, 180ms);
|
|
||||||
transition-timing-function: ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-expand-control {
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-glide-highlight {
|
|
||||||
right: 8px;
|
|
||||||
left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-sidebar-collapsed='true'] .sidebar-row {
|
|
||||||
width: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-sidebar-collapsed='true'] .sidebar-copy {
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(calc(var(--sidebar-copy-offset, 8px) * -1));
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-sidebar-collapsed='true'] .sidebar-workspace-control {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-sidebar-collapsed='true'] .sidebar-logo,
|
|
||||||
[data-sidebar-collapsed='true'] .sidebar-collapse-control {
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-sidebar-collapsed='true'] .sidebar-expand-control {
|
|
||||||
pointer-events: auto;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-sidebar-collapsed='true'] .sidebar-glide-highlight {
|
|
||||||
right: auto;
|
|
||||||
width: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── shared keyframes ─────────────────────────────────── */
|
/* ── shared keyframes ─────────────────────────────────── */
|
||||||
|
|
||||||
@keyframes shimmer-text {
|
@keyframes shimmer-text {
|
||||||
@@ -382,3 +364,61 @@ a:focus-visible .animated-underline::after {
|
|||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 项目机制类:侧栏折叠(对应 SidebarNav 原版 CSS,含应用内专用入口) ── */
|
||||||
|
|
||||||
|
.sidebar-logo,
|
||||||
|
.sidebar-collapse-control,
|
||||||
|
.sidebar-expand-control {
|
||||||
|
transition-duration: var(--sidebar-copy-duration, 180ms);
|
||||||
|
transition-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-expand-control {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-glide-highlight {
|
||||||
|
right: 8px;
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar-collapsed='true'] .sidebar-row {
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar-collapsed='true'] .sidebar-copy {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(calc(var(--sidebar-copy-offset, 8px) * -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar-collapsed='true'] .sidebar-workspace-control {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar-collapsed='true'] .sidebar-logo,
|
||||||
|
[data-sidebar-collapsed='true'] .sidebar-collapse-control {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar-collapsed='true'] .sidebar-expand-control {
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar-collapsed='true'] .sidebar-glide-highlight {
|
||||||
|
right: auto;
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
|
||||||
|
.sidebar-collapsed-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ body,
|
|||||||
|
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(142, 142, 160, 0.35) transparent;
|
scrollbar-color: color-mix(in srgb, var(--ink-3) 45%, transparent) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
@@ -24,43 +24,33 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb {
|
*::-webkit-scrollbar-thumb {
|
||||||
background: rgba(142, 142, 160, 0.35);
|
background: color-mix(in srgb, var(--ink-3) 45%, transparent);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb:hover {
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(142, 142, 160, 0.55);
|
background: color-mix(in srgb, var(--ink-3) 70%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-track {
|
*::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
/* ::selection 在 beautifului.css 基座(accent-tint + ink) */
|
||||||
background: rgba(142, 142, 160, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 流式输出光标 */
|
/* 流式输出光标:以 ::after 附加到流式正文最后一个文本块的最后一行文字之后
|
||||||
@keyframes caret-blink {
|
(inline 随文,而不是孤零零落在新的一行),结束后随容器 data-streaming 消失。
|
||||||
0%,
|
对齐 beautifului StreamText:流式期间光标常亮(不闪烁)。 */
|
||||||
60% {
|
.streaming-body[data-streaming='true'] > *:last-child > :is(p, h1, h2, h3, h4, h5, h6):last-child::after,
|
||||||
opacity: 1;
|
.streaming-body[data-streaming='true'] > *:last-child > :is(ul, ol):last-child > li:last-child::after {
|
||||||
}
|
content: '';
|
||||||
61%,
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stream-caret {
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 0.5em;
|
width: 2px;
|
||||||
height: 1.05em;
|
height: 1em;
|
||||||
margin-left: 2px;
|
margin-left: 3px;
|
||||||
vertical-align: text-bottom;
|
vertical-align: -0.15em;
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
animation: caret-blink 0.9s steps(1) infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 禁用 textarea 的默认 resize 与滚动条闪烁 */
|
/* 禁用 textarea 的默认 resize 与滚动条闪烁 */
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export interface SessionState {
|
|||||||
workspace?: string
|
workspace?: string
|
||||||
/** 权限模式(Composer 权限 pill / 审批行为) */
|
/** 权限模式(Composer 权限 pill / 审批行为) */
|
||||||
permissionMode?: PermissionMode
|
permissionMode?: PermissionMode
|
||||||
|
/** 当前思考等级(pi ThinkingLevel) */
|
||||||
|
thinkingLevel?: ThinkingLevel
|
||||||
|
/** 当前模型可用的思考等级(供切换器渲染) */
|
||||||
|
thinkingLevels?: ThinkingLevel[]
|
||||||
|
/** 当前模型是否支持思考 */
|
||||||
|
supportsThinking?: boolean
|
||||||
|
/** 正在压缩上下文 / 生成分支摘要 */
|
||||||
|
isCompacting?: boolean
|
||||||
|
/** 自动压缩(阈值/溢出触发)是否开启 */
|
||||||
|
autoCompactionEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工作区 = 一个本地目录(agent 的工作目录,会话按目录隔离) */
|
/** 工作区 = 一个本地目录(agent 的工作目录,会话按目录隔离) */
|
||||||
@@ -27,11 +37,56 @@ export interface WorkspaceMeta {
|
|||||||
path: string
|
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 {
|
export interface SessionStatsInfo {
|
||||||
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }
|
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }
|
||||||
cost: number
|
cost: number
|
||||||
/** 上下文占用百分比 0-100,取不到时省略 */
|
/** 上下文占用百分比 0-100,取不到时省略 */
|
||||||
contextPercent?: number
|
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 的映射) */
|
/** 会话列表项(SessionManager.list 的映射) */
|
||||||
@@ -84,8 +139,6 @@ export interface CustomProviderConfig {
|
|||||||
|
|
||||||
/** 应用级设置(存 userData/aso-settings.json) */
|
/** 应用级设置(存 userData/aso-settings.json) */
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
/** 覆盖 pi 默认系统提示词(空串 = 不覆盖) */
|
|
||||||
systemPrompt: string
|
|
||||||
/** 当前工作区目录(缺省 = 默认工作区 Default) */
|
/** 当前工作区目录(缺省 = 默认工作区 Default) */
|
||||||
workspace?: string
|
workspace?: string
|
||||||
/** 通过目录选择器添加的根目录之外的工作区 */
|
/** 通过目录选择器添加的根目录之外的工作区 */
|
||||||
@@ -94,8 +147,17 @@ export interface AppSettings {
|
|||||||
permissionMode?: PermissionMode
|
permissionMode?: PermissionMode
|
||||||
/** 主题色预设 id(缺省 default,见 renderer lib/theme.ts) */
|
/** 主题色预设 id(缺省 default,见 renderer lib/theme.ts) */
|
||||||
themeColor?: string
|
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 完全访问 */
|
/** 权限模式:confirm 变更前确认 / full 完全访问 */
|
||||||
export type PermissionMode = 'confirm' | 'full'
|
export type PermissionMode = 'confirm' | 'full'
|
||||||
|
|
||||||
@@ -135,6 +197,8 @@ export interface SkillDetail {
|
|||||||
scope: ResourceScope
|
scope: ResourceScope
|
||||||
/** true = 不进入系统提示词,仅可通过 /skill:name 显式调用 */
|
/** true = 不进入系统提示词,仅可通过 /skill:name 显式调用 */
|
||||||
disableModelInvocation: boolean
|
disableModelInvocation: boolean
|
||||||
|
/** true = 已被用户禁用(加载阶段过滤,不进会话) */
|
||||||
|
disabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置页命令条目(pi prompt template,含来源文件路径) */
|
/** 设置页命令条目(pi prompt template,含来源文件路径) */
|
||||||
@@ -145,13 +209,22 @@ export interface CommandDetail {
|
|||||||
argumentHint?: string
|
argumentHint?: string
|
||||||
filePath: string
|
filePath: string
|
||||||
scope: ResourceScope
|
scope: ResourceScope
|
||||||
|
/** true = 已被用户禁用(加载阶段过滤,不进会话) */
|
||||||
|
disabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置页技能/命令总览:条目 + 各资源目录(不存在时为空串) */
|
/** 设置页技能/命令总览:条目 + 各资源目录(不存在时为空串) */
|
||||||
export interface ResourceDetails {
|
export interface ResourceDetails {
|
||||||
skills: SkillDetail[]
|
skills: SkillDetail[]
|
||||||
commands: CommandDetail[]
|
commands: CommandDetail[]
|
||||||
dirs: { globalSkills: string; projectSkills: string; globalCommands: string; projectCommands: string }
|
dirs: {
|
||||||
|
globalSkills: string
|
||||||
|
globalAgentsSkills: string
|
||||||
|
projectSkills: string
|
||||||
|
projectAgentsSkills: string
|
||||||
|
globalCommands: string
|
||||||
|
projectCommands: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */
|
/** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */
|
||||||
@@ -182,10 +255,78 @@ export interface SendImage {
|
|||||||
* 转发到渲染进程的 agent 事件。
|
* 转发到渲染进程的 agent 事件。
|
||||||
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
|
* 已归一化为可结构化克隆的普通 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 =
|
export type AgentUiEvent =
|
||||||
| { type: 'session_ready'; state: SessionState }
|
| { type: 'session_ready'; state: SessionState }
|
||||||
| { type: 'session_error'; message: string }
|
| { 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: 'state'; state: SessionState }
|
||||||
| { type: 'approval_request'; request: ApprovalRequest }
|
| { type: 'approval_request'; request: ApprovalRequest }
|
||||||
| { type: 'approval_settled'; id: string; approved: boolean; timedOut: boolean }
|
| { type: 'approval_settled'; id: string; approved: boolean; timedOut: boolean }
|
||||||
@@ -203,3 +344,87 @@ export type AgentUiEvent =
|
|||||||
| { type: 'error'; message: string }
|
| { type: 'error'; message: string }
|
||||||
| { type: 'session_info'; name?: string }
|
| { type: 'session_info'; name?: string }
|
||||||
| { type: 'stats'; stats: SessionStatsInfo }
|
| { 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, PluginStorageSchema, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
|
||||||
|
|
||||||
|
/** 依赖状态(管理页 Tooltip 数据源):本插件 manifest 声明的每个依赖的实时可用性 */
|
||||||
|
export interface PluginDepStatus {
|
||||||
|
id: string
|
||||||
|
/** 声明的 semver 范围(^x.y.z / ~x.y.z / >=x.y.z / 精确 / *) */
|
||||||
|
range: string
|
||||||
|
/** true=必需(缺失/停用/版本不符 → 本插件激活失败);false=可选(只降级) */
|
||||||
|
required: boolean
|
||||||
|
/** ok=可用;missing=未安装;disabled=已停用;version=版本不符;error=自身装载失败 */
|
||||||
|
status: 'ok' | 'missing' | 'disabled' | 'version' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插件条目(管理页/侧栏入口数据源) */
|
||||||
|
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
|
||||||
|
/** 依赖清单及实时状态(manifest.dependencies + optionalDependencies 求值;无声明则空数组) */
|
||||||
|
dependencies: PluginDepStatus[]
|
||||||
|
/** 已声明的存储 schema(api.storage.declareSchema;未声明则缺省) */
|
||||||
|
storageSchema?: PluginStorageSchema
|
||||||
|
/** 装载/激活错误(不影响其他插件) */
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态胶囊条目(主进程单一事实源广播) */
|
||||||
|
export interface PluginStatusEntry {
|
||||||
|
pluginId: string
|
||||||
|
itemId: string
|
||||||
|
item: StatusItem
|
||||||
|
/** 最近一次写入时间(宿主记录;悬浮胶囊据此展示"插件最新消息") */
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具栏按钮条目 */
|
||||||
|
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`)
|
- ✅ Electron 桌面端(本仓库 `apps/desktop`)
|
||||||
- ⛔ NestJS 后端、Ant Design Pro 后台、PostgreSQL —— 未设计,不创建、不实现
|
- ⛔ NestJS 后端、Ant Design Pro 后台、PostgreSQL —— 未设计,不创建、不实现
|
||||||
- 业务插件中依赖服务器 API 的部分,先留接口占位(不依赖后端即可运行的本地能力可先行)
|
- 业务插件中依赖服务器 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 补开关与可见性管控。
|
||||||
|
|
||||||
|
|||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
# 插件系统(harness 插件契约)
|
||||||
|
|
||||||
|
> **教程入口**:[docs/plugins/README.md](./plugins/README.md) —— L0 第一个插件 → L1 工具命令 → L2 设置 → L3 UI 槽位 → L4 React 组件 → L5 自诊断 → L8 依赖互调 → L9 存储;视觉规范 [DESIGN.md](./plugins/DESIGN.md);脚手架 [packages/plugin-template](../packages/plugin-template/)。本文件是契约全集(参考手册)。
|
||||||
|
>
|
||||||
|
> 状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap)
|
||||||
|
> 契约版本:`HARNESS_CONTRACT_VERSION = 0.3.0`(`src/main/plugins/plugin-host.ts`;manifest.minContract 与之比对,不满足拒载。0.3.0 新增:依赖声明/服务互调/SQLite 存储)
|
||||||
|
|
||||||
|
## 两层能力模型
|
||||||
|
|
||||||
|
| | 内置插件 | 外置插件 |
|
||||||
|
|---|---|---|
|
||||||
|
| 位置 | 代码注册(`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,
|
||||||
|
"dependencies": { "fetch-tools": "^1.0.0" }, // 必需依赖:id → semver 范围(* ^ ~ >= 精确);缺失/停用/版本不符 → 激活失败并提示
|
||||||
|
"optionalDependencies": { "embed-store": "~2.1.0" }, // 可选依赖:缺失只降级(requirePlugin 返回 undefined)
|
||||||
|
"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 }) // 深链打开插件页
|
||||||
|
|
||||||
|
// 插件私有存储(SQLite,userData/plugin-data/<id>/data.db;API 全 async,宿主共享 worker 执行)
|
||||||
|
await api.storage.declareSchema({ tables: [{ name: 'notes', description: '…', columns: [{ name: 'id', type: 'TEXT', description: '…' }] }] })
|
||||||
|
await api.storage.kv.set('lastQuery', 'xxx') // KV(宿主管理的 kv 表,JSON 编码)
|
||||||
|
const rows = await api.storage.db.query('SELECT * FROM notes LIMIT 10')
|
||||||
|
api.storage.dir() // 自由文件目录
|
||||||
|
|
||||||
|
// 跨插件互调(依赖声明即访问授权;拓扑波次保证依赖先激活)
|
||||||
|
api.service('fetch', (url: string) => fetch(url).then((r) => r.text())) // 暴露服务
|
||||||
|
const deps = api.requirePlugin('fetch-tools') // 未声明依赖 → 抛错;可选未就绪 → undefined
|
||||||
|
|
||||||
|
return () => { /* dispose:宿主在 session_shutdown / 禁用 / 重载时调用 */ }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
实现:harness 在 pi `extensionFactories` 挂**一个聚合 extension**,工厂内创建真实 `ExtensionAPI` 上下文后扇出给所有已启用插件——工具注册走 pi 原生路径(提示词注入、审批拦截、事件时序由 pi 管)。激活顺序 = **必需依赖图的拓扑波次**(依赖先、依赖者后,同波并行;环整环报错;异步工厂限时 10s)。
|
||||||
|
|
||||||
|
依赖与存储细则:见 [L8 依赖与互调](./plugins/10-dependencies.md)、[L9 存储](./plugins/11-storage.md)。
|
||||||
|
|
||||||
|
## 插件页(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+B3) | `setStatusItem` 数据驱动,**悬浮胶囊**挂 main 区右上角(ZCode 复刻):最小化药丸展示最新一条消息(宿主记录 updatedAt),展开面板头即插件名、`detail` 分组为手风琴(默认收起显示 标题+trailing 徽标,点击展开行)、最大高度随内容自适应、行尾彩色数值与行内深链 | ✅ |
|
||||||
|
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
|
||||||
|
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
|
||||||
|
| 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 |
|
||||||
|
|
||||||
|
## 交互基线
|
||||||
|
|
||||||
|
- 整页替换:点插件入口 → 主列(聊天)隐藏保活;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
|
||||||
|
```
|
||||||
|
|
||||||
|
**重载粒度**:`plugins:reload` 是宿主级动作——重扫全部插件目录 + 重导入 main.mjs +
|
||||||
|
清内联 UI + 重建运行时(所有插件短暂重激活)。没有单插件重载粒度(工具聚合在同一个
|
||||||
|
pi 运行时里,无法热替换单个);改了哪个插件都执行同一个重载。排障走
|
||||||
|
[docs/plugins/09-troubleshooting.md](./plugins/09-troubleshooting.md)。
|
||||||
@@ -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,121 @@
|
|||||||
|
# L3 · UI 槽位:胶囊、工具栏、视图、面板、iframe 页
|
||||||
|
|
||||||
|
> 前置:[L0](./01-hello-plugin.md)。宿主开放 5 个数据驱动 UI 槽位 + 1 个 iframe 页形态。**数据驱动**意味着插件只提交数据,渲染与样式由宿主统一负责——这是 UI 一致性的第一道保障(自定义视觉见 [DESIGN.md](./DESIGN.md) 与 [L4](./05-renderer-components.md))。
|
||||||
|
|
||||||
|
## 1. 状态胶囊(悬浮胶囊:最小化药丸 ⇄ 展开面板)
|
||||||
|
|
||||||
|
所有插件**共用一个悬浮胶囊**,悬浮于 main 区域右上角(侧面板打开时自动左移让位;ZCode 复刻)。插件照常 `setStatusItem`,宿主记录每次写入时间,两种形态:
|
||||||
|
|
||||||
|
- **最小化药丸**:展示「最新的消息」(全部插件中 `updatedAt` 最新的一条:tone 点 + 插件图标 + 文本;多于一条时带总数角标),点击展开面板;
|
||||||
|
- **展开面板**:面板头就是插件名(第一个区域的右侧是收回药丸的 ⤡);多个插件时各占一个区域、区域头可收起/展开(记忆跨启动)。区域内 `detail` 的**分组是手风琴**——默认收起只显示「分组标题 + 行尾徽标」,点击展开 rows(ZCode 式「计划 ›」);无标题分组直接平铺。面板最大高度封顶(min(480px,58vh)),高度随内容自适应,溢出才滚动;点击面板外部 / Esc 收回药丸。
|
||||||
|
|
||||||
|
```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' } // 点击跳到插件整页视图
|
||||||
|
})
|
||||||
|
|
||||||
|
// ③ 携详情(分组 = 手风琴:收起显示 标题+trailing 徽标,点击展开 rows)
|
||||||
|
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,98 @@
|
|||||||
|
# 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` 豁免) |
|
||||||
|
|
||||||
|
装载失败/白屏/hooks 报错?直接查 [常见错误速查](./09-troubleshooting.md) §3。
|
||||||
|
|
||||||
|
## 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,109 @@
|
|||||||
|
# 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')
|
||||||
|
```
|
||||||
|
|
||||||
|
**存储也不例外**:`api.storage.*`(SQLite)在宿主的共享 worker 线程里执行,
|
||||||
|
主进程只做异步 RPC——大查询不会卡聊天流(见 [L9](./11-storage.md))。
|
||||||
|
自己开的文件句柄同理:全 async。
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
## 一张自查表
|
||||||
|
|
||||||
|
- [ ] `pnpm plugin:check <目录>` 0 error
|
||||||
|
- [ ] main.mjs 无同步 IO,激活 10s 内完成
|
||||||
|
- [ ] 一切异步有超时与重试,catch 后 `api.log` + `ErrorState`
|
||||||
|
- [ ] 长列表不整批渲染
|
||||||
|
- [ ] 深浅色各看一眼(token 正确时自动成立)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**上一篇** [L6 公共组件库](./07-ui-components.md) · 出问题了?[常见错误速查](./09-troubleshooting.md) · 返回 [教程索引](./README.md)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 附 · 常见错误速查(排障表)
|
||||||
|
|
||||||
|
> 面向「正在排障的 Agent/开发者」。**症状 → 原因 → 修复**三段式,按报错文本 grep 即可定位。
|
||||||
|
> 日志第一入口:`userData/logs/main-YYYY-MM-DD.log`(scope=plugin 的行带 pluginId,见 [L5](./06-self-diagnosis.md))。
|
||||||
|
> 改完任何文件后跑 `pnpm plugin:check <插件目录>`,再点插件页的「重载」。
|
||||||
|
|
||||||
|
## 1. 插件没出现在列表里
|
||||||
|
|
||||||
|
| 症状 | 原因 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| 设置 → 插件 → 对应作用域看不到插件 | 目录位置不对 | 只认三处:`userData/plugins/<id>/`、`<工作区>/.pi/extensions/<id>/`、`~/.pi/agent/extensions/<id>/`(工作区切换后项目级目录才同步) |
|
||||||
|
| 用户版构建下第三方区显示「装载已关闭」 | `EXTERNAL_PLUGINS_ENABLED` 仅开发版开启 | 用 dev 版,或启动加 `ASO_EXTERNAL_PLUGINS=1` |
|
||||||
|
| 列表有但灰着 | 插件被禁用(`aso-plugin-settings.json` disabled 列表) | 打开「启动」开关 |
|
||||||
|
| `plugin:check` 报 manifest 错误 | 缺字段 / version 非 semver / minContract 高于宿主契约 | 对照 [docs/plugins.md](../plugins.md) manifest 表;宿主契约版本以 `plugin-host.ts` 的 `HARNESS_CONTRACT_VERSION` 为准 |
|
||||||
|
| 目录里有代码但永不装载 | 目录名/入口是 `index.ts`/`index.js` | **不要用这两个名字**——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载;用 `main.mjs` |
|
||||||
|
|
||||||
|
## 2. main.mjs 激活失败
|
||||||
|
|
||||||
|
| 症状 | 原因 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| 插件行红字「激活失败: …」,日志有 `plugin activation failed` | 工厂抛错 | 看 err 信息修代码;激活路径只做「注册」级工作 |
|
||||||
|
| 红字「激活失败: … 超时(10000ms)」 | 异步工厂挂死超过 10s | 网络等慢操作移到工具首次调用;必须等待时用 `withTimeout` 先于宿主超时 |
|
||||||
|
| 应用整体卡顿、聊天停摆 | main.mjs 里同步 IO(`*Sync`/`execSync`)阻塞主进程事件循环 | 一律换 `node:fs/promises` 与异步子进程;检验器会标 error |
|
||||||
|
| 日志里没有插件的任何输出 | 用了 `console.*`(不落盘) | 改 `api.log.*`(scope=plugin,带 pluginId) |
|
||||||
|
| 其他插件的胶囊/按钮全消失了 | 该插件激活抛错触发 `clearPluginUi` | 修复激活错误;胶囊/按钮在插件 dispose/出错时被宿主清理是预期行为 |
|
||||||
|
|
||||||
|
## 3. renderer 装载失败(React 组件)
|
||||||
|
|
||||||
|
| 症状 | 原因 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| 插件页/面板显示「插件界面装载失败」+ 重试按钮 | 读源或 import 失败(详见下方各行) | 按 title/description 里的 message 对照下几行 |
|
||||||
|
| message 含 `超时(8000ms)` | 读源或 import 超过 8s | 瘦身 bundle;检查渲染层不 import 巨型依赖 |
|
||||||
|
| `Minified React error #310`/`Invalid hook call`/hooks 全部报错 | 插件打包了自带 React 副本 | esbuild 加 `--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js`(配方见 [L4](./05-renderer-components.md));产物必须含 `__ASO_PLUGIN_HOST__` |
|
||||||
|
| `Failed to fetch dynamically imported module` / 相对导入报错 | bundle 不自包含(相对导入无基路径,宿主用 Blob URL 装载) | `--bundle` 单文件产物;一切依赖打进包内(react 走别名除外) |
|
||||||
|
| 产物没被装载且无报错 | 超过 8MB 上限 | 瘦身;检查是否误打包 node_modules |
|
||||||
|
| 改了源码但界面没变 | 没重载 | 点插件行「重载」(或重开关插件);renderer 只在列表变化/重载时重装 |
|
||||||
|
|
||||||
|
## 4. Agent 能力(工具/命令/事件)不生效
|
||||||
|
|
||||||
|
| 症状 | 原因 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| 工具/命令没出现 | 改完代码没重载 | `plugins:reload` 是**宿主级**:重扫全部目录 + 重建运行时,所有插件会短暂重激活——改了哪个插件都跑同一个「重载」 |
|
||||||
|
| 工具在列表里但 Agent 不调用 | description 不清晰 / 参数 schema 不合法 | 按 pi 工具规范写 description 与 parameters |
|
||||||
|
| 重载后工具重复/残留 | 插件在 dispose 里没清资源 | 返回 dispose 函数清理(宿主会自动清内联 UI,但 pi 注册的资源要自己管) |
|
||||||
|
|
||||||
|
## 5. 设置与导航
|
||||||
|
|
||||||
|
| 症状 | 原因 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| 插件行没有「入口」行 | 未声明 settings(items 为空 = 无配置页)或插件未启动 | `api.ui.registerSettings` 声明至少一项;启动插件 |
|
||||||
|
| 配置页改了值插件没收到 | 没挂 `onSettingsChange` | 激活时订阅;值由宿主持久化并实时回推 |
|
||||||
|
| 设置页按钮点了没反应 | 没挂 `onSettingsAction` | 按 key 订阅动作回调 |
|
||||||
|
| 左侧导航「Agent 能力」没有插件入口 | `settingsNav` 未开启 | 插件行 → 入口开关打开 |
|
||||||
|
|
||||||
|
## 6. 依赖与互调(契约 0.3.0)
|
||||||
|
|
||||||
|
| 症状 | 原因 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| 行红字「缺少依赖插件 x(^1.0.0),请安装后重载」 | 必需依赖未安装 | 把依赖插件放入任一插件目录,点「重载」;宿主不自动安装 |
|
||||||
|
| 行红字「依赖插件 x 已停用,请先启用」 | 必需依赖被禁用 | 到该依赖插件行打开「启动」;启用后本插件自动恢复 |
|
||||||
|
| 行红字「依赖插件 x 需要 ^2.0,当前 v1.4.0」 | 版本不满足范围 | 升级依赖插件,或放宽自己 manifest 的范围 |
|
||||||
|
| 行红字「循环依赖: a → b → a」 | 必需依赖成环 | 解环(改为 optionalDependencies 或重构边界) |
|
||||||
|
| `requirePlugin` 抛「未声明对 x 的依赖」 | 未在 dependencies/optionalDependencies 声明 | 依赖声明即访问授权——manifest 里加上,并满足其 `minContract` |
|
||||||
|
| `requirePlugin` 返回 undefined(可选依赖) | 可选依赖缺失/停用/未就绪 | 预期行为;走降级路径 |
|
||||||
|
| 改了依赖方代码,依赖方行为没变 | 没重载 | 重载是宿主级动作,点同一个「重载」即可 |
|
||||||
|
|
||||||
|
## 7. 存储(SQLite)
|
||||||
|
|
||||||
|
| 症状 | 原因 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| `plugin:check` 报「使用了 api.storage.db 但未调用 declareSchema」 | 用了 SQLite 却没声明机器可读 schema | 激活期调用 `api.storage.declareSchema`,表/字段 description 写清楚(Agent 内省与设置页展示同源) |
|
||||||
|
| `api.storage.*` 报「存储操作超时(…5000ms)」 | worker 挂死或查询过重 | 检查 SQL 是否全表扫描/锁表;重启应用可恢复 worker |
|
||||||
|
| 报「database is locked」 | 跨进程/工具直接打开了同一个 data.db | 用宿主 API 访问,不要同时用外部 sqlite 工具长连接 |
|
||||||
|
| KV 读回来的值不对 | value 不可 JSON 序列化(Date/Map) | 存前转 JSON 兼容类型;大二进制放文件、路径存 KV |
|
||||||
|
| 想彻底重来 | — | 设置 → 插件 → 二级页「存储区」→ 清除数据(二次确认,整目录删除) |
|
||||||
|
| 表结构要改 | — | `PRAGMA user_version` 自管迁移(见 [L9](./11-storage.md) §4) |
|
||||||
|
|
||||||
|
## 8. 样式不对
|
||||||
|
|
||||||
|
| 症状 | 原因 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| 界面颜色和宿主不一致 / 深浅色切不过来 | 硬编码 hex/rgb | 只用 token 类词汇(DESIGN.md §2);`plugin:check` 会标 error |
|
||||||
|
| iframe 插件页没样式 | 未引宿主 token 表 | `<head>` 引 `<link rel="stylesheet" href="peer://assets/tokens.css" />`,并用同名 token 编译自己的 Tailwind |
|
||||||
|
| 暗色模式下页面发白 | 用了 `prefers-color-scheme` 自测 | 主题由宿主推送(`useHarnessTheme`),删掉自测 |
|
||||||
|
| 图标是拼图(fallback) | manifest.icon 不在 lucide 白名单 | 改成白名单内的 lucide 名(DESIGN.md §3;`plugin:check` 会 warn) |
|
||||||
|
| 按钮不是胶囊形 / 圆角不对 | 手写 rounded-control 当按钮 | renderer 用组件库 `Button`(pill 体系);圆角体系 chip6/control8/card10/window14 |
|
||||||
|
|
||||||
|
## 9. 检验器对照
|
||||||
|
|
||||||
|
`pnpm plugin:check <目录>` 输出的每条 error/warn 都带修复指引;完整规则表见 [L7](./08-performance.md)。速记:
|
||||||
|
|
||||||
|
- error = 装不了或必然出问题(同步 IO、console.*、React 副本、hex、超尺寸、契约非法、依赖声明非法、用 db 未声明 schema、requirePlugin 未声明依赖)
|
||||||
|
- warn = 应当修(缺 minContract、疑似 React 副本、三态缺失、阻塞对话框、icon 不在白名单、产物未构建、声明依赖但 minContract 过低)
|
||||||
|
|
||||||
|
## 10. 还不行?
|
||||||
|
|
||||||
|
1. `pnpm plugin:check <目录>` → 0 error
|
||||||
|
2. 重载插件 → 看 `userData/logs/main-*.log` 里 `plugin activated pluginId=<id>` 之后你自己的 `api.log` 输出
|
||||||
|
3. 对照 [L5 自诊断](./06-self-diagnosis.md) 的闭环流程补日志埋点,再重试
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# L8 插件依赖与服务互调
|
||||||
|
|
||||||
|
> 插件不是孤岛:A 依赖 B/C 时,声明、激活顺序、可用性判定、调用规范由宿主统一保证。
|
||||||
|
> 契约 0.3.0 起;全部机制在 `dependencies` / `optionalDependencies` + `api.service` / `api.requirePlugin` 四个名字里。
|
||||||
|
|
||||||
|
## 1. 声明依赖(manifest.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wiki-assistant",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"minContract": "0.3.0",
|
||||||
|
"dependencies": {
|
||||||
|
"fetch-tools": "^1.0.0",
|
||||||
|
"browser": "*"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"embed-store": "~2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 值是 **semver 范围**,支持五种写法:`*`(任意)、`^1.2.0`(同主版本)、`~1.2.0`(同次版本)、`>=1.2.0`(下限)、`1.2.0`(精确)。
|
||||||
|
- `dependencies` 是**必需依赖**:缺失 / 已停用 / 版本不符 / 自身装载失败 → 本插件激活失败。
|
||||||
|
- `optionalDependencies` 是**可选依赖**:不就绪只降级,`api.requirePlugin(id)` 返回 `undefined`。
|
||||||
|
- **依赖声明即访问授权**:`requirePlugin` 只对声明过的 id 放行,未声明直接抛错。
|
||||||
|
|
||||||
|
## 2. 运行时判定(软绑定,无强行绑定)
|
||||||
|
|
||||||
|
宿主在每次激活轮(启停/重载/重建运行时)做三步:
|
||||||
|
|
||||||
|
1. **静态预检**:逐项检查必需依赖——
|
||||||
|
|
||||||
|
| 状态 | 判定 | 插件行展示的错误 |
|
||||||
|
|---|---|---|
|
||||||
|
| missing | resolved 表里没有该 id | `缺少依赖插件 fetch-tools(^1.0.0),请安装后重载` |
|
||||||
|
| disabled | 在禁用列表里 | `依赖插件 browser 已停用,请先启用` |
|
||||||
|
| version | 不满足范围 | `依赖插件 fetch-tools 需要 ^1.0.0,当前 v0.9.0` |
|
||||||
|
| error | 依赖自身装载失败 | `依赖插件 fetch-tools 装载失败,无法激活` |
|
||||||
|
|
||||||
|
2. **环检测**:必需依赖图存在环 → 整环不激活,每个成员报 `循环依赖: a → b → a`。
|
||||||
|
3. **拓扑波次激活**:依赖先激活、依赖者后激活,无依赖关系的插件**同波并行**(异步工厂限时 10s,挂死不拖垮后续波次)。依赖激活失败时,依赖方报 `依赖插件 x 未能激活`。
|
||||||
|
|
||||||
|
行为语义(有意为之):
|
||||||
|
|
||||||
|
- A 声明依赖 B:**禁用 B → A 激活失败**(错误栏写明原因);**重新启用 B → A 自动恢复**(重建运行时天然重放)。
|
||||||
|
- **禁用 A 不影响 B**——依赖不级联、不偷改用户的选择。
|
||||||
|
- **不自动安装**:缺什么报什么,装不装由用户决定(错误文案就是行动指引)。
|
||||||
|
|
||||||
|
## 3. 服务互调(api.service / api.requirePlugin)
|
||||||
|
|
||||||
|
所有插件的 main 工厂运行在同一 pi 上下文,互调是**同进程直调**(无 IPC、无序列化),宿主只提供注册表与门控:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// fetch-tools 的 main.mjs —— 暴露服务
|
||||||
|
export default definePlugin((api) => {
|
||||||
|
api.service('fetch', async (url: string, init?: RequestInit) => {
|
||||||
|
const res = await fetch(url, init)
|
||||||
|
return res.text()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// wiki-assistant 的 main.mjs —— 消费服务
|
||||||
|
export default definePlugin(async (api) => {
|
||||||
|
const fetchTools = api.requirePlugin('fetch-tools') as
|
||||||
|
| { fetch: (url: string, init?: RequestInit) => Promise<string> }
|
||||||
|
| undefined
|
||||||
|
if (!fetchTools) {
|
||||||
|
// 可选依赖未就绪:降级路径(必需依赖不会走到这里——激活前置检已拦)
|
||||||
|
}
|
||||||
|
api.pi.registerTool({
|
||||||
|
name: 'wiki_lookup',
|
||||||
|
// ...
|
||||||
|
execute: async (args) => {
|
||||||
|
// 服务方法约定 async;调用方自行限时(与宿主性能护栏同构)
|
||||||
|
const body = await withTimeout(fetchTools!.fetch(args.url), 8_000, 'wiki_lookup')
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `api.service(name, impl)`:按名键控;重载/停用时随注册表清空,重激活即重新注册。
|
||||||
|
- `api.requirePlugin(id)`:返回 `{ serviceName: impl }`;未声明 → 抛错;必需依赖未就绪 → 抛错;可选未就绪 → `undefined`。
|
||||||
|
- **服务方法约定全 async**,调用方用 `withTimeout` 限时、必要时 `withRetry`(见 L7)——被调插件慢不能变成调用方卡死。
|
||||||
|
- 依赖可以指向内置插件(如 `browser`),内置与外置同一注册表。
|
||||||
|
|
||||||
|
## 4. UI 展示
|
||||||
|
|
||||||
|
- 管理页插件行名称旁出现**链接图标**,悬停 Tooltip 列出每个依赖:id / 范围 / 必需或可选 / 实时状态(可用·绿 / 未安装·橙 / 已停用·橙 / 版本不符·橙 / 装载失败·红)。
|
||||||
|
- 依赖问题的详细错误在该插件行红字与二级页展示;「去启用」到对应插件的行上操作。
|
||||||
|
|
||||||
|
## 5. 检验器
|
||||||
|
|
||||||
|
`pnpm plugin:check` 会校验:依赖 id 合法、范围语法、不能依赖自身、不能同时出现在必需与可选、声明了依赖但 `minContract < 0.3.0`(旧宿主会静默忽略声明)、调用了 `requirePlugin` 却未声明依赖。
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# L9 插件存储(SQLite)
|
||||||
|
|
||||||
|
> 每个插件一块**私有**持久化空间:一个 SQLite 库 + 一个自由文件目录。
|
||||||
|
> KV 简单状态、SQL 结构化数据、文件随便放;schema 必须机器可读声明——它是 Agent 自我迭代与 UI 展示的同一份事实源。
|
||||||
|
|
||||||
|
## 1. 空间布局
|
||||||
|
|
||||||
|
```
|
||||||
|
userData/plugin-data/<plugin-id>/
|
||||||
|
data.db ← SQLite 库(WAL 模式):kv 表(宿主管理)+ 插件自建表
|
||||||
|
data.db-wal ← WAL 伴生文件
|
||||||
|
<其余自由文件> ← api.storage.dir() 指向这里,插件自行读写
|
||||||
|
```
|
||||||
|
|
||||||
|
- **每插件一库**(不是共享大库):导出/清除/损坏恢复都以插件为单位,跨插件天然隔离。
|
||||||
|
- 卸载/移除插件**不删数据**(重装即恢复);删除只在设置页点「清除数据」(二次确认)时发生。
|
||||||
|
|
||||||
|
## 2. API(全部 async——底层在宿主共享 worker 线程执行,永不阻塞主进程)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export default definePlugin(async (api) => {
|
||||||
|
// ① 声明机器可读 schema(使用 db 前必须声明;plugin:check 强制)
|
||||||
|
api.storage.declareSchema({
|
||||||
|
kv: { description: '会话级偏好:ui.<key> = 用户界面偏好' },
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: 'notes',
|
||||||
|
description: '用户笔记',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'TEXT', description: '主键,ulid' },
|
||||||
|
{ name: 'title', type: 'TEXT', description: '标题,非空' },
|
||||||
|
{ name: 'created_at', type: 'INTEGER', description: '创建时间,Unix 毫秒' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ② KV:宿主管理的 kv 表(value 走 JSON 编码,适合设置类小状态)
|
||||||
|
const last = await api.storage.kv.get<string>('ui.lastQuery')
|
||||||
|
await api.storage.kv.set('ui.lastQuery', 'postgres')
|
||||||
|
await api.storage.kv.delete('ui.lastQuery')
|
||||||
|
const keys = await api.storage.kv.keys()
|
||||||
|
|
||||||
|
// ③ 自建表:SQL 自由发挥
|
||||||
|
await api.storage.db.exec('CREATE TABLE IF NOT EXISTS notes (...)') // DDL,激活期执行
|
||||||
|
await api.storage.db.run('INSERT INTO notes (id, title) VALUES (?, ?)', ['n1', '标题'])
|
||||||
|
const rows = await api.storage.db.query('SELECT * FROM notes WHERE title LIKE ?', ['%标%'])
|
||||||
|
// 原子批量:worker 端 BEGIN/COMMIT,任一失败整体回滚
|
||||||
|
await api.storage.db.batch([
|
||||||
|
{ sql: 'INSERT INTO notes (id, title) VALUES (?, ?)', params: ['n2', 'a'] },
|
||||||
|
{ sql: 'UPDATE notes SET title = ? WHERE id = ?', params: ['b', 'n2'] }
|
||||||
|
])
|
||||||
|
|
||||||
|
// ④ 自由文件(日志快照、导出等)
|
||||||
|
const dir = api.storage.dir() // userData/plugin-data/<id>/
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. schema 声明为什么是硬性要求
|
||||||
|
|
||||||
|
插件会被 **Agent 自我迭代**。迭代前 Agent 需要知道「库里有什么」:
|
||||||
|
|
||||||
|
- `declareSchema` 的内容随 `plugins:list` 下发——管理页「存储区」卡片与(未来的)Agent 内省工具读的是**同一份**数据;
|
||||||
|
- 每张表、每个字段的 `description` 必填,`plugin:check` 会拒绝「用了 db 却不声明 schema」的插件;
|
||||||
|
- 写给 Agent 读的 description 请具体(「创建时间,Unix 毫秒」而不是「时间」)。
|
||||||
|
|
||||||
|
## 4. 迁移(表结构变更)
|
||||||
|
|
||||||
|
宿主不解释 schema,迁移由插件自管,惯用法:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const row = await api.storage.db.query('PRAGMA user_version')
|
||||||
|
const v = Number(row[0]?.user_version ?? 0)
|
||||||
|
if (v < 1) {
|
||||||
|
await api.storage.db.exec('ALTER TABLE notes ADD COLUMN tag TEXT')
|
||||||
|
await api.storage.db.exec('PRAGMA user_version = 1')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 设置页管理
|
||||||
|
|
||||||
|
- 二级页「存储区」卡片:schema 表格化展示(表/列/类型/描述)+ **数据目录**(资源管理器打开)+ **清除数据**(二次确认,连接关闭后整目录删除)。
|
||||||
|
|
||||||
|
## 6. 边界
|
||||||
|
|
||||||
|
- 存储只在 **main 层**提供;renderer 组件 / iframe 页需要数据时,让 main 侧经状态胶囊/设置值转发(受控 IPC 通道属后续契约)。
|
||||||
|
- KV 的 value 必须可 JSON 序列化(Date/Map 请先转换);大二进制放文件,路径存 KV。
|
||||||
|
- 跨插件**不直读对方库**:需要别人的数据 = 声明依赖 + 走对方服务(见 L8)。
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# DESIGN.md · 插件视觉一致性规范(beautifului 审美)
|
||||||
|
|
||||||
|
> 读者是**写 UI 的 Agent/开发者**。目标:任何来源的插件,界面观感与宿主完全一致,
|
||||||
|
> 达到 [beautifului.dev](https://www.beautifului.dev/)(MIT)的质感。
|
||||||
|
> 规则分两类:**强制**(可机器校验/一眼可辨)与**默认**(无充分理由不得偏离)。
|
||||||
|
> 契约问题看 [../plugins.md](../plugins.md);本文件只管"长什么样"。
|
||||||
|
> Token 单一事实源:`apps/desktop/src/renderer/src/styles/beautifului.css`(= foundation 原版),
|
||||||
|
> 组件参考源码归档在 `docs/ui-ref/beautifului/*.json`(26 个 registry)。
|
||||||
|
|
||||||
|
## 0. 两条铁律
|
||||||
|
|
||||||
|
1. **色值只允许来自 token**。任何 `#hex`/`rgb()`/`oklch()` 硬编码(规范中列出的除外)都算违规;
|
||||||
|
语义永远用 token 名表达(`text-ink-3` 而不是 `text-gray-400`)。
|
||||||
|
2. **不发明新组件形态**。先查本文 §4 标准模式与公共组件库;确需新形态时,
|
||||||
|
用 §1 的 token 组合拼装,不引入新色/新圆角/新阴影。
|
||||||
|
|
||||||
|
## 1. Token 表(单一事实源:`peer://assets/tokens.css` = 宿主 beautifului.css)
|
||||||
|
|
||||||
|
### 表面(背景)——冷调近白,亮暗两套完整 token
|
||||||
|
|
||||||
|
| token | 意图 |
|
||||||
|
|---|---|
|
||||||
|
| `--page` | 页面最底层背景 |
|
||||||
|
| `--canvas` | 画布(primary 按钮的文字色取这里) |
|
||||||
|
| `--surface` | 卡片/面板表面(纯白) |
|
||||||
|
| `--inset` | 内嵌区域(输入框底、预览底、代码块底) |
|
||||||
|
| `--hover` / `--hover-2` | 悬停态一级/二级 |
|
||||||
|
|
||||||
|
### 文字——中性 ink ramp(深灰,不用纯黑)
|
||||||
|
|
||||||
|
| token | 意图 |
|
||||||
|
|---|---|
|
||||||
|
| `--ink` | 主文字(标题、关键值) |
|
||||||
|
| `--ink-2` | 次级文字(正文说明) |
|
||||||
|
| `--ink-3` | 弱化文字(占位、提示、元信息、路径) |
|
||||||
|
|
||||||
|
### 边线——实色 hairline,不用 alpha
|
||||||
|
|
||||||
|
| token | 意图 |
|
||||||
|
|---|---|
|
||||||
|
| `--line` | 常规边框(卡片、列表分隔) |
|
||||||
|
| `--line-strong` | 强边框(输入框、可点击描边) |
|
||||||
|
| `--line-soft` | 极弱分隔 |
|
||||||
|
|
||||||
|
### 强调与语义——蓝色 accent,语义色只作"调味料"
|
||||||
|
|
||||||
|
| token | 意图 |
|
||||||
|
|---|---|
|
||||||
|
| `--accent` / `--accent-ink` / `--accent-tint` | 焦点环/选区/选中态/链接(accent-ink 负责可交互文本,保 AA 对比) |
|
||||||
|
| `--green` / `--green-tint` | 成功/正常 |
|
||||||
|
| `--orange` / `--orange-tint` | 警示 |
|
||||||
|
| `--red` / `--red-tint` | 错误/危险 |
|
||||||
|
|
||||||
|
### 阴影——「ring + 分层柔影」,不是纯 hairline
|
||||||
|
|
||||||
|
| token | 意图 |
|
||||||
|
|---|---|
|
||||||
|
| `--shadow-btn` | ring(line-strong) + xs——secondary 按钮 |
|
||||||
|
| `--shadow-card` | ring(line) + sm——卡片 |
|
||||||
|
| `--shadow-raised` | ring(line) + md——悬浮件 |
|
||||||
|
| `--shadow-overlay` | ring(line) + lg——弹层/模态 |
|
||||||
|
| `--shadow-inset-field` | 输入框内嵌阴影 |
|
||||||
|
| `--shadow-hairline` | 纯 ring(无投影)|
|
||||||
|
|
||||||
|
暗色模式 ring 自动切换为低透明白渐进(0.10→0.15 按浮起高度),阴影加深——
|
||||||
|
**黑白主题不是简单反色**:暗色有一套专属 elevation 表达。
|
||||||
|
|
||||||
|
### 圆角体系(关键差异)
|
||||||
|
|
||||||
|
| token | 值 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--radius-chip` | 6px | chips、小徽标 |
|
||||||
|
| `--radius-control` | 8px | 输入框、菜单行、icon 按钮 |
|
||||||
|
| `--radius-card` | 10px | 卡片 |
|
||||||
|
| `--radius-window` | 14px | 窗口级容器(composer 外壳、弹窗) |
|
||||||
|
| `rounded-full` | 999px | **按钮(pill 是核心语言)**、状态点、头像 |
|
||||||
|
|
||||||
|
### 动效——强缓动曲线,内置 ease 太弱
|
||||||
|
|
||||||
|
| token | 值 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--ease-out-strong` | cubic-bezier(0.23, 1, 0.32, 1) | 大位移(滑层、展开、fade-up) |
|
||||||
|
| `--ease-in-out-strong` | cubic-bezier(0.77, 0, 0.175, 1) | 往返位移 |
|
||||||
|
| `--ease-link` | cubic-bezier(0.16, 1, 0.3, 1) | 下划线/微交互 |
|
||||||
|
| 控件默认 | 150ms ease-out | pill 按钮、悬停反馈 |
|
||||||
|
| 按压 | `active:scale-[0.96]` | 所有可点 pill |
|
||||||
|
|
||||||
|
## 2. 类名词汇(宿主 Tailwind 映射,renderer 组件直接用)
|
||||||
|
|
||||||
|
背景 `bg-page / bg-canvas / bg-surface / bg-inset / bg-hover / bg-hover-2 / bg-accent / bg-accent/10` ·
|
||||||
|
文字 `text-ink / text-ink-2 / text-ink-3 / text-accent / text-accent-ink / text-red` ·
|
||||||
|
边框 `border-line / border-line-strong / border-dashed border-line` ·
|
||||||
|
圆角 `rounded-chip`(6)`rounded-control`(8)`rounded-card`(10)`rounded-window`(14)`rounded-full`(pill)·
|
||||||
|
阴影 `shadow-btn / shadow-card / shadow-raised / shadow-overlay`。
|
||||||
|
|
||||||
|
## 3. 字号与间距
|
||||||
|
|
||||||
|
- 基准 14px/1.5,`letter-spacing -0.01em`;字号阶:`11px`(元信息/徽标)→ `12px`(chips、正文说明)→ `12.5/13px`(行标题、按钮)→ `14px`(正文)→ `16px`(页面标题)。**不使用其他字号**。
|
||||||
|
- 间距基数 4px:卡片内边距 `px-3 py-2.5`(紧凑)或 `primitive-card-pad`(12px),区块间 `space-y-1.5`,小节间 `pt-3`。
|
||||||
|
- 图标 12–16px 线条风格,`currentColor`;**一律 lucide**(宿主白名单 `plugin-icons.tsx`),禁止自绘 svg。
|
||||||
|
- 颜色只作"调味料":结构永远是中性 ink + line,彩色仅出现在状态/数值/选中。
|
||||||
|
|
||||||
|
## 4. 标准模式
|
||||||
|
|
||||||
|
> **renderer 组件优先用组件库**:`@aso/plugin-sdk/ui` 的 `Button`/`SettingRow`/`LoadingState`/
|
||||||
|
> `ErrorState`/`EmptyState`/`Badge`… 已按本规范实现(见 [L6](./07-ui-components.md))。
|
||||||
|
> 下面的 HTML 形态留给 iframe 插件页作者。
|
||||||
|
|
||||||
|
### 按钮(pill 是核心语言)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- primary:墨底 canvas 字 + 内高光;accent:蓝底白字;secondary:白底 ring+xs -->
|
||||||
|
<button class="inline-flex h-[27px] items-center justify-center gap-1.5 rounded-full px-3
|
||||||
|
text-[13px] font-medium leading-none transition-[transform,background-color,opacity]
|
||||||
|
duration-150 ease-out active:scale-[0.96] bg-accent text-white hover:bg-accent-ink
|
||||||
|
shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]">主操作</button>
|
||||||
|
<!-- quiet:透明直到悬停(密集工具行) -->
|
||||||
|
<button class="h-7 rounded-full px-2.5 text-[12px] text-ink hover:bg-hover">次要</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设置行(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>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 卡片(window 级容器用 14px)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="rounded-window border border-line bg-surface shadow-card">…</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 徽标 / 空态 / 开关
|
||||||
|
|
||||||
|
徽标 `rounded-chip bg-hover px-1.5 py-0.5 text-[10px] text-ink-3`;
|
||||||
|
空态 = 虚线卡(同旧版);开关 `h-5 w-9`(行内)/ `h-6 w-10`(表单级),
|
||||||
|
滑块 `size-4/5 rounded-full bg-white shadow`,开 `bg-accent`、关 `bg-line-strong`。
|
||||||
|
|
||||||
|
## 5. 动效范式
|
||||||
|
|
||||||
|
- 悬停反馈:150ms ease-out,只变 background-color/text(配 `transition-colors`)
|
||||||
|
- 内容进场:`fade-up 400ms var(--ease-out-strong)`(聊天消息、卡片)
|
||||||
|
- 弹层:`pop-in`(scale 0.95→1)+ `shadow-overlay`
|
||||||
|
- 滑动高亮层(菜单/标签条):220ms `cubic-bezier(0.23,1,0.32,1)`(GlideMenu)
|
||||||
|
- 流式输出:光标 2px `--ink` 常亮→结束后闪烁;`prefers-reduced-motion` 全局降级
|
||||||
|
|
||||||
|
## 6. iframe 插件页附加规则
|
||||||
|
|
||||||
|
- `<head>` 必须引入 `<link rel="stylesheet" href="peer://assets/tokens.css" />`,并用同名 token 编译自己的 Tailwind
|
||||||
|
- 主题跟随宿主推送(`useHarnessTheme`),**禁止** `prefers-color-scheme` 自测;亮暗两套 token 都要核对
|
||||||
|
- 与宿主的视觉差异应趋近于零:同 token + 同类名词汇 + 同字号阶 + 同圆角/阴影体系
|
||||||
|
|
||||||
|
## 7. 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))
|
||||||
|
|
||||||
|
## 8. 自检清单(提发布前)
|
||||||
|
|
||||||
|
- [ ] `pnpm plugin:check <插件目录>` 0 error(机器关卡,见 [L7](./08-performance.md))
|
||||||
|
- [ ] 无硬编码色值/阴影/圆角(铁律 1)
|
||||||
|
- [ ] 圆角落在体系上:chip 6 / control 8 / card 10 / window 14 / pill 999
|
||||||
|
- [ ] 字号都在 §3 的阶上;按钮是 pill(rounded-full)
|
||||||
|
- [ ] 深浅色都检查过(暗色不是反色:ring 是低透白、阴影更深)
|
||||||
|
- [ ] 复用了 §4 模式(renderer 直接用组件库)而非新造形态
|
||||||
|
- [ ] iframe 页引了 tokens.css 且无 `prefers-color-scheme`
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 插件开发教程(渐进式)
|
||||||
|
|
||||||
|
> 面向「写插件的人(含 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 机器关卡 | 可发布、不卡宿主的插件 |
|
||||||
|
| [L8 依赖与互调](./10-dependencies.md) | dependencies 声明、拓扑激活、api.service/requirePlugin | 可组合、可协作的插件 |
|
||||||
|
| [L9 存储](./11-storage.md) | 每插件 SQLite 库 + KV + schema 声明 + 数据管理 | 有持久化、可被 Agent 迭代的插件 |
|
||||||
|
|
||||||
|
> **排障中?** 直接跳 [常见错误速查](./09-troubleshooting.md)——症状 → 原因 → 修复三段式速查表。
|
||||||
|
|
||||||
|
## 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.3.0 | 模型与设置 | 模型选择、Provider/API key 管理(写入 ~/.pi auth)、系统提示词自定义、连通性测试 |
|
||||||
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
|
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
|
||||||
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
|
| 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 追踪/渲染层错误归档)、插件依赖与服务互调(dependencies/optionalDependencies + api.service/requirePlugin + 拓扑波次激活 + 环检测)、插件 SQLite 存储(每插件一库 userData/plugin-data/<id>/data.db + 共享 worker 线程 + 机器可读 declareSchema + 设置页存储区卡片)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、悬浮状态胶囊(ZCode 复刻:最小化药丸展示最新消息 ⇄ 展开面板每插件一区域、区域可收起、固定高度滚动、行尾彩色数值)、图标统一 lucide 白名单、面板平铺标签条 |
|
||||||
|
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、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. **B4/B5 槽位数据已通、渲染未接**——状态胶囊已升级为悬浮胶囊(B3 落地:药丸⇄面板);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` 修复;新克隆/重装后注意。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||||
|
"name": "button",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"title": "Button",
|
||||||
|
"description": "Shared building block used by other Beautiful UI components.",
|
||||||
|
"dependencies": [
|
||||||
|
"class-variance-authority"
|
||||||
|
],
|
||||||
|
"registryDependencies": [
|
||||||
|
"https://www.beautifului.dev/r/foundation.json"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "components/atoms/Button.tsx",
|
||||||
|
"content": "\"use client\";\n\nimport { ButtonHTMLAttributes } from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst filledShadow = \"shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]\";\n\n/* Pill-shaped by default — the app's core button style. Explicit symmetric\n * padding (not a fixed height) so the top/bottom spacing is always equal. */\nexport const buttonVariants = cva(\n `inline-flex items-center justify-center font-medium select-none\n transition-[transform,background-color,opacity] duration-150 ease-out\n active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,\n {\n variants: {\n variant: {\n primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,\n secondary: \"bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover\",\n ghost: \"bg-hover-2 text-ink hover:bg-line-strong\",\n accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,\n success: `bg-green text-white hover:brightness-95 ${filledShadow}`,\n /* transparent until hovered — for dense toolbars/action rows */\n quiet: \"text-ink hover:bg-hover\",\n },\n size: {\n /* compact toolbar pill — fixed height, lighter weight */\n xs: \"h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1\",\n /* canonical action pill — 27px tall, roomy sides */\n sm: \"h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5\",\n md: \"px-4 py-[9px] text-sm leading-none rounded-full gap-2\",\n },\n },\n defaultVariants: { variant: \"secondary\", size: \"md\" },\n },\n);\n\nexport type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>[\"variant\"]>;\n\nexport function Button({\n variant,\n size,\n className,\n ...props\n}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {\n return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;\n}\n",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"target": "components/atoms/Button.tsx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||||
|
"name": "context-cards",
|
||||||
|
"type": "registry:component",
|
||||||
|
"title": "Context Cards",
|
||||||
|
"description": "Retrieved knowledge chunks with their sources.",
|
||||||
|
"registryDependencies": [
|
||||||
|
"https://www.beautifului.dev/r/foundation.json"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "components/primitives/ContextCards.tsx",
|
||||||
|
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * CONTEXT CARDS\n * Retrieved chunks enter once, then remain available.\n * ───────────────────────────────────────────────────────── */\n\nexport type ContextChunk = {\n title: string;\n chars: string;\n body: string;\n source: string;\n badge: string;\n tone: string;\n};\n\nexport type ContextCardsLabels = {\n header: string;\n count: string;\n};\n\nconst DEFAULT_LABELS: ContextCardsLabels = {\n header: \"All chunks\",\n count: \"32\",\n};\n\nconst CHUNKS: ContextChunk[] = [\n {\n title: \"Vendor onboarding rule\",\n chars: \"290 characters\",\n body: \"Cold-chain certification must be verified before a new dairy can be added to the reorder workflow.\",\n source: \"Dairy Onboarding SOP.pdf\",\n badge: \"PDF\",\n tone: \"bg-red\",\n },\n {\n title: \"Seasonal demand row\",\n chars: \"1,250 characters\",\n body: \"Q4 velocity table: pistachio +18%, vanilla +6%, rocky road -11%; retire flavors below 40 scoops weekly.\",\n source: \"Sales Velocity Export.csv\",\n badge: \"CSV\",\n tone: \"bg-green\",\n },\n];\n\nexport default function ContextCards({\n chunks = CHUNKS,\n labels,\n className,\n}: {\n /** Accepted for gallery/registry parity; ContextCards has no visual variants. */\n variant?: string;\n chunks?: ContextChunk[];\n labels?: Partial<ContextCardsLabels>;\n className?: string;\n} = {}) {\n const [chipsShown, setChipsShown] = useState(false);\n const copy = { ...DEFAULT_LABELS, ...labels };\n\n useEffect(() => {\n const chips = setTimeout(() => setChipsShown(true), 700);\n return () => clearTimeout(chips);\n }, []);\n\n return (\n <div className={`flex w-full max-w-95 flex-col gap-2${className ? ` ${className}` : \"\"}`}>\n <div\n className=\"flex items-center gap-2 px-0.5\"\n style={{ animation: \"fade-in 400ms ease-out both\" }}\n >\n <span className=\"text-[13px] font-semibold text-ink\">{copy.header}</span>\n <span className=\"inline-flex h-5 items-center rounded-md bg-inset px-1.5 text-[11.5px] font-medium text-ink-2 shadow-hairline tabular-nums\">\n {copy.count}\n </span>\n </div>\n\n {chunks.map((chunk, i) => (\n <div\n key={chunk.title}\n className=\"overflow-hidden rounded-card bg-surface shadow-card\"\n style={{\n animation: `fade-up 400ms cubic-bezier(0.23,1,0.32,1) ${i * 100}ms both`,\n }}\n >\n <div className=\"primitive-card-bar flex items-center gap-2.5 border-b border-line\">\n <span className=\"flex min-w-0 items-center gap-1.5 text-[13px] font-medium text-ink\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\"><path d=\"M4 6h16M4 12h16M4 18h10\" /></svg>\n <span className=\"truncate\">{chunk.title}</span>\n </span>\n <span className=\"ml-auto shrink-0 text-[12px] text-ink-3 tabular-nums\">{chunk.chars}</span>\n </div>\n <p className=\"px-3 pt-2 pb-1 text-[12.5px] leading-relaxed text-ink-2\">\n {chunk.body}\n </p>\n <div className=\"px-3 pb-3\">\n <span\n className=\"inline-flex h-6 items-center gap-1.5 rounded-full bg-inset px-2\n text-[12px] font-medium text-ink-2 shadow-btn\n transition-[opacity,transform,background-color] duration-300 hover:bg-hover\"\n style={{\n opacity: chipsShown ? 1 : 0,\n transform: chipsShown ? \"scale(1)\" : \"scale(0.95)\",\n transitionTimingFunction: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n transitionDelay: `${i * 80}ms`,\n }}\n >\n <span className={`flex size-3.5 items-center justify-center rounded-[4px] ${chunk.tone} text-[7px] font-bold text-white`}>\n {chunk.badge}\n </span>\n {chunk.source}\n <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M7 17L17 7M7 7h10v10\" /></svg>\n </span>\n </div>\n </div>\n ))}\n </div>\n );\n}\n",
|
||||||
|
"type": "registry:component",
|
||||||
|
"target": "components/primitives/ContextCards.tsx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||||
|
"name": "entity-chip",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"title": "EntityChip",
|
||||||
|
"description": "Shared building block used by other Beautiful UI components.",
|
||||||
|
"registryDependencies": [
|
||||||
|
"https://www.beautifului.dev/r/foundation.json"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "components/atoms/EntityChip.tsx",
|
||||||
|
"content": "/** Monogram mark — a colored disc with an initial or short glyph.\n * The shared building block for entity chips and monogram headings. */\nexport function Monogram({\n children,\n color = \"#e08a3c\",\n className = \"\",\n}: {\n children: React.ReactNode;\n color?: string;\n className?: string;\n}) {\n return (\n <span\n className={`flex size-4 shrink-0 items-center justify-center rounded-full\n text-[9px] font-semibold leading-none text-white ${className}`}\n style={{ background: color }}\n >\n {children}\n </span>\n );\n}\n\n/** Inline entity reference — a monogram + name in a soft field pill.\n * Names a supplier, person, or record inside running text. Softer than a\n * StatusPill (no dot, no state) and not a mono token (see Chip). */\nexport function EntityChip({\n name,\n color,\n monogram,\n className = \"\",\n}: {\n name: string;\n color?: string;\n monogram?: React.ReactNode;\n className?: string;\n}) {\n return (\n <span\n className={`mx-0.5 inline-flex items-center gap-1 rounded-full bg-field\n py-px pl-[3px] pr-1.5 align-middle shadow-hairline ${className}`}\n >\n <Monogram color={color}>{monogram ?? name.charAt(0)}</Monogram>\n <span className=\"text-[12px] font-medium text-ink\">{name}</span>\n </span>\n );\n}\n",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"target": "components/atoms/EntityChip.tsx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||||
|
"name": "glide-menu",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"title": "GlideMenu",
|
||||||
|
"description": "Shared building block used by other Beautiful UI components.",
|
||||||
|
"registryDependencies": [
|
||||||
|
"https://www.beautifului.dev/r/foundation.json"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "components/primitives/GlideMenu.tsx",
|
||||||
|
"content": "\"use client\";\n\nimport { useRef, useState, type ReactNode } from \"react\";\n\ntype GlideMenuProps = {\n children: ReactNode;\n className?: string;\n highlightClassName?: string;\n rowSelector?: string;\n};\n\n/** A single hover layer that glides between interactive menu rows. */\nexport default function GlideMenu({\n children,\n className = \"\",\n highlightClassName = \"inset-x-0 rounded-[8px] bg-hover\",\n rowSelector = \"[data-menu-row]\",\n}: GlideMenuProps) {\n const ref = useRef<HTMLDivElement>(null);\n const [box, setBox] = useState<{ top: number; height: number } | null>(null);\n const [visible, setVisible] = useState(false);\n\n const moveTo = (target: EventTarget | null) => {\n const container = ref.current;\n if (!(target instanceof Element) || !container) return;\n const row = target.closest(rowSelector);\n if (!(row instanceof HTMLElement) || !container.contains(row)) return;\n const containerRect = container.getBoundingClientRect();\n const rowRect = row.getBoundingClientRect();\n setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });\n setVisible(true);\n };\n\n return (\n <div\n ref={ref}\n onMouseOver={(event) => moveTo(event.target)}\n onMouseLeave={() => setVisible(false)}\n onFocusCapture={(event) => moveTo(event.target)}\n onBlurCapture={(event) => {\n if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);\n }}\n className={`group/glide-menu relative ${className}`}\n >\n <span\n aria-hidden\n className={`pointer-events-none absolute ${highlightClassName}`}\n style={{\n top: box?.top ?? 0,\n height: box?.height ?? 0,\n opacity: box && visible ? 1 : 0,\n transition:\n \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n }}\n />\n {children}\n </div>\n );\n}\n",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"target": "components/primitives/GlideMenu.tsx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||||
|
"name": "search",
|
||||||
|
"type": "registry:component",
|
||||||
|
"title": "Search",
|
||||||
|
"description": "Command search with live filtering and an empty state.",
|
||||||
|
"registryDependencies": [
|
||||||
|
"https://www.beautifului.dev/r/foundation.json",
|
||||||
|
"https://www.beautifului.dev/r/glide-menu.json"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "components/primitives/SearchList.tsx",
|
||||||
|
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport GlideMenu from \"@/components/primitives/GlideMenu\";\n\n/* ─────────────────────────────────────────────────────────\n * SEARCH — command search with live filtering.\n * The field, clear action, and results are directly usable.\n * ───────────────────────────────────────────────────────── */\n\nexport type SearchItem = string;\n\nexport type SearchListLabels = {\n placeholder: string;\n ariaLabel: string;\n emptyTitle: string;\n emptyHint: string;\n};\n\nconst ITEMS: SearchItem[] = [\n \"Forecast summer demand\",\n \"Find waffle cone suppliers\",\n \"Compare seasonal flavors\",\n \"Draft flavor launch plan\",\n \"Check cold-chain status\",\n \"Audit sugar costs\",\n \"Retire low sellers\",\n];\n\nconst LABELS: SearchListLabels = {\n placeholder: \"Search flavors…\",\n ariaLabel: \"Search flavors\",\n emptyTitle: \"No results found\",\n emptyHint: \"Adjust your search to try again\",\n};\n\nexport default function SearchList({\n items = ITEMS,\n labels = LABELS,\n}: {\n items?: SearchItem[];\n labels?: SearchListLabels;\n variant?: string;\n} = {}) {\n const [query, setQuery] = useState(\"\");\n const results = query\n ? items.filter((i) => i.toLowerCase().includes(query.toLowerCase()))\n : items.slice(0, 5);\n const empty = query.length > 2 && results.length === 0;\n\n return (\n <div className=\"flex min-h-[248px] w-full max-w-72 flex-col items-stretch\">\n <div className=\"w-full self-start overflow-hidden rounded-card bg-surface shadow-raised\">\n {/* input row */}\n <div className=\"flex h-10 items-center gap-2 border-b border-line px-3 transition-colors duration-100 hover:bg-hover\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"var(--ink-3)\" strokeWidth=\"2\" strokeLinecap=\"round\" className=\"shrink-0\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"M21 21l-4.3-4.3\" />\n </svg>\n <input\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={labels.placeholder}\n aria-label={labels.ariaLabel}\n className=\"min-w-0 flex-1 bg-transparent text-[13px] text-ink outline-none placeholder:text-ink-3\"\n />\n {query && (\n <button\n aria-label=\"Clear search\"\n type=\"button\"\n onClick={() => setQuery(\"\")}\n className=\"flex size-6 items-center justify-center rounded-full text-ink-3\n transition-colors duration-100 hover:bg-line/70 hover:text-ink\"\n style={{ animation: \"fade-in 150ms ease-out both\" }}\n >\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\">\n <path d=\"M18 6L6 18M6 6l12 12\" />\n </svg>\n </button>\n )}\n </div>\n\n {/* results / empty state */}\n {empty ? (\n <div className=\"flex flex-col items-center justify-center gap-1 px-4 py-8\" style={{ animation: \"fade-in 250ms ease-out both\" }}>\n <span className=\"mb-1.5 flex size-8 items-center justify-center rounded-control bg-inset text-ink-3 shadow-hairline\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"M21 21l-4.3-4.3\" />\n </svg>\n </span>\n <span className=\"text-[13px] font-medium text-ink\">{labels.emptyTitle}</span>\n <span className=\"text-[12px] text-ink-3\">{labels.emptyHint}</span>\n </div>\n ) : (\n <div className=\"p-1\">\n <GlideMenu className=\"flex flex-col gap-px\" highlightClassName=\"inset-x-0 rounded-[6px] bg-hover\">\n {results.map((item) => (\n <button\n key={item}\n data-menu-row\n type=\"button\"\n onClick={() => setQuery(item)}\n className=\"relative z-10 flex h-8 w-full items-center rounded-[6px] px-2 text-left text-[13px] text-ink\"\n style={{ animation: \"fade-in 200ms ease-out both\" }}\n >\n {item}\n </button>\n ))}\n </GlideMenu>\n </div>\n )}\n </div>\n </div>\n );\n}\n",
|
||||||
|
"type": "registry:component",
|
||||||
|
"target": "components/primitives/SearchList.tsx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||||
|
"name": "shimmer",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"title": "Shimmer",
|
||||||
|
"description": "Shared building block used by other Beautiful UI components.",
|
||||||
|
"registryDependencies": [
|
||||||
|
"https://www.beautifului.dev/r/foundation.json"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "components/atoms/Shimmer.tsx",
|
||||||
|
"content": "/** Shimmering label — signals the agent is processing. */\nexport function Shimmer({\n children,\n className = \"\",\n}: {\n children: React.ReactNode;\n className?: string;\n}) {\n return (\n <span\n className={`inline-block bg-clip-text text-transparent ${className}`}\n style={{\n backgroundImage:\n \"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)\",\n backgroundSize: \"200% 100%\",\n animation: \"shimmer-text 1.8s linear infinite\",\n }}\n >\n {children}\n </span>\n );\n}\n",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"target": "components/atoms/Shimmer.tsx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||||
|
"name": "stream-text",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"title": "StreamText",
|
||||||
|
"description": "Shared building block used by other Beautiful UI components.",
|
||||||
|
"registryDependencies": [
|
||||||
|
"https://www.beautifului.dev/r/foundation.json"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "components/atoms/StreamText.tsx",
|
||||||
|
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * STREAM TEXT — reusable streaming primitive\n * Reveals characters quickly (fast, like a real token stream);\n * the leading edge resolves out of a soft blur, and the caret\n * stays solid while streaming, then blinks once the text\n * settles. Inherits typography from its context, so it drops\n * into any text surface (Selection Actions, chat, etc.).\n * ───────────────────────────────────────────────────────── */\n\nexport function StreamText({\n text,\n charsPerTick = 2,\n tickMs = 9,\n blurTail = 6,\n caret = true,\n className,\n onProgress,\n onDone,\n}: {\n text: string;\n /** characters revealed per tick — higher is faster */\n charsPerTick?: number;\n /** interval between reveals, ms */\n tickMs?: number;\n /** how many trailing characters carry the soft blur edge */\n blurTail?: number;\n /** render the caret (solid while streaming, blinks once idle) */\n caret?: boolean;\n className?: string;\n /** fires each tick — useful for re-anchoring UI to reflowing text */\n onProgress?: () => void;\n /** fires once the full string is shown */\n onDone?: () => void;\n}) {\n const [count, setCount] = useState(0);\n const onProgressRef = useRef(onProgress);\n const onDoneRef = useRef(onDone);\n onProgressRef.current = onProgress;\n onDoneRef.current = onDone;\n\n useEffect(() => {\n setCount(0);\n let i = 0;\n const id = setInterval(() => {\n i = Math.min(i + charsPerTick, text.length);\n setCount(i);\n onProgressRef.current?.();\n if (i >= text.length) {\n clearInterval(id);\n onDoneRef.current?.();\n }\n }, tickMs);\n return () => clearInterval(id);\n }, [text, charsPerTick, tickMs]);\n\n const streaming = count < text.length;\n const shown = text.slice(0, count);\n const split = streaming ? Math.max(0, shown.length - blurTail) : shown.length;\n\n return (\n <span className={className}>\n {shown.slice(0, split)}\n {split < shown.length && (\n <span className=\"stream-tail\">{shown.slice(split)}</span>\n )}\n {caret && (\n <span\n aria-hidden\n className={`stream-caret${streaming ? \" is-streaming\" : \"\"}`}\n />\n )}\n </span>\n );\n}\n",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"target": "components/atoms/StreamText.tsx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user