v0.7.0: 过程/答案分离 + 流式时序 + 自定义 API + 虚拟列表 + 打包

This commit is contained in:
Rick Ye
2026-09-06 23:15:41 +08:00
parent 5799be543b
commit f8c785afbc
15 changed files with 4526 additions and 365 deletions
+27
View File
@@ -0,0 +1,27 @@
# electron-builder 打包配置(npm run dist 产出 release/ 下的安装包)
appId: cc.inkreach.agent
productName: Inkreach Agent
directories:
output: release
buildResources: build
# 主进程 externalize 了 pi 全家桶(ESM 运行时加载),node_modules 必须随包携带
files:
- out/**
- '!out/preview/**'
- '!out/tsconfig*'
- node_modules/**
- package.json
asar: true
win:
icon: build/icon.ico
target:
- target: nsis
arch:
- x64
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
shortcutName: Inkreach Agent
artifactName: ${productName}-${version}-setup.${ext}
+3261 -2
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -1,8 +1,9 @@
{
"name": "@aso/desktop",
"private": true,
"version": "0.5.0",
"version": "0.7.0",
"description": "Inkreach Agent 桌面端(Electron for pi agent)",
"author": "Inkreach",
"type": "module",
"main": "out/main/index.mjs",
"scripts": {
@@ -10,7 +11,9 @@
"build": "electron-vite build",
"preview": "electron-vite preview",
"start": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json"
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"dist": "electron-vite build && electron-builder --win",
"dist:dir": "electron-vite build && electron-builder --dir"
},
"dependencies": {
"@earendil-works/pi-ai": "0.85.0",
@@ -19,6 +22,7 @@
"@llm-ui/code": "^0.13.3",
"@llm-ui/markdown": "^0.13.3",
"@llm-ui/react": "^0.13.3",
"@tanstack/react-virtual": "^3.14.10",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"gsap": "3.15.0",
@@ -38,6 +42,7 @@
"@types/react-dom": "19.2.7",
"@vitejs/plugin-react": "6.1.1",
"electron": "44.2.0",
"electron-builder": "^26.15.3",
"electron-vite": "5.0.0",
"tailwindcss": "4.3.3",
"typescript": "7.0.2",
+62 -4
View File
@@ -1,4 +1,5 @@
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { mkdirSync } from 'node:fs'
import { userInfo } from 'node:os'
import { AgentService } from './pi/agent-service'
@@ -90,6 +91,37 @@ export function registerIpc(): AgentService {
ipcMain.handle('models:catalog', () => pass(() => service.getModelCatalog()))
// ---- 自定义 API 提供商(models.json)----
ipcMain.handle('models:custom:list', () => service.listCustomProviders())
ipcMain.handle('models:custom:save', (_event, config: unknown) => {
if (typeof config !== 'object' || config === null) throw new Error('invalid custom provider')
const c = config as Record<string, unknown>
if (
typeof c.id !== 'string' ||
typeof c.baseUrl !== 'string' ||
typeof c.apiKey !== 'string' ||
typeof c.api !== 'string' ||
!Array.isArray(c.models)
) {
throw new Error('invalid custom provider')
}
const normalized = {
id: c.id,
name: typeof c.name === 'string' ? c.name : c.id,
baseUrl: c.baseUrl,
apiKey: c.apiKey,
api: c.api,
models: c.models.filter((m): m is string => typeof m === 'string')
}
return pass(() => service.saveCustomProvider(normalized))
})
ipcMain.handle('models:custom:delete', (_event, id: unknown) => {
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid provider id')
return pass(() => service.deleteCustomProvider(id))
})
ipcMain.handle('models:set', (_event, providerId: unknown, modelId: unknown) => {
if (typeof providerId !== 'string' || typeof modelId !== 'string') throw new Error('invalid model')
return pass(() => service.setModel(providerId, modelId))
@@ -178,9 +210,35 @@ export function registerIpc(): AgentService {
})
ipcMain.handle('permissions:set', (_event, mode: unknown) => {
const valid: ReadonlySet<string> = new Set(['confirm', 'autoEdit', 'plan', 'full'])
if (typeof mode !== 'string' || !valid.has(mode)) throw new Error('invalid permission mode')
service.setPermissionMode(mode as never)
if (mode !== 'confirm' && mode !== 'full') throw new Error('invalid permission mode')
service.setPermissionMode(mode)
})
// ---- 设置页:技能 / 命令详情 ----
ipcMain.handle('resources:details', () =>
service.listResourceDetails().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
// 在系统文件管理器中打开(技能/命令来源文件所在目录)
ipcMain.handle('shell:openPath', (_event, target: unknown) => {
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
return shell.openPath(target)
})
// 打开资源目录:不存在时先创建(skills/prompts 目录通常尚未建立)
ipcMain.handle('shell:openDirectory', (_event, target: unknown) => {
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
try {
mkdirSync(target, { recursive: true })
} catch {
// 已存在等情况忽略,交给 openPath 反馈
}
return shell.openPath(target).then((result) => {
// shell.openPath 失败时以字符串返回错误信息,成功返回空串
if (result) throw new Error(result)
})
})
ipcMain.handle('files:list', () =>
+174 -12
View File
@@ -21,13 +21,17 @@ import type {
AppSettings,
AttachmentInfo,
ChatResources,
CommandDetail,
CustomProviderConfig,
ModelCatalog,
ModelInfo,
PermissionMode,
ResourceDetails,
SendImage,
SessionMeta,
SessionState,
SessionStatsInfo,
SkillDetail,
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
@@ -75,7 +79,6 @@ export class AgentService {
}
private loadSettings(): AppSettings {
const VALID_MODES: ReadonlySet<string> = new Set(['confirm', 'autoEdit', 'plan', 'full'])
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<AppSettings>
this.settings = {
@@ -84,10 +87,9 @@ export class AgentService {
customWorkspaces: Array.isArray(raw.customWorkspaces)
? raw.customWorkspaces.filter((p): p is string => typeof p === 'string')
: undefined,
// 历史版本存过 autoEdit/plan,统一收敛回 confirm
permissionMode:
typeof raw.permissionMode === 'string' && VALID_MODES.has(raw.permissionMode)
? (raw.permissionMode as PermissionMode)
: 'confirm'
raw.permissionMode === 'full' ? 'full' : 'confirm'
}
} catch {
this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
@@ -498,6 +500,53 @@ export class AgentService {
return { commands, skills }
}
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新) */
async listResourceDetails(): Promise<ResourceDetails> {
const agentDir = getAgentDir()
const dirs = {
globalSkills: join(agentDir, 'skills'),
projectSkills: join(this.cwd, '.pi', 'skills'),
globalCommands: join(agentDir, 'prompts'),
projectCommands: join(this.cwd, '.pi', 'prompts')
}
let skills: SkillDetail[] = []
try {
const loaded = loadSkills({
cwd: this.cwd,
agentDir,
skillPaths: [],
includeDefaults: true
})
skills = loaded.skills.map((s) => ({
name: s.name,
description: s.description ?? '',
filePath: s.filePath,
scope: s.sourceInfo?.scope === 'user' ? 'user' : 'project',
disableModelInvocation: Boolean(s.disableModelInvocation)
}))
} catch (err) {
console.error('[agent-service] load skill details failed:', err)
}
const commands: CommandDetail[] = []
try {
const runtime = await this.getRuntime()
commands.push(
...runtime.session.promptTemplates.map((t) => ({
name: t.name,
description: t.description ?? '',
argumentHint: t.argumentHint || undefined,
filePath: t.filePath,
scope: t.sourceInfo?.scope === 'user' ? ('user' as const) : ('project' as const)
}))
)
} catch (err) {
console.error('[agent-service] list command details failed:', err)
}
skills.sort((a, b) => a.name.localeCompare(b.name))
commands.sort((a, b) => a.name.localeCompare(b.name))
return { skills, commands, dirs }
}
// ---- 工作区文件(@ 引用)----
async listWorkspaceFiles(): Promise<WorkspaceFile[]> {
@@ -557,19 +606,32 @@ export class AgentService {
webp: 'image/webp'
}
const mimeType = IMAGE_MIME[ext] ?? ''
if (!mimeType) {
return { name, path, mimeType: '', data: '' }
if (mimeType) {
try {
const stat = statSync(path)
if (stat.size > 8 * 1024 * 1024) {
throw new Error(`图片过大(>8MB): ${name}`)
}
return { name, path, mimeType, data: readFileSync(path).toString('base64') }
} catch (err) {
console.error('[agent-service] read attachment failed:', err)
return { name, path, mimeType: '', data: '' }
}
}
// 非图片:小体积且为文本的文件读出内容,发送时注入 <attachment> 块;二进制仅留路径引用
try {
const stat = statSync(path)
if (stat.size > 8 * 1024 * 1024) {
throw new Error(`图片过大(>8MB): ${name}`)
if (stat.size <= 512 * 1024) {
const buf = readFileSync(path)
const isText = TEXT_EXTS.has(ext) || looksLikeText(buf)
if (isText) {
return { name, path, mimeType: '', data: '', text: buf.toString('utf8') }
}
}
return { name, path, mimeType, data: readFileSync(path).toString('base64') }
} catch (err) {
console.error('[agent-service] read attachment failed:', err)
return { name, path, mimeType: '', data: '' }
}
return { name, path, mimeType: '', data: '' }
}
async steer(text: string): Promise<void> {
@@ -644,6 +706,91 @@ export class AgentService {
// ---- 模型与凭据 ----
/** 自定义提供商存放在 pi 的 models.json(与 pi CLI 共享,运行时自动合并进目录) */
private get modelsJsonFile(): string {
return join(getAgentDir(), 'models.json')
}
private readModelsConfig(): { providers: Record<string, Record<string, unknown>> } {
try {
const raw = JSON.parse(readFileSync(this.modelsJsonFile, 'utf8')) as Record<string, unknown>
if (raw && typeof raw.providers === 'object' && raw.providers !== null && !Array.isArray(raw.providers)) {
return { providers: raw.providers as Record<string, Record<string, unknown>> }
}
} catch {
// 文件不存在或损坏时按空配置处理(pi 侧同样容错)
}
return { providers: {} }
}
listCustomProviders(): CustomProviderConfig[] {
const { providers } = this.readModelsConfig()
return Object.entries(providers)
.map(([id, p]) => ({
id,
name: typeof p.name === 'string' ? p.name : id,
baseUrl: typeof p.baseUrl === 'string' ? p.baseUrl : '',
apiKey: typeof p.apiKey === 'string' ? p.apiKey : '',
api: typeof p.api === 'string' ? p.api : 'openai-completions',
models: Array.isArray(p.models)
? p.models
.map((m) => String((m as { id?: unknown })?.id ?? ''))
.filter(Boolean)
: []
}))
.sort((a, b) => a.id.localeCompare(b.id))
}
/** 保存(新增/覆盖)自定义提供商:写 models.json 后重建运行时使其生效 */
async saveCustomProvider(config: CustomProviderConfig): Promise<void> {
const id = config.id.trim()
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(id)) {
throw new Error('提供商 ID 只能包含字母、数字、- 和 _,且以字母或数字开头')
}
const baseUrl = config.baseUrl.trim()
if (!/^https?:\/\//.test(baseUrl)) throw new Error('API 地址需以 http(s):// 开头')
if (!config.apiKey.trim()) throw new Error('请填写 API Key')
const modelIds = [...new Set(config.models.map((m) => m.trim()).filter(Boolean))]
if (modelIds.length === 0) throw new Error('请至少填写一个模型 ID')
const doc = this.readModelsConfig()
doc.providers[id] = {
name: config.name.trim() || id,
baseUrl,
apiKey: config.apiKey.trim(),
api: config.api,
models: modelIds.map((mid) => ({
id: mid,
name: mid,
reasoning: false,
input: ['text'],
contextWindow: 128000,
maxTokens: 8192
}))
}
try {
writeFileSync(this.modelsJsonFile, JSON.stringify(doc, null, 2), 'utf8')
} catch (err) {
throw new Error(`写入 models.json 失败: ${err instanceof Error ? err.message : String(err)}`)
}
await this.rebuildRuntime()
const session = this.runtime?.session
if (session) this.emit({ type: 'state', state: this.buildState(session) })
}
async deleteCustomProvider(id: string): Promise<void> {
const doc = this.readModelsConfig()
if (!doc.providers[id]) throw new Error(`自定义提供商不存在: ${id}`)
delete doc.providers[id]
try {
writeFileSync(this.modelsJsonFile, JSON.stringify(doc, null, 2), 'utf8')
} catch (err) {
throw new Error(`写入 models.json 失败: ${err instanceof Error ? err.message : String(err)}`)
}
await this.rebuildRuntime()
const session = this.runtime?.session
if (session) this.emit({ type: 'state', state: this.buildState(session) })
}
async getModelCatalog(): Promise<ModelCatalog> {
const runtime = await this.getRuntime()
const mr = runtime.session.modelRuntime
@@ -745,8 +892,23 @@ export class AgentService {
}
}
function toSessionMeta(info: SessionInfo): SessionMeta {
return {
/** 视为文本的扩展名(直接按 UTF-8 读入,随消息注入) */
const TEXT_EXTS = new Set([
'md', 'markdown', 'txt', 'csv', 'tsv', 'json', 'jsonl', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env',
'js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx',
'py', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'swift', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'php',
'html', 'htm', 'css', 'scss', 'less', 'vue', 'svelte', 'astro',
'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd',
'sql', 'graphql', 'gql', 'prisma', 'dockerfile', 'makefile', 'gradle', 'gitignore', 'editorconfig', 'log', 'xml'
])
/** 无扩展名/罕见扩展名时按内容嗅探:首 4KB 出现 NUL 即视为二进制 */
function looksLikeText(buf: Buffer): boolean {
const probe = buf.subarray(0, 4096)
return !probe.includes(0)
}
function toSessionMeta(info: SessionInfo): SessionMeta { return {
id: info.id,
path: info.path,
name: info.name,
+3 -15
View File
@@ -7,13 +7,10 @@ import type { PermissionMode } from '@shared/types'
* - extension 拦截 tool_call → 挂起等待渲染层审批
* - ApprovalService 维护 pending 请求,经 IPC 与 Approval Card 交互
* - 超时自动拒绝,避免 agent 永久阻塞
* - 权限模式:full 全放行 / autoEdit 自动放行文件编辑 / plan 拦截修改类操作
* - 权限模式:confirm 变更前确认 / full 完全访问(全部放行)
*/
const APPROVAL_TIMEOUT_MS = 120_000
/** 文件编辑类工具(autoEdit 模式自动放行;plan 模式拦截) */
const EDIT_TOOLS = new Set(['write', 'edit'])
export interface PendingApproval {
id: string
toolName: string
@@ -73,8 +70,7 @@ function cloneable(value: unknown): unknown {
/**
* 生产 approval 拦截 extension:
* 匹配 NEEDS_APPROVAL 名单的工具调用按权限模式处理 ——
* confirm:先经用户审批,拒绝则 block;full:全部放行;
* autoEdit:文件编辑自动放行(bash 等仍需审批);plan:修改类操作直接拦截。
* confirm:先经用户审批,拒绝则 block;full:全部放行。
*/
export function createApprovalExtension(
service: ApprovalService,
@@ -86,15 +82,7 @@ export function createApprovalExtension(
factory: (pi: ExtensionAPI) => {
pi.on('tool_call', async (event) => {
if (!needsApproval.has(event.toolName)) return
const mode = getMode()
if (mode === 'full') return
if (mode === 'autoEdit' && EDIT_TOOLS.has(event.toolName)) return
if (mode === 'plan' && (EDIT_TOOLS.has(event.toolName) || event.toolName === 'bash')) {
return {
block: true,
reason: '计划模式已开启:请先输出完整的执行计划(只读分析),待用户切换权限模式后再执行修改类操作。'
}
}
if (getMode() === 'full') return
const approved = await service.request(event.toolName, event.input)
if (!approved) {
return { block: true, reason: '用户拒绝了本次工具调用审批' }
+9
View File
@@ -4,8 +4,10 @@ import type {
AppSettings,
AttachmentInfo,
ChatResources,
CustomProviderConfig,
ModelCatalog,
PermissionMode,
ResourceDetails,
SendImage,
SessionMeta,
SessionState,
@@ -41,12 +43,19 @@ const api = {
openWorkspace: (path: string): Promise<void> => ipcRenderer.invoke('workspaces:open', path),
browseWorkspace: (): Promise<string | null> => ipcRenderer.invoke('workspaces:browse'),
listChatResources: (): Promise<ChatResources> => ipcRenderer.invoke('chat:resources'),
listResourceDetails: (): Promise<ResourceDetails> => ipcRenderer.invoke('resources:details'),
openPath: (target: string): Promise<string> => ipcRenderer.invoke('shell:openPath', target),
openDirectory: (target: string): Promise<void> => ipcRenderer.invoke('shell:openDirectory', target),
setQueue: (steering: string[], followUp: string[]): Promise<void> =>
ipcRenderer.invoke('queue:set', steering, followUp),
setPermissionMode: (mode: PermissionMode): Promise<void> => ipcRenderer.invoke('permissions:set', mode),
listWorkspaceFiles: (): Promise<WorkspaceFile[]> => ipcRenderer.invoke('files:list'),
pickAttachments: (): Promise<AttachmentInfo[]> => ipcRenderer.invoke('attachments:pick'),
getModelCatalog: (): Promise<ModelCatalog> => ipcRenderer.invoke('models:catalog'),
listCustomProviders: (): Promise<CustomProviderConfig[]> => ipcRenderer.invoke('models:custom:list'),
saveCustomProvider: (config: CustomProviderConfig): Promise<void> =>
ipcRenderer.invoke('models:custom:save', config),
deleteCustomProvider: (id: string): Promise<void> => ipcRenderer.invoke('models:custom:delete', id),
setModel: (providerId: string, modelId: string): Promise<void> =>
ipcRenderer.invoke('models:set', providerId, modelId),
setApiKey: (providerId: string, apiKey: string): Promise<void> =>
@@ -136,7 +136,7 @@ export function ApprovalCard({
ok ? 'bg-green-tint text-green' : 'bg-inset text-ink-2'
}`}
>
<span className={`flex size-4.5 items-center justify-center rounded-full text-white ${ok ? 'bg-green' : 'bg-ink-3'}`}>
<span className={`flex size-4.5 items-center justify-center text-white ${ok ? 'rounded-full bg-green' : 'text-ink-3'}`}>
{ok ? (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5" />
@@ -1,14 +1,15 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import type { JSX, RefObject } from 'react'
import { LuCheck, LuClipboardList, LuFilePen, LuFileText, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
import { LuCheck, LuFileText, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
import type { AttachmentInfo, ChatResources, ModelCatalog, ModelInfo, PermissionMode, WorkspaceFile } from '@shared/types'
/**
* Prompt Bar(beautifului.dev #prompt-bar):
* 14px 圆角容器 + 五列网格(+/输入/模型/语音/发送),focus 边框加深。
* 14px 圆角容器 + 五列网格(+/输入/模型/权限/发送),focus 边框加深。
* "/" 命令面板:应用内动作 + pi 文件命令模板 + /skill: 技能(发送时由 SDK 展开);
* "@" 引用工作区文件;"+" 添加附件(图片为真实 ImageContent,其他文件以 @path 引用)。
* "@" 引用工作区文件;"+" 添加附件(图片为真实 ImageContent,文本文件内容注入,其余 @path 引用);
* 支持直接粘贴截图(clipboard 图片 → 附件)。
*/
interface LocalCommand {
@@ -19,53 +20,8 @@ interface LocalCommand {
enabled?: boolean
}
/** 语音识别(Chromium 内核;不支持/失败时静默降级为禁用态) */
function useDictation(onText: (text: string) => void): {
supported: boolean
listening: boolean
toggle: () => void
} {
const [listening, setListening] = useState(false)
const recRef = useRef<{ start: () => void; stop: () => void } | null>(null)
const onTextRef = useRef(onText)
onTextRef.current = onText
const SR = (
globalThis as unknown as { webkitSpeechRecognition?: new () => never; SpeechRecognition?: new () => never }
).SpeechRecognition
const supported = Boolean(SR)
const toggle = (): void => {
if (!SR) return
if (listening) {
recRef.current?.stop()
return
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rec = new (SR as any)()
rec.lang = 'zh-CN'
rec.interimResults = false
rec.continuous = false
rec.onresult = (event: { results: ArrayLike<ArrayLike<{ transcript: string }>> }): void => {
let text = ''
for (let i = 0; i < event.results.length; i += 1) text += event.results[i][0].transcript
if (text.trim()) onTextRef.current(text.trim())
}
rec.onend = (): void => setListening(false)
rec.onerror = (): void => setListening(false)
recRef.current = rec
rec.start()
setListening(true)
} catch {
setListening(false)
}
}
return { supported, listening, toggle }
}
/** 图片附件大小上限(与主进程 pickAttachments 一致) */
const MAX_IMAGE_BYTES = 8 * 1024 * 1024
/** 输入框锚定菜单:portal 到 body 固定定位(BottomSlot 的 overflow-hidden 会裁剪普通弹层) */
function ComposerMenu({
@@ -96,12 +52,10 @@ function ComposerMenu({
)
}
/** 权限模式菜单(参考 ZCode:变更前确认 / 自动编辑 / 计划模式 / 完全访问) */
/** 权限模式菜单:需要审批 / 完全访问 */
const PERMISSION_MODES: Array<{ id: PermissionMode; label: string; desc: string; icon: typeof LuHand }> = [
{ id: 'confirm', label: '变更前确认', desc: '改文件前先问我。', icon: LuHand },
{ id: 'autoEdit', label: '自动编辑', desc: '自动编辑文件。', icon: LuFilePen },
{ id: 'plan', label: '计划模式', desc: '编辑前先出计划。', icon: LuClipboardList },
{ id: 'full', label: '完全访问', desc: '减少确认次数。', icon: LuShieldCheck }
{ id: 'confirm', label: '需要审批', desc: '改文件、执行命令前先问我。', icon: LuHand },
{ id: 'full', label: '完全访问', desc: '自动执行所有工具调用,不再逐一确认。', icon: LuShieldCheck }
]
export function Composer({
@@ -296,8 +250,86 @@ export function Composer({
const [editingIdx, setEditingIdx] = useState<number | null>(null)
const [editValue, setEditValue] = useState('')
const dragFromRef = useRef<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
// ---- 队列排序:把手按下即拖;行内长按 350ms 进入拖拽(pointer 统一驱动,鼠标/触屏一致)----
const [dragState, setDragState] = useState<{ from: number; to: number } | null>(null)
const rowsRef = useRef<Array<HTMLDivElement | null>>([])
const pressRef = useRef<{ pointerId: number; timer: number } | null>(null)
/** 指针 Y 最近的一行(以每行中点距离判定,行高一致时等价于插入位置) */
const indexAtY = (clientY: number): number => {
let best = 0
let bestDist = Infinity
rowsRef.current.forEach((row, i) => {
if (!row) return
const rect = row.getBoundingClientRect()
const dist = Math.abs(clientY - (rect.top + rect.height / 2))
if (dist < bestDist) {
bestDist = dist
best = i
}
})
return best
}
const beginDrag = (index: number, pointerId: number): void => {
const row = rowsRef.current[index]
if (row) {
try {
row.setPointerCapture(pointerId)
} catch {
// 指针已抬起等场景捕获失败,放弃本次拖拽
return
}
}
setDragState({ from: index, to: index })
}
const onQueueRowPointerDown = (index: number) => (e: React.PointerEvent<HTMLDivElement>): void => {
if (editingIdx !== null || dragState) return
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('input')) return
if (target.closest('[data-grip]')) {
// 把手:立即进入拖拽
e.preventDefault()
beginDrag(index, e.pointerId)
return
}
// 行本体:长按 350ms 进入拖拽
const pointerId = e.pointerId
const timer = window.setTimeout(() => {
pressRef.current = null
beginDrag(index, pointerId)
}, 350)
pressRef.current = { pointerId, timer }
}
const onQueueRowPointerMove = (e: React.PointerEvent<HTMLDivElement>): void => {
if (!dragState) return
e.preventDefault()
const to = indexAtY(e.clientY)
setDragState((s) => (s && s.to !== to ? { ...s, to } : s))
}
const endQueueDrag = (e: React.PointerEvent<HTMLDivElement>): void => {
if (pressRef.current) {
clearTimeout(pressRef.current.timer)
pressRef.current = null
}
if (!dragState) return
e.preventDefault()
if (dragState.from !== dragState.to) reorderQueued(dragState.from, dragState.to)
setDragState(null)
}
/** 拖拽中的预览顺序(未提交,视觉先行) */
const previewQueueItems = useMemo(() => {
if (!dragState || dragState.from === dragState.to) return queueItems
const items = [...queueItems]
const [moved] = items.splice(dragState.from, 1)
items.splice(dragState.to, 0, moved)
return items
}, [queueItems, dragState])
// 命令/文件菜单:覆盖层(不占布局,避免聊天区上下跳动)
const barRef = useRef<HTMLDivElement>(null)
@@ -406,14 +438,49 @@ export function Composer({
.finally(() => setPicking(false))
}
// 粘贴图片:clipboard 里的图片文件直接变为附件(截图/复制的图)
const pasteSeq = useRef(0)
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
const images = Array.from(e.clipboardData.files).filter(
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_BYTES
)
if (images.length === 0) return
e.preventDefault()
for (const file of images) {
const reader = new FileReader()
reader.onload = (): void => {
const result = String(reader.result ?? '')
const base64 = result.includes(',') ? result.slice(result.indexOf(',') + 1) : result
pasteSeq.current += 1
const seq = pasteSeq.current
setAttachments((cur) => [
...cur,
{
name: file.name || `paste-${seq}.png`,
path: `clipboard://${seq}`,
mimeType: file.type,
data: base64
}
])
}
reader.readAsDataURL(file)
}
}
const submit = (): void => {
if (disabled) return
const text = value.trim()
const images = attachments.filter((a) => a.data && a.mimeType.startsWith('image/'))
const fileRefs = attachments.filter((a) => !(a.data && a.mimeType.startsWith('image/')))
if (!text && images.length === 0) return
const refLines = fileRefs.map((f) => `@${f.path}`)
const full = refLines.length > 0 ? `${text}${text ? '\n\n' : ''}${refLines.join('\n')}` : text
const textFiles = attachments.filter((a) => a.text)
const binaryRefs = attachments.filter((a) => !a.text && !(a.data && a.mimeType.startsWith('image/')))
if (!text && images.length === 0 && textFiles.length === 0) return
// 文本附件内容注入 <attachment> 块;二进制附件仅留 @path 引用行
const blocks = textFiles.map(
(f) => `<attachment name="${f.name}" path="${f.path}">\n${f.text}\n</attachment>`
)
const parts = [text, ...blocks].filter(Boolean).join('\n\n')
const refLines = binaryRefs.map((f) => `@${f.path}`)
const full = refLines.length > 0 ? `${parts}${parts ? '\n\n' : ''}${refLines.join('\n')}` : parts
onSend(full, images.map((img) => ({ mimeType: img.mimeType, data: img.data })))
setValue('')
setAttachments([])
@@ -449,13 +516,12 @@ export function Composer({
}
}
const dictation = useDictation((text) => setValue((current) => (current ? `${current} ${text}` : text)))
const permMeta = PERMISSION_MODES.find((m) => m.id === permissionMode) ?? PERMISSION_MODES[0]
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? []
const canSend = !disabled && (value.trim().length > 0 || attachments.some((a) => a.data && a.mimeType.startsWith('image/')))
const hasContentAttachment = attachments.some((a) => (a.data && a.mimeType.startsWith('image/')) || a.text)
const canSend = !disabled && (value.trim().length > 0 || hasContentAttachment)
// 菜单分段偏移(键盘高亮跨段连续)
const commandEnd = commandItems.length
@@ -485,37 +551,29 @@ export function Composer({
<div className="relative" ref={barRef}>
<div className="relative isolate flex flex-col overflow-hidden gap-1.5 rounded-[14px] border border-line bg-surface p-1.5 shadow-card transition-[border-color] duration-150 focus-within:border-line-strong">
{/* 排队消息:拖拽排序 / 立即 / 编辑 / 删除 */}
{queueItems.length > 0 && (
{/* 排队消息:长按/把手拖拽排序(pointer 驱动) + 立即 / 编辑 / 删除 */}
{previewQueueItems.length > 0 && (
<div className="flex flex-col gap-1 px-0.5 pt-0.5">
{queueItems.map((item, i) => (
<div
key={`${i}-${item.text.slice(0, 24)}`}
draggable={editingIdx !== i}
onDragStart={() => {
dragFromRef.current = i
}}
onDragOver={(e) => {
e.preventDefault()
setDragOverIdx(i)
}}
onDrop={(e) => {
e.preventDefault()
if (dragFromRef.current !== null) reorderQueued(dragFromRef.current, i)
dragFromRef.current = null
setDragOverIdx(null)
}}
onDragEnd={() => {
dragFromRef.current = null
setDragOverIdx(null)
}}
className={`group/qrow flex h-9 items-center gap-1 rounded-[8px] border border-line bg-canvas pl-1 pr-1.5 ${
dragOverIdx === i ? 'ring-1 ring-accent' : ''
} ${dragFromRef.current === i ? 'opacity-50' : ''}`}
>
<span className="cursor-grab select-none text-ink-3 active:cursor-grabbing" title="拖拽排序">
<LuGripVertical size={13} />
</span>
{previewQueueItems.map((item, i) => {
const dragging = dragState?.from === i
return (
<div
key={`${i}-${item.text.slice(0, 24)}`}
ref={(el) => {
rowsRef.current[i] = el
}}
onPointerDown={onQueueRowPointerDown(i)}
onPointerMove={onQueueRowPointerMove}
onPointerUp={endQueueDrag}
onPointerCancel={endQueueDrag}
style={{ touchAction: 'none' }}
className={`group/qrow flex h-9 select-none items-center gap-1 rounded-[8px] border border-line bg-canvas pl-1 pr-1.5 ${
dragging ? 'relative z-10 border-accent bg-surface opacity-70 shadow-md' : ''
} ${editingIdx === i ? 'cursor-text' : 'cursor-grab active:cursor-grabbing'}`}
>
<span data-grip className="shrink-0 cursor-grab select-none text-ink-3 active:cursor-grabbing" title="拖拽排序(行内长按同样生效)">
<LuGripVertical size={13} />
</span>
{editingIdx === i ? (
<>
<input
@@ -594,12 +652,13 @@ export function Composer({
</>
)}
</div>
))}
)
})}
</div>
)}
{/* 附件 chips:图片缩略图 / 文件图标 */}
{/* 附件 chips:图片缩略图 / 文件图标(文本文件带内容角标) */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-0.5 pt-0.5">
{attachments.map((att) => (
@@ -632,14 +691,14 @@ export function Composer({
</div>
)}
<div className="grid grid-cols-[28px_minmax(0,1fr)_auto_auto_28px_28px] items-end gap-x-1 gap-y-1.5">
<div className="grid grid-cols-[28px_minmax(0,1fr)_auto_auto_28px] items-end gap-x-1 gap-y-1.5">
{/* +:添加附件 */}
<button
type="button"
aria-label="添加附件"
onClick={addAttachments}
disabled={disabled || picking}
title="添加附件(图片随消息发送,其他文件以 @路径 引用)"
title="添加附件(图片/文本文件随消息发送,其余文件以 @路径 引用)"
className="col-start-1 row-start-1 flex size-7 shrink-0 justify-self-start items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-40"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@@ -652,6 +711,7 @@ export function Composer({
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
rows={1}
spellCheck={false}
disabled={disabled}
@@ -688,37 +748,19 @@ export function Composer({
disabled={disabled}
title={`权限模式:${permMeta.label}(${permMeta.desc})`}
className={`col-start-4 row-start-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:bg-hover hover:text-ink-2'
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:text-ink-2'
}`}
>
<permMeta.icon size={14} />
</button>
{/* 语音输入 */}
<button
type="button"
aria-label={dictation.listening ? '停止语音输入' : '开始语音输入'}
aria-pressed={dictation.listening}
onClick={dictation.toggle}
disabled={!dictation.supported}
title={dictation.supported ? '语音输入' : '当前环境不支持语音输入'}
className={`col-start-5 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-150 active:scale-[0.94] ${
dictation.listening ? 'bg-red-tint text-red' : 'text-ink-3 hover:bg-hover hover:text-ink'
} disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent`}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3" />
</svg>
</button>
{/* 发送 / 停止 */}
{streaming ? (
<button
type="button"
aria-label="停止生成"
onClick={onStop}
className="col-start-6 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] bg-ink text-canvas transition-[background-color,color,transform] duration-200 hover:opacity-90 active:scale-[0.94]"
className="col-start-5 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] bg-ink text-canvas transition-[background-color,color,transform] duration-200 hover:opacity-90 active:scale-[0.94]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2" />
@@ -730,7 +772,7 @@ export function Composer({
aria-label="Send"
onClick={submit}
disabled={!canSend}
className="col-start-6 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94] disabled:cursor-not-allowed"
className="col-start-5 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94] disabled:cursor-not-allowed"
style={canSend ? { background: 'var(--ink)', color: 'var(--canvas)' } : { background: 'var(--line-strong)', color: 'var(--ink-2)' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@@ -814,7 +856,7 @@ export function Composer({
active ? 'bg-hover' : ''
}`}
>
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-[6px] bg-inset text-ink-2">
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center text-ink-2">
<mode.icon size={13} />
</span>
<span className="min-w-0 flex-1">
@@ -872,7 +914,7 @@ export function Composer({
}}
className="flex w-full items-center rounded-control px-2.5 py-1.5 text-[12.5px] text-ink-2 transition-colors hover:bg-hover hover:text-ink"
>
管理模型与密钥…
管理模型
</button>
</div>
</div>
@@ -261,10 +261,12 @@ function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode {
return <Component blockMatch={blockMatch} />
}
export function Markdown({ text }: { text: string }): ReactNode {
export function Markdown({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode {
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
const { blockMatches } = useLLMOutput({
llmOutput: text,
isStreamFinished: true,
isStreamFinished: !streaming,
fallbackBlock: markdownFallback,
blocks: [codeBlock]
})
@@ -59,8 +59,11 @@ function useWorkingSeconds(working: boolean): string {
}
/** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。
* 过程可折叠:头部为工作计时/调用统计(ZCode 式),收起只留头部 + 正文,
* 展开按时间顺序铺出全部思考/工具行;流式期间自动展开,结束自动收起。 */
* 划分规则:最后一轮的正文 = 最终答案(始终可见);这轮里的其余一切 ——
* 思考、工具行、以及干活过程中模型穿插写的叙述性正文 —— 全部归入「过程」,
* 样式与普通正文一致,按时序穿插在思考/工具行之间,折叠时随过程一起收起。
* 流式中:正在流的消息就是答案的雏形,思考/正文按真实到达顺序分段渲染;
* 它结束、下一轮开始后即「沉」入过程区。 */
export function AssistantTurn({
messages,
tools,
@@ -72,14 +75,14 @@ export function AssistantTurn({
streaming: boolean
pendingApprovals: ApprovalRequest[]
}): ReactNode {
const units: ReactNode[] = []
const textUnits: ReactNode[] = []
const processNodes: ReactNode[] = []
const answerNodes: ReactNode[] = []
const labelCount = new Map<string, number>()
let calls = 0
let thinking = 0
let callsAndThinking: ToolCallView[] = []
let thinkingTexts: string[] = []
let stream: { text: string; thinking: string } | null = null
let stream: UiMessage['stream'] | null = null
/** 连续的思考/工具行作为一组裸行铺出(组内保留顺序),不缩进 */
const flushRows = (): void => {
@@ -87,19 +90,16 @@ export function AssistantTurn({
const group = { calls: callsAndThinking, thinkingTexts }
callsAndThinking = []
thinkingTexts = []
units.push(
<div key={`rows-${units.length}`} className="my-1.5">
processNodes.push(
<div key={`rows-${processNodes.length}`} className="my-1.5">
<ToolChips calls={group.calls} thinking={group.thinkingTexts} />
</div>
)
}
for (const message of messages) {
if (message.stream) {
stream = message.stream
continue
}
for (const block of readBlocks(message.raw)) {
/** 消费一条已完成消息:asAnswer=false 时正文作为过程叙述留在过程区 */
const consume = (raw: unknown, asAnswer: boolean): void => {
for (const block of readBlocks(raw)) {
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
thinkingTexts.push(thinkingText)
@@ -108,13 +108,13 @@ export function AssistantTurn({
}
if (block.type === 'text' && block.text) {
flushRows()
const textNode = (
<div key={`x-${units.length}`} className="my-3">
const node = (
<div key={`${asAnswer ? 'a' : 'p'}-${processNodes.length + answerNodes.length}`} className="my-3">
<Markdown text={block.text} />
</div>
)
units.push(textNode)
textUnits.push(textNode)
if (asAnswer) answerNodes.push(node)
else processNodes.push(node)
continue
}
if (block.type === 'toolCall') {
@@ -135,9 +135,24 @@ export function AssistantTurn({
}
}
}
// 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。
// stream 不存在时,最后一条已完成消息的正文是答案。
const streamPos = messages.findIndex((m) => m.stream)
for (let idx = 0; idx < messages.length; idx++) {
const message = messages[idx]
if (message.stream) {
stream = message.stream
continue
}
consume(message.raw, streamPos === -1 && idx === messages.length - 1)
}
flushRows()
const hasProcess = calls > 0 || thinking > 0
const streamHasThinking = stream
? stream.segments.some((s) => s.kind === 'thinking' && s.text.trim().length > 0)
: false
const hasProcess = calls > 0 || thinking > 0 || streamHasThinking
// 折叠状态:历史回合默认收起,流式回合默认展开;结束自动收起,可手动切换
const [collapsed, setCollapsed] = useState<boolean>(() => !streaming && hasProcess)
@@ -149,7 +164,38 @@ export function AssistantTurn({
const workedFor = useWorkingSeconds(streaming)
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
const streamRows = stream ? thinkingRows(stream.thinking) : []
// 流式分段:思考段属过程(折叠时隐藏),正文段属答案;均按真实时序渲染
const showStreamThinking = !collapsed || !hasProcess
const streamNodes: ReactNode[] = []
let visibleThinkingRows = 0
if (stream) {
stream.segments.forEach((seg, i) => {
if (seg.kind === 'thinking') {
if (!showStreamThinking) return
const rows = thinkingRows(seg.text)
if (rows.length === 0) return
visibleThinkingRows += rows.length
streamNodes.push(
<div key={`seg-${i}`} className="mb-3">
<Thinking rows={rows} streaming={streaming} />
</div>
)
return
}
if (seg.text) {
streamNodes.push(
<StreamingText key={`seg-${i}`} streaming={streaming} text={seg.text}>
<Markdown text={seg.text} streaming={streaming} />
</StreamingText>
)
}
})
}
// 折叠时思考不可见 → 头部提示「思考中…」(仅当前正在流思考段时)
const lastSeg = stream?.segments[stream.segments.length - 1]
const hiddenThinking =
collapsed && hasProcess && Boolean(stream && lastSeg?.kind === 'thinking' && lastSeg.text.trim())
return (
<div className="min-w-0 max-w-full">
@@ -175,29 +221,29 @@ export function AssistantTurn({
>
<path d="M6 9l6 6 6-6" />
</svg>
<span className="tabular-nums">{headerLabel}</span>
<span className="tabular-nums">
{headerLabel}
{hiddenThinking ? ',思考中…' : ''}
</span>
</button>
</div>
)}
{/* 展开态:过程行(缩进)+ 正文按时间交错;收起态:只保留正文(不缩进) */}
{!collapsed || !hasProcess ? units : textUnits}
{stream && (
<div>
{streamRows.length > 0 && (
<div className="mb-3">
<Thinking rows={streamRows} streaming={streaming} />
</div>
)}
{stream.text ? (
<StreamingText streaming={streaming} text={stream.text}>
<Markdown text={stream.text} />
</StreamingText>
) : streamRows.length === 0 ? (
{/* 展开态:过程(思考/工具/叙述正文按时序穿插)+ 最终答案;收起态:只留最终答案 */}
{!collapsed || !hasProcess ? (
<>
{processNodes}
{answerNodes}
{streamNodes.length > 0 && <div>{streamNodes}</div>}
{stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && (
<Loading label="思考中" variant="Dots" />
) : null}
</div>
)}
</>
) : (
<>
{answerNodes}
{streamNodes.length > 0 && <div>{streamNodes}</div>}
</>
)}
</div>
)
@@ -1,12 +1,15 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import type { JSX } from 'react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import type { JSX, RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import {
LuBot,
LuBrain,
LuCheck,
LuCpu,
LuFolderOpen,
LuKeyRound,
LuLogOut,
LuPlus,
LuPuzzle,
LuSearch,
LuServer,
@@ -17,7 +20,7 @@ import {
LuX
} from 'react-icons/lu'
import type { IconType } from 'react-icons'
import type { ModelCatalog, ProviderCatalogEntry, SessionState } from '@shared/types'
import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState } from '@shared/types'
type Tab =
| 'general'
@@ -58,20 +61,18 @@ const PLACEHOLDER: Record<string, string> = {
subagent: '为主流程配置可调度的子智能体,分头完成检索、写作与校验。',
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。',
mcp: '接入 MCP 服务器,把外部工具与数据源挂载到 Agent。',
skills: '管理可复用的技能包(SOP),让 Agent 按流程执行任务。',
commands: '自定义斜杠命令,把常用操作变成一键指令。',
hooks: '在会话生命周期的关键节点上挂载自动化钩子。'
}
const TAB_META: Record<Tab, { title: string; desc: string }> = {
general: { title: '常规', desc: '管理 Agent 的系统提示词与基础行为。' },
model: { title: '模型设置', desc: '管理模型供应商与密钥,配置后可在聊天时选择使用。' },
model: { title: '模型设置', desc: '' },
memory: { title: '记忆', desc: PLACEHOLDER.memory },
subagent: { title: '子智能体', desc: PLACEHOLDER.subagent },
plugins: { title: '插件', desc: PLACEHOLDER.plugins },
mcp: { title: 'MCP 服务器', desc: PLACEHOLDER.mcp },
skills: { title: '技能', desc: PLACEHOLDER.skills },
commands: { title: '命令', desc: PLACEHOLDER.commands },
skills: { title: '技能', desc: 'SKILL.md 技能包,Agent 自动按需加载。' },
commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' },
hooks: { title: '钩子', desc: PLACEHOLDER.hooks }
}
@@ -103,6 +104,500 @@ function ComingSoon({ icon: Icon, label }: { icon: IconType; label: string }): R
)
}
interface ResourceItem {
key: string
title: string
scope: 'user' | 'project'
badges?: Array<{ text: string; tone: 'accent' | 'warn' }>
desc: string
filePath: string
hint?: string
}
/** 技能 / 命令面板:全局 / 项目两个 tab,每次进入页面重新扫描;点击条目用默认编辑器打开来源文件 */
function ResourcePanel({
loading,
items,
dirs,
emptyTitle,
hints,
onOpen,
onOpenDir
}: {
loading: boolean
items: ResourceItem[]
dirs: { user: string; project: string }
emptyTitle: string
hints: { user: string; project: string }
onOpen: (path: string) => void
onOpenDir: (path: string) => void
}): ReactNode {
const [scope, setScope] = useState<'user' | 'project'>('user')
const scoped = items.filter((i) => i.scope === scope)
const dir = scope === 'user' ? dirs.user : dirs.project
return (
<div className="flex min-h-0 flex-col">
<div className="flex items-center gap-2 pb-3">
<div className="flex items-center rounded-control bg-zinc-100 p-0.5">
{(['user', 'project'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={`h-6 rounded-[6px] px-2.5 text-[11.5px] transition-colors ${
scope === s ? 'bg-white font-medium text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'
}`}
>
{s === 'user' ? '全局' : '项目'}
</button>
))}
</div>
{dir && (
<button
type="button"
title={dir}
onClick={() => onOpenDir(dir)}
className="ml-auto flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 text-[11.5px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
>
<LuFolderOpen size={12} className="shrink-0" />
打开目录
</button>
)}
</div>
{!loading && scoped.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-zinc-200 py-12 text-center">
<div className="text-[13px] font-medium text-zinc-600">{emptyTitle}</div>
<div className="text-[12px] text-zinc-400">{hints[scope]}</div>
</div>
) : (
<div className="space-y-1.5">
{scoped.map((item) => (
<button
key={item.key}
type="button"
title={`${item.filePath}(点击打开)`}
onClick={() => onOpen(item.filePath)}
className="block w-full rounded-card border border-zinc-200 px-3 py-2.5 text-left transition-colors hover:border-zinc-300 hover:bg-zinc-50"
>
<div className="flex items-center gap-2">
<span className="font-mono text-[12.5px] font-medium text-zinc-900">{item.title}</span>
{(item.badges ?? []).map((b) => (
<span
key={b.text}
className={`rounded-control px-1.5 py-0.5 text-[10px] ${
b.tone === 'accent' ? 'bg-accent-tint text-accent-ink' : 'bg-amber-50 text-amber-600'
}`}
>
{b.text}
</span>
))}
<span className="ml-auto min-w-0 truncate font-mono text-[10.5px] text-zinc-400">
{item.hint || item.filePath}
</span>
</div>
{item.desc && <div className="mt-1 line-clamp-2 text-[12px] leading-5 text-zinc-500">{item.desc}</div>}
</button>
))}
</div>
)}
</div>
)
}
/** 模型设置页的扁平行(provider 头 / 密钥编辑 / 模型行 / 空提示),供虚拟列表渲染 */
type ModelRowItem =
| { kind: 'provider'; key: string; provider: ProviderCatalogEntry }
| { kind: 'key'; key: string; provider: ProviderCatalogEntry }
| { kind: 'model'; key: string; provider: ProviderCatalogEntry; model: ModelMeta; last: boolean }
| { kind: 'empty'; key: string; provider: ProviderCatalogEntry }
/** 自定义 API 表单状态 */
interface CustomFormState {
id: string
name: string
baseUrl: string
apiKey: string
api: string
modelsText: string
}
const CUSTOM_API_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'openai-completions', label: 'OpenAI 兼容(Chat Completions)' },
{ value: 'openai-responses', label: 'OpenAI 兼容(Responses)' },
{ value: 'anthropic-messages', label: 'Anthropic 兼容' }
]
const customInputCls =
'w-full rounded-control border border-zinc-300 bg-white px-2.5 py-1.5 text-[12px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500'
/** 模型设置:搜索/过滤 + @tanstack/react-virtual 虚拟列表(供应商多、模型上千时滚动不卡顿)。
* 卡片视觉按行拼装:provider 头行圆角上盖、模型行接左右边框、末行圆角下盖,行尾留组间距。
* 支持自定义 API 提供商(写入 pi 的 models.json,保存后重建运行时即时生效)。 */
function ModelSettings({
catalog,
loading,
search,
onlyConfigured,
currentProvider,
currentModel,
busy,
keyProvider,
keyValue,
customProviders,
scrollRef,
onSearch,
onOnlyConfigured,
onEditKey,
onKeyChange,
onKeyCancel,
onSaveKey,
onLogout,
onSelectModel,
onSaveCustom,
onDeleteCustom
}: {
catalog: ModelCatalog | null
loading: boolean
search: string
onlyConfigured: boolean
currentProvider?: string
currentModel?: string
busy: boolean
keyProvider: string | null
keyValue: string
customProviders: CustomProviderConfig[]
scrollRef: RefObject<HTMLDivElement | null>
onSearch: (q: string) => void
onOnlyConfigured: (v: boolean) => void
onEditKey: (providerId: string) => void
onKeyChange: (v: string) => void
onKeyCancel: () => void
onSaveKey: (providerId: string) => void
onLogout: (providerId: string) => void
onSelectModel: (providerId: string, model: ModelMeta) => void
onSaveCustom: (config: CustomProviderConfig) => Promise<boolean>
onDeleteCustom: (id: string) => void
}): ReactNode {
const [customForm, setCustomForm] = useState<CustomFormState | null>(null)
const submitCustom = (): void => {
if (!customForm) return
void onSaveCustom({
id: customForm.id,
name: customForm.name,
baseUrl: customForm.baseUrl,
apiKey: customForm.apiKey,
api: customForm.api,
models: customForm.modelsText.split(/[\n,,;;]/).map((s) => s.trim()).filter(Boolean)
}).then((ok) => {
if (ok) setCustomForm(null)
})
}
const rows = useMemo<ModelRowItem[]>(() => {
const q = search.trim().toLowerCase()
const providers = (catalog?.providers ?? [])
.filter((p) => !onlyConfigured || p.hasApiKey || p.isOAuth)
.map((p) => ({
...p,
models: q
? p.models.filter(
(m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
)
: p.models
}))
.filter((p) => p.models.length > 0 || (!q && p.models.length === 0))
const out: ModelRowItem[] = []
for (const provider of providers) {
out.push({ kind: 'provider', key: `p-${provider.id}`, provider })
if (keyProvider === provider.id) out.push({ kind: 'key', key: `k-${provider.id}`, provider })
if (provider.models.length === 0) {
out.push({ kind: 'empty', key: `e-${provider.id}`, provider })
continue
}
provider.models.forEach((model, i) =>
out.push({
kind: 'model',
key: `m-${provider.id}/${model.id}`,
provider,
model,
last: i === provider.models.length - 1
})
)
}
return out
}, [catalog, search, onlyConfigured, keyProvider])
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: (i) => (rows[i].kind === 'provider' ? 56 : 46),
getItemKey: (i) => rows[i].key,
overscan: 12
})
const renderRow = (row: ModelRowItem): ReactNode => {
switch (row.kind) {
case 'provider':
return (
<div className="rounded-t-[14px] border border-b-0 border-zinc-200 bg-white px-3 pb-2 pt-3">
<div className="flex items-center gap-2">
<span className="text-[12.5px] font-medium text-zinc-800">{row.provider.name}</span>
<ProviderBadge provider={row.provider} />
{customProviders.some((c) => c.id === row.provider.id) && (
<span className="rounded-control bg-violet-100 px-1.5 py-0.5 text-[10px] text-violet-600">自定义</span>
)}
<button
type="button"
onClick={() => onEditKey(row.provider.id)}
className="ml-auto flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
>
<LuKeyRound size={11} />
{row.provider.hasApiKey ? '更换密钥' : '设置密钥'}
</button>
{(row.provider.hasApiKey || row.provider.isOAuth) && (
<button
type="button"
disabled={busy}
onClick={() => onLogout(row.provider.id)}
className="flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-red-50 hover:text-red-600"
>
<LuLogOut size={11} />
移除
</button>
)}
{customProviders.some((c) => c.id === row.provider.id) && (
<button
type="button"
disabled={busy}
onClick={() => onDeleteCustom(row.provider.id)}
className="flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-red-50 hover:text-red-600"
>
<LuX size={11} />
删除
</button>
)}
</div>
</div>
)
case 'key':
return (
<div className="border-x border-zinc-200 bg-zinc-50 px-3 py-2">
<div className="flex items-center gap-2 rounded-control border border-zinc-200 bg-white p-2">
<input
type="password"
value={keyValue}
onChange={(e) => onKeyChange(e.target.value)}
placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)"
className="flex-1 rounded-control border border-zinc-300 bg-white px-2.5 py-1.5 font-mono text-[12px] text-zinc-900 outline-none focus:border-zinc-500"
/>
<button
type="button"
disabled={busy || !keyValue.trim()}
onClick={() => onSaveKey(row.provider.id)}
className="flex items-center gap-1 rounded-control bg-zinc-900 px-2.5 py-1.5 text-[12px] text-white transition-colors hover:bg-zinc-700 disabled:opacity-50"
>
<LuCheck size={12} />
保存
</button>
<button
type="button"
onClick={onKeyCancel}
className="rounded-control px-2 py-1.5 text-[12px] text-zinc-500 hover:bg-zinc-100"
>
取消
</button>
</div>
</div>
)
case 'model': {
const selected = row.provider.id === currentProvider && row.model.id === currentModel
return (
<div className={`border-zinc-200 px-1.5 ${row.last ? 'border-x border-b pb-1.5' : 'border-x'}`}>
<button
type="button"
disabled={busy}
onClick={() => onSelectModel(row.provider.id, row.model)}
className={`flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors ${
selected ? 'bg-accent-tint ring-1 ring-accent' : 'hover:bg-zinc-100'
}`}
>
{selected ? (
<LuCheck size={13} className="shrink-0 text-accent-ink" />
) : (
<span className="h-[13px] w-[13px] shrink-0 rounded-full border border-zinc-300" />
)}
<span className={`text-[12.5px] ${selected ? 'font-medium text-zinc-900' : 'text-zinc-700'}`}>
{row.model.name}
</span>
<span className="font-mono text-[10.5px] text-zinc-400">{row.model.id}</span>
<span className="ml-auto flex items-center gap-2 text-[10.5px] text-zinc-400">
{row.model.reasoning && <span>推理</span>}
<span>{formatContext(row.model.contextWindow)} ctx</span>
</span>
</button>
</div>
)
}
case 'empty':
return (
<div className="rounded-b-[14px] border border-t-0 border-zinc-200 px-3 pb-1.5 pt-0.5 text-[11.5px] text-zinc-400">
配置密钥后显示可用模型
</div>
)
}
}
return (
<div className="flex min-h-0 flex-col">
<div className="flex items-center gap-2 pb-3">
<div className="relative flex-1">
<LuSearch size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="搜索模型或提供商"
className="w-full rounded-control border border-zinc-300 bg-white py-1.5 pl-8 pr-3 text-[12.5px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500"
/>
</div>
{loading && <span className="text-[11px] text-zinc-400">加载中…</span>}
<label className="flex shrink-0 cursor-pointer select-none items-center gap-1.5 text-[11.5px] text-zinc-500 hover:text-zinc-700">
<input
type="checkbox"
checked={onlyConfigured}
onChange={(e) => onOnlyConfigured(e.target.checked)}
className="h-3 w-3 accent-accent"
/>
仅显示已配置
</label>
<button
type="button"
onClick={() =>
setCustomForm({ id: '', name: '', baseUrl: '', apiKey: '', api: 'openai-completions', modelsText: '' })
}
className="flex shrink-0 items-center gap-1 rounded-control border border-zinc-200 bg-white px-2 py-1 text-[11.5px] text-zinc-600 transition-colors hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-800"
>
<LuPlus size={12} />
自定义 API
</button>
</div>
{customForm && (
<div className="mb-3 rounded-card border border-zinc-200 bg-zinc-50/70 p-3">
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-[11px] text-zinc-500">ID(唯一标识)</span>
<input
value={customForm.id}
onChange={(e) => setCustomForm({ ...customForm, id: e.target.value })}
placeholder="my-provider"
spellCheck={false}
className={customInputCls}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-zinc-500">显示名称(可空)</span>
<input
value={customForm.name}
onChange={(e) => setCustomForm({ ...customForm, name: e.target.value })}
placeholder="我的自定义提供商"
className={customInputCls}
/>
</label>
<label className="col-span-2 flex flex-col gap-1">
<span className="text-[11px] text-zinc-500">API 地址</span>
<input
value={customForm.baseUrl}
onChange={(e) => setCustomForm({ ...customForm, baseUrl: e.target.value })}
placeholder="https://api.example.com/v1"
spellCheck={false}
className={`${customInputCls} font-mono`}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-zinc-500">API Key</span>
<input
type="password"
value={customForm.apiKey}
onChange={(e) => setCustomForm({ ...customForm, apiKey: e.target.value })}
placeholder="sk-..."
className={`${customInputCls} font-mono`}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-zinc-500">协议</span>
<select
value={customForm.api}
onChange={(e) => setCustomForm({ ...customForm, api: e.target.value })}
className={customInputCls}
>
{CUSTOM_API_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
<label className="col-span-2 flex flex-col gap-1">
<span className="text-[11px] text-zinc-500">模型 ID(逗号或换行分隔)</span>
<textarea
value={customForm.modelsText}
onChange={(e) => setCustomForm({ ...customForm, modelsText: e.target.value })}
placeholder={'gpt-4o, claude-sonnet-4\ndeepseek-chat'}
rows={2}
spellCheck={false}
className={`${customInputCls} resize-none font-mono`}
/>
</label>
</div>
<div className="mt-2 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setCustomForm(null)}
className="rounded-control px-2.5 py-1.5 text-[12px] text-zinc-500 transition-colors hover:bg-zinc-100"
>
取消
</button>
<button
type="button"
disabled={busy || !customForm.id.trim() || !customForm.baseUrl.trim() || !customForm.apiKey.trim()}
onClick={submitCustom}
className="flex items-center gap-1 rounded-control bg-zinc-900 px-3 py-1.5 text-[12px] text-white transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<LuCheck size={12} />
保存
</button>
</div>
</div>
)}
{rows.length === 0 ? (
<div className="py-10 text-center text-[12.5px] text-zinc-400">
{loading ? '加载中…' : '没有匹配的模型'}
</div>
) : (
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vi) => {
const row = rows[vi.index]
return (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
>
{renderRow(row)}
{(row.kind === 'model' && row.last) || row.kind === 'empty' ? <div className="h-3" /> : null}
</div>
)
})}
</div>
)}
</div>
)
}
export function SettingsModal({
open,
sessionState,
@@ -125,6 +620,66 @@ export function SettingsModal({
const [promptDirty, setPromptDirty] = useState(false)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
const [resources, setResources] = useState<ResourceDetails | null>(null)
const [resourcesLoading, setResourcesLoading] = useState(false)
const [customProviders, setCustomProviders] = useState<CustomProviderConfig[]>([])
const scrollRef = useRef<HTMLDivElement>(null)
const refreshCustoms = (): void => {
window.api
.listCustomProviders()
.then(setCustomProviders)
.catch(() => undefined)
}
/** 保存自定义 API:写 models.json 并重建运行时。返回是否成功(成功才收起表单) */
const saveCustom = async (config: CustomProviderConfig): Promise<boolean> => {
if (streaming) {
setNotice({ kind: 'err', text: 'Agent 正在回复中,请先停止或等待完成后再保存(保存会重建运行时)' })
return false
}
setBusy(true)
setNotice(null)
try {
await window.api.saveCustomProvider(config)
setNotice({ kind: 'ok', text: '自定义 API 已保存,运行时已重建' })
refreshCatalog()
refreshCustoms()
return true
} catch (err: unknown) {
setNotice({ kind: 'err', text: err instanceof Error ? err.message : String(err) })
return false
} finally {
setBusy(false)
}
}
const deleteCustom = (id: string): void => {
if (streaming) {
setNotice({ kind: 'err', text: 'Agent 正在回复中,请先停止或等待完成后再删除(删除会重建运行时)' })
return
}
setBusy(true)
setNotice(null)
window.api
.deleteCustomProvider(id)
.then(() => {
setNotice({ kind: 'ok', text: `自定义 API「${id}」已删除,运行时已重建` })
refreshCatalog()
refreshCustoms()
})
.catch((err: unknown) => setNotice({ kind: 'err', text: err instanceof Error ? err.message : String(err) }))
.finally(() => setBusy(false))
}
const refreshResources = (): void => {
setResourcesLoading(true)
window.api
.listResourceDetails()
.then(setResources)
.catch(() => undefined)
.finally(() => setResourcesLoading(false))
}
const refreshCatalog = (): void => {
setLoading(true)
@@ -140,6 +695,7 @@ export function SettingsModal({
useEffect(() => {
if (!open) return
refreshCatalog()
refreshCustoms()
void window.api
.getSettings()
.then((s) => {
@@ -151,25 +707,16 @@ export function SettingsModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
// 技能 / 命令页:进入时加载详情
useEffect(() => {
if (!open || (tab !== 'skills' && tab !== 'commands')) return
refreshResources()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, tab])
const currentProvider = sessionState?.model?.provider
const currentModel = sessionState?.model?.id
const filtered = useMemo(() => {
if (!catalog) return []
const q = search.trim().toLowerCase()
return catalog.providers
.filter((p) => !onlyConfigured || p.hasApiKey || p.isOAuth)
.map((p) => ({
...p,
models: q
? p.models.filter(
(m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
)
: p.models
}))
.filter((p) => p.models.length > 0 || (!q && p.models.length === 0))
}, [catalog, search, onlyConfigured])
const run = (label: string, fn: () => Promise<void>, after?: () => void): void => {
setBusy(true)
setNotice(null)
@@ -222,7 +769,7 @@ export function SettingsModal({
<div className="flex items-start gap-3 px-6 pb-3 pt-5">
<div className="min-w-0 flex-1">
<div className="text-[16px] font-semibold text-zinc-900">{meta.title}</div>
<div className="mt-0.5 text-[12px] leading-5 text-zinc-500">{meta.desc}</div>
{meta.desc && <div className="mt-0.5 text-[12px] leading-5 text-zinc-500">{meta.desc}</div>}
</div>
<button
type="button"
@@ -233,7 +780,7 @@ export function SettingsModal({
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-4">
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-6 pb-4">
{tab === 'general' && (
<div className="flex h-full min-h-0 flex-col">
<textarea
@@ -269,141 +816,96 @@ export function SettingsModal({
)}
{tab === 'model' && (
<div className="flex min-h-0 flex-col">
<div className="flex items-center gap-2 pb-3">
<div className="relative flex-1">
<LuSearch size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索模型 / 服务商…"
className="w-full rounded-control border border-zinc-300 bg-white py-1.5 pl-8 pr-3 text-[12.5px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500"
/>
</div>
{loading && <span className="text-[11px] text-zinc-400">加载中…</span>}
<label className="flex shrink-0 cursor-pointer select-none items-center gap-1.5 text-[11.5px] text-zinc-500 hover:text-zinc-700">
<input
type="checkbox"
checked={onlyConfigured}
onChange={(e) => setOnlyConfigured(e.target.checked)}
className="h-3 w-3 accent-accent"
/>
仅显示已配置
</label>
</div>
<div className="space-y-4">
{filtered.map((provider) => (
<div key={provider.id} className="rounded-card border border-zinc-200 p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-[12.5px] font-medium text-zinc-800">{provider.name}</span>
<ProviderBadge provider={provider} />
<button
type="button"
onClick={() => {
setKeyProvider(provider.id)
setKeyValue('')
}}
className="ml-auto flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
>
<LuKeyRound size={11} />
{provider.hasApiKey ? '更换密钥' : '设置密钥'}
</button>
{(provider.hasApiKey || provider.isOAuth) && (
<button
type="button"
disabled={busy}
onClick={() =>
run('已移除凭据', () => window.api.logoutProvider(provider.id), refreshCatalog)
}
className="flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-red-50 hover:text-red-600"
>
<LuLogOut size={11} />
移除
</button>
)}
</div>
{keyProvider === provider.id && (
<div className="mb-2 flex items-center gap-2 rounded-control border border-zinc-200 bg-zinc-50 p-2">
<input
type="password"
value={keyValue}
onChange={(e) => setKeyValue(e.target.value)}
placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)"
className="flex-1 rounded-control border border-zinc-300 bg-white px-2.5 py-1.5 font-mono text-[12px] text-zinc-900 outline-none focus:border-zinc-500"
/>
<button
type="button"
disabled={busy || !keyValue.trim()}
onClick={() =>
run('密钥已保存', () => window.api.setApiKey(provider.id, keyValue.trim()), () => {
setKeyProvider(null)
setKeyValue('')
refreshCatalog()
})
}
className="flex items-center gap-1 rounded-control bg-zinc-900 px-2.5 py-1.5 text-[12px] text-white transition-colors hover:bg-zinc-700 disabled:opacity-50"
>
<LuCheck size={12} />
保存
</button>
<button
type="button"
onClick={() => {
setKeyProvider(null)
setKeyValue('')
}}
className="rounded-control px-2 py-1.5 text-[12px] text-zinc-500 hover:bg-zinc-100"
>
取消
</button>
</div>
)}
<div className="grid grid-cols-1 gap-1">
{provider.models.map((m) => {
const selected = provider.id === currentProvider && m.id === currentModel
return (
<button
key={m.id}
type="button"
disabled={busy}
onClick={() => run(`已切换到 ${m.name}`, () => window.api.setModel(provider.id, m.id))}
className={`flex items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors ${
selected ? 'bg-accent-tint ring-1 ring-accent' : 'hover:bg-zinc-100'
}`}
>
{selected ? (
<LuCheck size={13} className="shrink-0 text-accent-ink" />
) : (
<span className="h-[13px] w-[13px] shrink-0 rounded-full border border-zinc-300" />
)}
<span className={`text-[12.5px] ${selected ? 'font-medium text-zinc-900' : 'text-zinc-700'}`}>
{m.name}
</span>
<span className="font-mono text-[10.5px] text-zinc-400">{m.id}</span>
<span className="ml-auto flex items-center gap-2 text-[10.5px] text-zinc-400">
{m.reasoning && <span>推理</span>}
<span>{formatContext(m.contextWindow)} ctx</span>
</span>
</button>
)
})}
{provider.models.length === 0 && (
<div className="px-2.5 py-2 text-[11.5px] text-zinc-400">配置密钥后显示可用模型</div>
)}
</div>
</div>
))}
{!loading && filtered.length === 0 && (
<div className="py-10 text-center text-[12.5px] text-zinc-400">没有匹配的模型</div>
)}
</div>
</div>
<ModelSettings
catalog={catalog}
loading={loading}
search={search}
onlyConfigured={onlyConfigured}
currentProvider={currentProvider}
currentModel={currentModel}
busy={busy}
keyProvider={keyProvider}
keyValue={keyValue}
scrollRef={scrollRef}
onSearch={setSearch}
onOnlyConfigured={setOnlyConfigured}
onEditKey={(id) => {
setKeyProvider(id)
setKeyValue('')
}}
onKeyChange={setKeyValue}
onKeyCancel={() => {
setKeyProvider(null)
setKeyValue('')
}}
onSaveKey={(id) =>
run('密钥已保存', () => window.api.setApiKey(id, keyValue.trim()), () => {
setKeyProvider(null)
setKeyValue('')
refreshCatalog()
})
}
onLogout={(id) => run('已移除凭据', () => window.api.logoutProvider(id), refreshCatalog)}
onSelectModel={(id, model) => run(`已切换到 ${model.name}`, () => window.api.setModel(id, model.id))}
customProviders={customProviders}
onSaveCustom={saveCustom}
onDeleteCustom={deleteCustom}
/>
)}
{tab !== 'general' && tab !== 'model' && (
{tab === 'skills' && (
<ResourcePanel
loading={resourcesLoading}
items={(resources?.skills ?? []).map((s) => ({
key: s.filePath,
title: s.name,
scope: s.scope,
badges: s.disableModelInvocation ? [{ text: '仅手动调用', tone: 'warn' as const }] : [],
desc: s.description,
filePath: s.filePath
}))}
dirs={{
user: resources?.dirs.globalSkills ?? '',
project: resources?.dirs.projectSkills ?? ''
}}
emptyTitle="暂无技能"
hints={{ user: '~/.pi/agent/skills', project: '工作区/.pi/skills' }}
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
onOpenDir={(path) => {
window.api.openDirectory(path).catch((err: unknown) =>
setNotice({ kind: 'err', text: `打开目录失败: ${err instanceof Error ? err.message : String(err)}` })
)
}}
/>
)}
{tab === 'commands' && (
<ResourcePanel
loading={resourcesLoading}
items={(resources?.commands ?? []).map((c) => ({
key: c.filePath,
title: `/${c.name}`,
scope: c.scope,
badges: c.argumentHint ? [{ text: c.argumentHint, tone: 'accent' as const }] : [],
desc: c.description,
filePath: c.filePath
}))}
dirs={{
user: resources?.dirs.globalCommands ?? '',
project: resources?.dirs.projectCommands ?? ''
}}
emptyTitle="暂无自定义命令"
hints={{ user: '~/.pi/agent/prompts', project: '工作区/.pi/prompts' }}
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
onOpenDir={(path) => {
window.api.openDirectory(path).catch((err: unknown) =>
setNotice({ kind: 'err', text: `打开目录失败: ${err instanceof Error ? err.message : String(err)}` })
)
}}
/>
)}
{tab !== 'general' && tab !== 'model' && tab !== 'skills' && tab !== 'commands' && (
<ComingSoon icon={NAV[1].items.find((i) => i.id === tab)?.icon ?? LuPuzzle} label={meta.title} />
)}
</div>
@@ -23,8 +23,8 @@ export interface UiMessage {
role: string
/** 已完成的消息原文(AgentMessage,防御性读取) */
raw?: unknown
/** 流式中的增量缓冲 */
stream?: { text: string; thinking: string }
/** 流式中的增量缓冲:按到达顺序累积的分段(思考/正文交替时保持真实时序) */
stream?: { segments: Array<{ kind: 'text' | 'thinking'; text: string }> }
/** 本地乐观插入(发送即显示,等待 message_end 回填) */
local?: boolean
}
@@ -164,7 +164,7 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
if (event.role === 'assistant') {
return {
...state,
messages: [...state.messages, { key: nextKey('a'), role: 'assistant', stream: { text: '', thinking: '' } }]
messages: [...state.messages, { key: nextKey('a'), role: 'assistant', stream: { segments: [] } }]
}
}
return state
@@ -174,10 +174,15 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]
if (m.stream) {
const stream = { ...m.stream }
if (event.kind === 'text') stream.text += event.delta
else stream.thinking += event.delta
messages[i] = { ...m, stream }
// 同类增量追加到末段,类型切换(思考↔正文)则新开一段,保持真实输出时序
const segments = [...m.stream.segments]
const last = segments[segments.length - 1]
if (last && last.kind === event.kind) {
segments[segments.length - 1] = { kind: last.kind, text: last.text + event.delta }
} else {
segments.push({ kind: event.kind, text: event.delta })
}
messages[i] = { ...m, stream: { segments } }
break
}
}
+8
View File
@@ -3,9 +3,11 @@ import type {
AppSettings,
AttachmentInfo,
ChatResources,
CustomProviderConfig,
ModelCatalog,
ModelInfo,
PermissionMode,
ResourceDetails,
SendImage,
SessionMeta,
SessionState,
@@ -38,11 +40,17 @@ declare global {
openWorkspace: (path: string) => Promise<void>
browseWorkspace: () => Promise<string | null>
listChatResources: () => Promise<ChatResources>
listResourceDetails: () => Promise<ResourceDetails>
openPath: (target: string) => Promise<string>
openDirectory: (target: string) => Promise<void>
setQueue: (steering: string[], followUp: string[]) => Promise<void>
setPermissionMode: (mode: PermissionMode) => Promise<void>
listWorkspaceFiles: () => Promise<WorkspaceFile[]>
pickAttachments: () => Promise<AttachmentInfo[]>
getModelCatalog: () => Promise<ModelCatalog>
listCustomProviders: () => Promise<CustomProviderConfig[]>
saveCustomProvider: (config: CustomProviderConfig) => Promise<void>
deleteCustomProvider: (id: string) => Promise<void>
setModel: (providerId: string, modelId: string) => Promise<void>
setApiKey: (providerId: string, apiKey: string) => Promise<void>
logoutProvider: (providerId: string) => Promise<void>
+51 -3
View File
@@ -67,6 +67,21 @@ export interface ModelCatalog {
providers: ProviderCatalogEntry[]
}
/** 自定义 API 提供商(写入 ~/.pi/agent/models.json,pi 合并为普通 provider 参与目录/切换) */
export interface CustomProviderConfig {
/** 唯一 ID(字母/数字/-/_),同时是 provider id */
id: string
/** 显示名称(缺省用 id) */
name: string
/** API 地址(http(s):// 开头) */
baseUrl: string
apiKey: string
/** pi Api 协议:openai-completions / openai-responses / anthropic-messages 等 */
api: string
/** 模型 ID 列表 */
models: string[]
}
/** 应用级设置(存 userData/aso-settings.json) */
export interface AppSettings {
/** 覆盖 pi 默认系统提示词(空串 = 不覆盖) */
@@ -79,8 +94,8 @@ export interface AppSettings {
permissionMode?: PermissionMode
}
/** 权限模式:confirm 变更前确认 / autoEdit 自动编辑 / plan 计划模式 / full 完全访问 */
export type PermissionMode = 'confirm' | 'autoEdit' | 'plan' | 'full'
/** 权限模式:confirm 变更前确认 / full 完全访问 */
export type PermissionMode = 'confirm' | 'full'
/** 审批请求(主进程 → 渲染层 Approval Card) */
export interface ApprovalRequest {
@@ -107,6 +122,36 @@ export interface ChatResources {
skills: ChatSkillInfo[]
}
/** 资源来源范围:user = 全局(~/.pi/agent),project = 当前工作区(.pi) */
export type ResourceScope = 'user' | 'project'
/** 设置页技能条目(含来源文件路径) */
export interface SkillDetail {
name: string
description: string
filePath: string
scope: ResourceScope
/** true = 不进入系统提示词,仅可通过 /skill:name 显式调用 */
disableModelInvocation: boolean
}
/** 设置页命令条目(pi prompt template,含来源文件路径) */
export interface CommandDetail {
name: string
description: string
/** 参数占位提示(模板 frontmatter argument-hint) */
argumentHint?: string
filePath: string
scope: ResourceScope
}
/** 设置页技能/命令总览:条目 + 各资源目录(不存在时为空串) */
export interface ResourceDetails {
skills: SkillDetail[]
commands: CommandDetail[]
dirs: { globalSkills: string; projectSkills: string; globalCommands: string; projectCommands: string }
}
/** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */
export interface WorkspaceFile {
path: string
@@ -114,12 +159,15 @@ export interface WorkspaceFile {
isDir: boolean
}
/** 附件:图片带 base64(真实 ImageContent),其他文件仅路径(发送时以 @path 引用) */
/** 附件:图片带 base64(真实 ImageContent);文本文件带 text(随消息注入);
* 其余二进制文件仅路径(发送时以 @path 引用) */
export interface AttachmentInfo {
name: string
path: string
mimeType: string
data: string
/** 文本文件内容(UTF-8,≤512KB),发送时包进 <attachment> 块 */
text?: string
}
/** 发送图片附件(pi ImageContent 的可克隆子集) */