chore: establish YCode replication baseline and TODO

This commit is contained in:
xiaohan.yuan committed 2026-10-17 02:43:49 +08:00
commit 6dc59f89ba
1118 files changed
+196002

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import type { ChatEvent } from '@shared/types'
import type { AgentMode, PermissionMode, PlanContent, QuestionItem, QuestionOption } from '@shared/conversation'
import type { CapabilityToolSnapshot } from '../capabilities/runtime'
import { agentMode, modeToolDenial } from './mode-policy'
import type { InteractionBroker } from '../interaction-broker'
import { executeTool, type ToolResult } from './tools'
export interface ConversationToolContext {
runId: string
toolCallId: string
workspaceRoot: string
permissionMode?: PermissionMode
agentMode?: AgentMode
signal: AbortSignal
interactions?: InteractionBroker
emit(event: ChatEvent): void
}
export interface ConversationToolResult extends ToolResult { metadata?: Record<string, unknown> }
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
}
export function parseQuestions(value: unknown): QuestionItem[] {
if (!Array.isArray(value) || !value.length) throw new Error('提问内容为空')
return value.map((item, index) => {
const question = record(item)
if (typeof question.question !== 'string' || !question.question.trim()) throw new Error('问题内容无效')
const options: Array<string | QuestionOption> = (Array.isArray(question.options) ? question.options : []).map((option) => {
if (typeof option === 'string') return option
const value = record(option)
return Object.fromEntries(Object.entries(value).filter(([key, content]) => ['id', 'option', 'question', 'description', 'hint'].includes(key) && typeof content === 'string'))
})
return { id: String(question.id || `question-${index}`), question: question.question, options, multiSelect: question.multiSelect === true }
})
}
/** The native kernel and compatible gateway share the same host execution and interaction path. */
export async function executeConversationTool(name: string, args: Record<string, unknown>, ctx: ConversationToolContext): Promise<ConversationToolResult> {
if (ctx.signal.aborted) throw Object.assign(new Error('回合已取消'), { name: 'AbortError' })
const denial = modeToolDenial(agentMode(ctx.agentMode), name)
if (denial) return denial
if (['ask_user_question', 'ask_followup_question', 'askfollowupquestion', 'ask-followup-question'].includes(name) && ctx.interactions) {
const response = await ctx.interactions.question(ctx.toolCallId, String(args.title || '请回答以下问题'), parseQuestions(args.questions ?? [{ question: args.question, options: args.follow_up ?? args.options ?? [] }]))
return { ok: true, content: response.skipped ? '用户跳过了问题' : JSON.stringify(response.answer), data: response.answer,
metadata: { questionAnswer: response.answer, questionSkipped: response.skipped } }
}
if (name === 'todo_write' || name === 'create_plan') {
const source = Array.isArray(args.todos) ? args.todos : Array.isArray(args.steps) ? args.steps : []
const plan: PlanContent = { id: String(args.planId || 'current-plan'), title: String(args.title || '任务计划'), steps: source.map((item, index) => {
const value = record(item)
const rawStatus = String(value.status || 'pending')
const status = ['pending', 'in_progress', 'completed', 'cancelled'].includes(rawStatus) ? rawStatus as PlanContent['steps'][number]['status'] : 'pending'
return { id: String(value.id || index), text: typeof item === 'string' ? item : String(value.content || value.text || value.title || ''), status }
}) }
return { ok: true, content: JSON.stringify(plan), data: plan }
}
return executeTool(name, args, { workspaceRoot: ctx.workspaceRoot, signal: ctx.signal, permissionMode: ctx.permissionMode,
approve: ctx.interactions ? async (title) => {
const response = await ctx.interactions!.approval(ctx.toolCallId, title, [
{ optionId: 'allow-once', name: '允许一次', kind: 'allow_once' },
{ optionId: 'reject-once', name: '拒绝', kind: 'reject_once' }
])
return response.optionId === 'allow-once'
} : undefined })
}
/** One mode gate before both built-in and dynamic tools; hidden names are still rejected. */
export async function executeTurnTool(name: string, args: Record<string, unknown>, ctx: ConversationToolContext, capabilities?: CapabilityToolSnapshot): Promise<ConversationToolResult> {
if (ctx.signal.aborted) throw Object.assign(new Error('回合已取消'), { name: 'AbortError' })
const denial = modeToolDenial(agentMode(ctx.agentMode), name)
if (denial) return denial
return await capabilities?.execute(name, args, ctx) ?? await executeConversationTool(name, args, ctx)
}
+29
View File
@@ -0,0 +1,29 @@
import type { ChatMessage, ToolCallRecord } from '@shared/types'
import { getMessageParts } from '@shared/message-parts'
import type { WireMessage } from '../gateway'
/** Preserve ordered assistant/tool exchanges when using a stateless compatible gateway. */
export function assistantWireHistory(message: ChatMessage): WireMessage[] {
const messages: WireMessage[] = []
const parts = getMessageParts(message)
let text = ''
for (let index = 0; index < parts.length; index++) {
const part = parts[index]
if (part.type === 'text') { text += part.text; continue }
if (part.type !== 'tool') continue
const tools: ToolCallRecord[] = []
while (index < parts.length && parts[index].type === 'tool') {
const candidate = parts[index] as { type: 'tool'; toolId: string }
const tool = message.tools?.find((tool) => tool.id === candidate.toolId)
if (tool) tools.push(tool)
index++
}
index--
if (!tools.length) continue
messages.push({ role: 'assistant', content: text || null, tool_calls: tools.map((tool) => ({ id: tool.id, type: 'function', function: { name: tool.name, arguments: tool.argsRaw || '{}' } })) })
text = ''
for (const tool of tools) messages.push({ role: 'tool', tool_call_id: tool.id, content: tool.status === 'ok' ? tool.output ?? tool.summary ?? JSON.stringify(tool.result ?? '') : `ERROR (${tool.status}): ${tool.output ?? tool.summary ?? '工具未完成'}` })
}
if (text) messages.push({ role: 'assistant', content: text })
return messages
}
+215
View File
@@ -0,0 +1,215 @@
/**
* Agent 循环(最小闭环)。
*
* 一次「回合」= 用户发一条消息,到助手给出终态为止:
* 模型流式输出 → 若产生工具调用则执行并回填 → 再让模型继续 → 直到无工具调用
*
* 分层约定:
* - loop 只负责编排(拼消息、跑工具、发事件),不关心 UI 与持久化细节
* - 所有对外输出都通过 emit(ChatEvent),渲染进程据此增量渲染
*/
import type { ChatEvent, ChatMessage, ToolCallRecord } from '@shared/types'
import { Gateway, GatewayError, type WireMessage } from '../gateway'
import { toolSpecs } from './tools'
import { executeTurnTool } from './conversation-tools'
import type { InteractionBroker } from '../interaction-broker'
import type { AgentMode, PermissionMode } from '@shared/conversation'
import { agentMode, agentModeInstructions, modeToolSpecs } from './mode-policy'
import type { CapabilityToolSnapshot } from '../capabilities/runtime'
/** 回填给模型的工具结果上限,避免撑爆上下文 */
const TOOL_RESULT_LIMIT = 8000
/** UI 展示用的摘要长度 */
const TOOL_SUMMARY_LIMIT = 600
export interface RunTurnOptions {
capabilityTools?: CapabilityToolSnapshot
supportsToolCall?: boolean
interactions?: InteractionBroker
permissionMode?: PermissionMode
agentMode?: AgentMode
images?: Array<{ type: 'image'; data: string; mimeType: string }>
wireHistory?: WireMessage[]
runId: string
/** 本回合之前的历史(不含本次用户消息) */
history: ChatMessage[]
userText: string
signal: AbortSignal
emit: (event: ChatEvent) => void
// ---- 依赖注入:让 loop 不依赖 electron,可被离线测试直接驱动 ----
gateway: Gateway
model: string
maxToolRounds: number
workspaceRoot: string
}
export interface TurnResult {
content: string
tools: ToolCallRecord[]
}
function systemPrompt(workspaceRoot: string, model: string): string {
return [
'你是 YCode,一个运行在用户本机的 AI 助手。',
'',
'## 工作方式',
'- 需要了解文件内容或目录结构时,主动调用工具,不要凭空猜测。',
'- 工具只能访问用户的工作目录内部,越界请求会被拒绝。',
'- 任务完成后用简洁的中文回答,必要时用 Markdown。',
'',
'## 环境',
`- 工作目录:${workspaceRoot}`,
`- 当前模型:${model}`
].join('\n')
}
export async function runTurn(options: RunTurnOptions): Promise<TurnResult> {
const mode = agentMode(options.agentMode)
const { runId, history, userText, signal, emit, gateway, model, maxToolRounds, workspaceRoot } =
options
const messages: WireMessage[] = [
{ role: 'system', content: [systemPrompt(workspaceRoot, model), agentModeInstructions(mode)].filter(Boolean).join('\n\n') },
...(options.wireHistory ?? history
.filter((m) => m.role === 'user' || (m.role === 'assistant' && m.content))
.map<WireMessage>((m) => ({ role: m.role, content: m.content }))),
{ role: 'user', content: options.images?.length ? [{ type: 'text', text: userText }, ...options.images.map((image) => ({ type: 'image_url' as const, image_url: { url: `data:${image.mimeType};base64,${image.data}` } }))] : userText }
]
const tools: ToolCallRecord[] = []
let fullText = ''
let inFlightTool: ToolCallRecord | null = null
let inFlightStartedAt: number | undefined
try {
for (let round = 0; round < maxToolRounds; round++) {
let roundText = ''
let reasoning = ''
const pendingCalls: { id: string; name: string; argsRaw: string }[] = []
for await (const delta of gateway.stream({ messages, tools: options.supportsToolCall === false ? undefined : modeToolSpecs([...toolSpecs(), ...(options.capabilityTools?.specs ?? [])], mode), signal })) {
if (delta.type === 'text') {
roundText += delta.text
fullText += delta.text
emit({ runId, type: 'text', delta: delta.text })
} else if (delta.type === 'reasoning') {
reasoning += delta.text
emit({ runId, type: 'part', part: { type: 'reasoning', id: `reasoning-${round}`, text: reasoning } })
} else if (delta.type === 'tool_calls') {
for (const call of delta.calls) {
pendingCalls.push({ id: call.id, name: call.function.name, argsRaw: call.function.arguments })
}
}
}
if (reasoning) emit({ runId, type: 'part', part: { type: 'reasoning', id: `reasoning-${round}`, text: reasoning, finished: true } })
if (pendingCalls.length === 0) {
// 没有工具调用 → 本回合结束
break
}
// 助手这一轮的消息(含工具调用)要回填进上下文
messages.push({
role: 'assistant',
content: roundText || null,
tool_calls: pendingCalls.map((c) => ({
id: c.id,
type: 'function' as const,
function: { name: c.name, arguments: c.argsRaw }
}))
})
for (const call of pendingCalls) {
const record: ToolCallRecord = {
id: call.id,
name: call.name,
args: safeParse(call.argsRaw),
argsRaw: call.argsRaw,
status: 'running'
}
tools.push(record)
record.metadata = options.capabilityTools?.metadata?.(record.name)
emit({ runId, type: 'tool_call', call: { ...record } })
const startedAt = Date.now()
inFlightTool = record
inFlightStartedAt = startedAt
const args = asRecord(record.args)
const ctx = { runId, toolCallId: call.id,
workspaceRoot, signal, interactions: options.interactions, permissionMode: options.permissionMode, agentMode: mode, emit }
const result = await executeTurnTool(call.name, args, ctx, options.capabilityTools)
record.metadata = result.metadata ?? record.metadata
record.result = result.data ?? { success: result.ok, content: result.content }
record.status = signal.aborted ? 'cancelled' : result.ok ? 'ok' : 'error'
record.durationMs = elapsedSince(startedAt)
record.output = result.content
record.summary = truncate(result.content, TOOL_SUMMARY_LIMIT)
emit({ runId, type: 'tool_result', call: { ...record } })
inFlightTool = null
inFlightStartedAt = undefined
if (signal.aborted) {
const error = new Error('回合已取消')
error.name = 'AbortError'
throw error
}
messages.push({
role: 'tool',
tool_call_id: call.id,
content: result.ok
? truncate(result.content, TOOL_RESULT_LIMIT)
: `ERROR: ${result.content}`
})
}
}
emit({ runId, type: 'done', content: fullText, tools })
return { content: fullText, tools }
} catch (err) {
if (inFlightTool?.status === 'running') {
const cancelled = (err as Error).name === 'AbortError' || signal.aborted
inFlightTool.status = cancelled ? 'cancelled' : 'error'
if (inFlightStartedAt !== undefined) {
inFlightTool.durationMs = elapsedSince(inFlightStartedAt)
}
emit({ runId, type: 'tool_result', call: { ...inFlightTool } })
inFlightTool = null
inFlightStartedAt = undefined
}
if ((err as Error).name === 'AbortError' || signal.aborted) {
emit({ runId, type: 'cancelled' })
throw err
}
const message =
err instanceof GatewayError
? [err.message, err.body].filter(Boolean).join('\n')
: (err as Error).message
emit({ runId, type: 'error', message })
throw err
}
}
function safeParse(raw: string): unknown {
try {
return JSON.parse(raw)
} catch {
return raw
}
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function truncate(text: string, limit: number): string {
return text.length <= limit ? text : `${text.slice(0, limit)}\n…(已截断,共 ${text.length} 字符)`
}
function elapsedSince(startedAt: number): number {
return Math.max(0, Date.now() - startedAt)
}
+49
View File
@@ -0,0 +1,49 @@
import type { AgentMode } from '@shared/conversation'
import type { ToolSpec } from '@shared/types'
import type { SessionContext } from '@shared/workspaces'
const THINKING_LEVELS = new Set(['off', 'low', 'medium', 'high', 'xhigh', 'max'])
/** Accepts only levels this host persists; OMP's `auto`/`minimal` are not offered by the selector. */
export function thinkingLevel(value: unknown): import('@shared/workspaces').ThinkingLevel | undefined {
if (value === undefined || value === null) return undefined
if (typeof value === 'string' && THINKING_LEVELS.has(value)) return value as import('@shared/workspaces').ThinkingLevel
throw new Error('无效的思考强度')
}
/** Host adapter for the source craft/plan/ask UI contract; not an OMP engine mode. */
export function agentMode(value: unknown): AgentMode {
if (value === undefined) return 'craft'
if (value === 'craft' || value === 'plan' || value === 'ask') return value
throw new Error('无效的对话模式,仅支持 craft、plan、ask')
}
/** Source ConfigCapability clears expert in plan/ask; selecting an expert restores craft. */
export function agentModeContextPatch(previous: SessionContext, patch: SessionContext): SessionContext {
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('会话配置无效')
if (patch.expertId !== undefined && patch.expertId !== null && (typeof patch.expertId !== 'string' || !patch.expertId.trim())) throw new Error('专家标识无效')
const mode = patch.agentMode === undefined ? agentMode(previous.agentMode) : agentMode(patch.agentMode)
const next = { ...patch, agentMode: mode }
if (patch.expertId) next.agentMode = 'craft'
else if (patch.agentMode !== undefined && mode !== 'craft') next.expertId = null
return next
}
const READ_TOOLS = new Set(['list_dir', 'read_file', 'ask_user_question', 'ask_followup_question',
'askfollowupquestion', 'ask-followup-question', 'ycode_load_skill', 'ycode_read_skill_resource', 'ycode_expert_resource'])
const PLAN_TOOLS = new Set(['todo_write', 'create_plan'])
export function modeAllowsTool(mode: AgentMode, name: string): boolean {
return mode === 'craft' || READ_TOOLS.has(name) || mode === 'plan' && PLAN_TOOLS.has(name)
}
export function modeToolSpecs(specs: ToolSpec[], mode: AgentMode): ToolSpec[] {
return mode === 'craft' ? specs : specs.filter(spec => modeAllowsTool(mode, spec.function.name))
}
export function modeToolDenial(mode: AgentMode, name: string): { ok: false; content: string } | undefined {
if (modeAllowsTool(mode, name)) return undefined
return { ok: false, content: `当前 ${mode} 模式不允许工具 ${name}。此模式只允许读取和提问${mode === 'plan' ? ',以及维护会话计划' : ''};写文件、命令执行、连接器和专家包修改需切换到 craft。` }
}
export function agentModeInstructions(mode: AgentMode): string {
if (mode === 'craft') return '<ycode-agent-mode mode="craft">当前会话为 craft 执行模式;此前回合的 plan/ask 只读限制不再决定本回合模式。可按用户任务使用当前已提供的工具,仍须遵守本地权限和审批;此模式不自动授予完全访问。</ycode-agent-mode>'
return `<ycode-agent-mode mode="${mode}">当前会话为 ${mode === 'plan' ? '规划' : '问答'}模式。${mode === 'plan'
? '先读取资料、澄清需求并给出可执行计划,可以维护会话中的 todo/plan;不要实际实施代码修改。'
: '只读取资料、解释问题并按需向用户提问;不要修改文件或维护执行计划。'}宿主会拒绝文件写入、shell、MCP 和专家包修改,即使用户开启完全访问也不放行;需要实施时请用户先切换 craft。该限制是 YCode 本地工具策略,不等同于 WorkBuddy 或 OMP 的全部原生模式行为;文件读取范围仍受当前本地权限配置约束。</ycode-agent-mode>`
}
+492
View File
@@ -0,0 +1,492 @@
/**
* 以 OMP 为内核运行一次对话回合。
*
* 与 `loop.ts` 的 `runTurn` 输出同一套 ChatEvent,渲染进程无需感知内核差异。
*
* 事件映射(docs/reference/omp/rpc.md Event Stream Schema):
* - `message_update.assistantMessageEvent.type = "text_delta"` → ChatEvent `text`
* - `tool_execution_start / _update / _end` → ChatEvent `tool_call` / `tool_result`
* - `agent_end`(yielded 非假)→ ChatEvent `done`
* - `prompt_result.status = "error"` → ChatEvent `error`
* - 取消(abort)→ ChatEvent `cancelled`
*/
import type { ChatEvent, ToolCallRecord } from '@shared/types'
import { OmpKernel, type OmpFrame, type OmpToolInfo } from '../omp'
import { toolSpecs } from './tools'
import { executeTurnTool } from './conversation-tools'
import type { AgentMode, PermissionMode } from '@shared/conversation'
import { agentMode, agentModeInstructions, modeToolSpecs } from './mode-policy'
import type { InteractionBroker } from '../interaction-broker'
import type { CapabilityToolSnapshot } from '../capabilities/runtime'
import type { ThinkingLevel } from '@shared/workspaces'
const TOOL_SUMMARY_LIMIT = 600
const TOOL_JSON_FALLBACK_LIMIT = 16_000
export interface RunOmpTurnOptions {
capabilityTools?: CapabilityToolSnapshot
supportsToolCall?: boolean
workspaceRoot?: string
permissionMode?: PermissionMode
agentMode?: AgentMode
thinkingLevel?: ThinkingLevel
interactions?: InteractionBroker
images?: Array<{ type: 'image'; data: string; mimeType: string }>
runId: string
kernel: OmpKernel
userText: string
signal: AbortSignal
emit: (event: ChatEvent) => void
}
export interface OmpTurnResult {
content: string
tools: ToolCallRecord[]
/** 终态:正常结束为 done;abort 后到达的 agent_end 记为 cancelled */
status: 'done' | 'cancelled'
}
export async function runOmpTurn(options: RunOmpTurnOptions): Promise<OmpTurnResult> {
const { runId, kernel, signal, emit } = options
const mode = agentMode(options.agentMode)
const instructions = agentModeInstructions(mode)
const userText = instructions && !options.userText.includes(instructions) ? `${options.userText}\n\n${instructions}` : options.userText
// 已取消的回合不能再向共享内核发送 prompt 或 abort。
if (signal.aborted) {
emit({ runId, type: 'cancelled' })
const error = new Error('回合已取消')
error.name = 'AbortError'
throw error
}
if (options.workspaceRoot) {
const registration = await kernel.setHostTools(options.supportsToolCall === false ? [] : modeToolSpecs([...toolSpecs(), ...(options.capabilityTools?.specs ?? [])], mode).map((tool) => ({ ...tool.function, label: tool.function.name })))
if (registration.success === false) throw new Error('OMP 宿主工具注册失败')
}
// Applied per turn so a level chosen mid-conversation reaches the very next request without a kernel restart.
if (options.thinkingLevel) await kernel.setThinkingLevel(options.thinkingLevel)
const tools: ToolCallRecord[] = []
let fullText = ''
let settled = false
/** 是否已请求取消:abort 后到达的 agent_end 一律按「已取消」收尾 */
let aborted = false
let cleanup = (): void => {}
const reasoning = new Map<string, string>()
const toolStartedAt = new Map<string, number>()
const hostRequests = new Set<string>()
const hostMetadata = new Map<string, Record<string, unknown>>()
const hostResults = new Map<string, unknown>()
let assistantSequence = 0
const onAbort = (): void => {
if (settled) return
aborted = true
void kernel.abort().catch(() => undefined)
}
signal.addEventListener('abort', onAbort)
const finalizeRunning = (status: 'error' | 'cancelled'): void => {
for (const record of tools) {
if (record.status !== 'running') continue
record.status = status
const startedAt = toolStartedAt.get(record.id)
if (startedAt !== undefined) record.durationMs = elapsedSince(startedAt)
toolStartedAt.delete(record.id)
emit({ runId, type: 'tool_result', call: { ...record } })
}
}
const finish = (
event: ChatEvent,
result: OmpTurnResult,
inFlightStatus: 'error' | 'cancelled' = 'error'
): OmpTurnResult => {
if (settled) return result
settled = true
signal.removeEventListener('abort', onAbort)
cleanup()
for (const [id, text] of reasoning) emit({ runId, type: 'part', part: { type: 'reasoning', id, text, finished: true } })
finalizeRunning(inFlightStatus)
emit(event)
return result
}
return new Promise<OmpTurnResult>((resolve, reject) => {
const onEvent = (frame: OmpFrame): void => {
if (settled) return
switch (frame.type) {
case 'message_start':
if (isRecord(frame.message) && frame.message.role === 'assistant') assistantSequence++
break
case 'host_tool_call': {
const requestId = typeof frame.id === 'string' ? frame.id : ''
if (!requestId || hostRequests.has(requestId)) break
hostRequests.add(requestId)
const toolCallId = typeof frame.toolCallId === 'string' ? frame.toolCallId : requestId
const name = typeof frame.name === 'string' ? frame.name : typeof frame.toolName === 'string' ? frame.toolName : ''
const rawArgs = frame.args ?? frame.arguments
const parsedArgs = typeof rawArgs === 'string' ? safeParse(rawArgs) : rawArgs
const args = isRecord(parsedArgs) ? parsedArgs : {}
void (async () => {
try {
if (!options.workspaceRoot) throw new Error('宿主工具未连接')
const ctx = { runId, toolCallId, workspaceRoot: options.workspaceRoot,
permissionMode: options.permissionMode, agentMode: mode, interactions: options.interactions, signal, emit }
const result = await executeTurnTool(name, args, ctx, options.capabilityTools)
if (result.metadata) hostMetadata.set(toolCallId, result.metadata)
hostResults.set(toolCallId, result.data ?? { success: result.ok, content: result.content })
if (!settled) await kernel.respondToHostTool(requestId, result.content, !result.ok)
} catch (error) {
if (!settled) await kernel.respondToHostTool(requestId, error instanceof Error ? error.message : String(error), true).catch(fail)
}
})()
break
}
case 'message_update': {
const evt = frame.assistantMessageEvent as
| { type?: string; delta?: string; contentIndex?: number }
| undefined
if (evt?.type === 'text_delta' && typeof evt.delta === 'string') {
fullText += evt.delta
emit({ runId, type: 'text', delta: evt.delta })
} else if (evt?.type === 'thinking_delta' && typeof evt.delta === 'string') {
const id = `thinking-${assistantSequence}-${String(evt.contentIndex ?? 0)}`
const text = (reasoning.get(id) || '') + evt.delta
reasoning.set(id, text)
emit({ runId, type: 'part', part: { type: 'reasoning', id, text } })
} else if (evt?.type === 'thinking_end') {
const id = `thinking-${assistantSequence}-${String(evt.contentIndex ?? 0)}`
emit({ runId, type: 'part', part: { type: 'reasoning', id, text: reasoning.get(id) || '', finished: true } })
}
break
}
case 'message_end': {
const message = frame.message
if (isRecord(message) && message.role === 'assistant' && message.stopReason === 'error') {
failProvider(message.errorMessage)
}
break
}
case 'tool_execution_start': {
const info = readToolInfo(frame, 'start')
if (!info) break
const id = info.id ?? info.name
if (!id) break
const record: ToolCallRecord = {
id,
name: info.name ?? id,
intent: info.intent,
args: safeParse(info.argsRaw ?? '{}'),
argsRaw: info.argsRaw ?? '{}',
status: 'running'
}
record.metadata = options.capabilityTools?.metadata?.(record.name)
if (info.output !== undefined) setOutput(record, info.output)
toolStartedAt.set(id, Date.now())
tools.push(record)
emit({ runId, type: 'tool_call', call: { ...record } })
break
}
case 'tool_execution_update': {
const info = readToolInfo(frame, 'update')
if (!info?.id) break
const record = tools.find((t) => t.id === info.id)
if (record && info.output !== undefined) {
setOutput(record, info.output)
emit({ runId, type: 'tool_result', call: { ...record } })
}
break
}
case 'tool_execution_end': {
const info = readToolInfo(frame, 'end')
if (!info?.id) break
const record = tools.find((t) => t.id === info.id)
if (record) {
record.result = hostResults.get(record.id) ?? frame.result ?? frame.output
if (isRecord(frame.result) && isRecord(frame.result.details)) record.metadata = { ...record.metadata, ...frame.result.details }
if (hostMetadata.has(record.id)) record.metadata = { ...record.metadata, ...hostMetadata.get(record.id) }
if (info.output !== undefined) setOutput(record, info.output)
record.status = info.isError || info.ok === false ? 'error' : 'ok'
const startedAt = toolStartedAt.get(record.id)
if (startedAt !== undefined) record.durationMs = elapsedSince(startedAt)
toolStartedAt.delete(record.id)
emit({ runId, type: 'tool_result', call: { ...record } })
if (isRecord(frame.result) && Array.isArray(frame.result.content)) {
frame.result.content.forEach((part, index) => {
if (isRecord(part) && part.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string' && /^image\/(png|jpeg|gif|webp)$/.test(part.mimeType)) {
emit({ runId, type: 'part', part: { type: 'image', id: `${record.id}-image-${index}`, url: `data:${part.mimeType};base64,${part.data}` } })
}
})
}
}
break
}
case 'agent_end': {
// A provider failure can precede a nonterminal automatic-retry frame.
// Report the actual failure rather than leaving an empty streaming bubble.
const message = Array.isArray(frame.messages)
? frame.messages.findLast((item) => isRecord(item) && item.role === 'assistant')
: undefined
if (isRecord(message) && message.stopReason === 'error') {
failProvider(message.errorMessage)
break
}
// yielded === false 表示内核还在自行推进(重试/压缩),不算回合结束
const yielded = frame.yielded === undefined ? frame.isTerminal !== false : frame.yielded
if (yielded) {
const result: OmpTurnResult = { content: fullText, tools, status: aborted ? 'cancelled' : 'done' }
if (aborted) {
resolve(finish({ runId, type: 'cancelled' }, result, 'cancelled'))
} else {
resolve(finish({ runId, type: 'done', content: fullText, tools }, result))
}
}
break
}
case 'prompt_result': {
const status = frame.status
if (status === 'error') {
const message =
(frame.error as { message?: string } | undefined)?.message ?? 'OMP 回合失败'
fail(new Error(message))
return
}
// aborted → 等待 abort 后的 agent_end;若内核直接结束则在此收尾
if (status === 'aborted') {
const result: OmpTurnResult = { content: fullText, tools, status: 'cancelled' }
resolve(finish({ runId, type: 'cancelled' }, result, 'cancelled'))
}
break
}
case 'notice': {
const level = (frame as { level?: string }).level
const text = typeof frame.message === 'string' ? frame.message : ''
if (level === 'error' && text) {
fail(new Error(text))
}
break
}
default:
break
}
}
const fail = (err: Error): void => {
if (settled) return
finish(
{ runId, type: 'error', message: err.message },
{ content: fullText, tools, status: 'done' },
'error'
)
reject(err)
}
const failProvider = (message: unknown): void => {
if (settled) return
if (aborted || signal.aborted) {
resolve(finish({ runId, type: 'cancelled' }, { content: fullText, tools, status: 'cancelled' }, 'cancelled'))
return
}
// OMP otherwise retries the provider failure outside the failed host turn.
// Abort only this failed prompt; successful long thinking has no first-byte deadline.
void kernel.abort().catch(() => kernel.dispose()).catch(() => undefined)
fail(new Error(providerFailureMessage(message)))
}
const onError = (err: Error): void => fail(err)
const onExit = (): void => onError(new Error('OMP 进程已退出'))
kernel.on('event', onEvent)
kernel.once('error', onError)
kernel.once('exit', onExit)
// 完成 / 失败后解除监听
cleanup = (): void => {
kernel.off('event', onEvent)
kernel.off('error', onError)
kernel.off('exit', onExit)
}
kernel
.prompt(userText, options.images)
.then((frame) => {
if (settled) return
// prompt 的 ack 不代表回合结束;真正的结束由 agent_end / prompt_result 驱动。
// 但若 ack 失败,这里直接报错。
if (frame.success === false) {
const message =
(frame.error as { message?: string } | undefined)?.message ?? 'OMP 命令被拒绝'
fail(new Error(message))
}
})
.catch((err: Error) => fail(err))
})
}
function providerFailureMessage(value: unknown): string {
if (typeof value !== 'string' || !value.trim()) return 'OMP 模型服务请求失败'
return value.trim()
.replace(/https?:\/\/[^\s"'<>]+/gi, '[API 地址]')
.replace(/\bBearer\s+[^\s"',}]+/gi, 'Bearer [已隐藏]')
.replace(/\b(api[-_\s]?key|authorization)\b(["'\s:=]+)[^\s,"'}]+/gi, '$1$2[已隐藏]')
.replace(/\bsk[-_][\w-]{8,}/g, '[已隐藏]')
.slice(0, 1000)
}
/** 从工具事件里尽力取出 id / name / args / output(协议文档未给全载荷,做兼容解析) */
function readToolInfo(frame: OmpFrame, phase: 'start' | 'update' | 'end'): OmpToolInfo | null {
const candidates: unknown[] = [
frame.toolCall,
frame.call,
frame.tool,
frame.data,
frame
]
const objects = candidates.filter(isRecord)
const id = firstString(...objects.flatMap((o) => [o.id, o.toolCallId, o.callId])) ?? undefined
const name = firstString(...objects.flatMap((o) => [o.name, o.toolName, o.title])) ?? undefined
const intent = firstString(...objects.flatMap((o) => [o.intent])) ?? undefined
if (!id && !name) return null
const args = objects.flatMap((o) => [o.args, o.arguments, o.input]).find((value) => value !== undefined)
const argsRaw = args === undefined ? undefined : serialize(args)
const outputKeys =
phase === 'update'
? ['partialResult', 'partial', 'result', 'output', 'content', 'summary']
: ['result', 'output', 'content', 'summary', 'partialResult', 'partial']
let output: string | undefined
for (const o of objects) {
for (const key of outputKeys) {
if (!(key in o)) continue
output = extractOutput(o[key])
if (output !== undefined) break
}
if (output !== undefined) break
}
const successValues = objects.flatMap((o) => [
o.success,
isRecord(o.result) ? o.result.success : undefined,
isRecord(o.partialResult) ? o.partialResult.success : undefined
])
const ok = successValues.includes(false) ? false : successValues.includes(true) ? true : undefined
const isError = objects.some((o) =>
[o.isError, isRecord(o.result) ? o.result.isError : undefined].includes(true)
)
return { id, name, intent, argsRaw, output, ok, isError }
}
function firstString(...values: unknown[]): string | null {
for (const v of values) {
if (typeof v === 'string' && v) return v
}
return null
}
function safeParse(raw: string): unknown {
try {
return JSON.parse(raw)
} catch {
return raw
}
}
function serialize(value: unknown): string | undefined {
if (typeof value === 'string') return value
try {
return JSON.stringify(value)
} catch {
return undefined
}
}
function extractOutput(value: unknown): string | undefined {
if (typeof value === 'string') return value
if (value === undefined || value === null) return undefined
if (Array.isArray(value)) {
const text = textContent(value)
if (text !== undefined) return text
if (value.some(isContentPart)) return undefined
}
if (isRecord(value)) {
if (value.type === 'text' && typeof value.text === 'string') return value.text
if ('content' in value) {
if (Array.isArray(value.content)) return textContent(value.content)
return extractOutput(value.content)
}
if (containsBinaryPayload(value)) return undefined
}
const json = serialize(value)
return json === undefined ? undefined : truncate(json, TOOL_JSON_FALLBACK_LIMIT)
}
function textContent(parts: unknown[]): string | undefined {
const textParts = parts
.filter(isRecord)
.filter((part) => part.type === 'text' && typeof part.text === 'string')
.map((part) => part.text as string)
// No local OMP contract defines separators between text blocks; preserve order with line boundaries.
return textParts.length ? textParts.join('\n') : undefined
}
function isContentPart(value: unknown): boolean {
if (!isRecord(value) || typeof value.type !== 'string') return false
const kind = value.type.toLowerCase()
return /^(text|image|audio|video|binary|blob|file)([_-]|$)/.test(kind)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function containsBinaryPayload(value: unknown, depth = 0): boolean {
if (depth > 4 || value === null || typeof value !== 'object') return false
if (
Buffer.isBuffer(value) ||
value instanceof ArrayBuffer ||
ArrayBuffer.isView(value)
) {
return true
}
if (Array.isArray(value)) {
return value.some((item) => containsBinaryPayload(item, depth + 1))
}
const object = value as Record<string, unknown>
const kind = typeof object.type === 'string' ? object.type.toLowerCase() : ''
if (['image', 'audio', 'video', 'binary', 'blob'].includes(kind)) return true
const mediaType = object.mimeType ?? object.mediaType
if (typeof mediaType === 'string' && /^(image|audio|video)\//i.test(mediaType)) return true
for (const [key, child] of Object.entries(object)) {
if (['image', 'images', 'audio', 'video', 'binary', 'blob', 'bytes', 'buffer', 'base64'].includes(key.toLowerCase())) {
return true
}
if (containsBinaryPayload(child, depth + 1)) return true
}
return false
}
function setOutput(record: ToolCallRecord, output: string): void {
record.output = output
record.summary = truncate(output)
}
function elapsedSince(startedAt: number): number {
return Math.max(0, Date.now() - startedAt)
}
function truncate(text: string, limit = TOOL_SUMMARY_LIMIT): string {
return text.length <= limit ? text : `${text.slice(0, limit)}\n…(已截断,共 ${text.length} 字符)`
}
+243
View File
@@ -0,0 +1,243 @@
/**
* 宿主工具系统:路径约束、审批与取消由同一执行入口处理。
*
* 约定:
* - 所有工具都在 workspaceRoot 内活动,越界一律拒绝(含符号链接解析后的真实路径)
* - 工具结果统一为文本,便于回填给模型
* - 工具实现不感知 UI;进度与结果由 loop.ts 转成 ChatEvent
*/
import { promises as fs } from 'node:fs'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
import { dirname } from 'node:path'
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import type { ToolSpec } from '@shared/types'
import type { PermissionMode } from '@shared/conversation'
export interface ToolContext {
workspaceRoot: string
signal?: AbortSignal
permissionMode?: PermissionMode
approve?: (title: string) => Promise<boolean>
}
export interface ToolResult {
ok: boolean
content: string
data?: unknown
}
export interface Tool {
name: string
description: string
parameters: Record<string, unknown>
run(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>
}
class ToolError extends Error {}
async function writeTextAtomically(path: string, content: string): Promise<void> {
const temporary = join(dirname(path), `.ycode-write-${randomUUID()}.tmp`)
try { await fs.writeFile(temporary, content, 'utf8'); await fs.rename(temporary, path) }
finally { await fs.unlink(temporary).catch(() => undefined) }
}
/** 把用户/模型给的路径解析到工作区内,越界即抛错 */
async function resolveInWorkspace(input: unknown, ctx: ToolContext): Promise<string> {
const raw = typeof input === 'string' && input.trim() ? input.trim() : '.'
const rootReal = await fs.realpath(ctx.workspaceRoot).catch(() => resolve(ctx.workspaceRoot))
const target = isAbsolute(raw) ? resolve(raw) : resolve(rootReal, raw)
let existing = target
const suffix: string[] = []
let targetReal: string
for (;;) {
try { targetReal = join(await fs.realpath(existing), ...suffix); break }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const parent = dirname(existing)
if (parent === existing) throw error
suffix.unshift(existing.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)))
existing = parent
}
}
const rel = relative(rootReal, targetReal)
if (rel === '') return rootReal
if (ctx.permissionMode !== 'full-access' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
throw new ToolError(`路径越界,仅允许在工作目录内操作:${raw}`)
}
return targetReal
}
const listDir: Tool = {
name: 'list_dir',
description:
'列出工作目录内的文件与子目录。返回名称、类型与大小。用于了解项目结构。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: '相对于工作目录的路径,默认 "." 表示根目录' },
description: { type: 'string', description: 'Optional short English action label for the conversation timeline, e.g. "List contents of src directory". Keep it concise.' }
},
required: []
},
async run(args, ctx) {
const dir = await resolveInWorkspace(args.path, ctx)
const entries = await fs.readdir(dir, { withFileTypes: true })
if (entries.length === 0) return { ok: true, content: '(空目录)' }
const lines: string[] = []
for (const entry of entries.slice(0, 500)) {
if (entry.name === 'node_modules' || entry.name === '.git') continue
const full = join(dir, entry.name)
if (entry.isDirectory()) {
lines.push(`[dir] ${entry.name}/`)
} else {
const stat = await fs.stat(full).catch(() => null)
const size = stat ? `${stat.size}B` : '?'
lines.push(`[file] ${entry.name} ${size}`)
}
}
if (entries.length > 500) lines.push(`... 共 ${entries.length} 项,已截断`)
return { ok: true, content: lines.join('\n') || '(无内容)' }
}
}
const readFile: Tool = {
name: 'read_file',
description:
'读取工作目录内某个文本文件的内容。大文件请配合 offset/limit 分段读取。返回带行号的文本。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: '相对于工作目录的文件路径' },
description: { type: 'string', description: 'Optional short English action label for the conversation timeline, e.g. "Read file package.json". Keep it concise.' },
offset: { type: 'number', description: '起始行号,从 1 开始,默认 1' },
limit: { type: 'number', description: '读取行数,默认 400,最大 2000' }
},
required: ['path']
},
async run(args, ctx) {
const file = await resolveInWorkspace(args.path, ctx)
const stat = await fs.stat(file)
if (stat.isDirectory()) throw new ToolError(`${args.path} 是目录,请用 list_dir`)
if (stat.size > 4 * 1024 * 1024) throw new ToolError('文本文件超过 4 MB,请先缩小文件或使用其他读取方式')
const offset = Math.max(1, Number(args.offset) || 1)
const limit = Math.min(2000, Math.max(1, Number(args.limit) || 400))
const text = await fs.readFile(file, 'utf8')
const all = text.split(/\r?\n/)
const slice = all.slice(offset - 1, offset - 1 + limit)
const numbered = slice
.map((line, i) => `${String(offset + i).padStart(5, ' ')}| ${line}`)
.join('\n')
const header = `# ${args.path}(共 ${all.length} 行,显示 ${offset}-${offset + slice.length - 1})`
return { ok: true, content: `${header}\n${numbered}`, data: { path: file, content: slice.join('\n'), totalLines: all.length, startLine: offset, endLine: offset + slice.length - 1 } }
}
}
async function authorizeWrite(ctx: ToolContext, description: string): Promise<void> {
if (ctx.signal?.aborted) throw Object.assign(new Error('已取消'), { name: 'AbortError' })
if (ctx.permissionMode !== 'full-access' && !(await ctx.approve?.(description))) throw new ToolError('用户未批准此操作')
}
const writeFile: Tool = {
name: 'write_file', description: '创建或更新文本文件;默认权限下会请求用户确认。',
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
async run(args, ctx) {
if (typeof args.path !== 'string' || typeof args.content !== 'string') throw new ToolError('文件参数无效')
let path = await resolveInWorkspace(args.path, ctx)
const oldContent = await fs.readFile(path, 'utf8').catch((error) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
})
await authorizeWrite(ctx, `写入文件 ${args.path}`)
path = await resolveInWorkspace(args.path, ctx)
const current = await fs.readFile(path, 'utf8').catch((error) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
})
if (current !== oldContent) throw new ToolError('文件在等待审批时已变化,请重新读取后写入')
await fs.mkdir(dirname(path), { recursive: true })
path = await resolveInWorkspace(args.path, ctx)
await writeTextAtomically(path, args.content)
return { ok: true, content: `已写入 ${args.path}`, data: { path, oldContent, newContent: args.content } }
}
}
const editFile: Tool = {
name: 'edit_file', description: '精确替换文本文件中的唯一匹配内容;默认权限下会请求用户确认。',
parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'] },
async run(args, ctx) {
if (typeof args.path !== 'string' || typeof args.oldText !== 'string' || !args.oldText || typeof args.newText !== 'string') throw new ToolError('编辑参数无效')
let path = await resolveInWorkspace(args.path, ctx)
const oldContent = await fs.readFile(path, 'utf8')
if (oldContent.split(args.oldText).length !== 2) throw new ToolError('待替换内容必须唯一匹配')
await authorizeWrite(ctx, `编辑文件 ${args.path}`)
path = await resolveInWorkspace(args.path, ctx)
if (await fs.readFile(path, 'utf8') !== oldContent) throw new ToolError('文件已发生变化,请重新读取后编辑')
const newContent = oldContent.replace(args.oldText, args.newText)
await writeTextAtomically(path, newContent)
return { ok: true, content: `已编辑 ${args.path}`, data: { path, oldContent, newContent } }
}
}
const command: Tool = {
name: 'execute_command', description: '在工作目录执行 shell 命令;需要用户开启完全访问。',
parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
async run(args, ctx) {
if (ctx.permissionMode !== 'full-access') throw new ToolError('当前系统沙盒不可用,命令执行需要完全访问权限')
if (typeof args.command !== 'string' || !args.command.trim()) throw new ToolError('命令为空')
return new Promise((resolveResult, reject) => {
const child = spawn(args.command as string, { shell: true, cwd: ctx.workspaceRoot, windowsHide: true, detached: process.platform !== 'win32' })
const onAbort = (): void => {
if (!child.pid) return
if (process.platform === 'win32') {
const killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
killer.once('error', () => child.kill())
} else {
try { process.kill(-child.pid, 'SIGTERM') } catch { child.kill() }
}
}
ctx.signal?.addEventListener('abort', onAbort, { once: true })
if (ctx.signal?.aborted) onAbort()
let stdout = '', stderr = ''
const retain = (current: string, chunk: Buffer): string => (current + chunk.toString()).slice(-2 * 1024 * 1024)
child.stdout?.on('data', (chunk: Buffer) => { stdout = retain(stdout, chunk) })
child.stderr?.on('data', (chunk: Buffer) => { stderr = retain(stderr, chunk) })
child.once('error', (error) => { ctx.signal?.removeEventListener('abort', onAbort); reject(error) })
child.once('close', (exitCode) => { ctx.signal?.removeEventListener('abort', onAbort); resolveResult({ ok: exitCode === 0, content: [stdout, stderr].filter(Boolean).join('\n'), data: { stdout, stderr, exitCode } }) })
})
}
}
const askQuestion: Tool = { name: 'ask_user_question', description: '暂停并向用户提出选项或自由文本问题。', parameters: { type: 'object', properties: { title: { type: 'string' }, questions: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, question: { type: 'string' }, options: { type: 'array', items: { type: 'string' } }, multiSelect: { type: 'boolean' } }, required: ['question'] } } }, required: ['questions'] }, async run() { return { ok: false, content: '问答宿主未连接' } } }
const followupQuestion: Tool = { ...askQuestion, name: 'ask_followup_question', parameters: { type: 'object', properties: { question: { type: 'string' }, follow_up: { type: 'array', items: { type: 'string' } } }, required: ['question'] } }
const plan: Tool = { name: 'todo_write', description: '创建或更新任务计划和步骤进度。', parameters: { type: 'object', properties: { title: { type: 'string' }, todos: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'cancelled'] } }, required: ['content'] } } }, required: ['todos'] }, async run() { return { ok: false, content: '计划宿主未连接' } } }
export const ALL_TOOLS: Tool[] = [listDir, readFile, writeFile, editFile, command, askQuestion, followupQuestion, plan]
export function toolSpecs(): ToolSpec[] {
return ALL_TOOLS.map((t) => ({
type: 'function' as const,
function: { name: t.name, description: t.description, parameters: t.parameters }
}))
}
export function findTool(name: string): Tool | undefined {
return ALL_TOOLS.find((t) => t.name === name)
}
/** 统一执行入口:把异常收敛成 ToolResult,避免中断整个回合 */
export async function executeTool(
name: string,
args: Record<string, unknown>,
ctx: ToolContext
): Promise<ToolResult> {
const tool = findTool(name)
if (!tool) return { ok: false, content: `未知工具:${name}` }
try {
return await tool.run(args, ctx)
} catch (err) {
return { ok: false, content: `工具执行失败:${(err as Error).message}` }
}
}
+84
View File
@@ -0,0 +1,84 @@
import { app } from 'electron'
import { randomUUID } from 'node:crypto'
import { promises as fs } from 'node:fs'
import { basename, extname, join, relative, resolve, sep } from 'node:path'
import type { ChatAttachment } from '@shared/conversation'
const MAX_BYTES = 20 * 1024 * 1024
const MIME: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json', '.csv': 'text/csv' }
function root(): string { return join(app.getPath('userData'), 'attachments') }
function validId(id: string): boolean { return /^[0-9a-f-]{36}$/.test(id) }
const committedIds = new Set<string>()
export async function stageAttachment(name: string, mimeType: string, bytes: Buffer): Promise<ChatAttachment> {
if (!bytes.length || bytes.length > MAX_BYTES) throw new Error('附件为空或超过 20 MB')
const id = randomUUID()
const safeName = basename(name.replace(/\\/g, '/')).replace(/[<>:"|?*\x00-\x1f]/g, '_') || 'attachment'
if (['.', '..', 'metadata.json'].includes(safeName.toLowerCase())) throw new Error('附件文件名无效')
const mime = MIME[extname(safeName).toLowerCase()] ?? mimeType ?? 'application/octet-stream'
const directory = join(root(), id)
await fs.mkdir(directory, { recursive: true })
const path = join(directory, safeName)
const attachment: ChatAttachment = { id, name: safeName, mimeType: mime, size: bytes.length, path,
previewUrl: mime.startsWith('image/') ? `data:${mime};base64,${bytes.toString('base64')}` : undefined }
try {
await fs.writeFile(path, bytes)
await fs.writeFile(join(directory, 'metadata.json'), JSON.stringify(attachment))
} catch (error) { await removeAttachment(id); throw error }
return attachment
}
export async function stageAttachmentPath(path: string): Promise<ChatAttachment> {
const handle = await fs.open(path, 'r')
try {
const stat = await handle.stat()
if (!stat.isFile() || stat.size > MAX_BYTES) throw new Error('不支持该附件或超过 20 MB')
const bytes = Buffer.alloc(stat.size + 1)
let length = 0
while (length < bytes.length) {
const result = await handle.read(bytes, length, bytes.length - length, null)
if (!result.bytesRead) break
length += result.bytesRead
}
if (length > MAX_BYTES) throw new Error('附件超过 20 MB')
return stageAttachment(basename(path), '', bytes.subarray(0, length))
} finally { await handle.close() }
}
export async function getAttachment(id: string): Promise<ChatAttachment> {
if (!validId(id)) throw new Error('附件标识无效')
const attachment = JSON.parse(await fs.readFile(join(root(), id, 'metadata.json'), 'utf8')) as ChatAttachment
if (typeof attachment.name !== 'string' || basename(attachment.name) !== attachment.name || attachment.name === 'metadata.json') throw new Error('附件元数据无效')
const path = join(root(), id, attachment.name)
return { ...attachment, id, path }
}
export async function commitAttachments(attachments: ChatAttachment[]): Promise<void> {
for (const attachment of attachments) committedIds.add(attachment.id)
for (const attachment of attachments) {
const stored = await getAttachment(attachment.id)
await fs.writeFile(join(root(), attachment.id, 'metadata.json'), JSON.stringify({ ...stored, committed: true }))
}
}
export async function removeAttachment(id: string): Promise<void> {
if (!validId(id)) throw new Error('附件标识无效')
if (committedIds.has(id)) throw new Error('附件已用于会话,不能从草稿中删除原文件')
const metadata = await fs.readFile(join(root(), id, 'metadata.json'), 'utf8').catch((error) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '{}'
throw error
})
if ((JSON.parse(metadata) as { committed?: boolean }).committed) throw new Error('附件已用于会话,不能从草稿中删除原文件')
const target = resolve(root(), id)
const rel = relative(resolve(root()), target)
if (!rel || rel.startsWith(`..${sep}`) || rel === '..' || rel.includes(sep)) throw new Error('附件目录无效')
await fs.rm(target, { recursive: true, force: true })
}
export async function attachmentPrompt(attachments: ChatAttachment[]): Promise<{ text: string; images: Array<{ type: 'image'; data: string; mimeType: string }> }> {
const text: string[] = []
const images: Array<{ type: 'image'; data: string; mimeType: string }> = []
for (const reference of attachments) {
const attachment = await getAttachment(reference.id)
const bytes = await fs.readFile(attachment.path!)
if (attachment.mimeType.startsWith('image/') && attachment.mimeType !== 'image/svg+xml') images.push({ type: 'image', data: bytes.toString('base64'), mimeType: attachment.mimeType })
else if (!bytes.includes(0) && bytes.length < 2 * 1024 * 1024) text.push(`附件 ${attachment.name}:\n${bytes.toString('utf8')}`)
else text.push(`附件 ${attachment.name}(${attachment.mimeType},${attachment.size} 字节),本地路径:${attachment.path}`)
}
return { text: text.join('\n\n'), images }
}
+118
View File
@@ -0,0 +1,118 @@
import { WebContentsView, type BrowserWindow } from 'electron'
import { randomUUID } from 'node:crypto'
import { clampBrowserHostBounds, normalizeBrowserHostUrl, type BrowserHostBounds, type BrowserHostView } from '../shared/browser-host'
interface BrowserRecord { id: string; owner: BrowserWindow; sessionId?: string; view: WebContentsView; visible: boolean; error?: string; navigationVersion: number }
const browsers = new Map<string, BrowserRecord>()
const owners = new Set<number>()
function requireBrowser(owner: BrowserWindow, id: string, sessionId?: string): BrowserRecord {
const browser = browsers.get(id)
if (!browser || browser.owner !== owner || browser.sessionId !== sessionId || owner.isDestroyed() || browser.view.webContents.isDestroyed()) throw new Error('浏览器标签不存在或不属于当前任务')
return browser
}
function snapshot(browser: BrowserRecord): BrowserHostView {
const contents = browser.view.webContents
return { id: browser.id, sessionId: browser.sessionId, url: contents.getURL() || 'about:blank',
title: contents.getTitle() || '新标签页', loading: contents.isLoading(), canGoBack: contents.navigationHistory.canGoBack(),
canGoForward: contents.navigationHistory.canGoForward(), visible: browser.visible, error: browser.error }
}
function release(browser: BrowserRecord): void {
browsers.delete(browser.id)
browser.visible = false
if (!browser.owner.isDestroyed()) browser.owner.contentView.removeChildView(browser.view)
if (!browser.view.webContents.isDestroyed()) browser.view.webContents.close({ waitForBeforeUnload: false })
}
function guardNavigation(browser: BrowserRecord, event: { preventDefault(): void }, url: string): void {
try { normalizeBrowserHostUrl(url) } catch { event.preventDefault(); browser.error = '仅支持 HTTP 或 HTTPS 网页地址' }
}
function aborted(error: unknown): boolean { return !!error && typeof error === 'object' && 'code' in error && error.code === 'ERR_ABORTED' }
async function load(browser: BrowserRecord, url: string): Promise<void> {
const version = ++browser.navigationVersion
browser.error = undefined
try { await browser.view.webContents.loadURL(url) }
catch (error) { if (version === browser.navigationVersion && !browser.view.webContents.isDestroyed() && !aborted(error)) browser.error = error instanceof Error ? error.message : String(error) }
}
export function createBrowser(owner: BrowserWindow, sessionId?: string, initialUrl = 'about:blank'): BrowserHostView {
if (owner.isDestroyed()) throw new Error('宿主窗口已关闭')
if (sessionId !== undefined && (typeof sessionId !== 'string' || sessionId.length > 256 || !sessionId)) throw new Error('任务标识无效')
const url = normalizeBrowserHostUrl(initialUrl)
const id = randomUUID()
const view = new WebContentsView({ webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false,
webSecurity: true, allowRunningInsecureContent: false, partition: `ycode-browser-${id}` } })
const browser: BrowserRecord = { id, owner, sessionId, view, visible: false, navigationVersion: 0 }
browsers.set(id, browser)
const contents = view.webContents
contents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
contents.session.setPermissionCheckHandler(() => false)
contents.session.on('will-download', event => event.preventDefault())
contents.on('will-navigate', (event, nextUrl) => guardNavigation(browser, event, nextUrl))
contents.on('will-redirect', (event, nextUrl) => guardNavigation(browser, event, nextUrl))
// No unmanaged popup escapes the owner/session. Exact original popup routing is unverified.
contents.setWindowOpenHandler(({ url: nextUrl }) => {
try { void load(browser, normalizeBrowserHostUrl(nextUrl)) }
catch { browser.error = '仅支持 HTTP 或 HTTPS 网页地址' }
return { action: 'deny' }
})
contents.on('did-start-navigation', (_event, _nextUrl, _isInPlace, isMainFrame) => { if (isMainFrame) browser.error = undefined })
contents.on('did-fail-load', (_event, code, description, _url, isMainFrame) => { if (isMainFrame && code !== -3) browser.error = description })
contents.on('render-process-gone', (_event, details) => { browser.error = `浏览器进程已结束:${details.reason}` })
contents.on('destroyed', () => browsers.delete(id))
owner.contentView.addChildView(view)
view.setVisible(false)
if (!owners.has(owner.id)) {
owners.add(owner.id)
owner.once('closed', () => { owners.delete(owner.id); for (const record of [...browsers.values()]) if (record.owner === owner) release(record) })
owner.on('resize', () => { if (!owner.isDestroyed()) for (const record of browsers.values()) if (record.owner === owner && record.visible) {
const [width, height] = owner.getContentSize(); const bounds = clampBrowserHostBounds(record.view.getBounds(), width, height)
record.view.setBounds(bounds)
} })
}
if (url !== 'about:blank') void load(browser, url)
return snapshot(browser)
}
export function getBrowserState(owner: BrowserWindow, id: string, sessionId?: string): BrowserHostView { return snapshot(requireBrowser(owner, id, sessionId)) }
/** The IPC caller validates the destination YCode session before transferring a draft tab. */
export function rebindBrowser(owner: BrowserWindow, id: string, previousSessionId: string | undefined, nextSessionId: string): BrowserHostView {
const browser = requireBrowser(owner, id, previousSessionId)
if (typeof nextSessionId !== 'string' || !nextSessionId || nextSessionId.length > 256) throw new Error('目标任务标识无效')
browser.sessionId = nextSessionId
return snapshot(browser)
}
export async function navigateBrowser(owner: BrowserWindow, id: string, url: string, sessionId?: string): Promise<BrowserHostView> {
const browser = requireBrowser(owner, id, sessionId)
const target = normalizeBrowserHostUrl(url)
await load(browser, target)
return snapshot(browser)
}
export function backBrowser(owner: BrowserWindow, id: string, sessionId?: string): BrowserHostView {
const browser = requireBrowser(owner, id, sessionId)
if (browser.view.webContents.navigationHistory.canGoBack()) browser.view.webContents.navigationHistory.goBack()
return snapshot(browser)
}
export function forwardBrowser(owner: BrowserWindow, id: string, sessionId?: string): BrowserHostView {
const browser = requireBrowser(owner, id, sessionId)
if (browser.view.webContents.navigationHistory.canGoForward()) browser.view.webContents.navigationHistory.goForward()
return snapshot(browser)
}
export function reloadBrowser(owner: BrowserWindow, id: string, sessionId?: string): BrowserHostView {
const browser = requireBrowser(owner, id, sessionId)
browser.error = undefined; browser.view.webContents.reload()
return snapshot(browser)
}
export function setBrowserBounds(owner: BrowserWindow, id: string, bounds: BrowserHostBounds | null, sessionId?: string): void {
// Component teardown can finish after the tab has already released its native view.
if (bounds === null && !browsers.has(id)) return
const browser = requireBrowser(owner, id, sessionId)
if (bounds === null) { browser.view.setVisible(false); browser.visible = false; return }
const [width, height] = owner.getContentSize()
const clamped = clampBrowserHostBounds(bounds, width, height)
browser.view.setBounds(clamped)
browser.visible = clamped.width > 0 && clamped.height > 0
browser.view.setVisible(browser.visible)
}
export function hideBrowser(owner: BrowserWindow, id: string, sessionId?: string): void { setBrowserBounds(owner, id, null, sessionId) }
export function closeBrowser(owner: BrowserWindow, id: string, sessionId?: string): void {
if (!browsers.has(id)) return
release(requireBrowser(owner, id, sessionId))
}
export function disposeOwnerBrowsers(owner: BrowserWindow): void { for (const browser of [...browsers.values()]) if (browser.owner === owner) release(browser) }
@@ -0,0 +1,15 @@
{
"files": {
".codebuddy-plugin/plugin.json": "{\n \"name\": \"skill-expert-manager\",\n \"version\": \"5.8.2-wb.41111488.gb8f6c953.haebd5991e0e2\",\n \"description\": \"WorkBuddy bundled skill resource: expert-manager.\",\n \"author\": {\n \"name\": \"WorkBuddy\"\n },\n \"category\": \"skill\",\n \"keywords\": [\n \"workbuddy\",\n \"builtin-skill\",\n \"expert-manager\"\n ],\n \"workbuddy\": {\n \"kind\": \"builtin-skill\",\n \"legacyResourceName\": \"expert-manager\",\n \"bundleSegments\": [\n \"plugins\",\n \"workbuddy-builtin\",\n \"skills\",\n \"expert-manager\"\n ]\n }\n}\n",
"SKILL.md": "---\nname: expert-manager\ndescription: |\n 专家包的全生命周期运营:转化(从开源仓库/本地项目创建专家包)、修改已有专家、合规检查、批量更新、质量审查。\n 触发词:创建专家、转化专家、转成专家、生成专家包、导入专家、convert expert、修改专家、编辑专家、更新专家、modify expert、检查专家、审查专家包、专家合规、专家运营、expert ops。\ndescription_en: |\n Manage the full lifecycle of WorkBuddy Expert packages: create or convert an Expert from an open-source repository or local project, edit existing Experts, run compliance checks, apply batch updates, and review quality. Use for requests to create, import, modify, inspect, or maintain an Expert or Expert Team package.\n---\n\n# WorkBuddy 专家包管理器\n\n> ⚠️ **执行前必读**:当需要使用本 skill 时,你必须先从头到尾完整阅读本 SKILL.md 全文并严格遵守(包括所有规则、流程、References 列表),然后再开始执行任务。禁止跳读或仅凭部分段落就开始行动。\n\n你是 WorkBuddy 专家包管理器,帮助用户按照 WorkBuddy 专家开发规范(v2.0)创建和维护完整的、可提交审核的专家文件包。\n\n支持两种专家类型:\n- **Agent 型**(`expertType: \"agent\"`):单个 AI 专家\n- **Team 型**(`expertType: \"team\"`):多角色协作团队\n\n支持两种输入模式:\n1. **交互模式**:用户描述需求,通过提问收集信息后生成\n2. **资料转化模式**:用户提供现有资料(文档、流程、提示词等),从中提取信息直接转化\n\n---\n\n## 关键展示字段对应关系\n\n以下字段决定了专家在产物面板中的展示,**所有场景(创建/转化/修改)均需遵守**:\n\n| 展示 | 对应字段 | 变更规则 |\n|---------|---------|---------|\n| **名字(职业)** | `profession`(`{en, zh}`) | 卡片标题,应体现专家职业定位 |\n| **花名** | `displayName`(`{en, zh}`) | 可根据用户要求自由修改 |\n| **类型** | `expertType`(`\"agent\"` → 显示\"专家\",`\"team\"` → 显示\"专家团\") | **不可随意指定/变更**,必须根据实际结构判断:单角色 = `\"agent\"`,多角色协作 = `\"team\"` |\n| **行业分类** | `categoryId` | **不可随意指定/变更**,必须根据专家核心能力领域从分类列表中选择,并向用户说明选择理由 |\n| **能力介绍** | `displayDescription`(`{en, zh}`) | 中文 40-50 字,突出核心能力 |\n| **擅长领域** | `tags`(`{en, zh}[]`,固定 3 个) | 展示专家擅长的领域标签;已满 3 个时必须提示用户替换或删除已有标签,禁止继续新增 |\n| **试试这样问我** | `quickPrompts`(`{en, zh}[]`,固定 3 个) | 推荐提示词,第一条同时作为 `defaultInitPrompt` |\n\n---\n\n## 一、工作流程\n\n### 整体流程\n\n```\n1. 收集信息(交互 or 资料转化)\n2. 初始化目录 → scripts/init_expert.py\n3. 生成文件内容 → 参考 references/\n4. 生成头像 → 参考 references/avatar-spec.md\n5. 校验 → scripts/validate_expert.py\n6. 注册 → scripts/register_expert.py\n7. 打包 → scripts/package_expert.py\n```\n\n> **批量创建**:多个专家时串行重复上述流程,参考 `scripts/batch_create.py`。\n\n### 场景 A:交互模式\n\n**专家目录(固定)**:由环境变量 `WORKBUDDY_CONFIG_DIR` 决定,完整路径为 `$WORKBUDDY_CONFIG_DIR/plugins/marketplaces/my-experts/plugins`(未设置时默认 `~/.workbuddy/plugins/marketplaces/my-experts/plugins`)。**禁止**将专家生成到其他目录。如果用户要求创建到其他路径,必须拒绝并说明:\"专家必须生成到专家目录才能被检测到,其他目录生成后将无法使用。\"然后使用专家目录继续执行。\n\n**必须明确的信息:**\n1. **专家类型(expertType)**:Agent 还是 Team?(判定规则见上方\"关键展示字段对应关系\")\n2. **专家领域**:擅长什么?\n\n**Agent 型还需要:**\n- 名字(中英文)、职业头衔(中英文)\n- 详细能力描述(中英文,中文 40-50 字)\n- 首次对话问候语(中英文)\n- 行业分类(见下方列表)\n- 擅长领域标签(固定 3 个,中英文)\n- 推荐提示词(3 个,中英文,第一条同时作为 defaultInitPrompt)\n- 是否需要附带 Skill / bin/\n\n**Team 型还需要:**\n- 团队名称(中英文)、团队职业头衔(中英文,须与团队名称一致)\n- 主理人名字、职责\n- 每个团员的名字、职业头衔、职责\n- 团队 SOP 工作流程\n- 首次对话问候语、行业分类、标签、推荐提示词\n\n### 场景 B:资料转化模式\n\n当用户提供文件路径或粘贴内容时:\n\n1. **读取分析** — 从资料中提取角色定义、核心能力、SOP、输出规范、约束、脚本Line truncated
"references/plugin-json-spec.md": "# plugin.json 字段规范\n\n## 基础字段(必填)\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `name` | string | 唯一标识,小写字母+连字符,也是技能命名空间前缀 |\n| `version` | string | 语义化版本号(MAJOR.MINOR.PATCH) |\n| `description` | string | 英文一句话描述 |\n\n## 可选基础字段\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `author` | `{name, email}` | 作者信息 |\n| `homepage` | string 或 `{url, type}` | 项目主页 |\n| `license` | string | 许可证 |\n| `keywords` | string[] | 搜索标签 |\n\n## 类型字段\n\n| 字段 | 说明 |\n|------|------|\n| `expertType` | `\"agent\"` / `\"team\"` |\n| `agentName` | 主 Agent 名称(对应 agents/ 下 MD 文件名,不含 .md)。**必须有业务语义**,不能使用 `team-lead` 等通用名 |\n| `teamInfo` | team 时必填:`{leadAgent, memberAgents[]}` |\n\n## 资源声明\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `agents` | string[] | Agent 定义文件路径列表(如 `[\"./agents/my-expert.md\"]`) |\n| `skills` | string[] | Skill 目录路径列表(如 `[\"./skills/my-skill\"]`) |\n\n## 展示字段(agent/team 上架市场时必填)\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `displayName` | `{en, zh}` | 展示名称 |\n| `profession` | `{en, zh}` | 职业/定位。**Team 型须与 displayName 一致** |\n| `displayDescription` | `{en, zh}` | 展示描述。**中文 40-50 字,突出核心能力** |\n| `avatar` | string | 头像相对路径 |\n| `categoryId` | string | 行业分类 ID |\n| `defaultInitPrompt` | `{en, zh}` | 默认引导语。**须与 quickPrompts 第一条一致** |\n| `tags` | `{en, zh}[]` | 擅长领域标签(**固定 3 个**) |\n| `quickPrompts` | `{en, zh}[]` | 推荐提示词(**固定 3 个**) |\n| `plugin` | string | 值与 `name` 一致 |\n\n## Team 专用字段\n\n| 字段 | 说明 |\n|------|------|\n| `members[]` | 每个成员含 `{id, name:{en,zh}, profession:{en,zh}, avatar, role}` |\n\n- `role` 取值:`\"lead\"` 或 `\"member\"`\n- 主理人也必须在 members 中,role 为 `\"lead\"`\n- `teamInfo.memberAgents` **不含**主理人\n\n---\n\n## 模板:Agent 型\n\n```json\n{\n \"name\": \"{kebab-case-name}\",\n \"version\": \"1.0.0\",\n \"description\": \"{English one-line description}\",\n \"author\": {\n \"name\": \"{author-name}\",\n \"email\": \"{author-email}\"\n },\n \"agents\": [\"./agents/{agent-name}.md\"],\n \"skills\": [\"./skills/{skill-name}\"],\n\n \"expertType\": \"agent\",\n \"agentName\": \"{agent-name}\",\n\n \"displayName\": {\n \"en\": \"{English display name}\",\n \"zh\": \"{中文显示名称}\"\n },\n \"profession\": {\n \"en\": \"{English profession}\",\n \"zh\": \"{中文职业头衔}\"\n },\n \"displayDescription\": {\n \"en\": \"{English detailed description}\",\n \"zh\": \"{中文详细描述,40-50字}\"\n },\n \"avatar\": \"avatars/expert.png\",\n \"categoryId\": \"{XX-CategoryName}\",\n \"defaultInitPrompt\": {\n \"zh\": \"{中文首次对话提示}\",\n \"en\": \"{English first prompt}\"\n },\n \"plugin\": \"{kebab-case-name}\",\n \"tags\": [\n { \"en\": \"{Tag1 EN}\", \"zh\": \"{标签1}\" },\n { \"en\": \"{Tag2 EN}\", \"zh\": \"{标签2}\" },\n { \"en\": \"{Tag3 EN}\", \"zh\": \"{标签3}\" }\n ],\n \"quickPrompts\": [\n { \"en\": \"{Prompt1 EN}\", \"zh\": \"{提示词1}\" },\n { \"en\": \"{Prompt2 EN}\", \"zh\": \"{提示词2}\" },\n { \"en\": \"{Prompt3 EN}\", \"zh\": \"{提示词3}\" }\n ]\n}\n```\n\n> 如果没有 skills,则省略 `\"skills\"` 字段。\n\n## 模板:Team 型\n\n```json\n{\n \"name\": \"{kebab-case-name}\",\n \"version\": \"1.0.0\",\n \"description\": \"{English one-line description}\",\n \"author\": {\n \"name\": \"{author-name}\",\n \"email\": \"{author-email}\"\n },\n \"agents\": [\n \"./agents/{team}-team-lead.md\",\n \"./agents/{member-a}.md\",\n \"./agents/{member-b}.md\"\n ],\n \"skills\": [\"./skills/{skill-name}\"],\n\n \"expertType\": \"team\",\n \"agentName\": \"{team}-team-lead\",\n \"teamInfo\": {\n \"leadAgent\": \"{team}-team-lead\",\n \"memberAgents\": [\"{member-a}\", \"{member-b}\"]\n },\n\n \"displayName\": {\n \"en\": \"{English team name}\",\n \"zh\": \"{中文团队名称}\"\n },\n \"profession\": {\n \"en\": \"{English team name}\",\n \"zh\": \"{中文团队名称}\"\n },\n \"displayDescription\": {\n \"en\": \"{English team description}\",\n \"zh\": \"{中文团队描述,40-50字}\"\n },\n \"avatar\": \"avatars/team.png\",\n \"categoryId\": \"{XX-CategoryName}\",\n \"defaultInitPrompt\": {\n \"zh\": \"{中文首次问候}\",\n \"en\": \"{English first prompt}\"\n },\n \"plugin\": \"{kebab-case-name}\",\n \"tags\": [\n { \"en\": \"{Tag1 EN}\", \"zh\": \"{标签1}\" },\n { \"en\": \"{Tag2 EN}\", \"zh\": \Line truncated
"references/agent-md-spec.md": "# Agent MD 规范\n\n## Frontmatter\n\n### ⚠️ 重要:frontmatter 中不可添加 tools 字段\n\n所有工具权限由系统统一分配,开发者无需也不能在 frontmatter 中声明 `tools` 字段。\n\n### 必填字段\n\n```yaml\n---\nname: {与文件名一致,必须有业务语义}\ndescription: {英文描述,AI 用来判断何时激活}\ndisplayName:\n en: \"{English display name}\"\n zh: \"{中文显示名称}\"\nprofession:\n en: \"{English profession title}\"\n zh: \"{中文职业头衔}\"\nmaxTurns: {默认50}\n---\n```\n\n### 可选字段\n\n```yaml\nskills: [{skill-name}] # 启动时预加载的 Skill\n```\n\n---\n\n## 正文结构:普通 Agent / 团员\n\n```markdown\n# {角色名称} - {人名}\n\n{角色描述,说明这是谁,擅长什么}\n\n## 核心能力\n1. **{能力1}**:{描述}\n2. **{能力2}**:{描述}\n3. **{能力3}**:{描述}\n\n## 工作流程\n1. {步骤1}\n2. {步骤2}\n3. {步骤3}\n\n## 输出规范\n- {规范1}\n- {规范2}\n- {规范3}\n\n## 注意事项\n- {约束或边界条件}\n```\n\n### 团队成员额外要求\n\n- 不要在 frontmatter 中限制 tools 范围\n- 由主理人通过 Agent 工具 spawn 为正式 teammate\n- 分析完成后必须通过 SendMessage 将结果回传给主理人\n- Prompt 中须明确写出 SendMessage 回传要求\n\n### 成员 Prompt 必备结构\n\n1. **角色定义**:一句话说清\"你是谁\"\n2. **擅长领域**:3-5 个具体能力点\n3. **分析框架**:内嵌的分析能力,分步骤流程\n4. **数据获取方式**:具体的查询命令或工具调用\n5. **结构化输出模板**:表格/分段格式\n6. **SendMessage 回传要求**\n\n---\n\n## 正文结构:主理人\n\n> ⚠️ 文件名不可使用通用的 `team-lead.md`,必须加上专家团前缀,如 `trading-team-lead.md`\n\n### Frontmatter\n\n```yaml\n---\nname: {team}-team-lead\ndescription: {English description}\ndisplayName:\n en: \"{English display name}\"\n zh: \"{中文显示名称}\"\nprofession:\n en: \"{English profession title}\"\n zh: \"{中文职业头衔}\"\nmaxTurns: {turns, 建议150-200}\n---\n```\n\n### 正文模板\n\n```markdown\n# {团队名称} - 主理人\n\n{主理人角色描述,负责协调团队完成什么任务}\n\n## 团队成员\n\n### {分组名称}\n| 成员 | 名字 | 职责 |\n|------|------|------|\n| {member-id} | {name} | {职责描述} |\n\n## 标准工作流程(SOP)\n\n### Phase 1: {阶段名}\n{调用哪些成员、输入输出说明}\n\n### Phase 2: {阶段名}\n{...}\n\n### Phase N: 最终报告\n综合所有分析结果,生成最终报告返回用户。\n\n## 团队协作机制(铁律)\n\n你必须走正式的**团队协作流程**,严禁简化或跳过:\n\n1. **建立团队**:任务开始时由主理人亲自创建团队(TeamCreate),明确协作边界。**团队创建必须且只能由主理人执行,严禁委派任何成员创建团队**\n2. **调度成员**:按 SOP 阶段将成员拉入协作、下发独立任务;成员作为独立协作方输出专业产出,不得由主理人代写\n3. **消息中转**:成员产出回传给主理人,由主理人汇总、转交下一阶段;所有跨成员信息流必须经主理人中转,不得互相直连\n4. **成员结论为准**:任何专业产出必须由对应成员输出后再采信,主理人只做编排与汇编\n\n### 严禁行为\n- ❌ 禁止跳过 TeamCreate,直接自己模拟成员发言或并行写出多角色内容\n- ❌ 禁止自己代写任何团队成员的专业产出\n- ❌ 禁止未完成前序阶段就跳到后续阶段\n- ❌ 禁止让成员互相直连通信,所有跨成员信息流必须经主理人中转\n- ❌ 禁止 spawn 主理人自己\n\n## 协作规则\n1. 所有成员调度必须经过\"建立团队 → 调度成员 → 成员回传\"流程\n2. 每阶段结束后,将完整产出原文传递给下一阶段成员\n3. 每完成一个阶段向用户简要通报\n4. 所有输出使用与用户原始需求相同的语言\n5. 调度成员时,Agent 工具的 `name` 参数传入成员的 **Agent ID**(MD 文件名,不含 .md),`subagent_type` 也传入相同值。禁止使用中文名或自创名称\n```\n\n---\n\n## 主理人额外要求\n\n### 成员能力清单\n\n主理人 prompt 中必须列出每个成员的:\n- Agent ID\n- 擅长领域(3-5 个具体能力点)\n- 典型问法(什么问题该调它)\n\n### 预设 Workflow\n\n针对高频综合性问题设计预设 Workflow,每个 Workflow 写明:\n- **触发条件**:什么类型的问法匹配\n- **Phase 编排**:分 Phase 的串并行调度\n- **输入输出依赖**:每个 Phase 的输入来自哪里、输出传给谁\n\n### 单 agent 直调路由表\n\n| 问法类型 | 直接调谁 |\n|---------|---------|\n| 单一维度问题 | 对应成员 |\n| 综合性问题 | 走预设 Workflow |\n\n---\n\n## 能独立成 agent 的标准\n\n判断标准:Line truncated
"references/team-spec.md": "# Team 型专用规范\n\n## 成员命名规范\n\nTeam 型专家团中,每个成员有三个外露字段:`name`(名字)、`profession`(职业头衔)、`nickname`(昵称,可选)。三者不应重复。\n\n### name:谐音花名风格\n\n| 规则 | 说明 |\n|------|------|\n| 建议三个字 | 姓+名,建议三个字,两个字也可 |\n| 先是正常人名 | 姓+名结构,不解释也能当名字用 |\n| 暗含职能巧思 | 谐音、拆字、典故均可,但不刻意 |\n| 不与 profession 重复 | name 是\"谁\",profession 是\"做什么\" |\n| 中英文都自然 | 中文用花名,英文用拼音姓氏 |\n\n**示例(软件开发团队):**\n\n| profession | name(zh) | 巧思 | name(en) |\n|-----------|----------|------|----------|\n| 交付总监 | 齐活林 | 齐活了(交付完成) | Qi |\n| 产品经理 | 许清楚 | 需求要说清楚 | Xu |\n| 架构师 | 高见远 | 高见(架构视野) | Gao |\n| 项目经理 | 毕达成 | 必达(交付必达) | Bi |\n| 工程师 | 寇豆码 | code(代码) | Kou |\n| QA工程师 | 严过关 | 严格过关 | Yan |\n\n**禁止:**\n- 叠字谐音(领码码、需求求、蓝图图)\n- 一个字的 name 或纯职能词\n- 和 profession 重复\n- 无意义随机名(张三、John Doe)\n- 英文 name 用 Agent ID\n- 读起来是短语而非人名\n\n### 主理人 profession 规范\n\n主理人的 `profession` 不能用通用 title(团长、主理人、Team Lead),应体现该团队的调度风格和业务定位。\n\n**示例:**\n\n| 专家团 | 主理人 name | profession(zh) | profession(en) |\n|--------|-----------|---------------|----------------|\n| 软件开发团队 | 齐活林 | 交付总监 | Delivery Director |\n| 交易分析团队 | 何执舟 | 首席策略官 | Chief Strategist |\n| 营销战役团队 | 营销总监 | 增长操盘手 | Growth Operator |\n| 深度研究团队 | 顾全之 | 研究主编 | Research Editor |\n\n---\n\n## 协作铁律\n\n### 4 条正则\n\n1. **建立团队**:任务开始时由主理人亲自创建团队(TeamCreate),明确协作边界。**团队创建必须且只能由主理人执行,严禁委派任何成员创建团队**\n2. **调度成员**:按 SOP 阶段将成员拉入协作、下发独立任务;成员作为独立协作方输出专业产出,不得由主理人代写\n3. **消息中转**:成员产出回传给主理人,由主理人汇总、转交下一阶段;所有跨成员信息流必须经主理人中转,不得互相直连\n4. **成员结论为准**:任何专业产出必须由对应成员输出后再采信,主理人只做编排与汇编\n\n### 5 条红线\n\n- ❌ 禁止跳过 TeamCreate,直接自己模拟成员发言或并行写出多角色内容\n- ❌ 禁止自己代写任何团队成员的专业产出\n- ❌ 禁止未完成前序阶段就跳到后续阶段\n- ❌ 禁止让成员互相直连通信,所有跨成员信息流必须经主理人中转\n- ❌ 禁止 spawn 主理人自己(编排、汇总、决策由主理人亲自完成)\n\n### 协作规则\n\n1. 所有成员调度必须经过\"TeamCreate → Agent spawn → SendMessage 回传\"正式流程\n2. 每阶段结束后,将完整产出原文传递给下一阶段成员\n3. 调度成员时,在 Agent 工具的 `name` 参数中传入该成员的 **Agent ID**(agents/ 下的 MD 文件名,不含 .md),`subagent_type` 也传入相同值。**禁止**使用中文名或自创名称\n4. 裁决型角色(如研究主管、风险主管)必须给出明确结论,不得回避决策\n5. 每完成一个阶段向用户简要通报进度\n\n---\n\n## SOP 工作流编排\n\n### 并行 vs 串行\n\n- **并行 Phase**:同一条消息中 spawn 多个成员(使用 TeamCreate),适用于成员间无数据依赖\n- **串行 Phase**:等前一 Phase 全部回传后,将结论传入下一 Phase 的成员 prompt\n\n### 示例 SOP\n\n```\nPhase 1(并行):\n stock-researcher → 基本面+财务\n valuation-pricer → 估值判断\n money-tracker → 资金态度\n\nPhase 2(串行,Phase 1 结论传入):\n risk-doctor → 综合风险诊断\n\n主理人汇编 → 输出\n```\n\n### 预设 Workflow 设计原则\n\n每个 Workflow 写明:\n- **触发条件**:什么类型的问法匹配此 Workflow\n- **Phase 编排**:分 Phase 的串并行调度\n- **输入输出依赖**:每个 Phase 的输入来自哪里、输出传给谁\n\nWorkflow 数量根据用户实际高频场景设计,不是越多越好。\n\n---\n\n## settings.json\n\nTeam 型必须有 `settings.json`:\n\n```json\n{\n \"agent\": \"{team}-team-lead\"\n}\n```\n\n值必须与 plugin.json 的 `agentName` 和主理人 MD 文件的 `name` 一致。\n",
"references/avatar-spec.md": "# 头像生成规范\n\n## 基本要求\n\n| 项目 | 要求 |\n|------|------|\n| 格式 | PNG(推荐)或 JPG |\n| 尺寸 | 512×512 px(正方形) |\n| 大小 | 单张不超过 500KB |\n| 风格 | 统一漫画/插画风格,专业自然 |\n| 内容 | 符合角色定位,不含违规内容 |\n\n> 使用 `ImageGen` 工具生成,`size` 参数设为 `\"1024x1024\"`(高清大图,市场缩放为 512×512 展示)。\n\n## 生成策略\n\n**Agent 型**:1 张头像\n- `avatars/expert.png`\n\n**Team 型**:N+1 张头像\n- `avatars/team.png` — 团队整体头像\n- `avatars/{team}-team-lead.png` — 主理人头像\n- `avatars/{member-name}.png` — 每个团员头像\n\n---\n\n## Prompt 构建核心原则\n\n**每个头像的 prompt 必须从对应的 MD 文件描述中提取角色特征,不使用通用模板硬编码。**\n\n### 提取步骤\n\n1. **读取 Agent MD 文件**\n2. **提取角色身份**:从标题和首段提取\n3. **提取专业特征**:从\"核心能力\"章节提取关键词,转化为视觉元素\n4. **推断工作风格**:从\"工作流程\"和\"注意事项\"推断性格气质\n5. **推断人物属性**:从 name 字段推断性别和风格基调\n\n---\n\n## 个人头像 Prompt 组装\n\n```\n[风格前缀] + [角色身份] + [外观特征] + [表情气质] + [背景元素] + [质量后缀]\n```\n\n| 部分 | 说明 | 示例 |\n|------|------|------|\n| 风格前缀 | 统一漫画/插画风格 | `Professional cartoon-style illustration avatar,` |\n| 角色身份 | 从 MD 标题/首段提取 | `a female design system document architect` |\n| 外观特征 | 从核心能力推断穿着/配饰 | `wearing stylish glasses, holding a design specification document` |\n| 表情气质 | 从工作风格推断 | `confident and meticulous expression` |\n| 背景元素 | 从专业领域提取视觉符号 | `subtle design tokens and color palette swatches in background` |\n| 质量后缀 | 固定 | `Bust shot, facing forward. Clean simple background. High quality, professional, natural.` |\n\n### 示例 1:设计系统架构师\n\nMD 核心内容:角色=设计系统文档架构师,能力=9大标准章节、AI可读格式,输出=Markdown+HEX+CSS\n\n```\nProfessional cartoon-style illustration avatar, a female design system document architect,\nwearing stylish glasses, holding a design guideline document, modern creative smart casual attire,\nconfident and meticulous expression with a creative yet precise aura,\nsubtle color palette swatches, typography samples and design token symbols in the background.\nBust shot, facing forward. Clean simple warm-toned background. High quality, professional, natural.\n```\n\n### 示例 2:技术分析师\n\nMD 核心内容:角色=技术分析师,能力=K线形态、均线分析、MACD/RSI/KDJ\n\n```\nProfessional cartoon-style illustration avatar, a male technical stock market analyst named Marco,\nwearing a sharp vest over dress shirt, looking at holographic candlestick charts,\nfocused and analytical expression with sharp observant eyes,\nK-line charts, moving average lines and MACD indicators floating in the background.\nBust shot, facing forward. Clean simple blue-toned background. High quality, professional, natural.\n```\n\n---\n\n## 团队头像 Prompt 构建\n\n**输入来源**:plugin.json 的 `displayDescription` + 主理人 MD 的团队描述\n\n### 提取步骤\n\n1. **团队定位**:从 displayDescription 提取团队做什么\n2. **协作模式**:从主理人 MD 的 SOP 提取工作阶段\n3. **成员构成**:从成员列表提取角色类型的多样性\n4. **视觉表达**:将以上信息转化为体现团队协作的场景\n\n### Prompt 组装\n\n```\n[风格前缀] + [团队场景描述] + [协作元素] + [成员象征] + [质量后缀]\n```\n\n### 示例:交易分析团队\n\n```\nProfessional cartoon-style illustration, a dynamic stock trading analysis team scene,\nmultiple diverse analysts gathered around a central holographic display showing candlestick charts,\na bull figure and a bear figure debating on opposite sides symbolizing bull-bear debate,\nrisk gauges and decision dashboards floating around,\nwarm collaborative atmosphere with focused professional energy.\nClean simple multi-tone gradient background. High quality, professional, team composition.\n```\n\n---\n\n## 同一团队风格统一规则\n\nTeam 型的所有头像必须在 prompt 中保持一致的**风格锚定词**:\n\n**固定风格前缀(每个 prompt 开头):**\n```\nProfessional cartoon-style illustration avatar, consistent art style with warm lighting and soft shadows,\n```\n\n**固定质量后缀(每个 prompt 结尾):**\n```\nBust shot, facing forward. Clean simple {color}-toned background. High quality, professional, natural.\n```\n\n### 背景色调映射\n\n| categoryId | 背景色调 |\n|------------|---------|\n| 01-ProductDesign | warm orange-coral |\n| 02-EngineerinLine truncated
"scripts/register_expert.py": "#!/usr/bin/env python3\n\"\"\"\nExpert Register - Validates and registers an expert into marketplace.json\n\nThis script should be called AFTER the expert package is fully created and validated.\nOnly registered experts will be visible in the WorkBuddy expert center.\n\nUsage:\n register_expert.py <expert-dir> [--marketplace-dir <marketplace-dir>]\n\nExamples:\n python3 register_expert.py <expert-dir>\n python3 register_expert.py ./my-expert --marketplace-dir <marketplace-dir>\n\nThe creating session id is read from CODEBUDDY_SESSION_ID when present.\n\"\"\"\n\nimport json\nimport os\nimport sys\nfrom pathlib import Path\nfrom typing import List, Optional, Tuple\n\nSESSION_ID_ENV_VAR = 'CODEBUDDY_SESSION_ID'\nUNEXPANDED_SESSION_ID_ARGS = {'$CODEBUDDY_SESSION_ID', '${CODEBUDDY_SESSION_ID}'}\n\n\ndef get_expert_base_dir():\n \"\"\"获取专家 marketplace 根目录,优先读取 WORKBUDDY_CONFIG_DIR 环境变量。\"\"\"\n config_dir = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip()\n if not config_dir:\n config_dir = str(Path.home() / '.workbuddy')\n return Path(config_dir) / 'plugins' / 'marketplaces' / 'my-experts'\n\n\ndef find_plugin_json(expert_dir: Path) -> Optional[Path]:\n \"\"\"Find plugin.json in the expert directory.\"\"\"\n for meta_dir in ['.codebuddy-plugin', '.workbuddy-plugin']:\n candidate = expert_dir / meta_dir / 'plugin.json'\n if candidate.exists():\n return candidate\n return None\n\n\ndef validate_expert_completeness(expert_dir: Path) -> Tuple[bool, List[str]]:\n \"\"\"\n Validate that the expert package is complete and ready for registration.\n Returns (is_valid, list_of_errors).\n \"\"\"\n errors = []\n\n # 1. Check plugin.json exists\n plugin_json_path = find_plugin_json(expert_dir)\n if not plugin_json_path:\n errors.append(\"Missing plugin.json (checked .codebuddy-plugin/ and .workbuddy-plugin/)\")\n return False, errors\n\n # 2. Parse plugin.json\n try:\n with open(plugin_json_path, 'r', encoding='utf-8') as f:\n plugin_data = json.load(f)\n except json.JSONDecodeError as e:\n errors.append(f\"plugin.json is not valid JSON: {e}\")\n return False, errors\n\n # 3. Check required fields are not [TODO]\n required_fields = ['name', 'description', 'expertType']\n for field in required_fields:\n value = plugin_data.get(field, '')\n if not value or '[TODO' in str(value):\n errors.append(f\"Field '{field}' is missing or contains [TODO] placeholder\")\n\n # 4. Check displayName is filled\n display_name = plugin_data.get('displayName', {})\n if isinstance(display_name, dict):\n zh_name = display_name.get('zh', '')\n en_name = display_name.get('en', '')\n if (not zh_name or '[TODO' in zh_name) and (not en_name or '[TODO' in en_name):\n errors.append(\"displayName (zh or en) is missing or contains [TODO]\")\n elif not display_name or '[TODO' in str(display_name):\n errors.append(\"displayName is missing or contains [TODO]\")\n\n # 5. Check agents directory has at least one non-template .md file\n agents_dir = expert_dir / 'agents'\n if agents_dir.exists():\n md_files = list(agents_dir.glob('*.md'))\n if not md_files:\n errors.append(\"No .md files found in agents/ directory\")\n else:\n # Check at least one agent file has real content (not all [TODO])\n has_real_content = False\n for md_file in md_files:\n content = md_file.read_text(encoding='utf-8')\n # Count [TODO] occurrences - if more than 3, likely still a template\n todo_count = content.count('[TODO')\n if todo_count <= 2:\n has_real_content = True\n break\n if not has_real_content:\n errors.append(\"All agent .md files still contain many [TODO] placeholders\")\n else:\n errors.append(\"Missing agents/ directory\")\n\n return len(errors) == 0, errors\n\n\ndef _write_session_marker(expert_dir: Path, session_id: str) -> None:\n \"\"\"Write .created-by-session marker file into the expert directory.\"\"\"\n marker_path = expert_dir / '.created-by-session'\n try:\n marker_path.write_text(session_id, encoding='utf-8')\n except OSError as e:\n print(f\"⚠️ Warning: could not write session marker: {e}\")\n\n\ndef normalize_session_id(value: Optional[str]) -> Optional[str]:\n \"\"\"Return a usable session id, ignoring empty or unexpanded shell placeholders.\"\"\"\n session_id = (value or '').strip()\n if not session_id or session_id in UNEXPANDED_SESSION_ID_ARGS:\n return None\n return session_id\n\n\ndef get_session_id(explicit_session_id: Optional[str]) -> Optional[str]:\n \"\"\"Resolve the creating session id from CLI arg oLine truncated
"scripts/init_expert.py": "#!/usr/bin/env python3\n\"\"\"\nExpert Initializer - Creates a new expert package from template\n\nUsage:\n init_expert.py <expert-name> --type agent|team --path <output-dir>\n\nExamples:\n init_expert.py my-expert --type agent --path ./output\n init_expert.py my-team --type team --path ./output\n\"\"\"\n\nimport sys\nimport json\nimport os\nimport re\nfrom pathlib import Path\n\n\ndef get_expert_plugins_dir():\n \"\"\"获取专家目录,优先读取 WORKBUDDY_CONFIG_DIR 环境变量。\"\"\"\n config_dir = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip()\n if not config_dir:\n config_dir = str(Path.home() / '.workbuddy')\n return Path(config_dir) / 'plugins' / 'marketplaces' / 'my-experts' / 'plugins'\n\n\ndef title_case(name):\n \"\"\"Convert kebab-case to Title Case.\"\"\"\n return ' '.join(word.capitalize() for word in name.split('-'))\n\n\nAGENT_PLUGIN_JSON = \"\"\"{\n \"name\": \"%(name)s\",\n \"version\": \"1.0.0\",\n \"description\": \"[TODO: English one-line description]\",\n \"author\": {\n \"name\": \"[TODO: author name]\",\n \"email\": \"[TODO: author email]\"\n },\n \"agents\": [\"./agents/%(agent_name)s.md\"],\n\n \"expertType\": \"agent\",\n \"agentName\": \"%(agent_name)s\",\n\n \"displayName\": {\n \"en\": \"[TODO: English display name]\",\n \"zh\": \"[TODO: 中文显示名称]\"\n },\n \"profession\": {\n \"en\": \"[TODO: English profession]\",\n \"zh\": \"[TODO: 中文职业头衔]\"\n },\n \"displayDescription\": {\n \"en\": \"[TODO: English detailed description]\",\n \"zh\": \"[TODO: 中文详细描述,40-50字]\"\n },\n \"avatar\": \"avatars/expert.png\",\n \"categoryId\": \"[TODO: XX-CategoryName]\",\n \"defaultInitPrompt\": {\n \"zh\": \"[TODO: 中文首次对话提示]\",\n \"en\": \"[TODO: English first prompt]\"\n },\n \"plugin\": \"%(name)s\",\n \"tags\": [\n { \"en\": \"[TODO: Tag1]\", \"zh\": \"[TODO: 标签1]\" },\n { \"en\": \"[TODO: Tag2]\", \"zh\": \"[TODO: 标签2]\" },\n { \"en\": \"[TODO: Tag3]\", \"zh\": \"[TODO: 标签3]\" }\n ],\n \"quickPrompts\": [\n { \"en\": \"[TODO: Prompt1, same as defaultInitPrompt]\", \"zh\": \"[TODO: 提示词1,同defaultInitPrompt]\" },\n { \"en\": \"[TODO: Prompt2]\", \"zh\": \"[TODO: 提示词2]\" },\n { \"en\": \"[TODO: Prompt3]\", \"zh\": \"[TODO: 提示词3]\" }\n ]\n}\n\"\"\"\n\nTEAM_PLUGIN_JSON = \"\"\"{\n \"name\": \"%(name)s\",\n \"version\": \"1.0.0\",\n \"description\": \"[TODO: English one-line description]\",\n \"author\": {\n \"name\": \"[TODO: author name]\",\n \"email\": \"[TODO: author email]\"\n },\n \"agents\": [\n \"./agents/%(team)s-team-lead.md\",\n \"./agents/[TODO: member-a].md\"\n ],\n\n \"expertType\": \"team\",\n \"agentName\": \"%(team)s-team-lead\",\n \"teamInfo\": {\n \"leadAgent\": \"%(team)s-team-lead\",\n \"memberAgents\": [\"[TODO: member-a]\"]\n },\n\n \"displayName\": {\n \"en\": \"[TODO: English team name]\",\n \"zh\": \"[TODO: 中文团队名称]\"\n },\n \"profession\": {\n \"en\": \"[TODO: same as displayName.en]\",\n \"zh\": \"[TODO: 同displayName.zh]\"\n },\n \"displayDescription\": {\n \"en\": \"[TODO: English team description]\",\n \"zh\": \"[TODO: 中文团队描述,40-50字]\"\n },\n \"avatar\": \"avatars/team.png\",\n \"categoryId\": \"[TODO: XX-CategoryName]\",\n \"defaultInitPrompt\": {\n \"zh\": \"[TODO: 中文首次问候]\",\n \"en\": \"[TODO: English first prompt]\"\n },\n \"plugin\": \"%(name)s\",\n \"tags\": [\n { \"en\": \"[TODO: Tag1]\", \"zh\": \"[TODO: 标签1]\" },\n { \"en\": \"[TODO: Tag2]\", \"zh\": \"[TODO: 标签2]\" },\n { \"en\": \"[TODO: Tag3]\", \"zh\": \"[TODO: 标签3]\" }\n ],\n \"quickPrompts\": [\n { \"en\": \"[TODO: Prompt1, same as defaultInitPrompt]\", \"zh\": \"[TODO: 提示词1,同defaultInitPrompt]\" },\n { \"en\": \"[TODO: Prompt2]\", \"zh\": \"[TODO: 提示词2]\" },\n { \"en\": \"[TODO: Prompt3]\", \"zh\": \"[TODO: 提示词3]\" }\n ],\n \"members\": [\n {\n \"id\": \"%(team)s-team-lead\",\n \"displayName\": { \"en\": \"[TODO]\", \"zh\": \"[TODO]\" },\n \"profession\": { \"en\": \"[TODO]\", \"zh\": \"[TODO]\" },\n \"avatar\": \"avatars/%(team)s-team-lead.png\",\n \"role\": \"lead\"\n },\n {\n \"id\": \"[TODO: member-a]\",\n \"displayName\": { \"en\": \"[TODO]\", \"zh\": \"[TODO]\" },\n \"profession\": { \"en\": \"[TODO]\", \"zh\": \"[TODO]\" },\n \"avatar\": \"avatars/[TODO: member-a].png\",\n \"role\": \"member\"\n }\n ]\n}\n\"\"\"\n\nAGENT_MD_TEMPLATE = \"\"\"---\nname: %(agent_name)s\ndescription: \"[TODO: English description for AI to determine when to activate]\"\ndisplayName:\n en: \"[TODO: English display name]\"\n zh: \"[TODO: 中文显示名称]\"\nprofession:\n en: \"[TODO: English profession title]\"\n zh: \"[TODO: 中文职业头衔]\"\nmaxTurns: 50\Line truncated
"scripts/validate_expert.py": "#!/usr/bin/env python3\n\"\"\"\nExpert Validator - Validates an expert package against the WorkBuddy specification.\n\nUsage:\n validate_expert.py <path/to/expert-dir>\n\nExample:\n python3 validate_expert.py plugins/my-expert\n\"\"\"\n\nimport sys\nimport json\nimport os\nimport re\nfrom pathlib import Path\n\n\nVALID_CATEGORY_IDS = {\n '01-ProductDesign', '02-Engineering', '03-GameSpatial', '04-DataAI',\n '05-MarketingGrowth', '06-ContentCreative', '07-SalesCommerce',\n '08-FinanceInvestment', '09-OperationsHR', '10-ProjectQuality',\n '11-SecurityCompliance', '12-IndustryConsultant',\n}\n\nVALID_EXPERT_TYPES = {'agent', 'team'}\n\n\nclass ValidationResult:\n def __init__(self):\n self.errors = []\n self.warnings = []\n\n def error(self, msg):\n self.errors.append(msg)\n\n def warn(self, msg):\n self.warnings.append(msg)\n\n @property\n def is_valid(self):\n return len(self.errors) == 0\n\n def summary(self):\n lines = []\n if self.errors:\n lines.append(f\"❌ {len(self.errors)} error(s):\")\n for e in self.errors:\n lines.append(f\" • {e}\")\n if self.warnings:\n lines.append(f\"⚠️ {len(self.warnings)} warning(s):\")\n for w in self.warnings:\n lines.append(f\" • {w}\")\n if self.is_valid:\n lines.append(\"✅ Expert package is valid!\")\n return '\\n'.join(lines)\n\n\ndef parse_md_frontmatter(md_path):\n \"\"\"Parse YAML frontmatter from a Markdown file. Returns dict or None.\"\"\"\n try:\n content = md_path.read_text(encoding='utf-8')\n except Exception as e:\n return None, f\"Cannot read {md_path}: {e}\"\n\n if not content.startswith('---'):\n return None, f\"{md_path.name}: No YAML frontmatter found\"\n\n match = re.match(r'^---\\n(.*?)\\n---', content, re.DOTALL)\n if not match:\n return None, f\"{md_path.name}: Invalid frontmatter format\"\n\n # LIMITATION: Only parses simple top-level \"key: value\" pairs.\n # Does NOT handle nested objects, lists, or multi-line values.\n # Sufficient for extracting 'name', 'description', and detecting 'tools'.\n fm_text = match.group(1)\n result = {}\n\n # Extract simple key-value pairs\n for line in fm_text.split('\\n'):\n line = line.strip()\n if ':' in line and not line.startswith('-') and not line.startswith('#'):\n key, _, value = line.partition(':')\n key = key.strip()\n value = value.strip().strip('\"').strip(\"'\")\n if value:\n result[key] = value\n\n return result, None\n\n\ndef check_i18n_field(obj, field_name, result, context=\"plugin.json\"):\n \"\"\"Check that a field has both 'en' and 'zh' values.\"\"\"\n if field_name not in obj:\n result.error(f\"{context}: missing '{field_name}'\")\n return False\n\n val = obj[field_name]\n if not isinstance(val, dict):\n result.error(f\"{context}: '{field_name}' must be an object with 'en' and 'zh'\")\n return False\n\n if not val.get('en'):\n result.error(f\"{context}: '{field_name}.en' is empty\")\n if not val.get('zh'):\n result.error(f\"{context}: '{field_name}.zh' is empty\")\n\n return True\n\n\ndef validate_plugin_json(plugin_json, expert_dir, result):\n \"\"\"Validate plugin.json fields.\"\"\"\n # Required fields\n for field in ('name', 'version', 'description'):\n if field not in plugin_json:\n result.error(f\"plugin.json: missing required field '{field}'\")\n\n name = plugin_json.get('name', '')\n if name and (len(name) < 2 or not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', name)):\n result.error(f\"plugin.json: 'name' must be kebab-case (at least 2 chars), got '{name}'\")\n\n expert_type = plugin_json.get('expertType', '')\n if expert_type not in VALID_EXPERT_TYPES:\n result.error(f\"plugin.json: 'expertType' must be one of {VALID_EXPERT_TYPES}, got '{expert_type}'\")\n return expert_type\n\n # plugin field consistency\n plugin_field = plugin_json.get('plugin', '')\n if plugin_field and plugin_field != name:\n result.error(f\"plugin.json: 'plugin' ({plugin_field}) must equal 'name' ({name})\")\n\n # Agent/Team specific\n if expert_type in ('agent', 'team'):\n agent_name = plugin_json.get('agentName', '')\n if not agent_name:\n result.error(\"plugin.json: missing 'agentName'\")\n else:\n # Check agentName matches an MD file\n md_file = expert_dir / 'agents' / f'{agent_name}.md'\n if not md_file.exists():\n result.error(f\"plugin.json: agentName '{agent_name}' has no matching file agents/{agent_name}.md\")\n\n # agentName must not be generic \"team-lead\"\n if agent_name == 'team-lead':Line truncated
"scripts/package_expert.py": "#!/usr/bin/env python3\n\"\"\"\nExpert Packager - Validates and packages an expert directory into a zip file.\n\nUsage:\n package_expert.py <path/to/expert-dir> [output-dir]\n\nExample:\n python3 package_expert.py plugins/my-expert\n python3 package_expert.py plugins/my-expert ./dist\n\"\"\"\n\nimport sys\nimport zipfile\nfrom pathlib import Path\n\n# Import validate_expert from sibling script\nscript_dir = Path(__file__).parent\nsys.path.insert(0, str(script_dir))\nfrom validate_expert import validate_expert\n\n\ndef package_expert(expert_path, output_dir=None):\n \"\"\"\n Validate and package an expert directory into a .zip file.\n\n Args:\n expert_path: Path to the expert directory\n output_dir: Optional output directory (defaults to current directory)\n\n Returns:\n Path to the created zip file, or None if error\n \"\"\"\n expert_path = Path(expert_path).resolve()\n\n if not expert_path.exists():\n print(f\"❌ Error: Expert directory not found: {expert_path}\")\n return None\n\n if not expert_path.is_dir():\n print(f\"❌ Error: Path is not a directory: {expert_path}\")\n return None\n\n # Validate first\n print(\"🔍 Validating expert package...\\n\")\n result = validate_expert(expert_path)\n print(result.summary())\n\n if not result.is_valid:\n print(\"\\n❌ Packaging aborted. Please fix validation errors first.\")\n return None\n\n print()\n\n # Determine output location\n expert_name = expert_path.name\n if output_dir:\n out = Path(output_dir).resolve()\n out.mkdir(parents=True, exist_ok=True)\n else:\n out = Path.cwd()\n\n zip_filename = out / f\"{expert_name}.zip\"\n\n # Create the zip file\n try:\n file_count = 0\n with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:\n for file_path in sorted(expert_path.rglob('*')):\n if file_path.is_file():\n # Skip hidden files except .codebuddy-plugin\n rel = file_path.relative_to(expert_path)\n parts = rel.parts\n if any(p.startswith('.') and p != '.codebuddy-plugin' for p in parts):\n continue\n # Skip common junk files/directories\n if any(p in ('__pycache__', 'node_modules') for p in parts):\n continue\n if file_path.name in ('.gitkeep', '.DS_Store', 'Thumbs.db'):\n continue\n\n arcname = str(Path(expert_name) / rel)\n zipf.write(file_path, arcname)\n print(f\" 📄 {arcname}\")\n file_count += 1\n\n print(f\"\\n✅ Packaged {file_count} files to: {zip_filename}\")\n print(f\" Size: {zip_filename.stat().st_size / 1024:.1f} KB\")\n return zip_filename\n\n except Exception as e:\n print(f\"❌ Error creating zip: {e}\")\n return None\n\n\ndef main():\n if len(sys.argv) < 2:\n print(\"Usage: python3 package_expert.py <path/to/expert-dir> [output-dir]\")\n print(\"\\nExample:\")\n print(\" python3 package_expert.py plugins/my-expert\")\n print(\" python3 package_expert.py plugins/my-expert ./dist\")\n sys.exit(1)\n\n expert_path = sys.argv[1]\n output_dir = sys.argv[2] if len(sys.argv) > 2 else None\n\n print(f\"📦 Packaging expert: {expert_path}\")\n if output_dir:\n print(f\" Output directory: {output_dir}\")\n print()\n\n result = package_expert(expert_path, output_dir)\n\n if result:\n sys.exit(0)\n else:\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n",
"scripts/batch_create.py": "#!/usr/bin/env python3\n\"\"\"\n批量创建专家 — 每个专家串行走完整标准流程(init → validate → register)。\n\nUsage:\n python3 scripts/batch_create.py <batch-config.json> [--session-id <id>]\n\nbatch-config.json:\n {\n \"path\": \"<expert-plugins-dir>\",\n \"experts\": [\n { \"name\": \"my-expert\", \"type\": \"agent\" },\n { \"name\": \"my-team\", \"type\": \"team\" }\n ]\n }\n\"\"\"\n\nimport sys\nimport json\nimport os\nimport subprocess\nimport time\nfrom pathlib import Path\n\nSCRIPT_DIR = Path(__file__).parent.resolve()\nREGISTER_MAX_RETRIES = 2\nREGISTER_RETRY_DELAY = 1 # seconds\n\n\ndef run_step(script_name: str, args: list) -> bool:\n \"\"\"运行单个标准流程脚本,返回是否成功。\"\"\"\n cmd = [sys.executable, str(SCRIPT_DIR / script_name)] + args\n result = subprocess.run(cmd, capture_output=True, text=True)\n if result.stdout.strip():\n print(result.stdout.strip())\n if result.returncode != 0 and result.stderr.strip():\n print(result.stderr.strip())\n return result.returncode == 0\n\n\ndef process_one_expert(name: str, expert_type: str, output_dir: Path, session_id: str = None) -> bool:\n \"\"\"\n 单个专家的完整标准流程。\n 每个专家必须串行经过:init → validate → register,缺一不可。\n \"\"\"\n expert_dir = output_dir / name\n expert_path = str(expert_dir)\n\n # Step 1: 初始化目录\n print(f\"\\n{'─'*40}\")\n print(f\"📦 [{name}] Step 1/3: 初始化\")\n if not run_step('init_expert.py', [name, '--type', expert_type, '--path', str(output_dir)]):\n print(f\" ❌ [{name}] 初始化失败,跳过该专家\")\n return False\n\n # ═══════════════════════════════════════════════════════\n # Step 2: AI 填充内容(在实际使用中,这里由 AI 写入文件)\n # 本脚本仅做流程示例,不包含内容生成逻辑。\n # AI 在此步骤应写入 plugin.json、agents/*.md、头像等文件。\n # ═══════════════════════════════════════════════════════\n\n # Step 3: 校验(失败提醒修复后重试)\n print(f\"📋 [{name}] Step 2/3: 校验\")\n if not run_step('validate_expert.py', [expert_path]):\n print(f\" ❌ [{name}] 校验失败,请修复内容后重新校验: python3 scripts/validate_expert.py {expert_path}\")\n return False\n\n # Step 4: 注册(含重试,失败则提醒)\n print(f\"📝 [{name}] Step 3/3: 注册\")\n register_args = [expert_path]\n if session_id:\n register_args.extend(['--session-id', session_id])\n\n for attempt in range(1, REGISTER_MAX_RETRIES + 1):\n if run_step('register_expert.py', register_args):\n print(f\" ✅ [{name}] 完成\")\n return True\n if attempt < REGISTER_MAX_RETRIES:\n print(f\" ⚠️ 注册失败,{REGISTER_RETRY_DELAY}s 后重试 ({attempt}/{REGISTER_MAX_RETRIES})...\")\n time.sleep(REGISTER_RETRY_DELAY)\n\n # 重试耗尽:保留目录(内容已生成),提醒手动重试注册\n print(f\" ❌ [{name}] 注册失败(已重试 {REGISTER_MAX_RETRIES} 次),目录已保留: {expert_path}\")\n print(f\" 💡 请稍后手动重试: python3 scripts/register_expert.py {expert_path} --session-id <session-id>\")\n return False\n\n\ndef main():\n if len(sys.argv) < 2:\n print(__doc__)\n sys.exit(1)\n\n config_path = Path(sys.argv[1]).resolve()\n session_id = None\n\n i = 2\n while i < len(sys.argv):\n if sys.argv[i] == '--session-id' and i + 1 < len(sys.argv):\n session_id = sys.argv[i + 1]\n i += 2\n else:\n i += 1\n\n with open(config_path, 'r', encoding='utf-8') as f:\n config = json.load(f)\n\n default_config = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip() or str(Path.home() / '.workbuddy')\n default_plugins_path = str(Path(default_config) / 'plugins' / 'marketplaces' / 'my-experts' / 'plugins')\n output_dir = Path(config.get('path', default_plugins_path)).expanduser().resolve()\n experts = config.get('experts', [])\n\n if not experts:\n print(\"❌ 配置中无专家列表\")\n sys.exit(1)\n\n print(f\"🚀 批量创建 {len(experts)} 个专家 → {output_dir}\\n\")\n\n passed = []\n failed = []\n\n # ⚠️ 串行执行:逐个专家依次走完整流程,禁止并行/异步。\n for expert in experts:\n name = expert.get('name', '')\n expert_type = expert.get('type', 'agent')\n if not name:\n continue\n if process_one_expert(name, expert_type, output_dir, session_Line truncated
}
}
@@ -0,0 +1,24 @@
{
"name": "skill-expert-manager",
"version": "5.8.2-wb.41111488.gb8f6c953.haebd5991e0e2",
"description": "WorkBuddy bundled skill resource: expert-manager.",
"author": {
"name": "WorkBuddy"
},
"category": "skill",
"keywords": [
"workbuddy",
"builtin-skill",
"expert-manager"
],
"workbuddy": {
"kind": "builtin-skill",
"legacyResourceName": "expert-manager",
"bundleSegments": [
"plugins",
"workbuddy-builtin",
"skills",
"expert-manager"
]
}
}
@@ -0,0 +1,247 @@
---
name: expert-manager
description: |
专家包的全生命周期运营:转化(从开源仓库/本地项目创建专家包)、修改已有专家、合规检查、批量更新、质量审查。
触发词:创建专家、转化专家、转成专家、生成专家包、导入专家、convert expert、修改专家、编辑专家、更新专家、modify expert、检查专家、审查专家包、专家合规、专家运营、expert ops。
description_en: |
Manage the full lifecycle of WorkBuddy Expert packages: create or convert an Expert from an open-source repository or local project, edit existing Experts, run compliance checks, apply batch updates, and review quality. Use for requests to create, import, modify, inspect, or maintain an Expert or Expert Team package.
---
# WorkBuddy 专家包管理器
> ⚠️ **执行前必读**:当需要使用本 skill 时,你必须先从头到尾完整阅读本 SKILL.md 全文并严格遵守(包括所有规则、流程、References 列表),然后再开始执行任务。禁止跳读或仅凭部分段落就开始行动。
你是 WorkBuddy 专家包管理器,帮助用户按照 WorkBuddy 专家开发规范(v2.0)创建和维护完整的、可提交审核的专家文件包。
支持两种专家类型:
- **Agent 型**(`expertType: "agent"`):单个 AI 专家
- **Team 型**(`expertType: "team"`):多角色协作团队
支持两种输入模式:
1. **交互模式**:用户描述需求,通过提问收集信息后生成
2. **资料转化模式**:用户提供现有资料(文档、流程、提示词等),从中提取信息直接转化
---
## 关键展示字段对应关系
以下字段决定了专家在产物面板中的展示,**所有场景(创建/转化/修改)均需遵守**:
| 展示 | 对应字段 | 变更规则 |
|---------|---------|---------|
| **名字(职业)** | `profession`(`{en, zh}`) | 卡片标题,应体现专家职业定位 |
| **花名** | `displayName`(`{en, zh}`) | 可根据用户要求自由修改 |
| **类型** | `expertType`(`"agent"` → 显示"专家",`"team"` → 显示"专家团") | **不可随意指定/变更**,必须根据实际结构判断:单角色 = `"agent"`,多角色协作 = `"team"` |
| **行业分类** | `categoryId` | **不可随意指定/变更**,必须根据专家核心能力领域从分类列表中选择,并向用户说明选择理由 |
| **能力介绍** | `displayDescription`(`{en, zh}`) | 中文 40-50 字,突出核心能力 |
| **擅长领域** | `tags`(`{en, zh}[]`,固定 3 个) | 展示专家擅长的领域标签;已满 3 个时必须提示用户替换或删除已有标签,禁止继续新增 |
| **试试这样问我** | `quickPrompts`(`{en, zh}[]`,固定 3 个) | 推荐提示词,第一条同时作为 `defaultInitPrompt` |
---
## 一、工作流程
### 整体流程
```
1. 收集信息(交互 or 资料转化)
2. 初始化目录 → scripts/init_expert.py
3. 生成文件内容 → 参考 references/
4. 生成头像 → 参考 references/avatar-spec.md
5. 校验 → scripts/validate_expert.py
6. 注册 → scripts/register_expert.py
7. 打包 → scripts/package_expert.py
```
> **批量创建**:多个专家时串行重复上述流程,参考 `scripts/batch_create.py`。
### 场景 A:交互模式
**专家目录(固定)**:由环境变量 `WORKBUDDY_CONFIG_DIR` 决定,完整路径为 `$WORKBUDDY_CONFIG_DIR/plugins/marketplaces/my-experts/plugins`(未设置时默认 `~/.workbuddy/plugins/marketplaces/my-experts/plugins`)。**禁止**将专家生成到其他目录。如果用户要求创建到其他路径,必须拒绝并说明:"专家必须生成到专家目录才能被检测到,其他目录生成后将无法使用。"然后使用专家目录继续执行。
**必须明确的信息:**
1. **专家类型(expertType)**:Agent 还是 Team?(判定规则见上方"关键展示字段对应关系")
2. **专家领域**:擅长什么?
**Agent 型还需要:**
- 名字(中英文)、职业头衔(中英文)
- 详细能力描述(中英文,中文 40-50 字)
- 首次对话问候语(中英文)
- 行业分类(见下方列表)
- 擅长领域标签(固定 3 个,中英文)
- 推荐提示词(3 个,中英文,第一条同时作为 defaultInitPrompt)
- 是否需要附带 Skill / bin/
**Team 型还需要:**
- 团队名称(中英文)、团队职业头衔(中英文,须与团队名称一致)
- 主理人名字、职责
- 每个团员的名字、职业头衔、职责
- 团队 SOP 工作流程
- 首次对话问候语、行业分类、标签、推荐提示词
### 场景 B:资料转化模式
当用户提供文件路径或粘贴内容时:
1. **读取分析** — 从资料中提取角色定义、核心能力、SOP、输出规范、约束、脚本、参考资料、角色分工
2. **推断 expertType 和 categoryId** — 按"关键展示字段对应关系"中的规则判断,向用户说明理由
3. **确认补全** — 向用户确认推断结果(displayName、expertType、categoryId),补全展示信息(名称、头衔、描述、标签等)
4. **生成** — 执行后续的初始化 + 文件生成流程
### 场景 C:修改已有专家
当用户要求修改/编辑/更新某个专家时:
**专家目录**:`$WORKBUDDY_CONFIG_DIR/plugins/marketplaces/my-experts/plugins`(默认 `~/.workbuddy/...`)
**流程:**
1. **定位专家** — 在专家目录下找到用户指定名称的专家目录(如 `electron-dev`),读取 `.codebuddy-plugin/plugin.json` 和相关文件了解现有内容
2. **确认修改范围** — 向用户确认要修改什么(如:提示词、描述、能力、标签、头像、新增/删除团队成员等)
3. **执行修改** — 直接编辑对应文件,保持与现有内容风格一致
4. **校验** — `python3 scripts/validate_expert.py <expert-dir>`
5. **重新注册** — 无论修改了什么字段,都必须重新运行注册脚本:`python3 scripts/register_expert.py <expert-dir>`
**注意事项:**
- 修改前先完整读取现有文件,避免丢失已有内容
- 仅修改用户要求变更的部分,不要重写整个文件
- 如果用户要修改的专家不存在,提示用户确认【专家中心-我的专家】已有该专家
- 修改 `displayName`、`expertType`、`categoryId` 时须遵守"关键展示字段对应关系"中的变更规则
- **可以修改的字段**:`displayName`、`profession`、`displayDescription`、`description`、`tags`、`quickPrompts`、`defaultInitPrompt`、`avatar`、`categoryId`(需说明理由)、`members` 的展示信息、Agent MD 的正文内容等
- **严禁修改以下字段和文件名**(它们是专家的唯一标识,修改会导致专家丢失):
- `plugin.json` 中的 `name` 字段(kebab-case 标识符)
- `plugin.json` 中的 `agentName` 字段
- 专家目录名(如 `react-dev/`)
- `agents/` 目录下的 `.md` 文件名(因为 `agentName` = MD 文件名)
- 如果用户要求改 name/目录名,应告知:改名需要重新创建专家,不支持原地改名
### 第二步:初始化目录
```bash
python3 scripts/init_expert.py <expert-name> --type agent|team --path $WORKBUDDY_CONFIG_DIR/plugins/marketplaces/my-experts/plugins
```
> `--path` 固定为专家目录(由 `WORKBUDDY_CONFIG_DIR` 环境变量决定),禁止指定其他路径。
生成的模板文件带 `[TODO]` 占位符,后续由 AI 填充实际内容。
### 第三步:生成文件内容
参考以下 references 编写各文件:
- `@references/plugin-json-spec.md` — plugin.json 字段规范和模板
- `@references/agent-md-spec.md` — Agent MD frontmatter 和正文结构
- `@references/team-spec.md` — Team 型协作铁律、成员命名、SOP 编排
### 第四步:生成头像
参考 `@references/avatar-spec.md` 使用 `ImageGen` 工具为每个角色自动生成头像。
### 第五步:校验
```bash
python3 scripts/validate_expert.py <path/to/expert-dir>
```
### 第六步:注册
校验通过后,将专家注册到 `marketplace.json`:
```bash
python3 scripts/register_expert.py <path/to/expert-dir>
```
> 此脚本会:1) 再次校验关键字段不含 `[TODO]`;2) 将专家信息写入 `marketplace.json`;3) 自动从 Bash runtime 注入的 `CODEBUDDY_SESSION_ID` 读取当前会话并写入 `.created-by-session`。如果校验不通过会报错并拒绝注册。
### 第七步:打包
```bash
python3 scripts/package_expert.py <path/to/expert-dir> [output-dir]
```
---
## 二、行业分类(categoryId)
**判定规则:** 根据专家的核心能力和主要服务领域来选择分类,不可凭直觉或用户随意指定。判定优先级:
1. 专家的 **主要输出物** 属于哪个领域(如:输出代码/架构设计 → 技术工程;输出营销方案 → 营销增长)
2. 专家的 **服务对象** 是谁(如:服务开发者 → 技术工程;服务投资者 → 金融投资)
3. 如有跨领域情况,选择 **最核心** 的一个分类
| categoryId | 分类名称 | 适用场景举例 |
|---|---|---|
| 01-ProductDesign | 产品设计 | UI/UX 设计、产品规划、原型设计、交互设计 |
| 02-Engineering | 技术工程 | 编程开发、架构设计、DevOps、技术选型 |
| 03-GameSpatial | 游戏空间 | 游戏开发、3D 建模、虚拟现实、游戏设计 |
| 04-DataAI | 数据智能 | 数据分析、机器学习、大模型应用、BI |
| 05-MarketingGrowth | 营销增长 | 品牌营销、用户增长、广告投放、SEO |
| 06-ContentCreative | 内容创作 | 文案写作、视频脚本、创意策划、翻译 |
| 07-SalesCommerce | 销售商务 | 销售策略、商务谈判、客户管理、电商 |
| 08-FinanceInvestment | 金融投资 | 投资分析、财务管理、风控、量化交易 |
| 09-OperationsHR | 运营人力 | 项目运营、人力资源、组织管理、培训 |
| 10-ProjectQuality | 项目质量 | 项目管理、质量保障、测试、流程优化 |
| 11-SecurityCompliance | 法务安全 | 信息安全、合规审查、法务咨询、隐私保护 |
| 12-IndustryConsultant | 行业顾问 | 跨行业咨询、战略规划、不属于以上明确分类的 |
---
## 三、资料转化策略
| 资料中的内容 | 转化为 | 放在哪里 |
|-------------|--------|---------|
| 角色描述、专家人设 | Agent MD 的角色定义和核心能力 | `agents/{name}.md` |
| 工作流程、操作步骤 | Agent MD 的工作流程章节 | `agents/{name}.md` |
| 输出格式要求 | Agent MD 的输出规范章节 | `agents/{name}.md` |
| API 文档、字段定义 | SKILL.md + references/ | `skills/{name}/references/` |
| 可执行脚本代码 | scripts/ | `skills/{name}/scripts/` |
| 流程模板、报告模板 | templates/ | `skills/{name}/templates/` |
| 通用 CLI 工具 | bin/ | `bin/` |
| 多角色分工描述 | Team 型主理人 + 各团员 MD | `agents/` 多个 MD |
| SOP/阶段性流程 | 主理人 MD 的 SOP 章节 | `agents/{team}-team-lead.md` |
| 示例对话 | quickPrompts + defaultInitPrompt | `plugin.json` |
**转化质量要求:**
1. 不丢信息 — 资料中每条有价值信息都体现在生成文件中
2. 结构化整理 — 零散信息按标准结构重新组织
3. 专业术语保留 — 原样保留不简化
4. 大段参考资料放 `references/` — 不要塞进 Agent MD 正文
---
## 四、关键规则(铁律)
1. **name 字段 kebab-case**:如 `my-expert`
2. **agentName = MD 文件名**:如 `design-expert` → `agents/design-expert.md`
3. **agents 字段是路径数组**:如 `["./agents/my-expert.md"]`
4. **tags 固定且只能 3 个、quickPrompts 固定 3 个**:新增前必须检查数量,超过 3 个必须提示用户替换或删除,第一条 quickPrompt = defaultInitPrompt
5. **displayDescription 中文 40-50 字**
6. **Agent MD frontmatter 禁止声明 tools 字段**
7. **主理人文件名必须加专家团前缀**:如 `trading-team-lead.md`,不可用通用 `team-lead.md`
8. **Team 型 members 数组含主理人**(role=lead),teamInfo.memberAgents 不含主理人
9. **Team 型 profession 须与 displayName 一致**
10. **头像自动生成后放入 avatars/**,同一团队头像风格统一
11. **同名专家已存在时必须重新校验 + 注册**:用户要求创建专家但目标目录已存在同名专家时,如果不需要初始化目录和创建内容,也要执行后续的校验 + 注册流程,保证专家可用
12. **批量创建必须遵循标准流程**:批量创建/转化多个专家时,每个专家必须完整串行经过 `init → validate → register`,禁止跳过校验或注册。参考 `scripts/batch_create.py`,核心模式:
```python
for expert in experts:
# Step 1: init_expert.py(初始化目录)
# Step 2: AI 填充内容(plugin.json、agents/*.md、头像 等)
# Step 3: validate_expert.py(校验,失败则停止)
# Step 4: register_expert.py(注册,校验通过才执行)
```
**禁止**:只批量写文件而跳过 validate/register;直接写入 `marketplace.json` 绕过注册脚本
---
## 五、收尾提醒
生成完毕后告知用户:
1. 🎨 头像已通过 `ImageGen` 生成在 `avatars/`,可手动替换(PNG/JPG,512×512,≤500KB)
2. 📦 打包分享:`python3 scripts/package_expert.py <expert-dir>`
3. 📋 请核对内容是否准确
## References
- `references/plugin-json-spec.md` — plugin.json 完整字段规范和模板
- `references/agent-md-spec.md` — Agent MD 结构模板(普通 Agent / 主理人)
- `references/team-spec.md` — Team 型协作规范(铁律、命名、SOP)
- `references/avatar-spec.md` — 头像生成规范和 prompt 构建策略
@@ -0,0 +1,95 @@
{
"targetVersion": "WorkBuddy 5.8.2",
"rootSha256": "035254bc76310ea46317433e4840602094543ef323d2d5c5aff7c131729de918",
"scope": "exact retained expert-manager files; no original initialization or Python execution",
"files": [
{
"file": ".codebuddy-plugin/plugin.json",
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/.codebuddy-plugin/plugin.json",
"sha256": "6908b9cd71687525e342c2500fa9d32a847f72e3e263190655ca9347402cb389",
"bytes": 519,
"evidenceId": "ev_c3df29dcb19c5fcaa9e458b1b9acff92e8c75ac935791e0c81900733e61b6282",
"retainedExport": ".tmp/rea-expert-package-ev_c3df29dcb19c5fcaa9e458b1b9acff92e8c75ac935791e0c81900733e61b6282.json"
},
{
"file": "SKILL.md",
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/SKILL.md",
"sha256": "18b0655c3e7573fe9402fd00a605d2dc94918c9eecd028f9f3923e74da17bc45",
"bytes": 13860,
"evidenceId": "ev_3cba49fcb15fefc59f54c7691500ad1121a937edb55e606addae17e4186ebfef",
"retainedExport": ".tmp/rea-expert-package-ev_3cba49fcb15fefc59f54c7691500ad1121a937edb55e606addae17e4186ebfef.json"
},
{
"file": "references/plugin-json-spec.md",
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/references/plugin-json-spec.md",
"sha256": "14e1a6b99c74815c22772a54725ba6d34750a6c9db4c75746c1188e31d2504f3",
"bytes": 5226,
"evidenceId": "ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735",
"retainedExport": ".tmp/rea-expert-package-ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735.json"
},
{
"file": "references/agent-md-spec.md",
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/references/agent-md-spec.md",
"sha256": "ec7373578a5f84d73ba16dcc71e73755e57aa4170cb07cd9bf379627c0f9c007",
"bytes": 5021,
"evidenceId": "ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735",
"retainedExport": ".tmp/rea-expert-package-ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735.json"
},
{
"file": "references/team-spec.md",
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/references/team-spec.md",
"sha256": "1400af007c6b1179429c6d8c856dc1d45ae25dae63645097ddbc801d25d324f9",
"bytes": 4605,
"evidenceId": "ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735",
"retainedExport": ".tmp/rea-expert-package-ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735.json"
},
{
"file": "references/avatar-spec.md",
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/references/avatar-spec.md",
"sha256": "0b42762e05e311632b2f89af35f34458f851a03cce3fcc0300ca0a75e9f8890e",
"bytes": 6350,
"evidenceId": "ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735",
"retainedExport": ".tmp/rea-expert-package-ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735.json"
},
{
"file": "scripts/register_expert.py",
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/scripts/register_expert.py",
"sha256": "b2dd1a0141c2dc88ed7203246335fd261eca97a17f91f4e280d3ce8b10a65b1e",
"bytes": 10116,
"evidenceId": "ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8",
"retainedExport": ".tmp/rea-expert-package-ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8.json"
},
{
"file": "scripts/init_expert.py",
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/scripts/init_expert.py",
"sha256": "742fe9b70085a31850caf327d83f416ff19e9ee7482053e7aa99bd9b780206e4",
"bytes": 14149,
"evidenceId": "ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8",
"retainedExport": ".tmp/rea-expert-package-ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8.json"
},
{
"file": "scripts/validate_expert.py",
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/scripts/validate_expert.py",
"sha256": "6e85c716af94d6ee2ffc86a24a7230d15cd675a0fbaaf45a8980fd51d9c1c6fd",
"bytes": 14346,
"evidenceId": "ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8",
"retainedExport": ".tmp/rea-expert-package-ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8.json"
},
{
"file": "scripts/package_expert.py",
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/scripts/package_expert.py",
"sha256": "e3d82044fd5fc7b749d65c178af0bed18bbc0375a952b878cfa019b09e04d8fb",
"bytes": 3631,
"evidenceId": "ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8",
"retainedExport": ".tmp/rea-expert-package-ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8.json"
},
{
"file": "scripts/batch_create.py",
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/scripts/batch_create.py",
"sha256": "c926d7b8721c4ec4179a3d481271bdb01d41764c3301e01cdafd9e96c37e154c",
"bytes": 5106,
"evidenceId": "ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8",
"retainedExport": ".tmp/rea-expert-package-ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8.json"
}
]
}
@@ -0,0 +1,179 @@
# Agent MD 规范
## Frontmatter
### ⚠️ 重要:frontmatter 中不可添加 tools 字段
所有工具权限由系统统一分配,开发者无需也不能在 frontmatter 中声明 `tools` 字段。
### 必填字段
```yaml
---
name: {与文件名一致,必须有业务语义}
description: {英文描述,AI 用来判断何时激活}
displayName:
en: "{English display name}"
zh: "{中文显示名称}"
profession:
en: "{English profession title}"
zh: "{中文职业头衔}"
maxTurns: {默认50}
---
```
### 可选字段
```yaml
skills: [{skill-name}] # 启动时预加载的 Skill
```
---
## 正文结构:普通 Agent / 团员
```markdown
# {角色名称} - {人名}
{角色描述,说明这是谁,擅长什么}
## 核心能力
1. **{能力1}**:{描述}
2. **{能力2}**:{描述}
3. **{能力3}**:{描述}
## 工作流程
1. {步骤1}
2. {步骤2}
3. {步骤3}
## 输出规范
- {规范1}
- {规范2}
- {规范3}
## 注意事项
- {约束或边界条件}
```
### 团队成员额外要求
- 不要在 frontmatter 中限制 tools 范围
- 由主理人通过 Agent 工具 spawn 为正式 teammate
- 分析完成后必须通过 SendMessage 将结果回传给主理人
- Prompt 中须明确写出 SendMessage 回传要求
### 成员 Prompt 必备结构
1. **角色定义**:一句话说清"你是谁"
2. **擅长领域**:3-5 个具体能力点
3. **分析框架**:内嵌的分析能力,分步骤流程
4. **数据获取方式**:具体的查询命令或工具调用
5. **结构化输出模板**:表格/分段格式
6. **SendMessage 回传要求**
---
## 正文结构:主理人
> ⚠️ 文件名不可使用通用的 `team-lead.md`,必须加上专家团前缀,如 `trading-team-lead.md`
### Frontmatter
```yaml
---
name: {team}-team-lead
description: {English description}
displayName:
en: "{English display name}"
zh: "{中文显示名称}"
profession:
en: "{English profession title}"
zh: "{中文职业头衔}"
maxTurns: {turns, 建议150-200}
---
```
### 正文模板
```markdown
# {团队名称} - 主理人
{主理人角色描述,负责协调团队完成什么任务}
## 团队成员
### {分组名称}
| 成员 | 名字 | 职责 |
|------|------|------|
| {member-id} | {name} | {职责描述} |
## 标准工作流程(SOP)
### Phase 1: {阶段名}
{调用哪些成员、输入输出说明}
### Phase 2: {阶段名}
{...}
### Phase N: 最终报告
综合所有分析结果,生成最终报告返回用户。
## 团队协作机制(铁律)
你必须走正式的**团队协作流程**,严禁简化或跳过:
1. **建立团队**:任务开始时由主理人亲自创建团队(TeamCreate),明确协作边界。**团队创建必须且只能由主理人执行,严禁委派任何成员创建团队**
2. **调度成员**:按 SOP 阶段将成员拉入协作、下发独立任务;成员作为独立协作方输出专业产出,不得由主理人代写
3. **消息中转**:成员产出回传给主理人,由主理人汇总、转交下一阶段;所有跨成员信息流必须经主理人中转,不得互相直连
4. **成员结论为准**:任何专业产出必须由对应成员输出后再采信,主理人只做编排与汇编
### 严禁行为
- ❌ 禁止跳过 TeamCreate,直接自己模拟成员发言或并行写出多角色内容
- ❌ 禁止自己代写任何团队成员的专业产出
- ❌ 禁止未完成前序阶段就跳到后续阶段
- ❌ 禁止让成员互相直连通信,所有跨成员信息流必须经主理人中转
- ❌ 禁止 spawn 主理人自己
## 协作规则
1. 所有成员调度必须经过"建立团队 → 调度成员 → 成员回传"流程
2. 每阶段结束后,将完整产出原文传递给下一阶段成员
3. 每完成一个阶段向用户简要通报
4. 所有输出使用与用户原始需求相同的语言
5. 调度成员时,Agent 工具的 `name` 参数传入成员的 **Agent ID**(MD 文件名,不含 .md),`subagent_type` 也传入相同值。禁止使用中文名或自创名称
```
---
## 主理人额外要求
### 成员能力清单
主理人 prompt 中必须列出每个成员的:
- Agent ID
- 擅长领域(3-5 个具体能力点)
- 典型问法(什么问题该调它)
### 预设 Workflow
针对高频综合性问题设计预设 Workflow,每个 Workflow 写明:
- **触发条件**:什么类型的问法匹配
- **Phase 编排**:分 Phase 的串并行调度
- **输入输出依赖**:每个 Phase 的输入来自哪里、输出传给谁
### 单 agent 直调路由表
| 问法类型 | 直接调谁 |
|---------|---------|
| 单一维度问题 | 对应成员 |
| 综合性问题 | 走预设 Workflow |
---
## 能独立成 agent 的标准
判断标准:**有没有用户会直接问它的问题?**
- 有 → 独立成 agent
- 没有 → 归入其他 agent
每个 agent 覆盖一个完整的"分析域",域内多个能力归并进来,但不跨域。跨域协作由主理人通过 Workflow 编排。
@@ -0,0 +1,162 @@
# 头像生成规范
## 基本要求
| 项目 | 要求 |
|------|------|
| 格式 | PNG(推荐)或 JPG |
| 尺寸 | 512×512 px(正方形) |
| 大小 | 单张不超过 500KB |
| 风格 | 统一漫画/插画风格,专业自然 |
| 内容 | 符合角色定位,不含违规内容 |
> 使用 `ImageGen` 工具生成,`size` 参数设为 `"1024x1024"`(高清大图,市场缩放为 512×512 展示)。
## 生成策略
**Agent 型**:1 张头像
- `avatars/expert.png`
**Team 型**:N+1 张头像
- `avatars/team.png` — 团队整体头像
- `avatars/{team}-team-lead.png` — 主理人头像
- `avatars/{member-name}.png` — 每个团员头像
---
## Prompt 构建核心原则
**每个头像的 prompt 必须从对应的 MD 文件描述中提取角色特征,不使用通用模板硬编码。**
### 提取步骤
1. **读取 Agent MD 文件**
2. **提取角色身份**:从标题和首段提取
3. **提取专业特征**:从"核心能力"章节提取关键词,转化为视觉元素
4. **推断工作风格**:从"工作流程"和"注意事项"推断性格气质
5. **推断人物属性**:从 name 字段推断性别和风格基调
---
## 个人头像 Prompt 组装
```
[风格前缀] + [角色身份] + [外观特征] + [表情气质] + [背景元素] + [质量后缀]
```
| 部分 | 说明 | 示例 |
|------|------|------|
| 风格前缀 | 统一漫画/插画风格 | `Professional cartoon-style illustration avatar,` |
| 角色身份 | 从 MD 标题/首段提取 | `a female design system document architect` |
| 外观特征 | 从核心能力推断穿着/配饰 | `wearing stylish glasses, holding a design specification document` |
| 表情气质 | 从工作风格推断 | `confident and meticulous expression` |
| 背景元素 | 从专业领域提取视觉符号 | `subtle design tokens and color palette swatches in background` |
| 质量后缀 | 固定 | `Bust shot, facing forward. Clean simple background. High quality, professional, natural.` |
### 示例 1:设计系统架构师
MD 核心内容:角色=设计系统文档架构师,能力=9大标准章节、AI可读格式,输出=Markdown+HEX+CSS
```
Professional cartoon-style illustration avatar, a female design system document architect,
wearing stylish glasses, holding a design guideline document, modern creative smart casual attire,
confident and meticulous expression with a creative yet precise aura,
subtle color palette swatches, typography samples and design token symbols in the background.
Bust shot, facing forward. Clean simple warm-toned background. High quality, professional, natural.
```
### 示例 2:技术分析师
MD 核心内容:角色=技术分析师,能力=K线形态、均线分析、MACD/RSI/KDJ
```
Professional cartoon-style illustration avatar, a male technical stock market analyst named Marco,
wearing a sharp vest over dress shirt, looking at holographic candlestick charts,
focused and analytical expression with sharp observant eyes,
K-line charts, moving average lines and MACD indicators floating in the background.
Bust shot, facing forward. Clean simple blue-toned background. High quality, professional, natural.
```
---
## 团队头像 Prompt 构建
**输入来源**:plugin.json 的 `displayDescription` + 主理人 MD 的团队描述
### 提取步骤
1. **团队定位**:从 displayDescription 提取团队做什么
2. **协作模式**:从主理人 MD 的 SOP 提取工作阶段
3. **成员构成**:从成员列表提取角色类型的多样性
4. **视觉表达**:将以上信息转化为体现团队协作的场景
### Prompt 组装
```
[风格前缀] + [团队场景描述] + [协作元素] + [成员象征] + [质量后缀]
```
### 示例:交易分析团队
```
Professional cartoon-style illustration, a dynamic stock trading analysis team scene,
multiple diverse analysts gathered around a central holographic display showing candlestick charts,
a bull figure and a bear figure debating on opposite sides symbolizing bull-bear debate,
risk gauges and decision dashboards floating around,
warm collaborative atmosphere with focused professional energy.
Clean simple multi-tone gradient background. High quality, professional, team composition.
```
---
## 同一团队风格统一规则
Team 型的所有头像必须在 prompt 中保持一致的**风格锚定词**:
**固定风格前缀(每个 prompt 开头):**
```
Professional cartoon-style illustration avatar, consistent art style with warm lighting and soft shadows,
```
**固定质量后缀(每个 prompt 结尾):**
```
Bust shot, facing forward. Clean simple {color}-toned background. High quality, professional, natural.
```
### 背景色调映射
| categoryId | 背景色调 |
|------------|---------|
| 01-ProductDesign | warm orange-coral |
| 02-Engineering | blue-purple |
| 03-GameSpatial | purple-red gradient |
| 04-DataAI | cyan-teal |
| 05-MarketingGrowth | red-orange |
| 06-ContentCreative | pink-magenta |
| 07-SalesCommerce | golden-amber |
| 08-FinanceInvestment | dark blue with gold accent |
| 09-OperationsHR | navy slate-blue |
| 10-ProjectQuality | green-emerald |
| 11-SecurityCompliance | dark grey-blue |
| 12-IndustryConsultant | deep teal with silver accent |
---
## 执行流程
1. **读取 Agent MD** — 逐个读取 agents/ 下每个 MD
2. **提取角色特征** — 从角色定义、核心能力、工作流程中提取
3. **构建个人 prompt** — 按上述步骤将特征转化为视觉描述
4. **构建团队 prompt**(Team 型)— 从 displayDescription 和主理人 MD 提取
5. **统一风格锚定** — 确保所有 prompt 使用相同的风格前缀和后缀
6. **调用 ImageGen** — 逐张生成,`output_dir` 为专家包的 `avatars/`,`size` 为 `"1024x1024"`
7. **重命名文件** — 将生成的图片重命名为 plugin.json 中声明的文件名
8. **验证** — 确认所有头像文件已存在于 avatars/
## 注意事项
1. **必须基于 MD 描述生成**:不要使用通用 prompt
2. **团队头像体现协作**:不是简单人物剪影
3. **同一团队画风一致**:共用风格锚定词和背景色调
4. **生成失败处理**:在 README.md 中标注需手动补充的头像,附推荐 prompt
5. **用户可替换**:提醒自动生成的头像可手动替换(512×512,PNG/JPG,≤500KB)
@@ -0,0 +1,184 @@
# plugin.json 字段规范
## 基础字段(必填)
| 字段 | 类型 | 说明 |
|------|------|------|
| `name` | string | 唯一标识,小写字母+连字符,也是技能命名空间前缀 |
| `version` | string | 语义化版本号(MAJOR.MINOR.PATCH) |
| `description` | string | 英文一句话描述 |
## 可选基础字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `author` | `{name, email}` | 作者信息 |
| `homepage` | string 或 `{url, type}` | 项目主页 |
| `license` | string | 许可证 |
| `keywords` | string[] | 搜索标签 |
## 类型字段
| 字段 | 说明 |
|------|------|
| `expertType` | `"agent"` / `"team"` |
| `agentName` | 主 Agent 名称(对应 agents/ 下 MD 文件名,不含 .md)。**必须有业务语义**,不能使用 `team-lead` 等通用名 |
| `teamInfo` | team 时必填:`{leadAgent, memberAgents[]}` |
## 资源声明
| 字段 | 类型 | 说明 |
|------|------|------|
| `agents` | string[] | Agent 定义文件路径列表(如 `["./agents/my-expert.md"]`) |
| `skills` | string[] | Skill 目录路径列表(如 `["./skills/my-skill"]`) |
## 展示字段(agent/team 上架市场时必填)
| 字段 | 类型 | 说明 |
|------|------|------|
| `displayName` | `{en, zh}` | 展示名称 |
| `profession` | `{en, zh}` | 职业/定位。**Team 型须与 displayName 一致** |
| `displayDescription` | `{en, zh}` | 展示描述。**中文 40-50 字,突出核心能力** |
| `avatar` | string | 头像相对路径 |
| `categoryId` | string | 行业分类 ID |
| `defaultInitPrompt` | `{en, zh}` | 默认引导语。**须与 quickPrompts 第一条一致** |
| `tags` | `{en, zh}[]` | 擅长领域标签(**固定 3 个**) |
| `quickPrompts` | `{en, zh}[]` | 推荐提示词(**固定 3 个**) |
| `plugin` | string | 值与 `name` 一致 |
## Team 专用字段
| 字段 | 说明 |
|------|------|
| `members[]` | 每个成员含 `{id, name:{en,zh}, profession:{en,zh}, avatar, role}` |
- `role` 取值:`"lead"` 或 `"member"`
- 主理人也必须在 members 中,role 为 `"lead"`
- `teamInfo.memberAgents` **不含**主理人
---
## 模板:Agent 型
```json
{
"name": "{kebab-case-name}",
"version": "1.0.0",
"description": "{English one-line description}",
"author": {
"name": "{author-name}",
"email": "{author-email}"
},
"agents": ["./agents/{agent-name}.md"],
"skills": ["./skills/{skill-name}"],
"expertType": "agent",
"agentName": "{agent-name}",
"displayName": {
"en": "{English display name}",
"zh": "{中文显示名称}"
},
"profession": {
"en": "{English profession}",
"zh": "{中文职业头衔}"
},
"displayDescription": {
"en": "{English detailed description}",
"zh": "{中文详细描述,40-50字}"
},
"avatar": "avatars/expert.png",
"categoryId": "{XX-CategoryName}",
"defaultInitPrompt": {
"zh": "{中文首次对话提示}",
"en": "{English first prompt}"
},
"plugin": "{kebab-case-name}",
"tags": [
{ "en": "{Tag1 EN}", "zh": "{标签1}" },
{ "en": "{Tag2 EN}", "zh": "{标签2}" },
{ "en": "{Tag3 EN}", "zh": "{标签3}" }
],
"quickPrompts": [
{ "en": "{Prompt1 EN}", "zh": "{提示词1}" },
{ "en": "{Prompt2 EN}", "zh": "{提示词2}" },
{ "en": "{Prompt3 EN}", "zh": "{提示词3}" }
]
}
```
> 如果没有 skills,则省略 `"skills"` 字段。
## 模板:Team 型
```json
{
"name": "{kebab-case-name}",
"version": "1.0.0",
"description": "{English one-line description}",
"author": {
"name": "{author-name}",
"email": "{author-email}"
},
"agents": [
"./agents/{team}-team-lead.md",
"./agents/{member-a}.md",
"./agents/{member-b}.md"
],
"skills": ["./skills/{skill-name}"],
"expertType": "team",
"agentName": "{team}-team-lead",
"teamInfo": {
"leadAgent": "{team}-team-lead",
"memberAgents": ["{member-a}", "{member-b}"]
},
"displayName": {
"en": "{English team name}",
"zh": "{中文团队名称}"
},
"profession": {
"en": "{English team name}",
"zh": "{中文团队名称}"
},
"displayDescription": {
"en": "{English team description}",
"zh": "{中文团队描述,40-50字}"
},
"avatar": "avatars/team.png",
"categoryId": "{XX-CategoryName}",
"defaultInitPrompt": {
"zh": "{中文首次问候}",
"en": "{English first prompt}"
},
"plugin": "{kebab-case-name}",
"tags": [
{ "en": "{Tag1 EN}", "zh": "{标签1}" },
{ "en": "{Tag2 EN}", "zh": "{标签2}" },
{ "en": "{Tag3 EN}", "zh": "{标签3}" }
],
"quickPrompts": [
{ "en": "{Prompt1 EN}", "zh": "{提示词1}" },
{ "en": "{Prompt2 EN}", "zh": "{提示词2}" },
{ "en": "{Prompt3 EN}", "zh": "{提示词3}" }
],
"members": [
{
"id": "{team}-team-lead",
"displayName": { "en": "{EN}", "zh": "{ZH}" },
"profession": { "en": "{EN}", "zh": "{ZH}" },
"avatar": "avatars/{team}-team-lead.png",
"role": "lead"
},
{
"id": "{member-name}",
"displayName": { "en": "{EN}", "zh": "{ZH}" },
"profession": { "en": "{EN}", "zh": "{ZH}" },
"avatar": "avatars/{member-name}.png",
"role": "member"
}
]
}
```
@@ -0,0 +1,120 @@
# Team 型专用规范
## 成员命名规范
Team 型专家团中,每个成员有三个外露字段:`name`(名字)、`profession`(职业头衔)、`nickname`(昵称,可选)。三者不应重复。
### name:谐音花名风格
| 规则 | 说明 |
|------|------|
| 建议三个字 | 姓+名,建议三个字,两个字也可 |
| 先是正常人名 | 姓+名结构,不解释也能当名字用 |
| 暗含职能巧思 | 谐音、拆字、典故均可,但不刻意 |
| 不与 profession 重复 | name 是"谁",profession 是"做什么" |
| 中英文都自然 | 中文用花名,英文用拼音姓氏 |
**示例(软件开发团队):**
| profession | name(zh) | 巧思 | name(en) |
|-----------|----------|------|----------|
| 交付总监 | 齐活林 | 齐活了(交付完成) | Qi |
| 产品经理 | 许清楚 | 需求要说清楚 | Xu |
| 架构师 | 高见远 | 高见(架构视野) | Gao |
| 项目经理 | 毕达成 | 必达(交付必达) | Bi |
| 工程师 | 寇豆码 | code(代码) | Kou |
| QA工程师 | 严过关 | 严格过关 | Yan |
**禁止:**
- 叠字谐音(领码码、需求求、蓝图图)
- 一个字的 name 或纯职能词
- 和 profession 重复
- 无意义随机名(张三、John Doe)
- 英文 name 用 Agent ID
- 读起来是短语而非人名
### 主理人 profession 规范
主理人的 `profession` 不能用通用 title(团长、主理人、Team Lead),应体现该团队的调度风格和业务定位。
**示例:**
| 专家团 | 主理人 name | profession(zh) | profession(en) |
|--------|-----------|---------------|----------------|
| 软件开发团队 | 齐活林 | 交付总监 | Delivery Director |
| 交易分析团队 | 何执舟 | 首席策略官 | Chief Strategist |
| 营销战役团队 | 营销总监 | 增长操盘手 | Growth Operator |
| 深度研究团队 | 顾全之 | 研究主编 | Research Editor |
---
## 协作铁律
### 4 条正则
1. **建立团队**:任务开始时由主理人亲自创建团队(TeamCreate),明确协作边界。**团队创建必须且只能由主理人执行,严禁委派任何成员创建团队**
2. **调度成员**:按 SOP 阶段将成员拉入协作、下发独立任务;成员作为独立协作方输出专业产出,不得由主理人代写
3. **消息中转**:成员产出回传给主理人,由主理人汇总、转交下一阶段;所有跨成员信息流必须经主理人中转,不得互相直连
4. **成员结论为准**:任何专业产出必须由对应成员输出后再采信,主理人只做编排与汇编
### 5 条红线
- ❌ 禁止跳过 TeamCreate,直接自己模拟成员发言或并行写出多角色内容
- ❌ 禁止自己代写任何团队成员的专业产出
- ❌ 禁止未完成前序阶段就跳到后续阶段
- ❌ 禁止让成员互相直连通信,所有跨成员信息流必须经主理人中转
- ❌ 禁止 spawn 主理人自己(编排、汇总、决策由主理人亲自完成)
### 协作规则
1. 所有成员调度必须经过"TeamCreate → Agent spawn → SendMessage 回传"正式流程
2. 每阶段结束后,将完整产出原文传递给下一阶段成员
3. 调度成员时,在 Agent 工具的 `name` 参数中传入该成员的 **Agent ID**(agents/ 下的 MD 文件名,不含 .md),`subagent_type` 也传入相同值。**禁止**使用中文名或自创名称
4. 裁决型角色(如研究主管、风险主管)必须给出明确结论,不得回避决策
5. 每完成一个阶段向用户简要通报进度
---
## SOP 工作流编排
### 并行 vs 串行
- **并行 Phase**:同一条消息中 spawn 多个成员(使用 TeamCreate),适用于成员间无数据依赖
- **串行 Phase**:等前一 Phase 全部回传后,将结论传入下一 Phase 的成员 prompt
### 示例 SOP
```
Phase 1(并行):
stock-researcher → 基本面+财务
valuation-pricer → 估值判断
money-tracker → 资金态度
Phase 2(串行,Phase 1 结论传入):
risk-doctor → 综合风险诊断
主理人汇编 → 输出
```
### 预设 Workflow 设计原则
每个 Workflow 写明:
- **触发条件**:什么类型的问法匹配此 Workflow
- **Phase 编排**:分 Phase 的串并行调度
- **输入输出依赖**:每个 Phase 的输入来自哪里、输出传给谁
Workflow 数量根据用户实际高频场景设计,不是越多越好。
---
## settings.json
Team 型必须有 `settings.json`:
```json
{
"agent": "{team}-team-lead"
}
```
值必须与 plugin.json 的 `agentName` 和主理人 MD 文件的 `name` 一致。
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
批量创建专家 — 每个专家串行走完整标准流程(init → validate → register)。
Usage:
python3 scripts/batch_create.py <batch-config.json> [--session-id <id>]
batch-config.json:
{
"path": "<expert-plugins-dir>",
"experts": [
{ "name": "my-expert", "type": "agent" },
{ "name": "my-team", "type": "team" }
]
}
"""
import sys
import json
import os
import subprocess
import time
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
REGISTER_MAX_RETRIES = 2
REGISTER_RETRY_DELAY = 1 # seconds
def run_step(script_name: str, args: list) -> bool:
"""运行单个标准流程脚本,返回是否成功。"""
cmd = [sys.executable, str(SCRIPT_DIR / script_name)] + args
result = subprocess.run(cmd, capture_output=True, text=True)
if result.stdout.strip():
print(result.stdout.strip())
if result.returncode != 0 and result.stderr.strip():
print(result.stderr.strip())
return result.returncode == 0
def process_one_expert(name: str, expert_type: str, output_dir: Path, session_id: str = None) -> bool:
"""
单个专家的完整标准流程。
每个专家必须串行经过:init → validate → register,缺一不可。
"""
expert_dir = output_dir / name
expert_path = str(expert_dir)
# Step 1: 初始化目录
print(f"\n{'─'*40}")
print(f"📦 [{name}] Step 1/3: 初始化")
if not run_step('init_expert.py', [name, '--type', expert_type, '--path', str(output_dir)]):
print(f" ❌ [{name}] 初始化失败,跳过该专家")
return False
# ═══════════════════════════════════════════════════════
# Step 2: AI 填充内容(在实际使用中,这里由 AI 写入文件)
# 本脚本仅做流程示例,不包含内容生成逻辑。
# AI 在此步骤应写入 plugin.json、agents/*.md、头像等文件。
# ═══════════════════════════════════════════════════════
# Step 3: 校验(失败提醒修复后重试)
print(f"📋 [{name}] Step 2/3: 校验")
if not run_step('validate_expert.py', [expert_path]):
print(f" ❌ [{name}] 校验失败,请修复内容后重新校验: python3 scripts/validate_expert.py {expert_path}")
return False
# Step 4: 注册(含重试,失败则提醒)
print(f"📝 [{name}] Step 3/3: 注册")
register_args = [expert_path]
if session_id:
register_args.extend(['--session-id', session_id])
for attempt in range(1, REGISTER_MAX_RETRIES + 1):
if run_step('register_expert.py', register_args):
print(f" ✅ [{name}] 完成")
return True
if attempt < REGISTER_MAX_RETRIES:
print(f" ⚠️ 注册失败,{REGISTER_RETRY_DELAY}s 后重试 ({attempt}/{REGISTER_MAX_RETRIES})...")
time.sleep(REGISTER_RETRY_DELAY)
# 重试耗尽:保留目录(内容已生成),提醒手动重试注册
print(f" ❌ [{name}] 注册失败(已重试 {REGISTER_MAX_RETRIES} 次),目录已保留: {expert_path}")
print(f" 💡 请稍后手动重试: python3 scripts/register_expert.py {expert_path} --session-id <session-id>")
return False
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
config_path = Path(sys.argv[1]).resolve()
session_id = None
i = 2
while i < len(sys.argv):
if sys.argv[i] == '--session-id' and i + 1 < len(sys.argv):
session_id = sys.argv[i + 1]
i += 2
else:
i += 1
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
default_config = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip() or str(Path.home() / '.workbuddy')
default_plugins_path = str(Path(default_config) / 'plugins' / 'marketplaces' / 'my-experts' / 'plugins')
output_dir = Path(config.get('path', default_plugins_path)).expanduser().resolve()
experts = config.get('experts', [])
if not experts:
print("❌ 配置中无专家列表")
sys.exit(1)
print(f"🚀 批量创建 {len(experts)} 个专家 → {output_dir}\n")
passed = []
failed = []
# ⚠️ 串行执行:逐个专家依次走完整流程,禁止并行/异步。
for expert in experts:
name = expert.get('name', '')
expert_type = expert.get('type', 'agent')
if not name:
continue
if process_one_expert(name, expert_type, output_dir, session_id):
passed.append(name)
else:
failed.append(name)
# 汇总
print(f"\n{'═'*40}")
print(f"📊 结果: {len(passed)} 成功, {len(failed)} 失败")
if failed:
print(f" 失败: {', '.join(failed)}")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()
@@ -0,0 +1,485 @@
#!/usr/bin/env python3
"""
Expert Initializer - Creates a new expert package from template
Usage:
init_expert.py <expert-name> --type agent|team --path <output-dir>
Examples:
init_expert.py my-expert --type agent --path ./output
init_expert.py my-team --type team --path ./output
"""
import sys
import json
import os
import re
from pathlib import Path
def get_expert_plugins_dir():
"""获取专家目录,优先读取 WORKBUDDY_CONFIG_DIR 环境变量。"""
config_dir = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip()
if not config_dir:
config_dir = str(Path.home() / '.workbuddy')
return Path(config_dir) / 'plugins' / 'marketplaces' / 'my-experts' / 'plugins'
def title_case(name):
"""Convert kebab-case to Title Case."""
return ' '.join(word.capitalize() for word in name.split('-'))
AGENT_PLUGIN_JSON = """{
"name": "%(name)s",
"version": "1.0.0",
"description": "[TODO: English one-line description]",
"author": {
"name": "[TODO: author name]",
"email": "[TODO: author email]"
},
"agents": ["./agents/%(agent_name)s.md"],
"expertType": "agent",
"agentName": "%(agent_name)s",
"displayName": {
"en": "[TODO: English display name]",
"zh": "[TODO: 中文显示名称]"
},
"profession": {
"en": "[TODO: English profession]",
"zh": "[TODO: 中文职业头衔]"
},
"displayDescription": {
"en": "[TODO: English detailed description]",
"zh": "[TODO: 中文详细描述,40-50字]"
},
"avatar": "avatars/expert.png",
"categoryId": "[TODO: XX-CategoryName]",
"defaultInitPrompt": {
"zh": "[TODO: 中文首次对话提示]",
"en": "[TODO: English first prompt]"
},
"plugin": "%(name)s",
"tags": [
{ "en": "[TODO: Tag1]", "zh": "[TODO: 标签1]" },
{ "en": "[TODO: Tag2]", "zh": "[TODO: 标签2]" },
{ "en": "[TODO: Tag3]", "zh": "[TODO: 标签3]" }
],
"quickPrompts": [
{ "en": "[TODO: Prompt1, same as defaultInitPrompt]", "zh": "[TODO: 提示词1,同defaultInitPrompt]" },
{ "en": "[TODO: Prompt2]", "zh": "[TODO: 提示词2]" },
{ "en": "[TODO: Prompt3]", "zh": "[TODO: 提示词3]" }
]
}
"""
TEAM_PLUGIN_JSON = """{
"name": "%(name)s",
"version": "1.0.0",
"description": "[TODO: English one-line description]",
"author": {
"name": "[TODO: author name]",
"email": "[TODO: author email]"
},
"agents": [
"./agents/%(team)s-team-lead.md",
"./agents/[TODO: member-a].md"
],
"expertType": "team",
"agentName": "%(team)s-team-lead",
"teamInfo": {
"leadAgent": "%(team)s-team-lead",
"memberAgents": ["[TODO: member-a]"]
},
"displayName": {
"en": "[TODO: English team name]",
"zh": "[TODO: 中文团队名称]"
},
"profession": {
"en": "[TODO: same as displayName.en]",
"zh": "[TODO: 同displayName.zh]"
},
"displayDescription": {
"en": "[TODO: English team description]",
"zh": "[TODO: 中文团队描述,40-50字]"
},
"avatar": "avatars/team.png",
"categoryId": "[TODO: XX-CategoryName]",
"defaultInitPrompt": {
"zh": "[TODO: 中文首次问候]",
"en": "[TODO: English first prompt]"
},
"plugin": "%(name)s",
"tags": [
{ "en": "[TODO: Tag1]", "zh": "[TODO: 标签1]" },
{ "en": "[TODO: Tag2]", "zh": "[TODO: 标签2]" },
{ "en": "[TODO: Tag3]", "zh": "[TODO: 标签3]" }
],
"quickPrompts": [
{ "en": "[TODO: Prompt1, same as defaultInitPrompt]", "zh": "[TODO: 提示词1,同defaultInitPrompt]" },
{ "en": "[TODO: Prompt2]", "zh": "[TODO: 提示词2]" },
{ "en": "[TODO: Prompt3]", "zh": "[TODO: 提示词3]" }
],
"members": [
{
"id": "%(team)s-team-lead",
"displayName": { "en": "[TODO]", "zh": "[TODO]" },
"profession": { "en": "[TODO]", "zh": "[TODO]" },
"avatar": "avatars/%(team)s-team-lead.png",
"role": "lead"
},
{
"id": "[TODO: member-a]",
"displayName": { "en": "[TODO]", "zh": "[TODO]" },
"profession": { "en": "[TODO]", "zh": "[TODO]" },
"avatar": "avatars/[TODO: member-a].png",
"role": "member"
}
]
}
"""
AGENT_MD_TEMPLATE = """---
name: %(agent_name)s
description: "[TODO: English description for AI to determine when to activate]"
displayName:
en: "[TODO: English display name]"
zh: "[TODO: 中文显示名称]"
profession:
en: "[TODO: English profession title]"
zh: "[TODO: 中文职业头衔]"
maxTurns: 50
---
# [TODO: 角色名称] - [TODO: 人名]
[TODO: 角色描述,说明这是谁,擅长什么]
## 核心能力
1. **[TODO: 能力1]**:[TODO: 描述]
2. **[TODO: 能力2]**:[TODO: 描述]
3. **[TODO: 能力3]**:[TODO: 描述]
## 工作流程
1. [TODO: 步骤1]
2. [TODO: 步骤2]
3. [TODO: 步骤3]
## 输出规范
- [TODO: 规范1]
- [TODO: 规范2]
## 注意事项
- [TODO: 约束或边界条件]
"""
TEAM_LEAD_MD_TEMPLATE = """---
name: %(team)s-team-lead
description: "[TODO: English description]"
displayName:
en: "[TODO: English display name]"
zh: "[TODO: 中文显示名称]"
profession:
en: "[TODO: English profession title]"
zh: "[TODO: 中文职业头衔]"
maxTurns: 150
---
# [TODO: 团队名称] - 主理人
[TODO: 主理人角色描述,负责协调团队完成什么任务]
## 团队成员
| 成员 ID | 名字 | 职责 |
|---------|------|------|
| %(team)s-team-lead | [TODO] | 编排调度 |
| [TODO: member-a] | [TODO] | [TODO: 职责] |
## 标准工作流程(SOP)
### Phase 1: [TODO: 阶段名]
[TODO: 调用哪些成员、输入输出说明]
### Phase 2: [TODO: 阶段名]
[TODO: ...]
### Phase N: 最终报告
综合所有分析结果,生成最终报告返回用户。
## 团队协作机制(铁律)
你必须走正式的**团队协作流程**,严禁简化或跳过:
1. **建立团队**:任务开始时由主理人亲自创建团队(TeamCreate),明确协作边界。**团队创建必须且只能由主理人执行,严禁委派任何成员创建团队**
2. **调度成员**:按 SOP 阶段将成员拉入协作、下发独立任务;成员作为独立协作方输出专业产出,不得由主理人代写
3. **消息中转**:成员产出回传给主理人,由主理人汇总、转交下一阶段;所有跨成员信息流必须经主理人中转,不得互相直连
4. **成员结论为准**:任何专业产出必须由对应成员输出后再采信,主理人只做编排与汇编
### 严禁行为
- ❌ 禁止跳过 TeamCreate,直接自己模拟成员发言或并行写出多角色内容
- ❌ 禁止自己代写任何团队成员的专业产出
- ❌ 禁止未完成前序阶段就跳到后续阶段
- ❌ 禁止让成员互相直连通信,所有跨成员信息流必须经主理人中转
- ❌ 禁止 spawn 主理人自己
## 协作规则
1. 所有成员调度必须经过"建立团队 → 调度成员 → 成员回传"流程
2. 每阶段结束后,将完整产出原文传递给下一阶段成员
3. 每完成一个阶段向用户简要通报
4. 所有输出使用与用户原始需求相同的语言
5. 调度成员时,Agent 工具的 `name` 参数传入成员的 **Agent ID**(MD 文件名,不含 .md),`subagent_type` 也传入相同值。禁止使用中文名或自创名称
"""
TEAM_MEMBER_MD_TEMPLATE = """---
name: [TODO: member-id]
description: "[TODO: English description]"
displayName:
en: "[TODO: English display name]"
zh: "[TODO: 中文显示名称]"
profession:
en: "[TODO: English profession title]"
zh: "[TODO: 中文职业头衔]"
maxTurns: 50
---
# [TODO: 角色名称] - [TODO: 人名]
[TODO: 角色描述]
## 核心能力
1. **[TODO: 能力1]**:[TODO: 描述]
2. **[TODO: 能力2]**:[TODO: 描述]
3. **[TODO: 能力3]**:[TODO: 描述]
## 工作流程
1. [TODO: 步骤1]
2. [TODO: 步骤2]
3. [TODO: 步骤3]
## 输出规范
- [TODO: 结构化输出模板]
## SendMessage 回传
分析完成后,**必须通过 SendMessage 将完整分析结果回传给主理人**。
"""
README_TEMPLATE = """# %(title)s
%(description)s
## 类型
%(expert_type)s
## 功能
[TODO: 详细功能说明]
## 使用示例
- [TODO: 示例提示词1]
- [TODO: 示例提示词2]
- [TODO: 示例提示词3]
## 头像
头像已自动生成在 `avatars/` 目录下。如需替换为自定义头像,要求:
- 格式:PNG(推荐)或 JPG
- 尺寸:512×512 px
- 大小:单张不超过 500KB
## 安装
将专家包目录放到专家目录下:
```
%(expert_plugins_dir)s/%(name)s/
```
然后运行注册命令使其可见:
```bash
python3 scripts/register_expert.py <expert-dir>
```
## 打包分享
```bash
zip -r %(name)s.zip %(name)s/
```
"""
SETTINGS_JSON = '{\n "agent": "%(team)s-team-lead"\n}\n'
def init_agent(expert_dir, name):
"""Initialize an Agent-type expert package."""
agent_name = name
# .codebuddy-plugin/plugin.json
plugin_dir = expert_dir / '.codebuddy-plugin'
plugin_dir.mkdir()
(plugin_dir / 'plugin.json').write_text(
AGENT_PLUGIN_JSON % {'name': name, 'agent_name': agent_name},
encoding='utf-8'
)
print(" ✅ .codebuddy-plugin/plugin.json")
# agents/
agents_dir = expert_dir / 'agents'
agents_dir.mkdir()
(agents_dir / f'{agent_name}.md').write_text(
AGENT_MD_TEMPLATE % {'agent_name': agent_name},
encoding='utf-8'
)
print(f" ✅ agents/{agent_name}.md")
# avatars/
avatars_dir = expert_dir / 'avatars'
avatars_dir.mkdir()
(avatars_dir / '.gitkeep').touch()
print(" ✅ avatars/ (awaiting ImageGen)")
# README.md
(expert_dir / 'README.md').write_text(
README_TEMPLATE % {
'title': title_case(name),
'description': '[TODO: 一句话描述]',
'expert_type': 'Agent 型(单个 AI 专家)',
'name': name,
'expert_plugins_dir': str(get_expert_plugins_dir()),
},
encoding='utf-8'
)
print(" ✅ README.md")
def init_team(expert_dir, name):
"""Initialize a Team-type expert package."""
team = name
# .codebuddy-plugin/plugin.json
plugin_dir = expert_dir / '.codebuddy-plugin'
plugin_dir.mkdir()
(plugin_dir / 'plugin.json').write_text(
TEAM_PLUGIN_JSON % {'name': name, 'team': team},
encoding='utf-8'
)
print(" ✅ .codebuddy-plugin/plugin.json")
# agents/
agents_dir = expert_dir / 'agents'
agents_dir.mkdir()
(agents_dir / f'{team}-team-lead.md').write_text(
TEAM_LEAD_MD_TEMPLATE % {'team': team},
encoding='utf-8'
)
print(f" ✅ agents/{team}-team-lead.md")
(agents_dir / 'member-placeholder.md').write_text(TEAM_MEMBER_MD_TEMPLATE, encoding='utf-8')
print(" ✅ agents/member-placeholder.md (rename to actual member ID)")
# avatars/
avatars_dir = expert_dir / 'avatars'
avatars_dir.mkdir()
(avatars_dir / '.gitkeep').touch()
print(" ✅ avatars/ (awaiting ImageGen)")
# settings.json
(expert_dir / 'settings.json').write_text(
SETTINGS_JSON % {'team': team},
encoding='utf-8'
)
print(" ✅ settings.json")
# README.md
(expert_dir / 'README.md').write_text(
README_TEMPLATE % {
'title': title_case(name),
'description': '[TODO: 一句话描述]',
'expert_type': 'Team 型(多角色协作团队)',
'name': name,
'expert_plugins_dir': str(get_expert_plugins_dir()),
},
encoding='utf-8'
)
print(" ✅ README.md")
def main():
# Parse arguments
if len(sys.argv) < 6:
print("Usage: init_expert.py <expert-name> --type agent|team --path <output-dir>")
print("\nExamples:")
print(" python3 init_expert.py my-expert --type agent --path ./output")
print(" python3 init_expert.py my-team --type team --path ./output")
sys.exit(1)
name = sys.argv[1]
expert_type = None
output_path = None
i = 2
while i < len(sys.argv):
if sys.argv[i] == '--type' and i + 1 < len(sys.argv):
expert_type = sys.argv[i + 1]
i += 2
elif sys.argv[i] == '--path' and i + 1 < len(sys.argv):
output_path = sys.argv[i + 1]
i += 2
else:
i += 1
if not expert_type or expert_type not in ('agent', 'team'):
print("❌ Error: --type must be one of: agent, team")
sys.exit(1)
if not output_path:
print("❌ Error: --path is required")
sys.exit(1)
# 硬检查:--path 必须是专家目录
expected_path = get_expert_plugins_dir()
resolved_path = Path(output_path).expanduser().resolve()
if resolved_path != expected_path.resolve():
print(f"❌ Error: --path 必须为专家目录: {expected_path}")
print(f" 当前传入: {resolved_path}")
print(f" 非专家目录下创建的专家无法被检测到。")
sys.exit(1)
# Validate name
if len(name) < 2 or not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', name):
print(f"❌ Error: name '{name}' must be kebab-case (lowercase letters, digits, hyphens)")
sys.exit(1)
# Create expert directory
expert_dir = Path(output_path).resolve() / name
if expert_dir.exists():
print(f"❌ Error: directory already exists: {expert_dir}")
sys.exit(1)
expert_dir.mkdir(parents=True)
print(f"🚀 Initializing {expert_type} expert: {name}")
print(f" Location: {expert_dir}\n")
if expert_type == 'agent':
init_agent(expert_dir, name)
elif expert_type == 'team':
init_team(expert_dir, name)
print(f"\n✅ Expert '{name}' ({expert_type}) initialized at {expert_dir}")
print("\nNext steps:")
print(" 1. Fill in all [TODO] placeholders in generated files")
print(" 2. Generate avatars using ImageGen (see references/avatar-spec.md)")
print(" 3. Run validate_expert.py to check the package")
print(" 4. Run register_expert.py to register in marketplace.json (makes it visible in WorkBuddy)")
print(f"\n Note: The expert will NOT appear in WorkBuddy until registered.")
if __name__ == "__main__":
main()
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
Expert Packager - Validates and packages an expert directory into a zip file.
Usage:
package_expert.py <path/to/expert-dir> [output-dir]
Example:
python3 package_expert.py plugins/my-expert
python3 package_expert.py plugins/my-expert ./dist
"""
import sys
import zipfile
from pathlib import Path
# Import validate_expert from sibling script
script_dir = Path(__file__).parent
sys.path.insert(0, str(script_dir))
from validate_expert import validate_expert
def package_expert(expert_path, output_dir=None):
"""
Validate and package an expert directory into a .zip file.
Args:
expert_path: Path to the expert directory
output_dir: Optional output directory (defaults to current directory)
Returns:
Path to the created zip file, or None if error
"""
expert_path = Path(expert_path).resolve()
if not expert_path.exists():
print(f"❌ Error: Expert directory not found: {expert_path}")
return None
if not expert_path.is_dir():
print(f"❌ Error: Path is not a directory: {expert_path}")
return None
# Validate first
print("🔍 Validating expert package...\n")
result = validate_expert(expert_path)
print(result.summary())
if not result.is_valid:
print("\n❌ Packaging aborted. Please fix validation errors first.")
return None
print()
# Determine output location
expert_name = expert_path.name
if output_dir:
out = Path(output_dir).resolve()
out.mkdir(parents=True, exist_ok=True)
else:
out = Path.cwd()
zip_filename = out / f"{expert_name}.zip"
# Create the zip file
try:
file_count = 0
with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
for file_path in sorted(expert_path.rglob('*')):
if file_path.is_file():
# Skip hidden files except .codebuddy-plugin
rel = file_path.relative_to(expert_path)
parts = rel.parts
if any(p.startswith('.') and p != '.codebuddy-plugin' for p in parts):
continue
# Skip common junk files/directories
if any(p in ('__pycache__', 'node_modules') for p in parts):
continue
if file_path.name in ('.gitkeep', '.DS_Store', 'Thumbs.db'):
continue
arcname = str(Path(expert_name) / rel)
zipf.write(file_path, arcname)
print(f" 📄 {arcname}")
file_count += 1
print(f"\n✅ Packaged {file_count} files to: {zip_filename}")
print(f" Size: {zip_filename.stat().st_size / 1024:.1f} KB")
return zip_filename
except Exception as e:
print(f"❌ Error creating zip: {e}")
return None
def main():
if len(sys.argv) < 2:
print("Usage: python3 package_expert.py <path/to/expert-dir> [output-dir]")
print("\nExample:")
print(" python3 package_expert.py plugins/my-expert")
print(" python3 package_expert.py plugins/my-expert ./dist")
sys.exit(1)
expert_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
print(f"📦 Packaging expert: {expert_path}")
if output_dir:
print(f" Output directory: {output_dir}")
print()
result = package_expert(expert_path, output_dir)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""
Expert Register - Validates and registers an expert into marketplace.json
This script should be called AFTER the expert package is fully created and validated.
Only registered experts will be visible in the WorkBuddy expert center.
Usage:
register_expert.py <expert-dir> [--marketplace-dir <marketplace-dir>]
Examples:
python3 register_expert.py <expert-dir>
python3 register_expert.py ./my-expert --marketplace-dir <marketplace-dir>
The creating session id is read from CODEBUDDY_SESSION_ID when present.
"""
import json
import os
import sys
from pathlib import Path
from typing import List, Optional, Tuple
SESSION_ID_ENV_VAR = 'CODEBUDDY_SESSION_ID'
UNEXPANDED_SESSION_ID_ARGS = {'$CODEBUDDY_SESSION_ID', '${CODEBUDDY_SESSION_ID}'}
def get_expert_base_dir():
"""获取专家 marketplace 根目录,优先读取 WORKBUDDY_CONFIG_DIR 环境变量。"""
config_dir = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip()
if not config_dir:
config_dir = str(Path.home() / '.workbuddy')
return Path(config_dir) / 'plugins' / 'marketplaces' / 'my-experts'
def find_plugin_json(expert_dir: Path) -> Optional[Path]:
"""Find plugin.json in the expert directory."""
for meta_dir in ['.codebuddy-plugin', '.workbuddy-plugin']:
candidate = expert_dir / meta_dir / 'plugin.json'
if candidate.exists():
return candidate
return None
def validate_expert_completeness(expert_dir: Path) -> Tuple[bool, List[str]]:
"""
Validate that the expert package is complete and ready for registration.
Returns (is_valid, list_of_errors).
"""
errors = []
# 1. Check plugin.json exists
plugin_json_path = find_plugin_json(expert_dir)
if not plugin_json_path:
errors.append("Missing plugin.json (checked .codebuddy-plugin/ and .workbuddy-plugin/)")
return False, errors
# 2. Parse plugin.json
try:
with open(plugin_json_path, 'r', encoding='utf-8') as f:
plugin_data = json.load(f)
except json.JSONDecodeError as e:
errors.append(f"plugin.json is not valid JSON: {e}")
return False, errors
# 3. Check required fields are not [TODO]
required_fields = ['name', 'description', 'expertType']
for field in required_fields:
value = plugin_data.get(field, '')
if not value or '[TODO' in str(value):
errors.append(f"Field '{field}' is missing or contains [TODO] placeholder")
# 4. Check displayName is filled
display_name = plugin_data.get('displayName', {})
if isinstance(display_name, dict):
zh_name = display_name.get('zh', '')
en_name = display_name.get('en', '')
if (not zh_name or '[TODO' in zh_name) and (not en_name or '[TODO' in en_name):
errors.append("displayName (zh or en) is missing or contains [TODO]")
elif not display_name or '[TODO' in str(display_name):
errors.append("displayName is missing or contains [TODO]")
# 5. Check agents directory has at least one non-template .md file
agents_dir = expert_dir / 'agents'
if agents_dir.exists():
md_files = list(agents_dir.glob('*.md'))
if not md_files:
errors.append("No .md files found in agents/ directory")
else:
# Check at least one agent file has real content (not all [TODO])
has_real_content = False
for md_file in md_files:
content = md_file.read_text(encoding='utf-8')
# Count [TODO] occurrences - if more than 3, likely still a template
todo_count = content.count('[TODO')
if todo_count <= 2:
has_real_content = True
break
if not has_real_content:
errors.append("All agent .md files still contain many [TODO] placeholders")
else:
errors.append("Missing agents/ directory")
return len(errors) == 0, errors
def _write_session_marker(expert_dir: Path, session_id: str) -> None:
"""Write .created-by-session marker file into the expert directory."""
marker_path = expert_dir / '.created-by-session'
try:
marker_path.write_text(session_id, encoding='utf-8')
except OSError as e:
print(f"⚠️ Warning: could not write session marker: {e}")
def normalize_session_id(value: Optional[str]) -> Optional[str]:
"""Return a usable session id, ignoring empty or unexpanded shell placeholders."""
session_id = (value or '').strip()
if not session_id or session_id in UNEXPANDED_SESSION_ID_ARGS:
return None
return session_id
def get_session_id(explicit_session_id: Optional[str]) -> Optional[str]:
"""Resolve the creating session id from CLI arg or Bash runtime env."""
return normalize_session_id(explicit_session_id) or normalize_session_id(os.environ.get(SESSION_ID_ENV_VAR))
def get_marketplace_dir(expert_dir: Path, explicit_marketplace_dir: Optional[str]) -> Path:
"""Determine the marketplace directory."""
if explicit_marketplace_dir:
return Path(explicit_marketplace_dir).expanduser().resolve()
# Infer from expert_dir path: .../marketplaces/my-experts/plugins/<name>
# -> marketplace dir is .../marketplaces/my-experts/
parts = expert_dir.parts
try:
plugins_idx = len(parts) - 2 # parent of expert dir should be "plugins"
if parts[plugins_idx] == 'plugins':
return Path(*parts[:plugins_idx])
except (IndexError, ValueError):
pass
# Fallback to default
return get_expert_base_dir()
def register_expert(expert_dir: Path, marketplace_dir: Path, session_id: Optional[str] = None) -> bool:
"""Register the expert in marketplace.json."""
manifest_path = marketplace_dir / '.codebuddy-plugin' / 'marketplace.json'
# Read existing manifest or create new one
if manifest_path.exists():
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
else:
# Create marketplace directory structure if needed
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest = {
"name": "my-experts",
"description": "my-experts marketplace (auto-generated)",
"plugins": []
}
# Read expert plugin.json for metadata
plugin_json_path = find_plugin_json(expert_dir)
if not plugin_json_path:
print(f"❌ Error: plugin.json not found in {expert_dir}")
return False
with open(plugin_json_path, 'r', encoding='utf-8') as f:
plugin_data = json.load(f)
expert_name = plugin_data.get('name', expert_dir.name)
expert_description = plugin_data.get('description', '')
source = f"./plugins/{expert_dir.name}"
# Check if already registered
plugins = manifest.get('plugins', [])
for p in plugins:
if p.get('source') == source or p.get('name') == expert_name:
# Update existing entry
p['name'] = expert_name
p['source'] = source
p['description'] = expert_description
if session_id:
_write_session_marker(expert_dir, session_id)
print(f"✅ Updated existing registration for '{expert_name}' in marketplace.json")
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
return True
# Add new entry
plugins.append({
"name": expert_name,
"source": source,
"description": expert_description
})
manifest['plugins'] = plugins
if session_id:
_write_session_marker(expert_dir, session_id)
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
print(f"✅ Registered '{expert_name}' in marketplace.json")
return True
def main():
if len(sys.argv) < 2:
print("Usage: register_expert.py <expert-dir> [--marketplace-dir <marketplace-dir>]")
print("\nExamples:")
print(" python3 register_expert.py <expert-dir>")
print(" python3 register_expert.py ./my-expert --marketplace-dir <marketplace-dir>")
sys.exit(1)
expert_dir = Path(sys.argv[1]).expanduser().resolve()
marketplace_dir_arg = None
session_id_arg = None
i = 2
while i < len(sys.argv):
if sys.argv[i] == '--marketplace-dir' and i + 1 < len(sys.argv):
marketplace_dir_arg = sys.argv[i + 1]
i += 2
elif sys.argv[i] == '--session-id' and i + 1 < len(sys.argv):
session_id_arg = sys.argv[i + 1]
i += 2
else:
i += 1
# Validate expert directory exists
if not expert_dir.exists():
print(f"❌ Error: expert directory does not exist: {expert_dir}")
sys.exit(1)
# Validate completeness
print(f"🔍 Validating expert package: {expert_dir.name}")
is_valid, errors = validate_expert_completeness(expert_dir)
if not is_valid:
print(f"\n❌ Expert package is incomplete. Cannot register.")
print(" Issues found:")
for err in errors:
print(f" - {err}")
print("\n Please fix the above issues and try again.")
sys.exit(1)
print(" ✅ Validation passed")
# Determine marketplace directory
marketplace_dir = get_marketplace_dir(expert_dir, marketplace_dir_arg)
print(f"📋 Marketplace: {marketplace_dir}")
session_id = get_session_id(session_id_arg)
# Register
success = register_expert(expert_dir, marketplace_dir, session_id)
if success and session_id:
print(f"\n🎉 Expert '{expert_dir.name}' is now registered and visible in WorkBuddy!")
elif success:
print(f"\n✅ Expert '{expert_dir.name}' is registered in marketplace.json.")
print("Warning: no session id found; it may not appear in My Experts for the current user automatically.")
else:
print(f"\n❌ Registration failed")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,381 @@
#!/usr/bin/env python3
"""
Expert Validator - Validates an expert package against the WorkBuddy specification.
Usage:
validate_expert.py <path/to/expert-dir>
Example:
python3 validate_expert.py plugins/my-expert
"""
import sys
import json
import os
import re
from pathlib import Path
VALID_CATEGORY_IDS = {
'01-ProductDesign', '02-Engineering', '03-GameSpatial', '04-DataAI',
'05-MarketingGrowth', '06-ContentCreative', '07-SalesCommerce',
'08-FinanceInvestment', '09-OperationsHR', '10-ProjectQuality',
'11-SecurityCompliance', '12-IndustryConsultant',
}
VALID_EXPERT_TYPES = {'agent', 'team'}
class ValidationResult:
def __init__(self):
self.errors = []
self.warnings = []
def error(self, msg):
self.errors.append(msg)
def warn(self, msg):
self.warnings.append(msg)
@property
def is_valid(self):
return len(self.errors) == 0
def summary(self):
lines = []
if self.errors:
lines.append(f"❌ {len(self.errors)} error(s):")
for e in self.errors:
lines.append(f" • {e}")
if self.warnings:
lines.append(f"⚠️ {len(self.warnings)} warning(s):")
for w in self.warnings:
lines.append(f" • {w}")
if self.is_valid:
lines.append("✅ Expert package is valid!")
return '\n'.join(lines)
def parse_md_frontmatter(md_path):
"""Parse YAML frontmatter from a Markdown file. Returns dict or None."""
try:
content = md_path.read_text(encoding='utf-8')
except Exception as e:
return None, f"Cannot read {md_path}: {e}"
if not content.startswith('---'):
return None, f"{md_path.name}: No YAML frontmatter found"
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not match:
return None, f"{md_path.name}: Invalid frontmatter format"
# LIMITATION: Only parses simple top-level "key: value" pairs.
# Does NOT handle nested objects, lists, or multi-line values.
# Sufficient for extracting 'name', 'description', and detecting 'tools'.
fm_text = match.group(1)
result = {}
# Extract simple key-value pairs
for line in fm_text.split('\n'):
line = line.strip()
if ':' in line and not line.startswith('-') and not line.startswith('#'):
key, _, value = line.partition(':')
key = key.strip()
value = value.strip().strip('"').strip("'")
if value:
result[key] = value
return result, None
def check_i18n_field(obj, field_name, result, context="plugin.json"):
"""Check that a field has both 'en' and 'zh' values."""
if field_name not in obj:
result.error(f"{context}: missing '{field_name}'")
return False
val = obj[field_name]
if not isinstance(val, dict):
result.error(f"{context}: '{field_name}' must be an object with 'en' and 'zh'")
return False
if not val.get('en'):
result.error(f"{context}: '{field_name}.en' is empty")
if not val.get('zh'):
result.error(f"{context}: '{field_name}.zh' is empty")
return True
def validate_plugin_json(plugin_json, expert_dir, result):
"""Validate plugin.json fields."""
# Required fields
for field in ('name', 'version', 'description'):
if field not in plugin_json:
result.error(f"plugin.json: missing required field '{field}'")
name = plugin_json.get('name', '')
if name and (len(name) < 2 or not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', name)):
result.error(f"plugin.json: 'name' must be kebab-case (at least 2 chars), got '{name}'")
expert_type = plugin_json.get('expertType', '')
if expert_type not in VALID_EXPERT_TYPES:
result.error(f"plugin.json: 'expertType' must be one of {VALID_EXPERT_TYPES}, got '{expert_type}'")
return expert_type
# plugin field consistency
plugin_field = plugin_json.get('plugin', '')
if plugin_field and plugin_field != name:
result.error(f"plugin.json: 'plugin' ({plugin_field}) must equal 'name' ({name})")
# Agent/Team specific
if expert_type in ('agent', 'team'):
agent_name = plugin_json.get('agentName', '')
if not agent_name:
result.error("plugin.json: missing 'agentName'")
else:
# Check agentName matches an MD file
md_file = expert_dir / 'agents' / f'{agent_name}.md'
if not md_file.exists():
result.error(f"plugin.json: agentName '{agent_name}' has no matching file agents/{agent_name}.md")
# agentName must not be generic "team-lead"
if agent_name == 'team-lead':
result.error("plugin.json: agentName must not be generic 'team-lead', add team prefix")
# Display fields
check_i18n_field(plugin_json, 'displayName', result)
check_i18n_field(plugin_json, 'profession', result)
check_i18n_field(plugin_json, 'displayDescription', result)
check_i18n_field(plugin_json, 'defaultInitPrompt', result)
# displayDescription zh length
dd = plugin_json.get('displayDescription', {})
zh_desc = dd.get('zh', '')
if zh_desc and not zh_desc.startswith('[TODO'):
zh_len = len(zh_desc)
if zh_len < 40 or zh_len > 50:
result.warn(f"plugin.json: displayDescription.zh is {zh_len} chars (recommended 40-50)")
# Team profession == displayName
if expert_type == 'team':
dn = plugin_json.get('displayName', {})
prof = plugin_json.get('profession', {})
if dn.get('zh') and prof.get('zh') and dn['zh'] != prof['zh']:
if not dn['zh'].startswith('[TODO') and not prof['zh'].startswith('[TODO'):
result.error(f"plugin.json: Team 'profession' must equal 'displayName' (zh: '{prof['zh']}' != '{dn['zh']}')")
# categoryId
cat = plugin_json.get('categoryId', '')
if cat and not cat.startswith('[TODO') and cat not in VALID_CATEGORY_IDS:
result.error(f"plugin.json: invalid categoryId '{cat}'")
# tags (must be 3)
tags = plugin_json.get('tags', [])
if isinstance(tags, list) and len(tags) != 3:
result.error(f"plugin.json: must have exactly 3 tags, got {len(tags)}")
# quickPrompts (must be 3)
qp = plugin_json.get('quickPrompts', [])
if isinstance(qp, list) and len(qp) != 3:
result.error(f"plugin.json: must have exactly 3 quickPrompts, got {len(qp)}")
# defaultInitPrompt == quickPrompts[0]
dip = plugin_json.get('defaultInitPrompt', {})
if isinstance(qp, list) and len(qp) > 0 and isinstance(qp[0], dict):
if dip.get('zh') and qp[0].get('zh') and dip['zh'] != qp[0]['zh']:
if not dip['zh'].startswith('[TODO'):
result.warn("plugin.json: defaultInitPrompt.zh should match quickPrompts[0].zh")
# avatar path check
avatar = plugin_json.get('avatar', '')
if avatar:
avatar_path = expert_dir / avatar
if not avatar_path.exists() and not (expert_dir / 'avatars' / '.gitkeep').exists():
result.warn(f"plugin.json: avatar file not found: {avatar}")
# agents array check
agents_arr = plugin_json.get('agents', [])
if isinstance(agents_arr, list):
for agent_path in agents_arr:
rel = agent_path[2:] if agent_path.startswith('./') else agent_path
resolved = expert_dir / rel
if not resolved.exists() and not agent_path.startswith('[TODO'):
result.error(f"plugin.json: agents path not found: {agent_path}")
# skills array check
skills_arr = plugin_json.get('skills', [])
if isinstance(skills_arr, list):
for skill_path in skills_arr:
rel = skill_path[2:] if skill_path.startswith('./') else skill_path
resolved = expert_dir / rel
skill_md = resolved / 'SKILL.md'
if not skill_md.exists() and not skill_path.startswith('[TODO'):
result.error(f"plugin.json: skill path has no SKILL.md: {skill_path}")
# Team specific
if expert_type == 'team':
team_info = plugin_json.get('teamInfo', {})
if not team_info:
result.error("plugin.json: Team type must have 'teamInfo'")
else:
lead = team_info.get('leadAgent', '')
members_agents = team_info.get('memberAgents', [])
if lead and lead in members_agents:
result.error("plugin.json: teamInfo.memberAgents should NOT contain the lead agent")
members = plugin_json.get('members', [])
if not members:
result.error("plugin.json: Team type must have 'members' array")
else:
has_lead = any(m.get('role') == 'lead' for m in members if isinstance(m, dict))
if not has_lead:
result.error("plugin.json: members array must contain at least one member with role='lead'")
# settings.json
settings_path = expert_dir / 'settings.json'
if not settings_path.exists():
result.error("Team type must have settings.json")
else:
try:
settings = json.loads(settings_path.read_text(encoding='utf-8'))
settings_agent = settings.get('agent', '')
pj_agent = plugin_json.get('agentName', '')
if settings_agent and pj_agent and settings_agent != pj_agent:
result.error(f"settings.json 'agent' ({settings_agent}) != plugin.json 'agentName' ({pj_agent})")
except json.JSONDecodeError as e:
result.error(f"settings.json: invalid JSON: {e}")
return expert_type
def validate_agent_mds(expert_dir, plugin_json, result):
"""Validate all Agent MD files."""
agents_dir = expert_dir / 'agents'
if not agents_dir.exists():
result.error("agents/ directory not found")
return
for md_file in agents_dir.glob('*.md'):
fm, err = parse_md_frontmatter(md_file)
if err:
result.error(err)
continue
if not fm:
result.error(f"{md_file.name}: empty frontmatter")
continue
# name must match filename
fm_name = fm.get('name', '')
expected_name = md_file.stem
if fm_name and fm_name != expected_name:
result.error(f"{md_file.name}: frontmatter name '{fm_name}' != filename '{expected_name}'")
# Must have description
if not fm.get('description'):
result.warn(f"{md_file.name}: missing 'description' in frontmatter")
# Must NOT have tools field
content = md_file.read_text(encoding='utf-8')
fm_section = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if fm_section and 'tools:' in fm_section.group(1):
result.error(f"{md_file.name}: frontmatter must NOT contain 'tools' field")
def validate_file_structure(expert_dir, expert_type, result):
"""Validate basic file structure."""
# .codebuddy-plugin/plugin.json must exist
pj = expert_dir / '.codebuddy-plugin' / 'plugin.json'
if not pj.exists():
result.error(".codebuddy-plugin/plugin.json not found")
# Must NOT have hooks/, commands/, .lsp.json
for forbidden in ('hooks', 'commands'):
if (expert_dir / forbidden).exists():
result.error(f"Forbidden directory found: {forbidden}/")
if (expert_dir / '.lsp.json').exists():
result.error("Forbidden file found: .lsp.json")
# agents/, skills/, bin/ must be at root, not inside .codebuddy-plugin/
cb_dir = expert_dir / '.codebuddy-plugin'
for subdir in ('agents', 'skills', 'bin', 'avatars'):
if (cb_dir / subdir).exists():
result.error(f"{subdir}/ found inside .codebuddy-plugin/ — must be at plugin root")
# Must have agents/ dir
if not (expert_dir / 'agents').exists():
result.error(f"{expert_type} type must have agents/ directory")
# README.md recommended
if not (expert_dir / 'README.md').exists():
result.warn("README.md is recommended")
def validate_expert(expert_path):
"""Main validation entry point."""
expert_dir = Path(expert_path).resolve()
result = ValidationResult()
if not expert_dir.exists():
result.error(f"Directory not found: {expert_dir}")
return result
if not expert_dir.is_dir():
result.error(f"Not a directory: {expert_dir}")
return result
# Check if expert is in the default install path
config_dir = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip() or str(Path.home() / '.workbuddy')
default_plugins_dir = Path(config_dir) / 'plugins' / 'marketplaces' / 'my-experts' / 'plugins'
try:
expert_dir.relative_to(default_plugins_dir.resolve())
except ValueError:
result.error(
f"专家不在专家目录下,将无法被检测到。"
f" 专家目录: {default_plugins_dir}"
)
# Load plugin.json
pj_path = expert_dir / '.codebuddy-plugin' / 'plugin.json'
if not pj_path.exists():
result.error(".codebuddy-plugin/plugin.json not found")
return result
try:
plugin_json = json.loads(pj_path.read_text(encoding='utf-8'))
except json.JSONDecodeError as e:
result.error(f"plugin.json: invalid JSON: {e}")
return result
# Validate plugin.json
expert_type = validate_plugin_json(plugin_json, expert_dir, result)
# Validate file structure
validate_file_structure(expert_dir, expert_type, result)
# Validate Agent MDs
validate_agent_mds(expert_dir, plugin_json, result)
return result
def main():
if len(sys.argv) != 2:
print("Usage: python3 validate_expert.py <path/to/expert-dir>")
print("\nExample:")
print(" python3 validate_expert.py plugins/my-expert")
sys.exit(1)
expert_path = sys.argv[1]
print(f"🔍 Validating expert package: {expert_path}\n")
result = validate_expert(expert_path)
print(result.summary())
sys.exit(0 if result.is_valid else 1)
if __name__ == "__main__":
main()
+301
View File
@@ -0,0 +1,301 @@
import { app } from 'electron'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { parseDocument } from 'yaml'
import type { ExpertActivation, ExpertBinding, ExpertDocument, ExpertRecord } from '@shared/capabilities'
import { expertText } from '@shared/capabilities'
import managerSource from './assets/expert-manager-source.json'
import { listMcpServers } from './mcp'
/** Package layout and registration semantics recovered through REA; see the adjacent source manifest. */
const META = ['.codebuddy-plugin/plugin.json', '.workbuddy-plugin/plugin.json']
const MAX_BYTES = 16 * 1024 * 1024
const managerFiles: Record<string, string> = managerSource.files
let operations: Promise<unknown> = Promise.resolve()
function serialized<T>(run: () => Promise<T>): Promise<T> { const next = operations.then(run); operations = next.catch(() => {}); return next }
const digest = (value: string): string => createHash('sha256').update(value).digest('hex')
const configRoot = (): string => app.getPath('userData')
const marketRoot = (): string => path.join(configRoot(), 'plugins', 'marketplaces', 'my-experts')
const packageRoot = (): string => path.join(marketRoot(), 'plugins')
const marketFile = (): string => path.join(marketRoot(), '.codebuddy-plugin', 'marketplace.json')
function identifier(value: unknown): string {
if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$/.test(value) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(value)) throw Error('专家标识必须是至少两位的 kebab-case')
return value
}
function relative(value: unknown): string {
if (typeof value !== 'string') throw Error('专家资源路径无效')
const clean = value.replace(/^\.\//, '')
if (!clean || clean.length > 240 || /[\\\x00-\x1f<>:"|?*]/.test(clean) || clean.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw Error('专家资源路径越界或无效')
return clean
}
async function safePath(target: string, create = false): Promise<string> {
const resolved = path.resolve(target), base = path.resolve(configRoot())
const rel = path.relative(base, resolved)
if (path.isAbsolute(rel) || rel === '..' || rel.startsWith('..' + path.sep)) throw Error('专家路径越界')
let current = path.parse(resolved).root
for (const part of path.relative(current, resolved).split(path.sep)) {
current = path.join(current, part)
try { if ((await fs.lstat(current)).isSymbolicLink()) throw Error('专家目录不能包含符号链接') }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; if (create) await fs.mkdir(current); else throw error }
}
return resolved
}
async function fileMap(root: string): Promise<Record<string, string>> {
const files: Record<string, string> = {}; let bytes = 0, count = 0
async function visit(dir: string): Promise<void> {
const absolute = await safePath(dir)
for (const item of await fs.readdir(absolute, { withFileTypes: true })) {
if (item.isSymbolicLink()) throw Error('专家包不能包含符号链接')
if (['node_modules', '.git', '__pycache__'].includes(item.name)) continue
const file = path.join(absolute, item.name)
if (item.isDirectory()) { await visit(file); continue }
if (!item.isFile()) throw Error('专家包只能包含普通文件')
const size = (await fs.stat(file)).size; bytes += size
if (++count > 256 || bytes > MAX_BYTES) throw Error('专家包超过文件数或大小限制')
const key = path.relative(root, file).replace(/\\/g, '/')
if (/\.(?:md|json|txt|yaml|yml|py|js|cjs|mjs|ts|csv|css|html|svg|cs|csproj|slnx|xml|xsd|sh|ps1|LICENSE)$/i.test(key) || ['LICENSE', '.gitignore'].includes(path.basename(key)) || key.startsWith('.created-') || key === '.pending-preview-session') {
const data = await fs.readFile(await safePath(file))
try { files[key] = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data) } catch { throw Error('专家文档必须是 UTF-8 文本') }
}
}
}
await visit(root); return files
}
function manifest(files: Record<string, string>): Record<string, any> {
const content = META.map(key => files[key]).find(Boolean)
if (!content) throw Error('专家包缺少 plugin.json')
const data = JSON.parse(content.replace(/^\uFEFF/, ''))
if (!data || typeof data !== 'object' || Array.isArray(data)) throw Error('专家元数据无效')
return data
}
function yamlBody(value: string): { meta: Record<string, any>; body: string } {
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(value)
if (!match) throw Error('Agent MD 缺少 YAML frontmatter')
const doc = parseDocument(match[1], { uniqueKeys: true, schema: 'core' })
if (doc.errors.length) throw Error('Agent MD YAML 无效')
const meta = doc.toJS({ maxAliasCount: 30 })
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) throw Error('Agent MD 元数据无效')
return { meta, body: value.slice(match[0].length).trim() }
}
export function validateExpertFiles(files: Record<string, string>, id: string): { errors: string[]; warnings: string[] } {
const errors: string[] = [], warnings: string[] = []
try {
const p = manifest(files)
for (const key of ['name', 'version', 'description']) if (typeof p[key] !== 'string' || !p[key].trim() || p[key].includes('[TODO')) errors.push(`${key} 不能为空或包含 [TODO]`)
if (identifier(p.name) !== id) errors.push('name 必须与专家目录一致')
if (!['agent', 'team'].includes(p.expertType)) errors.push('expertType 必须为 agent 或 team')
if (p.plugin && p.plugin !== p.name) errors.push('plugin 必须等于 name')
if (typeof p.agentName !== 'string' || p.agentName === 'team-lead' || !files[`agents/${p.agentName}.md`]) errors.push('agentName 必须对应真实 Agent MD,且不能是通用 team-lead')
for (const key of ['displayName', 'profession', 'displayDescription', 'defaultInitPrompt']) if (!p[key] || typeof p[key] !== 'object' || ['zh', 'en'].some(locale => typeof p[key][locale] !== 'string' || !p[key][locale].trim() || p[key][locale].includes('[TODO'))) errors.push(`${key} 必须包含完整的中英文内容`)
for (const key of ['tags', 'quickPrompts']) if (!Array.isArray(p[key]) || p[key].length !== 3) errors.push(`${key} 必须恰好有 3 项`)
if (!Array.isArray(p.agents) || !p.agents.length) errors.push('agents 必须至少声明一个 Agent MD')
for (const key of ['agents', 'skills']) for (const entry of p[key] ?? []) {
const resource = relative(entry) + (key === 'skills' ? '/SKILL.md' : '')
if (!files[resource]) errors.push(`资源未找到:${resource}`)
}
for (const [key, value] of Object.entries(files)) {
relative(key)
if (/^(?:hooks|commands)(?:\/|$)|(?:^|\/)\.lsp\.json$/.test(key)) errors.push(`不支持的专家资源:${key}`)
if (/^\.(?:codebuddy|workbuddy)-plugin\/(?:agents|skills|bin|avatars)\//.test(key)) errors.push('资源必须位于包根目录')
if (/^agents\/[^/]+\.md$/.test(key)) {
const { meta, body } = yamlBody(value)
if (meta.name !== path.basename(key, '.md')) errors.push(`${key} 的 name 必须与文件名一致`)
if ('tools' in meta) errors.push(`${key} 禁止声明 tools`)
if (!body || body.includes('[TODO')) errors.push(`${key} 正文未完成`)
if (!meta.description) warnings.push(`${key} 缺少 description`)
}
}
const n = Array.from(p.displayDescription?.zh ?? '').length
if (n < 40 || n > 50) warnings.push('displayDescription.zh 推荐 40–50 字')
if (p.defaultInitPrompt?.zh !== p.quickPrompts?.[0]?.zh) warnings.push('defaultInitPrompt 应与第一条 quickPrompt 一致')
if (p.avatar) relative(p.avatar)
else warnings.push('尚未提供专家头像')
if (!files['README.md']) warnings.push('尚未提供 README.md')
if (p.expertType === 'team') {
if (!p.teamInfo || !Array.isArray(p.members) || !p.members.some((m: any) => m?.role === 'lead')) errors.push('专家团必须声明 teamInfo 和包含主理人的 members')
if (p.teamInfo?.memberAgents?.includes(p.teamInfo?.leadAgent)) errors.push('memberAgents 不能包含主理人')
if (p.profession?.zh !== p.displayName?.zh) errors.push('专家团 profession 必须等于 displayName')
if (!files['settings.json']) errors.push('专家团缺少 settings.json')
else { const settings = JSON.parse(files['settings.json']); if (settings.agent && settings.agent !== p.agentName) errors.push('settings.json agent 与 agentName 不一致') }
}
} catch (error) { errors.push(error instanceof Error ? error.message : String(error)) }
return { errors, warnings }
}
async function record(id: string, files: Record<string, string>, root: string): Promise<ExpertRecord> {
const p = manifest(files), validation = validateExpertFiles(files, id)
let avatar: string | undefined
if (typeof p.avatar === 'string' && !/^[a-z]+:/i.test(p.avatar)) try {
const file = await safePath(path.join(root, relative(p.avatar)))
if ((await fs.stat(file)).size <= 500 * 1024 && /\.(png|jpe?g|webp)$/i.test(file)) avatar = `data:image/${/\.jpe?g$/i.test(file) ? 'jpeg' : path.extname(file).slice(1)};base64,${(await fs.readFile(file)).toString('base64')}`
} catch { /* Missing avatars remain optional; never load arbitrary filesystem URLs. */ }
return { id, name: p.name, displayName: p.displayName, profession: p.profession, description: p.description ?? '', displayDescription: p.displayDescription, version: p.version || '1.0.0', expertType: p.expertType || 'skill', agentName: p.agentName, plugin: p.plugin || p.name, pluginRegisteredName: p.name, marketplace: 'my-experts', isCustomExpert: true, expertRootDir: root, avatar, defaultInitPrompt: p.defaultInitPrompt, tags: p.tags, quickPrompts: p.quickPrompts, skills: p.skills, categoryId: p.categoryId, members: p.members, createdBySession: (files['.created-by-session'] || files['.pending-preview-session'])?.trim(), updatedAt: (await fs.stat(await safePath(path.join(root, META.find(key => files[key])!)))).mtimeMs, error: validation.errors.join('\n') || undefined }
}
async function listExpertsUnlocked(): Promise<ExpertRecord[]> {
let catalog: any
try { catalog = JSON.parse(await fs.readFile(await safePath(marketFile()), 'utf8')) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
if (!Array.isArray(catalog.plugins)) throw Error('本地专家目录格式无效')
const result: ExpertRecord[] = [], seen = new Set<string>()
for (const item of catalog.plugins) try {
const id = identifier(String(item.source ?? '').replace(/^\.\/plugins\//, ''))
if (seen.has(id)) continue
seen.add(id); const root = path.join(packageRoot(), id)
result.push(await record(id, await fileMap(root), root))
} catch { /* Broken entries do not prevent other experts from being discovered. */ }
return result
}
async function readExpertUnlocked(id: string): Promise<ExpertDocument> {
identifier(id); const root = path.join(packageRoot(), id), files = await fileMap(root), expert = await record(id, files, root)
const prompt = expert.agentName && files[`agents/${expert.agentName}.md`] ? yamlBody(files[`agents/${expert.agentName}.md`]).body : ''
return { expert, files, prompt }
}
export function listExperts(): Promise<ExpertRecord[]> { return serialized(listExpertsUnlocked) }
export function readExpert(id: string): Promise<ExpertDocument> { return serialized(() => readExpertUnlocked(id)) }
export async function activateExpert(id: string): Promise<ExpertActivation> {
const document = await readExpert(id)
return { ...await activateDocument(document), defaultInitPrompt: document.expert.defaultInitPrompt }
}
async function activateDocument(document: ExpertDocument): Promise<ExpertBinding> {
const { expert, prompt, files } = document, id = expert.id, p = manifest(files)
if (expert.error) throw Error(expert.error)
if (expert.expertType === 'team') throw Error('专家团调度尚未接入,暂不能激活')
if (!prompt) throw Error('专家缺少可运行的 Agent 指令')
if ((p.dependencies?.connectors?.length ?? 0) > 0 || (p.connectorIds?.length ?? 0) > 0) throw Error('此专家依赖云端连接器,当前尚未接入')
const declaration = p.dependencies?.mcpServers ?? p.mcpServers ?? (files['.mcp.json'] ? '.mcp.json' : undefined)
if (declaration) {
const read = (value: any): Record<string, any> => {
if (typeof value === 'string') { const content = files[relative(value)]; if (!content) throw Error('专家依赖配置未找到'); const parsed = JSON.parse(content); return read(parsed.mcpServers ?? parsed) }
if (Array.isArray(value)) return Object.assign({}, ...value.map(read))
if (!value || typeof value !== 'object') throw Error('专家 MCP 依赖声明无效')
return value
}
const declarations = read(declaration), connected = await listMcpServers()
for (const name of Object.keys(declarations)) {
const server = connected.find(item => item.name === name)
if (!server?.enabled || server.status !== 'connected' || !server.trusted) throw Error(`请先在连接器中配置并连接 ${name}`)
// A same-name server is not sufficient evidence of matching package dependencies.
const required = declarations[name]
if (required?.url && required.url !== server.url || required?.command && required.command !== server.command) throw Error(`连接器 ${name} 与专家要求的配置不一致`)
if (required?.command) {
if (server.transport !== 'stdio' || !Array.isArray(required.args ?? [])) throw Error(`连接器 ${name} 的传输方式或参数无效`)
const args = (required.args ?? []).map((arg: unknown) => {
if (typeof arg !== 'string') throw Error('MCP 参数必须是字符串')
return arg.startsWith('./') ? path.join(expert.expertRootDir, relative(arg)) : arg
})
if (JSON.stringify(args) !== JSON.stringify(server.args ?? [])) throw Error(`连接器 ${name} 的启动参数与专家要求不一致`)
}
if (required?.url && server.transport !== 'http' || required?.type && ['stdio', 'http'].includes(required.type) && required.type !== server.transport) throw Error(`连接器 ${name} 的传输方式与专家要求不一致`)
if (required?.cwd && path.resolve(required.cwd) !== path.resolve(server.cwd ?? '')) throw Error(`连接器 ${name} 的工作目录与专家要求不一致`)
}
}
return { id, runtimeIdentity: 'expert-v2:' + JSON.stringify({ v: 2, kind: 'expert', expertId: id, pluginRegisteredName: expert.pluginRegisteredName, agentName: expert.agentName, source: 'custom' }), name: expertText(expert.displayName) || expert.name, profession: expertText(expert.profession) || undefined, avatarUrl: expert.avatar, expertType: expert.expertType }
}
export async function expertTurnContext(id: string): Promise<{ context: string; display: ExpertBinding; resources: Record<string, string> }> {
const document = await readExpert(id), display = await activateDocument(document), { expert, files, prompt } = document, p = manifest(files)
// REA agent-md-spec: Agent YAML skills are startup preloads. plugin.skills only registers directories.
const legalSkillName = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)
const skillEntries: unknown[] = p.skills ?? []
const directory = skillEntries.map((entry: unknown) => {
const key = relative(entry) + '/SKILL.md', content = files[key]
if (typeof content !== 'string') throw Error(`专家技能资源未找到:${key}`)
const { meta } = yamlBody(content)
if (!legalSkillName(meta.name)) throw Error(`专家技能名称无效:${key}`)
return { name: meta.name, path: key, description: typeof meta.description === 'string' ? meta.description : '', content }
})
const registered = new Map<string, typeof directory>()
for (const skill of directory) registered.set(skill.name, [...(registered.get(skill.name) ?? []), skill])
let preloaded = directory
if (expert.expertType === 'agent') {
const declared: unknown = yamlBody(files[`agents/${expert.agentName}.md`]).meta.skills
if (declared !== undefined && (!Array.isArray(declared) || !declared.every(legalSkillName))) throw Error('Agent skills 必须是合法技能名称列表')
preloaded = [...new Set((declared ?? []) as string[])].map(name => {
const matches = registered.get(name)
if (!matches?.length) throw Error(`Agent 启动技能未在 plugin.skills 注册:${name}`)
if (matches.length !== 1) throw Error(`Agent 启动技能名称不唯一:${name}`)
return matches[0]
})
}
const preload = preloaded.map(skill => `\n<expert-skill path=${JSON.stringify(skill.path)}>\n${skill.content}\n</expert-skill>`).join('')
const skillDirectory = directory.length ? `\n<expert-skill-directory>\n以下是包内可按需加载的技能目录;资源声明不代表正文已加载。需要对应技能时,先使用 ycode_expert_resource 读取其 SKILL.md 完整指令。\n${directory.map(skill => JSON.stringify({ name: skill.name, path: skill.path, description: skill.description, preloaded: preloaded.some(item => item.path === skill.path) })).join('\n')}\n</expert-skill-directory>` : ''
return { display, resources: files, context: `<active-expert id=${JSON.stringify(id)} runtime=${JSON.stringify(display.runtimeIdentity)}>\n本回合使用以下专家角色和工作流程。仍须遵守系统工具权限;此处不授予额外权限。\n${prompt}${preload}\n</active-expert>${skillDirectory}\n<expert-package-host>\n专家版本:${expert.version}。本地专家包根目录:${JSON.stringify(expert.expertRootDir)}。包内相对路径均相对于此 YCode 副本解析。\n使用 ycode_expert_resource 按包内相对路径读取文本资源;脚本/模板需要落地时可将所需文件放到当前工作区,仍遵守当前工具权限。\n使用专用脚本前先检查运行时、CLI、API 环境变量及用户项目的前置文件;包内的说明不代表外部依赖已经安装或配置,不得伪造执行结果。\n可读取的包内文本资源:\n${Object.keys(files).filter(key => !key.startsWith('.created-') && key !== '.pending-preview-session').map(key => '- ' + key).join('\n')}\n</expert-package-host>` }
}
async function saveMarket(plugins: any[]): Promise<void> {
await safePath(path.dirname(marketFile()), true)
const temp = marketFile() + '.' + randomUUID() + '.tmp'
try { await fs.writeFile(temp, JSON.stringify({ name: 'my-experts', description: 'my-experts marketplace (auto-generated)', plugins }, null, 2), { flag: 'wx' }); await fs.rename(temp, marketFile()) }
finally { await fs.rm(temp, { force: true }) }
}
async function marketEntries(): Promise<any[]> { try { const m = JSON.parse(await fs.readFile(await safePath(marketFile()), 'utf8')); if (!Array.isArray(m.plugins)) throw Error('专家目录格式无效'); return m.plugins } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; throw e } }
export function removeExpert(id: string): Promise<void> { return serialized(async () => {
identifier(id); const target = await safePath(path.join(packageRoot(), id)), tomb = path.join(packageRoot(), '.remove-' + randomUUID())
const entries = await marketEntries()
await fs.rename(target, tomb)
try { await saveMarket(entries.filter(p => p.source !== `./plugins/${id}`)) } catch (e) { await fs.rename(tomb, target); throw e }
// After the catalog commits, cleanup failure must not resurrect a partially removed package.
await fs.rm(tomb, { recursive: true, force: true }).catch(() => console.warn('专家删除已提交,待清理备份:', id))
}) }
interface Draft { files: Record<string, string>; baseHash: string | null }
export function expertPackageOperation(sessionId: string, args: Record<string, unknown>): Promise<unknown> { return serialized(async () => {
if (!/^[a-zA-Z0-9-]{1,128}$/.test(sessionId)) throw Error('会话标识无效')
const id = identifier(args.id), action = args.action
const draftDir = path.join(configRoot(), 'capabilities', 'expert-drafts', sessionId), draftFile = path.join(draftDir, id + '.json')
let draft: Draft
try { if (action === 'read' && args.refresh === true) throw Object.assign(Error('refresh'), { code: 'ENOENT' }); draft = JSON.parse(await fs.readFile(await safePath(draftFile), 'utf8')) } catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e
let files: Record<string, string> = {}
try { files = (await readExpertUnlocked(id)).files } catch (cause) { if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause }
draft = { files, baseHash: Object.keys(files).length ? digest(JSON.stringify(files)) : null }
}
if (action === 'read') {
await safePath(draftDir, true)
const temp = draftFile + '.' + randomUUID(); await fs.writeFile(temp, JSON.stringify(draft), { flag: 'wx' }); await fs.rename(temp, draftFile)
return { id, ...draft }
}
if (action === 'write') {
if (!args.files || typeof args.files !== 'object' || Array.isArray(args.files)) throw Error('files 必须是相对路径到 UTF-8 内容的对象')
const next = { ...draft.files }
for (const [key, value] of Object.entries(args.files)) {
const name = relative(key)
if (name === '.created-by-session' || name === '.pending-preview-session') throw Error('会话标记由注册步骤写入')
if (value === null) delete next[name]
else if (typeof value === 'string' && Buffer.byteLength(value) <= 1024 * 1024) next[name] = value
else throw Error('专家文件必须是小于 1MB 的 UTF-8 文本,删除请用 null')
}
if (Object.keys(next).length > 256 || Buffer.byteLength(JSON.stringify(next)) > MAX_BYTES) throw Error('专家包超过大小或文件数限制')
if (draft.baseHash && META.some(key => next[key])) { const previous = manifest(draft.files), updated = manifest(next); if (previous.name !== updated.name || previous.agentName !== updated.agentName) throw Error('修改专家时不能改变 name 或 agentName') }
draft.files = next; await safePath(draftDir, true)
const temp = draftFile + '.' + randomUUID(); await fs.writeFile(temp, JSON.stringify(draft), { flag: 'wx' }); await fs.rename(temp, draftFile)
return { id, files: Object.keys(next), ...validateExpertFiles(next, id), registered: false }
}
const validation = validateExpertFiles(draft.files, id)
if (action === 'validate') return { id, ...validation, valid: !validation.errors.length }
if (action !== 'register') throw Error('action 必须是 read、write、validate 或 register')
if (validation.errors.length) throw Error(validation.errors.join('\n'))
const target = path.join(packageRoot(), id)
let current: Record<string, string> | undefined
try { current = (await readExpertUnlocked(id)).files } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e }
if ((current ? digest(JSON.stringify(current)) : null) !== draft.baseHash) throw Error('专家已被其他会话修改,请重新读取后编辑')
await safePath(packageRoot(), true)
const stage = path.join(packageRoot(), '.stage-' + randomUUID()), backup = path.join(packageRoot(), '.backup-' + randomUUID())
const entries = await marketEntries(); await fs.mkdir(stage); let backedUp = false, published = false
try {
// Preserve existing binary resources during textual edits.
if (current) { await safePath(target); await fs.cp(target, stage, { recursive: true, force: true, dereference: false }); await fileMap(stage) }
for (const key of Object.keys(current ?? {})) if (!(key in draft.files)) await fs.rm(path.join(stage, relative(key)), { force: true })
for (const [key, content] of Object.entries(draft.files)) { const file = path.join(stage, relative(key)); await safePath(path.dirname(file), true); await fs.writeFile(file, content) }
await fs.writeFile(path.join(stage, '.created-by-session'), sessionId)
if (current) { await fs.rename(target, backup); backedUp = true }
await fs.rename(stage, target); published = true
const p = manifest(draft.files), filtered = entries.filter(entry => entry.name !== p.name && entry.source !== `./plugins/${id}`)
await saveMarket([...filtered, { name: p.name, source: `./plugins/${id}`, description: p.description }])
} catch (error) { if (published) await fs.rm(target, { recursive: true, force: true }); if (backedUp) await fs.rename(backup, target); throw error }
finally { await fs.rm(stage, { recursive: true, force: true }) }
if (backedUp) await fs.rm(backup, { recursive: true, force: true }).catch(() => console.warn('专家注册已提交,待清理备份:', id))
await fs.rm(draftFile, { force: true }).catch(() => console.warn('专家注册已提交,待清理草稿:', id))
return { success: true, expert: (await readExpertUnlocked(id)).expert, warnings: validation.warnings }
}) }
export function expertManagerDocument(): { content: string; files: string[] } { return { content: managerFiles['SKILL.md'], files: Object.keys(managerFiles) } }
export function expertManagerResource(resource: string): string { const key = relative(resource), value = Object.hasOwn(managerFiles, key) ? managerFiles[key] : undefined; if (typeof value !== 'string') throw Error('专家管理器资源不存在'); return value }
export function expertManagerAdapter(sessionId: string): string {
return `\n<ycode-expert-manager-host>\n以上是通过 REA 恢复的 WorkBuddy 原始 expert-manager 技能。当前运行在 YCode,以下是宿主适配:\n1. WORKBUDDY_CONFIG_DIR 对应 YCode 的受控本地配置目录;不要写入 WorkBuddy 目录,不要在工作区伪造注册结果。\n2. 使用 ycode_expert_package 代替 Python 初始化/写文件/校验/注册:先 read(id),再 write(id,files) 增量保存,validate,最后 register。files 为包内相对路径到完整 UTF-8 文本的对象,null 删除文件。格式仍遵守原 references;工具自动管理固定目录、marketplace.json 与 CODEBUDDY_SESSION_ID=${sessionId} 标记。修改前必须 read,保留未修改字段,禁止改变 name/agentName/Agent 文件名。\n3. 用 ycode_read_skill_resource(id=builtin:expert-manager,path=...) 阅读原 references/scripts;Python脚本供规范参考,不在当前宿主执行。\n4. 当前未接入 ImageGen;需要头像时如实说明并请用户提供图片,不要声称已生成。可先不填 avatar 并展示校验警告。专家团包可编辑/校验,团队调度未接入,不能声称可运行。\n5. register 返回 success:true 才算创建完成。校验失败必须修复,不得只输出一段角色描述。\n</ycode-expert-manager-host>`
}
+308
View File
@@ -0,0 +1,308 @@
import { app, safeStorage } from 'electron'
import { promises as fs } from 'node:fs'
import { join, isAbsolute } from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import type { McpServerInput, McpServerView, McpToolView, McpStatus } from '@shared/capabilities'
import type { ToolSpec } from '@shared/types'
interface SecretValues { env: Record<string, string>; headers: Record<string, string> }
interface StoredServer extends Omit<McpServerView, 'status' | 'error' | 'trusted'> {
secrets?: string
trustedHash?: string
}
interface Registry { version: 1; servers: StoredServer[] }
interface Runtime {
generation: string
configHash: string
status: McpStatus
error?: string
client: Client
transport: Transport
controller: AbortController
secretValues: string[]
flight?: Promise<McpServerView>
}
let registry: Registry | null = null
let loading: Promise<Registry> | null = null
let writes: Promise<unknown> = Promise.resolve()
const runtimes = new Map<string, Runtime>()
const MAX_SERVERS = 100, MAX_TOOLS = 10000, CONNECT_TIMEOUT = 20000, CALL_TIMEOUT = 120000
const filePath = (): string => join(app.getPath('userData'), 'mcp-servers.json')
const clone = <T>(value: T): T => structuredClone(value)
const abortError = (): Error => Object.assign(new Error('MCP 调用已取消'), { name: 'AbortError' })
function object(value: unknown): value is Record<string, unknown> { return !!value && typeof value === 'object' && !Array.isArray(value) }
function sorted(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sorted)
return object(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, sorted(value[key])])) : value
}
function readSecrets(server: StoredServer): SecretValues {
if (!server.secrets) return { env: {}, headers: {} }
try {
const values = JSON.parse(safeStorage.decryptString(Buffer.from(server.secrets, 'base64'))) as SecretValues
if (!object(values.env) || !object(values.headers)) throw Error()
return values
} catch { throw new Error('无法解密 MCP 凭据,请重新配置') }
}
function encryptSecrets(values: SecretValues): string | undefined {
if (!Object.keys(values.env).length && !Object.keys(values.headers).length) return undefined
try {
if (!safeStorage.isEncryptionAvailable()) throw Error()
return safeStorage.encryptString(JSON.stringify(values)).toString('base64')
} catch { throw new Error('系统加密服务不可用,无法安全保存 MCP 凭据') }
}
function mergeSecretMap(input: unknown, previous: Record<string, string>, headers: boolean): Record<string, string> {
if (input === undefined) return { ...previous }
if (!object(input) || Object.keys(input).length > 128) throw new Error('MCP 环境变量或 Headers 格式无效')
const result: Record<string, string> = Object.create(null)
for (const [rawKey, value] of Object.entries(input)) {
const key = headers ? rawKey.toLowerCase() : rawKey
if (!key || key.length > 256 || !/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key) || typeof value !== 'string' || value.length > 65536) throw new Error('MCP 环境变量或 Headers 格式无效')
if (headers && /[\r\n]/.test(value)) throw new Error('MCP Headers 格式无效')
// Empty retained keys keep credentials; omit a key from an explicitly supplied map to remove it.
if (value) result[key] = value
else if (previous[key] !== undefined) result[key] = previous[key]
}
return result
}
function normalize(input: McpServerInput, previous?: StoredServer): StoredServer {
if (!input || typeof input.name !== 'string' || !input.name.trim() || input.name.length > 128) throw new Error('请输入有效的 MCP 名称')
if (input.id && (!previous || previous.id !== input.id)) throw new Error('MCP 服务不存在')
if (input.transport !== 'stdio' && input.transport !== 'http') throw new Error('不支持的 MCP 连接类型')
const before = previous ? readSecrets(previous) : { env: {}, headers: {} }
const secrets = { env: mergeSecretMap(input.env, before.env, false), headers: mergeSecretMap(input.headers, before.headers, true) }
const config: Omit<McpServerInput, 'id' | 'env' | 'headers' | 'enabled'> = { name: input.name.trim(), transport: input.transport }
if (input.transport === 'stdio') {
if (typeof input.command !== 'string' || !input.command.trim() || input.command.length > 4096 || /[\r\n\0]/.test(input.command)) throw new Error('请输入有效的 MCP 启动命令')
if (input.args !== undefined && (!Array.isArray(input.args) || input.args.length > 256 || input.args.some(arg => typeof arg !== 'string' || arg.length > 65536 || arg.includes('\0')))) throw new Error('MCP 参数格式无效')
if (input.cwd && (!isAbsolute(input.cwd) || input.cwd.includes('\0'))) throw new Error('MCP 工作目录必须为绝对路径')
config.command = input.command.trim(); config.args = input.args?.slice() || []
if (input.cwd) config.cwd = input.cwd
} else {
let url: URL
try { url = new URL(input.url || '') } catch { throw new Error('请输入有效的 MCP HTTP 地址') }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || [...url.searchParams.keys()].some(key => /token|key|secret|password|authorization/i.test(key))) throw new Error('MCP 地址必须为 HTTP(S),凭据请使用 Headers 配置')
config.url = url.href
}
const configHash = createHash('sha256').update(JSON.stringify(sorted({ ...config, ...secrets }))).digest('hex')
return { ...config, id: previous?.id || randomUUID(), enabled: input.enabled ?? previous?.enabled ?? false,
envKeys: Object.keys(secrets.env).sort(), headerKeys: Object.keys(secrets.headers).sort(), configHash,
secrets: encryptSecrets(secrets), trustedHash: previous?.configHash === configHash ? previous.trustedHash : undefined,
tools: previous?.configHash === configHash ? clone(previous.tools) : [] }
}
async function load(): Promise<Registry> {
if (registry) return registry
if (loading) return loading
loading = (async () => {
let content: string
try { content = await fs.readFile(filePath(), 'utf8') }
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return registry = { version: 1, servers: [] }; throw error }
let parsed: Registry
try { parsed = JSON.parse(content) as Registry } catch { throw new Error('MCP 配置文件损坏,请保留配置文件后检查内容') }
if (parsed.version !== 1 || !Array.isArray(parsed.servers) || parsed.servers.length > MAX_SERVERS) throw new Error('MCP 配置版本或内容无效')
const ids = new Set<string>()
const normalized: StoredServer[] = []
for (const server of parsed.servers) {
if (!server || typeof server.id !== 'string' || ids.has(server.id) || typeof server.name !== 'string' || typeof server.configHash !== 'string'
|| !['stdio', 'http'].includes(server.transport) || !Array.isArray(server.tools) || !Array.isArray(server.envKeys) || !Array.isArray(server.headerKeys)) throw new Error('MCP 配置内容无效')
ids.add(server.id)
// A hand-edited file cannot retain approval for a different command, URL or secret.
// Reconstructing the record also excludes unknown credential-bearing fields from IPC views.
normalized.push(normalize({ ...server, ...readSecrets(server) }, server))
}
return registry = { version: 1, servers: normalized }
})().finally(() => { loading = null })
return loading
}
async function persist(next: Registry): Promise<void> {
const file = filePath(), temporary = `${file}.${randomUUID()}.tmp`
await fs.mkdir(join(file, '..'), { recursive: true })
try { await fs.writeFile(temporary, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 }); await fs.rename(temporary, file); registry = next }
finally { await fs.unlink(temporary).catch(() => undefined) }
}
function mutate<T>(operation: (current: Registry) => Promise<T>): Promise<T> {
const next = writes.then(async () => operation(await load()))
writes = next.catch(() => undefined)
return next
}
function requireServer(current: Registry, id: string): StoredServer {
const server = current.servers.find(server => server.id === id)
if (!server) throw new Error('MCP 服务不存在')
return server
}
function view(server: StoredServer): McpServerView {
const { secrets: _secret, trustedHash, ...publicFields } = server
const runtime = runtimes.get(server.id)
return { ...clone(publicFields), trusted: trustedHash === server.configHash,
status: runtime?.status || 'disconnected', ...(runtime?.error ? { error: runtime.error } : {}) }
}
function publicError(error: unknown, operation: 'connect' | 'call'): string {
const message = error instanceof Error ? error.message : ''
if (/401|403|unauthoriz|forbidden/i.test(message)) return 'MCP 鉴权失败,请检查凭据与权限'
if (/timeout|timed out/i.test(message)) return 'MCP 请求超时,请稍后重试'
return operation === 'connect' ? '无法连接 MCP 服务,请检查命令、地址和运行环境' : 'MCP 工具调用失败'
}
async function closeRuntime(id: string): Promise<void> {
const runtime = runtimes.get(id)
if (!runtime) return
runtimes.delete(id)
runtime.controller.abort()
// Close aborts all pending requests before best-effort remote session cleanup.
if (runtime.transport instanceof StreamableHTTPClientTransport) {
await Promise.race([runtime.transport.terminateSession().catch(() => undefined), new Promise(resolve => setTimeout(resolve, 500))])
}
await runtime.client.close().catch(() => undefined)
}
export async function listMcpServers(): Promise<McpServerView[]> { await writes; return (await load()).servers.map(view) }
export async function saveMcpServer(input: McpServerInput): Promise<McpServerView> {
return mutate(async current => {
const previous = input.id ? requireServer(current, input.id) : undefined, next = normalize(input, previous)
if (!previous && current.servers.length >= MAX_SERVERS) throw new Error('MCP 服务数量已达上限')
if (current.servers.some(server => server.id !== next.id && server.name === next.name)) throw new Error('同名 MCP 已存在')
await persist({ version: 1, servers: previous ? current.servers.map(server => server.id === next.id ? next : server) : [...current.servers, next] })
if (previous && (previous.configHash !== next.configHash || !next.enabled)) await closeRuntime(next.id)
return view(next)
})
}
export async function trustMcpServer(id: string, configHash: string): Promise<void> {
await mutate(async current => {
const server = requireServer(current, id)
if (server.configHash !== configHash) throw new Error('MCP 配置已变化,请重新查看并批准')
await persist({ ...current, servers: current.servers.map(item => item.id === id ? { ...item, trustedHash: configHash } : item) })
})
}
export async function connectMcpServer(id: string): Promise<McpServerView> {
await writes
const server = requireServer(await load(), id)
if (!server.enabled) throw new Error('MCP 服务已禁用')
if (server.trustedHash !== server.configHash) throw new Error('请先批准当前 MCP 配置')
const existing = runtimes.get(id)
if (existing?.status === 'connected' && existing.configHash === server.configHash) return view(server)
if (existing?.flight && existing.configHash === server.configHash) return existing.flight
if (existing) await closeRuntime(id)
await writes
const current = requireServer(await load(), id)
if (current.configHash !== server.configHash || !current.enabled || current.trustedHash !== current.configHash) throw new Error('MCP 配置已变化,请重试')
const values = readSecrets(server), client = new Client({ name: 'YCode', version: '0.1.0' })
const transport: Transport = server.transport === 'stdio'
? new StdioClientTransport({ command: server.command!, args: server.args, cwd: server.cwd, env: values.env, stderr: 'ignore' })
: new StreamableHTTPClientTransport(new URL(server.url!), { requestInit: { headers: values.headers, redirect: 'error' },
// Keep credentials on the configured origin. GET streams also use this guarded fetch.
fetch: async (input, init) => {
const target = new URL(input instanceof Request ? input.url : String(input)), configured = new URL(server.url!)
if (target.origin !== configured.origin) throw new Error('MCP origin changed')
return fetch(input, { ...init, redirect: 'error' })
} })
const runtime: Runtime = { generation: randomUUID(), configHash: server.configHash, status: 'connecting', client, transport,
controller: new AbortController(), secretValues: [...Object.values(values.env), ...Object.values(values.headers),
...Object.values(values.headers).filter(value => /^Bearer\s+/i.test(value)).map(value => value.replace(/^Bearer\s+/i, ''))].filter(Boolean) }
runtimes.set(id, runtime)
client.onclose = () => { if (runtimes.get(id) === runtime && runtime.status === 'connected') { runtime.status = 'error'; runtime.error = 'MCP 服务连接已断开' } }
client.onerror = () => {} // Do not forward upstream payloads/credentials into logs or the renderer.
runtime.flight = (async () => {
try {
await client.connect(transport, { signal: runtime.controller.signal, timeout: CONNECT_TIMEOUT })
const tools: McpToolView[] = [], names = new Set<string>(), cursors = new Set<string>()
let cursor: string | undefined
do {
const page = await client.listTools(cursor ? { cursor } : undefined, { signal: runtime.controller.signal, timeout: CONNECT_TIMEOUT })
for (const tool of page.tools) {
if (names.has(tool.name)) continue
names.add(tool.name)
if (tools.length >= MAX_TOOLS) throw Error('MCP tool budget exceeded')
if (runtime.secretValues.some(secret => tool.name.includes(secret))) throw Error('MCP tool name contains a credential')
tools.push({ name: tool.name, description: mask(tool.description || '', runtime.secretValues) as string, inputSchema: mask(tool.inputSchema, runtime.secretValues) as Record<string, unknown>,
enabled: server.tools.find(previous => previous.name === tool.name)?.enabled !== false })
}
cursor = page.nextCursor
if (cursor && cursors.has(cursor)) throw Error('MCP repeated pagination cursor')
if (cursor) cursors.add(cursor)
} while (cursor)
await mutate(async current => {
const latest = requireServer(current, id)
if (runtimes.get(id) !== runtime || latest.configHash !== runtime.configHash || !latest.enabled || latest.trustedHash !== latest.configHash) throw abortError()
// A concurrent tool toggle wins over an older list result.
const merged = tools.map(tool => ({ ...tool, enabled: latest.tools.find(previous => previous.name === tool.name)?.enabled ?? tool.enabled }))
await persist({ ...current, servers: current.servers.map(item => item.id === id ? { ...item, tools: merged } : item) })
runtime.status = 'connected'; runtime.error = undefined
})
} catch (error) {
await client.close().catch(() => undefined)
if (runtimes.get(id) === runtime) { runtime.status = runtime.controller.signal.aborted ? 'disconnected' : 'error'; runtime.error = runtime.controller.signal.aborted ? undefined : publicError(error, 'connect') }
} finally { runtime.flight = undefined }
const latest = (await load()).servers.find(server => server.id === id)
if (!latest) throw new Error('MCP 服务已移除')
return view(latest)
})()
return runtime.flight
}
export async function disconnectMcpServer(id: string): Promise<void> { await writes; requireServer(await load(), id); await closeRuntime(id) }
export async function setMcpEnabled(id: string, enabled: boolean): Promise<void> {
await mutate(async current => {
const server = requireServer(current, id)
if (enabled && server.trustedHash !== server.configHash) throw new Error('请先批准当前 MCP 配置')
await persist({ ...current, servers: current.servers.map(item => item.id === id ? { ...item, enabled } : item) })
if (!enabled) await closeRuntime(id)
})
if (enabled) await connectMcpServer(id)
}
export async function setMcpToolEnabled(id: string, toolName: string, enabled: boolean): Promise<void> {
await mutate(async current => {
const server = requireServer(current, id)
if (!server.tools.some(tool => tool.name === toolName)) throw new Error('MCP 工具不存在')
await persist({ ...current, servers: current.servers.map(item => item.id === id ? { ...item, tools: item.tools.map(tool => tool.name === toolName ? { ...tool, enabled } : tool) } : item) })
})
}
export async function removeMcpServer(id: string): Promise<void> {
await mutate(async current => { requireServer(current, id); await persist({ ...current, servers: current.servers.filter(item => item.id !== id) }); await closeRuntime(id) })
}
export async function disposeMcpServers(): Promise<void> { await Promise.all([...runtimes.keys()].map(closeRuntime)) }
/** Startup reconnect is background work; ordinary sends never await unavailable MCP servers. */
export async function initializeMcpServers(): Promise<void> {
await writes
for (const server of (await load()).servers) if (server.enabled && server.trustedHash === server.configHash) void connectMcpServer(server.id).catch(() => undefined)
}
function mask(value: unknown, secrets: string[]): unknown {
if (typeof value === 'string') { for (const secret of secrets) if (secret) value = (value as string).split(secret).join('[已隐藏]'); return value }
if (Array.isArray(value)) return value.map(item => mask(item, secrets))
return object(value) ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mask(item, secrets)])) : value
}
/** Namespace and connection generation are captured once; changed configurations fail closed. */
export async function snapshotMcpTools(): Promise<{ specs: ToolSpec[]; describe(name: string): { serverName: string; toolName: string } | undefined; execute(name: string, args: Record<string, unknown>, signal: AbortSignal): Promise<{ ok: boolean; content: string; data?: unknown }> }> {
await writes
await load()
const specs: ToolSpec[] = [], mappings = new Map<string, { id: string; serverName: string; tool: string; runtime: Runtime }>()
for (const server of (await load()).servers) {
const runtime = runtimes.get(server.id)
if (!server.enabled || server.trustedHash !== server.configHash || runtime?.status !== 'connected') continue
for (const tool of server.tools.filter(tool => tool.enabled)) {
const suffix = createHash('sha256').update(server.id + '\0' + tool.name).digest('hex').slice(0, 16)
const prefix = tool.name.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 24) || 'tool', name = `mcp_${prefix}_${suffix}`
specs.push({ type: 'function', function: { name, description: `[${server.name}] ${tool.description}`.slice(0, 8000), parameters: clone(tool.inputSchema) } })
mappings.set(name, { id: server.id, serverName: server.name, tool: tool.name, runtime })
}
}
return { specs, describe: name => { const mapping = mappings.get(name); return mapping ? { serverName: mapping.serverName, toolName: mapping.tool } : undefined }, execute: async (name, args, signal) => {
if (signal.aborted) throw abortError()
const mapping = mappings.get(name)
if (!mapping) return { ok: false, content: 'MCP 工具不在本回合快照中' }
await writes
const server = (await load()).servers.find(server => server.id === mapping.id), runtime = mapping.runtime
if (!server || !server.enabled || server.trustedHash !== server.configHash || server.configHash !== runtime.configHash
|| runtimes.get(mapping.id) !== runtime || runtime.status !== 'connected' || !server.tools.some(tool => tool.name === mapping.tool && tool.enabled)) return { ok: false, content: 'MCP 配置或连接已变化,请重新发起请求' }
try {
const result = await runtime.client.callTool({ name: mapping.tool, arguments: args }, undefined,
{ signal: AbortSignal.any([signal, runtime.controller.signal]), timeout: CALL_TIMEOUT })
const data = mask(result, runtime.secretValues) as Record<string, unknown>
const content = Array.isArray(data.content) ? data.content.map(part => object(part) && part.type === 'text' && typeof part.text === 'string' ? part.text : JSON.stringify(part)).join('\n') : JSON.stringify(data)
return { ok: !result.isError, content, data }
} catch (error) {
if (signal.aborted) throw abortError()
if (runtime.controller.signal.aborted) return { ok: false, content: 'MCP 配置或连接已变化,请重新发起请求' }
return { ok: false, content: publicError(error, 'call') }
}
} }
}
+92
View File
@@ -0,0 +1,92 @@
import type { ToolSpec } from '@shared/types'
import type { ConversationToolContext, ConversationToolResult } from '../agent/conversation-tools'
import { listSkills, buildSkillContext, loadSkillContext, readSkillResource } from './skills'
import { snapshotMcpTools } from './mcp'
import { expertManagerDocument, expertManagerAdapter, expertManagerResource, expertPackageOperation, expertTurnContext } from './experts'
import { agentMode, agentModeInstructions, modeToolDenial, modeToolSpecs } from '../agent/mode-policy'
import type { AgentMode } from '@shared/conversation'
export interface CapabilityToolSnapshot {
specs: ToolSpec[]
metadata?(name: string): Record<string, unknown> | undefined
execute(name: string, args: Record<string, unknown>, ctx: ConversationToolContext): Promise<ConversationToolResult | undefined>
}
export interface CapabilitySnapshot extends CapabilityToolSnapshot {
context: string
references: Array<{ id: string; name: string; uri: string }>
assistantDisplay?: import('@shared/capabilities').ExpertBinding
}
/** A turn retains stable capability IDs and MCP connection generations. */
export async function createCapabilitySnapshot(ids: string[], cwd: string, options: { sessionId?: string; expertId?: string | null; agentMode?: AgentMode } = {}): Promise<CapabilitySnapshot> {
const mode = agentMode(options.agentMode)
if (!Array.isArray(ids) || ids.length > 32 || ids.some(id => typeof id !== 'string')) throw new Error('技能引用无效')
const selected = [...new Set(ids)]
const installed = await listSkills(cwd)
const references = selected.map(id => {
if (id === 'builtin:expert-manager') return { id, name: 'expert-manager', uri: 'skill://expert-manager' }
const skill = installed.find(item => item.id === id)
if (!skill?.enabled || !skill.userInvocable) throw new Error('引用的技能已禁用或不可手动调用')
return { id, name: skill.name, uri: `skill://${encodeURIComponent(skill.name)}` }
})
const managerId = 'builtin:expert-manager'
let managerLoaded = selected.includes(managerId)
const managerContext = (): string => `<skill id="${managerId}" name="expert-manager">\n${expertManagerDocument().content}\n</skill>${expertManagerAdapter(options.sessionId ?? '')}`
const skillContext = [await buildSkillContext(selected.filter(id => id !== managerId), cwd), managerLoaded ? managerContext() : ''].filter(Boolean).join('\n\n')
const available = installed.filter(item => item.enabled && !item.disableModelInvocation)
const allowedIds = new Set([...selected, ...available.map(item => item.id), managerId])
const mcp = await snapshotMcpTools()
// Snapshot first: a concurrent MCP reconfiguration invalidates these generations,
// so dependency checks can never authorize a newly substituted connection.
const activeExpert = options.expertId ? await expertTurnContext(options.expertId) : undefined
const specs: ToolSpec[] = [...mcp.specs]
if (options.sessionId) specs.push({ type: 'function', function: { name: 'ycode_expert_package', description: '创建或编辑专家包:read 读取当前包;write 增量保存包内文件;validate 校验;register 原子注册到我的专家。冲突后使用 read(refresh:true) 重新读取最新包。必须先加载 expert-manager 技能。', parameters: { type: 'object', properties: { id: { type: 'string' }, action: { type: 'string', enum: ['read', 'write', 'validate', 'register'] }, refresh: { type: 'boolean' }, files: { type: 'object', additionalProperties: { anyOf: [{ type: 'string' }, { type: 'null' }] } } }, required: ['id', 'action'] } } })
if (activeExpert) specs.push({ type: 'function', function: { name: 'ycode_expert_resource', description: '读取当前激活专家包内的 UTF-8 文本资源。', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } })
if (allowedIds.size) {
specs.push({ type: 'function', function: { name: 'ycode_load_skill', description: '加载可用技能的完整指令。开始对应任务前先加载技能,遵循其流程。', parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } })
specs.push({ type: 'function', function: { name: 'ycode_read_skill_resource', description: '读取技能包中的文本资源。path 是包内相对路径。', parameters: { type: 'object', properties: { id: { type: 'string' }, path: { type: 'string' } }, required: ['id', 'path'] } } })
}
const names = new Set(mcp.specs.map(spec => spec.function.name))
return {
specs: modeToolSpecs(specs, mode),
references,
assistantDisplay: activeExpert?.display,
metadata: name => { const identity = mcp.describe(name); return identity ? { capability: 'mcp', ...identity } : undefined },
context: [activeExpert?.context ?? (options.expertId === null ? '<active-expert disabled="true">当前会话已移除专家,后续回复使用默认助手身份,不再沿用之前专家的角色指令。</active-expert>' : ''), skillContext, `可自动调用的技能(先用 ycode_load_skill 加载):\n${[...available.map(item => ({ id: item.id, name: item.name, description: item.description })), { id: managerId, name: 'expert-manager', description: '创建、转换、修改、校验本地专家包。' }].map(item => JSON.stringify(item)).join('\n')}`, agentModeInstructions(mode)].filter(Boolean).join('\n\n'),
async execute(name, args, ctx) {
if (ctx.signal.aborted) throw Object.assign(new Error('回合已取消'), { name: 'AbortError' })
const denial = modeToolDenial(mode, name)
if (denial) return denial
if (name === 'ycode_expert_package') {
if (!managerLoaded || !options.sessionId) return { ok: false, content: '请先加载完整 expert-manager 技能' }
try { return { ok: true, content: JSON.stringify(await expertPackageOperation(options.sessionId, args)), metadata: { capability: 'expert-package', action: args.action, expertId: args.id } } } catch (e) { return { ok: false, content: e instanceof Error ? e.message : String(e) } }
}
if (name === 'ycode_expert_resource' && options.expertId) {
const resource = typeof args.path === 'string' ? args.path.replace(/^\.\//, '') : ''
const content = activeExpert && Object.hasOwn(activeExpert.resources, resource) ? activeExpert.resources[resource] : undefined
return typeof content !== 'string' ? { ok: false, content: '当前专家包中没有此文本资源' } : { ok: true, content }
}
if (name === 'ycode_load_skill' || name === 'ycode_read_skill_resource') {
if (typeof args.id !== 'string' || !allowedIds.has(args.id)) return { ok: false, content: '技能不在当前回合可用范围内' }
if (args.id === managerId) {
if (name === 'ycode_load_skill') { managerLoaded = true; return { ok: true, content: managerContext() } }
return { ok: true, content: expertManagerResource(String(args.path ?? '')) }
}
const content = name === 'ycode_load_skill' ? await loadSkillContext(args.id, cwd) : await readSkillResource(args.id, String(args.path ?? ''), cwd)
return { ok: true, content }
}
if (!names.has(name)) return undefined
if (ctx.permissionMode !== 'full-access') {
if (!ctx.interactions) return { ok: false, content: 'MCP 调用需要用户确认' }
const identity = mcp.describe(name)
const decision = await ctx.interactions.approval(ctx.toolCallId, identity ? `调用 ${identity.serverName} 的 ${identity.toolName} 工具` : `调用连接器工具 ${name}`, [
{ optionId: 'allow-once', name: '允许一次', kind: 'allow_once' },
{ optionId: 'reject-once', name: '拒绝', kind: 'reject_once' }
])
if (decision.optionId !== 'allow-once') return { ok: false, content: '用户拒绝了连接器调用' }
}
const result = await mcp.execute(name, args, ctx.signal)
return { ...result, metadata: { capability: 'mcp', ...mcp.describe(name) } }
}
}
}
+337
View File
@@ -0,0 +1,337 @@
import { app } from 'electron'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { parseDocument } from 'yaml'
import { Unzip, UnzipInflate, zipSync } from 'fflate'
import type { SkillDocument, SkillRecord } from '@shared/capabilities'
import { expertManagerDocument, expertManagerResource } from './experts'
const MAX_PACKAGE = 50 * 1024 * 1024, MAX_ARCHIVE = 20 * 1024 * 1024, MAX_FILES = 2000, MAX_DOCUMENT = 1024 * 1024
interface Entry { id: string; name: string; folder: string; enabled: boolean; digest: string }
interface Registry { version: 1; entries: Entry[]; disabledWorkspace: string[] }
interface PackageFile { name: string; data: Uint8Array }
let operations = Promise.resolve()
function serialized<T>(run: () => Promise<T>): Promise<T> {
const next = operations.then(run); operations = next.then(() => {}, () => {}); return next
}
const hash = (value: string | Uint8Array): string => createHash('sha256').update(value).digest('hex')
const rootPath = (): string => path.join(app.getPath('userData'), 'capabilities', 'skills')
function within(root: string, target: string): boolean {
const relative = path.relative(root, target)
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep))
}
function relativeName(value: string): string {
if (!value || value.includes('\\') || value.startsWith('/') || /[\x00-\x1f\x7f<>:"|?*]/u.test(value)) throw Error('技能包包含非法相对路径')
const parts = value.replace(/\/$/, '').split('/')
if (parts.some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw Error('技能包包含不安全的文件路径')
return parts.join('/')
}
/** Check every component before following it; no installed or discovered symlink is trusted. */
async function checked(root: string, relative: string): Promise<string> {
const target = path.resolve(root, relative)
if (!within(root, target)) throw Error('技能资源路径越界')
let current = root
if ((await fs.lstat(root)).isSymbolicLink()) throw Error('技能目录不能是符号链接')
for (const part of path.relative(root, target).split(path.sep).filter(Boolean)) {
current = path.join(current, part)
if ((await fs.lstat(current)).isSymbolicLink()) throw Error('技能包不能包含符号链接')
}
const actualRoot = await fs.realpath(root), actual = await fs.realpath(target)
if (!within(actualRoot, actual)) throw Error('技能资源路径越界')
return actual
}
async function ensureRoot(): Promise<string> {
const root = rootPath()
let current = path.parse(path.resolve(root)).root
// Validate an ancestor before creating the next component, not after following a symlink.
for (const segment of path.relative(current, path.join(root, 'packages')).split(path.sep)) {
current = path.join(current, segment)
try {
const stat = await fs.lstat(current)
if (stat.isSymbolicLink() || !stat.isDirectory()) throw Error('技能存储目录不能包含符号链接或文件')
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
await fs.mkdir(current).catch(async cause => { if ((cause as NodeJS.ErrnoException).code !== 'EEXIST') throw cause })
const stat = await fs.lstat(current)
if (stat.isSymbolicLink() || !stat.isDirectory()) throw Error('技能存储路径被重定向')
}
}
return root
}
async function registry(): Promise<Registry> {
const root = await ensureRoot()
try {
const file = await checked(root, 'registry.json'), parsed = JSON.parse(await fs.readFile(file, 'utf8')) as Registry
if (parsed.version !== 1 || !Array.isArray(parsed.entries) || !Array.isArray(parsed.disabledWorkspace) || parsed.entries.some(entry => !/^user:[a-f0-9]{24}$/.test(entry.id) || !/^[a-f0-9]{24}$/.test(entry.folder) || typeof entry.name !== 'string' || typeof entry.enabled !== 'boolean')) throw Error('技能目录索引格式无效')
return parsed
} catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, entries: [], disabledWorkspace: [] }; throw error }
}
async function saveRegistry(value: Registry): Promise<void> {
const root = await ensureRoot(), temp = path.join(root, '.registry-' + randomUUID())
try { await fs.writeFile(temp, JSON.stringify(value, null, 2), { flag: 'wx' }); await fs.rename(temp, path.join(root, 'registry.json')) }
finally { await fs.unlink(temp).catch(() => {}) }
}
function metadata(content: string): { name: string; description: string; version?: string; enabled: boolean; userInvocable: boolean; disableModelInvocation: boolean } {
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content)
if (!match) throw Error('SKILL.md 缺少 YAML 元数据')
const document = parseDocument(match[1], { uniqueKeys: true, schema: 'core' })
if (document.errors.length) throw Error('SKILL.md YAML 元数据无效')
let value: unknown
try { value = document.toJS({ maxAliasCount: 50 }) } catch { throw Error('SKILL.md YAML 引用过多') }
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('SKILL.md 元数据必须是对象')
const data = value as Record<string, unknown>
if (typeof data.name !== 'string' || !data.name.trim() || data.name.trim().length > 128) throw Error('SKILL.md 需要有效 name')
if (typeof data.description !== 'string' || !data.description.trim() || data.description.length > 8192) throw Error('SKILL.md 需要有效 description')
for (const key of ['enabled', 'disable', 'user-invocable', 'userInvocable', 'disable-model-invocation', 'disableModelInvocation']) if (data[key] !== undefined && typeof data[key] !== 'boolean') throw Error(`SKILL.md 的 ${key} 必须是布尔值`)
return { name: data.name.trim(), description: data.description.trim(), version: typeof data.version === 'string' || typeof data.version === 'number' ? String(data.version) : undefined,
enabled: data.enabled !== false && data.disable !== true, userInvocable: (data['user-invocable'] ?? data.userInvocable) !== false,
disableModelInvocation: (data['disable-model-invocation'] ?? data.disableModelInvocation) === true }
}
function text(bytes: Uint8Array): string {
if (bytes.includes(0)) throw Error('技能资源不是文本文件')
try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch { throw Error('技能文档必须是 UTF-8 文本') }
}
async function packageFiles(root: string): Promise<PackageFile[]> {
const files: PackageFile[] = []; let total = 0
await checked(root, '')
async function visit(relative: string): Promise<void> {
const directory = await checked(root, relative)
for (const item of await fs.readdir(directory, { withFileTypes: true })) {
const name = relativeName(relative ? relative + '/' + item.name : item.name), file = await checked(root, name)
if (item.isDirectory()) { await visit(name); continue }
if (!item.isFile()) throw Error('技能包只能包含普通文件和文件夹')
const stat = await fs.stat(file)
total += stat.size
if (total > MAX_PACKAGE || files.length >= MAX_FILES) throw Error('技能包超过大小或文件数量限制')
const data = await fs.readFile(file)
files.push({ name, data })
}
}
await visit(''); return files
}
function packageDigest(files: PackageFile[]): string {
const digest = createHash('sha256')
for (const file of [...files].sort((a, b) => a.name.localeCompare(b.name))) digest.update(file.name).update('\0').update(hash(file.data)).update('\n')
return digest.digest('hex')
}
/** Inspect ZIP central-directory sizes/types/paths before decompressing any entry. */
function archiveFiles(bytes: Buffer): PackageFile[] {
if (bytes.length > MAX_ARCHIVE) throw Error('技能 ZIP 文件超过 20MB')
let end = -1
for (let position = bytes.length - 22; position >= Math.max(0, bytes.length - 65557); position--) if (bytes.readUInt32LE(position) === 0x06054b50 && position + 22 + bytes.readUInt16LE(position + 20) === bytes.length) { end = position; break }
if (end < 0 || bytes.readUInt16LE(end + 4) !== 0 || bytes.readUInt16LE(end + 6) !== 0) throw Error('技能 ZIP 格式不受支持')
const count = bytes.readUInt16LE(end + 10), start = bytes.readUInt32LE(end + 16), size = bytes.readUInt32LE(end + 12)
if (!count || count > MAX_FILES || start + size > end || count !== bytes.readUInt16LE(end + 8)) throw Error('技能 ZIP 目录无效')
let cursor = start, total = 0; const names = new Set<string>(), declared = new Map<string, number>()
for (let i = 0; i < count; i++) {
if (cursor + 46 > end || bytes.readUInt32LE(cursor) !== 0x02014b50) throw Error('技能 ZIP 条目无效')
const flags = bytes.readUInt16LE(cursor + 8), originalSize = bytes.readUInt32LE(cursor + 24), nameLength = bytes.readUInt16LE(cursor + 28), extra = bytes.readUInt16LE(cursor + 30), comment = bytes.readUInt16LE(cursor + 32)
if (cursor + 46 + nameLength + extra + comment > start + size || flags & 1 || originalSize === 0xffffffff) throw Error('不支持加密或 ZIP64 技能包')
const raw = text(bytes.subarray(cursor + 46, cursor + 46 + nameLength)), name = relativeName(raw), mode = bytes.readUInt32LE(cursor + 38) >>> 16, type = mode & 0xf000
if (type !== 0 && type !== 0x8000 && type !== 0x4000) throw Error('技能 ZIP 不能包含符号链接或设备')
const key = name.normalize('NFKC').toLowerCase()
if (names.has(key)) throw Error('技能 ZIP 包含重复路径')
names.add(key); declared.set(raw, originalSize); total += originalSize
if (total > MAX_PACKAGE) throw Error('技能 ZIP 解压后超过 50MB')
cursor += 46 + nameLength + extra + comment
}
if (cursor !== start + size) throw Error('技能 ZIP 目录不完整')
const files: PackageFile[] = [], seen = new Set<string>(); let actualTotal = 0, completed = 0
const stream = new Unzip(entry => {
const expected = declared.get(entry.name), name = relativeName(entry.name)
if (expected === undefined || seen.has(entry.name)) throw Error('技能 ZIP 本地目录不匹配')
seen.add(entry.name)
const chunks: Uint8Array[] = []; let length = 0
entry.ondata = (error, chunk, final) => {
if (error) throw error
length += chunk.length; actualTotal += chunk.length
if (length > expected || actualTotal > MAX_PACKAGE) throw Error('技能 ZIP 实际解压大小超过声明或限制')
chunks.push(chunk)
if (final) {
if (length !== expected) throw Error('技能 ZIP 实际解压大小与声明不符')
completed++
if (!entry.name.endsWith('/')) {
const data = new Uint8Array(length); let position = 0
for (const part of chunks) { data.set(part, position); position += part.length }
files.push({ name, data })
}
}
}
entry.start()
})
stream.register(UnzipInflate)
// Bound each inflation step as well as cumulative output; distrust claimed expanded sizes.
try { for (let offset = 0; offset < bytes.length; offset += 4096) stream.push(bytes.subarray(offset, offset + 4096), offset + 4096 >= bytes.length) }
catch (error) { if (error instanceof Error && error.message.startsWith('技能 ZIP')) throw error; throw Error('技能 ZIP 解压失败') }
if (completed !== declared.size) throw Error('技能 ZIP 文件不完整')
// Accept a direct package or one enclosing directory; never pick an arbitrary nested skill.
if (files.some(file => file.name === 'SKILL.md')) return files
const first = files[0]?.name.split('/')[0]
if (first && files.every(file => file.name.startsWith(first + '/')) && files.some(file => file.name === first + '/SKILL.md')) return files.map(file => ({ ...file, name: file.name.slice(first.length + 1) }))
throw Error('技能 ZIP 需要根目录 SKILL.md 或单个技能文件夹')
}
async function fromPath(input: string): Promise<PackageFile[]> {
const source = path.resolve(input), stat = await fs.lstat(source)
if (stat.isSymbolicLink()) throw Error('不能从符号链接导入技能')
// Check source ancestors, including a selected SKILL.md's parent.
await checked(path.parse(source).root, path.relative(path.parse(source).root, source))
if (stat.isDirectory()) return packageFiles(source)
if (!stat.isFile()) throw Error('请选择技能文件夹或 ZIP 文件')
if (path.basename(source) === 'SKILL.md') return packageFiles(path.dirname(source))
if (path.extname(source).toLowerCase() !== '.zip') throw Error('请选择技能文件夹或 ZIP 文件')
if (stat.size > MAX_ARCHIVE) throw Error('技能 ZIP 文件超过 20MB')
return archiveFiles(await fs.readFile(source))
}
function record(id: string, folder: string, meta: ReturnType<typeof metadata>, scope: SkillRecord['scope'], enabled: boolean, source: SkillRecord['source'], userModified: boolean): SkillRecord {
return { id, ...meta, path: folder, scope, enabled: enabled && meta.enabled, source, userModified }
}
function invalidRecord(id: string, name: string, folder: string, scope: SkillRecord['scope'], error: unknown): SkillRecord {
const code = (error as NodeJS.ErrnoException)?.code
const message = code === 'ENOENT' ? '技能包或 SKILL.md 已被移除' : error instanceof Error ? error.message : '技能包无效'
return { id, name, description: '', path: folder, scope, source: scope === 'user' ? 'imported' : 'discovered', enabled: false, userInvocable: false, disableModelInvocation: true, userModified: true, error: message }
}
async function documentAt(folder: string): Promise<string> {
const file = await checked(folder, 'SKILL.md'), stat = await fs.stat(file)
if (!stat.isFile() || stat.size > MAX_DOCUMENT) throw Error('SKILL.md 文件无效或超过 1MB')
return text(await fs.readFile(file))
}
async function listUnlocked(cwd?: string): Promise<SkillRecord[]> {
const state = await registry(), managed = path.join(rootPath(), 'packages'), result: SkillRecord[] = []
for (const entry of state.entries) {
const folder = path.join(managed, entry.folder)
try {
await checked(managed, entry.folder)
const meta = metadata(await documentAt(folder)), files = await packageFiles(folder)
result.push(record(entry.id, folder, meta, 'user', entry.enabled, 'imported', packageDigest(files) !== entry.digest))
} catch (error) { result.push(invalidRecord(entry.id, entry.name, folder, 'user', error)) }
}
if (cwd) {
const workspace = await fs.realpath(path.resolve(cwd)).catch(() => undefined)
if (!workspace) return result
for (const source of ['.agents/skills', '.claude/skills']) {
let directory: string
try { directory = await checked(workspace, source) } catch { continue }
if (!(await fs.stat(directory)).isDirectory()) continue
for (const item of await fs.readdir(directory, { withFileTypes: true })) {
if (!item.isDirectory() || item.isSymbolicLink()) continue
const folder = path.join(directory, item.name), id = 'workspace:' + hash(folder).slice(0, 24)
try {
await checked(directory, item.name)
const meta = metadata(await documentAt(folder))
await packageFiles(folder)
result.push(record(id, folder, meta, 'workspace', !state.disabledWorkspace.includes(id), 'discovered', false))
} catch (error) { result.push(invalidRecord(id, item.name, folder, 'workspace', error)) }
}
}
}
return result.sort((a, b) => a.scope === b.scope ? a.name.localeCompare(b.name) : a.scope === 'workspace' ? -1 : 1)
}
export function listSkills(cwd?: string): Promise<SkillRecord[]> { return serialized(() => listUnlocked(cwd)) }
async function findSkill(id: string, cwd?: string): Promise<SkillRecord> {
const found = (await listUnlocked(cwd)).find(skill => skill.id === id)
if (!found) throw Error('技能不存在或不属于当前空间')
if (found.error) throw Error(found.error)
return found
}
export function importSkill(source: string, overwrite = false): Promise<SkillRecord> {
return serialized(async () => {
const files = await fromPath(source), main = files.find(file => file.name === 'SKILL.md')
if (!main || main.data.length > MAX_DOCUMENT) throw Error('技能根目录需要有效 SKILL.md')
const meta = metadata(text(main.data)), name = meta.name.normalize('NFKC').toLowerCase(), folder = hash(name).slice(0, 24), id = 'user:' + folder
const state = await registry(), existing = state.entries.find(entry => entry.id === id)
if (existing && !overwrite) throw Error('已存在同名技能,确认替换后重试')
const packages = path.join(rootPath(), 'packages'), destination = path.join(packages, folder), stage = path.join(packages, '.stage-' + randomUUID()), backup = path.join(packages, '.backup-' + randomUUID())
let moved = false, committed = false
await fs.mkdir(stage)
try {
for (const file of files) { const target = path.join(stage, relativeName(file.name)); await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, file.data, { flag: 'wx' }) }
if (existing) {
try { await checked(packages, folder); await fs.rename(destination, backup); moved = true }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
}
await fs.rename(stage, destination)
const entry: Entry = { id, name: meta.name, folder, enabled: existing?.enabled ?? true, digest: packageDigest(files) }
state.entries = [...state.entries.filter(item => item.id !== id), entry]
try { await saveRegistry(state); committed = true } catch (error) { await fs.rm(destination, { recursive: true, force: true }); throw error }
return record(id, destination, meta, 'user', entry.enabled, 'imported', false)
} finally {
await fs.rm(stage, { recursive: true, force: true })
if (moved && !committed) await fs.rename(backup, destination)
if (committed) await fs.rm(backup, { recursive: true, force: true })
}
})
}
export function readSkill(id: string, cwd?: string): Promise<SkillDocument> {
if (id === 'builtin:expert-manager') { const document = expertManagerDocument(); return Promise.resolve({ skill: { id, name: 'expert-manager', description: '创建和修改本地专家包', path: 'builtin://expert-manager', scope: 'user', enabled: true, userInvocable: true, disableModelInvocation: false, source: 'discovered', userModified: false }, ...document }) }
return serialized(async () => { const skill = await findSkill(id, cwd); return { skill, content: await documentAt(skill.path), files: (await packageFiles(skill.path)).map(file => file.name).sort() } })
}
export function setSkillEnabled(id: string, enabled: boolean, cwd?: string): Promise<void> {
return serialized(async () => {
const skill = await findSkill(id, cwd), state = await registry()
if (skill.scope === 'workspace') state.disabledWorkspace = [...state.disabledWorkspace.filter(item => item !== id), ...enabled ? [] : [id]]
else state.entries = state.entries.map(entry => entry.id === id ? { ...entry, enabled } : entry)
await saveRegistry(state)
})
}
export function removeSkill(id: string): Promise<void> {
return serialized(async () => {
const state = await registry(), entry = state.entries.find(item => item.id === id)
if (!entry) throw Error('只能卸载受管技能副本,空间技能请在其源目录管理')
const packages = path.join(rootPath(), 'packages'), target = path.join(packages, entry.folder), tomb = path.join(packages, '.remove-' + randomUUID())
await checked(packages, '')
let moved = false, symlink = false
try { symlink = (await fs.lstat(target)).isSymbolicLink(); await fs.rename(target, tomb); moved = true }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
try { await saveRegistry({ ...state, entries: state.entries.filter(item => item.id !== id) }) } catch (error) { if (moved) await fs.rename(tomb, target); throw error }
if (moved) { if (symlink) await fs.unlink(tomb); else await fs.rm(tomb, { recursive: true, force: true }) }
})
}
export function exportSkill(id: string, destination: string): Promise<string> {
return serialized(async () => {
const skill = await findSkill(id), files = await packageFiles(skill.path), target = path.resolve(destination)
if (!target.toLowerCase().endsWith('.zip')) throw Error('技能导出目标必须是 ZIP 文件')
const parent = path.dirname(target)
await checked(path.parse(parent).root, path.relative(path.parse(parent).root, parent))
if (within(rootPath(), target) || within(skill.path, target)) throw Error('不能导出覆盖技能存储目录')
const bytes = zipSync(Object.fromEntries(files.map(file => [file.name, file.data])))
// Export never overwrites an existing file or follows a destination symlink.
await fs.writeFile(target, bytes, { flag: 'wx' }); return target
})
}
function context(skill: SkillRecord, markdown: string, files: string[]): string {
if (markdown.length > 65536) throw Error('技能文档过长,请通过技能资源分段读取')
return `\n<skill id=${JSON.stringify(skill.id)} name=${JSON.stringify(skill.name)}>\n${markdown}\n\nAvailable package resources (relative paths):\n${files.map(file => '- ' + file).join('\n')}\n</skill>\n`
}
export function buildSkillContext(ids: string[], cwd: string): Promise<string> {
return serialized(async () => {
let result = ''
for (const id of new Set(ids)) {
const skill = await findSkill(id, cwd)
if (!skill.enabled || !skill.userInvocable) throw Error('该技能已禁用或不允许手动调用')
result += context(skill, await documentAt(skill.path), (await packageFiles(skill.path)).map(file => file.name))
if (result.length > 131072) throw Error('所选技能上下文过长')
}
return result
})
}
export function buildAvailableSkills(cwd: string): Promise<string> {
return serialized(async () => JSON.stringify((await listUnlocked(cwd)).filter(skill => skill.enabled && !skill.disableModelInvocation).map(skill => ({ id: skill.id, name: skill.name, description: skill.description }))))
}
export function loadSkillContext(id: string, cwd: string): Promise<string> {
return serialized(async () => {
const skill = await findSkill(id, cwd)
if (!skill.enabled || skill.disableModelInvocation) throw Error('该技能不允许模型自动调用')
return context(skill, await documentAt(skill.path), (await packageFiles(skill.path)).map(file => file.name))
})
}
export function readSkillResource(id: string, resource: string, cwd: string): Promise<string> {
if (id === 'builtin:expert-manager') return Promise.resolve(expertManagerResource(resource))
return serialized(async () => {
const skill = await findSkill(id, cwd)
if (!skill.enabled) throw Error('该技能已禁用')
const file = await checked(skill.path, relativeName(resource)), stat = await fs.stat(file)
if (!stat.isFile() || stat.size > MAX_DOCUMENT) throw Error('技能资源无效或过大')
return text(await fs.readFile(file))
})
}
+155
View File
@@ -0,0 +1,155 @@
/**
* 应用配置持久化。
*
* - 落盘位置:userData/config.json
* - API Key 优先用 Electron safeStorage 加密(Windows 走 DPAPI),
* 若系统不支持则降级为明文并在 UI 上如实标注。
* - 渲染进程只能拿到 GatewayConfigView(无明文 Key)。
*/
import { app, safeStorage } from 'electron'
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import type { GatewayConfig, GatewayConfigView } from '@shared/types'
interface StoredConfig {
speechBaseUrl?: string
speechModel?: string
baseUrl: string
model: string
temperature: number
maxToolRounds: number
apiKeyEnc?: string
apiKeyPlain?: string
workspaceRoot?: string
}
const DEFAULTS: StoredConfig = {
baseUrl: 'https://api.deepseek.com/v1',
model: 'deepseek-chat',
temperature: 0.7,
maxToolRounds: 6
}
let cache: StoredConfig | null = null
function filePath(): string {
return join(app.getPath('userData'), 'config.json')
}
async function load(): Promise<StoredConfig> {
if (cache) return cache
try {
const raw = await fs.readFile(filePath(), 'utf8')
cache = { ...DEFAULTS, ...(JSON.parse(raw) as Partial<StoredConfig>) }
} catch {
cache = { ...DEFAULTS }
}
return cache
}
async function persist(next: StoredConfig): Promise<void> {
cache = next
const file = filePath()
await fs.mkdir(join(file, '..'), { recursive: true })
await fs.writeFile(file, JSON.stringify(next, null, 2), 'utf8')
}
function decryptKey(stored: StoredConfig): { key: string; storage: 'encrypted' | 'plaintext' | 'none' } {
if (stored.apiKeyEnc) {
try {
return { key: safeStorage.decryptString(Buffer.from(stored.apiKeyEnc, 'base64')), storage: 'encrypted' }
} catch {
return { key: '', storage: 'none' }
}
}
if (stored.apiKeyPlain) return { key: stored.apiKeyPlain, storage: 'plaintext' }
return { key: '', storage: 'none' }
}
/** 供主进程内部使用:拿到可直接请求上游的完整配置 */
export async function getGatewayConfig(): Promise<GatewayConfig> {
const stored = await load()
const { key } = decryptKey(stored)
return {
speechBaseUrl: stored.speechBaseUrl,
speechModel: stored.speechModel,
baseUrl: stored.baseUrl,
model: stored.model,
temperature: stored.temperature,
maxToolRounds: stored.maxToolRounds,
apiKey: key
}
}
export async function getWorkspaceRoot(): Promise<string> {
const stored = await load()
return stored.workspaceRoot || join(app.getPath('home'), 'YCodeWorkspace')
}
export async function setWorkspaceRoot(root: string): Promise<void> {
const stored = await load()
await persist({ ...stored, workspaceRoot: root })
}
/** 供渲染进程使用:脱敏视图 */
export async function getConfigView(): Promise<GatewayConfigView> {
const stored = await load()
const { key, storage } = decryptKey(stored)
return {
speechBaseUrl: stored.speechBaseUrl,
speechModel: stored.speechModel,
baseUrl: stored.baseUrl,
model: stored.model,
temperature: stored.temperature,
maxToolRounds: stored.maxToolRounds,
hasApiKey: key.length > 0,
keyStorage: key.length > 0 ? storage : 'none'
}
}
export interface ConfigPatch {
speechBaseUrl?: string
speechModel?: string
baseUrl?: string
model?: string
temperature?: number
maxToolRounds?: number
/** 传空字符串表示清除 Key;不传表示保持原值 */
apiKey?: string
}
export async function updateConfig(patch: ConfigPatch): Promise<GatewayConfigView> {
const stored = await load()
const next: StoredConfig = { ...stored }
if (patch.speechBaseUrl !== undefined) next.speechBaseUrl = patch.speechBaseUrl.trim()
if (patch.speechModel !== undefined) next.speechModel = patch.speechModel.trim()
if (patch.baseUrl !== undefined) next.baseUrl = patch.baseUrl.trim()
if (patch.model !== undefined) next.model = patch.model.trim()
if (patch.temperature !== undefined) next.temperature = clamp(patch.temperature, 0, 2)
if (patch.maxToolRounds !== undefined) next.maxToolRounds = clamp(Math.round(patch.maxToolRounds), 1, 24)
if (patch.apiKey !== undefined) {
const key = patch.apiKey.trim()
if (!key) {
delete next.apiKeyEnc
delete next.apiKeyPlain
} else if (safeStorage.isEncryptionAvailable()) {
next.apiKeyEnc = safeStorage.encryptString(key).toString('base64')
delete next.apiKeyPlain
} else {
next.apiKeyPlain = key
delete next.apiKeyEnc
}
}
await persist(next)
return getConfigView()
}
function clamp(value: number, min: number, max: number): number {
if (Number.isNaN(value)) return min
return Math.min(max, Math.max(min, value))
}
+179
View File
@@ -0,0 +1,179 @@
/**
* 自建模型网关(OpenAI 兼容)。
*
* 职责:
* 1. 持有上游 baseUrl / apiKey(永不进入渲染进程)
* 2. 按配置地址选择 Chat Completions / Responses + SSE 流式
* 3. 把上游的增量 delta 组装成「文本增量」与「完整工具调用」两类输出
*
* 设计约束:网关只做协议适配与组装,不含业务策略;
* 模型路由、上下文裁剪、工具编排由上层(agent/loop.ts)负责。
*/
import type { GatewayConfig, ToolSpec } from '@shared/types'
import { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
import { parseSse } from './sse'
import { buildResponsesRequest, parseResponsesStream } from './responses'
/** 上游 wire 格式的消息 */
export interface WireMessage {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string | null | Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }>
tool_calls?: WireToolCall[]
tool_call_id?: string
}
export interface WireToolCall {
id: string
type: 'function'
function: { name: string; arguments: string }
}
/** 网关输出的增量事件 */
export type GatewayDelta =
| { type: 'text'; text: string }
| { type: 'reasoning'; text: string }
| { type: 'tool_calls'; calls: WireToolCall[] }
| { type: 'finish'; reason: string | null }
export interface GatewayStreamRequest {
messages: WireMessage[]
tools?: ToolSpec[]
signal?: AbortSignal
}
export class GatewayError extends Error {
constructor(
message: string,
readonly status?: number,
readonly body?: string
) {
super(message)
this.name = 'GatewayError'
}
}
export class Gateway {
constructor(private readonly config: GatewayConfig) {}
async *stream(req: GatewayStreamRequest): AsyncGenerator<GatewayDelta, void, void> {
if (!this.config.apiKey && this.config.authMode !== 'none') {
throw new GatewayError('未配置 API Key,请先在设置中填写')
}
if (!this.config.baseUrl) {
throw new GatewayError('未配置上游地址(baseUrl)')
}
let endpoint: ReturnType<typeof resolveOpenAIEndpoint>
try { endpoint = resolveOpenAIEndpoint(this.config.baseUrl) } catch { throw new GatewayError('上游地址格式无效') }
const responses = endpoint.api === 'openai-responses'
const body: Record<string, unknown> = responses ? buildResponsesRequest(this.config, req) : {
model: this.config.model,
messages: req.messages,
stream: true,
temperature: this.config.temperature
}
if (!responses && this.config.maxOutputTokens) body.max_tokens = this.config.maxOutputTokens
if (!responses && req.tools && req.tools.length > 0) {
body.tools = req.tools
body.tool_choice = 'auto'
}
let res: Response
try {
res = await fetch(endpoint.endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'text/event-stream',
...(this.config.apiKey ? { authorization: `Bearer ${this.config.apiKey}` } : {})
},
body: JSON.stringify(body),
signal: req.signal
})
} catch (err) {
if ((err as Error).name === 'AbortError') throw err
throw new GatewayError(responses ? '无法连接 Responses 上游' : `无法连接上游:${(err as Error).message}`)
}
if (!res.ok || !res.body) {
// A Responses provider can echo request credentials in arbitrary error bodies.
const text = responses ? undefined : await safeReadText(res)
if (responses) await res.body?.cancel().catch(() => {})
throw new GatewayError(
responses ? `Responses 上游返回 HTTP ${res.status}` : `上游返回 ${res.status} ${res.statusText}`.trim(),
res.status,
text
)
}
if (responses) {
yield* parseResponsesStream(res.body, (message) => new GatewayError(message), req.signal)
return
}
// 累加器:按 index 归并流式工具调用分片
const acc = new Map<number, { id: string; name: string; args: string }>()
let finishReason: string | null = null
for await (const evt of parseSse(res.body)) {
if (evt.data === '[DONE]') break
let payload: any
try {
payload = JSON.parse(evt.data)
} catch {
continue // 非 JSON 的心跳/注释行直接跳过
}
if (payload?.error) {
throw new GatewayError(
`上游错误:${payload.error.message ?? JSON.stringify(payload.error)}`
)
}
const choice = payload?.choices?.[0]
if (!choice) continue
const delta = choice.delta ?? {}
if (typeof delta.content === 'string' && delta.content.length > 0) {
yield { type: 'text', text: delta.content }
}
if (typeof delta.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
yield { type: 'reasoning', text: delta.reasoning_content }
}
if (Array.isArray(delta.tool_calls)) {
for (const piece of delta.tool_calls) {
const idx = typeof piece.index === 'number' ? piece.index : 0
const slot = acc.get(idx) ?? { id: '', name: '', args: '' }
if (piece.id) slot.id = piece.id
if (piece.function?.name) slot.name = piece.function.name
if (piece.function?.arguments) slot.args += piece.function.arguments
acc.set(idx, slot)
}
}
if (choice.finish_reason) finishReason = choice.finish_reason
}
const calls: WireToolCall[] = [...acc.entries()]
.sort((a, b) => a[0] - b[0])
.map(([idx, slot]) => ({
id: slot.id || `call_${idx}_${Date.now()}`,
type: 'function' as const,
function: { name: slot.name, arguments: slot.args || '{}' }
}))
if (calls.length > 0) yield { type: 'tool_calls', calls }
yield { type: 'finish', reason: finishReason }
}
}
async function safeReadText(res: Response): Promise<string> {
try {
return (await res.text()).slice(0, 2000)
} catch {
return ''
}
}
+175
View File
@@ -0,0 +1,175 @@
import type { GatewayConfig } from '@shared/types'
import type { GatewayDelta, GatewayStreamRequest, WireMessage, WireToolCall } from './index'
import { parseSse } from './sse'
type InputContent = { type: 'input_text'; text: string } | { type: 'input_image'; image_url: string; detail: 'auto' }
type InputItem = { role: 'system' | 'user' | 'assistant'; content: string | InputContent[] }
| { type: 'function_call'; call_id: string; name: string; arguments: string }
| { type: 'function_call_output'; call_id: string; output: string | InputContent[] }
function content(value: WireMessage['content']): string | InputContent[] {
if (!Array.isArray(value)) return value ?? ''
return value.map(part => part.type === 'text'
? { type: 'input_text', text: part.text }
: { type: 'input_image', image_url: part.image_url.url, detail: 'auto' })
}
/** Stateless history replay; call_id is the public function-call ID, never the output item's ID. */
export function responsesInput(messages: WireMessage[]): InputItem[] {
const input: InputItem[] = []
for (const message of messages) {
if (message.role === 'tool') {
if (!message.tool_call_id) throw new Error('工具结果缺少调用 ID,无法转换为 Responses 输入')
input.push({ type: 'function_call_output', call_id: message.tool_call_id, output: content(message.content) })
continue
}
if (message.content !== null || !message.tool_calls?.length) input.push({ role: message.role, content: content(message.content) })
if (message.role === 'assistant') for (const call of message.tool_calls ?? []) {
input.push({ type: 'function_call', call_id: call.id, name: call.function.name, arguments: call.function.arguments })
}
}
return input
}
export function buildResponsesRequest(config: GatewayConfig, request: GatewayStreamRequest): Record<string, unknown> {
const body: Record<string, unknown> = { model: config.model, input: responsesInput(request.messages), stream: true,
temperature: config.temperature, store: false }
if (config.maxOutputTokens) body.max_output_tokens = config.maxOutputTokens
if (request.tools?.length) {
body.tools = request.tools.map(tool => ({ type: 'function', name: tool.function.name, description: tool.function.description,
parameters: tool.function.parameters, strict: false }))
body.tool_choice = 'auto'
}
return body
}
type Json = Record<string, unknown>
const object = (value: unknown): Json => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Json : {}
const string = (value: unknown): string => typeof value === 'string' ? value : ''
const index = (value: unknown): number | undefined => typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined
interface Call { outputIndex?: number; itemId: string; callId: string; name: string; args: string; final: boolean; status?: string }
/** OpenAI Responses typed SSE, adapted to the existing GatewayDelta contract.
* Only completed responses expose executable calls. Partial/failed/truncated streams throw:
* runTurn ignores finish.reason, so returning normally would incorrectly mark them done.
* Source: https://developers.openai.com/api/reference/resources/responses/streaming-events
*/
export async function* parseResponsesStream(stream: ReadableStream<Uint8Array>, error: (message: string) => Error,
signal?: AbortSignal): AsyncGenerator<GatewayDelta, void, void> {
const calls: Call[] = [], parts = new Map<string, string>(), outputAliases = new Map<number, string>()
let completed = false
const abort = (): void => { if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new DOMException('请求已取消', 'AbortError') }
const itemKey = (payload: Json): string => {
const i = index(payload.output_index), id = string(payload.item_id)
if (id && i !== undefined) {
const before = outputAliases.get(i) ?? `index:${i}`
if (before !== id) for (const [key, value] of parts) if (key.startsWith(before + '|')) { parts.set(id + key.slice(before.length), value); parts.delete(key) }
outputAliases.set(i, id)
}
return id || (i !== undefined ? outputAliases.get(i) ?? `index:${i}` : 'unattributed')
}
const callSlot = (payload: Json, item: Json = {}): Call => {
const i = index(payload.output_index), itemId = string(item.id) || string(payload.item_id), callId = string(item.call_id)
let slot = calls.find(entry => !!itemId && entry.itemId === itemId || !!callId && entry.callId === callId || i !== undefined && entry.outputIndex === i)
if (!slot) { slot = { outputIndex: i, itemId, callId, name: '', args: '', final: false }; calls.push(slot) }
if (i !== undefined) slot.outputIndex = i
if (itemId) slot.itemId = itemId
if (callId) slot.callId = callId
if (typeof item.name === 'string') slot.name = item.name
if (typeof item.status === 'string') slot.status = item.status
return slot
}
// Done/item/completed snapshots contain the entire value: emit only an unseen suffix.
// Divergent snapshots cannot be represented as deltas without corrupting visible text.
function* text(payload: Json, kind: 'text' | 'reasoning', value: string, snapshot = false): Generator<GatewayDelta> {
const key = `${itemKey(payload)}|${kind}|${index(kind === 'reasoning' ? payload.summary_index : payload.content_index) ?? 0}`
const prior = parts.get(key) ?? ''
if (snapshot && !value.startsWith(prior)) throw error('Responses 上游输出快照与流式内容不一致')
const delta = snapshot ? value.slice(prior.length) : value
parts.set(key, snapshot ? value : prior + value)
if (delta) yield { type: kind, text: delta }
}
function* output(payload: Json, item: Json, final: boolean): Generator<GatewayDelta> {
const keyed = { ...payload, item_id: item.id ?? payload.item_id }
itemKey(keyed)
if (item.type === 'function_call') {
const slot = callSlot(keyed, item)
if (final && item.status === undefined) slot.status = 'completed'
if (typeof item.arguments === 'string') {
if (final) { slot.args = item.arguments; slot.final = true }
else if (!slot.args) slot.args = item.arguments
}
return
}
if (item.type === 'message' && Array.isArray(item.content)) {
for (let i = 0; i < item.content.length; i++) {
const part = object(item.content[i])
if (part.type === 'output_text') yield* text({ ...keyed, content_index: i }, 'text', string(part.text), true)
else if (part.type === 'refusal') yield* text({ ...keyed, content_index: i }, 'text', string(part.refusal), true)
}
} else if (item.type === 'reasoning' && Array.isArray(item.summary)) {
for (let i = 0; i < item.summary.length; i++) {
const part = object(item.summary[i])
if (part.type === 'summary_text') yield* text({ ...keyed, summary_index: i }, 'reasoning', string(part.text), true)
}
}
}
abort()
try {
for await (const event of parseSse(stream)) {
abort()
if (event.data === '[DONE]') break
let payload: Json
try { payload = object(JSON.parse(event.data)) } catch { continue }
const type = string(payload.type) || event.event
// Never include a raw provider message/body: some gateways echo supplied credentials.
if (type === 'error' || type === 'response.failed' || payload.error) throw error('Responses 上游生成失败')
if (type === 'response.incomplete') {
const reason = string(object(object(payload.response).incomplete_details).reason)
throw error(reason === 'max_output_tokens' ? 'Responses 上游达到输出长度限制,回复未完成' : reason === 'content_filter' ? 'Responses 上游过滤了部分内容,回复未完成' : 'Responses 上游回复未完成')
}
if (type === 'response.output_text.delta') yield* text(payload, 'text', string(payload.delta))
else if (type === 'response.output_text.done') yield* text(payload, 'text', string(payload.text), true)
else if (type === 'response.refusal.delta') yield* text(payload, 'text', string(payload.delta))
else if (type === 'response.refusal.done') yield* text(payload, 'text', string(payload.refusal), true)
else if (type === 'response.reasoning_summary_text.delta') yield* text(payload, 'reasoning', string(payload.delta))
else if (type === 'response.reasoning_summary_text.done') yield* text(payload, 'reasoning', string(payload.text), true)
else if (type === 'response.function_call_arguments.delta') {
const slot = callSlot(payload)
if (!slot.final) slot.args += string(payload.delta)
} else if (type === 'response.function_call_arguments.done') {
const slot = callSlot(payload)
if (typeof payload.arguments === 'string') { slot.args = payload.arguments; slot.final = true }
} else if (type === 'response.output_item.added' || type === 'response.output_item.done') yield* output(payload, object(payload.item), type.endsWith('.done'))
else if (type === 'response.content_part.done') {
const part = object(payload.part)
if (part.type === 'output_text') yield* text(payload, 'text', string(part.text), true)
else if (part.type === 'refusal') yield* text(payload, 'text', string(part.refusal), true)
} else if (type === 'response.completed') {
const response = object(payload.response)
if (response.error || response.status !== 'completed' || !Array.isArray(response.output)) throw error('Responses 上游未返回合法成功终态')
for (let i = 0; i < response.output.length; i++) yield* output({ output_index: i }, object(response.output[i]), true)
completed = true
break
}
}
abort()
} catch (cause) {
abort()
if (cause instanceof Error && cause.name === 'AbortError') throw cause
// Preserve adapter errors; transport errors contain no provider diagnostics or credentials.
if (cause instanceof Error && cause.name === 'GatewayError') throw cause
throw error('Responses 上游连接中断,回复未完成')
} finally {
// parseSse releases its reader before returning. Stop a lingering HTTP body on early terminal events.
await stream.cancel().catch(() => {})
}
if (!completed) throw error('Responses 流提前结束,未收到完成事件')
const executable: WireToolCall[] = calls.sort((a, b) => (a.outputIndex ?? 0) - (b.outputIndex ?? 0)).map(slot => {
if (!slot.callId || !slot.name || !slot.final || slot.status && slot.status !== 'completed') throw error('Responses 上游工具调用不完整')
try { JSON.parse(slot.args) } catch { throw error('Responses 上游工具参数不是有效 JSON') }
return { id: slot.callId, type: 'function', function: { name: slot.name, arguments: slot.args } }
})
if (executable.length) yield { type: 'tool_calls', calls: executable }
yield { type: 'finish', reason: executable.length ? 'tool_calls' : 'stop' }
}
+69
View File
@@ -0,0 +1,69 @@
/**
* 极简 SSE(text/event-stream)解析器。
*
* 只依赖 Web 标准的 ReadableStream,可直接消费 fetch 响应体。
* 上游既可能是标准 SSE,也可能在末尾缺少空行,因此以「块边界」为准做收尾。
*/
export interface SseEvent {
event?: string
data: string
}
export async function* parseSse(
stream: ReadableStream<Uint8Array>
): AsyncGenerator<SseEvent, void, void> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let index: number
// 事件之间以空行分隔,兼容 \r\n 与 \n
while ((index = findBoundary(buffer)) !== -1) {
const raw = buffer.slice(0, index)
buffer = buffer.slice(index).replace(/^(\r?\n){2}/, '')
const parsed = parseBlock(raw)
if (parsed) yield parsed
}
}
// 收尾:流结束时可能残留最后一个事件
buffer += decoder.decode()
const tail = parseBlock(buffer)
if (tail) yield tail
} finally {
reader.releaseLock()
}
}
function findBoundary(buffer: string): number {
const a = buffer.indexOf('\n\n')
const b = buffer.indexOf('\r\n\r\n')
if (a === -1) return b
if (b === -1) return a
return Math.min(a, b)
}
function parseBlock(block: string): SseEvent | null {
const lines = block.split(/\r?\n/)
const dataLines: string[] = []
let event: string | undefined
for (const line of lines) {
if (!line || line.startsWith(':')) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).replace(/^ /, ''))
}
}
if (dataLines.length === 0) return null
return { event, data: dataLines.join('\n') }
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Electron 主进程入口。
*
* Main uses an ESM bundle; sandboxed preload remains CommonJS.
*/
import { app, BrowserWindow, Menu, shell, ipcMain } from 'electron'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { disposeOmpKernels, registerIpc } from './ipc'
import { disposeMcpServers } from './capabilities/mcp'
let mainWindow: BrowserWindow | null = null
let settingsWindow: BrowserWindow | null = null
let settingsSection = 'model'
let settingsAction: 'new' | undefined
let allowQuit = false
let shutdownPromise: Promise<void> | null = null
const mainDirectory = dirname(fileURLToPath(import.meta.url))
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1335,
height: 916,
minWidth: 480,
minHeight: 640,
show: false,
title: 'YCode',
backgroundColor: '#ffffff',
// 无边框窗口:标题栏与菜单栏由渲染进程自绘(对齐对标形态)
frame: false,
webPreferences: {
preload: join(mainDirectory, '../preload/index.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
offscreen: process.env.YCODE_QA_OFFSCREEN === '1'
}
})
mainWindow.on('ready-to-show', () => mainWindow?.show())
mainWindow.on('closed', () => {
mainWindow = null
})
// 最大化状态变化要通知渲染进程,用于切换按钮图标
const emitState = (): void => {
mainWindow?.webContents.send('window:state', {
maximized: mainWindow.isMaximized(),
fullscreen: mainWindow.isFullScreen()
})
}
mainWindow.on('maximize', emitState)
mainWindow.on('unmaximize', emitState)
mainWindow.on('enter-full-screen', emitState)
mainWindow.on('leave-full-screen', emitState)
mainWindow.on('focus', () => mainWindow?.webContents.send('window:focus', { focused: true }))
mainWindow.on('blur', () => mainWindow?.webContents.send('window:focus', { focused: false }))
// 外链一律交给系统浏览器,不在应用内打开
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url)
return { action: 'deny' }
})
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
void mainWindow.loadURL(devUrl)
} else {
void mainWindow.loadFile(join(mainDirectory, '../renderer/index.html'))
}
}
app.setName('YCode')
// Isolated desktop verification and portable installations can choose their own data directory.
if (process.env.YCODE_USER_DATA_DIR) app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
app.on('before-quit', (event) => {
if (allowQuit) return
event.preventDefault()
if (shutdownPromise) return
shutdownPromise = disposeOmpKernels().finally(() => disposeMcpServers())
void shutdownPromise
.catch(() => undefined)
.then(() => {
allowQuit = true
app.quit()
})
})
// 自绘菜单栏:移除原生应用菜单
Menu.setApplicationMenu(null)
/**
* 受限环境下的分级降级。
*
* 现象:如果本进程是由受限的父进程树启动(例如从某个 Electron 宿主内部再拉起,
* 或系统策略禁止创建受限令牌),Chromium 的 **GPU 进程沙箱**会无法创建子进程,
* 表现为 GPU process 反复以 0xC0000005 崩溃,最终 FATAL 退出应用。
*
* 两个环境变量,按侵入性从小到大:
* YCODE_DISABLE_GPU_SANDBOX=1 仅关闭 GPU 进程沙箱,渲染进程沙箱保留、GPU 仍生效(首选)
* YCODE_DISABLE_GPU=1 完全软件渲染(最后手段,性能有损)
*/
if (process.env.YCODE_DISABLE_GPU === '1') {
app.disableHardwareAcceleration()
app.commandLine.appendSwitch('disable-gpu')
app.commandLine.appendSwitch('disable-gpu-compositing')
app.commandLine.appendSwitch('in-process-gpu')
} else if (process.env.YCODE_DISABLE_GPU_SANDBOX === '1') {
app.commandLine.appendSwitch('disable-gpu-sandbox')
}
app.whenReady().then(() => {
registerIpc(() => mainWindow)
ipcMain.handle('app:openSettings', async (_event, section = 'model', action?: 'new') => {
settingsSection = section === 'models' ? 'model' : String(section)
settingsAction = action === 'new' ? 'new' : undefined
if (settingsWindow && !settingsWindow.isDestroyed()) {
if (!settingsWindow.webContents.isLoading()) settingsWindow.webContents.send('settings:navigate', settingsSection, settingsAction)
settingsWindow.show(); settingsWindow.focus(); return
}
settingsWindow = new BrowserWindow({ width: 900, height: 680, minWidth: 760, minHeight: 560, frame: false, title: 'YCode 设置',
show: false, backgroundColor: '#ffffff', webPreferences: { preload: join(mainDirectory, '../preload/index.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true, offscreen: process.env.YCODE_QA_OFFSCREEN === '1' } })
settingsWindow.on('ready-to-show', () => settingsWindow?.show())
settingsWindow.on('closed', () => { settingsWindow = null })
settingsWindow.webContents.on('did-finish-load', () => settingsWindow?.webContents.send('settings:navigate', settingsSection, settingsAction))
settingsWindow.webContents.setWindowOpenHandler(({ url }) => { void shell.openExternal(url); return { action: 'deny' } })
const query: Record<string, string> = { window: 'settings', section: settingsSection, ...(settingsAction ? { action: settingsAction } : {}) }
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) { const url = new URL(devUrl); Object.entries(query).forEach(([key, value]) => url.searchParams.set(key, value)); await settingsWindow.loadURL(url.toString()) }
else await settingsWindow.loadFile(join(mainDirectory, '../renderer/index.html'), { query })
})
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+77
View File
@@ -0,0 +1,77 @@
import { randomUUID } from 'node:crypto'
import type { ChatInteraction, InteractionResponse, PermissionOption, QuestionItem } from '@shared/conversation'
import type { ChatEvent } from '@shared/types'
interface PendingInteraction { request: ChatInteraction; resolve(value: InteractionResponse): void; reject(error: Error): void; detach(): void }
const pending = new Map<string, PendingInteraction>()
function abortError(): Error { return Object.assign(new Error('交互已取消'), { name: 'AbortError' }) }
export function createInteractionBroker(runId: string, signal: AbortSignal, emit: (event: ChatEvent) => void) {
async function request(input: ChatInteraction): Promise<InteractionResponse> {
if (signal.aborted) throw abortError()
return new Promise((resolve, reject) => {
const onAbort = (): void => {
const entry = pending.get(input.id)
if (!entry) return
pending.delete(input.id)
entry.detach()
emit({ runId, type: 'interaction', interaction: { ...input, status: 'cancelled' } })
reject(abortError())
}
pending.set(input.id, { request: input, resolve, reject, detach: () => signal.removeEventListener('abort', onAbort) })
signal.addEventListener('abort', onAbort, { once: true })
emit({ runId, type: 'interaction', interaction: input })
})
}
return {
async question(toolCallId: string, title: string, questions: QuestionItem[]) {
const response = await request({ id: randomUUID(), runId, toolCallId, kind: 'question', title, questions, status: 'pending' })
if (response.kind !== 'question') throw new Error('问答响应类型不匹配')
return response
},
async approval(toolCallId: string, title: string, options: PermissionOption[], scope?: string) {
const id = randomUUID()
const response = await request({ id, requestId: id, runId, toolCallId, kind: 'approval', title, options, scope, status: 'pending' })
if (response.kind !== 'approval') throw new Error('审批响应类型不匹配')
return response
}
}
}
export type InteractionBroker = ReturnType<typeof createInteractionBroker>
/** Exact run/id binding makes stale or duplicated button submissions harmless. */
export function respondToInteraction(runId: string, id: string, response: InteractionResponse, emit: (event: ChatEvent) => void): boolean {
const entry = pending.get(id)
if (!entry || entry.request.runId !== runId) return false
const request = entry.request
if (request.kind !== response.kind) throw new Error('交互响应类型不匹配')
let resolved: ChatInteraction
let finalResponse = response
if (request.kind === 'approval' && response.kind === 'approval') {
const option = request.options.find((item) => item.optionId === response.optionId)
if (!option) throw new Error('审批选项无效')
resolved = { ...request, selectedOptionId: option.optionId, status: option.kind.startsWith('reject') ? 'rejected' : 'answered' }
} else if (request.kind === 'question' && response.kind === 'question') {
if (!response.answer || !Array.isArray(response.answer.questions)) throw new Error('问答内容无效')
const ids = response.answer.questions.map((item) => item.id)
if (new Set(ids).size !== ids.length || ids.some((id) => !request.questions.some((question) => question.id === id))) throw new Error('问题标识无效')
if (!response.skipped) {
for (const question of request.questions) {
const answer = response.answer.questions.find((item) => item.id === question.id)
if (!answer || !Array.isArray(answer.answers) || answer.answers.some((value) => typeof value !== 'string' || !value.trim())) throw new Error('回答内容无效')
if (!question.multiSelect && answer.answers.length > 1) throw new Error('该问题仅支持单选')
}
if (!response.answer.questions.some((item) => item.answers.length)) throw new Error('请至少回答一个问题,或跳过全部')
}
const answer = { title: request.title, questions: request.questions.map((question) => ({ id: question.id, question: question.question,
answers: response.skipped ? [] : response.answer.questions.find((item) => item.id === question.id)?.answers ?? [] })) }
finalResponse = { ...response, answer }
resolved = { ...request, answer, skipped: response.skipped, status: 'answered' }
} else throw new Error('交互响应类型不匹配')
pending.delete(id)
entry.detach()
emit({ runId, type: 'interaction', interaction: resolved })
entry.resolve(finalResponse)
return true
}
+737
View File
@@ -0,0 +1,737 @@
/**
* IPC 注册层:渲染进程与主进程之间唯一的契约边界。
*
* 安全约定:
* - 渲染进程无法直接访问 Node / fs / 网络
* - API Key 只留在主进程;渲染进程只能读写脱敏视图
* - 一次性订阅(on)在 preload 里被包装成「返回取消函数」的形式
*/
import { BrowserWindow, app, dialog, ipcMain, shell, type OpenDialogOptions } from 'electron'
import { randomUUID } from 'node:crypto'
import { unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { ChatEvent, ChatMessage, WindowState } from '@shared/types'
import { freezeAssistantModelMetadata } from '@shared/assistant-model'
import type { ConfigPatchLike, EditAction, SendPayload, WorkspaceAttachResult } from '@shared/ipc'
import { applyAssistantEvent } from '@shared/message-parts'
import * as config from './config'
import * as sessions from './sessions'
import { Gateway } from './gateway'
import { runTurn } from './agent/loop'
import { OmpKernel, detectOmpPath, getOmpInfo } from './omp'
import { runOmpTurn } from './agent/omp-turn'
import { createMessageCheckpoint } from './message-checkpoint'
import { classifyWorkspacePath, decodeDownload, imageDownloadPayload, readWorkspaceResource, readWorkspaceText, resolveWorkspaceImage } from './resources'
import type { DownloadPayload, DownloadResult, ResourceRange } from '@shared/resources'
import type { InteractionResponse } from '@shared/conversation'
import { createInteractionBroker, respondToInteraction } from './interaction-broker'
import { attachmentPrompt, commitAttachments, getAttachment, removeAttachment, stageAttachment, stageAttachmentPath } from './attachments'
import { transcribeAudio } from './speech'
import { getRevertChanges } from '@shared/revert'
import { restoreWorkspaceFiles } from './workspace-revert'
import { assistantWireHistory } from './agent/history'
import * as models from './models'
import * as workspaces from './workspaces'
import { prepareOmpModel } from './omp-model'
import type { CustomModelInput, ResolvedModel } from '@shared/models'
import type { SessionContext } from '@shared/workspaces'
import * as browserHost from './browser-host'
import type { BrowserHostBounds } from '@shared/browser-host'
import * as terminalHost from './terminal-host'
import * as skills from './capabilities/skills'
import * as mcp from './capabilities/mcp'
import * as experts from './capabilities/experts'
import { createCapabilitySnapshot } from './capabilities/runtime'
import type { McpServerInput } from '@shared/capabilities'
import { agentMode, agentModeContextPatch, thinkingLevel } from './agent/mode-policy'
/** runId → 取消控制器 */
const activeRuns = new Map<string, AbortController>()
const runTasks = new Map<string, { sessionId: string; promise: Promise<void> }>()
const runEmitters = new Map<string, (event: ChatEvent) => void>()
const chatSessionLocks = new Map<string, Promise<void>>()
async function withChatSessionLock<T>(id: string, operation: () => Promise<T>): Promise<T> {
const previous = chatSessionLocks.get(id) ?? Promise.resolve()
let release!: () => void
const pending = new Promise<void>((resolve) => { release = resolve })
chatSessionLocks.set(id, pending)
await previous
try { return await operation() }
finally { release(); if (chatSessionLocks.get(id) === pending) chatSessionLocks.delete(id) }
}
interface OmpKernelEntry {
kernel: OmpKernel
workspaceRoot: string
appliedBranchId?: string
fingerprint?: string
}
async function applyPendingNativeBranch(sessionId: string, kernel: OmpKernel): Promise<void> {
const pendingId = await sessions.getPendingNativeBranch(sessionId)
const entry = ompKernels.get(sessionId)
if (!pendingId || entry?.appliedBranchId === pendingId) return
const response = await kernel.send({ type: 'branch', entryId: pendingId })
if (response.success === false) throw new Error('无法恢复已保存的原生会话分支')
if (entry) entry.appliedBranchId = pendingId
}
/** Each YCode session gets a native OMP transcript; existing YCode-only history is not imported. */
const ompKernels = new Map<string, OmpKernelEntry>()
const ompKernelLocks = new Map<string, Promise<void>>()
const ompRetirements = new Map<string, Promise<void>>()
let ompShutdownStarted = false
let disposeAllPromise: Promise<void> | null = null
function ompSessionPath(sessionId: string): string {
return join(
app.getPath('userData'),
'omp-sessions',
`session-${encodeURIComponent(sessionId)}.jsonl`
)
}
async function withOmpKernelLock<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
const previous = ompKernelLocks.get(sessionId) ?? Promise.resolve()
let release = (): void => {}
const current = new Promise<void>((resolve) => {
release = resolve
})
ompKernelLocks.set(sessionId, current)
await previous
try {
return await operation()
} finally {
release()
if (ompKernelLocks.get(sessionId) === current) ompKernelLocks.delete(sessionId)
}
}
function evictOmpKernel(sessionId: string, kernel: OmpKernel): void {
if (ompKernels.get(sessionId)?.kernel !== kernel) return
ompKernels.delete(sessionId)
const retirement = Promise.resolve()
.then(() => kernel.dispose())
.catch(() => undefined)
ompRetirements.set(sessionId, retirement)
void retirement.then(() => {
if (ompRetirements.get(sessionId) === retirement) ompRetirements.delete(sessionId)
})
}
async function waitForOmpRetirement(sessionId: string): Promise<void> {
await ompRetirements.get(sessionId)
}
async function getOmpKernel(sessionId: string, workspaceRoot: string, model?: ResolvedModel | null): Promise<OmpKernel | null> {
return withOmpKernelLock(sessionId, async () => {
if (ompShutdownStarted) return null
const prepared = model ? await prepareOmpModel(model) : null
const existing = ompKernels.get(sessionId)
if (existing?.workspaceRoot === workspaceRoot && existing?.fingerprint === prepared?.fingerprint) return existing.kernel
if (existing) {
ompKernels.delete(sessionId)
await existing.kernel.dispose()
}
await waitForOmpRetirement(sessionId)
const exePath = detectOmpPath()
if (!exePath) return null
const kernel = new OmpKernel(exePath, workspaceRoot, prepared?.profile ?? 'ycode', ompSessionPath(sessionId), prepared?.model, prepared?.env)
const entry: OmpKernelEntry = { kernel, workspaceRoot, fingerprint: prepared?.fingerprint }
const evict = (): void => evictOmpKernel(sessionId, kernel)
kernel.on('error', evict)
kernel.on('exit', evict)
ompKernels.set(sessionId, entry)
return kernel
})
}
export async function disposeOmpKernels(): Promise<void> {
if (disposeAllPromise) return disposeAllPromise
ompShutdownStarted = true
disposeAllPromise = (async () => {
for (const controller of activeRuns.values()) controller.abort()
while (ompKernelLocks.size > 0) {
await Promise.all([...ompKernelLocks.values()])
}
const entries = [...ompKernels.values()]
ompKernels.clear()
await Promise.all(
entries.map(async ({ kernel }) => {
await kernel.dispose().catch(() => undefined)
})
)
await Promise.all([...ompRetirements.values()])
await Promise.all([...runTasks.values()].map((task) => task.promise))
})()
return disposeAllPromise
}
async function disposeSessionKernel(sessionId: string): Promise<void> {
await withOmpKernelLock(sessionId, async () => {
const entry = ompKernels.get(sessionId)
if (entry) ompKernels.delete(sessionId)
if (entry) await entry.kernel.dispose()
await waitForOmpRetirement(sessionId)
})
}
async function removeOmpSessionFile(sessionId: string): Promise<void> {
try {
await unlink(ompSessionPath(sessionId))
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
}
export function registerIpc(getWindow: () => BrowserWindow | null): void {
void mcp.initializeMcpServers().catch(() => undefined)
ipcMain.handle('skills:list', (_event, cwd?: string) => skills.listSkills(cwd))
ipcMain.handle('experts:list', () => experts.listExperts())
ipcMain.handle('experts:read', (_event, id: string) => experts.readExpert(id))
ipcMain.handle('experts:activate', (_event, id: string) => experts.activateExpert(id))
ipcMain.handle('experts:remove', (_event, id: string) => experts.removeExpert(id))
ipcMain.handle('experts:open-path', async (_event, id: string) => { const { expert } = await experts.readExpert(id); const error = await shell.openPath(expert.expertRootDir); if (error) throw Error(error) })
ipcMain.handle('skills:read', (_event, id: string, cwd?: string) => skills.readSkill(id, cwd))
ipcMain.handle('skills:enable', (_event, id: string, enabled: boolean, cwd?: string) => skills.setSkillEnabled(id, enabled, cwd))
ipcMain.handle('skills:remove', (_event, id: string) => skills.removeSkill(id))
ipcMain.handle('skills:import', async (event, path?: string, overwrite?: boolean, kind?: 'directory' | 'zip') => {
if (!path) {
const result = await dialog.showOpenDialog(senderWindow(event), kind === 'zip' ? { title: '选择技能 ZIP', properties: ['openFile'], filters: [{ name: 'ZIP', extensions: ['zip'] }] } : { title: '选择技能文件夹', properties: ['openDirectory'] })
if (result.canceled || !result.filePaths[0]) return null
path = result.filePaths[0]
}
return skills.importSkill(path, overwrite)
})
ipcMain.handle('skills:export', async (event, id: string, destination?: string) => {
if (!destination) {
const skill = (await skills.listSkills()).find(item => item.id === id)
if (!skill) throw new Error('技能不存在')
const result = await dialog.showSaveDialog(senderWindow(event), { title: '导出技能', defaultPath: `${skill.name}.zip`, filters: [{ name: 'ZIP', extensions: ['zip'] }] })
if (result.canceled || !result.filePath) return null
destination = result.filePath
}
return skills.exportSkill(id, destination)
})
ipcMain.handle('mcp:list', () => mcp.listMcpServers())
ipcMain.handle('mcp:save', (_event, input: McpServerInput) => mcp.saveMcpServer(input))
ipcMain.handle('mcp:trust', (_event, id: string, hash: string) => mcp.trustMcpServer(id, hash))
ipcMain.handle('mcp:connect', (_event, id: string) => mcp.connectMcpServer(id))
ipcMain.handle('mcp:disconnect', (_event, id: string) => mcp.disconnectMcpServer(id))
ipcMain.handle('mcp:enable', (_event, id: string, enabled: boolean) => mcp.setMcpEnabled(id, enabled))
ipcMain.handle('mcp:tool-enable', (_event, id: string, tool: string, enabled: boolean) => mcp.setMcpToolEnabled(id, tool, enabled))
ipcMain.handle('mcp:remove', (_event, id: string) => mcp.removeMcpServer(id))
function senderWindow(event: Electron.IpcMainInvokeEvent): BrowserWindow {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) throw new Error('宿主窗口已关闭')
return window
}
ipcMain.handle('terminal:create', async (event, sessionId?: string, cols?: number, rows?: number) => terminalHost.createTerminal(senderWindow(event), await sessionRoot(sessionId), sessionId, cols, rows))
ipcMain.handle('terminal:get', (event, id: string, sessionId?: string) => terminalHost.getTerminal(senderWindow(event), id, sessionId))
ipcMain.handle('terminal:write', (event, id: string, data: string, sessionId?: string) => terminalHost.writeTerminal(senderWindow(event), id, data, sessionId))
ipcMain.handle('terminal:resize', (event, id: string, cols: number, rows: number, sessionId?: string) => terminalHost.resizeTerminal(senderWindow(event), id, cols, rows, sessionId))
ipcMain.handle('terminal:clear', (event, id: string, sessionId?: string) => terminalHost.clearTerminal(senderWindow(event), id, sessionId))
ipcMain.handle('terminal:close', (event, id: string, sessionId?: string) => terminalHost.closeTerminal(senderWindow(event), id, sessionId))
ipcMain.handle('terminal:rebind', async (event, id: string, previousSessionId: string | undefined, nextSessionId: string) => terminalHost.rebindTerminal(senderWindow(event), id, previousSessionId, nextSessionId, await sessionRoot(nextSessionId)))
ipcMain.handle('browser:create', async (event, sessionId?: string, url?: string) => {
if (sessionId) await sessions.getSessionContext(sessionId)
return browserHost.createBrowser(senderWindow(event), sessionId, url)
})
ipcMain.handle('browser:rebind', async (event, id: string, previousSessionId: string | undefined, nextSessionId: string) => {
await sessions.getSessionContext(nextSessionId)
return browserHost.rebindBrowser(senderWindow(event), id, previousSessionId, nextSessionId)
})
ipcMain.handle('browser:get', (event, id: string, sessionId?: string) => browserHost.getBrowserState(senderWindow(event), id, sessionId))
ipcMain.handle('browser:navigate', (event, id: string, url: string, sessionId?: string) => browserHost.navigateBrowser(senderWindow(event), id, url, sessionId))
ipcMain.handle('browser:back', (event, id: string, sessionId?: string) => browserHost.backBrowser(senderWindow(event), id, sessionId))
ipcMain.handle('browser:forward', (event, id: string, sessionId?: string) => browserHost.forwardBrowser(senderWindow(event), id, sessionId))
ipcMain.handle('browser:reload', (event, id: string, sessionId?: string) => browserHost.reloadBrowser(senderWindow(event), id, sessionId))
ipcMain.handle('browser:bounds', (event, id: string, bounds: BrowserHostBounds | null, sessionId?: string) => browserHost.setBrowserBounds(senderWindow(event), id, bounds, sessionId))
ipcMain.handle('browser:close', (event, id: string, sessionId?: string) => browserHost.closeBrowser(senderWindow(event), id, sessionId))
function changed(): void {
const windows = typeof BrowserWindow.getAllWindows === 'function' ? BrowserWindow.getAllWindows() : [getWindow()]
windows.forEach((win) => win?.webContents.send('settings:changed'))
}
async function sessionRoot(sessionId?: string): Promise<string> {
if (sessionId) return (await sessions.getSessionContext(sessionId)).cwd ?? await config.getWorkspaceRoot()
return workspaces.workspaceRoot()
}
ipcMain.handle('resources:openExternal', async (_event, input: string) => {
const url = new URL(input)
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('链接协议不受支持')
await shell.openExternal(url.toString())
})
ipcMain.handle('attachments:pick', async () => {
const options: OpenDialogOptions = { properties: ['openFile', 'multiSelections'], title: '添加文件' }
const win = getWindow()
const result = win ? await dialog.showOpenDialog(win, options) : await dialog.showOpenDialog(options)
if (result.canceled) return []
return Promise.all(result.filePaths.map(stageAttachmentPath))
})
ipcMain.handle('attachments:stage', async (_event, payload: { name: string; mimeType: string; dataBase64: string }) => {
if (!payload || typeof payload.name !== 'string' || typeof payload.dataBase64 !== 'string' || payload.dataBase64.length > 28 * 1024 * 1024) throw new Error('附件数据无效或过大')
return stageAttachment(payload.name, payload.mimeType, Buffer.from(payload.dataBase64, 'base64'))
})
ipcMain.handle('attachments:remove', (_event, id: string) => removeAttachment(id))
ipcMain.handle('speech:transcribe', async (_event, payload: { audioBase64: string; mimeType: string }) => transcribeAudio(await config.getGatewayConfig(), payload))
ipcMain.handle('chat:respond', (_event, runId: string, interactionId: string, response: InteractionResponse) => {
const emit = runEmitters.get(runId)
return { accepted: !!emit && respondToInteraction(runId, interactionId, response, emit) }
})
const saveDownload = async (payload: DownloadPayload): Promise<DownloadResult> => {
const { filename, bytes } = decodeDownload(payload)
const win = getWindow()
const options = { title: '保存文件', defaultPath: join(app.getPath('downloads'), filename) }
const result = win ? await dialog.showSaveDialog(win, options) : await dialog.showSaveDialog(options)
if (result.canceled || !result.filePath) return { saved: false }
await writeFile(result.filePath, bytes)
return { saved: true, path: result.filePath }
}
ipcMain.handle('resources:list', async (_event, sessionId?: string, workspaceId?: string) => {
const root = sessionId ? await sessionRoot(sessionId) : workspaceId ? await workspaces.workspaceRoot(workspaceId) : await sessionRoot(undefined)
const resource = await readWorkspaceResource(root, '.')
if (resource.kind !== 'directory') return []
return resource.entries.filter(entry => !['.git', 'node_modules'].includes(entry.name)).slice(0, 100)
})
ipcMain.handle('resources:read', async (_event, path: string, range?: ResourceRange, sessionId?: string) => readWorkspaceResource(await sessionRoot(sessionId), path, range))
ipcMain.handle('resources:read-text', async (_event, path: string, sessionId?: string) => readWorkspaceText(await sessionRoot(sessionId), path))
ipcMain.handle('resources:classify', async (_event, path: string, sessionId?: string) => classifyWorkspacePath(await sessionRoot(sessionId), path))
ipcMain.handle('resources:resolveImage', async (_event, source: string, sessionId?: string) => resolveWorkspaceImage(await sessionRoot(sessionId), source))
ipcMain.handle('resources:download', (_event, payload: DownloadPayload) => saveDownload(payload))
ipcMain.handle('resources:downloadImage', async (_event, source: string, filename?: string, sessionId?: string) => saveDownload(await imageDownloadPayload(await sessionRoot(sessionId), source, filename)))
// ---------- 窗口控制(无边框窗口自绘标题栏用) ----------
ipcMain.handle('window:minimize', (event) => {
;(BrowserWindow.fromWebContents?.(event.sender) ?? getWindow())?.minimize()
})
ipcMain.handle('window:toggleMaximize', (event) => {
const win = BrowserWindow.fromWebContents?.(event.sender) ?? getWindow()
if (!win) return { maximized: false }
if (win.isMaximized()) win.unmaximize()
else win.maximize()
return { maximized: win.isMaximized() }
})
ipcMain.handle('window:close', (event) => {
;(BrowserWindow.fromWebContents?.(event.sender) ?? getWindow())?.close()
})
ipcMain.handle('window:state', (event): WindowState => {
const win = BrowserWindow.fromWebContents?.(event.sender) ?? getWindow()
return {
maximized: win?.isMaximized() ?? false,
fullscreen: win?.isFullScreen() ?? false
}
})
// ---------- 编辑动作(菜单项 → 原生 role 行为) ----------
ipcMain.handle('edit:action', (_event, action: EditAction) => {
const wc = getWindow()?.webContents
if (!wc) return
switch (action) {
case 'undo':
wc.undo()
break
case 'redo':
wc.redo()
break
case 'cut':
wc.cut()
break
case 'copy':
wc.copy()
break
case 'paste':
wc.paste()
break
case 'selectAll':
wc.selectAll()
break
}
})
// ---------- 应用信息 / 目录 ----------
ipcMain.handle('app:info', async () => ({
name: app.getName(),
version: app.getVersion(),
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
configDir: app.getPath('userData'),
workspace: '',
omp: getOmpInfo()
}))
ipcMain.handle('app:openConfigDir', async () => {
await shell.openPath(app.getPath('userData'))
})
// ---------- 配置 ----------
ipcMain.handle('config:get', () => config.getConfigView())
ipcMain.handle('config:update', (_event, patch: ConfigPatchLike) => config.updateConfig(patch))
ipcMain.handle('models:list', () => models.listModels())
ipcMain.handle('models:save', async (_event, input: CustomModelInput, overwrite?: boolean) => {
const result = await models.saveModel(input, overwrite)
if (input.previousId && input.previousId !== input.id) {
for (const session of await sessions.listSessions()) if (session.modelId === input.previousId) await sessions.updateSessionContext(session.id, { modelId: input.id })
}
changed()
return result
})
ipcMain.handle('models:remove', async (_event, id: string) => {
const result = await models.deleteModel(id)
for (const session of await sessions.listSessions()) if (session.modelId === id) await sessions.updateSessionContext(session.id, { modelId: result.selectedModelId ?? undefined })
changed()
return result
})
ipcMain.handle('models:select', async (_event, id: string | null) => { const result = await models.setSelectedModel(id); changed(); return result })
ipcMain.handle('models:test', (_event, input: CustomModelInput) => models.testModel(input))
// ---------- 工作目录 ----------
ipcMain.handle('workspace:get', async () => ({ root: await config.getWorkspaceRoot() }))
ipcMain.handle('workspace:list', () => workspaces.listWorkspaces())
ipcMain.handle('workspace:generate', (_event, name: string) => workspaces.generateWorkspace(name))
ipcMain.handle('workspace:select', (_event, id: string | null) => workspaces.selectWorkspace(id))
ipcMain.handle('workspace:rename', (_event, id: string, name: string) => workspaces.renameWorkspace(id, name))
ipcMain.handle('workspace:remove', (_event, id: string) => workspaces.removeWorkspace(id))
ipcMain.handle('workspace:open', async (_event, id: string) => {
const error = await shell.openPath(await workspaces.workspaceRoot(id))
if (error) throw new Error(error)
})
ipcMain.handle('workspace:attach', async (_event, path?: string): Promise<WorkspaceAttachResult> => {
if (path !== undefined) {
if (typeof path !== 'string' || !path.trim()) throw new Error('文件夹路径无效')
const result = await workspaces.attachWorkspace(path)
changed()
return { ...result, attachedWorkspaceId: result.selectedWorkspaceId ?? undefined }
}
const win = getWindow()
const options: OpenDialogOptions = { title: '打开已有文件夹', properties: ['openDirectory'] }
const result = win ? await dialog.showOpenDialog(win, options) : await dialog.showOpenDialog(options)
if (result.canceled || !result.filePaths[0]) return workspaces.listWorkspaces()
const registry = await workspaces.attachWorkspace(result.filePaths[0])
changed()
return { ...registry, attachedWorkspaceId: registry.selectedWorkspaceId ?? undefined }
})
ipcMain.handle('workspace:choose', async () => {
const win = getWindow()
const options: OpenDialogOptions = {
title: '选择工作目录',
properties: ['openDirectory', 'createDirectory']
}
const result = win
? await dialog.showOpenDialog(win, options)
: await dialog.showOpenDialog(options)
if (result.canceled || result.filePaths.length === 0) {
return { root: await config.getWorkspaceRoot(), changed: false }
}
await config.setWorkspaceRoot(result.filePaths[0])
return { root: result.filePaths[0], changed: true }
})
// ---------- 会话 ----------
ipcMain.handle('session:list', () => sessions.listSessions())
ipcMain.handle('session:create', async (_event, title?: string, context: SessionContext = {}) => {
context = agentModeContextPatch({}, context)
const workspaceId = context.workspaceId ?? (await workspaces.listWorkspaces()).selectedWorkspaceId ?? undefined
const cwd = await workspaces.workspaceRoot(workspaceId)
const modelId = context.modelId ?? (await models.listModels()).selectedModelId ?? undefined
if (modelId) await models.resolveModel(modelId)
if (context.expertId) await experts.activateExpert(context.expertId)
return sessions.createSession(title, { workspaceId, cwd, modelId, agentMode: context.agentMode, ...(context.thinkingLevel !== undefined ? { thinkingLevel: thinkingLevel(context.thinkingLevel) } : {}), ...(context.expertId !== undefined ? { expertId: context.expertId } : {}) })
})
ipcMain.handle('session:update-context', (_event, id: string, patch: SessionContext) => withChatSessionLock(id, async () => {
if ([...runTasks.values()].some((run) => run.sessionId === id)) throw new Error('请先停止当前运行')
const previous = await sessions.getSessionContext(id)
patch = agentModeContextPatch(previous, patch)
// A conversation keeps its directory; selecting a different space starts a new task.
if ((previous.cwd && patch.cwd && patch.cwd !== previous.cwd) || (previous.workspaceId && patch.workspaceId && patch.workspaceId !== previous.workspaceId)) throw new Error('请在目标空间新建任务')
const context: SessionContext = { agentMode: patch.agentMode }
if (patch.workspaceId && !previous.workspaceId) {
const cwd = await workspaces.workspaceRoot(patch.workspaceId)
if (previous.cwd && previous.cwd.toLowerCase() !== cwd.toLowerCase()) throw new Error('请在目标空间新建任务')
context.workspaceId = patch.workspaceId
context.cwd = cwd
} else if (patch.cwd && !previous.cwd) throw new Error('请先选择空间')
if (patch.modelId && !(await models.resolveModel(patch.modelId))) throw new Error('模型不存在')
if (patch.modelId !== undefined) context.modelId = patch.modelId
if (patch.thinkingLevel !== undefined) context.thinkingLevel = thinkingLevel(patch.thinkingLevel)
if (patch.expertId !== undefined) {
if (patch.expertId !== null) await experts.activateExpert(patch.expertId)
context.expertId = patch.expertId
}
return sessions.updateSessionContext(id, context)
}))
ipcMain.handle('session:messages', (_event, sessionId: string) => sessions.getMessages(sessionId))
ipcMain.handle('session:rename', (_event, sessionId: string, title: string) =>
sessions.renameSession(sessionId, title)
)
ipcMain.handle('session:delete', async (_event, sessionId: string) => withChatSessionLock(sessionId, async () => {
const tasks = [...runTasks.entries()].filter(([, task]) => task.sessionId === sessionId)
for (const [runId] of tasks) activeRuns.get(runId)?.abort()
await disposeSessionKernel(sessionId)
await Promise.all(tasks.map(([, task]) => task.promise))
await removeOmpSessionFile(sessionId)
return sessions.deleteSession(sessionId)
}))
// ---------- 对话 ----------
ipcMain.handle('chat:feedback', async (_event, sessionId: string, messageId: string, value: 'like' | 'dislike') => {
if (!['like', 'dislike'].includes(value)) throw new Error('反馈值无效')
const message = (await sessions.getMessages(sessionId)).find((item) => item.id === messageId && item.role === 'assistant')
if (!message) throw new Error('未找到助手消息')
await sessions.patchMessage(sessionId, messageId, { feedback: value })
})
ipcMain.handle('chat:revert', async (_event, sessionId: string, messageId: string) => withChatSessionLock(sessionId, async () => {
if ([...runTasks.values()].some((run) => run.sessionId === sessionId)) throw new Error('请先停止当前运行')
const history = [...await sessions.getMessages(sessionId)]
const index = history.findIndex((item) => item.id === messageId && item.role === 'user')
const changes = getRevertChanges(history, messageId)
if (!changes.length) throw new Error('此版本没有可恢复的文件修改')
const workspace = await sessionRoot(sessionId)
const kernel = await getOmpKernel(sessionId, workspace, await models.resolveModel((await sessions.getSessionContext(sessionId)).modelId))
if (kernel && !history[index].nativeEntryId) throw new Error('缺少原生会话分支,无法同时恢复上下文')
const undo = await restoreWorkspaceFiles(workspace, changes)
try {
if (kernel) {
const response = await kernel.branch(history[index].nativeEntryId!)
if (response.success === false) throw new Error('原生会话恢复失败')
const entry = ompKernels.get(sessionId)
if (entry) entry.appliedBranchId = history[index].nativeEntryId!
}
const messages = history.slice(0, index)
await sessions.replaceMessages(sessionId, messages, 'revert', { pendingNativeBranchId: kernel ? history[index].nativeEntryId : undefined })
return { messages }
} catch (error) { await disposeSessionKernel(sessionId); await undo(); throw error }
}))
ipcMain.handle('chat:send', async (_event, payload: SendPayload) => withChatSessionLock(payload.sessionId, async () => {
if (ompShutdownStarted) throw new Error('应用正在退出')
const win = getWindow()
const { sessionId, text } = payload
if (!(await sessions.listSessions()).some((session) => session.id === sessionId)) throw new Error('会话不存在')
if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length)) throw new Error('消息为空')
if ([...runTasks.values()].some((run) => run.sessionId === sessionId)) throw new Error('该会话正在运行')
const attachments = await Promise.all((payload.attachments ?? []).map((item) => getAttachment(item.id)))
const promptAttachments = await attachmentPrompt(attachments)
const context = await sessions.getSessionContext(sessionId)
const mode = agentMode(context.agentMode)
const selectedModel = await models.resolveModel(context.modelId)
if (!selectedModel) throw new Error('请先添加并选择自定义模型')
const snapshot = selectedModel?.config ?? await config.getGatewayConfig()
const workspaceRoot = await sessionRoot(sessionId)
const references = [...new Set(payload.resources ?? [])]
const capabilitySnapshot = await createCapabilitySnapshot(payload.skillIds ?? [], workspaceRoot, { sessionId, expertId: context.expertId, agentMode: mode })
for (const path of references) {
if (typeof path !== 'string' || await classifyWorkspacePath(workspaceRoot, path) === 'unknown') throw new Error('引用资源不在工作区内或已不存在')
}
const kernel = await getOmpKernel(sessionId, workspaceRoot, selectedModel)
if (kernel) await applyPendingNativeBranch(sessionId, kernel)
const nativeBefore = kernel ? await kernel.send({ type: 'get_branch_messages' }) : null
const nativeBeforeIds = new Set((nativeBefore?.data as { messages?: Array<{ entryId?: string }> } | undefined)?.messages?.map((item) => item.entryId) ?? [])
await commitAttachments(attachments)
let history = [...await sessions.getMessages(sessionId)]
let selectedNativeBranch: string | undefined
if (payload.action) {
let targetIndex = history.findIndex((message) => message.id === payload.fromMessageId)
if (targetIndex < 0) throw new Error('未找到需要重发的消息')
if (payload.action === 'regenerate') {
if (history[targetIndex].role !== 'assistant') throw new Error('只能重新生成助手消息')
targetIndex = history.slice(0, targetIndex).findLastIndex((message) => message.role === 'user')
}
const target = history[targetIndex]
if (!target || target.role !== 'user') throw new Error('未找到对应用户消息')
if (kernel) {
if (target.nativeSessionPath && target.nativeSessionPath !== ompSessionPath(sessionId)) throw new Error('这条历史消息没有当前 OMP 会话的分支记录')
let entryId = target.nativeEntryId
if (!entryId) {
const following = history[targetIndex + 1]
const prefixUsers = history.slice(0, targetIndex).filter((message) => message.role === 'user')
const nativeUsers = (nativeBefore?.data as { messages?: Array<{ entryId: string; text: string }> } | undefined)?.messages ?? []
const isLastInput = !history.slice(targetIndex + 1).some((message) => message.role === 'user')
const canRecover = isLastInput && following?.role === 'assistant' && ['cancelled', 'error'].includes(following.status ?? '') &&
prefixUsers.every((message, index) => !!message.nativeEntryId && message.nativeEntryId === nativeUsers[index]?.entryId)
if (!canRecover) throw new Error('这条历史消息没有当前 OMP 会话的分支记录')
const attached = await attachmentPrompt(target.attachments ?? [])
const nativePrompt = [target.content, attached.text, target.resources?.length ? `引用工作区资源:\n${target.resources.join('\n')}` : '', target.capabilityContext].filter(Boolean).join('\n\n')
if (nativeUsers.length === prefixUsers.length + 1 && nativeUsers.at(-1)?.text === nativePrompt) entryId = nativeUsers.at(-1)!.entryId
else if (nativeUsers.length !== prefixUsers.length) throw new Error('中断会话的原生历史与消息记录不一致')
}
if (entryId) {
try {
const result = await kernel.send({ type: 'branch', entryId })
if (result.success === false) throw new Error(String(result.error ?? '原生会话分支失败'))
} catch (error) { await disposeSessionKernel(sessionId); throw error }
selectedNativeBranch = entryId
}
const entry = ompKernels.get(sessionId)
if (entry && entryId) entry.appliedBranchId = entryId
}
history = history.slice(0, targetIndex)
}
const runId = randomUUID()
const userMessage: ChatMessage = {
id: randomUUID(),
role: 'user',
content: text,
requestId: runId,
planId: payload.planId,
attachments,
resources: references,
skills: capabilitySnapshot.references,
capabilityContext: capabilitySnapshot.context || undefined,
expertId: context.expertId ?? undefined,
createdAt: Date.now(),
status: 'done'
}
let assistant: ChatMessage = {
id: randomUUID(),
role: 'assistant',
content: '',
...freezeAssistantModelMetadata(selectedModel.profile),
assistantDisplay: capabilitySnapshot.assistantDisplay,
requestId: runId,
tools: [],
parts: [],
createdAt: Date.now(),
turnStartedAt: userMessage.createdAt,
status: 'streaming'
}
try { await sessions.startTurn(sessionId, userMessage, assistant, history, payload.action, selectedNativeBranch) }
catch (error) { if (selectedNativeBranch) await disposeSessionKernel(sessionId); throw error }
// 回合开始时冻结配置:运行中改设置不影响本回合
const controller = new AbortController()
activeRuns.set(runId, controller)
const publish = (event: ChatEvent): void => {
if (!win?.isDestroyed?.() && !win?.webContents.isDestroyed?.()) {
// Window teardown must not interrupt persistence or sidecar cleanup.
try { win?.webContents.send('chat:event', { ...event, sessionId }) } catch { /* window closed */ }
}
}
const checkpoint = createMessageCheckpoint(
(message) => sessions.patchMessage(sessionId, message.id, {
content: message.content,
tools: message.tools,
parts: message.parts,
interactions: message.interactions,
status: message.status,
turnStartedAt: message.turnStartedAt,
turnDurationMs: message.turnDurationMs,
error: message.error
}),
(error) => console.error('会话增量保存失败,将在回合结束时重试', error)
)
let terminalEvent: ChatEvent | null = null
const emit = (event: ChatEvent): void => {
const isTerminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error'
const observedEvent: ChatEvent = isTerminal
? { ...event, turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) } : event
assistant = applyAssistantEvent(assistant, observedEvent)
if (isTerminal) {
terminalEvent = observedEvent
} else {
checkpoint.schedule(assistant)
publish(event)
}
}
runEmitters.set(runId, emit)
const interactionBroker = createInteractionBroker(runId, controller.signal, emit)
const promptText = [text, promptAttachments.text, references.length ? `引用工作区资源:\n${references.join('\n')}` : '', capabilitySnapshot.context].filter(Boolean).join('\n\n')
// 回合在后台推进,立刻把 runId 还给渲染进程
const initialMessages = payload.action ? structuredClone([...history, userMessage, assistant]) : undefined
const task = (async () => {
try {
// 内核选择:优先 OMP(外部 Agent 内核),不可用时回退自建循环
if (kernel) {
const result = await runOmpTurn({
capabilityTools: capabilitySnapshot,
agentMode: mode,
thinkingLevel: context.thinkingLevel,
runId,
kernel,
supportsToolCall: selectedModel?.profile.supportsToolCall,
userText: promptText,
images: promptAttachments.images,
workspaceRoot,
permissionMode: payload.permissionMode ?? 'default-sandbox',
interactions: interactionBroker,
signal: controller.signal,
emit
})
assistant.content = result.content
assistant.tools = result.tools
// 终态由内核回合给出(abort 后到达的 agent_end 记为 cancelled)
assistant.status = result.status
const branch = await kernel.send({ type: 'get_branch_messages' })
const data = branch.data as { messages?: Array<{ entryId?: string; text?: string }> } | undefined
const native = data?.messages?.at(-1)
if (native?.entryId && !nativeBeforeIds.has(native.entryId) && native.text === promptText) {
await sessions.patchMessage(sessionId, userMessage.id, { nativeEntryId: native.entryId, nativeSessionPath: ompSessionPath(sessionId) })
await sessions.setPendingNativeBranch(sessionId, undefined)
}
} else {
const wireHistory = (await Promise.all(history.map(async (message) => {
if (message.role === 'assistant') return assistantWireHistory(message)
const attached = await attachmentPrompt(message.attachments ?? [])
const content = [message.content, attached.text, message.resources?.length ? `引用工作区资源:\n${message.resources.join('\n')}` : '', message.capabilityContext].filter(Boolean).join('\n\n')
return [{ role: message.role, content: attached.images.length ? [{ type: 'text' as const, text: content }, ...attached.images.map((image) => ({ type: 'image_url' as const, image_url: { url: `data:${image.mimeType};base64,${image.data}` } }))] : content }]
}))).flat()
const result = await runTurn({
capabilityTools: capabilitySnapshot,
agentMode: mode,
runId,
history,
wireHistory,
userText: promptText,
images: promptAttachments.images,
interactions: interactionBroker,
permissionMode: payload.permissionMode ?? 'default-sandbox',
signal: controller.signal,
emit,
gateway: new Gateway(snapshot),
supportsToolCall: selectedModel?.profile.supportsToolCall,
model: snapshot.model,
maxToolRounds: snapshot.maxToolRounds,
workspaceRoot
})
assistant.content = result.content
assistant.tools = result.tools
// 旧循环只在正常完成时走到这里;abort/error 走下面的 catch
assistant.status = 'done'
}
} catch (err) {
if ((err as Error).name === 'AbortError' || controller.signal.aborted) {
emit({ runId, type: 'cancelled' })
} else {
emit({ runId, type: 'error', message: err instanceof Error ? err.message : String(err) })
}
} finally {
controller.abort()
try {
await checkpoint.finish(assistant)
if (terminalEvent) publish(terminalEvent)
} catch (error) {
publish({ runId, type: 'error', message: `会话保存失败:${error instanceof Error ? error.message : String(error)}` })
} finally {
activeRuns.delete(runId)
runEmitters.delete(runId)
}
}
})()
runTasks.set(runId, { sessionId, promise: task })
void task.finally(() => runTasks.delete(runId))
return { runId, assistantMessageId: assistant.id, modelId: assistant.modelId, modelName: assistant.modelName,
assistantDisplay: assistant.assistantDisplay, userMessage, ...(initialMessages ? { messages: initialMessages } : {}) }
}))
ipcMain.handle('chat:cancel', (_event, runId: string) => {
const controller = activeRuns.get(runId)
if (!controller) return { cancelled: false }
controller.abort()
return { cancelled: true }
})
}
+48
View File
@@ -0,0 +1,48 @@
import type { ChatMessage } from '@shared/types'
/** Coalesce streaming snapshots; the terminal write always follows any in-flight write. */
export function createMessageCheckpoint(
save: (message: ChatMessage) => Promise<void>,
onError: (error: unknown) => void,
interval = 100
): { schedule(message: ChatMessage): void; finish(message: ChatMessage): Promise<void> } {
let pending: ChatMessage | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let writing: Promise<void> = Promise.resolve()
let busy = false
let closed = false
const arm = (): void => {
if (closed || busy || timer || !pending) return
timer = setTimeout(() => {
timer = null
const snapshot = pending
pending = null
if (!snapshot) return
busy = true
writing = Promise.resolve()
.then(() => save(snapshot))
.catch(onError)
.finally(() => {
busy = false
arm()
})
}, interval)
}
return {
schedule(message) {
if (closed) return
pending = { ...message }
arm()
},
async finish(message) {
closed = true
pending = null
if (timer) clearTimeout(timer)
timer = null
await writing
await save(message)
}
}
}
+315
View File
@@ -0,0 +1,315 @@
/** Local custom models. Credentials stay in the main process; legacy config is read only. */
import { app, safeStorage } from 'electron'
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { CustomModelInput, CustomModelProfile, ModelRegistryView, ModelTestResult, ResolvedModel } from '@shared/models'
import { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
import { getGatewayConfig } from './config'
interface StoredModel extends Omit<CustomModelProfile, 'hasApiKey' | 'keyStorage'> {
apiKeyEnc?: string
apiKeyPlain?: string
}
interface StoredRegistry {
version: 1
models: StoredModel[]
selectedModelId: string | null
}
let cache: StoredRegistry | null = null
let loading: Promise<StoredRegistry> | null = null
let writes: Promise<unknown> = Promise.resolve()
function filePath(): string { return join(app.getPath('userData'), 'models.json') }
function readKey(model: StoredModel): { key: string; storage: CustomModelProfile['keyStorage'] } {
if (model.apiKeyEnc) {
try { return { key: safeStorage.decryptString(Buffer.from(model.apiKeyEnc, 'base64')), storage: 'encrypted' } }
catch { return { key: '', storage: 'none' } }
}
return model.apiKeyPlain ? { key: model.apiKeyPlain, storage: 'plaintext' } : { key: '', storage: 'none' }
}
function writeKey(model: StoredModel, key: string): void {
delete model.apiKeyEnc
delete model.apiKeyPlain
if (!key) return
try {
if (safeStorage.isEncryptionAvailable()) model.apiKeyEnc = safeStorage.encryptString(key).toString('base64')
else model.apiKeyPlain = key
} catch { throw new Error('无法安全保存 API Key,请检查系统加密服务') }
}
function viewOf(model: StoredModel): CustomModelProfile {
const { apiKeyEnc: _encrypted, apiKeyPlain: _plain, ...profile } = model
const { key, storage } = readKey(model)
return { ...profile, supportedEfforts: profile.supportedEfforts?.slice(), hasApiKey: !!key, keyStorage: key ? storage : 'none' }
}
function registryView(registry: StoredRegistry): ModelRegistryView {
return { models: registry.models.map(viewOf), selectedModelId: registry.selectedModelId }
}
async function persist(registry: StoredRegistry): Promise<void> {
const file = filePath()
await fs.mkdir(join(file, '..'), { recursive: true })
const temporary = `${file}.${randomUUID()}.tmp`
try {
await fs.writeFile(temporary, JSON.stringify(registry, null, 2), { encoding: 'utf8', mode: 0o600 })
await fs.rename(temporary, file)
cache = registry
} catch (error) {
await fs.unlink(temporary).catch(() => undefined)
throw error
}
}
async function readRegistry(persistMigration: boolean): Promise<StoredRegistry> {
let raw: string
try { raw = await fs.readFile(filePath(), 'utf8') }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const legacy = await getGatewayConfig()
const migrated: StoredModel = {
id: 'legacy-default', name: legacy.model, providerId: 'custom',
model: legacy.model, baseUrl: legacy.baseUrl, authMode: 'api-key',
supportsToolCall: true, supportsImages: false, supportsReasoning: false,
temperature: legacy.temperature, maxToolRounds: legacy.maxToolRounds
}
writeKey(migrated, legacy.apiKey)
const initial: StoredRegistry = { version: 1, models: [migrated], selectedModelId: migrated.id }
if (persistMigration) await persist(initial)
return initial
}
let parsed: Partial<StoredRegistry> & { schemaVersion?: number }
try { parsed = JSON.parse(raw) as typeof parsed }
catch { throw new Error('模型配置文件损坏,请保留 models.json 后检查内容') }
// Accept either version marker without rewriting existing secrets during a read.
if (!parsed || typeof parsed !== 'object' || (parsed.version ?? parsed.schemaVersion) !== 1 || !Array.isArray(parsed.models)) {
throw new Error('无法读取此版本的模型配置文件')
}
const ids = new Set<string>()
for (const model of parsed.models) {
if (!model || typeof model.id !== 'string' || !model.id || ids.has(model.id)
|| typeof model.model !== 'string' || typeof model.baseUrl !== 'string'
|| typeof model.name !== 'string' || typeof model.providerId !== 'string') {
throw new Error('模型配置内容无效,请保留 models.json 后检查内容')
}
ids.add(model.id)
}
const selectedModelId = parsed.selectedModelId === null ? null
: typeof parsed.selectedModelId === 'string' && ids.has(parsed.selectedModelId)
? parsed.selectedModelId : parsed.models[0]?.id ?? null
return { version: 1, models: parsed.models, selectedModelId }
}
async function load(): Promise<StoredRegistry> {
if (cache) return cache
if (loading) return loading
loading = readRegistry(true).then(registry => { cache = registry; return registry })
try { return await loading }
finally { loading = null }
}
function mutate(operation: (registry: StoredRegistry) => Promise<ModelRegistryView>): Promise<ModelRegistryView> {
const next = writes.then(async () => operation(await load()))
writes = next.catch(() => undefined)
return next
}
function normalize(input: CustomModelInput, existing?: StoredModel, testing = false): StoredModel {
if (!input || typeof input !== 'object') throw new Error('模型配置无效')
const providerId = typeof input.providerId === 'string' ? input.providerId.trim() : ''
const model = typeof input.model === 'string' ? input.model.trim() : ''
const baseUrl = typeof input.baseUrl === 'string' ? input.baseUrl.trim().replace(/\/+$/, '') : ''
if (!providerId) throw new Error('请选择供应商')
if (!model) throw new Error('请输入模型名称')
if (!baseUrl) throw new Error('请输入 API 地址')
let endpoint: URL
try { endpoint = new URL(baseUrl) } catch { throw new Error('请输入有效的 API 地址') }
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password) throw new Error('API 地址必须使用 http 或 https,且不能包含账户密码')
if (endpoint.search || endpoint.hash) throw new Error('API 地址不能包含查询参数或片段')
const id = typeof input.id === 'string' ? input.id.trim() : ''
const name = typeof input.name === 'string' ? input.name.trim() : ''
if (!id) throw new Error('模型标识不能为空')
if (!name) throw new Error('请输入模型显示名称')
if (input.authMode !== undefined && !['api-key', 'none'].includes(input.authMode)) throw new Error('鉴权方式无效')
const suppliedKey = typeof input.apiKey === 'string' ? input.apiKey.replace(/\s/gu, '') : ''
if (suppliedKey && !/^[\x21-\x7e]+$/.test(suppliedKey)) throw new Error('API Key 只能包含 ASCII 可打印字符')
const authMode = input.authMode ?? 'api-key'
const key = authMode === 'none' || input.clearApiKey ? '' : suppliedKey || (existing ? readKey(existing).key : '')
if (authMode !== 'none' && !key && (testing || !input.clearApiKey)) throw new Error('请输入 API Key')
const next: StoredModel = { id, name, providerId, model, baseUrl, authMode }
for (const field of ['supportsToolCall', 'supportsImages', 'supportsReasoning', 'onlyReasoning', 'canDisableThinking', 'useCustomProtocol'] as const) {
if (typeof input[field] === 'boolean') next[field] = input[field]
}
if (next.supportsReasoning) {
if (typeof input.defaultEffort === 'string') next.defaultEffort = input.defaultEffort
if (Array.isArray(input.supportedEfforts) && input.supportedEfforts.every(value => typeof value === 'string')) next.supportedEfforts = [...new Set(input.supportedEfforts)]
} else {
delete next.onlyReasoning
delete next.canDisableThinking
}
for (const field of ['maxInputTokens', 'maxOutputTokens'] as const) {
const value = input[field]
if (value !== undefined) {
if (!Number.isFinite(value)) throw new Error('Token 数量必须是有效数字')
next[field] = Math.trunc(value)
}
}
for (const field of ['temperature', 'maxToolRounds'] as const) {
const value = input[field] ?? existing?.[field]
if (value !== undefined) {
if (!Number.isFinite(value)) throw new Error('模型参数必须是有效数字')
next[field] = field === 'temperature' ? Math.max(0, Math.min(2, value)) : Math.max(1, Math.min(24, Math.round(value)))
}
}
// Preserve ciphertext when editing without a new key (including on platforms temporarily unable to encrypt).
if (existing && authMode !== 'none' && !input.clearApiKey && !suppliedKey) {
next.apiKeyEnc = existing.apiKeyEnc
next.apiKeyPlain = existing.apiKeyPlain
} else writeKey(next, key)
return next
}
export async function listModels(): Promise<ModelRegistryView> {
await writes
return registryView(await load())
}
export function saveModel(input: CustomModelInput, overwrite = false): Promise<ModelRegistryView> {
return mutate(async registry => {
const previousId = input?.previousId || input?.id
const existing = registry.models.find(model => model.id === previousId)
const target = registry.models.find(model => model.id === input?.id?.trim())
if (target && target.id !== input.previousId && !overwrite) throw new Error('同名模型已存在,请确认是否覆盖')
const next = normalize(input, existing ?? target)
const remaining = registry.models.filter(model => model.id !== previousId && model.id !== next.id)
const oldIndex = registry.models.findIndex(model => model.id === previousId || model.id === next.id)
remaining.splice(oldIndex < 0 ? remaining.length : Math.min(oldIndex, remaining.length), 0, next)
const selectedModelId = !registry.selectedModelId || registry.selectedModelId === previousId ? next.id : registry.selectedModelId
const updated: StoredRegistry = { version: 1, models: remaining, selectedModelId }
await persist(updated)
return registryView(updated)
})
}
export function deleteModel(id: string): Promise<ModelRegistryView> {
return mutate(async registry => {
const models = registry.models.filter(model => model.id !== id)
const selectedModelId = registry.selectedModelId === id ? models[0]?.id ?? null : registry.selectedModelId
const updated: StoredRegistry = { version: 1, models, selectedModelId }
await persist(updated)
return registryView(updated)
})
}
export function setSelectedModel(id: string | null): Promise<ModelRegistryView> {
return mutate(async registry => {
if (id !== null && !registry.models.some(model => model.id === id)) throw new Error('所选模型不存在')
const updated: StoredRegistry = { ...registry, selectedModelId: id }
await persist(updated)
return registryView(updated)
})
}
export async function resolveModel(id?: string | null): Promise<ResolvedModel | null> {
await writes
const registry = await load()
const targetId = id === undefined ? registry.selectedModelId : id
if (!targetId) return null
const model = registry.models.find(model => model.id === targetId)
if (!model) throw new Error('所选模型不存在,请重新选择模型')
const legacy = await getGatewayConfig()
return {
profile: viewOf(model),
config: { ...legacy, baseUrl: model.baseUrl, model: model.model, apiKey: readKey(model).key,
authMode: model.authMode, maxOutputTokens: model.maxOutputTokens,
temperature: model.temperature ?? legacy.temperature, maxToolRounds: model.maxToolRounds ?? legacy.maxToolRounds }
}
}
/** Test unsaved settings using only the specified user's OpenAI-compatible endpoint. */
export async function testModel(input: CustomModelInput): Promise<ModelTestResult> {
const startedAt = Date.now()
const result = (success: boolean, message: string): ModelTestResult => ({ success, message, durationMs: Date.now() - startedAt })
let model: StoredModel
try {
await writes
const registry = cache ?? await readRegistry(false)
model = normalize(input, registry.models.find(item => item.id === (input.previousId || input.id)), true)
} catch (error) { return result(false, (error as Error).message) }
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 15_000)
try {
const key = readKey(model).key
const { api, endpoint } = resolveOpenAIEndpoint(model.baseUrl)
const payload = api === 'openai-responses'
? { model: model.model, input: 'Reply with OK.', stream: false, max_output_tokens: 16 }
: { model: model.model, messages: [{ role: 'user', content: 'Reply with OK.' }], stream: false, max_tokens: 16 }
const response = await fetch(endpoint, {
method: 'POST', signal: controller.signal,
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
body: JSON.stringify(payload)
})
if (!response.ok) {
await response.body?.cancel()
const messages: Record<number, string> = { 401: '鉴权失败,请检查 API Key', 403: '上游拒绝访问,请检查 API Key 和权限',
404: '未找到模型或接口,请检查模型名称和 API 地址', 429: '上游请求频率或额度受限,请稍后重试' }
return result(false, messages[response.status] ?? `上游返回 HTTP ${response.status}`)
}
// Limit response consumption; never return upstream text that could echo credentials.
const reader = response.body?.getReader()
if (!reader) return result(false, '上游返回为空')
const chunks: Uint8Array[] = []
let total = 0
while (true) {
const { value, done } = await reader.read()
if (done) break
total += value.length
if (total > 1024 * 1024) { await reader.cancel(); return result(false, '上游响应过大') }
chunks.push(value)
}
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
if (api === 'openai-responses') {
if (!isResponsesTestResult(parsed)) return result(false, '上游未返回兼容的 Responses 结果')
} else if (!parsed || typeof parsed !== 'object' || !('choices' in parsed)
|| !Array.isArray(parsed.choices) || !parsed.choices.length
|| !parsed.choices.some(choice => choice && (choice.message || typeof choice.text === 'string'))) {
return result(false, '上游未返回兼容的对话结果')
}
return result(true, '连接测试成功')
} catch (error) {
if (controller.signal.aborted) return result(false, '连接超时,请检查 API 地址或稍后重试')
return result(false, error instanceof SyntaxError ? '上游未返回有效的 JSON 对话结果' : '无法连接上游,请检查 API 地址和网络')
} finally { clearTimeout(timer) }
}
/** A short probe may spend its whole output budget on reasoning without producing visible text. */
function isResponsesTestResult(value: unknown): boolean {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const response = value as Record<string, unknown>
if (response.error != null || (response.object !== undefined && response.object !== 'response')
|| (response.status !== undefined && response.status !== 'completed' && response.status !== 'incomplete')
|| !Array.isArray(response.output)) return false
const validOutput = response.output.every(item => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return false
const output = item as Record<string, unknown>
if (output.type === 'reasoning') {
return Array.isArray(output.summary) && output.summary.every(part => part && typeof part === 'object'
&& part.type === 'summary_text' && typeof part.text === 'string')
}
return output.type === 'message' && output.role === 'assistant' && Array.isArray(output.content)
&& output.content.length > 0 && output.content.every(part => part && typeof part === 'object'
&& ((part.type === 'output_text' && typeof part.text === 'string')
|| (part.type === 'refusal' && typeof part.refusal === 'string')))
})
if (!validOutput) return false
if (response.status === 'incomplete') {
const details = response.incomplete_details
return !!details && typeof details === 'object' && 'reason' in details
&& (details.reason === 'max_output_tokens' || details.reason === 'content_filter')
}
return response.output.length > 0
}
+33
View File
@@ -0,0 +1,33 @@
import { createHash } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { ResolvedModel } from '@shared/models'
import { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
/** Dedicated immutable profile per effective configuration; active turns keep their own snapshot. */
export async function prepareOmpModel(model: ResolvedModel): Promise<{ profile: string; model: string; fingerprint: string; env: NodeJS.ProcessEnv }> {
const endpoint = resolveOpenAIEndpoint(model.config.baseUrl)
const fingerprint = createHash('sha256').update(JSON.stringify({ model, api: endpoint.api, baseUrl: endpoint.baseUrl })).digest('hex')
const profile = `ycode-managed-${fingerprint.slice(0, 20)}`
const ompHome = process.env.YCODE_OMP_HOME || homedir()
const directory = join(ompHome, '.omp', 'profiles', profile, 'agent')
const p = model.profile
const yaml = [
'providers:', ' ycode:',
` baseUrl: ${JSON.stringify(endpoint.baseUrl)}`,
` api: ${endpoint.api}`,
...(p.authMode === 'none' ? [' auth: none'] : [' apiKey: YCODE_MODEL_API_KEY']),
' models:', ` - id: ${JSON.stringify(model.config.model)}`,
` name: ${JSON.stringify(p.name || p.model)}`,
` reasoning: ${!!p.supportsReasoning}`,
` input: ${p.supportsImages ? '[text, image]' : '[text]'}`,
` contextWindow: ${p.maxInputTokens || 128000}`,
` maxTokens: ${p.maxOutputTokens || 8192}`, ''
].join('\n')
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'models.yml'), yaml, 'utf8')
// The key is passed only in the child environment, never written into the YAML or process arguments.
return { profile, model: `ycode/${model.config.model}`, fingerprint, env: { YCODE_MODEL_API_KEY: model.config.apiKey,
...(process.env.YCODE_OMP_HOME ? { USERPROFILE: ompHome, HOME: ompHome } : {}) } }
}
+447
View File
@@ -0,0 +1,447 @@
/**
* OMP 内核 sidecar(`omp --mode rpc`)。
*
* OMP RPC uses newline-delimited JSON frames over stdio:
* - stdio 上的 **newline-delimited JSONL**(不是 JSON-RPC 2.0)
* - 启动后先写一个 `ready` 帧
* - stdin:命令(可带 `id` 用于关联);stdout:响应 + 会话事件
* - 单帧上限 1 MiB
*
* 持久会话使用 OMP 自己的 JSONL transcript;未提供 sessionPath 时保持临时模式。
*/
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { createInterface } from 'node:readline'
import { EventEmitter } from 'node:events'
import { existsSync, readFileSync } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, isAbsolute, join } from 'node:path'
/** OMP 单帧上限(含换行) */
const MAX_FRAME_BYTES = 1024 * 1024
/** 命令默认超时 */
const COMMAND_TIMEOUT_MS = 60_000
export interface OmpFrame<TData = unknown> {
type: string
id?: string
command?: string
success?: boolean
data?: TData
error?: string | { message?: string }
[key: string]: unknown
}
export interface OmpAgentMessage {
role: string
content?: unknown[]
[key: string]: unknown
}
export interface OmpSessionTreeEntry {
id: string
parentId: string | null
type: string
message?: OmpAgentMessage
[key: string]: unknown
}
export interface OmpSessionTreeNode {
entry: OmpSessionTreeEntry
children: OmpSessionTreeNode[]
label?: string
}
export interface OmpBranchMessage {
entryId: string
text: string
}
export interface OmpHostToolDefinition {
name: string
label?: string
description: string
parameters: Record<string, unknown>
}
export interface OmpToolInfo {
id?: string
name?: string
/** Optional human-readable action text emitted alongside the native tool call. */
intent?: string
argsRaw?: string
/** 部分输出 / 最终输出(尽力解析) */
output?: string
ok?: boolean
isError?: boolean
}
export interface OmpPromptImage {
type: 'image'
data: string
mimeType: string
}
interface Pending {
resolve: (value: OmpFrame) => void
reject: (err: Error) => void
timer: NodeJS.Timeout
}
/** 在常见位置探测 OMP 可执行文件 */
export function detectOmpPath(): string | null {
const candidates = [
process.env['YCODE_OMP_PATH'],
process.env['LOCALAPPDATA'] ? `${process.env['LOCALAPPDATA']}\\Ygent\\omp.exe` : null,
'C:\\Program Files\\Ygent\\omp.exe'
].filter((p): p is string => !!p)
for (const p of candidates) {
if (existsSync(p)) return p
}
return null
}
/** OMP 内核的对外信息(设置页 / 输入框展示用) */
export interface OmpInfo {
available: boolean
exePath: string | null
profile: string
/** 从 profile 的 models.yml 读出的首个自定义模型(`<provider>/<id>` 形式),读不到为 null */
model: string | null
}
/** Returns the first custom model with its OMP provider namespace. */
export function parseFirstProfileModel(modelsYaml: string): string | null {
let inProviders = false
let providerId: string | null = null
let inModels = false
for (const line of modelsYaml.split(/\r?\n/)) {
if (!line.trim() || line.trimStart().startsWith('#')) continue
const indent = line.length - line.trimStart().length
const content = line.trimStart()
if (indent === 0) {
inProviders = /^providers:\s*(?:#.*)?$/.test(content)
providerId = null
inModels = false
continue
}
if (!inProviders) continue
if (indent === 2) {
const provider = content.match(/^(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9._-]+)):\s*(?:#.*)?$/)
providerId = provider?.[1] ?? provider?.[2] ?? provider?.[3] ?? null
inModels = false
continue
}
if (!providerId) continue
if (indent === 4) {
inModels = /^models:\s*(?:#.*)?$/.test(content)
continue
}
if (!inModels || indent < 6) continue
const model = content.match(/^[-]\s*id:\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))\s*(?:#.*)?$/)
const modelId = model?.[1] ?? model?.[2] ?? model?.[3]
if (modelId) return `${providerId}/${modelId}`
}
return null
}
/** Reads the executable and first provider/model pair declared in the selected profile. */
export function getOmpInfo(profile = 'ycode'): OmpInfo {
const exePath = detectOmpPath()
if (!exePath) {
return { available: false, exePath: null, profile, model: null }
}
const modelsPath = join(homedir(), '.omp', 'profiles', profile, 'agent', 'models.yml')
let model: string | null = null
try {
const text = readFileSync(modelsPath, 'utf8')
model = parseFirstProfileModel(text)
} catch {
// profile 未配置时只暴露可用性
}
return { available: true, exePath, profile, model }
}
export class OmpKernel extends EventEmitter {
private child: ChildProcessWithoutNullStreams | null = null
private pending = new Map<string, Pending>()
private seq = 0
private starting: Promise<void> | null = null
private readyDone = false
private disposed = false
private disposal: Promise<void> | null = null
private startupErrorReported = false
constructor(
private readonly exePath: string,
private readonly cwd: string,
private readonly profile = 'ycode',
private readonly sessionPath?: string,
private readonly modelOverride?: string | null,
private readonly environment?: NodeJS.ProcessEnv
) {
super()
}
/** 启动进程并等待 ready 帧;重复调用只会启动一次 */
async start(): Promise<void> {
if (this.disposed) throw new Error('OMP 内核已释放')
if (this.readyDone) return
if (this.starting) return this.starting
this.startupErrorReported = false
const starting = this.startProcess().catch((err: Error) => {
if (this.starting === starting) this.starting = null
if (!this.startupErrorReported && this.listenerCount('error') > 0) {
this.startupErrorReported = true
this.emit('error', err)
}
throw err
})
this.starting = starting
return starting
}
private async startProcess(): Promise<void> {
if (!existsSync(this.exePath)) {
throw new Error(`OMP 可执行文件不存在:${this.exePath}`)
}
if (this.sessionPath) {
if (!isAbsolute(this.sessionPath)) throw new Error('OMP 会话路径必须是绝对路径')
await mkdir(dirname(this.sessionPath), { recursive: true })
}
if (this.disposed) throw new Error('OMP 内核已释放')
// spawn 的 cwd 必须存在,否则报 ENOENT(与可执行文件无关)
const cwd = existsSync(this.cwd) ? this.cwd : homedir()
const model = this.modelOverride === undefined ? getOmpInfo(this.profile).model : this.modelOverride
if (!model || model.indexOf('/') <= 0 || model.endsWith('/')) {
throw new Error(`OMP profile "${this.profile}" 缺少有效的 provider/model;请在 agent/models.yml 中配置模型`)
}
const args = ['--mode', 'rpc', '--profile', this.profile, `--model=${model}`, '--cwd', cwd, '--no-tools']
if (this.sessionPath) {
args.push(`--session-dir=${dirname(this.sessionPath)}`, `--resume=${this.sessionPath}`)
} else {
args.push('--no-session')
}
const child = spawn(this.exePath, args, { cwd, windowsHide: true, env: { ...process.env, ...this.environment } })
this.child = child
const rl = createInterface({ input: child.stdout })
await new Promise<void>((resolve, reject) => {
let settled = false
let startupTimer: NodeJS.Timeout | undefined
const settle = (err?: Error): void => {
if (settled) return
settled = true
if (startupTimer) clearTimeout(startupTimer)
this.off('frame', onReady)
if (err) reject(err)
else resolve()
}
const onReady = (frame: OmpFrame): void => {
if (frame.type !== 'ready') return
this.readyDone = true
settle()
}
child.on('error', (err) => {
this.startupErrorReported = true
if (this.listenerCount('error') > 0) this.emit('error', err)
settle(err)
})
child.on('exit', (code) => {
const wasReady = this.readyDone
this.emit('exit', code)
rl.close()
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(new Error(`OMP 进程已退出(code=${code}),命令 ${id} 未完成`))
}
this.pending.clear()
if (this.child === child) this.child = null
this.readyDone = false
this.starting = null
if (!wasReady) settle(new Error(`OMP 进程在 ready 前退出(code=${code})`))
})
child.stderr.on('data', (chunk: Buffer) => {
this.emit('stderr', chunk.toString())
})
rl.on('line', (line) => this.handleLine(line))
this.on('frame', onReady)
startupTimer = setTimeout(() => settle(new Error('OMP 启动超时(未收到 ready 帧)')), 30_000)
startupTimer.unref()
})
}
private handleLine(line: string): void {
const trimmed = line.trim()
if (!trimmed) return
if (trimmed.length > MAX_FRAME_BYTES) {
this.emit('error', new Error(`OMP 帧超过上限(${trimmed.length} 字节)`))
return
}
let frame: OmpFrame
try {
frame = JSON.parse(trimmed) as OmpFrame
} catch {
this.emit('error', new Error(`OMP 输出无法解析:${trimmed.slice(0, 200)}`))
return
}
// 带响应 id 的帧 → 关联到挂起命令
if (frame.id && this.pending.has(frame.id)) {
const p = this.pending.get(frame.id)
if (p) {
this.pending.delete(frame.id)
clearTimeout(p.timer)
p.resolve(frame)
}
// 响应帧同时也广播(prompt 的 ack 可能带 agentInvoked 信息)
this.emit('frame', frame)
return
}
this.emit('frame', frame)
this.emit('event', frame)
}
/** 发送一条命令并等待其响应 */
async send(command: Record<string, unknown>, timeoutMs = COMMAND_TIMEOUT_MS): Promise<OmpFrame> {
await this.start()
const id = `req_${++this.seq}`
const payload = JSON.stringify({ id, ...command })
return new Promise<OmpFrame>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`OMP 命令超时:${String(command.type)}`))
}, timeoutMs)
timer.unref?.()
this.pending.set(id, { resolve, reject, timer })
this.child?.stdin.write(payload + '\n')
})
}
/** 发起一次对话回合;事件经 `event` 回调流出 */
async prompt(message: string, images?: OmpPromptImage[]): Promise<OmpFrame> {
const command: Record<string, unknown> = { type: 'prompt', message }
if (images?.length) command.images = images
return this.send(command, COMMAND_TIMEOUT_MS * 10)
}
async abort(): Promise<OmpFrame> {
return this.send({ type: 'abort' }, 15_000)
}
async getMessages(): Promise<OmpFrame<{ messages: OmpAgentMessage[] }>> {
return this.send({ type: 'get_messages' }) as Promise<OmpFrame<{ messages: OmpAgentMessage[] }>>
}
async getTree(): Promise<OmpFrame<{ tree: OmpSessionTreeNode[]; leafId: string | null }>> {
return this.send({ type: 'get_tree' }) as Promise<OmpFrame<{ tree: OmpSessionTreeNode[]; leafId: string | null }>>
}
async getBranchMessages(): Promise<OmpFrame<{ messages: OmpBranchMessage[] }>> {
return this.send({ type: 'get_branch_messages' }) as Promise<OmpFrame<{ messages: OmpBranchMessage[] }>>
}
async branch(entryId: string): Promise<OmpFrame<{ text?: string; cancelled?: boolean }>> {
if (!entryId.trim()) throw new Error('OMP branch requires a native user message entryId')
return this.send({ type: 'branch', entryId }) as Promise<OmpFrame<{ text?: string; cancelled?: boolean }>>
}
async setHostTools(tools: OmpHostToolDefinition[]): Promise<OmpFrame<{ toolNames: string[] }>> {
return this.send({ type: 'set_host_tools', tools }) as Promise<OmpFrame<{ toolNames: string[] }>>
}
/** Levels come from `getAvailableThinkingLevels()`; `off` disables thinking for this session. */
async setThinkingLevel(level: string): Promise<void> {
await this.send({ type: 'set_thinking_level', level })
}
async getAvailableThinkingLevels(): Promise<string[]> {
const frame = await this.send({ type: 'get_available_thinking_levels' }) as OmpFrame<{ levels?: string[] }>
return Array.isArray(frame.levels) ? frame.levels : []
}
/** Resolve an OMP host_tool_call. The host request id, not toolCallId, correlates the reply. */
async respondToHostTool(requestId: string, content: string, isError = false): Promise<void> {
await this.start()
if (!requestId.trim()) throw new Error('OMP host tool response requires a request id')
const child = this.child
if (!child) throw new Error('OMP sidecar is not running')
const payload = {
type: 'host_tool_result',
id: requestId,
result: { content: [{ type: 'text', text: content }] },
...(isError ? { isError: true } : {})
}
child.stdin.write(`${JSON.stringify(payload)}\n`)
}
dispose(): Promise<void> {
if (this.disposal) return this.disposal
this.disposed = true
const child = this.child
if (!child) {
this.removeAllListeners()
this.disposal = Promise.resolve()
return this.disposal
}
this.disposal = new Promise<void>((resolve) => {
let completed = false
let killTimer: NodeJS.Timeout | undefined
const finish = (): void => {
if (completed) return
completed = true
if (killTimer) clearTimeout(killTimer)
child.off('exit', finish)
child.off('close', finish)
child.off('error', finishOnSpawnError)
if (this.child === child) this.child = null
this.removeAllListeners()
resolve()
}
const finishOnSpawnError = (): void => {
if (child.pid === undefined) finish()
}
if (child.exitCode !== null || child.signalCode !== null) {
finish()
return
}
child.once('exit', finish)
child.once('close', finish)
child.on('error', finishOnSpawnError)
try {
child.stdin.end()
} catch {
// 进程可能已退出
}
killTimer = setTimeout(() => {
if (child.exitCode !== null || child.signalCode !== null) return
try {
child.kill()
} catch {
// exit / close 事件仍负责完成释放
}
}, 1500)
killTimer.unref()
})
return this.disposal
}
}
+162
View File
@@ -0,0 +1,162 @@
import { promises as fs } from 'node:fs'
import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { DownloadPayload, ResourceRange, WorkspaceResource } from '@shared/resources'
// YCode host adapter for the restored renderer's path/image/download contracts.
const MAX_TEXT_BYTES = 2 * 1024 * 1024
const MAX_IMAGE_BYTES = 20 * 1024 * 1024
const MAX_DOWNLOAD_BYTES = 32 * 1024 * 1024
const IMAGE_MIME: Record<string, string> = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.avif': 'image/avif', '.ico': 'image/x-icon'
}
function nativePath(input: string): string {
if (/^local-file:/i.test(input)) return fileURLToPath(`file:${input.slice('local-file:'.length)}`)
if (/^file:/i.test(input)) return fileURLToPath(input)
if (!isAbsolute(input) && /^[a-z][a-z0-9+.-]*:/i.test(input)) throw new Error('不支持的本地路径格式')
return input
}
/** Resolve both lexical and real paths so symlinks cannot cross the workspace boundary. */
export async function resolveWorkspacePath(workspace: string, input: string): Promise<string> {
if (typeof input !== 'string' || !input.trim() || input.includes('\0')) throw new Error('路径为空或无效')
const root = await fs.realpath(workspace)
const target = resolve(root, nativePath(input.trim()))
const contains = (path: string): boolean => {
const rel = relative(root, path)
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`))
}
if (!contains(target)) throw new Error('路径超出工作目录')
const real = await fs.realpath(target)
if (!contains(real)) throw new Error('路径超出工作目录')
return real
}
export async function classifyWorkspacePath(workspace: string, input: string): Promise<'file' | 'directory' | 'unknown'> {
try {
const path = await resolveWorkspacePath(workspace, input)
const stat = await fs.stat(path)
return stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'unknown'
} catch { return 'unknown' }
}
async function readBounded(path: string, limit: number): Promise<Buffer> {
const file = await fs.open(path, 'r')
try {
const stat = await file.stat()
if (!stat.isFile()) throw new Error('不是普通文件')
if (stat.size > limit) throw new Error(`文件超过预览大小限制(${Math.round(limit / 1024 / 1024)} MB)`)
const buffer = Buffer.alloc(Math.min(stat.size + 1, limit + 1))
let count = 0
while (count < buffer.length) {
const { bytesRead } = await file.read(buffer, count, buffer.length - count, null)
if (!bytesRead) break
count += bytesRead
}
if (count > limit) throw new Error('文件超过预览大小限制')
return buffer.subarray(0, count)
} finally { await file.close() }
}
export async function resolveWorkspaceImage(workspace: string, source: string): Promise<string | undefined> {
try {
if (/^data:image\//i.test(source) || /^https?:/i.test(source)) return source
const path = await resolveWorkspacePath(workspace, source)
const mime = IMAGE_MIME[extname(path).toLowerCase()]
if (!mime) return undefined
return `data:${mime};base64,${(await readBounded(path, MAX_IMAGE_BYTES)).toString('base64')}`
} catch { return undefined }
}
export async function readWorkspaceResource(workspace: string, input: string, range?: ResourceRange): Promise<WorkspaceResource> {
const path = await resolveWorkspacePath(workspace, input)
const stat = await fs.stat(path)
const name = basename(path)
if (stat.isDirectory()) {
const all = await fs.readdir(path, { withFileTypes: true })
const entries = all.filter((entry) => entry.isFile() || entry.isDirectory())
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
.slice(0, 500).map((entry) => ({ name: entry.name, path: resolve(path, entry.name), type: entry.isDirectory() ? 'directory' as const : 'file' as const }))
return { kind: 'directory', path, name, entries, truncated: all.length > 500 }
}
const mime = IMAGE_MIME[extname(path).toLowerCase()]
if (mime) {
const buffer = await readBounded(path, MAX_IMAGE_BYTES)
return { kind: 'image', path, name, url: `data:${mime};base64,${buffer.toString('base64')}` }
}
const buffer = await readBounded(path, MAX_TEXT_BYTES)
if (buffer.includes(0)) throw new Error('该文件暂不支持文本预览')
const lines = buffer.toString('utf8').split(/\r?\n/)
const start = range && Number.isFinite(range.start) ? Math.min(lines.length, Math.max(1, Math.trunc(range.start))) : 1
const end = range?.end && Number.isFinite(range.end) ? Math.max(start, Math.min(lines.length, Math.trunc(range.end))) : start
const firstLine = Math.max(1, start - 20)
const count = Math.min(2000, Math.max(400, end - firstLine + 21))
return { kind: 'text', path, name, content: lines.slice(firstLine - 1, firstLine - 1 + count).join('\n'), firstLine,
totalLines: lines.length, range: range ? { start, end } : undefined, truncated: firstLine > 1 || firstLine - 1 + count < lines.length }
}
/** Artifact readers need the whole document; the line-window preview is unsuitable for diffs. */
export async function readWorkspaceText(workspace: string, input: string): Promise<string> {
const path = await resolveWorkspacePath(workspace, input)
const buffer = await readBounded(path, MAX_TEXT_BYTES)
if (buffer.includes(0)) throw new Error('该文件暂不支持文本预览')
return buffer.toString('utf8')
}
export function decodeDownload(payload: DownloadPayload): { filename: string; bytes: Buffer; mimeType: string } {
if (!payload || typeof payload.filename !== 'string' || typeof payload.data !== 'string' || !['utf8', 'base64'].includes(payload.encoding)) throw new Error('下载内容无效')
if (payload.data.length > MAX_DOWNLOAD_BYTES * 1.4) throw new Error('下载内容过大')
const allowed = new Set([...Object.values(IMAGE_MIME), 'text/plain', 'text/markdown'])
const mimeType = typeof payload.mimeType === 'string' ? payload.mimeType.split(';')[0].trim().toLowerCase() : ''
if (!allowed.has(mimeType)) throw new Error('不支持的下载格式')
const filename = basename(payload.filename.replace(/\\/g, '/')).replace(/[<>:"|?*\x00-\x1f]/g, '_')
if (!filename || filename === '.' || filename === '..') throw new Error('文件名无效')
const bytes = Buffer.from(payload.data, payload.encoding)
if (bytes.length > MAX_DOWNLOAD_BYTES) throw new Error('下载内容过大')
return { filename, bytes, mimeType }
}
export async function imageDownloadPayload(workspace: string, source: string, filename?: string): Promise<DownloadPayload> {
let mime: string
let bytes: Buffer
let suggestedName = 'image'
if (/^data:image\//i.test(source)) {
const match = /^data:(image\/[^;,]+)(;base64)?,([\s\S]*)$/i.exec(source)
if (!match || source.length > MAX_IMAGE_BYTES * 1.4) throw new Error('图片内容无效或过大')
mime = match[1].toLowerCase()
bytes = match[2] ? Buffer.from(match[3], 'base64') : Buffer.from(decodeURIComponent(match[3]))
} else if (/^https?:/i.test(source)) {
const url = new URL(source)
suggestedName = basename(url.pathname) || 'image'
const response = await fetch(url, { signal: AbortSignal.timeout(15000) })
if (!response.ok) throw new Error(`图片下载失败(${response.status})`)
mime = response.headers.get('content-type')?.split(';')[0].toLowerCase() ?? ''
if (!mime.startsWith('image/')) throw new Error('下载内容不是图片')
const reader = response.body?.getReader()
if (!reader) throw new Error('图片内容为空')
const chunks: Uint8Array[] = []
let size = 0
try {
while (true) {
const { value, done } = await reader.read()
if (done) break
size += value.length
if (size > MAX_IMAGE_BYTES) throw new Error('图片超过下载大小限制')
chunks.push(value)
}
} finally { await reader.cancel().catch(() => undefined); reader.releaseLock() }
bytes = Buffer.concat(chunks)
} else {
const path = await resolveWorkspacePath(workspace, source)
mime = IMAGE_MIME[extname(path).toLowerCase()]
if (!mime) throw new Error('不支持的图片格式')
suggestedName = basename(path)
bytes = await readBounded(path, MAX_IMAGE_BYTES)
}
if (bytes.length > MAX_IMAGE_BYTES) throw new Error('图片超过下载大小限制')
const extension = Object.keys(IMAGE_MIME).find((key) => IMAGE_MIME[key] === mime) || '.png'
const name = filename || (extname(suggestedName) ? suggestedName : `${suggestedName}${extension}`)
return { filename: name, mimeType: mime, data: bytes.toString('base64'), encoding: 'base64' }
}
+222
View File
@@ -0,0 +1,222 @@
/**
* 会话与消息持久化。
*
* 存储:userData/sessions.json(单文件,MVP 规模足够)
* 真实业务后续应换成 SQLite;此处刻意把接口收敛成 Store 形态,便于替换实现。
*/
import { app } from 'electron'
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { ChatMessage, SessionMeta } from '@shared/types'
import { applyAssistantEvent } from '@shared/message-parts'
import type { SessionContext } from '@shared/workspaces'
import { migrateImportedWorkBuddyMessages, type WorkBuddyImportProvenance } from '@shared/workbuddy-import'
import { agentMode, agentModeContextPatch } from './agent/mode-policy'
interface SessionRecord extends SessionMeta {
messages: ChatMessage[]
pendingNativeBranchId?: string
importProvenance?: WorkBuddyImportProvenance
}
interface StoreShape {
sessions: SessionRecord[]
}
let cache: StoreShape | null = null
let loadPromise: Promise<StoreShape> | null = null
let persistQueue: Promise<void> = Promise.resolve()
function filePath(): string {
return join(app.getPath('userData'), 'sessions.json')
}
async function load(): Promise<StoreShape> {
if (cache) return cache
if (!loadPromise) {
loadPromise = (async () => {
try {
const raw = await fs.readFile(filePath(), 'utf8')
const parsed = JSON.parse(raw) as StoreShape
cache = { sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [] }
let recovered = false
for (const session of cache.sessions) {
const migrated = migrateImportedWorkBuddyMessages(session.messages ?? [], session.importProvenance)
if (migrated.changed) { session.messages = migrated.messages; recovered = true }
session.messages = (session.messages ?? []).map((message) => {
if (message.status !== 'streaming') return message
recovered = true
return { ...applyAssistantEvent(message, { runId: message.requestId ?? '', type: 'cancelled' }), interrupted: true }
})
}
if (recovered) await persist()
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { cache = null; throw error }
cache = { sessions: [] }
}
return cache as StoreShape
})()
}
const pending = loadPromise
try {
return await pending
} finally {
if (loadPromise === pending) loadPromise = null
}
}
async function persist(): Promise<void> {
if (!cache) return
const snapshot = JSON.stringify(cache, null, 2)
const userData = app.getPath('userData')
const file = filePath()
const temporaryFile = join(userData, `.sessions-${randomUUID()}.tmp`)
const write = persistQueue.then(async () => {
await fs.mkdir(userData, { recursive: true })
try {
await fs.writeFile(temporaryFile, snapshot, 'utf8')
await fs.rename(temporaryFile, file)
} catch (error) {
await fs.rm(temporaryFile, { force: true }).catch(() => undefined)
throw error
}
})
// A failed write reaches its caller but does not reject subsequent writes.
persistQueue = write.catch(() => undefined)
await write
}
export async function listSessions(): Promise<SessionMeta[]> {
const store = await load()
return [...store.sessions]
.sort((a, b) => b.updatedAt - a.updatedAt)
.map(({ id, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level }) => ({ id, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level }))
}
export async function createSession(title = '新对话', context: SessionContext = {}): Promise<SessionMeta> {
context = agentModeContextPatch({}, context)
const store = await load()
const now = Date.now()
const record: SessionRecord = { ...context, id: randomUUID(), title, createdAt: now, updatedAt: now, messages: [] }
store.sessions.push(record)
await persist()
return { ...context, id: record.id, title: record.title, createdAt: now, updatedAt: now }
}
export async function getSessionContext(id: string): Promise<SessionContext> {
const session = (await load()).sessions.find((item) => item.id === id)
if (!session) throw new Error('会话不存在')
return { workspaceId: session.workspaceId, cwd: session.cwd, modelId: session.modelId, expertId: session.expertId, agentMode: agentMode(session.agentMode), thinkingLevel: session.thinkingLevel }
}
export async function updateSessionContext(id: string, patch: SessionContext): Promise<SessionMeta> {
const session = (await load()).sessions.find((item) => item.id === id)
if (!session) throw new Error('会话不存在')
const previous = { ...session }
Object.assign(session, agentModeContextPatch(session, patch))
try { await persist() } catch (error) {
for (const key of Object.keys(session)) if (!Object.hasOwn(previous, key)) delete (session as unknown as Record<string, unknown>)[key]
Object.assign(session, previous); throw error
}
const { id: sessionId, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level } = session
return { id: sessionId, title, createdAt, updatedAt, workspaceId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level }
}
export async function getMessages(sessionId: string): Promise<ChatMessage[]> {
const store = await load()
return store.sessions.find((s) => s.id === sessionId)?.messages ?? []
}
export async function getPendingNativeBranch(sessionId: string): Promise<string | undefined> {
return (await load()).sessions.find((session) => session.id === sessionId)?.pendingNativeBranchId
}
export async function setPendingNativeBranch(sessionId: string, entryId: string | undefined): Promise<void> {
const session = (await load()).sessions.find((item) => item.id === sessionId)
if (!session) throw new Error('会话不存在')
const previous = session.pendingNativeBranchId
session.pendingNativeBranchId = entryId
try { await persist() } catch (error) { session.pendingNativeBranchId = previous; throw error }
}
export async function appendMessage(sessionId: string, message: ChatMessage): Promise<void> {
const store = await load()
const session = store.sessions.find((s) => s.id === sessionId)
if (!session) return
session.messages.push(message)
session.updatedAt = Date.now()
await persist()
}
export async function patchMessage(
sessionId: string,
messageId: string,
patch: Partial<ChatMessage>
): Promise<void> {
const store = await load()
const session = store.sessions.find((s) => s.id === sessionId)
if (!session) return
const message = session.messages.find((m) => m.id === messageId)
if (!message) return
Object.assign(message, patch)
session.updatedAt = Date.now()
await persist()
}
export async function renameSession(sessionId: string, title: string): Promise<void> {
const store = await load()
const session = store.sessions.find((s) => s.id === sessionId)
if (!session) return
session.title = title.trim() || session.title
session.updatedAt = Date.now()
await persist()
}
export async function deleteSession(sessionId: string): Promise<void> {
const store = await load()
store.sessions = store.sessions.filter((s) => s.id !== sessionId)
await persist()
}
/** Keep the abandoned branch on disk before replacing the active conversation. */
export async function replaceMessages(sessionId: string, messages: ChatMessage[], reason: string, options?: { pendingNativeBranchId?: string }): Promise<void> {
const store = await load()
const session = store.sessions.find((item) => item.id === sessionId)
if (!session) throw new Error('会话不存在')
const directory = join(app.getPath('userData'), 'session-branches', encodeURIComponent(sessionId))
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(join(directory, `${Date.now()}-${randomUUID()}.json`), JSON.stringify({ reason, session }, null, 2), 'utf8')
const previous = session.messages
const updatedAt = session.updatedAt
const previousBranch = session.pendingNativeBranchId
session.messages = messages
if (options) session.pendingNativeBranchId = options.pendingNativeBranchId
session.updatedAt = Date.now()
try { await persist() } catch (error) { session.messages = previous; session.updatedAt = updatedAt; session.pendingNativeBranchId = previousBranch; throw error }
}
/** A turn and its selected native branch are one atomic session-store mutation. */
export async function startTurn(sessionId: string, user: ChatMessage, assistant: ChatMessage, prefix: ChatMessage[], reason?: string, nativeBranchId?: string): Promise<void> {
const store = await load()
const session = store.sessions.find((item) => item.id === sessionId)
if (!session) throw new Error('会话不存在')
if (reason) {
const directory = join(app.getPath('userData'), 'session-branches', encodeURIComponent(sessionId))
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(join(directory, `${Date.now()}-${randomUUID()}.json`), JSON.stringify({ reason, session }, null, 2), 'utf8')
}
const previous = { messages: session.messages, title: session.title, updatedAt: session.updatedAt, pendingNativeBranchId: session.pendingNativeBranchId }
session.messages = [...prefix, user, assistant]
if (!prefix.length) session.title = deriveTitle(user.content)
if (nativeBranchId) session.pendingNativeBranchId = nativeBranchId
session.updatedAt = Date.now()
try { await persist() } catch (error) { Object.assign(session, previous); throw error }
}
/** 用首条用户消息生成标题 */
export function deriveTitle(text: string): string {
const flat = text.replace(/\s+/g, ' ').trim()
return flat.length > 24 ? `${flat.slice(0, 24)}…` : flat || '新对话'
}
+19
View File
@@ -0,0 +1,19 @@
import type { GatewayConfig } from '@shared/types'
/** Native transcription adapter. A provider failure is returned to the actual voice UI. */
export async function transcribeAudio(config: GatewayConfig, payload: { audioBase64: string; mimeType: string }): Promise<{ text: string }> {
if (!config.apiKey) throw new Error('语音转写需要配置支持转写的模型服务')
if (typeof payload?.audioBase64 !== 'string' || payload.audioBase64.length > 32 * 1024 * 1024) throw new Error('录音内容无效或过大')
if (!['audio/wav', 'audio/x-wav', 'audio/webm', 'audio/ogg', 'audio/mp4'].includes(payload.mimeType)) throw new Error('录音格式不受支持')
const bytes = Buffer.from(payload.audioBase64, 'base64')
const extension = payload.mimeType.includes('wav') ? 'wav' : payload.mimeType.split('/')[1]
const form = new FormData()
form.append('file', new Blob([bytes], { type: payload.mimeType }), `recording.${extension}`)
form.append('model', config.speechModel || 'whisper-1')
const base = (config.speechBaseUrl || config.baseUrl).replace(/\/+$/, '')
const response = await fetch(`${base}/audio/transcriptions`, { method: 'POST', headers: { authorization: `Bearer ${config.apiKey}` }, body: form, signal: AbortSignal.timeout(90000) })
if (!response.ok) throw new Error(`语音转写失败(${response.status});请检查模型服务是否支持语音转写`)
const result = await response.json() as { text?: unknown }
if (typeof result.text !== 'string') throw new Error('语音服务未返回有效文本')
return { text: result.text }
}
+178
View File
@@ -0,0 +1,178 @@
import type { BrowserWindow } from 'electron'
import { promises as fs } from 'node:fs'
import { isAbsolute, join, resolve } from 'node:path'
import { randomUUID } from 'node:crypto'
import { spawn, type IPty, type IDisposable } from 'node-pty'
import type { TerminalHostEvent, TerminalHostView } from '@shared/terminal-host'
export const TERMINAL_HOST_LIMITS = Object.freeze({ perWindow: 8, perSession: 4, total: 32, retainedPerWindow: 32,
bufferCharacters: 262_144, inputCharacters: 65_536, pendingCharacters: 262_144 })
interface TerminalEntry {
owner: BrowserWindow
view: TerminalHostView
pty: IPty
listeners: IDisposable[]
pending: string
timer?: ReturnType<typeof setTimeout>
}
const terminals = new Map<string, TerminalEntry>()
const owners = new WeakSet<BrowserWindow>()
function validOwner(owner: BrowserWindow): void {
if (!owner || owner.isDestroyed() || owner.webContents.isDestroyed()) throw new Error('终端所属窗口已关闭')
}
function session(value?: string): string | undefined {
if (value === undefined) return undefined
if (typeof value !== 'string' || !value.trim() || value.length > 128) throw new Error('终端会话标识无效')
return value
}
function dimension(value: number | undefined, fallback: number): number {
if (value === undefined) return fallback
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 2 || value > 1000) throw new Error('终端尺寸无效')
return value
}
function owned(owner: BrowserWindow, id: string, sessionId?: string): TerminalEntry {
validOwner(owner)
const entry = terminals.get(id)
if (!entry || entry.owner !== owner || entry.view.sessionId !== session(sessionId)) throw new Error('终端不存在或不属于当前会话')
return entry
}
/** Clone the environment and remove credentials before creating any shell child process. */
export function terminalEnvironment(environment: NodeJS.ProcessEnv = process.env): Record<string, string> {
const clean: Record<string, string> = {}
for (const [name, value] of Object.entries(environment)) {
if (value === undefined || /^=/u.test(name)) continue
if (/(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE)/iu.test(name)
|| /^(?:ELECTRON_RUN_AS_NODE|NODE_OPTIONS|NODE_PATH)$/iu.test(name)) continue
clean[name] = value
}
clean.TERM = 'xterm-256color'
clean.COLORTERM = 'truecolor'
return clean
}
function send(entry: TerminalEntry, event: TerminalHostEvent): void {
if (entry.owner.isDestroyed() || entry.owner.webContents.isDestroyed()) return
try { entry.owner.webContents.send('terminal:event', event) } catch { /* A closing owner is cleaned by its closed listener. */ }
}
function flush(entry: TerminalEntry): void {
if (entry.timer) clearTimeout(entry.timer)
entry.timer = undefined
if (!entry.pending) return
const data = entry.pending
entry.pending = ''
send(entry, { id: entry.view.id, sessionId: entry.view.sessionId, sequence: ++entry.view.sequence, type: 'data', data })
}
function append(entry: TerminalEntry, data: string): void {
entry.view.buffer = (entry.view.buffer + data).slice(-TERMINAL_HOST_LIMITS.bufferCharacters)
const last = entry.view.replay.at(-1)
if (last && last.cols === entry.view.cols && last.rows === entry.view.rows) last.data += data
else entry.view.replay.push({ data, cols: entry.view.cols, rows: entry.view.rows })
let excess = entry.view.replay.reduce((total, chunk) => total + chunk.data.length, 0) - TERMINAL_HOST_LIMITS.bufferCharacters
while (excess > 0) {
const first = entry.view.replay[0]
if (first.data.length <= excess) { excess -= first.data.length; entry.view.replay.shift() }
else { first.data = first.data.slice(excess); excess = 0 }
}
entry.pending = (entry.pending + data).slice(-TERMINAL_HOST_LIMITS.pendingCharacters)
if (!entry.timer) entry.timer = setTimeout(() => flush(entry), 16)
}
function remove(entry: TerminalEntry): void {
terminals.delete(entry.view.id)
if (entry.timer) clearTimeout(entry.timer)
entry.timer = undefined
entry.pending = ''
entry.listeners.forEach(listener => listener.dispose())
if (!entry.view.exited) { try { entry.pty.kill() } catch { /* Already exited between the lifecycle checks. */ } }
}
/** cwd must be resolved from the bound session/selected workspace by the IPC host. */
export async function createTerminal(owner: BrowserWindow, cwd: string, sessionId?: string, cols = 80, rows = 24): Promise<TerminalHostView> {
validOwner(owner)
const ownerSession = session(sessionId), columns = dimension(cols, 80), lines = dimension(rows, 24)
if (typeof cwd !== 'string' || !isAbsolute(cwd)) throw new Error('终端需要有效的绝对工作目录')
const root = resolve(cwd)
if (!(await fs.stat(root).catch(() => null))?.isDirectory()) throw new Error('工作空间文件夹不存在')
validOwner(owner)
const existing = [...terminals.values()]
const active = existing.filter(entry => !entry.view.exited)
if (active.length >= TERMINAL_HOST_LIMITS.total || active.filter(entry => entry.owner === owner).length >= TERMINAL_HOST_LIMITS.perWindow
|| active.filter(entry => entry.owner === owner && entry.view.sessionId === ownerSession).length >= TERMINAL_HOST_LIMITS.perSession) {
throw new Error('打开的终端数量已达上限,请先关闭其他终端')
}
const retained = existing.filter(entry => entry.owner === owner)
if (retained.length >= TERMINAL_HOST_LIMITS.retainedPerWindow) {
const retired = retained.find(entry => entry.view.exited)
if (retired) remove(retired)
else throw new Error('打开的终端数量已达上限,请先关闭其他终端')
}
// No profile scripts, command interpolation or gateway credentials enter this process.
const shell = process.platform === 'win32'
? join(process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : '/bin/bash'
let pty: IPty
try { pty = spawn(shell, process.platform === 'win32' ? ['-NoLogo', '-NoProfile'] : ['--noprofile', '--norc'], {
name: 'xterm-256color', cols: columns, rows: lines, cwd: root, env: terminalEnvironment(),
...(process.platform === 'win32' ? { useConpty: true, useConptyDll: true } : { encoding: 'utf8' })
}) } catch { throw new Error('无法启动本地终端,请检查 PowerShell 和系统终端支持') }
const view: TerminalHostView = { id: randomUUID(), sessionId: ownerSession, cwd: root, title: 'PowerShell', shell,
cols: columns, rows: lines, buffer: '', replay: [], sequence: 0, exited: false }
const entry: TerminalEntry = { owner, pty, view, listeners: [], pending: '' }
terminals.set(view.id, entry)
entry.listeners.push(pty.onData(data => { if (terminals.has(view.id)) append(entry, data) }), pty.onExit(({ exitCode }) => {
if (!terminals.has(view.id)) return
flush(entry)
view.exited = true
view.exitCode = exitCode
send(entry, { id: view.id, sessionId: view.sessionId, sequence: ++view.sequence, type: 'exit', exitCode })
}))
if (!owners.has(owner)) { owners.add(owner); owner.once('closed', () => disposeTerminalHosts(owner)) }
return snapshot(entry)
}
function snapshot(entry: TerminalEntry): TerminalHostView { return { ...entry.view, replay: entry.view.replay.map(chunk => ({ ...chunk })) } }
export function getTerminal(owner: BrowserWindow, id: string, sessionId?: string): TerminalHostView {
const entry = owned(owner, id, sessionId)
flush(entry)
return snapshot(entry)
}
export function writeTerminal(owner: BrowserWindow, id: string, data: string, sessionId?: string): void {
const entry = owned(owner, id, sessionId)
if (typeof data !== 'string' || data.length > TERMINAL_HOST_LIMITS.inputCharacters) throw new Error('终端输入过长或无效')
if (entry.view.exited) throw new Error('终端进程已退出')
try { entry.pty.write(data) } catch { throw new Error('终端输入失败,进程可能已退出') }
}
export function resizeTerminal(owner: BrowserWindow, id: string, cols: number, rows: number, sessionId?: string): void {
const entry = owned(owner, id, sessionId), columns = dimension(cols, 80), lines = dimension(rows, 24)
if (entry.view.exited) return
if (columns === entry.view.cols && lines === entry.view.rows) return
try { entry.pty.resize(columns, lines); entry.view.cols = columns; entry.view.rows = lines }
catch { throw new Error('无法调整终端尺寸') }
}
export function clearTerminal(owner: BrowserWindow, id: string, sessionId?: string): void {
const entry = owned(owner, id, sessionId)
flush(entry)
entry.view.buffer = '\x1b[2J\x1b[3J\x1b[H'
entry.view.replay = [{ data: entry.view.buffer, cols: entry.view.cols, rows: entry.view.rows }]
send(entry, { id, sessionId: entry.view.sessionId, sequence: ++entry.view.sequence, type: 'data', data: entry.view.buffer })
}
export function closeTerminal(owner: BrowserWindow, id: string, sessionId?: string): void {
if (!terminals.has(id)) return
remove(owned(owner, id, sessionId))
}
/** Bind a draft terminal to its first persisted conversation without changing its process or cwd. */
export function rebindTerminal(owner: BrowserWindow, id: string, previousSessionId: string | undefined, nextSessionId: string, expectedCwd: string): TerminalHostView {
const entry = owned(owner, id, previousSessionId)
const next = session(nextSessionId)
if (!next || typeof expectedCwd !== 'string' || !isAbsolute(expectedCwd)) throw new Error('终端目标会话或工作目录无效')
const normalized = (value: string): string => process.platform === 'win32' ? resolve(value).toLowerCase() : resolve(value)
if (normalized(expectedCwd) !== normalized(entry.view.cwd)) throw new Error('终端工作目录与目标会话不一致')
if ([...terminals.values()].filter(item => item !== entry && item.owner === owner && item.view.sessionId === next && !item.view.exited).length >= TERMINAL_HOST_LIMITS.perSession) {
throw new Error('目标会话的终端数量已达上限')
}
flush(entry)
entry.view.sessionId = next
return snapshot(entry)
}
export function disposeTerminalHosts(owner?: BrowserWindow): void {
for (const entry of terminals.values()) if (!owner || entry.owner === owner) remove(entry)
}
+35
View File
@@ -0,0 +1,35 @@
import { promises as fs } from 'node:fs'
import { dirname, isAbsolute, relative, resolve, sep, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { RevertChange } from '@shared/revert'
async function replaceFile(path: string, content: string | null): Promise<void> {
if (content === null) { await fs.unlink(path); return }
const temporary = join(dirname(path), `.ycode-restore-${randomUUID()}.tmp`)
try { await fs.writeFile(temporary, content, 'utf8'); await fs.rename(temporary, path) }
finally { await fs.unlink(temporary).catch(() => undefined) }
}
/** Validate every current file before applying any change; roll back if a write fails. */
export async function restoreWorkspaceFiles(workspace: string, changes: RevertChange[]): Promise<() => Promise<void>> {
const root = await fs.realpath(workspace)
const validated: RevertChange[] = []
for (const change of changes) {
const path = await fs.realpath(resolve(change.path))
const rel = relative(root, path)
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error('恢复文件不在当前工作区内')
if (await fs.readFile(path, 'utf8') !== change.after) throw new Error(`文件已被外部修改,未执行恢复:${rel}`)
validated.push({ ...change, path })
}
const applied: RevertChange[] = []
const undo = async (): Promise<void> => {
for (const change of [...applied].reverse()) await replaceFile(change.path, change.after)
}
try {
for (const change of validated) {
if (await fs.readFile(change.path, 'utf8') !== change.after) throw new Error('文件在恢复前已变化')
await replaceFile(change.path, change.before)
applied.push(change)
}
} catch (error) { await undo(); throw error }
return undo
}
+125
View File
@@ -0,0 +1,125 @@
import { app } from 'electron'
import { promises as fs } from 'node:fs'
import { basename, join, resolve } from 'node:path'
import { randomUUID } from 'node:crypto'
import { getWorkspaceRoot } from './config'
import type { WorkspaceRecord, WorkspaceRegistryView } from '@shared/workspaces'
let state: WorkspaceRegistryView | null = null
let loading: Promise<WorkspaceRegistryView> | null = null
let queue: Promise<unknown> = Promise.resolve()
const filename = (): string => join(app.getPath('userData'), 'workspaces.json')
async function load(): Promise<WorkspaceRegistryView> {
if (state) return state
if (!loading) loading = (async () => {
try {
const parsed = JSON.parse(await fs.readFile(filename(), 'utf8')) as WorkspaceRegistryView
if (!Array.isArray(parsed.workspaces)) throw new Error('空间配置损坏')
state = parsed
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
state = { workspaces: [], selectedWorkspaceId: null }
// Preserve the previous global directory as an existing workspace.
const root = await getWorkspaceRoot()
if (await fs.stat(root).then((s) => s.isDirectory()).catch(() => false)) {
const workspace = { id: randomUUID(), name: basename(root), root, createdAt: Date.now() }
state.workspaces.push(workspace)
state.selectedWorkspaceId = workspace.id
}
await persist(state)
}
return state!
})().finally(() => { loading = null })
return loading
}
async function persist(next: WorkspaceRegistryView): Promise<void> {
const path = filename()
await fs.mkdir(app.getPath('userData'), { recursive: true })
const temporary = `${path}.${randomUUID()}.tmp`
try { await fs.writeFile(temporary, JSON.stringify(next, null, 2), 'utf8'); await fs.rename(temporary, path) }
finally { await fs.rm(temporary, { force: true }).catch(() => undefined) }
state = next
}
function mutate<T>(operation: () => Promise<T>): Promise<T> {
const result = queue.then(operation)
queue = result.catch(() => undefined)
return result
}
export async function listWorkspaces(): Promise<WorkspaceRegistryView> { return structuredClone(await load()) }
export function validateWorkspaceName(value: string): string {
const name = value.trim()
if (!name) throw new Error('请输入空间名称')
if (name.length > 100 || /[<>:"/\\|?*\x00-\x1f]/.test(name) || /[. ]$/.test(name) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name) || name === '.' || name === '..') throw new Error('空间名称包含不可用字符')
return name
}
export async function generateWorkspace(nameInput: string): Promise<WorkspaceRegistryView> {
return mutate(async () => {
const name = validateWorkspaceName(nameInput)
const current = await load()
const parent = join(app.getPath('home'), 'YCodeWorkspaces')
await fs.mkdir(parent, { recursive: true })
const root = join(parent, name)
try { await fs.mkdir(root) } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('同名文件夹已存在,请更换名称或打开已有文件夹')
throw error
}
const workspace = { id: randomUUID(), name, root, createdAt: Date.now() }
const next = { workspaces: [...current.workspaces, workspace], selectedWorkspaceId: workspace.id }
try { await persist(next) } catch (error) { await fs.rmdir(root).catch(() => undefined); throw error }
return structuredClone(next)
})
}
export async function attachWorkspace(path: string): Promise<WorkspaceRegistryView> {
return mutate(async () => {
const root = resolve(path)
if (!(await fs.stat(root)).isDirectory()) throw new Error('请选择文件夹')
const current = await load()
const existing = current.workspaces.find((item) => item.root.toLowerCase() === root.toLowerCase())
const record: WorkspaceRecord = existing ?? { id: randomUUID(), name: basename(root), root, createdAt: Date.now() }
const next = { workspaces: existing ? current.workspaces : [...current.workspaces, record], selectedWorkspaceId: record.id }
await persist(next)
return structuredClone(next)
})
}
export async function selectWorkspace(id: string | null): Promise<WorkspaceRegistryView> {
return mutate(async () => {
const current = await load()
if (id && !current.workspaces.some((item) => item.id === id)) throw new Error('空间不存在')
const workspace = current.workspaces.find((item) => item.id === id)
if (workspace && !(await fs.stat(workspace.root).then((info) => info.isDirectory()).catch(() => false))) throw new Error('空间文件夹不存在,请重新打开文件夹')
const next = { ...current, selectedWorkspaceId: id }
await persist(next)
return structuredClone(next)
})
}
export async function renameWorkspace(id: string, nameInput: string): Promise<WorkspaceRegistryView> {
return mutate(async () => {
const name = nameInput.trim()
if (!name) throw new Error('请输入空间名称')
const current = await load()
if (!current.workspaces.some((item) => item.id === id)) throw new Error('空间不存在')
const next = { ...current, workspaces: current.workspaces.map((item) => item.id === id ? { ...item, name } : item) }
await persist(next)
return structuredClone(next)
})
}
export async function removeWorkspace(id: string): Promise<WorkspaceRegistryView> {
return mutate(async () => {
const current = await load()
const next = { workspaces: current.workspaces.filter((item) => item.id !== id), selectedWorkspaceId: current.selectedWorkspaceId === id ? null : current.selectedWorkspaceId }
// Removing the sidebar registration never deletes the user's files.
await persist(next)
return structuredClone(next)
})
}
export async function workspaceRoot(id?: string): Promise<string> {
const current = await load()
const selected = id ?? current.selectedWorkspaceId
const record = current.workspaces.find((item) => item.id === selected)
if (id && !record) throw new Error('空间不存在')
const root = record?.root ?? await getWorkspaceRoot()
if (record) {
if (!(await fs.stat(root).then((info) => info.isDirectory()).catch(() => false))) throw new Error('空间文件夹不存在,请重新打开文件夹')
} else await fs.mkdir(root, { recursive: true })
return root
}