Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
557887c1b3 | ||
|
|
573ac5bcde | ||
|
|
2efcd8e346 | ||
|
|
d8c20022d8 | ||
|
|
5cf13f4396 | ||
|
|
06a316b6d1 | ||
|
|
95044c3a15 | ||
|
|
09cc5bf648 | ||
|
|
d0b97f16b2 | ||
|
|
6259a2bba6 | ||
|
|
cf2cc1cb77 | ||
|
|
b1405ee71f | ||
|
|
70930e4952 | ||
|
|
4eb78ebc48 | ||
|
|
b6e6031415 | ||
|
|
6507948f01 | ||
|
|
d8c1097b9d | ||
|
|
038339f2fe | ||
|
|
403b3b3ca1 | ||
|
|
c117385d01 | ||
|
|
a052b6a2c4 | ||
|
|
781a6c8beb | ||
|
|
ad16ed7239 | ||
|
|
1326f9b672 | ||
|
|
335a08454a |
@@ -2,9 +2,10 @@ import { getLogger } from '../logger'
|
|||||||
const log = getLogger('browser')
|
const log = getLogger('browser')
|
||||||
|
|
||||||
import { app, BrowserWindow, session as electronSession, WebContentsView, type Rectangle, type Session } from 'electron'
|
import { app, BrowserWindow, session as electronSession, WebContentsView, type Rectangle, type Session } from 'electron'
|
||||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import { rm as rmAsync } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import type { BrowserImportInfo, BrowserTabInfo, BrowserUiState } from '@shared/types'
|
import type { BrowserProxySettings, BrowserTabInfo, BrowserUiState } from '@shared/types'
|
||||||
import {
|
import {
|
||||||
BrowserCdpTimeoutError,
|
BrowserCdpTimeoutError,
|
||||||
BROWSER_OBSERVE_TIMEOUT_MS,
|
BROWSER_OBSERVE_TIMEOUT_MS,
|
||||||
@@ -30,8 +31,10 @@ import {
|
|||||||
* 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。
|
* 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。
|
||||||
* - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制
|
* - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制
|
||||||
* + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。
|
* + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。
|
||||||
* - 登录态隔离在 persist:aso-browser 分区;本地浏览器配置导入由 browser-profile.ts
|
* - 登录态隔离在 persist:aso-browser 分区,随分区持久保存。
|
||||||
* 直接迁移分区目录数据(Cookies/Local Storage + 密钥移植)。
|
* - 代理设置作用于同一分区会话(session.setProxy):跟随系统 / 直连 / 手动规则
|
||||||
|
* (Chromium proxyRules 语法 + 绕过列表),持久化进 aso-browser.json,
|
||||||
|
* 启动即应用、修改后断开在途连接立即生效(对 Agent 的 browser_* 工具同样生效)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PARTITION = 'persist:aso-browser'
|
const PARTITION = 'persist:aso-browser'
|
||||||
@@ -70,7 +73,7 @@ interface TabRecord {
|
|||||||
|
|
||||||
interface BrowserSettings {
|
interface BrowserSettings {
|
||||||
riskAcknowledged: boolean
|
riskAcknowledged: boolean
|
||||||
imported?: BrowserImportInfo | null
|
proxy?: BrowserProxySettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserObservation {
|
export interface BrowserObservation {
|
||||||
@@ -111,6 +114,36 @@ function textValue(v: unknown): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验/规范化代理输入(fixed 模式必须有可解析的 host:port 规则;其余模式丢弃规则字段) */
|
||||||
|
function normalizeProxy(input: BrowserProxySettings): BrowserProxySettings {
|
||||||
|
if (input.mode !== 'fixed') return { mode: input.mode }
|
||||||
|
const entries = (input.rules ?? '')
|
||||||
|
.split(';')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (entries.length === 0) {
|
||||||
|
throw new Error('手动代理需要填写规则,如 127.0.0.1:7890(多协议用分号分隔,如 http=127.0.0.1:7890;https=127.0.0.1:7890)。')
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const hostPort = entry.includes('://')
|
||||||
|
? entry.slice(entry.indexOf('://') + 3)
|
||||||
|
: entry.includes('=')
|
||||||
|
? entry.slice(entry.indexOf('=') + 1)
|
||||||
|
: entry
|
||||||
|
const colon = hostPort.lastIndexOf(':')
|
||||||
|
const port = colon >= 0 ? hostPort.slice(colon + 1) : ''
|
||||||
|
if (!hostPort || colon < 0 || !/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
|
||||||
|
throw new Error(`代理规则「${entry}」无效:应为 host:port(如 127.0.0.1:7890)。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bypass = (input.bypass ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',')
|
||||||
|
return { mode: 'fixed', rules: entries.join(';'), bypass: bypass || undefined }
|
||||||
|
}
|
||||||
|
|
||||||
function isEditableAxNode(node: Record<string, unknown>): boolean {
|
function isEditableAxNode(node: Record<string, unknown>): boolean {
|
||||||
const role = textValue(node.role).toLowerCase()
|
const role = textValue(node.role).toLowerCase()
|
||||||
if (role === 'textfield' || role === 'textbox' || role === 'searchbox') return true
|
if (role === 'textfield' || role === 'textbox' || role === 'searchbox') return true
|
||||||
@@ -289,19 +322,24 @@ export class BrowserController {
|
|||||||
private closedTabs: Array<{ title: string; url: string; closedAt: number }> = []
|
private closedTabs: Array<{ title: string; url: string; closedAt: number }> = []
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.browserSession = electronSession.fromPartition(PARTITION)
|
this.settings = this.loadSettings()
|
||||||
|
this.browserSession = this.createBrowserSession()
|
||||||
|
this.cleanupLegacyImportArtifacts()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建受管分区会话并挂上固定策略(UA、权限、下载、代理) */
|
||||||
|
private createBrowserSession(): Session {
|
||||||
|
const session = electronSession.fromPartition(PARTITION)
|
||||||
// 诚实的 UA:保留 Chromium token 保证站点兼容,去掉 Electron 标识
|
// 诚实的 UA:保留 Chromium token 保证站点兼容,去掉 Electron 标识
|
||||||
this.browserSession.setUserAgent(
|
session.setUserAgent(session.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`))
|
||||||
this.browserSession.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`)
|
|
||||||
)
|
|
||||||
// 网页权限请求(摄像头/定位等)默认拒绝
|
// 网页权限请求(摄像头/定位等)默认拒绝
|
||||||
this.browserSession.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
|
session.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
|
||||||
// 下载留在受管浏览器内,落到系统下载目录(重名自动加序号)
|
// 下载留在受管浏览器内,落到系统下载目录(重名自动加序号)
|
||||||
this.browserSession.on('will-download', (_event, item) => {
|
session.on('will-download', (_event, item) => {
|
||||||
const dir = app.getPath('downloads')
|
const dir = app.getPath('downloads')
|
||||||
const base = item.getFilename() || 'download'
|
const base = item.getFilename() || 'download'
|
||||||
const dot = base.lastIndexOf('.')
|
const dot = base.lastIndexOf('.')
|
||||||
const stem = dot > 0 ? base.slice(0, dot) : base
|
const stem = dot > 0 ? base.slice(0, dot) : ''
|
||||||
const ext = dot > 0 ? base.slice(dot) : ''
|
const ext = dot > 0 ? base.slice(dot) : ''
|
||||||
let target = join(dir, base)
|
let target = join(dir, base)
|
||||||
for (let i = 1; existsSync(target) && i < 100; i++) {
|
for (let i = 1; existsSync(target) && i < 100; i++) {
|
||||||
@@ -309,7 +347,11 @@ export class BrowserController {
|
|||||||
}
|
}
|
||||||
item.setSavePath(target)
|
item.setSavePath(target)
|
||||||
})
|
})
|
||||||
this.settings = this.loadSettings()
|
// 启动即应用持久化的代理设置(setProxy 异步;失败不阻塞浏览器初始化,日志可查)
|
||||||
|
this.applyProxyTo(session, this.proxySettings()).catch((err: unknown) => {
|
||||||
|
log.error('启动时代理设置应用失败:', { err })
|
||||||
|
})
|
||||||
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 设置持久化 ----
|
// ---- 设置持久化 ----
|
||||||
@@ -321,12 +363,16 @@ export class BrowserController {
|
|||||||
private loadSettings(): BrowserSettings {
|
private loadSettings(): BrowserSettings {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings>
|
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings>
|
||||||
|
const proxy = raw.proxy
|
||||||
return {
|
return {
|
||||||
riskAcknowledged: raw.riskAcknowledged === true,
|
riskAcknowledged: raw.riskAcknowledged === true,
|
||||||
imported: raw.imported ?? null
|
proxy:
|
||||||
|
proxy && (proxy.mode === 'direct' || proxy.mode === 'fixed' || proxy.mode === 'system')
|
||||||
|
? proxy
|
||||||
|
: { mode: 'system' }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { riskAcknowledged: false }
|
return { riskAcknowledged: false, proxy: { mode: 'system' } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,20 +384,66 @@ export class BrowserController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 配置导入完成后由 browser-profile.ts 回写摘要 */
|
/** 历史遗留清理:删除已下线的「登录态导入」功能留下的轮换分区目录与暂存
|
||||||
setImportedInfo(info: BrowserImportInfo | null): void {
|
* (aso-browser-g* / .import-staging-* / aso-browser.pending-import)。
|
||||||
this.settings.imported = info
|
* 控制器创建时无人持有这些目录的句柄;失败只记日志,下次启动再试。 */
|
||||||
|
private cleanupLegacyImportArtifacts(): void {
|
||||||
|
const root = join(app.getPath('userData'), 'Partitions')
|
||||||
|
if (!existsSync(root)) return
|
||||||
|
const stale: string[] = []
|
||||||
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
if (
|
||||||
|
entry.name.startsWith('.import-staging-') ||
|
||||||
|
entry.name === 'aso-browser.pending-import' ||
|
||||||
|
/^aso-browser-g\d+$/.test(entry.name)
|
||||||
|
) {
|
||||||
|
stale.push(entry.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stale.length === 0) return
|
||||||
|
void (async () => {
|
||||||
|
for (const name of stale) {
|
||||||
|
try {
|
||||||
|
await rmAsync(join(root, name), { recursive: true, force: true, maxRetries: 1 })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('清理遗留导入目录失败,下次启动重试', { name, err: err instanceof Error ? err.message : String(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info('cleaned legacy import artifacts', { stale })
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 代理设置 ----
|
||||||
|
|
||||||
|
private proxySettings(): BrowserProxySettings {
|
||||||
|
return this.settings.proxy ?? { mode: 'system' }
|
||||||
|
}
|
||||||
|
|
||||||
|
getProxySettings(): BrowserProxySettings {
|
||||||
|
return this.proxySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用到指定分区会话;切换后断开在途连接,让新规则对已打开的标签立即生效 */
|
||||||
|
private async applyProxyTo(session: Session, proxy: BrowserProxySettings): Promise<void> {
|
||||||
|
await session.setProxy(
|
||||||
|
proxy.mode === 'fixed'
|
||||||
|
? { mode: 'fixed_servers', proxyRules: proxy.rules ?? '', proxyBypassRules: proxy.bypass }
|
||||||
|
: proxy.mode === 'direct'
|
||||||
|
? { mode: 'direct' }
|
||||||
|
: { mode: 'system' }
|
||||||
|
)
|
||||||
|
await session.closeAllConnections()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新并持久化代理设置:先应用成功才落盘(失败抛错给 UI,原设置保持不变) */
|
||||||
|
async setProxySettings(input: BrowserProxySettings): Promise<BrowserProxySettings> {
|
||||||
|
const next = normalizeProxy(input)
|
||||||
|
await this.applyProxyTo(this.browserSession, next)
|
||||||
|
this.settings.proxy = next
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
this.emitState()
|
this.emitState()
|
||||||
}
|
return next
|
||||||
|
|
||||||
get importedInfo(): BrowserImportInfo | null {
|
|
||||||
return this.settings.imported ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 当前是否有活跃标签页(导入配置前必须没有) */
|
|
||||||
get hasTabs(): boolean {
|
|
||||||
return this.tabs.size > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 窗口与状态广播 ----
|
// ---- 窗口与状态广播 ----
|
||||||
@@ -395,7 +487,7 @@ export class BrowserController {
|
|||||||
activeTabId: this.activeTabId,
|
activeTabId: this.activeTabId,
|
||||||
recentClosed: this.closedTabs.map(({ title, url }) => ({ title, url })),
|
recentClosed: this.closedTabs.map(({ title, url }) => ({ title, url })),
|
||||||
trace: this.traceText,
|
trace: this.traceText,
|
||||||
imported: this.settings.imported ?? null
|
proxy: this.proxySettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,8 +548,11 @@ export class BrowserController {
|
|||||||
if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法创建浏览器标签。')
|
if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法创建浏览器标签。')
|
||||||
if (this.tabs.size >= MAX_TABS) throw new Error(`浏览器标签页已达上限(${MAX_TABS}),请先关闭不需要的标签。`)
|
if (this.tabs.size >= MAX_TABS) throw new Error(`浏览器标签页已达上限(${MAX_TABS}),请先关闭不需要的标签。`)
|
||||||
const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
// popup 的 viewOptions 不一定带 partition(会导致弹窗落入默认会话),统一强制受管分区
|
||||||
const view = new WebContentsView(
|
const view = new WebContentsView(
|
||||||
viewOptions ?? {
|
viewOptions
|
||||||
|
? { ...viewOptions, webPreferences: { ...(viewOptions.webPreferences ?? {}), partition: PARTITION } }
|
||||||
|
: {
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
partition: PARTITION,
|
partition: PARTITION,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
@@ -611,7 +706,7 @@ export class BrowserController {
|
|||||||
return this.buildState()
|
return this.buildState()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关闭整个受管浏览器(销毁全部标签;导入配置前也会走这里) */
|
/** 关闭整个受管浏览器(销毁全部标签;分区会话与登录态保留) */
|
||||||
close(): BrowserUiState {
|
close(): BrowserUiState {
|
||||||
for (const tabId of [...this.tabs.keys()]) this.closeTab(tabId)
|
for (const tabId of [...this.tabs.keys()]) this.closeTab(tabId)
|
||||||
this.agentTabId = null
|
this.agentTabId = null
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
import { app } from 'electron'
|
|
||||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
||||||
import { homedir } from 'node:os'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import type { BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types'
|
|
||||||
import { getBrowserController } from './browser-controller'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 本地浏览器配置导入(复用各网站登录态)。
|
|
||||||
*
|
|
||||||
* 实现思路(零解密依赖,纯文件迁移):
|
|
||||||
* - Local Storage / IndexedDB 在 Chromium 里是明文 leveldb,直接拷入受管分区即可生效。
|
|
||||||
* - Cookies 用 AES-GCM 加密,密钥经 OS 加密后存于浏览器根目录 Local State
|
|
||||||
* (os_crypt.encrypted_key)。Windows 上该密钥由 DPAPI(用户级)包裹——同一
|
|
||||||
* Windows 用户的任意进程都能解开,因此把这份密钥「移植」进本应用的 Local State,
|
|
||||||
* 本应用内的 Chromium 即可解密拷贝过来的 Cookies(Chrome 127+ 的 app-bound
|
|
||||||
* 加密 V20 除外,会明确提示不支持)。macOS/Linux 的密钥绑定钥匙串/KWallet,
|
|
||||||
* Cookies 无法直接迁移,仅迁移 Local Storage。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Chromium 系浏览器的用户数据根目录(按平台) */
|
|
||||||
const SOURCE_ROOTS: Array<{ id: string; name: string; win: string; mac: string; linux: string }> = [
|
|
||||||
{
|
|
||||||
id: 'chrome',
|
|
||||||
name: 'Google Chrome',
|
|
||||||
win: join(process.env.LOCALAPPDATA ?? '', 'Google', 'Chrome', 'User Data'),
|
|
||||||
mac: join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome'),
|
|
||||||
linux: join(homedir(), '.config', 'google-chrome')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'edge',
|
|
||||||
name: 'Microsoft Edge',
|
|
||||||
win: join(process.env.LOCALAPPDATA ?? '', 'Microsoft', 'Edge', 'User Data'),
|
|
||||||
mac: join(homedir(), 'Library', 'Application Support', 'Microsoft Edge'),
|
|
||||||
linux: join(homedir(), '.config', 'microsoft-edge')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'brave',
|
|
||||||
name: 'Brave',
|
|
||||||
win: join(process.env.LOCALAPPDATA ?? '', 'BraveSoftware', 'Brave-Browser', 'User Data'),
|
|
||||||
mac: join(homedir(), 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'),
|
|
||||||
linux: join(homedir(), '.config', 'BraveSoftware', 'Brave-Browser')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'chromium',
|
|
||||||
name: 'Chromium',
|
|
||||||
win: join(process.env.LOCALAPPDATA ?? '', 'Chromium', 'User Data'),
|
|
||||||
mac: join(homedir(), 'Library', 'Application Support', 'Chromium'),
|
|
||||||
linux: join(homedir(), '.config', 'chromium')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 受管浏览器分区目录(persist:aso-browser 的落盘位置) */
|
|
||||||
function partitionDir(): string {
|
|
||||||
return join(app.getPath('userData'), 'Partitions', 'aso-browser')
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceRoot(id: string): string {
|
|
||||||
const source = SOURCE_ROOTS.find((s) => s.id === id)
|
|
||||||
if (!source) return ''
|
|
||||||
return process.platform === 'win32' ? source.win : process.platform === 'darwin' ? source.mac : source.linux
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取 profile 的展示名(Preferences 的 profile.name,失败时用目录名) */
|
|
||||||
function readProfileName(profileDir: string): string {
|
|
||||||
try {
|
|
||||||
const file = join(profileDir, 'Preferences')
|
|
||||||
if (statSync(file).size > 8 * 1024 * 1024) return profileDir.split(/[\\/]/).pop() || profileDir
|
|
||||||
const raw = JSON.parse(readFileSync(file, 'utf8')) as { profile?: { name?: string } }
|
|
||||||
return raw.profile?.name?.trim() || profileDir.split(/[\\/]/).pop() || profileDir
|
|
||||||
} catch {
|
|
||||||
return profileDir.split(/[\\/]/).pop() || profileDir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 检测本机可导入的浏览器与 profile 列表 */
|
|
||||||
export function detectBrowserSources(): BrowserSource[] {
|
|
||||||
const sources: BrowserSource[] = []
|
|
||||||
for (const source of SOURCE_ROOTS) {
|
|
||||||
const root = sourceRoot(source.id)
|
|
||||||
if (!root || !existsSync(root)) continue
|
|
||||||
const profiles: BrowserProfileMeta[] = []
|
|
||||||
try {
|
|
||||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
||||||
if (!entry.isDirectory()) continue
|
|
||||||
if (entry.name.startsWith('.') || entry.name === 'System Profile' || entry.name === 'Guest Profile') continue
|
|
||||||
const profileDir = join(root, entry.name)
|
|
||||||
if (!existsSync(join(profileDir, 'Preferences'))) continue
|
|
||||||
profiles.push({ dir: entry.name, name: readProfileName(profileDir) })
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 目录不可读则跳过该浏览器
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (profiles.length > 0) {
|
|
||||||
sources.push({ id: source.id, name: source.name, userDataDir: root, profiles })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sources
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyFileSafe(from: string, to: string): void {
|
|
||||||
try {
|
|
||||||
copyFileSync(from, to)
|
|
||||||
} catch (err) {
|
|
||||||
const code = (err as NodeJS.ErrnoException).code
|
|
||||||
if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') {
|
|
||||||
throw new Error('源浏览器文件被占用:请完全退出对应的浏览器(含后台进程)后重试。')
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 拷贝 leveldb 目录(跳过 LOCK/LOG 运行时文件),目标已存在时先清空 */
|
|
||||||
function copyLevelDbDir(from: string, to: string): boolean {
|
|
||||||
if (!existsSync(from)) return false
|
|
||||||
const entries = readdirSync(from, { withFileTypes: true })
|
|
||||||
const files = entries.filter((e) => e.isFile() && e.name !== 'LOCK' && !e.name.startsWith('LOG'))
|
|
||||||
if (files.length === 0) return false
|
|
||||||
rmSync(to, { recursive: true, force: true })
|
|
||||||
mkdirSync(to, { recursive: true })
|
|
||||||
for (const file of files) {
|
|
||||||
copyFileSafe(join(from, file.name), join(to, file.name))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SourceKeyInfo {
|
|
||||||
key: string | null
|
|
||||||
/** app-bound(V20)等不可移植场景的说明 */
|
|
||||||
note?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。
|
|
||||||
* Windows:前缀 DPAPI 可移植;V20 为 app-bound 加密(Chrome/Edge 127+),
|
|
||||||
* 其他进程无法解开,明确降级。macOS/Linux:密钥在系统钥匙串/KWallet,跳过。
|
|
||||||
*/
|
|
||||||
function readSourceKey(userDataDir: string): SourceKeyInfo {
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
return { key: null, note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const raw = JSON.parse(readFileSync(join(userDataDir, 'Local State'), 'utf8')) as {
|
|
||||||
os_crypt?: { encrypted_key?: string }
|
|
||||||
}
|
|
||||||
const encoded = raw.os_crypt?.encrypted_key
|
|
||||||
if (!encoded) return { key: null, note: '未找到源浏览器 Cookie 密钥,跳过 Cookie 迁移。' }
|
|
||||||
const bytes = Buffer.from(encoded, 'base64')
|
|
||||||
if (bytes.subarray(0, 3).toString('utf8') === 'V20') {
|
|
||||||
return { key: null, note: '该浏览器使用了应用绑定加密(app-bound encryption),无法第三方迁移 Cookie;已导入 Local Storage,首次访问各站点仍需登录一次。' }
|
|
||||||
}
|
|
||||||
if (bytes.subarray(0, 5).toString('utf8') !== 'DPAPI') {
|
|
||||||
return { key: null, note: '源浏览器 Cookie 密钥格式不受支持,跳过 Cookie 迁移。' }
|
|
||||||
}
|
|
||||||
return { key: encoded }
|
|
||||||
} catch {
|
|
||||||
return { key: null, note: '读取源浏览器 Local State 失败,跳过 Cookie 迁移。' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把源密钥移植进本应用根 Local State(首次会备份原文件) */
|
|
||||||
function transplantKey(sourceKey: string): void {
|
|
||||||
const appLocalState = join(app.getPath('userData'), 'Local State')
|
|
||||||
const backup = `${appLocalState}.aso-backup`
|
|
||||||
let doc: Record<string, unknown> = {}
|
|
||||||
if (existsSync(appLocalState)) {
|
|
||||||
try {
|
|
||||||
doc = JSON.parse(readFileSync(appLocalState, 'utf8')) as Record<string, unknown>
|
|
||||||
if (!existsSync(backup)) copyFileSync(appLocalState, backup)
|
|
||||||
} catch {
|
|
||||||
doc = {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const osCrypt = (doc.os_crypt ?? {}) as Record<string, unknown>
|
|
||||||
osCrypt.encrypted_key = sourceKey
|
|
||||||
doc.os_crypt = osCrypt
|
|
||||||
writeFileSync(appLocalState, JSON.stringify(doc, null, 2), 'utf8')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 导入指定浏览器的 profile 配置到受管浏览器分区 */
|
|
||||||
export function importBrowserProfile(sourceId: string, profileDir: string): BrowserImportResult {
|
|
||||||
const browser = getBrowserController()
|
|
||||||
if (browser.hasTabs) {
|
|
||||||
throw new Error('请先关闭内置浏览器的所有标签页,再导入浏览器配置。')
|
|
||||||
}
|
|
||||||
const root = sourceRoot(sourceId)
|
|
||||||
if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。')
|
|
||||||
// 只接受该根目录下的直接子目录名,拒绝任意路径注入
|
|
||||||
const dirName = profileDir.split(/[\\/]/).pop() ?? ''
|
|
||||||
const sourceProfile = join(root, dirName)
|
|
||||||
if (dirName !== profileDir || !existsSync(join(sourceProfile, 'Preferences'))) {
|
|
||||||
throw new Error('profile 目录不存在。')
|
|
||||||
}
|
|
||||||
const sourceName = SOURCE_ROOTS.find((s) => s.id === sourceId)?.name ?? sourceId
|
|
||||||
const target = partitionDir()
|
|
||||||
|
|
||||||
// 1) Local Storage(明文 leveldb,直接迁移)
|
|
||||||
let localStorage = false
|
|
||||||
try {
|
|
||||||
localStorage = copyLevelDbDir(join(sourceProfile, 'Local Storage', 'leveldb'), join(target, 'Local Storage', 'leveldb'))
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(err instanceof Error ? err.message : `Local Storage 迁移失败: ${String(err)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Cookies + 密钥移植
|
|
||||||
let cookies = false
|
|
||||||
let cookieNote: string | undefined
|
|
||||||
const keyInfo = readSourceKey(root)
|
|
||||||
const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies'))
|
|
||||||
? join(sourceProfile, 'Network', 'Cookies')
|
|
||||||
: existsSync(join(sourceProfile, 'Cookies'))
|
|
||||||
? join(sourceProfile, 'Cookies')
|
|
||||||
: null
|
|
||||||
if (keyInfo.key && cookieDb) {
|
|
||||||
try {
|
|
||||||
transplantKey(keyInfo.key)
|
|
||||||
mkdirSync(join(target, 'Network'), { recursive: true })
|
|
||||||
copyFileSafe(cookieDb, join(target, 'Network', 'Cookies'))
|
|
||||||
for (const suffix of ['-wal', '-shm']) {
|
|
||||||
if (existsSync(`${cookieDb}${suffix}`)) copyFileSafe(`${cookieDb}${suffix}`, join(target, 'Network', `Cookies${suffix}`))
|
|
||||||
}
|
|
||||||
cookies = true
|
|
||||||
} catch (err) {
|
|
||||||
cookieNote = err instanceof Error ? err.message : 'Cookie 迁移失败。'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts: string[] = []
|
|
||||||
if (cookies) parts.push('Cookie(站点登录态)')
|
|
||||||
if (localStorage) parts.push('LocalStorage')
|
|
||||||
if (!cookies && !localStorage) {
|
|
||||||
return { ok: false, cookies, localStorage, cookieNote, message: `未在 ${sourceName}(${dirName})中找到可迁移的登录数据。` }
|
|
||||||
}
|
|
||||||
|
|
||||||
browser.setImportedInfo({
|
|
||||||
browser: sourceName,
|
|
||||||
profile: readProfileName(sourceProfile),
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
cookies,
|
|
||||||
localStorage,
|
|
||||||
note: cookieNote
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
cookies,
|
|
||||||
localStorage,
|
|
||||||
cookieNote,
|
|
||||||
message: `已从 ${sourceName} 导入 ${parts.join(' + ')}。${cookieNote ?? ''}${
|
|
||||||
cookies ? '若登录态未生效,重启应用后重试。' : ''
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { userInfo } from 'node:os'
|
|||||||
import { AgentService } from './pi/agent-service'
|
import { AgentService } from './pi/agent-service'
|
||||||
import { getPluginHost } from './plugins/plugin-host'
|
import { getPluginHost } from './plugins/plugin-host'
|
||||||
import { getBrowserController } from './browser/browser-controller'
|
import { getBrowserController } from './browser/browser-controller'
|
||||||
import { detectBrowserSources, importBrowserProfile } from './browser/browser-profile'
|
|
||||||
import { getLogger } from './logger'
|
import { getLogger } from './logger'
|
||||||
|
|
||||||
const log = getLogger('ipc')
|
const log = getLogger('ipc')
|
||||||
@@ -402,11 +401,15 @@ export function registerIpc(): AgentService {
|
|||||||
return browserPass(() => getBrowserController().applyLayout({ tabId, visible, revision, bounds: { x, y, width, height } }))
|
return browserPass(() => getBrowserController().applyLayout({ tabId, visible, revision, bounds: { x, y, width, height } }))
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- 本地浏览器配置导入 ----
|
// ---- 受管浏览器代理设置(persist:aso-browser 分区,保存即生效) ----
|
||||||
handle('browser:detectSources', () => browserPass(() => detectBrowserSources()))
|
handle('browser:getProxy', () => getBrowserController().getProxySettings())
|
||||||
handle('browser:importProfile', (_e, sourceId: unknown, profileDir: unknown) => {
|
handle('browser:setProxy', (_e, input: unknown) => {
|
||||||
if (typeof sourceId !== 'string' || typeof profileDir !== 'string') throw new Error('invalid import source')
|
if (!input || typeof input !== 'object') throw new Error('invalid proxy settings')
|
||||||
return browserPass(() => importBrowserProfile(sourceId, profileDir))
|
const { mode, rules, bypass } = input as Record<string, unknown>
|
||||||
|
if (mode !== 'system' && mode !== 'direct' && mode !== 'fixed') throw new Error('invalid proxy mode')
|
||||||
|
if (rules !== undefined && typeof rules !== 'string') throw new Error('invalid proxy rules')
|
||||||
|
if (bypass !== undefined && typeof bypass !== 'string') throw new Error('invalid proxy bypass')
|
||||||
|
return getBrowserController().setProxySettings({ mode, rules, bypass })
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- 插件系统 ----
|
// ---- 插件系统 ----
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ export const browserPlugin: BuiltinPlugin = {
|
|||||||
minContract: '0.1.0',
|
minContract: '0.1.0',
|
||||||
panel: true,
|
panel: true,
|
||||||
order: 10,
|
order: 10,
|
||||||
description: '受管浏览器'
|
description: '受管浏览器',
|
||||||
|
icon: 'Globe'
|
||||||
},
|
},
|
||||||
|
/** 宿主专属二级设置页(代理设置;见 SettingsModal 插件页特判) */
|
||||||
|
settingsPage: true,
|
||||||
main: (api) => {
|
main: (api) => {
|
||||||
for (const tool of browserTools) {
|
for (const tool of browserTools) {
|
||||||
api.pi.registerTool(tool)
|
api.pi.registerTool(tool)
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export interface BuiltinPlugin {
|
|||||||
main?: HarnessPluginFactory
|
main?: HarnessPluginFactory
|
||||||
/** 渲染层宿主树组件 key(renderer/src/plugins/builtin-components.ts) */
|
/** 渲染层宿主树组件 key(renderer/src/plugins/builtin-components.ts) */
|
||||||
component?: string
|
component?: string
|
||||||
|
/** 带宿主专属二级设置页(设置页插件行可点击进入、可固定一级入口;
|
||||||
|
* 不走声明式设置表单,页面内容由设置页特判渲染) */
|
||||||
|
settingsPage?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BUILTIN_PLUGINS: BuiltinPlugin[] = [browserPlugin]
|
export const BUILTIN_PLUGINS: BuiltinPlugin[] = [browserPlugin]
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ class PluginHost {
|
|||||||
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
|
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
|
||||||
private statusItems = new Map<string, Map<string, StatusItem>>()
|
private statusItems = new Map<string, Map<string, StatusItem>>()
|
||||||
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
|
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
|
||||||
|
/** 状态条目最近写入时间(悬浮胶囊"最新消息"判定),key = pluginId\0itemId */
|
||||||
|
private statusUpdatedAt = new Map<string, number>()
|
||||||
/** 跨插件服务注册表(pluginId → serviceName → impl);每次 runtime 重建时清空重建 */
|
/** 跨插件服务注册表(pluginId → serviceName → impl);每次 runtime 重建时清空重建 */
|
||||||
private services = new Map<string, Map<string, unknown>>()
|
private services = new Map<string, Map<string, unknown>>()
|
||||||
/** 已声明的存储 schema(api.storage.declareSchema;管理页 Tooltip 与 Agent 内省数据源) */
|
/** 已声明的存储 schema(api.storage.declareSchema;管理页 Tooltip 与 Agent 内省数据源) */
|
||||||
@@ -339,6 +341,7 @@ class PluginHost {
|
|||||||
log.info('reload begin')
|
log.info('reload begin')
|
||||||
this.statusItems.clear()
|
this.statusItems.clear()
|
||||||
this.toolbarButtons.clear()
|
this.toolbarButtons.clear()
|
||||||
|
this.statusUpdatedAt.clear()
|
||||||
this.services.clear()
|
this.services.clear()
|
||||||
this.storageSchemas.clear()
|
this.storageSchemas.clear()
|
||||||
await this.scan()
|
await this.scan()
|
||||||
@@ -359,7 +362,8 @@ class PluginHost {
|
|||||||
/** 设置入口是否固定到设置页一级导航(仅声明了设置表单的插件可开) */
|
/** 设置入口是否固定到设置页一级导航(仅声明了设置表单的插件可开) */
|
||||||
async setSettingsNav(id: string, on: boolean): Promise<void> {
|
async setSettingsNav(id: string, on: boolean): Promise<void> {
|
||||||
const plugin = this.resolved.get(id)
|
const plugin = this.resolved.get(id)
|
||||||
if (!plugin || !this.settingsSchemas.has(id)) return
|
// 可固定入口 = 声明了设置表单,或内置插件带宿主专属设置页(如浏览器)
|
||||||
|
if (!plugin || (!this.settingsSchemas.has(id) && !this.builtinSettingsPage(id))) return
|
||||||
log.info('set settings nav', { pluginId: id, on })
|
log.info('set settings nav', { pluginId: id, on })
|
||||||
if (on) this.settingsNavSet.add(id)
|
if (on) this.settingsNavSet.add(id)
|
||||||
else this.settingsNavSet.delete(id)
|
else this.settingsNavSet.delete(id)
|
||||||
@@ -464,6 +468,11 @@ class PluginHost {
|
|||||||
return !this.disabled.has(id)
|
return !this.disabled.has(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 内置插件是否带宿主专属二级设置页(不走声明式表单,设置页特判渲染) */
|
||||||
|
private builtinSettingsPage(id: string): boolean {
|
||||||
|
return BUILTIN_PLUGINS.some((b) => b.manifest.id === id && b.settingsPage === true)
|
||||||
|
}
|
||||||
|
|
||||||
list(): PluginInfo[] {
|
list(): PluginInfo[] {
|
||||||
return [...this.resolved.values()]
|
return [...this.resolved.values()]
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
@@ -475,8 +484,13 @@ class PluginHost {
|
|||||||
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
|
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
|
||||||
hasComponent: Boolean(p.componentKey),
|
hasComponent: Boolean(p.componentKey),
|
||||||
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
|
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
|
||||||
hasSettings: (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
hasSettings:
|
||||||
settingsNav: this.settingsNavSet.has(p.manifest.id) && (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
(this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0 ||
|
||||||
|
this.builtinSettingsPage(p.manifest.id),
|
||||||
|
settingsNav:
|
||||||
|
this.settingsNavSet.has(p.manifest.id) &&
|
||||||
|
((this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0 ||
|
||||||
|
this.builtinSettingsPage(p.manifest.id)),
|
||||||
dependencies: this.depStatus(p),
|
dependencies: this.depStatus(p),
|
||||||
storageSchema: this.storageSchemas.get(p.manifest.id),
|
storageSchema: this.storageSchemas.get(p.manifest.id),
|
||||||
error: p.error
|
error: p.error
|
||||||
@@ -534,7 +548,14 @@ class PluginHost {
|
|||||||
uiState(): PluginsUiState {
|
uiState(): PluginsUiState {
|
||||||
const status: PluginStatusEntry[] = []
|
const status: PluginStatusEntry[] = []
|
||||||
for (const [pluginId, items] of this.statusItems) {
|
for (const [pluginId, items] of this.statusItems) {
|
||||||
for (const [itemId, item] of items) status.push({ pluginId, itemId, item })
|
for (const [itemId, item] of items) {
|
||||||
|
status.push({
|
||||||
|
pluginId,
|
||||||
|
itemId,
|
||||||
|
item,
|
||||||
|
updatedAt: this.statusUpdatedAt.get(`${pluginId}\0${itemId}`) ?? Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const toolbar: PluginToolbarEntry[] = []
|
const toolbar: PluginToolbarEntry[] = []
|
||||||
for (const [pluginId, buttons] of this.toolbarButtons) {
|
for (const [pluginId, buttons] of this.toolbarButtons) {
|
||||||
@@ -552,8 +573,14 @@ class PluginHost {
|
|||||||
items = new Map()
|
items = new Map()
|
||||||
this.statusItems.set(pluginId, items)
|
this.statusItems.set(pluginId, items)
|
||||||
}
|
}
|
||||||
if (item) items.set(itemId, item)
|
const stampKey = `${pluginId}\0${itemId}`
|
||||||
else items.delete(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)
|
if (items.size === 0) this.statusItems.delete(pluginId)
|
||||||
this.send('plugins:uiState', this.uiState())
|
this.send('plugins:uiState', this.uiState())
|
||||||
}
|
}
|
||||||
@@ -581,6 +608,9 @@ class PluginHost {
|
|||||||
private clearPluginUi(pluginId: string): void {
|
private clearPluginUi(pluginId: string): void {
|
||||||
this.statusItems.delete(pluginId)
|
this.statusItems.delete(pluginId)
|
||||||
this.toolbarButtons.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.settingsSchemas.delete(pluginId)
|
||||||
this.dropSettingsCallbacks(pluginId)
|
this.dropSettingsCallbacks(pluginId)
|
||||||
this.services.delete(pluginId)
|
this.services.delete(pluginId)
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import type {
|
|||||||
AgentUiEvent,
|
AgentUiEvent,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AttachmentInfo,
|
AttachmentInfo,
|
||||||
BrowserImportResult,
|
BrowserProxySettings,
|
||||||
BrowserSource,
|
|
||||||
BrowserUiEvent,
|
BrowserUiEvent,
|
||||||
BrowserUiState,
|
BrowserUiState,
|
||||||
ChatResources,
|
ChatResources,
|
||||||
@@ -126,9 +125,9 @@ const api = {
|
|||||||
revision: number
|
revision: number
|
||||||
bounds: { x: number; y: number; width: number; height: number }
|
bounds: { x: number; y: number; width: number; height: number }
|
||||||
}): Promise<void> => ipcRenderer.invoke('browser:layout', layout),
|
}): Promise<void> => ipcRenderer.invoke('browser:layout', layout),
|
||||||
browserDetectSources: (): Promise<BrowserSource[]> => ipcRenderer.invoke('browser:detectSources'),
|
browserGetProxy: (): Promise<BrowserProxySettings> => ipcRenderer.invoke('browser:getProxy'),
|
||||||
browserImportProfile: (sourceId: string, profileDir: string): Promise<BrowserImportResult> =>
|
browserSetProxy: (input: BrowserProxySettings): Promise<BrowserProxySettings> =>
|
||||||
ipcRenderer.invoke('browser:importProfile', sourceId, profileDir),
|
ipcRenderer.invoke('browser:setProxy', input),
|
||||||
onBrowserEvent: (callback: (event: BrowserUiEvent) => void): (() => void) => {
|
onBrowserEvent: (callback: (event: BrowserUiEvent) => void): (() => void) => {
|
||||||
const listener = (_e: Electron.IpcRendererEvent, event: BrowserUiEvent): void => callback(event)
|
const listener = (_e: Electron.IpcRendererEvent, event: BrowserUiEvent): void => callback(event)
|
||||||
ipcRenderer.on('browser:event', listener)
|
ipcRenderer.on('browser:event', listener)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from './plugins/renderer-host'
|
} from './plugins/renderer-host'
|
||||||
import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui'
|
import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui'
|
||||||
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
|
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
|
||||||
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
|
import { PluginFloatingCapsule } from './plugins/PluginFloatingCapsule'
|
||||||
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
|
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
|
||||||
import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components'
|
import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components'
|
||||||
import { applyThemeColor, watchColorScheme } from './lib/theme'
|
import { applyThemeColor, watchColorScheme } from './lib/theme'
|
||||||
@@ -329,6 +329,32 @@ export default function App(): JSX.Element {
|
|||||||
// 浏览器插件(内置):面板 tab 按其启用态门控(禁用后 tab 与可开列表同步消失)
|
// 浏览器插件(内置):面板 tab 按其启用态门控(禁用后 tab 与可开列表同步消失)
|
||||||
const browserEnabled = plugins.find((p) => p.manifest.id === 'browser')?.enabled ?? true
|
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 页)
|
// 面板标签:browser(宿主内置组件)+ manifest 声明 panel 的外置插件(iframe 页)
|
||||||
const panelTabs = sideTabIds.flatMap((id) => {
|
const panelTabs = sideTabIds.flatMap((id) => {
|
||||||
if (id === 'browser') {
|
if (id === 'browser') {
|
||||||
@@ -351,8 +377,6 @@ export default function App(): JSX.Element {
|
|||||||
onCloseTab={browser.closeTab}
|
onCloseTab={browser.closeTab}
|
||||||
onCloseAllTabs={browser.closeBrowser}
|
onCloseAllTabs={browser.closeBrowser}
|
||||||
onAcknowledgeRisk={browser.acknowledgeRisk}
|
onAcknowledgeRisk={browser.acknowledgeRisk}
|
||||||
onDetectSources={browser.detectSources}
|
|
||||||
onImportProfile={browser.importProfile}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -424,8 +448,11 @@ export default function App(): JSX.Element {
|
|||||||
title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name}
|
title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name}
|
||||||
panelOpen={sideOpen}
|
panelOpen={sideOpen}
|
||||||
onTogglePanel={toggleSide}
|
onTogglePanel={toggleSide}
|
||||||
extra={<PluginStatusCapsules entries={pluginsUi.status} onOpenView={handleOpenView} />}
|
|
||||||
/>
|
/>
|
||||||
|
{/* 悬浮状态胶囊: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">
|
<div className="flex min-h-0 flex-1">
|
||||||
{/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */}
|
{/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { memo, useEffect, useMemo, useRef, useState, useTransition, type ReactNo
|
|||||||
import type { JSX, RefObject } from 'react'
|
import type { JSX, RefObject } from 'react'
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import {
|
import {
|
||||||
LuArrowLeft,
|
|
||||||
LuCheck,
|
LuCheck,
|
||||||
|
LuChevronRight,
|
||||||
LuCpu,
|
LuCpu,
|
||||||
LuDatabase,
|
LuDatabase,
|
||||||
LuFolderOpen,
|
LuFolderOpen,
|
||||||
@@ -37,6 +37,7 @@ import type { PluginSettingsSchema, PluginSettingsSchemas, PluginSettingValues,
|
|||||||
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
|
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
|
||||||
import { usePluginList } from '../plugins/store'
|
import { usePluginList } from '../plugins/store'
|
||||||
import { PluginIconValue } from '../plugins/plugin-icons'
|
import { PluginIconValue } from '../plugins/plugin-icons'
|
||||||
|
import { BrowserProxyForm } from './browser/BrowserProxyForm'
|
||||||
|
|
||||||
type PluginSettingsTab = `plugin:${string}`
|
type PluginSettingsTab = `plugin:${string}`
|
||||||
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' | PluginSettingsTab
|
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' | PluginSettingsTab
|
||||||
@@ -774,7 +775,7 @@ function PluginsPanel({
|
|||||||
onNavToggle: (pluginId: string, on: boolean) => void
|
onNavToggle: (pluginId: string, on: boolean) => void
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const plugins = usePluginList()
|
const plugins = usePluginList()
|
||||||
const [scope, setScope] = useState<'user' | 'project' | 'builtin'>('user')
|
const [scope, setScope] = useState<'all' | 'user' | 'project' | 'builtin'>('all')
|
||||||
/** 全局插件重载进行中(重扫目录 + 重建运行时;期间刷新按钮禁用旋转) */
|
/** 全局插件重载进行中(重扫目录 + 重建运行时;期间刷新按钮禁用旋转) */
|
||||||
const [reloading, setReloading] = useState(false)
|
const [reloading, setReloading] = useState(false)
|
||||||
const [dirs, setDirs] = useState<{ user: string[]; project: string[]; global: string[] }>({
|
const [dirs, setDirs] = useState<{ user: string[]; project: string[]; global: string[] }>({
|
||||||
@@ -796,17 +797,23 @@ function PluginsPanel({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const scoped =
|
const scoped =
|
||||||
scope === 'builtin'
|
scope === 'all'
|
||||||
|
? plugins
|
||||||
|
: scope === 'builtin'
|
||||||
? plugins.filter((p) => p.source === 'builtin')
|
? plugins.filter((p) => p.source === 'builtin')
|
||||||
: plugins.filter((p) => p.source === 'external' && (scope === 'project' ? p.origin === 'project' : p.origin !== 'project'))
|
: plugins.filter((p) =>
|
||||||
|
p.source === 'external' ? (scope === 'project' ? p.origin === 'project' : p.origin !== 'project') : false
|
||||||
|
)
|
||||||
const scopedDirs = scope === 'user' ? dirs.global : scope === 'project' ? dirs.project : []
|
const scopedDirs = scope === 'user' ? dirs.global : scope === 'project' ? dirs.project : []
|
||||||
const scopedHints = scope === 'user' ? ['全局'] : ['项目']
|
const scopedHints = scope === 'user' ? ['全局'] : ['项目']
|
||||||
|
const originLabel = (p: PluginInfo): string =>
|
||||||
|
p.source === 'builtin' ? '内置' : p.origin === 'project' ? '项目' : '全局'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-col">
|
<div className="flex min-h-0 flex-col">
|
||||||
<div className="flex items-center gap-2 pb-3">
|
<div className="flex items-center gap-2 pb-3">
|
||||||
<div className="flex items-center rounded-control bg-zinc-100 p-0.5">
|
<div className="flex items-center rounded-control bg-zinc-100 p-0.5">
|
||||||
{(['user', 'project', 'builtin'] as const).map((s) => (
|
{(['all', 'user', 'project', 'builtin'] as const).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -815,7 +822,7 @@ function PluginsPanel({
|
|||||||
scope === s ? 'bg-white font-medium text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'
|
scope === s ? 'bg-white font-medium text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s === 'user' ? '全局' : s === 'project' ? '项目' : '内置'}
|
{s === 'all' ? '全部' : s === 'user' ? '全局' : s === 'project' ? '项目' : '内置'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -837,9 +844,15 @@ function PluginsPanel({
|
|||||||
{scoped.length === 0 ? (
|
{scoped.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center">
|
<div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center">
|
||||||
<div className="text-[13px] font-medium text-ink-2">
|
<div className="text-[13px] font-medium text-ink-2">
|
||||||
{scope === 'builtin' ? '暂无内置插件' : devBuild ? '暂无第三方插件' : '第三方插件装载已关闭(用户版构建)'}
|
{scope === 'all'
|
||||||
|
? '暂无插件'
|
||||||
|
: scope === 'builtin'
|
||||||
|
? '暂无内置插件'
|
||||||
|
: devBuild
|
||||||
|
? '暂无第三方插件'
|
||||||
|
: '第三方插件装载已关闭(用户版构建)'}
|
||||||
</div>
|
</div>
|
||||||
{scope !== 'builtin' && devBuild && (
|
{(scope === 'user' || scope === 'project') && devBuild && (
|
||||||
<div className="text-[11.5px] text-ink-3">
|
<div className="text-[11.5px] text-ink-3">
|
||||||
把含 manifest.json 的插件目录放入
|
把含 manifest.json 的插件目录放入
|
||||||
{scope === 'user' ? '全局(~/.pi/agent/extensions)' : '项目 .pi/extensions/'}即可自动识别
|
{scope === 'user' ? '全局(~/.pi/agent/extensions)' : '项目 .pi/extensions/'}即可自动识别
|
||||||
@@ -855,7 +868,8 @@ function PluginsPanel({
|
|||||||
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) || a.manifest.id.localeCompare(b.manifest.id)
|
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) || a.manifest.id.localeCompare(b.manifest.id)
|
||||||
)
|
)
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const openSettings = p.enabled && (p.hasSettings || Boolean(p.storageSchema))
|
const openSettings =
|
||||||
|
p.enabled && (p.hasSettings || Boolean(p.storageSchema) || p.manifest.id === 'browser')
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.manifest.id}
|
key={p.manifest.id}
|
||||||
@@ -880,7 +894,7 @@ function PluginsPanel({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[12.5px] font-medium text-ink">{p.manifest.name}</span>
|
<span className="text-[12.5px] font-medium text-ink">{p.manifest.name}</span>
|
||||||
<span className="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">
|
<span className="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">
|
||||||
{scope === 'builtin' ? '内置' : scope === 'user' ? '全局' : '项目'} v{p.manifest.version}
|
{originLabel(p)} v{p.manifest.version}
|
||||||
</span>
|
</span>
|
||||||
{p.dependencies.length > 0 && <PluginDepsTip deps={p.dependencies} />}
|
{p.dependencies.length > 0 && <PluginDepsTip deps={p.dependencies} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -1542,20 +1556,46 @@ export function SettingsModal({
|
|||||||
{/* 右侧:内容页 */}
|
{/* 右侧:内容页 */}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<div className="flex items-start gap-3 px-6 pb-3 pt-5">
|
<div className="flex items-start gap-3 px-6 pb-3 pt-5">
|
||||||
{tab.startsWith('plugin:') && (
|
{tab.startsWith('plugin:') ? (
|
||||||
|
// 二级页顶部:面包屑(导航层,shadcn 语义——上级可点、ChevronRight 分隔、当前页仅标记)
|
||||||
|
// + 页标题(与一级页同级 16px semibold)+ 元数据行(来源 · 版本)
|
||||||
|
(() => {
|
||||||
|
const info = plugins.find((p) => `plugin:${p.manifest.id}` === tab)
|
||||||
|
const originLabel =
|
||||||
|
!info || info.source === 'builtin' ? '内置' : info.origin === 'project' ? '项目' : '全局'
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<nav aria-label="面包屑" className="flex items-center gap-1 text-[11.5px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab('plugins')}
|
onClick={() => setTab('plugins')}
|
||||||
className="mt-0.5 flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 text-[11.5px] text-ink-3 transition-colors hover:bg-hover hover:text-ink"
|
className="rounded-control px-1 py-0.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink"
|
||||||
>
|
>
|
||||||
<LuArrowLeft size={13} />
|
插件
|
||||||
返回
|
|
||||||
</button>
|
</button>
|
||||||
|
<LuChevronRight size={12} aria-hidden="true" className="shrink-0 text-ink-3" />
|
||||||
|
<span aria-current="page" className="truncate text-ink-2">
|
||||||
|
{info?.manifest.name ?? '插件'}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
<div className="mt-1.5 flex min-w-0 items-center gap-2">
|
||||||
|
{info && <PluginIconValue icon={info.manifest.icon} size={16} className="shrink-0 text-ink-2" />}
|
||||||
|
<div className="truncate text-[16px] font-semibold text-ink">{info?.manifest.name ?? '插件'}</div>
|
||||||
|
</div>
|
||||||
|
{info && (
|
||||||
|
<div className="mt-0.5 text-[12px] leading-5 text-ink-3">
|
||||||
|
{originLabel}插件 · v{info.manifest.version}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[16px] font-semibold text-ink">{meta.title}</div>
|
<div className="text-[16px] font-semibold text-ink">{meta.title}</div>
|
||||||
{meta.desc && <div className="mt-0.5 text-[12px] leading-5 text-ink-3">{meta.desc}</div>}
|
{meta.desc && <div className="mt-0.5 text-[12px] leading-5 text-ink-3">{meta.desc}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -1633,6 +1673,19 @@ export function SettingsModal({
|
|||||||
{tab.startsWith('plugin:') &&
|
{tab.startsWith('plugin:') &&
|
||||||
(() => {
|
(() => {
|
||||||
const pid = tab.slice(7)
|
const pid = tab.slice(7)
|
||||||
|
// 内置浏览器:专属二级页(代理设置),不走向声明式设置表单
|
||||||
|
if (pid === 'browser') {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto pb-2 pr-1">
|
||||||
|
<section className="shrink-0 rounded-card border border-line">
|
||||||
|
<div className="border-b border-line px-3 py-2 text-[12px] font-medium text-ink">代理设置</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<BrowserProxyForm />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
const schema = settingSchemas[pid]
|
const schema = settingSchemas[pid]
|
||||||
const info = plugins.find((p) => p.manifest.id === pid)
|
const info = plugins.find((p) => p.manifest.id === pid)
|
||||||
const hasForm = Boolean(schema && schema.items.length > 0)
|
const hasForm = Boolean(schema && schema.items.length > 0)
|
||||||
|
|||||||
@@ -70,18 +70,26 @@ export function SidePanel({
|
|||||||
onWidthChange(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragRef.current.startWidth + delta)))
|
onWidthChange(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragRef.current.startWidth + delta)))
|
||||||
}
|
}
|
||||||
const onUp = (): void => {
|
const onUp = (): void => {
|
||||||
|
if (!dragRef.current) return
|
||||||
dragRef.current = null
|
dragRef.current = null
|
||||||
|
document.body.style.userSelect = ''
|
||||||
}
|
}
|
||||||
window.addEventListener('pointermove', onMove)
|
window.addEventListener('pointermove', onMove)
|
||||||
window.addEventListener('pointerup', onUp)
|
window.addEventListener('pointerup', onUp)
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('pointermove', onMove)
|
window.removeEventListener('pointermove', onMove)
|
||||||
window.removeEventListener('pointerup', onUp)
|
window.removeEventListener('pointerup', onUp)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
}
|
}
|
||||||
}, [onWidthChange])
|
}, [onWidthChange])
|
||||||
|
|
||||||
const startResize = (e: React.PointerEvent): void => {
|
const startResize = (e: React.PointerEvent): void => {
|
||||||
|
// preventDefault 必须在 pointerdown 上:否则 Chromium 会把它当作文本选择
|
||||||
|
// 手势的锚点,拖动经过聊天区/面板文字时把沿途内容选中;拖拽期间再对
|
||||||
|
// body 全局禁选(双保险,覆盖手势未被拦截的边缘情况),松手恢复
|
||||||
|
e.preventDefault()
|
||||||
dragRef.current = { startX: e.clientX, startWidth: width }
|
dragRef.current = { startX: e.clientX, startWidth: width }
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 菜单打开时 Esc 关闭
|
// 菜单打开时 Esc 关闭
|
||||||
|
|||||||
@@ -455,8 +455,9 @@ export function SidebarNav({
|
|||||||
})}
|
})}
|
||||||
</GlideMenu>
|
</GlideMenu>
|
||||||
|
|
||||||
{/* 会话列表区 */}
|
{/* 会话列表区:折叠态 margin 收敛为 1px,让「会话」切换图标与上方
|
||||||
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
|
导航图标保持同一垂直节奏(行高 32 + 间隙 1px 等距) */}
|
||||||
|
<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="relative mx-2 mb-1 h-8">
|
||||||
<div className="sidebar-copy absolute inset-0">
|
<div className="sidebar-copy absolute inset-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import { useEffect, useState, type JSX, type ReactNode } from 'react'
|
import { useEffect, useState, type JSX } from 'react'
|
||||||
import { LuPanelRight } from 'react-icons/lu'
|
import { LuPanelRight } from 'react-icons/lu'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
|
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
|
||||||
* 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」);
|
* 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」);
|
||||||
* title 之后可挂 extra 槽(插件状态胶囊);右侧自绘窗口控制按钮(最小化/
|
* 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
|
||||||
* 最大化还原/关闭),高度 40px。不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
* 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
||||||
|
* (插件悬浮状态胶囊不再挂这里:改悬浮于 main 区域右上角。)
|
||||||
*/
|
*/
|
||||||
export function TitleBar({
|
export function TitleBar({
|
||||||
title,
|
title,
|
||||||
panelOpen,
|
panelOpen,
|
||||||
onTogglePanel,
|
onTogglePanel
|
||||||
extra
|
|
||||||
}: {
|
}: {
|
||||||
title?: string
|
title?: string
|
||||||
panelOpen: boolean
|
panelOpen: boolean
|
||||||
onTogglePanel: () => void
|
onTogglePanel: () => void
|
||||||
/** 标题右侧插槽(插件状态胶囊等;聊天/插件视图下均可见) */
|
|
||||||
extra?: ReactNode
|
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const name = title?.trim()
|
const name = title?.trim()
|
||||||
const [maximized, setMaximized] = useState(false)
|
const [maximized, setMaximized] = useState(false)
|
||||||
@@ -27,7 +25,6 @@ export function TitleBar({
|
|||||||
return (
|
return (
|
||||||
<div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-line bg-surface pl-4 [-webkit-app-region:drag]">
|
<div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-line bg-surface pl-4 [-webkit-app-region:drag]">
|
||||||
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div>
|
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div>
|
||||||
{extra && <div className="ml-3 flex shrink-0 items-center [-webkit-app-region:no-drag]">{extra}</div>}
|
|
||||||
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
|
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,20 +4,21 @@ import {
|
|||||||
LuArrowRight,
|
LuArrowRight,
|
||||||
LuChevronDown,
|
LuChevronDown,
|
||||||
LuCircleAlert,
|
LuCircleAlert,
|
||||||
LuDownload,
|
|
||||||
LuGlobe,
|
LuGlobe,
|
||||||
|
LuNetwork,
|
||||||
LuPlus,
|
LuPlus,
|
||||||
LuRotateCw,
|
LuRotateCw,
|
||||||
LuX
|
LuX
|
||||||
} from 'react-icons/lu'
|
} from 'react-icons/lu'
|
||||||
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types'
|
import type { BrowserUiState } from '@shared/types'
|
||||||
import { BrowserSlot } from './BrowserSlot'
|
import { BrowserSlot } from './BrowserSlot'
|
||||||
|
import { BrowserProxyForm } from './BrowserProxyForm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/导入浮层)。
|
* 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/代理浮层)。
|
||||||
* 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView
|
* 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView
|
||||||
* (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。
|
* (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。
|
||||||
* 任何全屏浮层(设置/搜索/导入)出现时必须卸载 Slot 隐藏原生视图。
|
* 任何全屏浮层(设置/搜索/代理)出现时必须卸载 Slot 隐藏原生视图。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface BrowserPanelProps {
|
interface BrowserPanelProps {
|
||||||
@@ -35,14 +36,12 @@ interface BrowserPanelProps {
|
|||||||
/** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */
|
/** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */
|
||||||
onCloseAllTabs: () => void
|
onCloseAllTabs: () => void
|
||||||
onAcknowledgeRisk: () => void
|
onAcknowledgeRisk: () => void
|
||||||
onDetectSources: () => Promise<BrowserSource[]>
|
|
||||||
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
||||||
const { state, suspended } = props
|
const { state, suspended } = props
|
||||||
const [address, setAddress] = useState('')
|
const [address, setAddress] = useState('')
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [proxyOpen, setProxyOpen] = useState(false)
|
||||||
const [tabListOpen, setTabListOpen] = useState(false)
|
const [tabListOpen, setTabListOpen] = useState(false)
|
||||||
const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null)
|
const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null)
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
|||||||
|
|
||||||
const riskBanner = state && !state.riskAcknowledged
|
const riskBanner = state && !state.riskAcknowledged
|
||||||
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上)
|
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上)
|
||||||
const overlayOpen = importOpen || tabListOpen || tabMenu !== null
|
const overlayOpen = proxyOpen || tabListOpen || tabMenu !== null
|
||||||
// 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单)
|
// 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单)
|
||||||
const hideCause = suspended || overlayOpen
|
const hideCause = suspended || overlayOpen
|
||||||
|
|
||||||
@@ -356,11 +355,12 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setImportOpen(true)}
|
onClick={() => setProxyOpen(true)}
|
||||||
title="导入本机浏览器登录态"
|
title="代理设置"
|
||||||
|
aria-expanded={proxyOpen}
|
||||||
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
||||||
>
|
>
|
||||||
<LuDownload size={13} />
|
<LuNetwork size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -384,8 +384,9 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 页面区域:原生 WebContentsView 对齐到这里的矩形。
|
{/* 页面区域:原生 WebContentsView 对齐到这里的矩形。
|
||||||
* 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白 */}
|
* 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白。
|
||||||
<div className="relative min-h-0 flex-1 bg-page">
|
* 左内缩 4px:原生视图盖住一切 DOM,必须让出侧面板左缘的拖拽把手 */}
|
||||||
|
<div className="relative min-h-0 flex-1 bg-page pl-1">
|
||||||
{activeTab && !slotHidden ? (
|
{activeTab && !slotHidden ? (
|
||||||
<BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} />
|
<BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} />
|
||||||
) : activeTab ? (
|
) : activeTab ? (
|
||||||
@@ -403,69 +404,25 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent 操作轨迹 */}
|
{/* Agent 操作轨迹 */}
|
||||||
{state?.trace && !importOpen && (
|
{state?.trace && (
|
||||||
<div className="shrink-0 truncate border-t border-line bg-surface px-3 py-1.5 text-[10.5px] text-ink-3">
|
<div className="shrink-0 truncate border-t border-line bg-surface px-3 py-1.5 text-[10.5px] text-ink-3">
|
||||||
{state.trace}
|
{state.trace}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 配置导入浮层(覆盖面板,出现时原生视图已隐藏) */}
|
{/* 代理设置浮层(覆盖面板,出现时原生视图已隐藏) */}
|
||||||
{importOpen && (
|
{proxyOpen && <ProxyOverlay onClose={() => setProxyOpen(false)} />}
|
||||||
<ImportOverlay
|
|
||||||
state={state}
|
|
||||||
onClose={() => setImportOpen(false)}
|
|
||||||
onDetectSources={props.onDetectSources}
|
|
||||||
onImportProfile={props.onImportProfile}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</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))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function ProxyOverlay({ onClose }: { onClose: () => void }): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]">
|
<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="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>
|
<div className="text-[12.5px] font-medium text-ink-2">代理设置</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
@@ -475,86 +432,8 @@ function ImportOverlay({
|
|||||||
<LuX size={13} />
|
<LuX size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-3 text-[12px] text-ink-2">
|
<BrowserProxyForm />
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* 受管浏览器代理设置表单(面板浮层与设置页二级页共用)。
|
||||||
|
* 只作用于 persist:aso-browser 分区;保存即生效(主进程 setProxy + 断开在途连接)。
|
||||||
|
* 自含数据源:自行订阅 browser 事件流获取当前生效设置。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState, type JSX } from 'react'
|
||||||
|
import type { BrowserProxySettings, BrowserUiState } from '@shared/types'
|
||||||
|
|
||||||
|
const PROXY_MODES: Array<{ id: BrowserProxySettings['mode']; label: string }> = [
|
||||||
|
{ id: 'system', label: '跟随系统' },
|
||||||
|
{ id: 'direct', label: '直连' },
|
||||||
|
{ id: 'fixed', label: '手动代理' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function BrowserProxyForm(): JSX.Element {
|
||||||
|
const [state, setState] = useState<BrowserUiState | null>(null)
|
||||||
|
const [mode, setMode] = useState<BrowserProxySettings['mode']>('system')
|
||||||
|
const [rules, setRules] = useState('')
|
||||||
|
const [bypass, setBypass] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
// 当前生效设置:首查 + 事件跟随(主进程广播,保存成功后表单同步)
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
window.api
|
||||||
|
.browserGetState()
|
||||||
|
.then((s) => {
|
||||||
|
if (alive) setState(s)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
const off = window.api.onBrowserEvent((event) => {
|
||||||
|
if (event.type === 'state' && alive) setState(event.state)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
off()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const current = state?.proxy
|
||||||
|
useEffect(() => {
|
||||||
|
if (!current) return
|
||||||
|
setMode(current.mode)
|
||||||
|
setRules(current.mode === 'fixed' ? (current.rules ?? '') : '')
|
||||||
|
setBypass(current.bypass ?? '')
|
||||||
|
}, [current?.mode, current?.rules, current?.bypass])
|
||||||
|
|
||||||
|
const save = (): void => {
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
setSaved(false)
|
||||||
|
window.api
|
||||||
|
.browserSetProxy({ mode, rules: rules.trim(), bypass: bypass.trim() })
|
||||||
|
.then((p) => {
|
||||||
|
setMode(p.mode)
|
||||||
|
setRules(p.rules ?? '')
|
||||||
|
setBypass(p.bypass ?? '')
|
||||||
|
setSaved(true)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setBusy(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'w-full rounded border border-line bg-inset px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-accent focus:bg-surface'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-[12px] text-ink-2">
|
||||||
|
<div className="flex w-fit items-center rounded-control border border-line bg-hover p-0.5">
|
||||||
|
{PROXY_MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode(m.id)}
|
||||||
|
className={`h-7 rounded-[6px] px-3 text-[12px] transition-colors ${
|
||||||
|
mode === m.id ? 'bg-surface font-medium text-ink shadow-card' : 'text-ink-3 hover:text-ink-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'fixed' && (
|
||||||
|
<div className="mt-3 flex flex-col gap-2.5">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-ink-3">代理规则(多协议用分号分隔)</span>
|
||||||
|
<input
|
||||||
|
value={rules}
|
||||||
|
onChange={(e) => setRules(e.target.value)}
|
||||||
|
placeholder="127.0.0.1:7890 或 http=127.0.0.1:7890;https=127.0.0.1:7890"
|
||||||
|
spellCheck={false}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-ink-3">绕过列表(逗号分隔,可留空)</span>
|
||||||
|
<input
|
||||||
|
value={bypass}
|
||||||
|
onChange={(e) => setBypass(e.target.value)}
|
||||||
|
placeholder="<local>,*.example.com,127.0.0.1"
|
||||||
|
spellCheck={false}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-[10.5px] leading-4 text-ink-3">
|
||||||
|
规则语法与 Chromium 一致:host:port 应用于全部协议;也可按协议指定(http=…;https=…;socks=…)。
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
{saved && !error && (
|
||||||
|
<div className="mt-3 rounded border border-green-200 bg-green-tint px-2.5 py-2 text-[11.5px] text-green">
|
||||||
|
已保存并立即生效(在途连接已重置)。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || (mode === 'fixed' && !rules.trim())}
|
||||||
|
onClick={save}
|
||||||
|
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busy ? '应用中…' : '保存并应用'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types'
|
import type { BrowserUiState } from '@shared/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内置受管浏览器面板状态与操作。
|
* 内置受管浏览器面板状态与操作。
|
||||||
@@ -16,8 +16,6 @@ export function useBrowser(): {
|
|||||||
closeTab: (tabId: string) => void
|
closeTab: (tabId: string) => void
|
||||||
closeBrowser: () => void
|
closeBrowser: () => void
|
||||||
acknowledgeRisk: () => void
|
acknowledgeRisk: () => void
|
||||||
detectSources: () => Promise<BrowserSource[]>
|
|
||||||
importProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
|
||||||
} {
|
} {
|
||||||
const [state, setState] = useState<BrowserUiState | null>(null)
|
const [state, setState] = useState<BrowserUiState | null>(null)
|
||||||
|
|
||||||
@@ -50,8 +48,6 @@ export function useBrowser(): {
|
|||||||
selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)),
|
selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)),
|
||||||
closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)),
|
closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)),
|
||||||
closeBrowser: wrap(() => window.api.browserClose()),
|
closeBrowser: wrap(() => window.api.browserClose()),
|
||||||
acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk()),
|
acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk())
|
||||||
detectSources: () => window.api.browserDetectSources(),
|
|
||||||
importProfile: (sourceId: string, profileDir: string) => window.api.browserImportProfile(sourceId, profileDir)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import type {
|
|||||||
AgentUiEvent,
|
AgentUiEvent,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AttachmentInfo,
|
AttachmentInfo,
|
||||||
BrowserImportResult,
|
BrowserProxySettings,
|
||||||
BrowserSource,
|
|
||||||
BrowserUiEvent,
|
BrowserUiEvent,
|
||||||
BrowserUiState,
|
BrowserUiState,
|
||||||
ChatResources,
|
ChatResources,
|
||||||
@@ -36,8 +35,6 @@ export type {
|
|||||||
SessionMeta,
|
SessionMeta,
|
||||||
SessionState,
|
SessionState,
|
||||||
SessionStatsInfo,
|
SessionStatsInfo,
|
||||||
BrowserImportResult,
|
|
||||||
BrowserSource,
|
|
||||||
BrowserUiEvent,
|
BrowserUiEvent,
|
||||||
BrowserUiState
|
BrowserUiState
|
||||||
}
|
}
|
||||||
@@ -113,8 +110,8 @@ declare global {
|
|||||||
revision: number
|
revision: number
|
||||||
bounds: { x: number; y: number; width: number; height: number }
|
bounds: { x: number; y: number; width: number; height: number }
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
browserDetectSources: () => Promise<BrowserSource[]>
|
browserGetProxy: () => Promise<BrowserProxySettings>
|
||||||
browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
browserSetProxy: (input: BrowserProxySettings) => Promise<BrowserProxySettings>
|
||||||
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void
|
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void
|
||||||
// ---- 插件系统 ----
|
// ---- 插件系统 ----
|
||||||
pluginsList: () => Promise<PluginInfo[]>
|
pluginsList: () => Promise<PluginInfo[]>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
/**
|
|
||||||
* 插件状态胶囊(TitleBar B2 槽位):数据驱动渲染主进程广播的状态条目。
|
|
||||||
* 顶栏胶囊只是紧凑状态条;点击行为分三档:
|
|
||||||
* 1. 声明了 detail → 展开宿主渲染的详情面板(ZCode 式:头部标题+关闭、
|
|
||||||
* 分组列表、行尾彩色数值;行内数据再负责深链)
|
|
||||||
* 2. 声明了 openView → 执行深链
|
|
||||||
* 3. 都没有 → 纯展示
|
|
||||||
* 聊天视图与插件视图下均可见。面板点击外部/Esc 关闭。
|
|
||||||
*/
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import { LuX } from 'react-icons/lu'
|
|
||||||
import type { PluginStatusEntry } from '@shared/types'
|
|
||||||
import { PluginIconValue } from './plugin-icons'
|
|
||||||
|
|
||||||
/** 胶囊前导色点:tone 的安静表达(胶囊底保持中性,不整块染色) */
|
|
||||||
export const TONE_DOT_CLASS: Record<string, string> = {
|
|
||||||
default: 'bg-ink-3',
|
|
||||||
accent: 'bg-accent',
|
|
||||||
success: 'bg-green',
|
|
||||||
warn: 'bg-orange',
|
|
||||||
danger: 'bg-red'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 行尾数值的着色(ZCode 式:+76 绿 / -40 红 的纯色文本,不用底色块) */
|
|
||||||
const TRAILING_TONE_CLASS: Record<string, string> = {
|
|
||||||
default: 'text-ink-3',
|
|
||||||
accent: 'text-accent',
|
|
||||||
success: 'text-green',
|
|
||||||
warn: 'text-orange',
|
|
||||||
danger: 'text-red'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 详情面板(ZCode Git 工具式):头部标题 + 关闭,分组列表;
|
|
||||||
* 行 = 图标 + 标签(+次级说明)+ 行尾彩色数值。direction 控制从胶囊下方/上方弹出。
|
|
||||||
*/
|
|
||||||
export function StatusPopover({
|
|
||||||
entry,
|
|
||||||
onOpenView,
|
|
||||||
onClose,
|
|
||||||
direction = 'down'
|
|
||||||
}: {
|
|
||||||
entry: PluginStatusEntry
|
|
||||||
onOpenView: (view: string, params?: unknown) => void
|
|
||||||
onClose: () => void
|
|
||||||
direction?: 'down' | 'up'
|
|
||||||
}): ReactNode {
|
|
||||||
const { item } = entry
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute right-0 z-50 w-80 overflow-hidden rounded-card border border-line bg-surface shadow-overlay ${
|
|
||||||
direction === 'up' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2 py-2 pl-3 pr-1.5">
|
|
||||||
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{item.detail?.title ?? item.text}</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="关闭"
|
|
||||||
onClick={onClose}
|
|
||||||
className="shrink-0 rounded-control p-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
|
||||||
>
|
|
||||||
<LuX size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-96 overflow-y-auto overscroll-contain border-t border-line px-1.5 py-1.5">
|
|
||||||
{item.detail?.sections.map((section, si) => (
|
|
||||||
<div key={si} className={si > 0 ? 'mt-1' : ''}>
|
|
||||||
{section.title && (
|
|
||||||
<div className="px-2 pb-1 pt-1.5 text-[11px] font-medium text-ink-2">{section.title}</div>
|
|
||||||
)}
|
|
||||||
{section.rows.map((row, ri) => {
|
|
||||||
const clickable = Boolean(row.openView)
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
{row.icon && <PluginIconValue icon={row.icon} size={14} className="shrink-0 text-ink-2" />}
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block truncate text-[12px] leading-4 text-ink">{row.label}</span>
|
|
||||||
{row.detail && <span className="mt-0.5 block truncate text-[11px] leading-4 text-ink-3">{row.detail}</span>}
|
|
||||||
</span>
|
|
||||||
{row.trailing && (
|
|
||||||
<span className={`shrink-0 text-[11.5px] font-medium ${TRAILING_TONE_CLASS[row.tone ?? 'default']}`}>
|
|
||||||
{row.trailing}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
return clickable ? (
|
|
||||||
<button
|
|
||||||
key={ri}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (row.openView) onOpenView(row.openView.view, row.openView.params)
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2 rounded-control px-2 py-2 text-left transition-colors hover:bg-hover"
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div key={ri} className="flex w-full items-center gap-2 rounded-control px-2 py-2">
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PluginStatusCapsules({
|
|
||||||
entries,
|
|
||||||
onOpenView
|
|
||||||
}: {
|
|
||||||
entries: PluginStatusEntry[]
|
|
||||||
onOpenView: (view: string, params?: unknown) => void
|
|
||||||
}): ReactNode {
|
|
||||||
const [openKey, setOpenKey] = useState<string | null>(null)
|
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
// 弹层开着时:点外部 / Esc 关闭;条目消失(插件停用/重载)也收起
|
|
||||||
useEffect(() => {
|
|
||||||
if (!openKey) return
|
|
||||||
const onDown = (e: MouseEvent): void => {
|
|
||||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpenKey(null)
|
|
||||||
}
|
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
|
||||||
if (e.key === 'Escape') setOpenKey(null)
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', onDown)
|
|
||||||
document.addEventListener('keydown', onKey)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', onDown)
|
|
||||||
document.removeEventListener('keydown', onKey)
|
|
||||||
}
|
|
||||||
}, [openKey])
|
|
||||||
|
|
||||||
// 条目集合变化(停用/重载/删除)时收起弹层;按 key 序列比较,避免引用变化误关
|
|
||||||
const entryKeys = entries.map((e) => `${e.pluginId}:${e.itemId}`).join(',')
|
|
||||||
useEffect(() => {
|
|
||||||
setOpenKey(null)
|
|
||||||
}, [entryKeys])
|
|
||||||
|
|
||||||
if (entries.length === 0) return null
|
|
||||||
return (
|
|
||||||
<div ref={rootRef} className="flex min-w-0 items-center gap-1.5 [-webkit-app-region:no-drag]">
|
|
||||||
{entries.slice(0, 4).map(({ pluginId, itemId, item }) => {
|
|
||||||
const tone = item.tone ?? 'default'
|
|
||||||
const key = `${pluginId}:${itemId}`
|
|
||||||
const expandable = Boolean(item.detail)
|
|
||||||
const clickable = expandable || Boolean(item.openView)
|
|
||||||
const open = openKey === key
|
|
||||||
return (
|
|
||||||
<div key={key} className="relative shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={item.text}
|
|
||||||
onClick={() => {
|
|
||||||
if (item.detail) setOpenKey(open ? null : key)
|
|
||||||
else if (item.openView) onOpenView(item.openView.view, item.openView.params)
|
|
||||||
}}
|
|
||||||
className={`flex max-w-44 shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface px-2 py-0.5 text-[11px] leading-[18px] text-ink-2 transition-colors ${
|
|
||||||
clickable ? 'cursor-pointer hover:border-line-strong hover:text-ink' : 'cursor-default'
|
|
||||||
} ${open ? 'border-line-strong text-ink' : ''}`}
|
|
||||||
>
|
|
||||||
<span aria-hidden className={`size-1.5 shrink-0 rounded-full ${TONE_DOT_CLASS[tone] ?? TONE_DOT_CLASS.default}`} />
|
|
||||||
{item.icon ? <PluginIconValue icon={item.icon} size={12} className="shrink-0" /> : null}
|
|
||||||
<span className="truncate">{item.text}</span>
|
|
||||||
</button>
|
|
||||||
{open && item.detail && (
|
|
||||||
<StatusPopover entry={{ pluginId, itemId, item }} onOpenView={onOpenView} onClose={() => setOpenKey(null)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{entries.length > 4 && (
|
|
||||||
<span className="shrink-0 rounded-full border border-line px-2 py-0.5 text-[11px] leading-[18px] text-ink-3">
|
|
||||||
+{entries.length - 4}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -271,16 +271,6 @@ export interface BrowserClosedTab {
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已导入的本地浏览器配置摘要(面板展示用) */
|
|
||||||
export interface BrowserImportInfo {
|
|
||||||
browser: string
|
|
||||||
profile: string
|
|
||||||
at: string
|
|
||||||
cookies: boolean
|
|
||||||
localStorage: boolean
|
|
||||||
note?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 浏览器面板完整状态(事件与查询共用同一结构) */
|
/** 浏览器面板完整状态(事件与查询共用同一结构) */
|
||||||
export interface BrowserUiState {
|
export interface BrowserUiState {
|
||||||
/** 是否存在受管浏览器会话(有标签页即视为打开) */
|
/** 是否存在受管浏览器会话(有标签页即视为打开) */
|
||||||
@@ -293,36 +283,22 @@ export interface BrowserUiState {
|
|||||||
recentClosed: BrowserClosedTab[]
|
recentClosed: BrowserClosedTab[]
|
||||||
/** 最近一次 Agent 浏览器操作描述(面板底部轨迹) */
|
/** 最近一次 Agent 浏览器操作描述(面板底部轨迹) */
|
||||||
trace: string | null
|
trace: string | null
|
||||||
imported?: BrowserImportInfo | null
|
/** 当前代理设置(面板展示与编辑;变更即广播) */
|
||||||
|
proxy: BrowserProxySettings
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 受管浏览器代理设置(作用于 persist:aso-browser 分区;保存后立即生效) */
|
||||||
|
export interface BrowserProxySettings {
|
||||||
|
/** system=跟随系统(Electron 默认)| direct=直连 | fixed=手动规则 */
|
||||||
|
mode: 'system' | 'direct' | 'fixed'
|
||||||
|
/** fixed 模式:Chromium proxyRules 语法——host:port,多协议用分号分隔,可选 scheme= 前缀(如 http=127.0.0.1:7890;https=127.0.0.1:7890) */
|
||||||
|
rules?: string
|
||||||
|
/** 绕过列表(逗号分隔:<local>、*.example.com 等;Chromium bypass 语法) */
|
||||||
|
bypass?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BrowserUiEvent = { type: 'state'; state: BrowserUiState }
|
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 }
|
||||||
@@ -389,7 +365,7 @@ export interface PluginInfo {
|
|||||||
hasComponent: boolean
|
hasComponent: boolean
|
||||||
/** 有渲染层模块(entry.renderer,宿主动态 import 后注册宿主内 React 组件) */
|
/** 有渲染层模块(entry.renderer,宿主动态 import 后注册宿主内 React 组件) */
|
||||||
hasRenderer: boolean
|
hasRenderer: boolean
|
||||||
/** 已声明设置表单(api.ui.registerSettings;空 schema 视为无) */
|
/** 有设置页可进:已声明设置表单(api.ui.registerSettings),或内置插件带宿主专属设置页(如浏览器) */
|
||||||
hasSettings: boolean
|
hasSettings: boolean
|
||||||
/** 设置入口已固定到设置页一级导航(用户开关;仅 hasSettings 时可开) */
|
/** 设置入口已固定到设置页一级导航(用户开关;仅 hasSettings 时可开) */
|
||||||
settingsNav: boolean
|
settingsNav: boolean
|
||||||
@@ -406,6 +382,8 @@ export interface PluginStatusEntry {
|
|||||||
pluginId: string
|
pluginId: string
|
||||||
itemId: string
|
itemId: string
|
||||||
item: StatusItem
|
item: StatusItem
|
||||||
|
/** 最近一次写入时间(宿主记录;悬浮胶囊据此展示"插件最新消息") */
|
||||||
|
updatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工具栏按钮条目 */
|
/** 工具栏按钮条目 */
|
||||||
|
|||||||
+1
-2
@@ -181,11 +181,10 @@ api.ui.onSettingsAction((key) => {}) // button 条目点击回调(key = 按钮
|
|||||||
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
|
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
|
||||||
| 插件整页 Main | 内置组件级 / 外置 renderer 组件 / 外置 iframe;聊天列 display:none 保活 | ✅ |
|
| 插件整页 Main | 内置组件级 / 外置 renderer 组件 / 外置 iframe;聊天列 display:none 保活 | ✅ |
|
||||||
| 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件 / 外置 renderer 组件 / iframe) | ✅ |
|
| 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件 / 外置 renderer 组件 / iframe) | ✅ |
|
||||||
| 状态胶囊(B2) | `setStatusItem` 数据驱动,TitleBar 渲染,两种视图均可见;顶栏为紧凑状态条,点击可展开宿主渲染的详情弹层(`detail`:分组/行/徽标/行内深链) | ✅ |
|
| 状态胶囊(B2+B3) | `setStatusItem` 数据驱动,**悬浮胶囊**挂 main 区右上角(ZCode 复刻):最小化药丸展示最新一条消息(宿主记录 updatedAt),展开面板头即插件名、`detail` 分组为手风琴(默认收起显示 标题+trailing 徽标,点击展开行)、最大高度随内容自适应、行尾彩色数值与行内深链 | ✅ |
|
||||||
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
|
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
|
||||||
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
|
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
|
||||||
| 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 |
|
| 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 |
|
||||||
| 聊天区浮动胶囊(B3) | 独立浮动态暂缓——顶栏胶囊的详情面板(ZCode 式)已覆盖同场景;数据同源(`setStatusItem`) | ⏳ |
|
|
||||||
|
|
||||||
## 交互基线
|
## 交互基线
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
> 前置:[L0](./01-hello-plugin.md)。宿主开放 5 个数据驱动 UI 槽位 + 1 个 iframe 页形态。**数据驱动**意味着插件只提交数据,渲染与样式由宿主统一负责——这是 UI 一致性的第一道保障(自定义视觉见 [DESIGN.md](./DESIGN.md) 与 [L4](./05-renderer-components.md))。
|
> 前置:[L0](./01-hello-plugin.md)。宿主开放 5 个数据驱动 UI 槽位 + 1 个 iframe 页形态。**数据驱动**意味着插件只提交数据,渲染与样式由宿主统一负责——这是 UI 一致性的第一道保障(自定义视觉见 [DESIGN.md](./DESIGN.md) 与 [L4](./05-renderer-components.md))。
|
||||||
|
|
||||||
## 1. 状态胶囊(TitleBar 顶部)
|
## 1. 状态胶囊(悬浮胶囊:最小化药丸 ⇄ 展开面板)
|
||||||
|
|
||||||
顶栏胶囊是**紧凑状态条**,点击行为三档:
|
所有插件**共用一个悬浮胶囊**,悬浮于 main 区域右上角(侧面板打开时自动左移让位;ZCode 复刻)。插件照常 `setStatusItem`,宿主记录每次写入时间,两种形态:
|
||||||
|
|
||||||
|
- **最小化药丸**:展示「最新的消息」(全部插件中 `updatedAt` 最新的一条:tone 点 + 插件图标 + 文本;多于一条时带总数角标),点击展开面板;
|
||||||
|
- **展开面板**:面板头就是插件名(第一个区域的右侧是收回药丸的 ⤡);多个插件时各占一个区域、区域头可收起/展开(记忆跨启动)。区域内 `detail` 的**分组是手风琴**——默认收起只显示「分组标题 + 行尾徽标」,点击展开 rows(ZCode 式「计划 ›」);无标题分组直接平铺。面板最大高度封顶(min(480px,58vh)),高度随内容自适应,溢出才滚动;点击面板外部 / Esc 收回药丸。
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// ① 纯展示
|
// ① 纯展示(只出现在药丸/区域行)
|
||||||
api.ui.setStatusItem('conn', { text: '已连接 · 3 任务' })
|
api.ui.setStatusItem('conn', { text: '已连接 · 3 任务' })
|
||||||
|
|
||||||
// ② 点击深链
|
// ② 点击深链
|
||||||
@@ -18,12 +21,12 @@ api.ui.setStatusItem('conn', {
|
|||||||
openView: { view: 'my-plugin' } // 点击跳到插件整页视图
|
openView: { view: 'my-plugin' } // 点击跳到插件整页视图
|
||||||
})
|
})
|
||||||
|
|
||||||
// ③ 点击展开详情弹层(声明了 detail 时优先于 openView)
|
// ③ 携详情(分组 = 手风琴:收起显示 标题+trailing 徽标,点击展开 rows)
|
||||||
api.ui.setStatusItem('tasks', {
|
api.ui.setStatusItem('tasks', {
|
||||||
text: '任务 2/5',
|
text: '任务 2/5',
|
||||||
tone: 'accent',
|
tone: 'accent',
|
||||||
detail: {
|
detail: {
|
||||||
title: '任务概览', // 缺省用胶囊文本
|
title: '任务概览', // 面板中省略(区域头已是插件名)
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
title: '进行中', // 分组标题可省略
|
title: '进行中', // 分组标题可省略
|
||||||
|
|||||||
+3
-3
@@ -12,8 +12,8 @@
|
|||||||
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
|
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
|
||||||
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
|
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
|
||||||
| v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 |
|
| 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 Git 工具式:头部+分组+行尾彩色数值,B3 浮动态并入顶栏胶囊)、图标统一 lucide 白名单、面板平铺标签条 |
|
| 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 复刻:最小化药丸展示最新消息 ⇄ 展开面板每插件一区域、区域可收起、固定高度滚动、行尾彩色数值)、受管浏览器代理设置(persist 分区 session.setProxy:跟随系统/直连/手动 Chromium proxyRules + 绕过列表,保存即生效并对 browser_* 工具同样生效)、图标统一 lucide 白名单、面板平铺标签条 |
|
||||||
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B3/B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
|
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
**自省**(v0.8.0 → 指导 M4+):
|
**自省**(v0.8.0 → 指导 M4+):
|
||||||
1. **外置插件尚未强制签名**——当前 `userData/plugins/` 直接装载(代码里已留 TODO);M5 验签落地前不对外分发任何外置插件。
|
1. **外置插件尚未强制签名**——当前 `userData/plugins/` 直接装载(代码里已留 TODO);M5 验签落地前不对外分发任何外置插件。
|
||||||
2. **B3/B4/B5 槽位数据已通、渲染未接**——状态胶囊已在 TitleBar 生效;Composer 工具行按钮、聊天流围栏块元组件(data-table 等)、聊天区浮动胶囊排 M4 同批或下版本。
|
2. **B4/B5 槽位数据已通、渲染未接**——状态胶囊已升级为悬浮胶囊(B3 落地:药丸⇄面板);Composer 工具行按钮、聊天流围栏块元组件(data-table 等)排 M4 同批或下版本。
|
||||||
3. **切换回聊天未自动聚焦输入框**——保活后焦点丢失,需 Composer 暴露 focus 入口(data 属性 + App effect)。
|
3. **切换回聊天未自动聚焦输入框**——保活后焦点丢失,需 Composer 暴露 focus 入口(data 属性 + App effect)。
|
||||||
4. **插件管理 UI 仍占位**——SettingsModal"插件"tab 转正(列表/启停/重载/导入)排 M4。
|
4. **插件管理 UI 仍占位**——SettingsModal"插件"tab 转正(列表/启停/重载/导入)排 M4。
|
||||||
5. **TS7(typescript 7 native)增量缓存坑**——改 `declare global` 后 typecheck 可能读到旧程序,删 `out/tsconfig.{node,web}/` 下 tsbuildinfo 即恢复。
|
5. **TS7(typescript 7 native)增量缓存坑**——改 `declare global` 后 typecheck 可能读到旧程序,删 `out/tsconfig.{node,web}/` 下 tsbuildinfo 即恢复。
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ export interface StatusDetailRow {
|
|||||||
openView?: OpenViewAction
|
openView?: OpenViewAction
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 详情弹层分组 */
|
/** 详情弹层分组(悬浮面板中为手风琴:默认收起只显示标题行,点击展开 rows) */
|
||||||
export interface StatusDetailSection {
|
export interface StatusDetailSection {
|
||||||
/** 分组标题(如「Git 工具」「计划」);缺省无标题 */
|
/** 分组标题(如「计划」「进程」);缺省无标题(rows 直接平铺、不可收起) */
|
||||||
title?: string
|
title?: string
|
||||||
|
/** 标题行尾徽标(如「4/4」「35分7秒」;收起态也可见——ZCode 式摘要) */
|
||||||
|
trailing?: string
|
||||||
|
/** 行尾徽标色调,缺省 default */
|
||||||
|
trailingTone?: StatusTone
|
||||||
rows: StatusDetailRow[]
|
rows: StatusDetailRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"description": "TODO:一句话描述插件做什么",
|
"description": "TODO:一句话描述插件做什么",
|
||||||
"order": 50,
|
"order": 50,
|
||||||
"panel": true,
|
"panel": true,
|
||||||
"icon": "Puzzle",
|
"icon": "Blocks",
|
||||||
"entry": {
|
"entry": {
|
||||||
"main": "main.mjs",
|
"main": "main.mjs",
|
||||||
"renderer": "renderer.mjs"
|
"renderer": "renderer.mjs"
|
||||||
|
|||||||
Reference in New Issue
Block a user