feat: ship skill and MCP workflows with signed updater for v0.0.7

This commit is contained in:
xiaohan.yuan committed 2026-10-21 00:04:17 +08:00
1 parent 1b52df0603
commit a01085c656
136 files changed
+18646 -217

No files matched your search

+215 -14
View File
@@ -15,13 +15,14 @@ import {
} from '@anthropic-ai/claude-agent-sdk'
import { z } from 'zod'
import type { AgentMode, PermissionMode } from '@shared/conversation'
import type { ChatEvent, MessagePart, TokenUsage, ToolCallRecord } from '@shared/types'
import type { ChatEvent, ContextUsageBreakdown, MessagePart, TokenUsage, ToolCallRecord } from '@shared/types'
import type { ResolvedModel } from '@shared/models'
import type { ThinkingLevel } from '@shared/workspaces'
import type { InteractionBroker } from '../../interaction-broker'
import { browserTool } from '../tools/browser'
import { claudeAnthropicBaseUrl } from './claude-endpoint'
import { planFromExitInput, questionsFromInput } from './claude-tool-input'
import { createClaudeCapabilityRuntime } from '../../capabilities/claude-capabilities'
const READ_ONLY_TOOLS = new Set([
'Read',
@@ -75,6 +76,9 @@ const DISALLOWED_TOOLS = [
]
interface ClaudeTurnRequest {
command?: 'compact'
skillIds?: string[]
mcpServerIds?: string[]
runId: string
userMessageId: string
prompt: string
@@ -83,6 +87,20 @@ interface ClaudeTurnRequest {
interactions: InteractionBroker
signal: AbortSignal
ownerWebContentsId?: number
onSegmentComplete?(result: ClaudeTurnResult): Promise<void>
onSegmentStart?(inputs: ClaudeAppendInput[]): Promise<void>
}
export interface ClaudeAppendInput {
userMessageId: string
prompt: string
images?: ClaudeTurnRequest['images']
skillIds?: string[]
mcpServerIds?: string[]
}
function appendError(message: string, submitted: boolean): Error & { submitted: boolean } {
return Object.assign(new Error(message), { submitted })
}
export interface ClaudeTurnResult {
@@ -118,6 +136,14 @@ interface ActiveTurn {
reasoning: Map<number, string>
lastEntryId?: string
planEmitted: boolean
streamUsage?: TokenUsage
nativeUsage?: Record<string, unknown>
compactBoundary?: boolean
foreignOutput?: boolean
groupIds: string[]
awaitingGroup: boolean
appends: Map<string, { input: ClaudeAppendInput; resolve(): void; reject(error: Error): void }>
segmentReady?: { promise: Promise<void>; resolve(): void }
}
class AsyncMessageQueue<T> implements AsyncIterable<T> {
@@ -294,12 +320,18 @@ function tokenUsage(value: unknown): TokenUsage | undefined {
}
export class ClaudeKernel {
private readonly capabilities: ReturnType<typeof createClaudeCapabilityRuntime>
private input: AsyncMessageQueue<SDKUserMessage> | null = null
private processAbort: AbortController | null = null
private instance: Query | null = null
private consuming: Promise<void> | null = null
private active: ActiveTurn | null = null
private starting = false
private draining: Promise<void> | null = null
private contextQuery: ReturnType<Query['getContextUsage']> | null = null
private lastRequestId?: string
private lastGroupKey = ''
private contextQueryGroup = ''
private closed = false
private sessionEstablished = false
private currentSessionId: string
@@ -307,6 +339,9 @@ export class ClaudeKernel {
private agentMode: AgentMode
constructor(private readonly options: ClaudeKernelOptions) {
this.capabilities = createClaudeCapabilityRuntime({ cwd: options.workspaceRoot, requestSignal: () => this.active?.request.signal, beforeCall: async () => {
if (!await this.waitForSegment()) throw new Error('当前没有可调用能力的活动回合')
} })
this.currentSessionId = options.resumeSessionId ?? options.sessionId
this.permissionMode = options.permissionMode
this.agentMode = options.agentMode
@@ -361,7 +396,7 @@ export class ClaudeKernel {
PreToolUse: [{
matcher: 'ExitPlanMode',
hooks: [async (input) => {
const active = this.active
const active = await this.waitForSegment()
if (!active) {
return {
hookSpecificOutput: {
@@ -407,6 +442,7 @@ export class ClaudeKernel {
snapshot: true
},
mcpServers: {
ycode_capabilities: this.capabilities.server,
ycode: createSdkMcpServer({
name: 'ycode',
tools: [
@@ -471,14 +507,16 @@ export class ClaudeKernel {
private async consume(instance: Query): Promise<void> {
try {
for await (const message of instance) this.handleMessage(message)
for await (const message of instance) await this.handleMessage(message)
} catch (error) {
this.failActive(error instanceof Error ? error : new Error(String(error)))
if (!this.closed) console.error('Claude Code process stopped', error)
} finally {
this.failActive(new Error('Claude Code 进程已退出'))
if (this.instance === instance) {
instance.close()
this.instance = null
this.contextQuery = null
this.input = null
this.processAbort = null
}
@@ -486,7 +524,7 @@ export class ClaudeKernel {
}
private canUseTool: CanUseTool = async (toolName, input, options): Promise<PermissionResult | null> => {
const active = this.active
const active = await this.waitForSegment()
if (!active) return { behavior: 'deny', message: '当前没有正在运行的 YCode 回合。' }
if (options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
try {
@@ -558,7 +596,7 @@ export class ClaudeKernel {
}
}
private handleMessage(message: SDKMessage): void {
private async handleMessage(message: SDKMessage): Promise<void> {
const value = message as unknown as Record<string, unknown>
if (typeof value.session_id === 'string' && value.session_id !== this.currentSessionId) {
this.rememberSessionId(value.session_id)
@@ -569,6 +607,45 @@ export class ClaudeKernel {
if (sessionId) this.rememberSessionId(sessionId)
return
}
if (value.type === 'system' && value.subtype === 'compact_boundary' && this.active?.request.command === 'compact') {
this.active.compactBoundary = true
return
}
const activeOwner = this.active
if (activeOwner?.request.signal.aborted && ['stream_event', 'assistant', 'result'].includes(String(value.type))) return
if (activeOwner && ['stream_event', 'assistant', 'result'].includes(String(value.type)) && !value.parent_tool_use_id) {
const ids = Array.isArray(value.user_message_uuids) ? value.user_message_uuids :
typeof value.user_message_uuid === 'string' ? [value.user_message_uuid] : undefined
if (activeOwner.awaitingGroup && ids?.length && ids.every(id => typeof id === 'string' && activeOwner.appends.has(id))) {
const inputs = (ids as string[]).map(id => activeOwner.appends.get(id)!.input)
await activeOwner.request.onSegmentStart!(inputs)
if (this.active !== activeOwner || activeOwner.settled || activeOwner.request.signal.aborted) return
activeOwner.groupIds = ids as string[]
this.lastGroupKey = activeOwner.groupIds.join('|')
activeOwner.request.skillIds = [...new Set([...(activeOwner.request.skillIds ?? []), ...inputs.flatMap(input => input.skillIds ?? [])])]
activeOwner.request.mcpServerIds = [...new Set([...(activeOwner.request.mcpServerIds ?? []), ...inputs.flatMap(input => input.mcpServerIds ?? [])])]
this.capabilities.setRequestSelection({ explicitSkillIds: activeOwner.request.skillIds, selectedMcpServerIds: activeOwner.request.mcpServerIds })
activeOwner.awaitingGroup = false
activeOwner.content = ''
activeOwner.tools = new Map()
activeOwner.toolOrder = []
activeOwner.reasoning = new Map()
activeOwner.streamUsage = undefined
activeOwner.nativeUsage = undefined
activeOwner.lastEntryId = undefined
activeOwner.planEmitted = false
for (const input of inputs) {
const pending = activeOwner.appends.get(input.userMessageId)!
activeOwner.appends.delete(input.userMessageId)
pending.resolve()
}
activeOwner.segmentReady?.resolve()
activeOwner.segmentReady = undefined
}
if (ids?.length) activeOwner.foreignOutput = !ids.some(id => activeOwner.groupIds.includes(String(id)))
if (activeOwner.awaitingGroup) return
if (activeOwner.foreignOutput) return
}
if (value.type === 'stream_event') {
const event = value.event as Record<string, unknown> | undefined
@@ -578,6 +655,19 @@ export class ClaudeKernel {
const index = typeof event.index === 'number' ? event.index : 0
if (event.type === 'message_start') {
active.assistantSequence++
const message = event.message as Record<string, unknown> | undefined
active.nativeUsage = message?.usage && typeof message.usage === 'object' ? message.usage as Record<string, unknown> : undefined
active.streamUsage = tokenUsage(active.nativeUsage)
if (active.streamUsage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage: active.streamUsage })
return
}
if (event.type === 'message_delta') {
const usage = event.usage as Record<string, unknown> | undefined
if (usage && typeof usage === 'object') {
active.nativeUsage = { ...active.nativeUsage, ...usage }
active.streamUsage = tokenUsage(active.nativeUsage)
if (active.streamUsage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage: active.streamUsage })
}
return
}
if (event.type === 'content_block_delta') {
@@ -679,10 +769,13 @@ export class ClaudeKernel {
if (value.type === 'result') {
const active = this.active
if (!active) return
const usage = tokenUsage(value.usage)
if (usage) active.request.emit({ runId: active.request.runId, type: 'usage', usage })
// A result aggregates multiple API calls; it is not the last request's context size.
const usage = active.streamUsage
if (usage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage })
const failed = value.is_error === true || value.subtype !== 'success'
if (failed) {
if (active.request.command === 'compact' && !failed && !active.compactBoundary) {
active.reject(new Error('内核未确认原生压缩完成,不能将普通回复视为压缩成功'))
} else if (failed) {
const errors = Array.isArray(value.errors) ? value.errors.filter((item): item is string => typeof item === 'string') : []
const message = errors.length
? errors.join('\n')
@@ -691,11 +784,25 @@ export class ClaudeKernel {
: `Claude Code 回合失败 (${String(value.subtype ?? 'unknown')})`
active.reject(new Error(message))
} else {
if (active.request.command === 'compact') {
active.content = '上下文压缩完成'
active.request.emit({ runId: active.request.runId, type: 'text', delta: active.content })
}
if (!active.content && typeof value.result === 'string') {
active.content = value.result
active.request.emit({ runId: active.request.runId, type: 'text', delta: value.result })
}
active.resolve({ content: active.content, tools: [...active.toolOrder], status: 'done', entryId: active.lastEntryId })
const result: ClaudeTurnResult = { content: active.content, tools: [...active.toolOrder], status: 'done', entryId: active.lastEntryId }
if (active.appends.size) {
active.awaitingGroup = true
let ready!: () => void
const promise = new Promise<void>(resolve => { ready = resolve })
active.segmentReady = { promise, resolve: ready }
await active.request.onSegmentComplete!(result)
if (this.active !== active || active.settled) return
return
}
active.resolve(result)
}
this.finishActive(active)
}
@@ -712,21 +819,33 @@ export class ClaudeKernel {
private finishActive(active: ActiveTurn): void {
if (active.settled) return
active.settled = true
active.segmentReady?.resolve()
for (const pending of active.appends.values()) pending.reject(appendError('运行已结束,追加消息交付未确认', true))
active.appends.clear()
if (this.active === active) this.active = null
}
private async waitForSegment(): Promise<ActiveTurn | undefined> {
const active = this.active
if (!active) return undefined
if (active.awaitingGroup) await active.segmentReady?.promise
return this.active === active && !active.settled && !active.request.signal.aborted ? active : undefined
}
private failActive(error: Error): void {
const active = this.active
if (!active || active.settled) return
active.reject(error)
active.reject(active.request.signal.aborted ? abortError() : error)
this.finishActive(active)
}
async runTurn(request: ClaudeTurnRequest): Promise<ClaudeTurnResult> {
await this.draining
if (this.closed) throw new Error('Claude 内核已释放')
if (this.active || this.starting) throw new Error('该会话正在运行')
this.starting = true
try {
this.capabilities.setRequestSelection({ explicitSkillIds: request.skillIds, selectedMcpServerIds: request.mcpServerIds })
await this.ensureStarted()
const input = this.input
if (!input) throw new Error('Claude Code 输入流未就绪')
@@ -742,13 +861,23 @@ export class ClaudeKernel {
settled: false,
assistantSequence: 0,
reasoning: new Map(),
planEmitted: false
planEmitted: false,
groupIds: [request.userMessageId], awaitingGroup: false, appends: new Map()
}
const onAbort = (): void => {
if (active.settled) return
void this.instance?.interrupt().catch(() => undefined)
active.reject(abortError())
this.finishActive(active)
active.segmentReady?.resolve()
// Retire the producer before allowing a new turn. A late result from an
// interrupted producer must never settle the next turn's promise.
const consuming = this.consuming
this.processAbort?.abort()
this.instance?.close()
const draining = (consuming ?? Promise.resolve()).catch(() => undefined)
this.draining = draining
void draining.finally(() => { if (this.draining === draining) this.draining = null })
void draining.finally(() => {
if (!active.settled) { active.reject(abortError()); this.finishActive(active) }
})
}
request.signal.addEventListener('abort', onAbort, { once: true })
const originalResolve = active.resolve
@@ -762,9 +891,12 @@ export class ClaudeKernel {
originalReject(error)
}
this.active = active
this.lastRequestId = request.runId
this.lastGroupKey = request.userMessageId
try {
input.push({
type: 'user',
...(request.command ? {} : { client_composed: true }),
message: {
role: 'user',
content: [
@@ -793,12 +925,80 @@ export class ClaudeKernel {
}
}
/** Resolve only after an identity-stamped native response and host history commit. */
appendInput(input: ClaudeAppendInput): Promise<void> {
const active = this.active
if (!active || active.settled || active.request.command || !this.input || this.draining || this.closed ||
!active.request.onSegmentStart || !active.request.onSegmentComplete) {
return Promise.reject(appendError('当前运行尚不能接收引导消息,请稍后重试', false))
}
if (active.request.signal.aborted || active.appends.has(input.userMessageId) || active.groupIds.includes(input.userMessageId)) {
return Promise.reject(appendError('追加消息已提交或当前运行已取消', false))
}
return new Promise<void>((resolve, reject) => {
active.appends.set(input.userMessageId, { input, resolve, reject })
try {
this.input!.push({ type: 'user', client_composed: true, parent_tool_use_id: null,
uuid: input.userMessageId as SDKUserMessage['uuid'], session_id: this.currentSessionId,
message: { role: 'user', content: [
{ type: 'text', text: input.prompt },
...(input.images ?? []).map(image => ({ type: 'image' as const, source: { type: 'base64' as const,
media_type: image.mimeType as 'image/png', data: image.data } }))
] } })
} catch (error) {
active.appends.delete(input.userMessageId)
reject(appendError(error instanceof Error ? error.message : String(error), false))
}
})
}
async setPolicy(permissionMode: PermissionMode, agentMode: AgentMode): Promise<void> {
await this.draining
this.permissionMode = permissionMode
this.agentMode = agentMode
if (this.instance) await this.instance.setPermissionMode(permissionModeFor(permissionMode, agentMode))
}
/** Read only: never start a kernel or request per-category token-count calls. */
async getContextSummary(): Promise<Awaited<ReturnType<Query['getContextUsage']>> | undefined> {
if (!this.instance || this.draining || this.closed) return undefined
const query = this.contextQuery ?? this.instance.getContextUsage({ detail: 'summary' })
if (!this.contextQuery) {
this.contextQuery = query
this.contextQueryGroup = this.lastGroupKey
void query.finally(() => { if (this.contextQuery === query) this.contextQuery = null }).catch(() => {})
}
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([query, new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('内核上下文统计查询超时')), 5000)
timer.unref()
})])
} finally { if (timer) clearTimeout(timer) }
}
async getContextBreakdown(): Promise<ContextUsageBreakdown | undefined> {
const requestId = this.lastRequestId
const groupKey = this.lastGroupKey
if (this.contextQuery && this.contextQueryGroup !== groupKey) return undefined
const instance = this.instance
const sampledAt = Date.now()
const result = await this.getContextSummary()
if (!result || !requestId || requestId !== this.lastRequestId || groupKey !== this.lastGroupKey || instance !== this.instance) return undefined
const counts: ContextUsageBreakdown['counts'] = { system: 0, tools: 0, conversation: 0, mcp: 0, skills: 0, other: 0 }
for (const category of result.categories) {
if (category.kind !== 'used' || !Number.isFinite(category.tokens) || category.tokens < 0) continue
const id = category.id
const key = id === 'system_prompt' || id === 'memory_files' ? 'system' : id === 'system_tools' ? 'tools' :
id === 'messages' ? 'conversation' : id === 'mcp_tools' || id === 'mcp_instructions' ? 'mcp' : id === 'skills' ? 'skills' : 'other'
counts[key] += category.tokens
}
const contextWindow = this.options.model.profile.contextWindowTokens ?? this.options.model.profile.maxInputTokens
return { requestId, sequence: sampledAt, sampledAt, modelId: this.options.model.profile.id,
contextWindow, method: 'native-summary', scope: 'prepared-request', counts,
inputTokens: Object.values(counts).reduce((sum, value) => sum + value, 0), partial: true }
}
async rewindFiles(userMessageId: string): Promise<void> {
if (!this.instance) return
const result = await this.instance.rewindFiles(userMessageId, { dryRun: false })
@@ -839,6 +1039,7 @@ function mustAskInFullAccess(options: Parameters<CanUseTool>[2]): boolean {
function isReadOnlyCall(toolName: string, input: Record<string, unknown>): boolean {
if (READ_ONLY_TOOLS.has(toolName)) return true
if (['mcp__ycode_capabilities__list_capabilities', 'mcp__ycode_capabilities__load_skill', 'mcp__ycode_capabilities__read_skill_resource'].includes(toolName)) return true
if (toolName !== 'mcp__ycode__browser') return false
return ['open', 'navigate', 'snapshot', 'wait', 'list', 'screenshot'].includes(String(input.action ?? ''))
}
+24
View File
@@ -0,0 +1,24 @@
import type { PermissionMode } from '../../shared/conversation'
export interface QueuedRequestSettingsInput {
queued: boolean
payloadPermissionMode?: PermissionMode
sessionPermissionMode?: PermissionMode
}
export interface QueuedRequestSettings {
permissionMode: PermissionMode
persistSessionPermission: boolean
}
/** Direct sends may seed session policy; queued delivery always follows current session policy. */
export function resolveQueuedRequestSettings(input: QueuedRequestSettingsInput): QueuedRequestSettings {
const permissionMode = input.queued
? input.sessionPermissionMode ?? 'default-sandbox'
: input.payloadPermissionMode ?? input.sessionPermissionMode ?? 'default-sandbox'
if (permissionMode !== 'default-sandbox' && permissionMode !== 'full-access') throw new Error('权限模式无效')
return {
permissionMode,
persistSessionPermission: !input.queued && input.sessionPermissionMode !== permissionMode
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { SessionContext } from '../../shared/workspaces'
import { thinkingLevel } from './mode-policy'
/** During a run only future-request settings may change, never active policy. */
export function nextRequestSettings(patch: SessionContext): SessionContext {
if (!patch || typeof patch !== 'object' || Array.isArray(patch) ||
Object.keys(patch).some(key => key !== 'modelId' && key !== 'thinkingLevel')) {
throw new Error('运行期间只能修改下次发送的模型和思考强度')
}
const result: SessionContext = {}
if (patch.modelId !== undefined) {
if (typeof patch.modelId !== 'string' || !patch.modelId.trim()) throw new Error('模型标识无效')
result.modelId = patch.modelId
}
if (patch.thinkingLevel !== undefined) result.thinkingLevel = thinkingLevel(patch.thinkingLevel)
return result
}
@@ -0,0 +1,244 @@
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
import { z } from 'zod'
import type { SkillRecord } from '@shared/capabilities'
import {
discoverMcpCapabilities,
executeMcpTool,
type McpCapabilityDescriptor
} from './mcp'
import {
buildSkillContext,
listSkills,
loadSkillContext,
readSkillResourceForInvocation
} from './skills'
export interface ClaudeCapabilityPorts {
mcp: {
discover(options?: { serverId?: string; query?: string }): Promise<McpCapabilityDescriptor[]>
execute(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal):
Promise<{ ok: boolean; content: string; data?: unknown }>
}
skills: {
list(cwd: string): Promise<SkillRecord[]>
loadAutomatic(id: string, cwd: string): Promise<string>
loadExplicit(ids: string[], cwd: string): Promise<string>
readResource(id: string, resource: string, cwd: string, explicit: boolean): Promise<string>
}
}
export interface ClaudeCapabilityOptions {
beforeCall?(): Promise<void>
requestSignal?(): AbortSignal | undefined
cwd: string
/** Explicit references belong to this request only and never alter the global enabled flag. */
explicitSkillIds?: readonly string[]
selectedMcpServerIds?: readonly string[]
}
export interface ClaudeCapabilityCatalog {
mcp: Array<Omit<McpCapabilityDescriptor, 'tools'> & {
selected: boolean
tools: Array<{ name: string; description: string; inputSchema?: Record<string, unknown> }>
}>
skills: Array<Pick<SkillRecord, 'id' | 'name' | 'description' | 'userInvocable' | 'disableModelInvocation'> & {
selected: boolean
}>
}
const MAX_QUERY = 256
const MAX_LISTED_MCP_TOOLS = 200
const MAX_DESCRIPTION = 600
const defaultPorts: ClaudeCapabilityPorts = {
mcp: { discover: discoverMcpCapabilities, execute: executeMcpTool },
skills: {
list: listSkills,
loadAutomatic: loadSkillContext,
loadExplicit: buildSkillContext,
readResource: readSkillResourceForInvocation
}
}
function textResult(text: string, isError = false) {
return { content: [{ type: 'text' as const, text }], isError }
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function normalizedQuery(value: string | undefined): string {
const query = value?.trim() ?? ''
if (query.length > MAX_QUERY) throw new Error('能力搜索词过长')
return query.toLocaleLowerCase()
}
function matches(query: string, ...values: string[]): boolean {
return !query || values.some(value => value.toLocaleLowerCase().includes(query))
}
export function createClaudeCapabilityRuntime(
options: ClaudeCapabilityOptions,
override: Partial<ClaudeCapabilityPorts> = {}
) {
const ports: ClaudeCapabilityPorts = {
mcp: override.mcp ?? defaultPorts.mcp,
skills: { ...defaultPorts.skills, ...override.skills }
}
let explicitSkillIds = new Set(options.explicitSkillIds ?? [])
let selectedMcpServerIds = new Set(options.selectedMcpServerIds ?? [])
/** The persistent kernel is sequential; switch its request-scoped references before each turn. */
function setRequestSelection(next: Pick<ClaudeCapabilityOptions, 'explicitSkillIds' | 'selectedMcpServerIds'>): void {
explicitSkillIds = new Set(next.explicitSkillIds ?? [])
selectedMcpServerIds = new Set(next.selectedMcpServerIds ?? [])
}
async function findSkill(id: string): Promise<SkillRecord> {
const skill = (await ports.skills.list(options.cwd)).find(item => item.id === id)
if (!skill) throw new Error('技能不存在或不属于当前空间')
if (skill.error) throw new Error(skill.error)
if (!skill.enabled) throw new Error('该技能已禁用')
if (explicitSkillIds.has(id)) {
if (!skill.userInvocable) throw new Error('该技能不允许用户手动调用')
} else if (skill.disableModelInvocation) {
throw new Error('该技能不允许模型自动调用')
}
return skill
}
async function list(queryValue?: string, serverId?: string): Promise<ClaudeCapabilityCatalog> {
await options.beforeCall?.()
const query = normalizedQuery(queryValue)
const serverFilter = serverId?.trim() || undefined
if (serverFilter && serverFilter.length > 256) throw new Error('MCP 服务标识过长')
const [mcp, skills] = await Promise.all([
ports.mcp.discover({ ...(serverFilter ? { serverId: serverFilter } : {}), ...(query ? { query: queryValue?.trim() } : {}) }),
ports.skills.list(options.cwd)
])
let remainingTools = MAX_LISTED_MCP_TOOLS
const mcpView = mcp
.filter(server => serverFilter ? server.id === serverFilter : true)
.map(server => {
const serverMatches = matches(query, server.name, server.reason ?? '')
const filtered = !query || serverMatches
? server.tools
: server.tools.filter(item => matches(query, item.name, item.description))
const tools = filtered.slice(0, remainingTools).map(item => ({
name: item.name,
description: item.description.slice(0, MAX_DESCRIPTION),
...(serverFilter || query && matches(query, item.name, item.description)
? item.inputSchema ? { inputSchema: item.inputSchema } : {}
: {})
}))
remainingTools -= tools.length
return {
...server,
selected: selectedMcpServerIds.has(server.id),
tools,
...(filtered.length > tools.length ? { reason: '工具列表已截断,请使用搜索词继续发现' } : {})
}
})
.filter(server => !query || server.tools.length > 0 || matches(query, server.name, server.reason ?? ''))
const skillView = skills
.filter(skill => skill.enabled && !skill.error)
.filter(skill => explicitSkillIds.has(skill.id) ? skill.userInvocable : !skill.disableModelInvocation)
.filter(skill => matches(query, skill.name, skill.description))
.map(skill => ({
id: skill.id,
name: skill.name,
description: skill.description,
userInvocable: skill.userInvocable,
disableModelInvocation: skill.disableModelInvocation,
selected: explicitSkillIds.has(skill.id)
}))
return { mcp: mcpView, skills: skillView }
}
async function loadSkill(id: string): Promise<string> {
await options.beforeCall?.()
await findSkill(id)
return explicitSkillIds.has(id)
? ports.skills.loadExplicit([id], options.cwd)
: ports.skills.loadAutomatic(id, options.cwd)
}
async function skillResource(id: string, resource: string): Promise<string> {
await options.beforeCall?.()
await findSkill(id)
return ports.skills.readResource(id, resource, options.cwd, explicitSkillIds.has(id))
}
async function explicitSkillContext(): Promise<string> {
if (!explicitSkillIds.size) return ''
for (const id of explicitSkillIds) await findSkill(id)
return ports.skills.loadExplicit([...explicitSkillIds], options.cwd)
}
async function callMcp(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal) {
await options.beforeCall?.()
if (!serverId || !toolName) throw new Error('MCP 服务和工具标识不能为空')
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('MCP 工具参数必须是对象')
const requestSignal = options.requestSignal?.()
return ports.mcp.execute(serverId, toolName, args, requestSignal ? AbortSignal.any([signal, requestSignal]) : signal)
}
const server = createSdkMcpServer({
name: 'ycode_capabilities',
version: '1.0.0',
// Keep only the four small bridge tools visible; supplier tools stay on demand.
alwaysLoad: true,
instructions: 'YCode capabilities are discovered on demand. List before invoking an unfamiliar MCP tool or loading a skill body. Selecting a capability is not proof that it ran.',
tools: [
tool(
'list_capabilities',
'List currently enabled MCP servers/tools and skills. Use a search query when the catalog is large. This only discovers capabilities; it does not run them.',
{
query: z.string().max(MAX_QUERY).optional(),
serverId: z.string().min(1).max(256).optional()
},
async args => {
try { return textResult(JSON.stringify(await list(args.query, args.serverId))) }
catch (error) { return textResult(message(error), true) }
}
),
tool(
'call_mcp_tool',
'Call one enabled tool from an approved, connected MCP server. The host rechecks service approval, enabled state, connection generation and tool state before invocation.',
{
serverId: z.string().min(1).max(256),
toolName: z.string().min(1).max(256),
arguments: z.record(z.string(), z.unknown())
},
async (args, extra) => {
const signal = (extra as { signal?: AbortSignal } | undefined)?.signal ?? new AbortController().signal
try {
const result = await callMcp(args.serverId, args.toolName, args.arguments, signal)
return textResult(result.content, !result.ok)
} catch (error) { return textResult(message(error), true) }
}
),
tool(
'load_skill',
'Load the selected or model-invocable SKILL.md body and its resource list on demand. Skills are not executed by this tool.',
{ id: z.string().min(1).max(256) },
async args => {
try { return textResult(await loadSkill(args.id)) }
catch (error) { return textResult(message(error), true) }
}
),
tool(
'read_skill_resource',
'Read a UTF-8 resource by path relative to its skill package root. Paths outside the package and symlinks are rejected.',
{ id: z.string().min(1).max(256), resource: z.string().min(1).max(4096) },
async args => {
try { return textResult(await skillResource(args.id, args.resource)) }
catch (error) { return textResult(message(error), true) }
}
)
]
})
return { setRequestSelection, list, loadSkill, skillResource, explicitSkillContext, callMcp, server }
}
+61
View File
@@ -241,6 +241,7 @@ export async function connectMcpServer(id: string): Promise<McpServerView> {
}
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> {
if (typeof enabled !== 'boolean') throw new Error('MCP 启停状态无效')
await mutate(async current => {
const server = requireServer(current, id)
if (enabled && server.trustedHash !== server.configHash) throw new Error('请先批准当前 MCP 配置')
@@ -250,6 +251,7 @@ export async function setMcpEnabled(id: string, enabled: boolean): Promise<void>
if (enabled) await connectMcpServer(id)
}
export async function setMcpToolEnabled(id: string, toolName: string, enabled: boolean): Promise<void> {
if (typeof enabled !== 'boolean') throw new Error('MCP 工具启停状态无效')
await mutate(async current => {
const server = requireServer(current, id)
if (!server.tools.some(tool => tool.name === toolName)) throw new Error('MCP 工具不存在')
@@ -265,6 +267,65 @@ 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)
}
export interface McpCapabilityDescriptor {
id: string
name: string
status: McpStatus
available: boolean
reason?: string
tools: Array<{ name: string; description: string; inputSchema?: Record<string, unknown> }>
}
export interface McpDiscoveryOptions { serverId?: string; query?: string }
/** Model discovery never connects or restarts a server; it only reports current approved state. */
export async function discoverMcpCapabilities(options: McpDiscoveryOptions = {}): Promise<McpCapabilityDescriptor[]> {
await writes
const query = options.query?.trim().toLocaleLowerCase() ?? ''
const serverId = options.serverId?.trim()
return (await load()).servers.filter(server => server.enabled && (!serverId || server.id === serverId)).map(server => {
const runtime = runtimes.get(server.id)
const approved = server.trustedHash === server.configHash
const available = approved && runtime?.status === 'connected'
const serverMatches = !query || [server.name, runtime?.error ?? ''].some(value => value.toLocaleLowerCase().includes(query))
const tools = available ? server.tools.filter(tool => {
if (!tool.enabled) return false
return serverMatches || [tool.name, tool.description].some(value => value.toLocaleLowerCase().includes(query))
}) : []
return {
id: server.id,
name: server.name,
status: runtime?.status ?? 'disconnected',
available,
...(!available ? { reason: !approved ? '请先批准当前 MCP 配置' : runtime?.error ?? 'MCP 服务尚未连接' } : {}),
// Schemas require an explicit drill-down or a tool search, never the default catalog.
tools: tools.map(tool => ({ name: tool.name, description: tool.description,
...(serverId || query && [tool.name, tool.description].some(value => value.toLocaleLowerCase().includes(query))
? { inputSchema: clone(tool.inputSchema) } : {}) }))
}
})
}
export async function executeMcpTool(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal): Promise<{ ok: boolean; content: string; data?: unknown }> {
if (signal.aborted) throw abortError()
await writes
const server = (await load()).servers.find(item => item.id === serverId), runtime = runtimes.get(serverId)
if (!server) return { ok: false, content: 'MCP 服务不存在' }
if (!server.enabled) return { ok: false, content: 'MCP 服务已禁用' }
if (server.trustedHash !== server.configHash) return { ok: false, content: '请先批准当前 MCP 配置' }
if (!runtime || runtime.configHash !== server.configHash || runtime.status !== 'connected') return { ok: false, content: runtime?.error ?? 'MCP 服务尚未连接' }
if (!server.tools.some(tool => tool.name === toolName && tool.enabled)) return { ok: false, content: 'MCP 工具不存在或已禁用' }
try {
const result = await runtime.client.callTool({ name: toolName, 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 || runtimes.get(serverId) !== runtime) return { ok: false, content: 'MCP 配置或连接已变化,请重新发起请求' }
return { ok: false, content: publicError(error, 'call') }
}
}
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))
+247
View File
@@ -0,0 +1,247 @@
import { app } from 'electron'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import type { RecommendedSkill, RecommendedSkillInstallOptions, SkillInstallProgress, SkillRecord } from '@shared/capabilities'
import { SKILL_RECOMMENDATIONS } from './skill-recommendations'
import { getSkillInstallSnapshot, importRecommendedSkillArchive } from './skills'
const MAX_BYTES = 20 * 1024 * 1024
const UUID = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i
const DIRECTORY = /^install-(\d+)-[a-f\d-]{36}$/i
const ACTIVE_PHASES = new Set(['downloading', 'validating', 'installing'])
export interface RecommendedSkillInstallerPorts {
tempRoot: string
catalog: readonly RecommendedSkill[]
snapshot: typeof getSkillInstallSnapshot
importArchive: typeof importRecommendedSkillArchive
request?: typeof fetch
timeoutMs?: number
}
/** The factory is a test boundary; IPC only exposes the fixed production catalog. */
export function createRecommendedSkillInstaller(ports: RecommendedSkillInstallerPorts) {
const root = path.resolve(ports.tempRoot)
const catalog = structuredClone([...ports.catalog])
const listeners = new Set<(progress: SkillInstallProgress) => void>()
const usedIds = new Set<string>()
let state: SkillInstallProgress | null = null
let stopping = false
let initialized = false
let lifecycle = 0
let initialization: Promise<void> | undefined
let active: { operationId: string; controller: AbortController; task: Promise<SkillRecord> } | undefined
function publish(operationId: string, recommendationId: string, phase: SkillInstallProgress['phase'],
receivedBytes: number, totalBytes?: number, error?: string): void {
state = { operationId, recommendationId, phase, receivedBytes, totalBytes, error,
updatedAt: Math.max(Date.now(), (state?.updatedAt ?? 0) + 1) }
for (const listener of listeners) {
try { listener(structuredClone(state)) } catch { /* A closed renderer cannot fail installation. */ }
}
}
async function ensureRoot(): Promise<void> {
let current = path.parse(root).root
for (const component of path.relative(current, root).split(path.sep)) {
current = path.join(current, component)
await fs.mkdir(current).catch(error => { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error })
const stat = await fs.lstat(current)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('技能安装临时目录不能包含符号链接或文件')
}
}
async function removeOwned(directory: string): Promise<void> {
const target = path.resolve(directory)
if (path.dirname(target) !== root || !DIRECTORY.test(path.basename(target))) throw Error('技能安装临时路径越界')
const stat = await fs.lstat(target).catch(error => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
throw error
})
if (!stat) return
if (stat.isSymbolicLink() || !stat.isDirectory()) throw Error('技能安装临时目录已被重定向')
await fs.rm(target, { recursive: true, force: true })
}
async function ensureInitialized(): Promise<void> {
if (initialized) return
if (!initialization) {
initialization = (async () => {
await ensureRoot()
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
const match = DIRECTORY.exec(entry.name)
if (!match || !entry.isDirectory() || entry.isSymbolicLink()) continue
let alive = true
try { process.kill(Number(match[1]), 0) }
catch (error) { alive = (error as NodeJS.ErrnoException).code !== 'ESRCH' }
if (!alive) await removeOwned(path.join(root, entry.name))
}
initialized = true
})()
void initialization.catch(() => { initialization = undefined })
}
await initialization
}
function downloadUrl(recommendation: RecommendedSkill): URL {
if (!/^[\w.-]+\/[\w.-]+$/.test(recommendation.repository) || !/^[a-f\d]{40}$/i.test(recommendation.commit) ||
!/^[a-f\d]{64}$/i.test(recommendation.sha256) || recommendation.packagePath.split('/').some(part => !part || part === '.' || part === '..' || /[^\w.-]/.test(part))) {
throw Error('推荐技能目录条目无效')
}
// Request the pinned file bytes through GitHub's API; raw.githubusercontent.com
// is reset by some networks even when api.github.com remains reachable.
return new URL(`https://api.github.com/repos/${recommendation.repository}/contents/${recommendation.packagePath}?ref=${recommendation.commit}`)
}
async function download(recommendation: RecommendedSkill, file: string, signal: AbortSignal,
operationId: string): Promise<number> {
const request = ports.request ?? fetch
let url = downloadUrl(recommendation)
let response: Response | undefined
for (let redirects = 0; redirects <= 3; redirects++) {
signal.throwIfAborted()
if (url.protocol !== 'https:' || !['api.github.com', 'raw.githubusercontent.com'].includes(url.hostname) || url.username || url.password || (url.port && url.port !== '443')) throw Error('推荐技能下载跳转到不允许的地址')
response = await request(url, { signal, redirect: 'manual', headers: {
'Accept-Encoding': 'identity', Accept: 'application/vnd.github.raw+json', 'X-GitHub-Api-Version': '2022-11-28'
} }).catch(error => {
if (signal.aborted) throw error
throw Error('无法连接 GitHub 下载服务,请检查网络或代理后重试', { cause: error })
})
if (![301, 302, 303, 307, 308].includes(response.status)) break
const location = response.headers.get('location')
await response.body?.cancel()
if (!location || redirects === 3) throw Error('推荐技能下载重定向过多或缺少地址')
url = new URL(location, url)
}
if (!response?.ok || !response.body) {
await response?.body?.cancel().catch(() => {})
if (response?.status === 429 || response?.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') throw Error('GitHub 下载请求限额已用完,请稍后重试')
throw Error(`推荐技能下载失败(HTTP ${response?.status ?? 'unknown'})`)
}
const declared = Number(response.headers.get('content-length'))
if (declared > MAX_BYTES) { await response.body.cancel(); throw Error('推荐技能包超过 20MB') }
const handle = await fs.open(file, 'wx').catch(async error => { await response!.body!.cancel().catch(() => {}); throw error })
const reader = response.body.getReader()
const digest = createHash('sha256')
let received = 0, lastProgress = 0
try {
for (;;) {
signal.throwIfAborted()
const chunk = await reader.read()
if (chunk.done) break
received += chunk.value.byteLength
if (received > MAX_BYTES) throw Error('推荐技能包超过 20MB')
digest.update(chunk.value)
await handle.writeFile(chunk.value)
if (Date.now() - lastProgress >= 80 || received === recommendation.expectedBytes) {
publish(operationId, recommendation.id, 'downloading', received, recommendation.expectedBytes)
lastProgress = Date.now()
}
}
signal.throwIfAborted()
if (received !== recommendation.expectedBytes) throw Error('推荐技能包大小与目录不符')
if (digest.digest('hex') !== recommendation.sha256) throw Error('推荐技能包 SHA-256 校验失败')
return received
} finally {
await reader.cancel().catch(() => {})
await handle.close()
}
}
function install(id: string, options: RecommendedSkillInstallOptions): Promise<SkillRecord> {
if (stopping) return Promise.reject(Error('技能安装服务正在停止'))
const recommendation = catalog.find(item => item.id === id)
if (!recommendation) return Promise.reject(Error('推荐技能不存在'))
if (!options || !UUID.test(options.operationId) || (options.overwrite !== undefined && typeof options.overwrite !== 'boolean')) return Promise.reject(Error('技能安装操作参数无效'))
if (active) return Promise.reject(Error('已有推荐技能正在安装,请等待当前操作完成'))
if (usedIds.has(options.operationId)) return Promise.reject(Error('技能安装操作已提交,不能重复执行'))
const operationId = options.operationId
usedIds.add(operationId)
const controller = new AbortController()
let directory: string | undefined
let received = 0, timedOut = false
const task = Promise.resolve().then(async () => {
try {
await ensureInitialized()
controller.signal.throwIfAborted()
const snapshot = await ports.snapshot(recommendation.name)
const existing = snapshot.skill
const origin = existing?.origin
if (existing && !existing.error && !existing.userModified && origin?.recommendationId === recommendation.id &&
origin.repository === recommendation.repository && origin.commit === recommendation.commit &&
origin.packagePath === recommendation.packagePath && origin.archiveSha256 === recommendation.sha256 && origin.version === recommendation.version) {
controller.signal.throwIfAborted()
publish(operationId, id, 'installed', 0)
return existing
}
if (existing && !options.overwrite) throw Error('已存在同名技能,请确认替换后重试')
directory = path.join(root, `install-${process.pid}-${randomUUID()}`)
await fs.mkdir(directory)
const file = path.join(directory, 'package.zip')
publish(operationId, id, 'downloading', 0, recommendation.expectedBytes)
const timer = setTimeout(() => { timedOut = true; controller.abort() }, ports.timeoutMs ?? 120000)
timer.unref()
try { received = await download(recommendation, file, controller.signal, operationId) }
finally { clearTimeout(timer) }
controller.signal.throwIfAborted()
publish(operationId, id, 'validating', received, recommendation.expectedBytes)
const skill = await ports.importArchive(file, recommendation, options.overwrite === true, snapshot.fingerprint, () => {
controller.signal.throwIfAborted()
publish(operationId, id, 'installing', received, recommendation.expectedBytes)
})
publish(operationId, id, 'installed', received, recommendation.expectedBytes)
return skill
} catch (error) {
const message = timedOut ? '推荐技能下载超时,请重试' : controller.signal.aborted ? '推荐技能安装已取消' : error instanceof Error ? error.message : String(error)
publish(operationId, id, controller.signal.aborted && !timedOut ? 'cancelled' : 'failed', state?.operationId === operationId ? state.receivedBytes : received, recommendation.expectedBytes, message)
throw Object.assign(Error(message), { name: controller.signal.aborted && !timedOut ? 'AbortError' : 'Error' })
} finally {
if (directory) await removeOwned(directory).catch(error => console.error('技能安装临时文件清理失败', error))
}
})
active = { operationId, controller, task }
void task.finally(() => { if (active?.operationId === operationId) active = undefined }).catch(() => {})
return task
}
return {
recommendations: (): RecommendedSkill[] => structuredClone(catalog),
install,
cancel(operationId: string): { cancelled: boolean } {
if (typeof operationId !== 'string' || !UUID.test(operationId)) throw Error('技能安装操作标识无效')
if (active?.operationId !== operationId || state?.operationId === operationId && !ACTIVE_PHASES.has(state.phase) || state?.operationId === operationId && state.phase === 'installing') return { cancelled: false }
active.controller.abort()
return { cancelled: true }
},
state: (): SkillInstallProgress | null => state ? structuredClone(state) : null,
subscribe(listener: (progress: SkillInstallProgress) => void): () => void { listeners.add(listener); return () => listeners.delete(listener) },
async initialize(): Promise<void> {
const generation = ++lifecycle
await ensureInitialized()
if (generation === lifecycle) stopping = false
},
async stop(): Promise<void> {
++lifecycle
stopping = true
if (active && (state?.operationId !== active.operationId || state.phase !== 'installing')) active.controller.abort()
await active?.task.catch(() => {})
await initialization?.catch(() => {})
}
}
}
let installer: ReturnType<typeof createRecommendedSkillInstaller> | undefined
function service(): ReturnType<typeof createRecommendedSkillInstaller> {
return installer ??= createRecommendedSkillInstaller({
tempRoot: path.join(app.getPath('userData'), 'capabilities', 'skill-installs'), catalog: SKILL_RECOMMENDATIONS,
snapshot: getSkillInstallSnapshot, importArchive: importRecommendedSkillArchive
})
}
export const getRecommendedSkills = (): RecommendedSkill[] => service().recommendations()
export const installRecommendedSkill = (id: string, options: RecommendedSkillInstallOptions): Promise<SkillRecord> => service().install(id, options)
export const cancelRecommendedSkillInstall = (id: string): { cancelled: boolean } => service().cancel(id)
export const getRecommendedSkillInstallState = (): SkillInstallProgress | null => service().state()
export const subscribeRecommendedSkillInstall = (listener: (progress: SkillInstallProgress) => void): (() => void) => service().subscribe(listener)
export const initializeRecommendedSkillInstalls = (): Promise<void> => service().initialize()
export const stopRecommendedSkillInstalls = (): Promise<void> => service().stop()
@@ -0,0 +1,12 @@
import type { RecommendedSkill } from '@shared/capabilities'
export const SKILL_RECOMMENDATION_CATALOG_VERSION = 1
export const SKILL_RECOMMENDATIONS: readonly RecommendedSkill[] = Object.freeze([
Object.freeze({
id: 'archify', name: 'archify', description: '将想法、计划或代码库转为可交互的架构图、流程图和一页式说明。',
author: 'tt-a1i', license: 'MIT', repository: 'tt-a1i/archify', version: '3.2.0',
commit: '25cf37080fc8b1dbe758a4efc3935b55f3327083', packagePath: 'archify.zip',
sha256: '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5',
expectedBytes: 5271959, containsScripts: true, avatarResource: 'archify-owner'
})
])
+99 -13
View File
@@ -4,11 +4,11 @@ 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 type { RecommendedSkill, SkillDocument, SkillInstallOrigin, 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 Entry { id: string; name: string; folder: string; enabled: boolean; digest: string; origin?: SkillInstallOrigin }
interface Registry { version: 1; entries: Entry[]; disabledWorkspace: string[] }
interface PackageFile { name: string; data: Uint8Array }
let operations = Promise.resolve()
@@ -64,6 +64,17 @@ async function registry(): Promise<Registry> {
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('技能目录索引格式无效')
for (const entry of parsed.entries) {
const origin = entry.origin
if (origin !== undefined && (!origin || typeof origin !== 'object' ||
typeof origin.recommendationId !== 'string' || origin.recommendationId.length > 128 ||
typeof origin.repository !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(origin.repository) ||
typeof origin.commit !== 'string' || !/^[a-f\d]{40}$/.test(origin.commit) ||
typeof origin.archiveSha256 !== 'string' || !/^[a-f\d]{64}$/.test(origin.archiveSha256) ||
typeof origin.packagePath !== 'string' || origin.packagePath.length > 1024 ||
typeof origin.version !== 'string' || origin.version.length > 128 ||
!Number.isFinite(origin.installedAt) || origin.installedAt < 0)) throw Error('技能安装来源索引无效')
}
return parsed
} catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, entries: [], disabledWorkspace: [] }; throw error }
}
@@ -84,7 +95,9 @@ function metadata(content: string): { name: string; description: string; version
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,
const nested = data.metadata && typeof data.metadata === 'object' ? (data.metadata as Record<string, unknown>).version : undefined
const version = data.version ?? nested
return { name: data.name.trim(), description: data.description.trim(), version: typeof version === 'string' || typeof version === 'number' ? String(version) : undefined,
enabled: data.enabled !== false && data.disable !== true, userInvocable: (data['user-invocable'] ?? data.userInvocable) !== false,
disableModelInvocation: (data['disable-model-invocation'] ?? data.disableModelInvocation) === true }
}
@@ -203,13 +216,13 @@ async function listUnlocked(cwd?: string): Promise<SkillRecord[]> {
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)) }
result.push({ ...record(entry.id, folder, meta, 'user', entry.enabled, 'imported', packageDigest(files) !== entry.digest), ...(entry.origin ? { origin: entry.origin } : {}) })
} catch (error) { result.push({ ...invalidRecord(entry.id, entry.name, folder, 'user', error), ...(entry.origin ? { origin: entry.origin } : {}) }) }
}
if (cwd) {
const workspace = await fs.realpath(path.resolve(cwd)).catch(() => undefined)
if (!workspace) return result
for (const source of ['.agents/skills', '.claude/skills']) {
for (const source of ['.agents/skills']) {
let directory: string
try { directory = await checked(workspace, source) } catch { continue }
if (!(await fs.stat(directory)).isDirectory()) continue
@@ -234,32 +247,90 @@ async function findSkill(id: string, cwd?: string): Promise<SkillRecord> {
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')
async function importFiles(files: PackageFile[], overwrite: boolean, origin?: SkillInstallOrigin,
expectedFingerprint?: string, beforeCommit?: () => void): Promise<SkillRecord> {
const 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('已存在同名技能,确认替换后重试')
if (expectedFingerprint !== undefined && (await snapshotUnlocked(meta.name)).fingerprint !== expectedFingerprint) 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 (expectedFingerprint !== undefined && (await snapshotUnlocked(meta.name)).fingerprint !== expectedFingerprint) throw Error('本地技能在安装期间已变化,请重新确认替换')
beforeCommit?.()
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) }
const entry: Entry = { id, name: meta.name, folder, enabled: existing?.enabled ?? true, digest: packageDigest(files), ...(origin ? { origin } : {}) }
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)
return { ...record(id, destination, meta, 'user', entry.enabled, 'imported', false), ...(origin ? { origin } : {}) }
} finally {
await fs.rm(stage, { recursive: true, force: true })
await fs.rm(stage, { recursive: true, force: true }).catch(error => console.error('技能暂存清理失败', error))
if (moved && !committed) await fs.rename(backup, destination)
if (committed) await fs.rm(backup, { recursive: true, force: true })
if (committed) await fs.rm(backup, { recursive: true, force: true }).catch(error => console.error('技能备份清理失败', error))
}
}
export function importSkill(source: string, overwrite = false): Promise<SkillRecord> {
return serialized(async () => importFiles(await fromPath(source), overwrite))
}
async function snapshotUnlocked(name: string): Promise<{ skill?: SkillRecord; fingerprint: string }> {
const normalized = name.normalize('NFKC').toLowerCase()
const state = await registry()
const entry = state.entries.find(item => item.name.normalize('NFKC').toLowerCase() === normalized)
if (!entry) return { fingerprint: 'absent' }
const folder = path.join(rootPath(), 'packages', entry.folder)
let digest = '', skill: SkillRecord
try {
digest = packageDigest(await packageFiles(folder))
skill = record(entry.id, folder, metadata(await documentAt(folder)), 'user', entry.enabled, 'imported', digest !== entry.digest)
} catch (error) {
if (!digest) digest = `invalid:${String(error)}`
skill = invalidRecord(entry.id, entry.name, folder, 'user', error)
}
if (entry.origin) skill.origin = entry.origin
return { skill, fingerprint: hash(JSON.stringify({ entry, digest })) }
}
export function getSkillInstallSnapshot(name: string): Promise<{ skill?: SkillRecord; fingerprint: string }> {
return serialized(() => snapshotUnlocked(name))
}
function normalizedVersion(version: unknown): string | undefined {
if (typeof version !== 'string' || !/^\d+\.\d+(?:\.\d+)?$/.test(version)) return undefined
const parts = version.split('.').map(Number)
return [parts[0], parts[1], parts[2] ?? 0].join('.')
}
export function importRecommendedSkillArchive(source: string, recommendation: RecommendedSkill,
overwrite: boolean, expectedFingerprint: string, beforeCommit: () => void): Promise<SkillRecord> {
return serialized(async () => {
const stat = await fs.lstat(source)
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_ARCHIVE) throw Error('推荐技能包文件无效或过大')
const bytes = await fs.readFile(source)
if (hash(bytes) !== recommendation.sha256) throw Error('推荐技能包 SHA-256 校验失败')
const files = archiveFiles(bytes), main = files.find(file => file.name === 'SKILL.md')
if (!main || main.data.length > MAX_DOCUMENT) throw Error('推荐技能包缺少有效 SKILL.md')
const content = text(main.data), meta = metadata(content)
const front = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content)!
const yaml = parseDocument(front[1], { uniqueKeys: true, schema: 'core' }).toJS({ maxAliasCount: 50 }) as Record<string, unknown>
if (meta.name !== recommendation.name || normalizedVersion(meta.version) !== normalizedVersion(recommendation.version) ||
!normalizedVersion(recommendation.version) || yaml.license !== recommendation.license) throw Error('推荐技能名称、版本或许可证与目录不符')
const packageFile = files.find(file => file.name === 'package.json')
if (packageFile) {
const pkg = JSON.parse(text(packageFile.data)) as Record<string, unknown>
if (pkg.name !== recommendation.name || normalizedVersion(pkg.version) !== normalizedVersion(recommendation.version) || pkg.license !== recommendation.license) throw Error('推荐技能 package.json 身份与目录不符')
}
const license = files.find(file => file.name === 'LICENSE' || file.name === 'LICENSE.md')
if (!license || !text(license.data).includes(recommendation.license === 'MIT' ? 'MIT License' : recommendation.license)) throw Error('推荐技能许可证文件与目录不符')
return importFiles(files, overwrite, { recommendationId: recommendation.id, repository: recommendation.repository,
commit: recommendation.commit, packagePath: recommendation.packagePath, archiveSha256: recommendation.sha256,
version: recommendation.version, installedAt: Date.now() }, expectedFingerprint, beforeCommit)
})
}
export function readSkill(id: string, cwd?: string): Promise<SkillDocument> {
@@ -268,6 +339,7 @@ export function readSkill(id: string, cwd?: string): Promise<SkillDocument> {
}
export function setSkillEnabled(id: string, enabled: boolean, cwd?: string): Promise<void> {
return serialized(async () => {
if (typeof enabled !== 'boolean') throw new Error('技能启停状态无效')
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)
@@ -335,3 +407,17 @@ export function readSkillResource(id: string, resource: string, cwd: string): Pr
return text(await fs.readFile(file))
})
}
/** Gate and read under the same serialized skill operation, including per-skill invocation flags. */
export function readSkillResourceForInvocation(id: string, resource: string, cwd: string, explicit: boolean): 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('该技能已禁用')
if (explicit ? !skill.userInvocable : skill.disableModelInvocation) {
throw Error(explicit ? '该技能不允许用户手动调用' : '该技能不允许模型自动调用')
}
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))
})
}
+136 -15
View File
@@ -18,6 +18,9 @@ import { createInteractionBroker } from './interaction-broker'
import { agentMode, agentModeInstructions } from './agent/mode-policy'
import { cachedClaudeKernel, getClaudeKernel, releaseClaudeKernel } from './agent/claude/claude-kernel'
import { normalizeUserContent } from '../shared/user-content'
import { buildSkillContext, listSkills } from './capabilities/skills'
import { discoverMcpCapabilities } from './capabilities/mcp'
import { resolveQueuedRequestSettings } from './agent/queued-request-settings'
export interface ChatTurnCompletion {
runId: string
@@ -30,6 +33,7 @@ export interface StartedChatTurn extends SendResult {
completion: Promise<ChatTurnCompletion>
}
export interface ChatTurnServicePorts {
hasQueuedPrompts?(sessionId: string, queueEditId?: string): Promise<boolean>
projects: ProjectRepository
sessionRoot(sessionId: string): Promise<string>
broadcastChat(channel: string, payload: unknown, sender?: WebContents): void
@@ -92,25 +96,53 @@ function contextWindowOf(model: { profile: { contextWindowTokens?: number; maxIn
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
}
export function createChatTurnService({ projects: projectRepository, sessionRoot, broadcastChat, getDefaultRecipient }: ChatTurnServicePorts) {
const startTurn = async (payload: SendPayload, sender?: WebContents): Promise<StartedChatTurn> => withChatSessionLock(payload.sessionId, async () => {
export function createChatTurnService({ projects: projectRepository, sessionRoot, broadcastChat, getDefaultRecipient, hasQueuedPrompts }: ChatTurnServicePorts) {
const appenders = new Map<string, { runId: string; prepare(payload: SendPayload, id: string): Promise<{ delivery: Promise<void> }> }>()
const startTurn = async (payload: SendPayload, sender?: WebContents, queuedMessageId?: string): Promise<StartedChatTurn> => withChatSessionLock(payload.sessionId, async () => {
const recipient = sender ?? getDefaultRecipient?.()
const { sessionId, text } = payload
if (payload.rollbackArtifacts !== undefined && typeof payload.rollbackArtifacts !== 'boolean') throw new Error('产物回退选项无效')
if (!(await sessions.listSessions()).some((session) => session.id === sessionId)) throw new Error('会话不存在')
if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length && !payload.quotes?.length && !payload.resources?.length)) throw new Error('消息为空')
if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length && !payload.quotes?.length && !payload.resources?.length && !payload.skillIds?.length && !payload.mcpServerIds?.length)) throw new Error('消息为空')
if ([...runTasks.values()].some((run) => run.sessionId === sessionId)) throw new Error('该会话正在运行')
if (!queuedMessageId && await hasQueuedPrompts?.(sessionId, payload.command ? undefined : payload.queueEditId)) throw new Error('请先处理待发送队列;普通消息请加入队列,压缩或重发不能绕过队列')
if (payload.command !== undefined && payload.command !== 'compact') throw new Error('不支持的内置命令')
if (payload.command && (text.trim() !== '/compact' || payload.attachments?.length || payload.quotes?.length || payload.resources?.length || payload.skillIds?.length || payload.mcpServerIds?.length || payload.action)) throw new Error('上下文压缩不能与普通消息、附件或能力引用混发')
const stored = await sessions.getMessages(sessionId)
if (payload.command && !stored.some(message => message.role === 'assistant' && message.claudeEntryId)) throw new Error('已有内核历史后才能压缩上下文')
if (stored.some(message => message.interactions?.some(interaction => ['pending', 'responding', 'error'].includes(interaction.status)))) throw new Error('请先处理当前问答或授权')
const deliveryMessageId = queuedMessageId ?? payload.queueEditId
if (deliveryMessageId && stored.some(message => message.id === deliveryMessageId)) 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 project = context.projectId ? await projectRepository.get(context.projectId) : undefined
const mode = agentMode(context.agentMode)
const permissionMode = payload.permissionMode ?? 'default-sandbox'
const { permissionMode, persistSessionPermission } = resolveQueuedRequestSettings({ queued: !!queuedMessageId,
payloadPermissionMode: payload.permissionMode, sessionPermissionMode: context.permissionMode })
if (persistSessionPermission) await sessions.updateSessionContext(sessionId, { permissionMode })
const selectedModel = await models.resolveModel(context.modelId)
if (!selectedModel) throw new Error('请先添加并选择模型')
if (!isSupportedClaudeModel(selectedModel)) throw new Error('首版仅支持 Anthropic/Claude 兼容模型')
const workspaceRoot = await sessionRoot(sessionId)
if (payload.skillIds !== undefined && (!Array.isArray(payload.skillIds) || payload.skillIds.some(id => typeof id !== 'string') || payload.skillIds.length > 100)) throw new Error('技能引用无效')
if (payload.mcpServerIds !== undefined && (!Array.isArray(payload.mcpServerIds) || payload.mcpServerIds.some(id => typeof id !== 'string') || payload.mcpServerIds.length > 100)) throw new Error('MCP 服务引用无效')
const selectedSkillIds = [...new Set(payload.skillIds ?? [])]
const selectedMcpIds = [...new Set(payload.mcpServerIds ?? [])]
const selectedSkillContext = await buildSkillContext(selectedSkillIds, workspaceRoot)
const skillRecords = selectedSkillIds.length ? await listSkills(workspaceRoot) : []
const selectedSkills = selectedSkillIds.map(id => {
const record = skillRecords.find(skill => skill.id === id)!
return { id, name: record.name, uri: record.path }
})
if (selectedMcpIds.length) {
const servers = await discoverMcpCapabilities()
for (const id of selectedMcpIds) {
const server = servers.find(item => item.id === id)
if (!server?.available) throw new Error(server?.reason ?? '所选 MCP 服务不存在或已禁用')
}
}
const references = [...new Set(payload.resources ?? [])]
for (const path of references) {
if (typeof path !== 'string' || await classifyWorkspacePath(workspaceRoot, path) === 'unknown') {
@@ -142,6 +174,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
}
const target = history[targetIndex]
if (!target || target.role !== 'user') throw new Error('未找到对应用户消息')
if (target.command) throw new Error('压缩记录不能编辑或重新生成,请使用新的原生压缩命令')
const prefix = history.slice(0, targetIndex)
forkAt = [...prefix].reverse().find((message) => message.role === 'assistant' && message.claudeEntryId)?.claudeEntryId
forceNew = !forkAt
@@ -159,8 +192,8 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
}
const runId = randomUUID()
const userMessage: ChatMessage = {
id: randomUUID(),
let userMessage: ChatMessage = {
id: deliveryMessageId ?? randomUUID(),
role: 'user',
content: text,
requestId: runId,
@@ -168,7 +201,10 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
attachments,
resources: references,
quotes: payload.quotes?.length ? payload.quotes : undefined,
userContent: normalizeUserContent(payload.userContent, text, { attachments, resources: references, quotes: payload.quotes, skills: [] }),
skills: selectedSkills.length ? selectedSkills : undefined,
mcpServerIds: selectedMcpIds.length ? selectedMcpIds : undefined,
command: payload.command,
userContent: normalizeUserContent(payload.userContent, text, { attachments, resources: references, quotes: payload.quotes, skills: selectedSkills }),
createdAt: Date.now(),
status: 'done'
}
@@ -187,7 +223,9 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
await sessions.startTurn(sessionId, userMessage, assistant, history, payload.action)
const quoteText = (payload.quotes ?? []).map((quote) => quote.markdown.trim()).filter(Boolean).join('\n\n')
const promptText = [text, quoteText, promptAttachments.text, references.length ? `引用工作区资源:\n${references.join('\n')}` : '', modeReminder].filter(Boolean).join('\n\n')
const promptText = payload.command === 'compact' ? '/compact' : [text, quoteText, promptAttachments.text, selectedSkillContext,
selectedMcpIds.length ? `本消息指定 MCP 服务(尚未执行;其他已启用能力仍可使用):${selectedMcpIds.join(', ')}` : '',
references.length ? `引用工作区资源:\n${references.join('\n')}` : '', modeReminder].filter(Boolean).join('\n\n')
let eventSequence = 0
let usage: TokenUsage | undefined
@@ -196,7 +234,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
activeRuns.set(runId, controller)
generalSettings.updateActiveRuns(1)
const publish = (event: ChatEvent): void => broadcastChat('chat:event', { ...event, sessionId }, recipient)
const checkpoint = createMessageCheckpoint(
const createCheckpoint = () => 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,
@@ -205,7 +243,9 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
}),
(error) => console.error('会话增量保存失败,将在回合结束时重试', error)
)
let checkpoint = createCheckpoint()
let terminalEvent: ChatEvent | null = null
let betweenSegments = false
const emit = (event: ChatEvent): void => {
const isTerminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error'
let observedEvent: ChatEvent = isTerminal
@@ -244,9 +284,69 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
onSessionId: (id) => sessions.setClaudeSessionId(sessionId, id)
})
await kernel.setPolicy(permissionMode, mode)
const preparedAppends = new Map<string, ChatMessage>()
appenders.set(sessionId, { runId, prepare: async (input, id) => {
const notSubmitted = (message: string): never => { throw Object.assign(new Error(message), { submitted: false }) }
if (controller.signal.aborted || !kernel.busy || payload.command || input.command || input.action) notSubmitted('当前运行不能接收引导消息')
const pendingInteraction = () => assistant.interactions?.some(item => ['pending', 'responding', 'error'].includes(item.status))
if (pendingInteraction()) notSubmitted('请先处理当前问答或授权')
if (preparedAppends.has(id) || (await sessions.getMessages(sessionId)).some(message => message.id === id)) notSubmitted('追加消息已提交,不能重复执行')
const files = await Promise.all((input.attachments ?? []).map(item => getAttachment(item.id)))
const attachment = await attachmentPrompt(files)
const paths = [...new Set(input.resources ?? [])]
for (const path of paths) if (typeof path !== 'string' || await classifyWorkspacePath(workspaceRoot, path) === 'unknown') notSubmitted('引用资源已不存在或不属于当前工作区')
const skillIds = [...new Set(input.skillIds ?? [])]
const skillContext = await buildSkillContext(skillIds, workspaceRoot)
const records = skillIds.length ? await listSkills(workspaceRoot) : []
const selected = skillIds.map(skillId => { const skill = records.find(item => item.id === skillId)!; return { id: skillId, name: skill.name, uri: skill.path } })
const servers = [...new Set(input.mcpServerIds ?? [])]
if (servers.length) {
const catalog = await discoverMcpCapabilities()
for (const serverId of servers) if (!catalog.some(item => item.id === serverId && item.available)) notSubmitted('所选 MCP 服务已禁用、未连接或尚未批准')
}
if (controller.signal.aborted || pendingInteraction()) notSubmitted('运行已取消或正在等待问答/授权')
await commitAttachments(files)
const message: ChatMessage = { id, role: 'user', content: input.text, createdAt: Date.now(), status: 'done', requestId: runId,
attachments: files, resources: paths, quotes: input.quotes, skills: selected.length ? selected : undefined,
mcpServerIds: servers.length ? servers : undefined,
userContent: normalizeUserContent(input.userContent, input.text, { attachments: files, resources: paths, quotes: input.quotes, skills: selected }) }
preparedAppends.set(id, message)
const prompt = [input.text, ...(input.quotes ?? []).map(quote => quote.markdown), attachment.text, skillContext,
paths.length ? `引用工作区资源:\n${paths.join('\n')}` : '', servers.length ? `本消息指定 MCP 服务(尚未执行):${servers.join(', ')}` : '', modeReminder].filter(Boolean).join('\n\n')
const delivery = kernel.appendInput({ userMessageId: id, prompt, images: attachment.images, skillIds, mcpServerIds: servers }).catch(error => {
if ((error as { submitted?: boolean })?.submitted === false) preparedAppends.delete(id)
throw error
})
// The lock owner returns this promise as data; acknowledgement must be awaited outside the session lock.
return { delivery }
} })
const result = await kernel.runTurn({
runId, userMessageId: userMessage.id, prompt: promptText, images: promptAttachments.images,
emit, interactions: interactionBroker, signal: controller.signal, ownerWebContentsId: recipient?.id
runId, userMessageId: userMessage.id, prompt: promptText, command: payload.command,
skillIds: selectedSkillIds, mcpServerIds: selectedMcpIds, images: promptAttachments.images,
emit, interactions: interactionBroker, signal: controller.signal, ownerWebContentsId: recipient?.id,
onSegmentComplete: async result => {
assistant = { ...assistant, content: result.content, tools: result.tools, claudeEntryId: result.entryId,
status: 'done', turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt), turnStartedAt: undefined }
betweenSegments = true
await checkpoint.finish(assistant)
},
onSegmentStart: async inputs => {
if (controller.signal.aborted) throw new Error('运行已取消,追加交付未确认')
const users = inputs.map(input => preparedAppends.get(input.userMessageId))
if (!users.length || users.some(message => !message)) throw new Error('内核返回了未知追加消息身份')
const nextHistory = [...history, userMessage, assistant, ...(users.slice(0, -1) as ChatMessage[])]
const nextUser = users.at(-1)!
const nextAssistant: ChatMessage = { id: randomUUID(), role: 'assistant', content: '', ...freezeAssistantModelMetadata(selectedModel.profile),
requestId: runId, tools: [], parts: [], createdAt: Date.now(), turnStartedAt: nextUser.createdAt, status: 'streaming' }
await sessions.startTurn(sessionId, nextUser, nextAssistant, nextHistory)
history = nextHistory; userMessage = nextUser; assistant = nextAssistant
betweenSegments = false
for (const input of inputs) preparedAppends.delete(input.userMessageId)
usage = undefined
checkpoint = createCheckpoint()
++eventSequence
broadcastChat('chat:run-started', { ...runSnapshot(), segmentBoundary: true }, recipient)
}
})
assistant.content = result.content
assistant.tools = result.tools
@@ -256,7 +356,10 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
await sessions.clearClaudeFork(sessionId)
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error))
if (failure.name === 'AbortError' || controller.signal.aborted) emit({ runId, type: 'cancelled' })
if (betweenSegments) {
terminalEvent = failure.name === 'AbortError' || controller.signal.aborted
? { runId, type: 'cancelled' } : { runId, type: 'error', message: failure.message }
} else if (failure.name === 'AbortError' || controller.signal.aborted) emit({ runId, type: 'cancelled' })
else {
if (/Resume rejected by --resume-drops-turn:/i.test(failure.message)) {
await sessions.setClaudeFork(sessionId, undefined, true).catch(() => undefined)
@@ -269,15 +372,20 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
try {
if (terminalEvent) {
terminalEvent = { ...terminalEvent, sequence: ++eventSequence, turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) }
assistant = applyAssistantEvent(assistant, terminalEvent)
if (!betweenSegments) assistant = applyAssistantEvent(assistant, terminalEvent)
}
await checkpoint.finish(assistant)
if (terminalEvent) publish(terminalEvent)
if (betweenSegments) {
++eventSequence
broadcastChat('chat:run-started', { ...runSnapshot(), segmentBoundary: true }, recipient)
}
} catch (error) {
const failure: ChatEvent = { runId, type: 'error', sequence: ++eventSequence, message: `会话保存失败:${error instanceof Error ? error.message : String(error)}` }
assistant = applyAssistantEvent(assistant, failure)
publish(failure)
} finally {
if (appenders.get(sessionId)?.runId === runId) appenders.delete(sessionId)
activeRuns.delete(runId)
generalSettings.updateActiveRuns(-1)
runEmitters.delete(runId)
@@ -287,11 +395,11 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
})
runTasks.set(runId, { sessionId, recipientId: recipient?.id, promise: task, snapshot: runSnapshot })
broadcastChat('chat:run-started', runSnapshot(), recipient)
void task.finally(() => runTasks.delete(runId))
void task.then(() => runTasks.delete(runId), () => runTasks.delete(runId))
const completion = task.then((): ChatTurnCompletion => ({
runId, sessionId, assistantMessageId: assistant.id,
status: assistant.status === 'done' ? 'done' : assistant.status === 'cancelled' ? 'cancelled' : 'error',
status: terminalEvent?.type === 'cancelled' ? 'cancelled' : terminalEvent?.type === 'error' ? 'error' : assistant.status === 'done' ? 'done' : 'error',
...(assistant.error ? { error: assistant.error } : {})
}), (error): ChatTurnCompletion => ({
runId, sessionId, assistantMessageId: assistant.id, status: 'error',
@@ -355,6 +463,19 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
return {
startTurn,
async appendToRun(payload: SendPayload, id: string): Promise<void> {
let prepared: { delivery: Promise<void> }
try {
prepared = await withChatSessionLock(payload.sessionId, async () => {
const active = appenders.get(payload.sessionId)
if (!active) throw new Error('当前运行尚未准备好接收引导消息')
return active.prepare(payload, id)
})
} catch (error) {
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { submitted: false })
}
await prepared.delivery
},
rewindFiles,
revert,
cancel(runId: string) {
+11 -4
View File
@@ -1,10 +1,11 @@
import type { ChatMessage, ContextUsageInfo, ContextUsageSnapshot } from '../shared/types'
import type { ChatMessage, ContextUsageBreakdown, ContextUsageInfo, ContextUsageSnapshot } from '../shared/types'
import { contextInfoFromMessages } from '../shared/context-usage'
export interface ContextUsageDependencies {
messages(id:string):Promise<ChatMessage[]>
modelId(id:string):Promise<string|undefined>
running(id:string):boolean
summary?(id:string):Promise<ContextUsageBreakdown|undefined>
/** The host rechecks identity inside its existing session mutation lock. */
persistIfCurrent(id:string,assistantId:string,requestId:string|undefined,sample:ContextUsageSnapshot):Promise<void>
}
@@ -12,13 +13,19 @@ const latestAssistant=(messages:ChatMessage[])=>messages.findLast(message=>messa
export function createContextUsageService(deps:ContextUsageDependencies):{get(id:string):Promise<ContextUsageInfo>} {
return {async get(id) {
const messages=await deps.messages(id)
let messages=await deps.messages(id)
let summary:ContextUsageBreakdown|undefined
try { summary=await deps.summary?.(id) } catch { /* Retain real stream samples on query failure. */ }
if(summary)messages=await deps.messages(id)
const assistant=latestAssistant(messages)
const info=contextInfoFromMessages(messages)
let info=contextInfoFromMessages(messages)
if(summary&&summary.requestId===assistant?.requestId&&summary.modelId===assistant?.modelId)info={...info,breakdown:summary,status:info.sample?'latest':'unavailable',reason:'分类来自内核 summary 的本地估计'}
const selectedModelId=await deps.modelId(id)
const running=deps.running(id)
const decorate=(value:ContextUsageInfo):ContextUsageInfo=>{
const model=value.sample?.modelId??value.breakdown?.modelId??assistant?.modelId
return model&&selectedModelId&&model!==selectedModelId?{...value,status:'previous',reason:'来自切换模型前的最近请求'}:value
return model&&selectedModelId&&model!==selectedModelId&&!running&&assistant?.status!=='streaming'
?{...value,status:'previous',reason:'来自切换模型前的最近请求'}:value
}
if(!assistant)return {status:'unavailable',reason:'发送消息后可查看用量'}
return decorate(info)
+30
View File
@@ -15,6 +15,11 @@ import { getPreventSleep } from './config'
import { configurePreventSleep } from './general-settings'
import { executeBrowserTool, installBrowserAutomationLifecycle } from './browser-automation'
import { configureBrowserTool } from './agent/tools/browser'
import { registerUpdates } from './updates'
import { activeRuns, runTasks } from './chat-turn-service'
import { disposeTerminalHosts } from './terminal-host'
import { pauseRuntimeStarts, resumeRuntimeStarts } from './update-maintenance'
import { createUpdateLifecycle } from './update-lifecycle'
let mainWindow: BrowserWindow | null = null
let settingsWindow: BrowserWindow | null = null
@@ -27,6 +32,7 @@ let settingsRequested = false
let settingsRequestId = 0
let allowQuit = false
let shutdownPromise: Promise<void> | null = null
let updates: ReturnType<typeof registerUpdates> | undefined
const mainDirectory = dirname(fileURLToPath(import.meta.url))
/**
@@ -271,6 +277,7 @@ app.on('before-quit', (event) => {
if (allowQuit) return
event.preventDefault()
if (shutdownPromise) return
updates?.dispose()
shutdownPromise = stopBackgroundServices().finally(() => disposeClaudeKernels()).finally(() => disposeMcpServers())
void shutdownPromise
.catch(() => undefined)
@@ -309,6 +316,29 @@ app.whenReady().then(() => {
// 通用设置:启动时套用持久化偏好(运行任务时阻止系统休眠)。
void getPreventSleep().then((enabled) => configurePreventSleep(enabled)).catch(() => undefined)
registerIpc(() => mainWindow)
const updateLifecycle = createUpdateLifecycle({
quiesce: async () => {
await pauseRuntimeStarts()
for (const controller of activeRuns.values()) controller.abort()
await stopBackgroundServices()
await Promise.all([...runTasks.values()].map(task => task.promise))
},
restore: () => { resumeRuntimeStarts(); startBackgroundServices() },
shutdown: async () => {
updates?.dispose()
disposeTerminalHosts()
await disposeClaudeKernels()
await disposeMcpServers()
},
exit: () => app.exit(0),
log: error => console.error('更新退出时清理失败', error)
})
updates = registerUpdates({
beforeInstall: updateLifecycle.beforeInstall,
installFailed: updateLifecycle.installFailed,
quitForUpdate: updateLifecycle.quitForUpdate
})
updates.start()
startBackgroundServices()
setTaskWindowFactory((sessionId) => { openTaskWindow(sessionId) })
ipcMain.handle('app:openSettings', (_event, section?: string, action?: 'new') => openSettings(section, action))
+151 -15
View File
@@ -18,7 +18,7 @@ import { classifyWorkspacePath, decodeDownload, imageDownloadPayload, readWorksp
import type { DownloadPayload, DownloadResult, ResourceRange } from '@shared/resources'
import type { InteractionResponse } from '@shared/conversation'
import { respondToInteraction } from './interaction-broker'
import { getAttachment, removeAttachment, stageAttachment, stageAttachmentPath } from './attachments'
import { commitAttachments, getAttachment, removeAttachment, stageAttachment, stageAttachmentPath } from './attachments'
import { transcribeAudio } from './speech'
import * as models from './models'
import * as workspaces from './workspaces'
@@ -39,7 +39,7 @@ import { modelMetadata, modelMetadataStatus, refreshModelMetadata } from './mode
import { readGitBranch } from './git'
import { createChatTurnService, activeRuns, runTasks, runEmitters, withChatSessionLock,
disposeSessionKernel, cancelRunsForWebContents } from './chat-turn-service'
import { claudeRuntimeInfo, setSessionVisible, removeSessionView } from './agent/claude/claude-kernel'
import { cachedClaudeKernel, claudeRuntimeInfo, setSessionVisible, removeSessionView } from './agent/claude/claude-kernel'
import { createManagedSession } from './session-factory'
import { createAutomationHost } from './automations/host'
import type { AutomationDefinitionCreateInput, AutomationDefinitionPatch, AutomationRunFilter } from '@shared/automation'
@@ -48,11 +48,27 @@ import { ArtifactIndexService } from './library/artifact-service'
import { LibraryService } from './library/service'
import { JsonLibraryUserStateRepository, libraryUserStatePath } from './library/user-state'
import { createContextUsageService } from './context-usage-service'
import { nextRequestSettings } from './agent/request-settings'
import { createPromptQueue } from './prompt-queue'
import * as skills from './capabilities/skills'
import { getRecommendedSkills, installRecommendedSkill, cancelRecommendedSkillInstall,
getRecommendedSkillInstallState, subscribeRecommendedSkillInstall,
initializeRecommendedSkillInstalls, stopRecommendedSkillInstalls } from './capabilities/recommended-skills'
import type { RecommendedSkillInstallOptions } from '@shared/capabilities'
import * as mcp from './capabilities/mcp'
import { contextInfoFromMessages } from '@shared/context-usage'
import { assertRuntimeAvailable, withRuntimeStart } from './update-maintenance'
let automationHost: ReturnType<typeof createAutomationHost> | undefined
export function startBackgroundServices(): void { automationHost?.start() }
export async function stopBackgroundServices(): Promise<void> { await automationHost?.stop() }
let stopInstallBroadcast: (() => void) | undefined
export function startBackgroundServices(): void {
automationHost?.start()
void mcp.initializeMcpServers().catch(error => console.error('MCP 启动连接失败', error))
void initializeRecommendedSkillInstalls().catch(error => console.error('推荐技能安装初始化失败', error))
}
export async function stopBackgroundServices(): Promise<void> {
await Promise.all([automationHost?.stop(), stopRecommendedSkillInstalls()])
}
/** 主进程入口注入:打开某任务的独立窗口。未注入时该菜单项给出诚实错误。 */
let taskWindowFactory: ((sessionId: string) => void) | null = null
@@ -75,7 +91,62 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
}
browserHost.setBrowserHostEventSink(event => broadcastChat('browser:event', event))
const projects = new ProjectRepository(join(app.getPath('userData'), 'projects.json'))
const chatTurns = createChatTurnService({ projects, sessionRoot, broadcastChat, getDefaultRecipient: () => getWindow()?.webContents })
const chatTurns = createChatTurnService({ projects, sessionRoot, broadcastChat, getDefaultRecipient: () => getWindow()?.webContents,
hasQueuedPrompts: async (id, editId) => {
const queue = await promptQueue.get(id)
return queue.items.length > 0 || !!queue.editingDraft && queue.editingDraft.id !== editId
} })
const assertQueueSession = async (id: string): Promise<void> => {
if (typeof id !== 'string' || !(await sessions.listSessions()).some(session => session.id === id)) throw new Error('会话不存在')
}
const hasPendingInteraction = async (id: string): Promise<boolean> => (await sessions.getMessages(id)).some(message =>
message.interactions?.some(interaction => ['pending', 'responding', 'error'].includes(interaction.status)))
const promptQueue = createPromptQueue({
file: join(app.getPath('userData'), 'prompt-queues.json'),
validate: async payload => {
await assertQueueSession(payload.sessionId)
if (payload.command || payload.action || payload.fromMessageId) throw new Error('命令与重新发送不能加入追加队列')
if (typeof payload.text !== 'string' || (!payload.text.trim() && !payload.attachments?.length && !payload.resources?.length && !payload.quotes?.length && !payload.skillIds?.length && !payload.mcpServerIds?.length)) throw new Error('消息为空')
if (await hasPendingInteraction(payload.sessionId)) throw new Error('请先处理当前问答或授权')
const cwd = await sessionRoot(payload.sessionId)
const labels: Record<string, string> = {}
if (payload.skillIds?.length) {
for (const skill of await skills.listSkills(cwd)) if (payload.skillIds.includes(skill.id)) labels[`skill:${skill.id}`] = skill.name
}
if (payload.mcpServerIds?.length) {
for (const server of await mcp.listMcpServers()) if (payload.mcpServerIds.includes(server.id)) labels[`mcp:${server.id}`] = server.name
}
payload.capabilityLabels = labels
const files = await Promise.all((payload.attachments ?? []).map(item => getAttachment(item.id)))
await commitAttachments(files)
payload.attachments = files.map(({ previewUrl: _preview, ...file }) => file)
},
blocked: async id => [...runTasks.values()].some(run => run.sessionId === id) || await hasPendingInteraction(id),
deliver: (payload, id) => withRuntimeStart(() => chatTurns.startTurn(payload, undefined, id)),
deliverNow: (payload, id) => chatTurns.appendToRun(payload, id),
changed: snapshot => broadcastChat('chat:queue:changed', snapshot)
})
ipcMain.handle('chat:queue:get', async (_event, id: string) => { await assertQueueSession(id); return promptQueue.get(id) })
ipcMain.handle('chat:queue:enqueue', (_event, payload: SendPayload) => withRuntimeStart(() => promptQueue.enqueue(payload)))
ipcMain.handle('chat:queue:restore-edit', async (_event, sessionId: string, id: string) => {
await assertQueueSession(sessionId)
if (typeof id !== 'string') throw new Error('编辑取回标识无效')
return promptQueue.restoreEdit(sessionId, id)
})
for (const action of ['take', 'remove', 'reorder', 'resume', 'sendNow'] as const) {
const channel = action === 'sendNow' ? 'send-now' : action
ipcMain.handle(`chat:queue:${channel}`, async (_event, id: string, argument: unknown) => {
await assertQueueSession(id)
if (action === 'resume') return withRuntimeStart(() => promptQueue.resume(id))
if (action === 'reorder') {
if (!Array.isArray(argument) || !argument.every(value => typeof value === 'string')) throw new Error('追加消息排序无效')
return promptQueue.reorder(id, argument)
}
if (typeof argument !== 'string') throw new Error('追加消息标识无效')
if (action === 'sendNow') return withRuntimeStart(() => promptQueue.sendNow(id, argument))
return promptQueue[action](id, argument)
})
}
const automation = createAutomationHost({ userData: app.getPath('userData'), projects, chatTurns,
changed: () => broadcastChat('automation:changed', undefined) })
automationHost = automation
@@ -85,7 +156,7 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
ipcMain.handle('automation:set-enabled', (_event, id: string, enabled: boolean) => automation.api.setEnabled(id, enabled))
ipcMain.handle('automation:remove', (_event, id: string) => automation.api.remove(id))
ipcMain.handle('automation:runs', (_event, filter?: AutomationRunFilter) => automation.api.listRuns(filter))
ipcMain.handle('automation:run-now', (_event, id: string) => automation.api.runNow(id))
ipcMain.handle('automation:run-now', (_event, id: string) => { assertRuntimeAvailable(); return automation.api.runNow(id) })
const artifactIndex = new ArtifactIndexService({ repository: new FileArtifactIndexRepository(),
listSourceSessions: sessions.listSessions, getMessages: sessions.getMessages })
const library = new LibraryService({ artifactIndex,
@@ -109,17 +180,62 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
ipcMain.handle('projects:update', (_event, projectId: string, patch: Partial<ProjectInput>) => projects.update(projectId, patch))
const capabilityMaintenance = (): never => { throw new Error('技能、专家和连接器正在维护') }
for (const channel of [
'skills:list', 'skills:read', 'skills:enable', 'skills:remove', 'skills:import', 'skills:export',
'experts:list', 'experts:read', 'experts:activate', 'experts:remove', 'experts:open-path',
'mcp:list', 'mcp:save', 'mcp:trust', 'mcp:connect', 'mcp:disconnect', 'mcp:enable',
'mcp:tool-enable', 'mcp:remove'
'experts:list', 'experts:read', 'experts:activate', 'experts:remove', 'experts:open-path'
]) ipcMain.handle(channel, capabilityMaintenance)
stopInstallBroadcast?.()
stopInstallBroadcast = subscribeRecommendedSkillInstall(progress => broadcastChat('skills:install-progress', progress))
const skillsChanged = (): void => broadcastChat('skills:changed', undefined)
ipcMain.handle('skills:recommendations', () => getRecommendedSkills())
ipcMain.handle('skills:install-state', () => getRecommendedSkillInstallState())
ipcMain.handle('skills:cancel-install', (_event, id: string) => cancelRecommendedSkillInstall(id))
ipcMain.handle('skills:install-recommended', async (_event, id: string, options: RecommendedSkillInstallOptions) => {
assertRuntimeAvailable()
const installed = await installRecommendedSkill(id, options)
skillsChanged()
return installed
})
ipcMain.handle('skills:list', (_event, cwd?: string) => skills.listSkills(cwd))
ipcMain.handle('skills:read', (_event, id: string, cwd?: string) => skills.readSkill(id, cwd))
ipcMain.handle('skills:enable', async (_event, id: string, enabled: boolean, cwd?: string) => {
await skills.setSkillEnabled(id, enabled, cwd)
skillsChanged()
})
ipcMain.handle('skills:remove', async (_event, id: string) => {
await skills.removeSkill(id)
skillsChanged()
})
ipcMain.handle('skills:import', async (_event, path?: string, overwrite = false, kind: 'directory' | 'zip' = 'directory') => {
if (!path) {
const selected = await dialog.showOpenDialog({ properties: [kind === 'zip' ? 'openFile' : 'openDirectory'], ...(kind === 'zip' ? { filters: [{ name: 'Skill ZIP', extensions: ['zip'] }] } : {}) })
if (selected.canceled || !selected.filePaths[0]) return null
path = selected.filePaths[0]
}
const installed = await skills.importSkill(path, overwrite)
skillsChanged()
return installed
})
ipcMain.handle('skills:export', async (_event, id: string, destination?: string) => {
if (!destination) {
const selected = await dialog.showSaveDialog({ defaultPath: 'skill.zip', filters: [{ name: 'Skill ZIP', extensions: ['zip'] }] })
if (selected.canceled || !selected.filePath) return null
destination = selected.filePath
}
return skills.exportSkill(id, destination)
})
ipcMain.handle('mcp:list', () => mcp.listMcpServers())
ipcMain.handle('mcp:save', (_event, input) => mcp.saveMcpServer(input))
ipcMain.handle('mcp:trust', (_event, id: string, hash: string) => mcp.trustMcpServer(id, hash))
ipcMain.handle('mcp:connect', (_event, id: string) => withRuntimeStart(() => mcp.connectMcpServer(id)))
ipcMain.handle('mcp:disconnect', (_event, id: string) => mcp.disconnectMcpServer(id))
ipcMain.handle('mcp:enable', (_event, id: string, enabled: boolean) => withRuntimeStart(() => 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:create', async (event, sessionId?: string, cols?: number, rows?: number) => withRuntimeStart(async () => 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))
@@ -415,7 +531,11 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
ipcMain.handle('session:list-archived', () => sessions.listVisibleSessions(true))
ipcMain.handle('session:create', (_event, title?: string, context: SessionContext = {}) => createManagedSession(projects, title, context))
ipcMain.handle('session:update-context', (_event, id: string, patch: SessionContext) => withChatSessionLock(id, async () => {
if ([...runTasks.values()].some((run) => run.sessionId === id)) throw new Error('请先停止当前运行')
if ([...runTasks.values()].some((run) => run.sessionId === id)) {
const next = nextRequestSettings(patch)
if (next.modelId && !(await models.resolveModel(next.modelId))) throw new Error('模型不存在')
return sessions.updateSessionContext(id, next)
}
const previous = await sessions.getSessionContext(id)
patch = agentModeContextPatch(previous, patch)
if (patch.projectId !== undefined && patch.projectId !== previous.projectId) throw new Error('请在目标项目新建任务')
@@ -431,11 +551,16 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
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)
context.expertId = null
if (patch.permissionMode !== undefined) {
if (patch.permissionMode !== 'default-sandbox' && patch.permissionMode !== 'full-access') throw new Error('权限模式无效')
context.permissionMode = patch.permissionMode
}
if (patch.expertId !== undefined) context.expertId = patch.expertId
return sessions.updateSessionContext(id, context)
}))
ipcMain.handle('session:messages', (_event, sessionId: string) => sessions.getMessages(sessionId))
const contextUsageService=createContextUsageService({messages:sessions.getMessages,
summary: async id => cachedClaudeKernel(id)?.getContextBreakdown(),
modelId:async id=>(await sessions.getSessionContext(id)).modelId,
running:id=>[...runTasks.values()].some(run=>run.sessionId===id),
persistIfCurrent:(id,assistantId,requestId,sample)=>withChatSessionLock(id,async()=>{
@@ -472,13 +597,17 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
})
ipcMain.handle('session:delete', async (_event, sessionId: string, options?: { onlyIfEmpty?: boolean }) => withChatSessionLock(sessionId, async () => {
const tasks = [...runTasks.entries()].filter(([, task]) => task.sessionId === sessionId)
if (options?.onlyIfEmpty && (tasks.length || (await sessions.getMessages(sessionId)).length)) {
const queued = await promptQueue.get(sessionId)
if (queued.items.some(item => item.status === 'delivering')) throw new Error('追加消息正在交付,请稍后删除会话')
if (options?.onlyIfEmpty && (tasks.length || queued.items.length || queued.editingDraft || (await sessions.getMessages(sessionId)).length)) {
throw new Error('该会话已有消息或正在运行,已保留任务')
}
await promptQueue.pause(sessionId, '会话正在删除')
for (const [runId] of tasks) activeRuns.get(runId)?.abort()
await disposeSessionKernel(sessionId)
await Promise.all(tasks.map(([, task]) => task.promise))
disposeSessionBrowsers(sessionId)
await promptQueue.clear(sessionId)
return sessions.deleteSession(sessionId)
}))
@@ -515,7 +644,14 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
ipcMain.handle('chat:revert', (_event, sessionId: string, messageId: string, options?: RewindOptions) =>
chatTurns.revert(sessionId, messageId, options))
ipcMain.handle('chat:send', async (event, payload: SendPayload) => {
const { completion: _completion, ...result } = await chatTurns.startTurn(payload, event.sender)
if (payload.queueEditId) await promptQueue.assertEditing(payload.sessionId, payload.queueEditId)
if (payload.command) {
const queue = await promptQueue.get(payload.sessionId)
if (queue.items.length || queue.editingDraft) throw new Error('请先处理待发送消息再压缩上下文')
}
const { completion, ...result } = await withRuntimeStart(() => chatTurns.startTurn(payload, event.sender))
if (payload.queueEditId) await promptQueue.completeEdit(payload.sessionId, payload.queueEditId).catch(error => console.error('编辑取回记录清理失败,已保留待核对记录', error))
void completion.then(done => done.status === 'done' ? promptQueue.notify(payload.sessionId) : promptQueue.pause(payload.sessionId, '运行停止或异常,已暂停自动发送')).catch(error => console.error('追加队列状态保存失败', error))
return result
})
+802
View File
@@ -0,0 +1,802 @@
import { randomUUID } from 'node:crypto'
import { promises as fs } from 'node:fs'
import { hostname } from 'node:os'
import { dirname } from 'node:path'
import type { SendPayload } from '../shared/ipc'
import type { PromptQueueSnapshot, QueuedPrompt } from '../shared/prompt-queue'
interface Delivery {
completion: Promise<{ status: 'done' | 'cancelled' | 'error' }>
}
interface Ports {
file: string
validate(payload: SendPayload): Promise<void>
blocked(sessionId: string): Promise<boolean>
deliver(payload: SendPayload, id: string): Promise<Delivery>
deliverNow?(payload: SendPayload, id: string): Promise<void>
/** Host-owned request settings resolved immediately before delivery; stored payload stays immutable. */
resolveDispatch?(payload: SendPayload, id: string): Promise<SendPayload> | SendPayload
changed(snapshot: PromptQueueSnapshot): void
}
interface DeliveryOwner {
pid: number
nonce: string
hostname: string
startedAt: number
}
interface StoredQueuedPrompt extends Omit<QueuedPrompt, 'status'> {
status: QueuedPrompt['status'] | 'editing'
editingUnknown?: boolean
deliveryOwner?: DeliveryOwner
}
interface StoredQueue extends Omit<PromptQueueSnapshot, 'items'> {
items: StoredQueuedPrompt[]
}
interface Store {
version: 1
queues: StoredQueue[]
}
interface LockMetadata {
pid: number
nonce: string
hostname: string
createdAt: number
expiresAt: number
}
interface MemoryOverlay {
itemId: string
error: string
reason: string
}
const MAX_ITEMS = 19
const MAX_FILE_BYTES = 10 * 1024 * 1024
const LOCK_TIMEOUT_MS = 10_000
const LOCK_LEASE_MS = 30_000
const LOCK_HEARTBEAT_MS = 10_000
const LOCAL_HOST = hostname()
/** Shared by repository instances in this process so a fresh read preserves their active delivery. */
const PROCESS_NONCE = randomUUID()
const overlays = new Map<string, MemoryOverlay>()
const lastKnownQueues = new Map<string, StoredQueue>()
const clone = <T>(value: T): T => structuredClone(value)
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
const queueKey = (file: string, sessionId: string): string => `${file}\0${sessionId}`
const empty = (sessionId: string): StoredQueue => ({ sessionId, revision: 0, paused: false, items: [] })
const object = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value)
const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error)
function fail(message: string): never {
throw new Error(`追加队列文件损坏:${message}`)
}
function validatePayload(value: unknown, sessionId: string): SendPayload {
if (!object(value)) fail('消息载荷无效')
if (value.sessionId !== sessionId) fail('消息载荷会话与队列会话不一致')
if (typeof value.text !== 'string') fail('消息文本无效')
if (value.command !== undefined || value.action !== undefined || value.fromMessageId !== undefined) {
fail('普通队列不能包含命令或重新发送操作')
}
if (value.permissionMode !== undefined && value.permissionMode !== 'default-sandbox' && value.permissionMode !== 'full-access') {
fail('权限模式无效')
}
if (value.capabilityLabels !== undefined) {
const labels = value.capabilityLabels
if (!object(labels) || Object.keys(labels).length > 100 ||
Object.entries(labels).some(([key, label]) =>
!key || key.length > 512 || typeof label !== 'string' || label.length > 1024)) {
fail('能力标签字段无效')
}
}
for (const key of ['resources', 'skillIds', 'mcpServerIds'] as const) {
if (value[key] !== undefined && (!Array.isArray(value[key]) || value[key].some(item => typeof item !== 'string'))) {
fail(`${key} 字段无效`)
}
}
const attachments = Array.isArray(value.attachments) ? value.attachments : []
if (value.attachments !== undefined) {
if (!Array.isArray(value.attachments) || attachments.some(item => !object(item) ||
typeof item.id !== 'string' || typeof item.name !== 'string' ||
typeof item.mimeType !== 'string' || typeof item.size !== 'number' || !Number.isFinite(item.size))) {
fail('附件字段无效')
}
}
const quotes = Array.isArray(value.quotes) ? value.quotes : []
if (value.quotes !== undefined) {
if (!Array.isArray(value.quotes) || quotes.some(item => !object(item) ||
typeof item.id !== 'string' || item.kind !== 'table' ||
typeof item.markdown !== 'string' || typeof item.preview !== 'string')) {
fail('引用字段无效')
}
}
if (value.userContent !== undefined && !Array.isArray(value.userContent)) fail('内容顺序字段无效')
if (value.queueEditId !== undefined &&
(typeof value.queueEditId !== 'string' || !value.queueEditId.trim() || value.queueEditId.length > 256)) {
fail('编辑取回标识无效')
}
if (value.planId !== undefined && typeof value.planId !== 'string') fail('计划标识无效')
if (value.rollbackArtifacts !== undefined && typeof value.rollbackArtifacts !== 'boolean') fail('产物回退字段无效')
if (!value.text.trim() && !attachments.length && !quotes.length &&
!(Array.isArray(value.resources) && value.resources.length) &&
!(Array.isArray(value.skillIds) && value.skillIds.length) &&
!(Array.isArray(value.mcpServerIds) && value.mcpServerIds.length)) fail('消息为空')
return value as unknown as SendPayload
}
function validateIncomingPayload(value: unknown): SendPayload {
if (!object(value) || typeof value.sessionId !== 'string' || !value.sessionId || value.sessionId.length > 256) {
throw new Error('追加消息载荷无效')
}
try {
return validatePayload(value, value.sessionId)
} catch (error) {
throw new Error(`追加消息无效:${errorMessage(error).replace(/^追加队列文件损坏:/, '')}`)
}
}
function validateStore(value: unknown): Store {
if (!object(value) || value.version !== 1 || !Array.isArray(value.queues)) fail('版本或顶层结构无效')
const sessions = new Set<string>()
const queues: StoredQueue[] = []
for (const rawQueue of value.queues) {
if (!object(rawQueue)) fail('队列不是对象')
const sessionId = rawQueue.sessionId
if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 256 || sessions.has(sessionId)) fail('会话标识缺失或重复')
sessions.add(sessionId)
if (!Number.isInteger(rawQueue.revision) || Number(rawQueue.revision) < 0) fail('revision 无效')
if (typeof rawQueue.paused !== 'boolean') fail('暂停状态无效')
if (rawQueue.reason !== undefined && typeof rawQueue.reason !== 'string') fail('暂停原因无效')
if (!Array.isArray(rawQueue.items) || rawQueue.items.length > MAX_ITEMS + 1) fail('消息数量无效')
const ids = new Set<string>()
const items: StoredQueuedPrompt[] = rawQueue.items.map(rawItem => {
if (!object(rawItem)) fail('队列消息不是对象')
if (typeof rawItem.id !== 'string' || !rawItem.id || rawItem.id.length > 256 || ids.has(rawItem.id)) fail('消息标识缺失或重复')
ids.add(rawItem.id)
if (typeof rawItem.createdAt !== 'number' || !Number.isFinite(rawItem.createdAt) || rawItem.createdAt < 0) fail('消息时间无效')
if (!['pending', 'delivering', 'unknown', 'editing'].includes(String(rawItem.status))) fail('消息状态无效')
if (rawItem.error !== undefined && typeof rawItem.error !== 'string') fail('消息错误信息无效')
const status = rawItem.status as StoredQueuedPrompt['status']
if (status !== 'unknown' && status !== 'pending' && rawItem.error !== undefined) fail('消息恢复错误状态无效')
if (rawItem.editingUnknown !== undefined && (status !== 'editing' || typeof rawItem.editingUnknown !== 'boolean')) fail('编辑交接状态无效')
let deliveryOwner: DeliveryOwner | undefined
if (rawItem.deliveryOwner !== undefined) {
if (status !== 'delivering' && status !== 'editing' || !object(rawItem.deliveryOwner)) fail('交付所有权标记无效')
const owner = rawItem.deliveryOwner
if (!Number.isInteger(owner.pid) || Number(owner.pid) <= 0 || typeof owner.nonce !== 'string' ||
!owner.nonce || typeof owner.hostname !== 'string' || !owner.hostname ||
typeof owner.startedAt !== 'number' || !Number.isFinite(owner.startedAt)) fail('交付所有权内容无效')
deliveryOwner = { pid: Number(owner.pid), nonce: owner.nonce, hostname: owner.hostname, startedAt: owner.startedAt }
}
return {
id: rawItem.id,
createdAt: rawItem.createdAt,
status,
...(rawItem.editingUnknown !== undefined ? { editingUnknown: rawItem.editingUnknown } : {}),
payload: validatePayload(rawItem.payload, sessionId),
...(rawItem.error !== undefined ? { error: rawItem.error } : {}),
...(deliveryOwner ? { deliveryOwner } : {})
}
})
if (items.filter(item => item.status !== 'editing').length > MAX_ITEMS || items.filter(item => item.status === 'editing').length > 1) fail('消息数量或编辑取回数量无效')
queues.push({
sessionId,
revision: Number(rawQueue.revision),
paused: rawQueue.paused,
...(rawQueue.reason !== undefined ? { reason: rawQueue.reason } : {}),
items
})
}
return { version: 1, queues }
}
async function readStore(file: string): Promise<Store> {
try {
const stat = await fs.stat(file)
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) throw new Error('追加队列文件不是普通文件或过大')
return validateStore(JSON.parse(await fs.readFile(file, 'utf8')))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, queues: [] }
if (error instanceof SyntaxError) throw new Error('追加队列文件格式无效')
throw error
}
}
async function saveStore(file: string, value: Store): Promise<void> {
const serialized = JSON.stringify(value)
if (Buffer.byteLength(serialized, 'utf8') > MAX_FILE_BYTES) throw new Error('追加队列内容超过 10 MB,已保留原队列和输入草稿')
await fs.mkdir(dirname(file), { recursive: true })
const temporary = `${file}.${randomUUID()}.tmp`
try {
await fs.writeFile(temporary, serialized, { encoding: 'utf8', mode: 0o600 })
await fs.rename(temporary, file)
} catch (error) {
throw new Error(`追加队列保存失败:${errorMessage(error)}`)
} finally {
await fs.rm(temporary, { force: true }).catch(() => undefined)
}
}
function pidAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM'
}
}
async function readLock(file: string): Promise<LockMetadata | undefined> {
try {
const value = JSON.parse(await fs.readFile(file, 'utf8'))
if (!object(value) || !Number.isInteger(value.pid) || typeof value.nonce !== 'string' ||
typeof value.hostname !== 'string' || typeof value.createdAt !== 'number' ||
typeof value.expiresAt !== 'number') return undefined
return value as unknown as LockMetadata
} catch {
return undefined
}
}
async function staleLock(lockPath: string): Promise<boolean> {
const metadata = await readLock(lockPath)
if (metadata) {
// A live local process is never preempted merely because its heartbeat
// stalled; callers get a clear timeout instead. Other hosts use the lease.
return metadata.hostname === LOCAL_HOST
? !pidAlive(metadata.pid)
: metadata.expiresAt <= Date.now()
}
try {
const stat = await fs.stat(lockPath)
return Date.now() - stat.mtimeMs > LOCK_LEASE_MS
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT'
}
}
async function reclaimLock(lockPath: string): Promise<boolean> {
if (!await staleLock(lockPath)) return false
const quarantine = `${lockPath}.stale.${randomUUID()}`
try {
await fs.rename(lockPath, quarantine)
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT'
}
const moved = await readLock(quarantine)
const stillActive = moved && (moved.hostname === LOCAL_HOST
? pidAlive(moved.pid)
: moved.expiresAt > Date.now())
if (stillActive) {
let restore: Awaited<ReturnType<typeof fs.open>> | undefined
try {
// Exclusive creation ensures a competing owner's lock is never replaced.
restore = await fs.open(lockPath, 'wx', 0o600)
await restore.writeFile(await fs.readFile(quarantine))
await restore.sync()
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') await fs.rm(quarantine, { force: true }).catch(() => undefined)
return false
} finally {
await restore?.close().catch(() => undefined)
}
await fs.rm(quarantine, { force: true }).catch(() => undefined)
return false
}
await fs.rm(quarantine, { force: true }).catch(() => undefined)
return true
}
async function withFileLock<T>(file: string, operation: () => Promise<T>): Promise<T> {
const lockPath = `${file}.lock`
await fs.mkdir(dirname(file), { recursive: true })
const deadline = Date.now() + LOCK_TIMEOUT_MS
let handle: Awaited<ReturnType<typeof fs.open>> | undefined
const nonce = randomUUID()
const metadata: LockMetadata = {
pid: process.pid,
nonce,
hostname: LOCAL_HOST,
createdAt: Date.now(),
expiresAt: Date.now() + LOCK_LEASE_MS
}
while (!handle) {
try {
handle = await fs.open(lockPath, 'wx', 0o600)
await handle.writeFile(JSON.stringify(metadata), 'utf8')
await handle.sync()
} catch (error) {
if (handle) {
await handle.close().catch(() => undefined)
await fs.rm(lockPath, { force: true }).catch(() => undefined)
handle = undefined
}
const code = (error as NodeJS.ErrnoException).code
const exists = code === 'EEXIST' ||
code === 'EPERM' && await fs.stat(lockPath).then(() => true, () => false)
if (!exists) throw error
if (await reclaimLock(lockPath)) continue
if (Date.now() >= deadline) throw new Error('追加队列正被其他 YCode 实例使用,等待文件锁超时')
await sleep(40)
}
}
let released = false
let heartbeatInFlight: Promise<void> | undefined
const lockHandle = handle
const refreshLock = (): void => {
if (released || heartbeatInFlight) return
const task = (async () => {
if (released) return
const bytes = Buffer.from(JSON.stringify({ ...metadata, expiresAt: Date.now() + LOCK_LEASE_MS }), 'utf8')
// Keep using the original descriptor; reopening the path could overwrite a new owner.
await lockHandle.truncate(0)
await lockHandle.write(bytes, 0, bytes.length, 0)
await lockHandle.sync()
})().catch(() => undefined).finally(() => {
if (heartbeatInFlight === task) heartbeatInFlight = undefined
})
heartbeatInFlight = task
}
const heartbeat = setInterval(() => {
refreshLock()
}, LOCK_HEARTBEAT_MS)
heartbeat.unref()
try {
return await operation()
} finally {
released = true
clearInterval(heartbeat)
await heartbeatInFlight
const current = await readLock(lockPath)
if (current?.nonce === nonce) await fs.rm(lockPath, { force: true }).catch(() => undefined)
await lockHandle.close().catch(() => undefined)
}
}
function ownerIsActive(owner: DeliveryOwner | undefined): boolean {
if (!owner) return false
if (owner.hostname !== LOCAL_HOST) return Date.now() - owner.startedAt < LOCK_LEASE_MS
if (owner.pid === process.pid) return owner.nonce === PROCESS_NONCE
return pidAlive(owner.pid)
}
function recoverQueues(state: Store): Set<string> {
const changed = new Set<string>()
for (const queue of state.queues) {
let recovered = false
for (const item of queue.items) {
if (item.status === 'editing' && !ownerIsActive(item.deliveryOwner)) {
item.status = item.editingUnknown ? 'unknown' : 'pending'
if (item.editingUnknown) item.error = '原消息交付结果不明,编辑尚未提交,请核对历史后处理'
delete item.editingUnknown
delete item.deliveryOwner
recovered = true
continue
}
if (item.status !== 'delivering' || ownerIsActive(item.deliveryOwner)) continue
item.status = 'unknown'
item.error = '上次交付结果不明,请核对历史后编辑或删除'
delete item.deliveryOwner
recovered = true
}
if (recovered) {
queue.paused = true
queue.reason = '检测到未完成的交付,已暂停自动发送'
queue.revision++
changed.add(queue.sessionId)
}
}
return changed
}
function applyOverlay(file: string, sessionId: string, queue: StoredQueue): boolean {
const overlay = overlays.get(queueKey(file, sessionId))
if (!overlay) return false
const item = queue.items.find(value => value.id === overlay.itemId)
if (!item) {
overlays.delete(queueKey(file, sessionId))
return false
}
const changed = item.status !== 'unknown' || item.error !== overlay.error ||
!queue.paused || queue.reason !== overlay.reason
if (changed) {
item.status = 'unknown'
item.error = overlay.error
delete item.deliveryOwner
delete item.editingUnknown
queue.paused = true
queue.reason = overlay.reason
queue.revision++
}
return changed
}
function overlaySessions(file: string): string[] {
const prefix = `${file}\0`
return [...overlays.keys()].filter(key => key.startsWith(prefix)).map(key => key.slice(prefix.length))
}
function lastKnownSnapshots(file: string): StoredQueue[] {
const prefix = `${file}\0`
return [...lastKnownQueues.entries()]
.filter(([key]) => key.startsWith(prefix))
.map(([, queue]) => clone(queue))
}
function snapshot(value: StoredQueue): PromptQueueSnapshot {
const toPrompt = ({ deliveryOwner: _owner, editingUnknown, status, ...item }: StoredQueuedPrompt): QueuedPrompt => ({ ...clone(item), status: status === 'editing' ? editingUnknown ? 'unknown' : 'pending' : status })
return {
sessionId: value.sessionId,
revision: value.revision,
paused: value.paused,
...(value.reason !== undefined ? { reason: value.reason } : {}),
items: value.items.filter(item => item.status !== 'editing').map(toPrompt),
...(value.items.some(item => item.status === 'editing') ? { editingDraft: (() => {
const draft = toPrompt(value.items.find(item => item.status === 'editing')!)
return { ...draft, payload: { ...draft.payload, queueEditId: draft.id } }
})() } : {})
}
}
export function createPromptQueue(ports: Ports) {
let serial: Promise<unknown> = Promise.resolve()
let loadedOnce = false
const pumping = new Set<string>()
const locked = <T>(operation: () => Promise<T>): Promise<T> => {
const result = serial.catch(() => {}).then(() => withFileLock(ports.file, operation))
serial = result.catch(() => {})
return result
}
async function load(): Promise<Store> {
let state: Store
try {
state = await readStore(ports.file)
} catch (error) {
const fallback = lastKnownSnapshots(ports.file)
if (!overlaySessions(ports.file).length || !fallback.length) throw error
state = { version: 1, queues: fallback }
for (const queue of state.queues) {
if (applyOverlay(ports.file, queue.sessionId, queue)) {
queue.reason = `${queue.reason ?? '追加队列不可用'};读取失败:${errorMessage(error)}`
}
}
return state
}
if (loadedOnce && !state.queues.length) {
// The store can disappear between two operations in the same process.
// Keep the last validated queues instead of silently dropping unrelated
// sessions; a real restart starts with an empty in-memory fallback.
state.queues = lastKnownSnapshots(ports.file)
}
for (const sessionId of overlaySessions(ports.file)) {
if (state.queues.some(queue => queue.sessionId === sessionId)) continue
const fallback = lastKnownQueues.get(queueKey(ports.file, sessionId))
if (fallback) state.queues.push(clone(fallback))
}
for (const queue of state.queues) lastKnownQueues.set(queueKey(ports.file, queue.sessionId), clone(queue))
const recovered = recoverQueues(state)
if (!loadedOnce) {
loadedOnce = true
for (const queue of state.queues) {
if (!queue.items.length || queue.paused) continue
// A live delivery owned by this process is a fresh read, not a restart.
if (queue.items.some(item => item.status === 'delivering' && ownerIsActive(item.deliveryOwner))) continue
queue.paused = true
queue.reason = '应用重启后队列已暂停,请确认后恢复'
queue.revision++
recovered.add(queue.sessionId)
}
}
if (recovered.size) {
try {
await saveStore(ports.file, state)
for (const sessionId of recovered) overlays.delete(queueKey(ports.file, sessionId))
} catch (error) {
for (const sessionId of recovered) {
const queue = state.queues.find(value => value.sessionId === sessionId)
const item = queue?.items.find(value => value.status === 'unknown')
if (!queue || !item) continue
overlays.set(queueKey(ports.file, sessionId), {
itemId: item.id,
error: `${item.error ?? '交付结果不明'};恢复状态保存失败:${errorMessage(error)}`,
reason: `${queue.reason ?? '已暂停自动发送'};恢复状态保存失败:${errorMessage(error)}`
})
}
}
}
for (const queue of state.queues) {
applyOverlay(ports.file, queue.sessionId, queue)
lastKnownQueues.set(queueKey(ports.file, queue.sessionId), clone(queue))
}
if (recovered.size) {
for (const sessionId of recovered) {
const queue = state.queues.find(value => value.sessionId === sessionId)
if (queue) {
try { ports.changed(snapshot(queue)) } catch { /* Persistence is independent of windows. */ }
}
}
}
return state
}
async function mutate<T>(sessionId: string, edit: (queue: StoredQueue) => T | Promise<T>): Promise<T> {
return locked(async () => {
const state = await load()
const current = state.queues.find(queue => queue.sessionId === sessionId)
const queue = clone(current ?? empty(sessionId))
const before = JSON.stringify(queue)
const result = await edit(queue)
if (JSON.stringify(queue) === before) return clone(result)
queue.revision++
state.queues = [...state.queues.filter(value => value.sessionId !== sessionId), queue]
await saveStore(ports.file, state)
overlays.delete(queueKey(ports.file, sessionId))
lastKnownQueues.set(queueKey(ports.file, sessionId), clone(queue))
try { ports.changed(snapshot(queue)) } catch { /* Window lifetime does not affect persistence. */ }
return clone(result)
})
}
function item(queue: StoredQueue, id: string): StoredQueuedPrompt {
const found = queue.items.find(value => value.id === id)
if (!found) throw new Error('追加消息不存在')
if (found.status === 'delivering') throw new Error('消息正在交付,不能编辑或删除')
if (found.status === 'editing') throw new Error('消息已在输入框中编辑')
return found
}
async function setUnknown(sessionId: string, itemId: string, reason: string, detail: string): Promise<void> {
try {
await mutate(sessionId, queue => {
const found = queue.items.find(value => value.id === itemId)
if (!found) return queue
found.status = 'unknown'
found.error = detail
delete found.deliveryOwner
delete found.editingUnknown
queue.paused = true
queue.reason = reason
return queue
})
} catch (error) {
const known = lastKnownQueues.get(queueKey(ports.file, sessionId))
if (!known) return
overlays.set(queueKey(ports.file, sessionId), {
itemId,
error: `${detail};暂停状态保存失败:${errorMessage(error)}`,
reason: `${reason};暂停状态保存失败:${errorMessage(error)}`
})
const fallback = clone(known)
applyOverlay(ports.file, sessionId, fallback)
lastKnownQueues.set(queueKey(ports.file, sessionId), fallback)
}
}
const pump = async (sessionId: string): Promise<void> => {
if (pumping.has(sessionId)) return
pumping.add(sessionId)
let reserved: StoredQueuedPrompt | undefined
try {
for (;;) {
reserved = await mutate(sessionId, async queue => {
if (queue.paused || !queue.items.length || await ports.blocked(sessionId)) return undefined
const first = queue.items.find(item => item.status !== 'editing')
if (!first) return undefined
if (first.status !== 'pending') return undefined
first.status = 'delivering'
delete first.error
first.deliveryOwner = {
pid: process.pid,
nonce: PROCESS_NONCE,
hostname: LOCAL_HOST,
startedAt: Date.now()
}
return clone(first)
})
if (!reserved) return
let delivery: Delivery
try {
const dispatchPayload = ports.resolveDispatch
? await ports.resolveDispatch(clone(reserved.payload), reserved.id)
: reserved.payload
if (dispatchPayload.sessionId !== sessionId) throw new Error('派发配置会话与队列会话不一致')
delivery = await ports.deliver(dispatchPayload, reserved.id)
} catch (error) {
await setUnknown(sessionId, reserved.id, '交付未确认,已暂停自动发送', errorMessage(error))
return
}
try {
await mutate(sessionId, queue => {
queue.items = queue.items.filter(value => value.id !== reserved!.id)
return queue
})
} catch (error) {
await setUnknown(sessionId, reserved.id, '交付已开始,但队列清理失败,已暂停自动发送', errorMessage(error))
return
}
reserved = undefined
let result: Awaited<Delivery['completion']>
try {
result = await delivery.completion
} catch (error) {
try {
await mutate(sessionId, queue => {
queue.paused = true
queue.reason = `运行状态读取失败:${errorMessage(error)}`
return queue
})
} catch { /* The next explicit resume reports storage failure. */ }
return
}
if (result.status !== 'done') {
try {
await mutate(sessionId, queue => {
queue.paused = true
queue.reason = '运行停止或异常,已暂停自动发送'
return queue
})
} catch (error) {
console.error('追加队列暂停状态保存失败', error)
}
return
}
}
} finally {
pumping.delete(sessionId)
}
}
const notify = (sessionId: string): void => {
void pump(sessionId).catch(error => { console.error('追加队列派发失败', error) })
}
return {
get: (sessionId: string) => locked(async () => snapshot((await load()).queues.find(queue => queue.sessionId === sessionId) ?? empty(sessionId))),
async enqueue(payload: SendPayload): Promise<PromptQueueSnapshot> {
const incoming = clone(validateIncomingPayload(payload))
const result = await mutate(incoming.sessionId, async queue => {
if (queue.items.filter(item => item.status !== 'editing').length >= MAX_ITEMS) throw new Error('最多追加 19 条消息,请先处理队列')
let insertAt = queue.items.length
if (payload.queueEditId) {
const editingIndex = queue.items.findIndex(item => item.id === payload.queueEditId && item.status === 'editing')
if (editingIndex < 0) throw new Error('取回的追加消息已提交或不再处于编辑状态')
insertAt = editingIndex
queue.items = queue.items.filter(item => item.id !== payload.queueEditId)
}
await ports.validate(incoming)
// Host enrichment (for example display labels) is also untrusted input
// until it crosses this boundary again.
const storedPayload = clone(validatePayload(incoming, incoming.sessionId))
delete storedPayload.queueEditId
// A stop can leave an empty queue paused. The next independent run starts
// with a clean dispatch state; an existing queue keeps its explicit pause.
if (queue.items.length === 0) {
queue.paused = false
delete queue.reason
}
const item = { id: randomUUID(), createdAt: Date.now(), status: 'pending' as const, payload: storedPayload }
queue.items.splice(Math.min(insertAt, queue.items.length), 0, item)
return queue
})
notify(incoming.sessionId)
return snapshot(result)
},
take: async (sessionId: string, id: string): Promise<QueuedPrompt> => locked(async () => {
const state = await load()
const current = state.queues.find(queue => queue.sessionId === sessionId)
const queue = clone(current ?? empty(sessionId))
const found = item(queue, id)
const taken = snapshot({ ...empty(sessionId), items: [found] }).items[0]
queue.items = queue.items.filter(value => value.status !== 'editing')
found.editingUnknown = found.status === 'unknown'
found.status = 'editing'
delete found.error
found.deliveryOwner = { pid: process.pid, nonce: PROCESS_NONCE, hostname: LOCAL_HOST, startedAt: Date.now() }
queue.revision++
state.queues = [...state.queues.filter(value => value.sessionId !== sessionId), queue]
await saveStore(ports.file, state)
overlays.delete(queueKey(ports.file, sessionId))
lastKnownQueues.set(queueKey(ports.file, sessionId), clone(queue))
try { ports.changed(snapshot(queue)) } catch { /* Window lifetime does not affect persistence. */ }
return { ...taken, payload: { ...taken.payload, queueEditId: id } }
}),
restoreEdit: async (sessionId: string, id: string): Promise<PromptQueueSnapshot> => mutate(sessionId, queue => {
const found = queue.items.find(item => item.id === id && item.status === 'editing')
if (!found) throw new Error('编辑取回记录已不存在')
found.status = found.editingUnknown ? 'unknown' : 'pending'
if (found.editingUnknown) found.error = '原消息交付结果不明,请核对历史后处理'
delete found.editingUnknown; delete found.deliveryOwner
queue.paused = true; queue.reason = '编辑取回未完成,原消息已保留,请确认后恢复'
return queue
}).then(snapshot),
async assertEditing(sessionId: string, id: string): Promise<void> {
await locked(async () => {
if (!(await load()).queues.find(queue => queue.sessionId === sessionId)?.items.some(item => item.id === id && item.status === 'editing')) throw new Error('编辑取回记录已提交或不存在')
})
},
async completeEdit(sessionId: string, id: string): Promise<void> {
try {
await mutate(sessionId, queue => { queue.items = queue.items.filter(item => item.id !== id || item.status !== 'editing') })
} catch (error) {
await setUnknown(sessionId, id, '编辑消息已提交,但取回记录清理失败,请核对历史', errorMessage(error))
throw error
}
},
remove: async (sessionId: string, id: string): Promise<PromptQueueSnapshot> => mutate(sessionId, queue => {
item(queue, id)
queue.items = queue.items.filter(value => value.id !== id)
return queue
}).then(snapshot),
reorder: async (sessionId: string, ids: string[]): Promise<PromptQueueSnapshot> => mutate(sessionId, queue => {
if (!Array.isArray(ids) || ids.length !== queue.items.filter(item => item.status !== 'editing').length || new Set(ids).size !== ids.length ||
ids.some(id => !queue.items.some(value => value.id === id && value.status !== 'editing'))) throw new Error('追加消息排序无效')
if (queue.items.some(value => value.status === 'delivering')) throw new Error('正在交付时不能排序')
queue.items = [...ids.map(id => queue.items.find(value => value.id === id)!), ...queue.items.filter(item => item.status === 'editing')]
return queue
}).then(snapshot),
async resume(sessionId: string): Promise<PromptQueueSnapshot> {
const result = await mutate(sessionId, queue => {
if (queue.items.some(value => value.status === 'unknown')) throw new Error('请先核对并编辑或删除交付结果不明的消息')
queue.paused = false
delete queue.reason
return queue
})
notify(sessionId)
return snapshot(result)
},
async clear(sessionId: string): Promise<PromptQueueSnapshot> {
return locked(async () => {
const state = await load()
const existing = state.queues.find(queue => queue.sessionId === sessionId)
if (!existing) return snapshot(empty(sessionId))
if (existing.items.some(item => item.status === 'delivering')) throw new Error('存在正在交付的消息,不能清空队列')
state.queues = state.queues.filter(queue => queue.sessionId !== sessionId)
await saveStore(ports.file, state)
overlays.delete(queueKey(ports.file, sessionId))
lastKnownQueues.delete(queueKey(ports.file, sessionId))
const cleared = { ...empty(sessionId), revision: existing.revision + 1 }
try { ports.changed(snapshot(cleared)) } catch { /* Window lifetime does not affect persistence. */ }
return snapshot(cleared)
})
},
pause: (sessionId: string, reason: string): Promise<void> => mutate(sessionId, queue => {
queue.paused = true
queue.reason = reason
return queue
}).then(() => undefined),
async sendNow(sessionId: string, id: string): Promise<PromptQueueSnapshot> {
if (!ports.deliverNow) throw new Error('当前内核尚未接入追加结果归属,暂不能立即发送')
const reserved = await mutate(sessionId, async queue => {
const found = item(queue, id)
if (found.status !== 'pending') throw new Error('交付结果不明的消息不能立即重试')
await ports.validate(found.payload)
found.status = 'delivering'
delete found.error
found.deliveryOwner = { pid: process.pid, nonce: PROCESS_NONCE, hostname: LOCAL_HOST, startedAt: Date.now() }
return clone(found)
})
try {
await ports.deliverNow(reserved.payload, reserved.id)
} catch (error) {
if ((error as { submitted?: boolean })?.submitted === false) {
await mutate(sessionId, queue => {
const found = queue.items.find(value => value.id === id)
if (found) { found.status = 'pending'; found.error = errorMessage(error); delete found.deliveryOwner }
queue.paused = true; queue.reason = '立即发送未提交,请确认后恢复队列'
})
} else await setUnknown(sessionId, id, '引导消息交付未确认,已暂停自动发送', errorMessage(error))
throw error
}
try {
const result = await mutate(sessionId, queue => { queue.items = queue.items.filter(value => value.id !== id); return queue })
notify(sessionId)
return snapshot(result)
} catch (error) {
await setUnknown(sessionId, id, '消息已进入历史,但队列清理失败,请核对后删除', errorMessage(error))
throw error
}
},
notify
}
}
+7 -7
View File
@@ -94,7 +94,7 @@ export async function listSessions(): Promise<SessionMeta[]> {
return [...store.sessions]
// 置顶任务优先(最近置顶在前),其余按更新时间倒序。
.sort((a, b) => Number(b.pinnedAt ?? 0) - Number(a.pinnedAt ?? 0) || b.updatedAt - a.updatedAt)
.map(({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }) => ({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }))
.map(({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }) => ({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }))
}
/** 侧栏可见任务:默认排除已归档;`includeArchived` 供归档视图使用。 */
@@ -124,8 +124,8 @@ export async function setSessionArchived(sessionId: string, archived: boolean):
}
function meta(session: SessionRecord): SessionMeta {
const { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt } = session
return { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }
const { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt } = session
return { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }
}
export async function createSession(title = '新对话', context: SessionContext = {}): Promise<SessionMeta> {
@@ -141,7 +141,7 @@ export async function createSession(title = '新对话', context: SessionContext
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, projectId: session.projectId, cwd: session.cwd, modelId: session.modelId, expertId: session.expertId, agentMode: agentMode(session.agentMode), thinkingLevel: session.thinkingLevel }
return { permissionMode: session.permissionMode, workspaceId: session.workspaceId, projectId: session.projectId, 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)
@@ -152,8 +152,8 @@ export async function updateSessionContext(id: string, patch: SessionContext): P
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, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level } = session
return { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level }
const { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, permissionMode } = session
return { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, permissionMode }
}
export async function getMessages(sessionId: string): Promise<ChatMessage[]> {
@@ -266,4 +266,4 @@ export async function startTurn(sessionId: string, user: ChatMessage, assistant:
export function deriveTitle(text: string): string {
const flat = text.replace(/\s+/g, ' ').trim()
return flat.length > 24 ? `${flat.slice(0, 24)}…` : flat || '新对话'
}
}
+4
View File
@@ -176,3 +176,7 @@ export function rebindTerminal(owner: BrowserWindow, id: string, previousSession
export function disposeTerminalHosts(owner?: BrowserWindow): void {
for (const entry of terminals.values()) if (!owner || entry.owner === owner) remove(entry)
}
export function activeTerminalCount(): number {
return [...terminals.values()].filter(entry => !entry.view.exited).length
}
+59
View File
@@ -0,0 +1,59 @@
export interface UpdateLifecyclePorts {
quiesce(): Promise<void>
restore(): void
shutdown(): Promise<void>
exit(): void
log(error: unknown): void
prepareTimeoutMs?: number
quitTimeoutMs?: number
}
export function createUpdateLifecycle(ports: UpdateLifecyclePorts): {
beforeInstall(): Promise<boolean>
installFailed(): void
quitForUpdate(): Promise<void>
} {
let preparation: Promise<void> | null = null
let committed = false
const restore = (): void => { if (!committed) ports.restore() }
const installFailed = (): void => {
// Stopping work is not cancellable. Do not resume underneath an old stop.
if (preparation) void preparation.then(restore, restore)
else restore()
}
return {
async beforeInstall() {
if (committed) throw new Error('安装器已启动,请退出应用完成更新')
if (preparation) throw new Error('上次更新准备仍在结束中,请稍后重试')
let timer: ReturnType<typeof setTimeout> | undefined
const pending = ports.quiesce()
preparation = pending
void pending.finally(() => { if (preparation === pending) preparation = null }).catch(() => undefined)
try {
await Promise.race([
pending,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('任务清理尚未结束,暂不能开始新任务;请稍后重试或重启应用')), ports.prepareTimeoutMs ?? 30_000)
})
])
return true
} finally { clearTimeout(timer) }
},
installFailed,
async quitForUpdate() {
committed = true
let timer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
ports.shutdown(),
new Promise<void>(resolve => { timer = setTimeout(resolve, ports.quitTimeoutMs ?? 15_000) })
])
} catch (error) {
ports.log(error)
} finally {
clearTimeout(timer)
ports.exit()
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
let paused = false
const pending = new Set<Promise<unknown>>()
export function assertRuntimeAvailable(): void {
if (paused) throw new Error('正在准备重启更新,请稍后重试')
}
export function withRuntimeStart<T>(operation: () => Promise<T>): Promise<T> {
assertRuntimeAvailable()
const task = Promise.resolve().then(operation)
pending.add(task)
void task.finally(() => pending.delete(task)).catch(() => undefined)
return task
}
export async function pauseRuntimeStarts(): Promise<void> {
paused = true
await Promise.allSettled([...pending])
}
export function resumeRuntimeStarts(): void { paused = false }
+67
View File
@@ -0,0 +1,67 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { mkdir, rename, rm, stat } from 'node:fs/promises'
import { isAbsolute, relative, resolve } from 'node:path'
import { parseVersion } from './semver'
import { UpdateError, type CachedInstaller } from './types'
export function installerCachePaths(cacheDirectory: string, version: string): CachedInstaller {
parseVersion(version)
const filename = `YCode-${version}-Setup.exe`
const path = resolve(cacheDirectory, filename)
const relation = relative(resolve(cacheDirectory), path)
if (!relation || relation.startsWith('..') || isAbsolute(relation)) {
throw new UpdateError('unsafe-cache-path', '安装包缓存路径无效')
}
const partPath = `${path}.part`
return { version, path, partPath, size: 0, sha256: '' }
}
export async function sha256File(filename: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(filename)) hash.update(chunk)
return hash.digest('hex')
}
export async function verifyInstallerFile(
installer: CachedInstaller,
expected: { size: number; sha256: string }
): Promise<void> {
let info
try {
info = await stat(installer.path)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new UpdateError('cache-missing', '安装包尚未下载')
}
throw error
}
if (!info.isFile() || info.size !== expected.size) {
throw new UpdateError('cache-invalid', '安装包缓存大小不匹配')
}
if (await sha256File(installer.path) !== expected.sha256) {
throw new UpdateError('cache-invalid', '安装包缓存 SHA-256 不匹配')
}
}
export async function prepareCacheDirectory(cacheDirectory: string): Promise<void> {
await mkdir(cacheDirectory, { recursive: true })
}
export async function commitPartFile(installer: CachedInstaller): Promise<void> {
await rm(installer.path, { force: true })
await rename(installer.partPath, installer.path)
}
export async function removeCacheFiles(installer: CachedInstaller): Promise<void> {
await rm(installer.path, { force: true })
await rm(installer.partPath, { force: true })
}
export function assertInstallerInsideCache(cacheDirectory: string, filename: string): void {
const relation = relative(resolve(cacheDirectory), resolve(filename))
if (!relation || relation.startsWith('..') || isAbsolute(relation)) {
throw new UpdateError('unsafe-cache-path', '安装包路径不在更新缓存目录中')
}
}
+91
View File
@@ -0,0 +1,91 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import { UpdateManager } from './manager'
import { createNsisInstaller } from './installer'
import { registerUpdateIpc } from './ipc'
import { physicalRealPath, physicalStat } from './physical-files'
import { FileUpdatePersistence } from './storage'
import { UPDATE_MANIFEST_URL } from './types'
export interface RegisterUpdatesOptions {
beforeInstall: () => Promise<boolean>
quitForUpdate: () => Promise<void>
installFailed?: () => void | Promise<void>
}
export interface RegisteredUpdates {
start(): void
dispose(): void
}
function publicKeyPath(): string {
return app.isPackaged
? join(process.resourcesPath, 'updates', 'public-key.pem')
: join(app.getAppPath(), 'resources', 'updates', 'public-key.pem')
}
async function readPublicKey(): Promise<string> {
const value = await readFile(publicKeyPath(), 'utf8')
if (value.length > 64 * 1024) throw new Error('更新公钥文件过大')
return value
}
export function registerUpdates(options: RegisterUpdatesOptions): RegisteredUpdates {
const userData = app.getPath('userData')
const cacheDirectory = join(userData, 'updates', 'cache')
const logFile = join(userData, 'updates', 'updater.log')
const supported = app.isPackaged && process.platform === 'win32' && process.arch === 'x64'
const installer = createNsisInstaller({
platform: process.platform,
isPackaged: app.isPackaged,
execPath: process.execPath,
appPath: app.getAppPath(),
cacheDirectory,
resolveRealPath: physicalRealPath,
readStat: physicalStat
})
const manager = new UpdateManager({
currentVersion: app.getVersion(),
manifestUrl: UPDATE_MANIFEST_URL,
cacheDirectory,
logFile,
persistence: new FileUpdatePersistence(join(userData, 'updates', 'state.json')),
publicKey: readPublicKey,
supported,
installer,
beforeInstall: options.beforeInstall,
quitForUpdate: options.quitForUpdate,
installFailed: options.installFailed
})
const ipc = registerUpdateIpc({
manager,
ipcMain,
shell,
getAllWindows: () => BrowserWindow.getAllWindows(),
appPath: app.getAppPath(),
rendererUrl: process.env.ELECTRON_RENDERER_URL,
platform: process.platform
})
let started = false
let disposed = false
return {
start: () => {
if (started || disposed) return
started = true
manager.start()
},
dispose: () => {
if (disposed) return
disposed = true
manager.dispose()
ipc.dispose()
}
}
}
export { UpdateManager } from './manager'
export { isTrustedUpdateRendererUrl } from './trust'
export { UPDATE_MANIFEST_URL } from './types'
+205
View File
@@ -0,0 +1,205 @@
import { execFile, spawn } from 'node:child_process'
import { realpath, stat, statfs } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, parse, resolve } from 'node:path'
import { promisify } from 'node:util'
import { assertInstallerInsideCache, verifyInstallerFile } from './cache'
import { UpdateError, type CachedInstaller, type InstallEnvironment, type InstallerAdapter } from './types'
const execFileAsync = promisify(execFile)
// Current YCode NSIS uninstall registration under HKCU.
export const NSIS_UNINSTALL_KEY = 'Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\8a4ee941-d893-569b-8bcb-2a55d270c04b'
export interface SpawnedInstallerProcess {
once(event: 'spawn', listener: () => void): this
once(event: 'error', listener: (error: Error) => void): this
unref(): void
}
export type SpawnInstaller = (
command: string,
args: readonly string[],
options: { cwd: string; detached: boolean; windowsHide: boolean; stdio: 'ignore' }
) => SpawnedInstallerProcess
export interface NsisInstallerOptions {
platform?: NodeJS.Platform
isPackaged: boolean
execPath: string
appPath: string
cacheDirectory: string
processId?: number
spawn?: SpawnInstaller
queryRegistryValue?: (name: string) => Promise<string | null>
verifyFile?: (installer: CachedInstaller) => Promise<void>
resolveRealPath?: (value: string) => Promise<string>
readStat?: (value: string) => Promise<{ isFile(): boolean }>
readDiskStats?: (value: string) => Promise<{ bsize: number | bigint; bavail: number | bigint }>
}
function parseRegistryValue(output: string, name: string): string | null {
const expression = new RegExp(`^\\s*${name}\\s+REG_\\w+\\s+(.+?)\\s*$`, 'mi')
const match = expression.exec(output)
if (!match) return null
return match[1].replace(/^"(.*)"$/, '$1')
}
export async function queryNsisRegistryValue(name: string): Promise<string | null> {
if (process.platform !== 'win32') return null
try {
const { stdout } = await execFileAsync(
'reg.exe',
['query', `HKCU\\${NSIS_UNINSTALL_KEY}`, '/v', name],
{ encoding: 'utf8', windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }
)
return parseRegistryValue(stdout, name)
} catch (error) {
const code = (error as { code?: unknown }).code
if (code === 1 || code === '1') return null
throw new UpdateError('installer-registry', '无法读取 NSIS 安装注册信息')
}
}
function normalizedWindowsPath(value: string): string {
return resolve(value).replace(/[\\/]+$/, '').toLowerCase()
}
function registryExecutablePath(value: string): string | null {
const quoted = /^\s*"([^"]+\.exe)"/i.exec(value)
if (quoted) return quoted[1]
const plain = /^\s*(.+?\.exe)(?:\s|$|,)/i.exec(value)
return plain ? plain[1] : null
}
export function createNsisInstaller(options: NsisInstallerOptions): InstallerAdapter {
const platform = options.platform ?? process.platform
const queryRegistryValue = options.queryRegistryValue ?? queryNsisRegistryValue
const spawnInstaller: SpawnInstaller = options.spawn ?? ((command, args, spawnOptions) => spawn(
command,
[...args],
spawnOptions
))
const verifyFile = options.verifyFile
?? ((installer: CachedInstaller) => verifyInstallerFile(installer, {
size: installer.size,
sha256: installer.sha256
}))
const resolveRealPath = options.resolveRealPath ?? realpath
const readStat = options.readStat ?? stat
const readDiskStats = options.readDiskStats ?? statfs
async function validateEnvironment(requiredBytes = 0): Promise<InstallEnvironment> {
if (platform !== 'win32') throw new UpdateError('unsupported', '自动更新仅支持 Windows')
if (!options.isPackaged) throw new UpdateError('development-build', '开发模式禁止启动安装器')
let executablePath: string
let appPath: string
try {
executablePath = await resolveRealPath(options.execPath)
appPath = await resolveRealPath(options.appPath)
} catch {
throw new UpdateError('installer-environment', '无法解析当前安装位置')
}
if (basename(executablePath).toLowerCase() !== 'ycode.exe') {
throw new UpdateError('installer-environment', '当前进程不是 YCode 安装版')
}
const installationDirectory = dirname(executablePath)
const root = parse(installationDirectory).root
if (normalizedWindowsPath(installationDirectory) === normalizedWindowsPath(root)) {
throw new UpdateError('installer-environment', '安装目录不安全')
}
if (resolve(appPath) !== resolve(installationDirectory, 'resources', 'app.asar')) {
throw new UpdateError('installer-environment', '应用资源目录与安装位置不一致')
}
const uninstallString = await queryRegistryValue('UninstallString')
const uninstallerPath = uninstallString ? registryExecutablePath(uninstallString) : null
if (!uninstallerPath
|| normalizedWindowsPath(dirname(uninstallerPath)) !== normalizedWindowsPath(installationDirectory)
|| basename(uninstallerPath).toLowerCase() !== 'uninstall ycode.exe') {
throw new UpdateError('installer-registry', '当前用户 NSIS 卸载注册信息不匹配')
}
const displayIcon = await queryRegistryValue('DisplayIcon')
const iconPath = displayIcon ? registryExecutablePath(displayIcon.replace(/,\s*-?\d+\s*$/, '')) : null
if (!iconPath
|| normalizedWindowsPath(dirname(iconPath)) !== normalizedWindowsPath(installationDirectory)
|| normalizedWindowsPath(iconPath) !== normalizedWindowsPath(executablePath)) {
throw new UpdateError('installer-registry', '当前用户 YCode 图标注册信息不匹配')
}
for (const marker of [
uninstallerPath,
executablePath,
resolve(installationDirectory, 'resources', 'app.asar')
]) {
let info
try {
info = await readStat(marker)
} catch {
throw new UpdateError('installer-marker', '缺少 NSIS 安装标记文件')
}
if (!info.isFile()) {
throw new UpdateError('installer-marker', `NSIS 安装标记不是文件:${basename(marker)}`)
}
}
if (requiredBytes > 0) {
const required = BigInt(Math.ceil(requiredBytes)) * 3n
for (const directory of new Set([options.cacheDirectory, installationDirectory, tmpdir()])) {
let disk
try {
disk = await readDiskStats(directory)
} catch {
throw new UpdateError('installer-space-check', '无法检查更新所需磁盘空间')
}
const available = BigInt(disk.bsize) * BigInt(disk.bavail)
if (available < required) {
throw new UpdateError('installer-space-check', '更新缓存、安装目录或临时目录磁盘空间不足')
}
}
}
return {
installationDirectory,
executablePath,
registryKey: `HKCU\\${NSIS_UNINSTALL_KEY}`
}
}
async function verifyInstaller(installer: CachedInstaller): Promise<void> {
assertInstallerInsideCache(options.cacheDirectory, installer.path)
await verifyFile(installer)
}
async function launch(installer: CachedInstaller, environment: InstallEnvironment): Promise<void> {
await verifyInstaller(installer)
const args = [
'/S',
'--updated',
'--force-run',
'/currentuser',
`/YCODEWAITPID=${options.processId ?? process.pid}`,
`/D=${environment.installationDirectory}`
]
let child: SpawnedInstallerProcess
try {
child = spawnInstaller(installer.path, args, {
cwd: environment.installationDirectory,
detached: true,
windowsHide: true,
stdio: 'ignore'
})
} catch {
throw new UpdateError('installer-launch', '无法启动更新安装器')
}
await new Promise<void>((resolveSpawn, rejectSpawn) => {
child.once('spawn', resolveSpawn)
child.once('error', rejectSpawn)
}).catch(() => {
throw new UpdateError('installer-launch', '无法启动更新安装器')
})
child.unref()
}
return { validateEnvironment, verifyInstaller, launch }
}
+103
View File
@@ -0,0 +1,103 @@
import { BrowserWindow, type IpcMainInvokeEvent, type Shell } from 'electron'
import type { UpdateState } from '../../shared/updates'
import { releasePageUrl } from './manifest'
import type { UpdateManager } from './manager'
import { isTrustedUpdateRendererUrl, type TrustedRendererOptions } from './trust'
const HANDLER_CHANNELS = [
'updates:get-state',
'updates:check',
'updates:download',
'updates:install',
'updates:dismiss',
'updates:open-download-page',
'updates:open-log'
] as const
function isTrustedSender(event: IpcMainInvokeEvent, options: TrustedRendererOptions): boolean {
if (event.senderFrame !== event.sender.mainFrame || event.sender.isDestroyed()) return false
const window = BrowserWindow.fromWebContents(event.sender)
if (!window || window.isDestroyed() || window.webContents !== event.sender) return false
return isTrustedUpdateRendererUrl(event.sender.getURL(), options)
}
export interface RegisterUpdateIpcOptions extends TrustedRendererOptions {
manager: UpdateManager
ipcMain: Pick<Electron.IpcMain, 'handle' | 'removeHandler'>
shell: Pick<Shell, 'openExternal' | 'openPath'>
getAllWindows: () => BrowserWindow[]
}
function handle(
options: RegisterUpdateIpcOptions,
channel: typeof HANDLER_CHANNELS[number],
callback: (...args: unknown[]) => Promise<unknown> | unknown
): void {
options.ipcMain.handle(channel, async (event, ...args) => {
if (!isTrustedSender(event, options)) throw new Error('更新请求来源不受信任')
return callback(...args)
})
}
function safeExternalUrl(value: string): string {
const url = new URL(value)
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) {
throw new Error('更新页面地址不受信任')
}
return url.href
}
export function registerUpdateIpc(options: RegisterUpdateIpcOptions): { dispose(): void } {
for (const channel of HANDLER_CHANNELS) {
switch (channel) {
case 'updates:get-state':
handle(options, channel, () => options.manager.getState())
break
case 'updates:check':
handle(options, channel, () => options.manager.checkAndDownload(true))
break
case 'updates:download':
handle(options, channel, () => options.manager.download())
break
case 'updates:install':
handle(options, channel, () => options.manager.install())
break
case 'updates:dismiss':
handle(options, channel, () => options.manager.dismiss())
break
case 'updates:open-download-page':
handle(options, channel, async () => {
await options.shell.openExternal(safeExternalUrl(releasePageUrl(options.manager.manifestUrl)))
})
break
case 'updates:open-log':
handle(options, channel, async () => {
await options.manager.ensureLogFile()
const failure = await options.shell.openPath(options.manager.logPath)
if (failure) throw new Error(`无法打开更新日志:${failure}`)
})
break
}
}
const unsubscribe = options.manager.subscribe((state: UpdateState) => {
for (const window of options.getAllWindows()) {
try {
if (window.isDestroyed() || !isTrustedUpdateRendererUrl(window.webContents.getURL(), options)) continue
window.webContents.send('updates:state', state)
} catch {
// A window can close while the updater is broadcasting.
}
}
})
return {
dispose: () => {
unsubscribe()
for (const channel of HANDLER_CHANNELS) options.ipcMain.removeHandler(channel)
}
}
}
export { isTrustedUpdateRendererUrl }
+53
View File
@@ -0,0 +1,53 @@
import { appendFile, mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
function sanitizeUrl(value: string): string {
try {
const url = new URL(value)
url.username = ''
url.password = ''
url.search = ''
url.hash = ''
return url.toString()
} catch {
return '<invalid-url>'
}
}
export function sanitizeLogText(value: string): string {
return value
.replace(/https?:\/\/[^\s"'<>]+/gi, match => sanitizeUrl(match))
.replace(/\b(authorization|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=<redacted>')
.slice(0, 16_384)
}
export class UpdateLogger {
private queue: Promise<void> = Promise.resolve()
constructor(readonly filePath: string) {}
info(message: string): void {
this.write('INFO', message)
}
error(message: string, error?: unknown): void {
const detail = error instanceof Error ? error.message : error === undefined ? '' : String(error)
this.write('ERROR', detail ? `${message}: ${detail}` : message)
}
async ensureFile(): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true })
await appendFile(this.filePath, '', { encoding: 'utf8', mode: 0o600 })
}
private write(level: string, message: string): void {
const line = `${new Date().toISOString()} ${level} ${sanitizeLogText(message)}\n`
this.queue = this.queue
.catch(() => undefined)
.then(async () => {
await mkdir(dirname(this.filePath), { recursive: true })
await appendFile(this.filePath, line, { encoding: 'utf8', mode: 0o600 })
})
.catch(() => undefined)
}
}
+852
View File
@@ -0,0 +1,852 @@
import { open, rm, stat } from 'node:fs/promises'
import type { UpdateState } from '../../shared/updates'
import {
commitPartFile,
installerCachePaths,
prepareCacheDirectory,
removeCacheFiles,
sha256File,
verifyInstallerFile
} from './cache'
import { UpdateLogger } from './logger'
import { normalizeManifestUrl, verifySignedManifest } from './manifest'
import {
createRequestDeadline,
fetchManifestBytes,
fetchSameOrigin,
parseContentRange,
responseContentLength
} from './network'
import { compareVersions, parseVersion } from './semver'
import {
DEFAULT_CHECK_DELAY_MS,
DEFAULT_CHECK_INTERVAL_MS,
DEFAULT_DOWNLOAD_TIMEOUT_MS,
DEFAULT_MANIFEST_TIMEOUT_MS,
DEFAULT_UP_TO_DATE_RESET_MS,
StaleReleaseError,
UpdateCancelledError,
UpdateError,
type CachedInstaller,
type FetchLike,
type InstallEnvironment,
type InstallerAdapter,
type PersistedUpdateState,
type ReleaseInfo,
type StateListener,
type UpdatePersistence
} from './types'
export interface UpdateManagerOptions {
currentVersion: string
manifestUrl: string
cacheDirectory: string
logFile: string
persistence: UpdatePersistence
publicKey: () => Promise<string>
supported: boolean
installer: InstallerAdapter
beforeInstall: () => Promise<boolean>
quitForUpdate: () => Promise<void>
installFailed?: () => void | Promise<void>
fetch?: FetchLike
now?: () => Date
checkDelayMs?: number
checkIntervalMs?: number
upToDateResetMs?: number
manifestTimeoutMs?: number
downloadTimeoutMs?: number
autoDownload?: boolean
}
function emptyPersisted(): PersistedUpdateState {
return { schemaVersion: 1 }
}
function cloneState(state: UpdateState): UpdateState {
return { ...state }
}
function errorMessage(error: unknown): string {
if (error instanceof UpdateError && error.message) return error.message
if (error instanceof Error && error.message) return error.message
return '更新操作失败'
}
function identity(release: ReleaseInfo): string {
return [
release.payload.version,
release.asset.url,
release.asset.size,
release.asset.sha256
].join('\n')
}
export class UpdateManager {
private readonly logger: UpdateLogger
private readonly fetchImpl: FetchLike
private readonly now: () => Date
private persisted: PersistedUpdateState = emptyPersisted()
private state: UpdateState
private release: ReleaseInfo | null = null
private generation = 0
private initialized: Promise<void> | null = null
private checkPromise: Promise<UpdateState> | null = null
private downloadPromise: Promise<UpdateState> | null = null
private installPromise: Promise<UpdateState> | null = null
private downloadAbort: AbortController | null = null
private readonly disposedAbort = new AbortController()
private disposed = false
private started = false
private explicitRequested = false
private installCommitted = false
private checkDelayTimer: ReturnType<typeof setTimeout> | null = null
private checkIntervalTimer: ReturnType<typeof setInterval> | null = null
private upToDateTimer: ReturnType<typeof setTimeout> | null = null
private saveQueue: Promise<void> = Promise.resolve()
private readonly listeners = new Set<StateListener>()
constructor(private readonly options: UpdateManagerOptions) {
this.logger = new UpdateLogger(options.logFile)
this.fetchImpl = options.fetch ?? fetch
this.now = options.now ?? (() => new Date())
this.state = {
status: options.supported ? 'idle' : 'unsupported',
currentVersion: options.currentVersion
}
}
subscribe(listener: StateListener): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
get manifestUrl(): string {
return this.options.manifestUrl
}
get logPath(): string {
return this.options.logFile
}
async ensureLogFile(): Promise<void> {
await this.logger.ensureFile()
}
async getState(): Promise<UpdateState> {
await this.ensureInitialized()
return cloneState(this.state)
}
start(): void {
if (this.started || this.disposed || !this.options.supported) return
this.started = true
void this.ensureInitialized().catch(error => this.logger.error('初始化更新模块失败', error))
this.checkDelayTimer = setTimeout(() => {
this.checkDelayTimer = null
void this.checkAndDownload(false).catch(error => this.logger.error('后台检查更新失败', error))
}, this.options.checkDelayMs ?? DEFAULT_CHECK_DELAY_MS)
this.checkDelayTimer.unref?.()
this.checkIntervalTimer = setInterval(() => {
void this.checkAndDownload(false).catch(error => this.logger.error('后台检查更新失败', error))
}, this.options.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS)
this.checkIntervalTimer.unref?.()
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.started = false
if (this.checkDelayTimer) clearTimeout(this.checkDelayTimer)
if (this.checkIntervalTimer) clearInterval(this.checkIntervalTimer)
if (this.upToDateTimer) clearTimeout(this.upToDateTimer)
this.checkDelayTimer = null
this.checkIntervalTimer = null
this.upToDateTimer = null
this.disposedAbort.abort()
this.downloadAbort?.abort()
this.listeners.clear()
}
async check(explicit = true): Promise<UpdateState> {
await this.ensureInitialized()
if (this.disposed || this.installCommitted) return cloneState(this.state)
if (this.installPromise) return cloneState(this.state)
if (this.checkPromise) {
if (explicit) this.setExplicit(true)
return this.checkPromise
}
if (!this.downloadPromise || explicit) this.setExplicit(explicit)
if (!this.options.supported) return cloneState(this.state)
const task = this.runCheck()
this.checkPromise = task
try {
return await task
} finally {
if (this.checkPromise === task) this.checkPromise = null
}
}
async checkAndDownload(explicit = true): Promise<UpdateState> {
const checked = await this.check(explicit)
if (!this.options.supported || this.options.autoDownload === false || this.disposed) return checked
if (checked.status !== 'available' || !checked.version) return checked
if (this.persisted.dismissedVersion === checked.version) return checked
void this.beginDownload(explicit).catch(error => this.logger.error('自动下载更新失败', error))
return cloneState(this.state)
}
async download(explicit = true): Promise<UpdateState> {
await this.ensureInitialized()
if (this.disposed || this.installCommitted) return cloneState(this.state)
this.setExplicit(explicit)
if (!this.options.supported) return cloneState(this.state)
if (this.installPromise) return cloneState(this.state)
return this.beginDownload(explicit)
}
private beginDownload(explicit: boolean): Promise<UpdateState> {
if (this.installCommitted) return Promise.resolve(cloneState(this.state))
this.setExplicit(explicit)
if (this.downloadPromise) return this.downloadPromise
const task = (async (): Promise<UpdateState> => {
if (!this.release) {
const checked = await this.check(explicit)
if (!this.release || checked.status === 'unsupported' || checked.status === 'up-to-date'
|| checked.status === 'error') return checked
}
const release = this.release
if (!release) return cloneState(this.state)
return this.runDownload(release)
})()
this.downloadPromise = task
return task.finally(() => {
if (this.downloadPromise === task) this.downloadPromise = null
})
}
async install(): Promise<UpdateState> {
await this.ensureInitialized()
if (this.disposed || this.installCommitted) return cloneState(this.state)
this.setExplicit(true)
if (!this.options.supported) return cloneState(this.state)
if (this.installPromise) return this.installPromise
const task = this.runInstall()
this.installPromise = task
try {
return await task
} finally {
if (this.installPromise === task) this.installPromise = null
}
}
async dismiss(): Promise<UpdateState> {
await this.ensureInitialized()
if (this.disposed || this.installCommitted || this.installPromise || this.state.status === 'installing') {
return cloneState(this.state)
}
this.setExplicit(true)
const version = this.state.version ?? this.release?.payload.version
if (!version) return cloneState(this.state)
this.downloadAbort?.abort()
this.persisted = { ...this.persisted, dismissedVersion: version }
await this.queuePersist(this.persisted).catch(error => this.logger.error('保存忽略版本失败', error))
this.setState({
status: 'idle',
currentVersion: this.options.currentVersion,
version,
notes: this.state.notes,
lastChecked: this.state.lastChecked,
dismissedVersion: version,
unfinishedVersion: this.persisted.unfinishedVersion
})
return cloneState(this.state)
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) this.initialized = this.initialize()
return this.initialized
}
private async initialize(): Promise<void> {
let persisted = emptyPersisted()
try {
persisted = await this.options.persistence.load()
} catch (error) {
this.logger.error('读取更新状态失败,已使用默认状态', error)
}
this.persisted = persisted
if (!this.options.supported) {
this.setState({ status: 'unsupported', currentVersion: this.options.currentVersion })
return
}
try {
parseVersion(this.options.currentVersion)
} catch (error) {
this.logger.error('当前应用版本无效', error)
this.setState({
status: 'error',
currentVersion: this.options.currentVersion,
error: '当前应用版本号无效'
})
return
}
let changed = false
if (persisted.dismissedVersion) {
try {
if (compareVersions(persisted.dismissedVersion, this.options.currentVersion) <= 0) {
persisted = { ...persisted, dismissedVersion: undefined }
changed = true
}
} catch {
persisted = { ...persisted, dismissedVersion: undefined }
changed = true
}
}
if (persisted.target) {
try {
if (compareVersions(persisted.target.version, this.options.currentVersion) <= 0) {
const { target: _target, unfinishedVersion: _unfinished, ...rest } = persisted
persisted = rest
changed = true
}
} catch {
const { target: _target, unfinishedVersion: _unfinished, ...rest } = persisted
persisted = rest
changed = true
}
}
this.persisted = persisted
if (changed) await this.queuePersist(persisted).catch(error => this.logger.error('清理过期更新状态失败', error))
this.setState({
status: 'idle',
currentVersion: this.options.currentVersion,
dismissedVersion: persisted.dismissedVersion,
unfinishedVersion: persisted.unfinishedVersion
})
}
private async runCheck(): Promise<UpdateState> {
this.clearUpToDateTimer()
const preservedState = this.downloadPromise || this.state.status === 'ready'
? cloneState(this.state)
: null
if (!preservedState) {
this.setState({
status: 'checking',
currentVersion: this.options.currentVersion,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion,
lastChecked: this.state.lastChecked
})
}
try {
const publicKeyPem = await this.options.publicKey()
const bytes = await fetchManifestBytes(this.options.manifestUrl, {
fetch: this.fetchImpl,
signal: this.disposedAbort.signal,
timeout: this.options.manifestTimeoutMs ?? DEFAULT_MANIFEST_TIMEOUT_MS
})
const verified = verifySignedManifest(bytes, {
manifestUrl: this.options.manifestUrl,
publicKeyPem
})
const downloadingSameRelease = Boolean(
this.downloadPromise
&& this.release
&& identity(this.release) === identity({ ...verified, generation: this.release.generation })
)
if (downloadingSameRelease && this.release) {
this.release = { ...verified, generation: this.release.generation }
const lastChecked = this.now().toISOString()
this.setState({
...this.state,
lastChecked
})
return cloneState(this.state)
}
if (this.downloadPromise) this.downloadAbort?.abort()
this.generation += 1
this.release = { ...verified, generation: this.generation }
const lastChecked = this.now().toISOString()
const comparison = compareVersions(verified.payload.version, this.options.currentVersion)
if (comparison <= 0) {
this.setState({
status: 'up-to-date',
currentVersion: this.options.currentVersion,
lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
this.scheduleUpToDateReset()
this.logger.info(`检查完成,当前已是最新版本 ${this.options.currentVersion}`)
return cloneState(this.state)
}
const cached = this.cachedInstaller(this.release)
let ready = false
try {
await verifyInstallerFile(cached, this.release.asset)
ready = true
} catch (error) {
if (error instanceof UpdateError && error.code === 'cache-invalid') {
await removeCacheFiles(cached).catch(() => undefined)
} else if (!(error instanceof UpdateError && error.code === 'cache-missing')) {
this.logger.error('校验已有更新缓存失败', error)
}
}
if (!this.release || cached.sha256 !== this.release.asset.sha256) return cloneState(this.state)
this.setState({
status: ready ? 'ready' : 'available',
currentVersion: this.options.currentVersion,
version: this.release.payload.version,
notes: this.release.payload.notes.zh || this.release.payload.notes.en,
...(ready ? { progress: 100 } : {}),
lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
this.logger.info(`发现更新 ${this.release.payload.version}`)
return cloneState(this.state)
} catch (error) {
if (this.disposed || error instanceof UpdateCancelledError) return cloneState(this.state)
const usableState = this.state.status === 'ready' || this.state.status === 'downloading'
? this.state
: preservedState
if (usableState) {
this.logger.error('检查更新失败,保留已校验安装包或当前下载', error)
this.setState({ ...usableState, lastChecked: this.now().toISOString() })
return cloneState(this.state)
}
const message = errorMessage(error)
this.logger.error('检查更新失败', error)
this.setState({
status: 'error',
currentVersion: this.options.currentVersion,
error: message,
lastChecked: this.now().toISOString(),
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
return cloneState(this.state)
}
}
private async runDownload(release: ReleaseInfo): Promise<UpdateState> {
const cached = this.cachedInstaller(release)
this.assertCurrentRelease(release)
this.clearUpToDateTimer()
this.setState({
status: 'downloading',
currentVersion: this.options.currentVersion,
version: release.payload.version,
notes: release.payload.notes.zh || release.payload.notes.en,
progress: 0,
lastChecked: this.state.lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
const abort = new AbortController()
this.downloadAbort = abort
let handle: Awaited<ReturnType<typeof open>> | null = null
try {
await prepareCacheDirectory(this.options.cacheDirectory)
try {
await verifyInstallerFile(cached, release.asset)
this.assertCurrentRelease(release)
this.setState({ ...this.state, status: 'ready', progress: 100 })
return cloneState(this.state)
} catch (error) {
if (error instanceof UpdateError && error.code === 'cache-invalid') {
await rm(cached.path, { force: true })
} else if (!(error instanceof UpdateError && error.code === 'cache-missing')) {
throw error
}
}
let start = 0
try {
const info = await stat(cached.partPath)
if (!info.isFile()) await rm(cached.partPath, { force: true })
else start = info.size
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
if (start > release.asset.size) {
await rm(cached.partPath, { force: true })
start = 0
}
if (start === release.asset.size) {
if (await sha256File(cached.partPath) === release.asset.sha256) {
await commitPartFile(cached)
this.assertCurrentRelease(release)
this.setState({ ...this.state, status: 'ready', progress: 100 })
return cloneState(this.state)
}
await rm(cached.partPath, { force: true })
start = 0
}
if (start > 0) this.setState({ ...this.state, progress: Math.floor(start / release.asset.size * 100) })
const deadline = createRequestDeadline(
abort.signal,
this.options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS
)
try {
const { response } = await fetchSameOrigin(release.asset.url, {
method: 'GET',
headers: {
accept: 'application/octet-stream',
...(start > 0 ? { range: `bytes=${start}-` } : {})
},
signal: deadline.signal,
cache: 'no-store'
}, {
fetch: this.fetchImpl,
allowedOrigin: new URL(this.options.manifestUrl).origin
})
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('download-http', `安装包下载失败(HTTP ${response.status})`)
}
let writeStart = start
if (start > 0 && response.status === 200) writeStart = 0
if (start > 0 && response.status !== 206 && response.status !== 200) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('range-unsupported', '服务器未返回可续传的响应')
}
let expectedSegment: number
if (writeStart > 0) {
const range = parseContentRange(response.headers.get('content-range'))
if (!range || range.start !== writeStart || range.total !== release.asset.size
|| range.end !== release.asset.size - 1) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('invalid-content-range', '断点续传 Content-Range 无效')
}
expectedSegment = range.end - range.start + 1
const declared = responseContentLength(response)
if (declared !== undefined && declared !== expectedSegment) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('invalid-content-length', '断点续传长度无效')
}
} else {
if (response.status === 206) {
const range = parseContentRange(response.headers.get('content-range'))
if (!range || range.start !== 0 || range.total !== release.asset.size || range.end !== release.asset.size - 1) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('invalid-content-range', '安装包响应范围无效')
}
}
expectedSegment = release.asset.size
const declared = responseContentLength(response)
if (declared !== undefined && declared !== expectedSegment) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('invalid-content-length', '安装包响应长度无效')
}
}
if (!response.body) throw new UpdateError('empty-response', '安装包响应没有正文')
handle = await open(cached.partPath, writeStart > 0 ? 'a' : 'w', 0o600)
let written = writeStart
let lastProgress = this.state.progress ?? 0
for await (const raw of response.body as unknown as AsyncIterable<Uint8Array>) {
this.assertCurrentRelease(release)
if (abort.signal.aborted || this.disposed) throw new UpdateCancelledError()
const chunk = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
if (written + chunk.byteLength > release.asset.size) {
throw new UpdateError('download-too-large', '安装包正文超过签名大小')
}
await handle.write(chunk)
written += chunk.byteLength
const progress = Math.min(99, Math.floor(written / release.asset.size * 100))
if (progress !== lastProgress) {
lastProgress = progress
this.setState({ ...this.state, progress })
}
}
await handle.sync()
await handle.close()
handle = null
if (written !== release.asset.size) throw new UpdateError('download-incomplete', '安装包下载不完整')
if (deadline.timedOut()) throw new UpdateError('download-timeout', '安装包下载超时')
this.assertCurrentRelease(release)
const digest = await sha256File(cached.partPath)
if (digest !== release.asset.sha256) {
await rm(cached.partPath, { force: true })
throw new UpdateError('download-checksum', '安装包 SHA-256 校验失败')
}
await commitPartFile(cached)
this.assertCurrentRelease(release)
await verifyInstallerFile(cached, release.asset)
this.setState({
status: 'ready',
currentVersion: this.options.currentVersion,
version: release.payload.version,
notes: release.payload.notes.zh || release.payload.notes.en,
progress: 100,
lastChecked: this.state.lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
this.logger.info(`安装包下载并校验完成 ${release.payload.version}`)
return cloneState(this.state)
} catch (error) {
if (deadline.timedOut()) throw new UpdateError('download-timeout', '安装包下载超时')
throw error
} finally {
deadline.dispose()
}
} catch (error) {
if (handle) await handle.close().catch(() => undefined)
const releaseChanged = !this.release || this.release.generation !== release.generation
if (abort.signal.aborted || this.disposed || error instanceof StaleReleaseError || releaseChanged) {
if (releaseChanged) await rm(cached.partPath, { force: true }).catch(() => undefined)
return cloneState(this.state)
}
if (error instanceof UpdateCancelledError) {
if (this.state.status === 'downloading') {
this.setState({ ...this.state, status: 'available', progress: undefined })
}
return cloneState(this.state)
}
const detail = (error instanceof UpdateError && error.code === 'download-timeout')
? '安装包下载超时'
: errorMessage(error)
this.logger.error('下载更新失败', error)
this.setState({
status: 'error',
currentVersion: this.options.currentVersion,
version: release.payload.version,
notes: release.payload.notes.zh || release.payload.notes.en,
error: detail,
lastChecked: this.state.lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
return cloneState(this.state)
} finally {
if (this.downloadAbort === abort) this.downloadAbort = null
}
}
private async runInstall(): Promise<UpdateState> {
if (!this.release && (this.state.status === 'available' || this.state.status === 'idle')) {
await this.check()
}
if (!this.release) return cloneState(this.state)
if (this.state.status === 'available') {
const downloaded = await this.beginDownload(true)
if (downloaded.status !== 'ready') return downloaded
}
if (this.state.status !== 'ready') return cloneState(this.state)
const release = this.release
const cached = this.cachedInstaller(release)
let environment: InstallEnvironment
try {
this.assertCurrentRelease(release)
await verifyInstallerFile(cached, release.asset)
environment = await this.options.installer.validateEnvironment(release.asset.size)
} catch (error) {
const message = errorMessage(error)
this.logger.error('安装前校验失败', error)
this.setState({
...this.state,
status: 'error',
error: message
})
return cloneState(this.state)
}
let accepted: boolean
try {
accepted = await this.options.beforeInstall() === true
} catch (error) {
this.logger.error('安装确认或运行任务清理失败', error)
await this.notifyInstallFailed()
this.setState({
...this.state,
status: 'error',
error: errorMessage(error)
})
return cloneState(this.state)
}
if (!accepted) return cloneState(this.state)
try {
this.assertCurrentRelease(release)
environment = await this.options.installer.validateEnvironment(release.asset.size)
await this.options.installer.verifyInstaller(cached)
this.assertCurrentRelease(release)
} catch (error) {
this.logger.error('安装确认后复核失败', error)
await this.notifyInstallFailed()
this.setState({
...this.state,
status: 'error',
error: errorMessage(error)
})
return cloneState(this.state)
}
this.setState({
status: 'installing',
currentVersion: this.options.currentVersion,
version: release.payload.version,
notes: release.payload.notes.zh || release.payload.notes.en,
progress: 100,
lastChecked: this.state.lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: release.payload.version
})
this.persisted = {
...this.persisted,
unfinishedVersion: release.payload.version,
target: {
version: release.payload.version,
size: release.asset.size,
sha256: release.asset.sha256,
manifestUrl: normalizeManifestUrl(this.options.manifestUrl)
}
}
try {
await this.queuePersist(this.persisted)
} catch (error) {
this.logger.error('写入更新安装标记失败', error)
await this.clearUnfinished()
await this.notifyInstallFailed()
this.setState({
...this.state,
status: 'error',
error: '无法写入更新安装标记'
})
return cloneState(this.state)
}
try {
await this.options.installer.launch(cached, environment)
} catch (error) {
this.logger.error('启动更新安装器失败', error)
await this.clearUnfinished()
await this.notifyInstallFailed()
this.setState({
...this.state,
status: 'error',
error: errorMessage(error)
})
return cloneState(this.state)
}
this.installCommitted = true
try {
await this.options.quitForUpdate()
} catch (error) {
this.logger.error('退出应用以安装更新失败', error)
this.setState({
...this.state,
status: 'installing',
error: '更新安装器已启动,必须退出 YCode 才能继续安装'
})
return cloneState(this.state)
}
return cloneState(this.state)
}
private cachedInstaller(release: ReleaseInfo): CachedInstaller {
const paths = installerCachePaths(this.options.cacheDirectory, release.payload.version)
return {
...paths,
size: release.asset.size,
sha256: release.asset.sha256
}
}
private assertCurrentRelease(release: ReleaseInfo): void {
if (!this.release || this.release.generation !== release.generation || identity(this.release) !== identity(release)) {
throw new StaleReleaseError()
}
}
private setState(state: UpdateState): void {
if (this.disposed) return
this.state = cloneState({ ...state, lastExplicit: this.explicitRequested })
for (const listener of this.listeners) {
try {
listener(cloneState(this.state))
} catch {
// A renderer subscriber must not break updater state transitions.
}
}
}
private setExplicit(explicit: boolean): void {
if (this.explicitRequested === explicit) return
this.explicitRequested = explicit
this.setState({ ...this.state, lastExplicit: explicit })
}
private scheduleUpToDateReset(): void {
this.clearUpToDateTimer()
this.upToDateTimer = setTimeout(() => {
this.upToDateTimer = null
if (this.state.status === 'up-to-date' && !this.downloadPromise && !this.installPromise) {
this.setState({
status: 'idle',
currentVersion: this.options.currentVersion,
lastChecked: this.state.lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
}
}, this.options.upToDateResetMs ?? DEFAULT_UP_TO_DATE_RESET_MS)
this.upToDateTimer.unref?.()
}
private clearUpToDateTimer(): void {
if (this.upToDateTimer) clearTimeout(this.upToDateTimer)
this.upToDateTimer = null
}
private queuePersist(state: PersistedUpdateState): Promise<void> {
const snapshot: PersistedUpdateState = {
schemaVersion: 1,
...(state.dismissedVersion ? { dismissedVersion: state.dismissedVersion } : {}),
...(state.unfinishedVersion ? { unfinishedVersion: state.unfinishedVersion } : {}),
...(state.target ? { target: { ...state.target } } : {})
}
const task = this.saveQueue
.catch(() => undefined)
.then(() => this.options.persistence.save(snapshot))
this.saveQueue = task
return task
}
private async clearUnfinished(): Promise<void> {
const { target: _target, unfinishedVersion: _unfinished, ...rest } = this.persisted
this.persisted = rest
this.setState({ ...this.state, unfinishedVersion: undefined })
await this.queuePersist(this.persisted).catch(error => this.logger.error('清除更新安装标记失败', error))
}
private async notifyInstallFailed(): Promise<void> {
if (!this.options.installFailed) return
try {
await this.options.installFailed()
} catch (error) {
this.logger.error('恢复更新失败后的后台服务失败', error)
}
}
}
+194
View File
@@ -0,0 +1,194 @@
import { createHash, createPublicKey, verify } from 'node:crypto'
import { posix } from 'node:path'
import { parseVersion } from './semver'
import {
MAX_INSTALLER_BYTES,
MAX_MANIFEST_BYTES,
UPDATE_PLATFORM,
UpdateError,
type ReleaseInfo,
type UpdateManifestPayload,
type UpdatePlatformAsset
} from './types'
const SHA256_RE = /^[a-f0-9]{64}$/
const SIGNATURE_RE = /^[A-Za-z0-9+/]{86}==$/
const EXE_NAME_RE = /^[A-Za-z0-9._-]+\.exe$/
function exactKeys(value: unknown, expected: string[], label: string): asserts value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new UpdateError('invalid-manifest', `${label} 必须是对象`)
}
const actual = Object.keys(value)
if (actual.length !== expected.length || !expected.every(key => actual.includes(key))) {
throw new UpdateError('invalid-manifest', `${label} 包含缺失或未知字段`)
}
}
export function normalizeManifestUrl(value: string): string {
let url: URL
try {
url = new URL(value)
} catch {
throw new UpdateError('invalid-manifest-url', '更新清单地址无效')
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new UpdateError('invalid-manifest-url', '更新清单只支持 HTTP 或 HTTPS')
}
if (url.username || url.password || url.search || url.hash) {
throw new UpdateError('invalid-manifest-url', '更新清单地址不能包含凭据、查询参数或片段')
}
if (!url.pathname.endsWith('/latest.json')) {
throw new UpdateError('invalid-manifest-url', '更新清单地址必须以 latest.json 结尾')
}
return url.href
}
export function releasePageUrl(manifestUrl: string): string {
const normalized = normalizeManifestUrl(manifestUrl)
return new URL('./', normalized).href
}
function assertHttpArtifactUrl(value: unknown): string {
if (typeof value !== 'string' || value.length === 0 || value.length > 4096) {
throw new UpdateError('invalid-manifest', '安装包 URL 无效')
}
let url: URL
try {
url = new URL(value)
} catch {
throw new UpdateError('invalid-manifest', '安装包 URL 无效')
}
if ((url.protocol !== 'http:' && url.protocol !== 'https:')
|| url.username || url.password || url.search || url.hash) {
throw new UpdateError('invalid-manifest', '安装包 URL 不安全')
}
return url.href
}
function parsePublicKey(publicKeyPem: string) {
if (typeof publicKeyPem !== 'string' || publicKeyPem.length === 0 || publicKeyPem.length > 64 * 1024
|| publicKeyPem.includes('PRIVATE KEY')) {
throw new UpdateError('invalid-public-key', '更新公钥无效')
}
let key
try {
key = createPublicKey(publicKeyPem)
} catch {
throw new UpdateError('invalid-public-key', '无法解析更新公钥')
}
if (key.asymmetricKeyType !== 'ed25519') {
throw new UpdateError('invalid-public-key', '更新公钥必须是 Ed25519')
}
return key
}
function validatePayload(
payload: Record<string, unknown>,
manifestUrl: string
): { payload: UpdateManifestPayload; asset: UpdatePlatformAsset } {
exactKeys(payload, ['schemaVersion', 'version', 'publishedAt', 'notes', 'platforms'], '更新清单 payload')
if (payload.schemaVersion !== 1) throw new UpdateError('invalid-manifest', '更新清单 schemaVersion 不受支持')
if (typeof payload.version !== 'string') throw new UpdateError('invalid-manifest', '更新清单版本字段无效')
parseVersion(payload.version)
if (typeof payload.publishedAt !== 'string') throw new UpdateError('invalid-manifest', 'publishedAt 必须是字符串')
const published = new Date(payload.publishedAt)
if (Number.isNaN(published.valueOf()) || published.toISOString() !== payload.publishedAt) {
throw new UpdateError('invalid-manifest', 'publishedAt 必须是规范 UTC ISO 时间')
}
exactKeys(payload.notes, ['zh', 'en'], '更新说明')
const notes = payload.notes as Record<string, unknown>
for (const language of ['zh', 'en']) {
const text = notes[language]
if (typeof text !== 'string' || text.trim().length === 0 || text.length > 16_384) {
throw new UpdateError('invalid-manifest', `更新说明 ${language} 无效`)
}
}
exactKeys(payload.platforms, [UPDATE_PLATFORM], '更新平台列表')
const platforms = payload.platforms as Record<string, unknown>
const rawAsset = platforms[UPDATE_PLATFORM]
exactKeys(rawAsset, ['url', 'size', 'sha256'], 'windows-x64 安装包')
const asset = rawAsset as Record<string, unknown>
if (!Number.isSafeInteger(asset.size) || (asset.size as number) <= 0 || (asset.size as number) > MAX_INSTALLER_BYTES) {
throw new UpdateError('invalid-manifest', '安装包大小无效')
}
if (typeof asset.sha256 !== 'string' || !SHA256_RE.test(asset.sha256)) {
throw new UpdateError('invalid-manifest', '安装包 SHA-256 无效')
}
const artifactUrl = assertHttpArtifactUrl(asset.url)
const parsedUrl = new URL(artifactUrl)
const filename = posix.basename(parsedUrl.pathname)
if (!EXE_NAME_RE.test(filename)) throw new UpdateError('invalid-manifest', '安装包文件名不受支持')
const baseUrl = new URL('./', normalizeManifestUrl(manifestUrl))
const expected = new URL(`versions/${payload.version}/${filename}`, baseUrl).href
if (artifactUrl !== expected) {
throw new UpdateError('invalid-manifest', '安装包 URL 不在受控版本目录中')
}
return {
payload: payload as unknown as UpdateManifestPayload,
asset: {
url: artifactUrl,
size: asset.size as number,
sha256: asset.sha256
}
}
}
export function verifySignedManifest(
raw: Uint8Array,
options: { manifestUrl: string; publicKeyPem: string }
): ReleaseInfo {
if (raw.byteLength === 0 || raw.byteLength > MAX_MANIFEST_BYTES) {
throw new UpdateError('invalid-manifest', '更新清单大小无效')
}
const manifestUrl = normalizeManifestUrl(options.manifestUrl)
let text: string
let document: unknown
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(raw)
document = JSON.parse(text)
} catch {
throw new UpdateError('invalid-manifest', '更新清单不是有效 UTF-8 JSON')
}
exactKeys(document, ['payload', 'signature'], '更新清单')
const root = document as { payload: unknown; signature: unknown }
if (!root.payload || typeof root.payload !== 'object' || Array.isArray(root.payload)) {
throw new UpdateError('invalid-manifest', '更新清单 payload 必须是对象')
}
if (typeof root.signature !== 'string' || !SIGNATURE_RE.test(root.signature)) {
throw new UpdateError('invalid-manifest', '更新清单签名格式无效')
}
const signature = Buffer.from(root.signature, 'base64')
if (signature.length !== 64 || signature.toString('base64') !== root.signature) {
throw new UpdateError('invalid-manifest', '更新清单签名必须使用规范 Base64')
}
const publicKey = parsePublicKey(options.publicKeyPem)
const canonicalPayload = Buffer.from(JSON.stringify(root.payload), 'utf8')
let valid = false
try {
valid = verify(null, canonicalPayload, publicKey, signature)
} catch {
valid = false
}
if (!valid) throw new UpdateError('invalid-signature', '更新清单签名验证失败')
const validated = validatePayload(root.payload as Record<string, unknown>, manifestUrl)
const fingerprint = createHash('sha256')
.update(publicKey.export({ type: 'spki', format: 'der' }))
.digest('hex')
return {
manifestUrl,
payload: validated.payload,
asset: validated.asset,
signatureFingerprint: fingerprint,
generation: 0
}
}
+164
View File
@@ -0,0 +1,164 @@
import {
MAX_MANIFEST_BYTES,
MAX_REDIRECTS,
UpdateCancelledError,
UpdateError,
type FetchLike
} from './types'
export interface RequestDeadline {
signal: AbortSignal
timedOut(): boolean
dispose(): void
}
export function createRequestDeadline(parent: AbortSignal | undefined, timeoutMs: number): RequestDeadline {
const controller = new AbortController()
let timedOut = false
const abortFromParent = (): void => controller.abort(parent?.reason)
if (parent?.aborted) controller.abort(parent.reason)
else parent?.addEventListener('abort', abortFromParent, { once: true })
const timer = setTimeout(() => {
timedOut = true
controller.abort(new Error('request timeout'))
}, timeoutMs)
timer.unref?.()
return {
signal: controller.signal,
timedOut: () => timedOut,
dispose: () => {
clearTimeout(timer)
parent?.removeEventListener('abort', abortFromParent)
}
}
}
function checkedUrl(value: string | URL, label: string): URL {
let url: URL
try {
url = new URL(value)
} catch {
throw new UpdateError('invalid-url', `${label}无效`)
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new UpdateError('invalid-url', `${label}只支持 HTTP 或 HTTPS`)
}
if (url.username || url.password) throw new UpdateError('invalid-url', `${label}不能包含凭据`)
url.hash = ''
return url
}
export async function fetchSameOrigin(
input: string | URL,
init: RequestInit,
options: { fetch: FetchLike; allowedOrigin?: string }
): Promise<{ response: Response; url: URL }> {
let current = checkedUrl(input, '请求地址')
const allowedOrigin = options.allowedOrigin ?? current.origin
if (current.origin !== allowedOrigin) throw new UpdateError('cross-origin', '请求地址不在更新服务器源内')
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
const response = await options.fetch(current.href, {
...init,
redirect: 'manual'
})
if (response.url) {
const effective = checkedUrl(response.url, '响应地址')
if (effective.origin !== allowedOrigin) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('cross-origin', '更新服务器返回了跨源响应')
}
}
if (![301, 302, 303, 307, 308].includes(response.status)) return { response, url: current }
if (redirectCount === MAX_REDIRECTS) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('too-many-redirects', '更新请求重定向次数过多')
}
const location = response.headers.get('location')
if (!location) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('invalid-redirect', '更新请求缺少重定向地址')
}
await response.body?.cancel().catch(() => undefined)
const next = checkedUrl(new URL(location, current).href, '重定向地址')
if (next.origin !== allowedOrigin) throw new UpdateError('cross-origin', '禁止跨源重定向')
current = next
}
throw new UpdateError('too-many-redirects', '更新请求重定向次数过多')
}
function contentLength(response: Response): number | undefined {
const value = response.headers.get('content-length')
if (value === null) return undefined
if (!/^\d+$/.test(value)) throw new UpdateError('invalid-response', '响应 Content-Length 无效')
const parsed = Number(value)
if (!Number.isSafeInteger(parsed)) throw new UpdateError('invalid-response', '响应 Content-Length 无效')
return parsed
}
export async function readBoundedResponse(response: Response, maximum: number): Promise<Uint8Array> {
const declared = contentLength(response)
if (declared !== undefined && declared > maximum) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('response-too-large', '更新响应超过大小限制')
}
if (!response.body) throw new UpdateError('empty-response', '更新响应没有正文')
const chunks: Uint8Array[] = []
let total = 0
for await (const raw of response.body as unknown as AsyncIterable<Uint8Array>) {
const chunk = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
total += chunk.byteLength
if (total > maximum) throw new UpdateError('response-too-large', '更新响应超过大小限制')
chunks.push(chunk)
}
const result = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.byteLength
}
return result
}
export async function fetchManifestBytes(
manifestUrl: string,
options: { fetch: FetchLike; signal: AbortSignal; timeout: number }
): Promise<Uint8Array> {
const deadline = createRequestDeadline(options.signal, options.timeout)
try {
const { response } = await fetchSameOrigin(manifestUrl, {
method: 'GET',
headers: { accept: 'application/json' },
signal: deadline.signal,
cache: 'no-store'
}, { fetch: options.fetch })
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('manifest-http', `更新清单请求失败(HTTP ${response.status})`)
}
return await readBoundedResponse(response, MAX_MANIFEST_BYTES)
} catch (error) {
if (options.signal.aborted) throw new UpdateCancelledError()
if (deadline.timedOut()) throw new UpdateError('manifest-timeout', '检查更新超时')
throw error
} finally {
deadline.dispose()
}
}
export function parseContentRange(value: string | null): { start: number; end: number; total: number } | null {
if (!value) return null
const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(value)
if (!match) return null
const start = Number(match[1])
const end = Number(match[2])
const total = Number(match[3])
if (![start, end, total].every(Number.isSafeInteger) || start > end || end >= total) return null
return { start, end, total }
}
export function responseContentLength(response: Response): number | undefined {
return contentLength(response)
}
export { checkedUrl }
+14
View File
@@ -0,0 +1,14 @@
import { createRequire } from 'node:module'
import type * as NodeFs from 'node:fs'
// Electron's patched fs exposes app.asar as a virtual directory. Installer
// identity checks must observe the physical archive instead.
const originalFs = createRequire(process.execPath)('node:original-fs') as typeof NodeFs
export async function physicalRealPath(value: string): Promise<string> {
return originalFs.promises.realpath(value)
}
export async function physicalStat(value: string): Promise<NodeFs.Stats> {
return originalFs.promises.stat(value)
}
+64
View File
@@ -0,0 +1,64 @@
import { UpdateError } from './types'
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/
export interface ParsedVersion {
major: bigint
minor: bigint
patch: bigint
prerelease: string[]
build: string[]
}
export function parseVersion(value: string): ParsedVersion {
if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
throw new UpdateError('invalid-version', '版本号格式无效')
}
const match = SEMVER_RE.exec(value)
if (!match) throw new UpdateError('invalid-version', '版本号不是严格的 SemVer')
const prerelease = match[4] ? match[4].split('.') : []
for (const identifier of prerelease) {
if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0')) {
throw new UpdateError('invalid-version', 'SemVer 预发布数字不能包含前导零')
}
}
return {
major: BigInt(match[1]),
minor: BigInt(match[2]),
patch: BigInt(match[3]),
prerelease,
build: match[5] ? match[5].split('.') : []
}
}
function compareIdentifier(left: string, right: string): number {
const leftNumeric = /^\d+$/.test(left)
const rightNumeric = /^\d+$/.test(right)
if (leftNumeric && rightNumeric) {
const a = BigInt(left)
const b = BigInt(right)
return a < b ? -1 : a > b ? 1 : 0
}
if (leftNumeric) return -1
if (rightNumeric) return 1
return left < right ? -1 : left > right ? 1 : 0
}
export function compareVersions(left: string, right: string): number {
const a = parseVersion(left)
const b = parseVersion(right)
for (const key of ['major', 'minor', 'patch'] as const) {
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
}
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
if (a.prerelease.length === 0) return 1
if (b.prerelease.length === 0) return -1
const length = Math.max(a.prerelease.length, b.prerelease.length)
for (let index = 0; index < length; index += 1) {
if (a.prerelease[index] === undefined) return -1
if (b.prerelease[index] === undefined) return 1
const compared = compareIdentifier(a.prerelease[index], b.prerelease[index])
if (compared !== 0) return compared
}
return 0
}
+84
View File
@@ -0,0 +1,84 @@
import { randomUUID } from 'node:crypto'
import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { PersistedUpdateState, UpdatePersistence } from './types'
const MAX_STATE_BYTES = 64 * 1024
function emptyState(): PersistedUpdateState {
return { schemaVersion: 1 }
}
function validOptionalVersion(value: unknown): value is string | undefined {
return value === undefined || typeof value === 'string'
}
function validateState(value: unknown): PersistedUpdateState {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('更新状态文件格式无效')
const candidate = value as Partial<PersistedUpdateState>
if (candidate.schemaVersion !== 1) throw new Error('更新状态文件版本不受支持')
if (!validOptionalVersion(candidate.dismissedVersion) || !validOptionalVersion(candidate.unfinishedVersion)) {
throw new Error('更新状态文件版本字段无效')
}
const target = candidate.target
if (target !== undefined) {
if (!target || typeof target !== 'object' || Array.isArray(target)
|| typeof target.version !== 'string'
|| !Number.isSafeInteger(target.size) || target.size <= 0
|| typeof target.sha256 !== 'string'
|| typeof target.manifestUrl !== 'string') {
throw new Error('更新目标记录无效')
}
}
return {
schemaVersion: 1,
...(candidate.dismissedVersion ? { dismissedVersion: candidate.dismissedVersion } : {}),
...(candidate.unfinishedVersion ? { unfinishedVersion: candidate.unfinishedVersion } : {}),
...(target ? {
target: {
version: target.version,
size: target.size,
sha256: target.sha256,
manifestUrl: target.manifestUrl
}
} : {})
}
}
export class FileUpdatePersistence implements UpdatePersistence {
constructor(readonly filePath: string) {}
async load(): Promise<PersistedUpdateState> {
let size: number
try {
const info = await stat(this.filePath)
if (!info.isFile()) throw new Error('更新状态路径不是文件')
size = info.size
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyState()
throw error
}
if (size > MAX_STATE_BYTES) throw new Error('更新状态文件过大')
return validateState(JSON.parse(await readFile(this.filePath, 'utf8')))
}
async save(state: PersistedUpdateState): Promise<void> {
const validated = validateState(state)
const directory = dirname(this.filePath)
const temporary = join(directory, `.${randomUUID()}.tmp`)
await mkdir(directory, { recursive: true })
const handle = await open(temporary, 'wx', 0o600)
try {
await handle.writeFile(`${JSON.stringify(validated, null, 2)}\n`, 'utf8')
await handle.sync()
} finally {
await handle.close()
}
try {
await rename(temporary, this.filePath)
} catch (error) {
await rm(temporary, { force: true })
throw error
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
export interface TrustedRendererOptions {
appPath: string
rendererUrl?: string
platform?: NodeJS.Platform
}
function comparablePath(value: string, platform: NodeJS.Platform): string {
const resolved = resolve(value)
return platform === 'win32' ? resolved.toLowerCase() : resolved
}
export function isTrustedUpdateRendererUrl(raw: string, options: TrustedRendererOptions): boolean {
let url: URL
try {
url = new URL(raw)
} catch {
return false
}
if (url.username || url.password) return false
if (url.searchParams.get('window') === 'task') return false
if (url.protocol === 'file:') {
let pathname: string
try {
pathname = fileURLToPath(url)
} catch {
return false
}
const platform = options.platform ?? process.platform
const candidate = comparablePath(pathname, platform)
return [
resolve(options.appPath, 'out', 'renderer', 'index.html'),
resolve(options.appPath, 'out', 'renderer', 'settings.html')
].some(allowed => comparablePath(allowed, platform) === candidate)
}
if ((url.protocol === 'http:' || url.protocol === 'https:') && options.rendererUrl) {
let renderer: URL
try {
renderer = new URL(options.rendererUrl)
} catch {
return false
}
if (renderer.username || renderer.password || renderer.origin !== url.origin) return false
return url.pathname === '/'
|| url.pathname === '/index.html'
|| url.pathname === '/settings.html'
}
return false
}
+110
View File
@@ -0,0 +1,110 @@
import type { UpdateState } from '../../shared/updates'
export const UPDATE_MANIFEST_URL = 'http://192.168.10.6/ycode/releases/latest.json'
export const UPDATE_PLATFORM = 'windows-x64' as const
export const MAX_MANIFEST_BYTES = 1024 * 1024
export const MAX_INSTALLER_BYTES = 4 * 1024 * 1024 * 1024
export const MAX_REDIRECTS = 5
export const DEFAULT_CHECK_DELAY_MS = 10_000
export const DEFAULT_CHECK_INTERVAL_MS = 5 * 60_000
export const DEFAULT_UP_TO_DATE_RESET_MS = 3_000
export const DEFAULT_MANIFEST_TIMEOUT_MS = 20_000
export const DEFAULT_DOWNLOAD_TIMEOUT_MS = 10 * 60_000
export interface UpdateNotes {
zh: string
en: string
}
export interface UpdatePlatformAsset {
url: string
size: number
sha256: string
}
export interface UpdateManifestPayload {
schemaVersion: 1
version: string
publishedAt: string
notes: UpdateNotes
platforms: {
'windows-x64': UpdatePlatformAsset
}
}
export interface SignedUpdateManifest {
payload: UpdateManifestPayload
signature: string
}
export interface ReleaseInfo {
manifestUrl: string
payload: UpdateManifestPayload
asset: UpdatePlatformAsset
signatureFingerprint: string
generation: number
}
export interface CachedInstaller {
version: string
path: string
partPath: string
size: number
sha256: string
}
export interface InstallEnvironment {
installationDirectory: string
executablePath: string
registryKey: string
}
export interface InstallerAdapter {
validateEnvironment(requiredBytes?: number): Promise<InstallEnvironment>
verifyInstaller(installer: CachedInstaller): Promise<void>
launch(installer: CachedInstaller, environment: InstallEnvironment): Promise<void>
}
export interface PersistedUpdateState {
schemaVersion: 1
dismissedVersion?: string
unfinishedVersion?: string
target?: {
version: string
size: number
sha256: string
manifestUrl: string
}
}
export interface UpdatePersistence {
load(): Promise<PersistedUpdateState>
save(state: PersistedUpdateState): Promise<void>
}
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>
export type StateListener = (state: UpdateState) => void
export class UpdateError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.name = 'UpdateError'
this.code = code
}
}
export class StaleReleaseError extends UpdateError {
constructor() {
super('stale-release', '更新版本已变化')
this.name = 'StaleReleaseError'
}
}
export class UpdateCancelledError extends UpdateError {
constructor() {
super('cancelled', '更新操作已取消')
this.name = 'UpdateCancelledError'
}
}
+43
View File
@@ -10,6 +10,20 @@ import type { DownloadPayload, ResourceRange } from '@shared/resources'
import type { InteractionResponse } from '@shared/conversation'
const api: YCodeApi = {
updates: {
getState: () => ipcRenderer.invoke('updates:get-state'),
check: () => ipcRenderer.invoke('updates:check'),
download: () => ipcRenderer.invoke('updates:download'),
install: () => ipcRenderer.invoke('updates:install'),
dismiss: () => ipcRenderer.invoke('updates:dismiss'),
openDownloadPage: () => ipcRenderer.invoke('updates:open-download-page'),
openLog: () => ipcRenderer.invoke('updates:open-log'),
onState: (listener) => {
const handler = (_event: unknown, state: Parameters<typeof listener>[0]): void => listener(state)
ipcRenderer.on('updates:state', handler)
return () => ipcRenderer.removeListener('updates:state', handler)
}
},
projects: {
list: () => ipcRenderer.invoke('projects:list'),
create: (input) => ipcRenderer.invoke('projects:create', input),
@@ -23,6 +37,20 @@ const api: YCodeApi = {
openPath: id => ipcRenderer.invoke('experts:open-path', id)
},
skills: {
recommendations: () => ipcRenderer.invoke('skills:recommendations'),
installRecommended: (id, options) => ipcRenderer.invoke('skills:install-recommended', id, options),
cancelInstall: (id) => ipcRenderer.invoke('skills:cancel-install', id),
installState: () => ipcRenderer.invoke('skills:install-state'),
onInstallProgress: (listener) => {
const handler = (_event: unknown, progress: Parameters<typeof listener>[0]): void => listener(progress)
ipcRenderer.on('skills:install-progress', handler)
return () => ipcRenderer.removeListener('skills:install-progress', handler)
},
onChanged: (listener) => {
const handler = (): void => listener()
ipcRenderer.on('skills:changed', handler)
return () => ipcRenderer.removeListener('skills:changed', handler)
},
list: (cwd) => ipcRenderer.invoke('skills:list', cwd),
importPath: (path, overwrite, kind) => ipcRenderer.invoke('skills:import', path, overwrite, kind),
read: (id, cwd) => ipcRenderer.invoke('skills:read', id, cwd),
@@ -216,6 +244,21 @@ const api: YCodeApi = {
openWindow: (sessionId: string) => ipcRenderer.invoke('session:open-window', sessionId)
},
chat: {
queue: {
get: id => ipcRenderer.invoke('chat:queue:get', id),
enqueue: payload => ipcRenderer.invoke('chat:queue:enqueue', payload),
take: (sessionId, id) => ipcRenderer.invoke('chat:queue:take', sessionId, id),
restoreEdit: (sessionId, id) => ipcRenderer.invoke('chat:queue:restore-edit', sessionId, id),
remove: (sessionId, id) => ipcRenderer.invoke('chat:queue:remove', sessionId, id),
reorder: (sessionId, ids) => ipcRenderer.invoke('chat:queue:reorder', sessionId, ids),
resume: id => ipcRenderer.invoke('chat:queue:resume', id),
sendNow: (sessionId, id) => ipcRenderer.invoke('chat:queue:send-now', sessionId, id),
onChanged: listener => {
const handler = (_event: Electron.IpcRendererEvent, snapshot: import('../shared/prompt-queue').PromptQueueSnapshot): void => listener(snapshot)
ipcRenderer.on('chat:queue:changed', handler)
return () => ipcRenderer.removeListener('chat:queue:changed', handler)
}
},
activeRun: (sessionId) => ipcRenderer.invoke('chat:active-run', sessionId),
setSessionVisible: (sessionId) => ipcRenderer.invoke('chat:set-session-visible', sessionId),
onStarted: (listener) => {
+6 -12
View File
@@ -64,14 +64,14 @@ export default function App(): React.JSX.Element {
const defaultWorkspace = useSettingsStore(state => state.workspace)
const capabilityCwd = sessions.find(session => session.id === currentSessionId)?.cwd ?? draftContext.cwd ?? defaultWorkspace
const useSkill = (skill: SkillRecord): void => {
setRoute('chat')
window.dispatchEvent(new CustomEvent('ycode:select-skill', { detail: { skill, owner: currentSessionId ?? `new-${draftId}` } }))
if (!skill.enabled || skill.error || !skill.userInvocable) return
creationIntent('', skill)
}
const creationIntent = (text: string, skill?: Pick<SkillRecord, 'id' | 'name'>): void => {
const state = useChatStore.getState()
const settings = useSettingsStore.getState()
const current = state.sessions.find(session => session.id === state.currentSessionId)
state.startTask(current ? { workspaceId: current.workspaceId, cwd: current.cwd, modelId: current.modelId ?? settings.selectedModelId ?? undefined } : { ...state.draftContext, expertId: null, ...(settings.selectedModelId ? { modelId: settings.selectedModelId } : {}) })
state.startTask(current ? { workspaceId: current.workspaceId, cwd: current.cwd, modelId: current.modelId ?? settings.selectedModelId ?? undefined, expertId: null } : { ...state.draftContext, expertId: null, modelId: state.draftContext.modelId ?? settings.selectedModelId ?? undefined })
setRoute('chat')
// The chat pane remains mounted. Publish after its new draft owner commits.
const owner = `new-${useChatStore.getState().draftId}`
@@ -330,13 +330,12 @@ export default function App(): React.JSX.Element {
// 全局快捷键(TODO 12):与「设置 → 快捷键」中已接入的行保持一致。
// Ctrl+, 设置;Ctrl+K 聚焦全局搜索;Ctrl+B / Ctrl+Shift+B 切换左/右侧栏;
// Ctrl+N 新建对话;生成中按 Esc 停止。其余键位只在该页面展示,未接入时不宣称生效。
// Escape belongs to menus, previews and dialogs, never generation cancellation.
const shortcutActions = useRef({
openSettings: (): void => {},
focusSearch: (): void => {},
toggleSidebar: (): void => {},
toggleRightSidebar: (): void => {},
stopRun: (): void => {}
})
shortcutActions.current = {
openSettings,
@@ -349,18 +348,13 @@ export default function App(): React.JSX.Element {
if (narrow.isNarrowForSidebar) setLeftDrawerOpen((value) => !value)
else setSidebarCollapsed((value) => !value)
},
toggleRightSidebar: () => setRightVisible((value) => !value),
stopRun: () => { if (useChatStore.getState().running) void useChatStore.getState().cancel() }
toggleRightSidebar: () => setRightVisible((value) => !value)
}
useEffect(() => {
const onKey = (event: KeyboardEvent): void => {
if (event.isComposing) return
if (document.querySelector('.yc-skill-modal-host')) return
const actions = shortcutActions.current
if (event.key === 'Escape' && !event.defaultPrevented) {
// 与 WorkBuddy 一致:Esc 只在生成中停止本次运行,不吞掉其他弹层的 Esc。
if (useChatStore.getState().running) { event.preventDefault(); actions.stopRun() }
return
}
if ((!event.ctrlKey && !event.metaKey) || event.altKey) return
const key = event.key.toLowerCase()
if (key === ',') { event.preventDefault(); actions.openSettings(); return }
@@ -66,7 +66,7 @@ export default function CapabilityCenter(props: CapabilityCenterProps): React.JS
</header>
<div className="capability-page-host" role="tabpanel" aria-label={{ experts: '专家', skills: '技能', connectors: '连接器' }[tab]}>
{tab === 'experts' && <ExpertsPage />}
{tab === 'skills' && <SkillsPage />}
{tab === 'skills' && <SkillsPage cwd={props.cwd} onUseSkill={props.onUseSkill} onCreateSkill={props.onCreateSkill} onFindSkill={props.onFindSkill} />}
{tab === 'connectors' && <ConnectorsPage />}
</div>
</div></div>
@@ -1,5 +1,101 @@
import CapabilityMaintenance from './CapabilityMaintenance'
import { useCallback, useEffect, useRef, useState, type ComponentProps, type ComponentType } from 'react'
import { createPortal } from 'react-dom'
import type { McpServerView } from '@shared/capabilities'
import { CapabilityToolbar } from './CapabilityCenter'
import { SearchIcon, ClearFilledIcon } from './SkillSourceIcons'
import { ChevronIcon, CloseIcon, ConnectorAddIcon, ConnectorEmptyIcon, RefreshIcon, CopyIcon, ConnectorEditIcon, McpAddIcon, McpHubIcon, McpCloseIcon, McpSearchIcon, McpClearIcon, SOURCE_BUDDY_ICON } from './ConnectorSourceIcons'
import { MonacoEditor, configureMonaco, themeManager } from '../workbuddy/workbench/source-code-assets/wrapper.mjs'
import { mcpEditorDocument, parseMcpEditor, mcpIconColor } from './connectors-helpers'
import '../workbuddy/workbench/sourceCode.css'
import './connectors-source.css'
import './connectors-adapters.css'
function Search({ value, onChange, placeholder, mcp = false }: { value: string; onChange(value: string): void; placeholder: string; mcp?: boolean }): React.JSX.Element {
if (mcp) return <div className="cb-search-input cb-search-input--small"><div className="cb-search-input__icon"><McpSearchIcon /></div><input className="cb-search-input__input" value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} aria-label={placeholder} /><button className={'cb-search-input__clear' + (value ? ' cb-search-input__clear--visible' : '')} aria-label="清除搜索" onClick={() => onChange('')}><McpClearIcon /></button></div>
return <span className="wb-input-wrapper wb-input-wrapper--medium yc-mcp-search"><span className="wb-input__prefix"><SearchIcon /></span><input className="wb-input__inner" value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} aria-label={placeholder} />{value && <button className="yc-mcp-search-clear" aria-label="清除搜索" onClick={() => onChange('')}><ClearFilledIcon /></button>}</span>
}
function SourceButton({ children, icon, onClick, disabled = false, primary = false }: { children: React.ReactNode; icon?: React.ReactNode; onClick(): void; disabled?: boolean; primary?: boolean }): React.JSX.Element {
return <button type="button" className={`wb-button wb-button--${primary ? 'primary' : 'secondary'} wb-button--medium`} onClick={onClick} disabled={disabled}>{icon && <span className="wb-button__icon wb-button__icon--left">{icon}</span>}<span className="wb-button__content">{children}</span></button>
}
function JsonEditor({ content, setContent }: { content: string; setContent(value: string): void }): React.JSX.Element {
const [ready, setReady] = useState(false), [failed, setFailed] = useState(false)
useEffect(() => { let active = true; void configureMonaco().then(() => { if (active) { themeManager().setTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'); setReady(true) } }).catch(() => { if (active) setFailed(true) }); return () => { active = false } }, [])
// The recovered source wrapper already provides onChange; the preview-only declaration is narrower.
const EditableMonaco = MonacoEditor as ComponentType<ComponentProps<typeof MonacoEditor> & { onChange(value: string | undefined): void }>
return failed ? <textarea className="mcp-config-textarea" aria-label="MCP JSON 配置" spellCheck={false} value={content} onChange={event => setContent(event.target.value)} /> : !ready ? <div className="mcp-config-editor-loading">加载编辑器…</div> : <EditableMonaco value={content} onChange={value => setContent(value || '')} language="json" readOnly={false} minimap={false} lineNumbers="on" wordWrap="on" fontSize={13} height="100%" onMount={editor => {
editor.updateOptions({ readOnly: false, automaticLayout: true, scrollBeyondLastLine: false, padding: { top: 8, bottom: 8 } })
}} />
}
function ServerEditor({ server, onSaved, onBack }: { server?: McpServerView; onSaved(): Promise<void>; onBack(): void }): React.JSX.Element {
const initial = useRef(mcpEditorDocument(server)), [content, setContent] = useState(initial.current), [saving, setSaving] = useState(false), [error, setError] = useState('')
const dirty = content !== initial.current
const back = (): void => { if (!saving && (!dirty || window.confirm('配置尚未保存,确定返回吗?'))) onBack() }
const save = async (): Promise<void> => {
if (saving) return
setError('')
let input
try { input = parseMcpEditor(content, server) } catch (reason) { setError((reason as Error).message); return }
setSaving(true)
try { await window.ycode.mcp.save(input); await onSaved(); onBack() }
catch (reason) { setError((reason as Error).message || '保存配置失败') }
finally { setSaving(false) }
}
return <div className="mcp-config-editor"><div className="mcp-config-editor-header"><button className="mcp-config-editor-back" onClick={back} disabled={saving}>‹ 返回列表</button><div className="mcp-config-editor-header-actions"><SourceButton onClick={back} disabled={saving}>取消</SourceButton><SourceButton onClick={() => void save()} primary disabled={saving || !dirty}>{saving ? '保存中…' : '保存'}</SourceButton></div></div>
<div className="mcp-config-editor-path"><span className="mcp-config-editor-path-label">{server ? server.name : '添加 MCP'}</span>{dirty && <span className="mcp-config-editor-unsaved">未保存</span>}</div>
<p className="yc-mcp-editor-note">每次配置一个 MCP 服务。已有环境变量和 Headers 留空保持不变;保存后需批准变更的配置。</p>{error && <div className="yc-mcp-error" role="alert">{error}</div>}
<div className="mcp-config-editor-body"><div className="mcp-config-editor-monaco-wrapper"><JsonEditor content={content} setContent={setContent} /></div></div>
</div>
}
export default function ConnectorsPage(): React.JSX.Element {
return <CapabilityMaintenance title="连接器" />
const [servers, setServers] = useState<McpServerView[]>([]), [query, setQuery] = useState(''), [listQuery, setListQuery] = useState('')
const [loading, setLoading] = useState(true), [error, setError] = useState(''), [modal, setModal] = useState(false), [warningDismissed, setWarningDismissed] = useState(false)
const [editor, setEditor] = useState<{ server?: McpServerView } | null>(null), [detail, setDetail] = useState<string | null>(null), [confirmRemove, setConfirmRemove] = useState<McpServerView | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set()), [busy, setBusy] = useState<Set<string>>(new Set())
const busyRef = useRef(new Set<string>()), alive = useRef(true), opener = useRef<HTMLButtonElement>(null), refreshGeneration = useRef(0)
const refresh = useCallback(async (): Promise<void> => {
const generation = ++refreshGeneration.current
const items = await window.ycode.mcp.list()
if (alive.current && generation === refreshGeneration.current) setServers(items)
}, [])
useEffect(() => { alive.current = true; void refresh().catch(reason => setError(reason.message || '加载 MCP 配置失败')).finally(() => setLoading(false)); return () => { alive.current = false } }, [refresh])
useEffect(() => { if (!servers.some(server => server.status === 'connecting')) return; const timer = setInterval(() => { void refresh().catch(() => undefined) }, 3000); return () => clearInterval(timer) }, [servers, refresh])
const action = async (id: string, operation: () => Promise<unknown>): Promise<void> => {
if (busyRef.current.has(id)) return
busyRef.current.add(id); setBusy(new Set(busyRef.current)); setError('')
try { await operation() } catch (reason) { if (alive.current) setError((reason as Error).message || 'MCP 操作失败') }
finally { try { await refresh() } catch { if (alive.current) setError('刷新 MCP 配置失败') }; busyRef.current.delete(id); if (alive.current) setBusy(new Set(busyRef.current)) }
}
const close = (): void => { if (editor) { if (window.confirm('离开配置编辑器?未保存的修改将丢失。')) setEditor(null); return }; setModal(false); requestAnimationFrame(() => opener.current?.focus()) }
const color = (server: McpServerView): string => !server.enabled ? '#8c8c8c' : server.status === 'connected' ? '#52c41a' : server.status === 'connecting' ? '#faad14' : server.status === 'error' ? '#ff6b6b' : '#8c8c8c'
const status = (server: McpServerView): string => !server.enabled ? '已禁用' : ({ connected: '已连接', connecting: '连接中', disconnected: '未连接', error: '连接失败' })[server.status]
const icon = (server: McpServerView): React.JSX.Element => <span style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: mcpIconColor(server.name) }}>{server.name.charAt(0).toUpperCase()}</span>
const matches = (server: McpServerView, needle: string): boolean => server.name.toLowerCase().includes(needle.trim().toLowerCase())
const visible = servers.filter(server => matches(server, query)), listed = servers.filter(server => matches(server, listQuery)), selected = servers.find(server => server.id === detail)
const enabledTools = servers.filter(server => server.enabled).reduce((sum, server) => sum + server.tools.filter(tool => tool.enabled).length, 0)
const approve = (server: McpServerView): Promise<void> => action(server.id, async () => { await window.ycode.mcp.trust(server.id, server.configHash); await window.ycode.mcp.setEnabled(server.id, true) })
const portalHost = document.querySelector('.yc-capability-center .expert-center-page') ?? document.body
return <div className="ycode-connectors connectors-page"><div className="connector-panel">
<CapabilityToolbar><div className="ycode-connectors connectors-toolbar"><div className="ec-search-wrapper"><Search value={query} onChange={setQuery} placeholder="搜索连接器" /></div><button ref={opener} className="wb-button wb-button--primary wb-button--medium connector-topbar-action-btn" onClick={() => { setModal(true); void refresh().catch(() => undefined) }}><span className="wb-button__icon wb-button__icon--left"><ConnectorEditIcon /></span><span className="wb-button__content">自定义连接器</span></button></div></CapabilityToolbar>
<div className="connector-panel-content">{error && <div className="yc-mcp-error" role="alert">{error}</div>}{loading ? <div className="connector-panel-empty">加载中…</div> : !visible.length ? <div className="connector-panel-empty"><ConnectorEmptyIcon /><p className="connector-panel-empty-title">{query ? '未找到连接器' : '暂无连接器'}</p><p className="connector-panel-empty-desc">{query ? '试试其他关键词' : '连接器目录尚未接入,可添加并管理自定义 MCP 服务。'}</p></div> : <div className="connector-grid">{visible.map(server => <div key={server.id} className="connector-card connector-card--installed" role="button" tabIndex={0} onClick={() => setDetail(server.id)} onKeyDown={event => { if (event.key === 'Enter') setDetail(server.id) }}><div className="connector-card-content"><div className="connector-card-icon">{icon(server)}</div><div className="connector-card-main"><div className="connector-card-name-row"><div className="connector-card-name">{server.name}</div><span className={`connector-card-status-dot connector-card-status-dot--${server.status}`} title={status(server)} /></div><div className="connector-card-desc">{server.tools.length ? `${server.tools.filter(tool => tool.enabled).length}/${server.tools.length} 个工具已启用` : '自定义 MCP 服务'}</div></div><div className="connector-card-action" onClick={event => event.stopPropagation()}><button className="connector-connect-btn" disabled={busy.has(server.id)} aria-label={`管理 ${server.name}`} onClick={() => { setModal(true); setExpanded(new Set([server.id])) }}><ConnectorAddIcon /></button></div></div></div>)}</div>}</div>
</div>
{modal && createPortal(<div className="ycode-connectors"><div className="mcp-modal-mask"><section className="mcp-modal" role="dialog" aria-modal="true" aria-label="MCP 服务管理"><header className="mcp-modal-header"><div className="mcp-modal-header-left"><div className="mcp-modal-header-text"><h2 className="mcp-modal-title">MCP 服务管理</h2><p className="mcp-modal-subtitle">安装 MCP 服务,为 AI 扩展更多工具能力</p></div></div><div className="mcp-modal-header-right"><SourceButton icon={<McpAddIcon />} onClick={() => setEditor({})} disabled={!!editor}>添加 MCP</SourceButton><button className="mcp-modal-close" aria-label="关闭 MCP 服务管理" onClick={close}><McpCloseIcon /></button></div></header><div className="mcp-modal-body">
{/* The panel is portaled above the card list, so a failed approval, toggle,
tool switch or removal has to be reported inside the modal itself. */}
{error && <div className="yc-mcp-error" role="alert">{error}</div>}
{editor ? <ServerEditor key={editor.server?.id || 'new'} server={editor.server} onSaved={refresh} onBack={() => setEditor(null)} /> : <div className="mcp-panel">
<div className="mcp-panel-search" style={{ display: 'flex', alignItems: 'center', gap: 8 }}><div style={{ flex: 1 }}><Search mcp value={listQuery} onChange={setListQuery} placeholder="搜索MCP" /></div><SourceButton icon={<McpHubIcon />} onClick={() => void window.ycode.resources.openExternal('https://cloud.tencent.com/developer/mcp').catch(() => setError('无法打开 MCP Hub'))}>MCP Hub</SourceButton></div>
{servers.length > 0 && <div className="mcp-panel-section-header"><h3 className="mcp-panel-section-title">我的 MCP<span className="mcp-installed-badge">({listed.length})</span></h3><span className="mcp-panel-status-summary">{servers.filter(server => server.enabled).length} 启用{servers.some(server => server.status === 'error') && <span className="status-count-error"> · {servers.filter(server => server.status === 'error').length} 失败</span>}</span></div>}
{enabledTools > 80 && !warningDismissed && <div className="mcp-tools-warning"><div className="mcp-tools-warning-content"><span className="mcp-tools-warning-icon">⚠</span><div className="mcp-tools-warning-text"><strong>启用工具数量过多</strong><p>启用超过 80 个工具可能影响 AI 回复质量,建议禁用不常用的工具。</p></div></div><button className="mcp-tools-warning-dismiss" onClick={() => setWarningDismissed(true)}>知道了</button></div>}
<div className="mcp-panel-content">{!listed.length ? <div className="mcp-panel-empty"><ConnectorEmptyIcon /><p className="mcp-panel-empty-title">{listQuery ? '未找到相关 MCP' : '暂无 MCP 服务'}</p><p className="mcp-panel-empty-desc">{listQuery ? '试试其他关键词' : '添加 MCP 服务,为 AI 扩展更多工具能力'}</p>{!listQuery && <SourceButton onClick={() => setEditor({})}>添加 MCP</SourceButton>}</div> : <div className="mcp-panel-list">{listed.map(server => {
const open = expanded.has(server.id), working = busy.has(server.id)
return <div className={'mcp-item' + (server.status === 'error' ? ' mcp-item--error' : '')} key={server.id}><div className="mcp-item-header" onClick={() => setExpanded(previous => { const next = new Set(previous); if (next.has(server.id)) next.delete(server.id); else next.add(server.id); return next })}><ChevronIcon expanded={open} /><div className="mcp-item-icon" style={{ background: mcpIconColor(server.name) }}>{server.name.charAt(0).toUpperCase()}</div><div className="mcp-item-info"><div className="mcp-item-name">{server.name}<span className={'mcp-item-status' + (server.status === 'connecting' ? ' mcp-item-status--connecting' : '')} style={{ backgroundColor: color(server) }} title={status(server)} /></div><div className="mcp-item-meta">{server.tools.length ? `${server.tools.filter(tool => tool.enabled).length}/${server.tools.length} 个工具已启用` : '工具'}</div></div><div className="mcp-item-actions" onClick={event => event.stopPropagation()}>{open && <><button className="mcp-item-action" title="编辑配置" aria-label={`编辑 ${server.name}`} onClick={() => setEditor({ server })} disabled={working}>编辑</button>{server.enabled && server.trusted && <button className="mcp-item-action" title="重新连接" aria-label={`重新连接 ${server.name}`} disabled={working || server.status === 'connecting'} onClick={() => void action(server.id, async () => { await window.ycode.mcp.disconnect(server.id); await window.ycode.mcp.connect(server.id) })}><RefreshIcon /></button>}<button className="mcp-item-action" title="删除" aria-label={`删除 ${server.name}`} onClick={() => setConfirmRemove(server)} disabled={working}>删除</button></>}<span className="mcp-toggle-wrap"><button className={'mcp-toggle' + (server.enabled && server.trusted ? ' mcp-toggle--checked' : '') + (!server.trusted || working ? ' mcp-toggle--disabled' : '')} type="button" role="switch" aria-label={`${server.name} 启用`} aria-checked={server.enabled && server.trusted} disabled={!server.trusted || working} onClick={() => void action(server.id, () => window.ycode.mcp.setEnabled(server.id, !server.enabled))}><span className="mcp-toggle-thumb" /></button></span></div></div>
{!server.trusted && <div className="mcp-item-approval"><div className="mcp-item-approval-text">此 MCP 配置尚未批准</div><div className="mcp-item-approval-actions"><button className="mcp-item-approval-btn mcp-item-approval-btn--approve" disabled={working} onClick={() => void approve(server)}>批准配置</button></div></div>}
{server.error && <div className="mcp-item-error"><div className="mcp-item-error-content">{server.error}</div><div className="mcp-item-error-actions"><button className="mcp-item-error-link" disabled={working || !server.trusted} onClick={() => void action(server.id, () => window.ycode.mcp.connect(server.id))}><RefreshIcon />重新连接</button><button className="mcp-item-error-link" onClick={() => void navigator.clipboard.writeText(server.error!).catch(() => setError('复制失败'))}><CopyIcon />复制日志</button></div></div>}
{open && server.tools.length > 0 && <div className="mcp-item-tools">{server.tools.map(tool => <span className="mcp-item-tool-wrap" key={tool.name} title={tool.description}><button className={'mcp-item-tool' + (!tool.enabled ? ' mcp-item-tool--disabled' : '')} disabled={working || !server.enabled || !server.trusted} onClick={() => void action(server.id, () => window.ycode.mcp.setToolEnabled(server.id, tool.name, !tool.enabled))}>{tool.name}</button></span>)}</div>}
</div>
})}</div>}</div>
</div>}</div></section></div>{confirmRemove && <div className="yc-mcp-confirm-mask"><section className="yc-mcp-confirm" role="alertdialog" aria-modal="true" aria-label="删除 MCP"><h3>删除 MCP 服务</h3><p>确定删除“{confirmRemove.name}”吗?</p><div><SourceButton onClick={() => setConfirmRemove(null)} disabled={busy.has(confirmRemove.id)}>取消</SourceButton><SourceButton primary disabled={busy.has(confirmRemove.id)} onClick={() => void action(confirmRemove.id, async () => { await window.ycode.mcp.remove(confirmRemove.id); setConfirmRemove(null) })}>删除</SourceButton></div></section></div>}</div>, portalHost)}
{selected && createPortal(<div className="ycode-connectors"><div className="connector-detail-overlay connector-detail-modal-mask" onClick={event => { if (event.target === event.currentTarget) setDetail(null) }}><section className="connector-detail-modal connector-detail-modal--connected" role="dialog" aria-modal="true" aria-label={selected.name}><button className="connector-detail-close" type="button" aria-label="关闭详情" onClick={() => setDetail(null)}><CloseIcon /></button><div className="connector-detail-modal-body"><div className="connector-detail-flow"><div className="connector-detail-flow-avatar connector-detail-flow-avatar--workbuddy"><img src={SOURCE_BUDDY_ICON} alt="" draggable={false} /></div><span className="connector-detail-flow-icon"><span /><span /><span /></span><div className="connector-detail-flow-avatar connector-detail-flow-avatar--connector">{icon(selected)}</div></div><h3 className="connector-detail-name">{selected.name}</h3><p className="connector-detail-desc">{status(selected)} · {selected.tools.length} 个工具</p><div className="connector-detail-actions"><button className="connector-detail-btn connector-detail-btn--try" onClick={() => { setDetail(null); setModal(true); setExpanded(new Set([selected.id])) }}>管理 MCP</button></div>{selected.tools.length > 0 && <div className="connector-detail-expand"><div className="connector-detail-tools">{selected.tools.map(tool => <div className="connector-detail-tool-item" key={tool.name}><span className="connector-detail-tool-name">{tool.name}</span><span className="connector-detail-tool-desc">{tool.description}</span></div>)}</div></div>}</div></section></div></div>, portalHost)}
</div>
}
@@ -0,0 +1,292 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from 'react'
import type { RecommendedSkill, SkillDocument, SkillInstallProgress, SkillRecord } from '@shared/capabilities'
import { CardFrame, PlusIcon, UseSkillIcon } from './skills-source.mjs'
import { SkillDetailShell } from './SkillDetailShell'
import { SkillAvatar, skillAvatarUrl } from './SkillAvatar'
import MarkdownContent from '../components/MarkdownContent'
import './recommended-flow.css'
export interface RecommendedSkillsProps {
cwd?: string
installed: SkillRecord[]
onUseSkill?(skill: SkillRecord): void
onInstalledChange(): void
onViewInstalled(): void
}
type InstallPhase = SkillInstallProgress['phase'] | 'idle'
const isRunning = (phase: string): boolean => ['downloading', 'validating', 'installing'].includes(phase)
interface InstallView {
recommendation: RecommendedSkill
operationId: string
phase: InstallPhase
receivedBytes: number
totalBytes?: number
error?: string
overwrite: boolean
}
/** The recovered card supports a source label, but its published type does not declare it. */
const Card = CardFrame as ComponentType<{
title: string
description: string
sourceLabel?: string
iconUrl?: string
disabled?: boolean
onOpen?(): void
actions?: React.ReactNode
className?: string
}>
const PHASE_LABEL: Record<InstallPhase, string> = {
idle: '准备安装',
downloading: '下载中',
validating: '校验中',
installing: '安装中',
installed: '已安装',
cancelled: '已取消',
failed: '安装失败'
}
function operationId(): string {
const globalCrypto = window.crypto
if (globalCrypto?.randomUUID) return globalCrypto.randomUUID()
// The host rejects a non-UUID operation id, so derive a v4 UUID from the CSPRNG.
const bytes = new Uint8Array(16)
globalCrypto.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/** Compare the recommendation against the installed list, honouring local edits. */
export function matchInstalled(
recommendation: RecommendedSkill,
installed: SkillRecord[]
): { skill?: SkillRecord; state: 'none' | 'current' | 'outdated' | 'modified' | 'conflict' } {
const normalize = (value: string): string => value.normalize('NFKC').toLowerCase()
// Only a managed user skill can be replaced; a workspace skill is never overwritten.
const byName = installed.filter(skill => skill.scope === 'user' && normalize(skill.name) === normalize(recommendation.name))
if (!byName.length) return { state: 'none' }
const origin = byName.find(skill => skill.origin?.recommendationId === recommendation.id)
if (!origin) return { skill: byName[0], state: 'conflict' }
const installedOrigin = origin.origin!
if (origin.userModified) return { skill: origin, state: 'modified' }
const same = installedOrigin.version === recommendation.version
&& installedOrigin.archiveSha256 === recommendation.sha256
&& installedOrigin.commit === recommendation.commit
&& installedOrigin.repository === recommendation.repository
&& installedOrigin.packagePath === recommendation.packagePath
return same && !origin.error ? { skill: origin, state: 'current' } : { skill: origin, state: 'outdated' }
}
function InstallDisclosure({ recommendation, installed }: { recommendation: RecommendedSkill; installed?: SkillRecord }): React.JSX.Element {
return <div className="yc-skill-install-disclosure">
<strong>{installed ? '确认安装推荐版本并替换' : '确认安装此技能'}</strong>
<p>将从 GitHub 下载固定版本 <b>{recommendation.repository}</b> 的 {recommendation.packagePath}(版本 {recommendation.version},提交 {recommendation.commit.slice(0, 12)})。</p>
<p>{installed ? '替换后保留当前启停状态。' : '安装后默认启用,模型可以在需要时加载它。'}{recommendation.containsScripts ? '该技能包含脚本,运行仍受当前工具权限与审批规则约束。' : ''}安装过程只下载并保存文件,不会执行仓库中的任何安装命令。</p>
{installed && <p>本机已存在「{installed.name}」{installed.userModified ? ',且本地内容已被修改' : ''}。当前来源:{installed.origin ? `${installed.origin.repository}@${installed.origin.commit.slice(0, 12)} · v${installed.origin.version}` : '本地导入'};替换为 v{recommendation.version},保留{installed.enabled ? '启用' : '禁用'}状态。</p>}
</div>
}
function InstalledOverview({ skill, cwd }: { skill?: SkillRecord; cwd?: string }): React.JSX.Element | null {
const [document, setDocument] = useState<SkillDocument | null>(null)
const [error, setError] = useState('')
useEffect(() => {
let alive = true
setDocument(null); setError('')
if (skill && !skill.error) void window.ycode.skills.read(skill.id, cwd).then(value => {
if (alive) setDocument(value)
}).catch(reason => { if (alive) setError(String(reason.message ?? reason)) })
return () => { alive = false }
}, [skill?.id, skill?.origin?.installedAt, skill?.userModified, skill?.error, cwd])
if (!skill || skill.error) return null
return <details className="yc-skill-source-details yc-skill-installed-overview">
<summary>技能说明</summary>
{error ? <p className="yc-skill-error" role="alert">{error}</p> : document
? <MarkdownContent content={document.content} imageUrlResolver={async () => undefined} /> : <p role="status">正在读取技能说明…</p>}
</details>
}
export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChange, onViewInstalled }: RecommendedSkillsProps): React.JSX.Element {
const [recommendations, setRecommendations] = useState<RecommendedSkill[]>([])
const [loading, setLoading] = useState(true)
const [unavailable, setUnavailable] = useState('')
const [view, setView] = useState<InstallView | null>(null)
const [detail, setDetail] = useState<RecommendedSkill | null>(null)
const [error, setError] = useState('')
const [notice, setNotice] = useState<RecommendedSkill | null>(null)
const [foreign, setForeign] = useState<SkillInstallProgress | null>(null)
const [cancelPending, setCancelPending] = useState(false)
const [enabling, setEnabling] = useState(false)
const latestProgress = useRef<SkillInstallProgress | null>(null)
const submitting = useRef(false)
const active = useRef<InstallView | null>(null)
active.current = view
const changedRef = useRef(onInstalledChange)
changedRef.current = onInstalledChange
useEffect(() => {
let alive = true
if (typeof window.ycode.skills.recommendations !== 'function') { setUnavailable('当前版本未提供推荐技能目录'); setLoading(false); return }
void window.ycode.skills.recommendations().then(values => {
if (!alive) return
setRecommendations(values)
setUnavailable(values.length ? '' : '当前没有可用的内置推荐技能')
}).catch(reason => { if (alive) setUnavailable(String(reason.message ?? reason)) })
.finally(() => { if (alive) setLoading(false) })
return () => { alive = false }
}, [])
useEffect(() => {
if (!notice) return
const timer = window.setTimeout(() => setNotice(null), 6000)
return () => window.clearTimeout(timer)
}, [notice])
const applyProgress = useCallback((progress: SkillInstallProgress): void => {
const previous = latestProgress.current
if (previous && (progress.updatedAt < previous.updatedAt || progress.updatedAt === previous.updatedAt &&
(progress.operationId !== previous.operationId || !isRunning(previous.phase)))) return
latestProgress.current = progress
const current = active.current
if (current?.operationId === progress.operationId) {
const next = { ...current, phase: progress.phase, receivedBytes: progress.receivedBytes, totalBytes: progress.totalBytes, error: progress.error }
active.current = next
setView(next)
setForeign(null)
if (!isRunning(progress.phase)) {
submitting.current = false
setCancelPending(false)
if (progress.phase === 'installed') setNotice(current.recommendation)
}
} else setForeign(isRunning(progress.phase) ? progress : null)
if (progress.phase === 'installed') changedRef.current()
}, [])
useEffect(() => {
let alive = true
const api = window.ycode.skills
if (typeof api.onInstallProgress !== 'function') return
const unsubscribe = api.onInstallProgress(progress => { if (alive) applyProgress(progress) })
void api.installState().then(state => { if (alive && state) applyProgress(state) }).catch(() => undefined)
return () => { alive = false; unsubscribe() }
}, [applyProgress])
const matches = useMemo(() => recommendations.map(recommendation => ({ recommendation, match: matchInstalled(recommendation, installed) })), [recommendations, installed])
const operationFor = (recommendation: RecommendedSkill): SkillInstallProgress | InstallView | null =>
foreign?.recommendationId === recommendation.id ? foreign : view?.recommendation.id === recommendation.id ? view : null
const running = !!(view && isRunning(view.phase) || foreign && isRunning(foreign.phase))
const begin = (recommendation: RecommendedSkill): void => {
setDetail(recommendation)
if (running || submitting.current) return
setError('')
const next: InstallView = { recommendation, operationId: operationId(), phase: 'idle', receivedBytes: 0, totalBytes: recommendation.expectedBytes, overwrite: false }
active.current = next; setView(next)
}
const run = async (overwrite: boolean): Promise<void> => {
const current = active.current
if (!current || current.phase !== 'idle' || submitting.current || foreign && isRunning(foreign.phase)) return
const operation = current.operationId
submitting.current = true
setError('')
const next: InstallView = { ...current, phase: 'downloading', overwrite }
active.current = next; setView(next)
try {
await window.ycode.skills.installRecommended(current.recommendation.id, { operationId: operation, overwrite })
if (active.current?.operationId !== operation) return
submitting.current = false
const completed: InstallView = { ...active.current, phase: 'installed', error: undefined }
active.current = completed; setView(completed)
setNotice(current.recommendation)
changedRef.current()
} catch (reason) {
if (active.current?.operationId !== operation) return
submitting.current = false
const state = await window.ycode.skills.installState().catch(() => null)
if (active.current?.operationId !== operation) return
if (state?.operationId === operation && !isRunning(state.phase)) { applyProgress(state); return }
const failed: InstallView = { ...active.current, phase: 'failed', error: String((reason as Error).message ?? reason) }
active.current = failed; setView(failed)
}
}
const cancel = async (): Promise<void> => {
const current = view && isRunning(view.phase) ? view : foreign
if (!current || !isRunning(current.phase) || current.phase === 'installing' || cancelPending) return
setCancelPending(true)
try {
await window.ycode.skills.cancelInstall(current.operationId)
const state = await window.ycode.skills.installState()
if (state) applyProgress(state)
} catch (reason) { setError(String((reason as Error).message ?? reason)) }
finally { setCancelPending(false) }
}
const useSkill = async (skill: SkillRecord): Promise<void> => {
if (!onUseSkill || skill.error || !skill.userInvocable || enabling || running) return
if (!skill.enabled) {
setEnabling(true); setError('')
try {
await window.ycode.skills.setEnabled(skill.id, true, cwd)
const refreshed = (await window.ycode.skills.list(cwd)).find(item => item.id === skill.id)
changedRef.current()
if (!refreshed?.enabled || refreshed.error || !refreshed.userInvocable) throw Error('技能当前不可使用,请刷新后重试')
onUseSkill(refreshed); setDetail(null); setNotice(null)
} catch (reason) { setError(String((reason as Error).message ?? reason)) }
finally { setEnabling(false) }
} else { onUseSkill(skill); setDetail(null); setNotice(null) }
}
const useButton = (recommendation: RecommendedSkill, skill: SkillRecord | undefined, primary = false): React.ReactNode =>
skill && !skill.error && skill.userInvocable && onUseSkill && <button type="button"
className={primary ? 'sm-skill-detail-btn sm-skill-detail-btn--primary' : 'yc-skill-recommendation-link'}
aria-label={`使用推荐技能 ${recommendation.name}`} disabled={enabling || running}
onClick={event => { event.stopPropagation(); void useSkill(skill) }}><UseSkillIcon />{enabling ? '正在启用…' : skill.enabled ? '去使用' : '启用后使用'}</button>
if (loading) return <div className="sm-empty" role="status">加载中…</div>
if (unavailable) return <div className="sm-empty yc-skill-recommendation-unavailable"><p>推荐技能目录暂不可用</p><p>{unavailable}</p><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={onViewInstalled}>查看已安装技能</button></div>
return <>
{error && !detail && <p className="yc-skill-error" role="alert">{error}</p>}
{notice && <div className="yc-skill-notice yc-skill-install-success" role="status"><span>已安装 {notice.name}</span>{useButton(notice, matchInstalled(notice, installed).skill)}<button type="button" className="yc-skill-recommendation-link" aria-label="关闭安装成功提示" onClick={() => setNotice(null)}>关闭</button></div>}
{foreign && <div className="yc-skill-recommendation-progress" role="status" data-foreign-install="true" data-install-phase={foreign.phase}><span>其他窗口正在安装推荐技能({PHASE_LABEL[foreign.phase]})</span><button type="button" disabled={foreign.phase === 'installing' || cancelPending} onClick={() => void cancel()}>取消</button></div>}
<div className="sm-grid yc-skill-recommendation-grid">{matches.map(({ recommendation, match }) => {
const operation = operationFor(recommendation)
const pending = !!operation && isRunning(operation.phase)
const status = pending ? PHASE_LABEL[operation.phase] : match.state === 'current' ? '已安装' : match.state === 'modified' ? '已安装(本地已修改)' : match.state === 'outdated' ? '已安装(版本不同)' : match.state === 'conflict' ? '已存在同名本地技能' : '未安装'
return <div key={recommendation.id} className="yc-skill-card-keyboard yc-skill-recommendation-card" data-recommendation-id={recommendation.id} data-recommendation-state={match.state} data-install-phase={operation?.phase} role="button" tabIndex={0} aria-label={`查看 ${recommendation.name}`}
onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); setDetail(recommendation) } }}>
<Card title={recommendation.name} description={recommendation.description} iconUrl={skillAvatarUrl(recommendation.id)} sourceLabel={status} onOpen={() => setDetail(recommendation)} actions={<div className="sm-status" onClick={event => event.stopPropagation()}><button type="button" className={'sm-add-btn' + (pending ? ' sm-add-btn--loading' : '')} aria-label={`安装 ${recommendation.name}`} disabled={running || match.state === 'current'} title={running ? '当前有推荐技能正在安装' : undefined} onClick={() => begin(recommendation)}>{pending ? <span className="yc-skill-recommendation-spinner" aria-hidden /> : <PlusIcon />}</button></div>} />
<p className="yc-skill-recommendation-meta"><span>{recommendation.author}</span><span>v{recommendation.version}</span><span>{recommendation.license}</span>{foreign && <span className="yc-skill-recommendation-busy">其他窗口正在安装</span>}{useButton(recommendation, match.skill)}</p>
</div>
})}</div>
<div className="yc-skill-recommendation-footer"><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={onViewInstalled}>查看已安装技能</button></div>
{detail && (() => {
const match = matchInstalled(detail, installed)
const operation = operationFor(detail)
const pending = !!operation && isRunning(operation.phase)
const confirming = operation?.phase === 'idle'
const canInstall = match.state !== 'current'
return <SkillDetailShell title={detail.name} description={detail.description} ariaLabel={`推荐技能详情 ${detail.name}`} recommendationId={detail.id}
avatar={<SkillAvatar name={detail.name} recommendationId={detail.id} size={68} />}
metadata={<><span>v{detail.version}</span><span>{detail.license}</span><span>{detail.author}</span>{detail.containsScripts && <span>包含脚本</span>}</>}
onClose={() => { setDetail(null); if (active.current?.phase === 'idle') { active.current = null; setView(null) } }}
actions={<>
{pending ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" disabled><span className="yc-skill-recommendation-spinner" />{PHASE_LABEL[operation.phase]}</button><button type="button" className="sm-skill-detail-btn" disabled={operation.phase === 'installing' || cancelPending} onClick={() => void cancel()}>{cancelPending ? '正在请求取消…' : '取消安装'}</button></>
: confirming ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label="确认安装推荐技能" onClick={() => void run(!!match.skill)}>{match.skill ? '替换并安装' : '确认安装'}</button><button type="button" className="sm-skill-detail-btn" onClick={() => { active.current = null; setView(null) }}>返回详情</button></>
: <>{useButton(detail, match.skill, true)}{canInstall && <button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label={`安装推荐版本 ${detail.name}`} disabled={running} onClick={() => begin(detail)}>{operation?.phase === 'failed' || operation?.phase === 'cancelled' ? '重试安装' : match.skill ? '安装推荐版本' : '安装'}</button>}</>}
</>}>
{confirming && <InstallDisclosure recommendation={detail} installed={match.skill} />}
{operation && operation.phase !== 'idle' && <div className="yc-skill-inline-install" role="status" aria-live="polite" data-install-phase={operation.phase} data-operation-id={operation.operationId}>
<span>{PHASE_LABEL[operation.phase]}{operation.phase === 'downloading' && operation.totalBytes ? ` · ${Math.round(operation.receivedBytes / operation.totalBytes * 100)}%` : ''}</span>
{pending && <progress max={operation.totalBytes || 1} value={operation.phase === 'downloading' ? operation.receivedBytes : operation.totalBytes || 1} />}
{operation.phase === 'failed' && operation.error && <p className="yc-skill-error" role="alert">{operation.error}</p>}
</div>}
{error && <p className="yc-skill-error" role="alert">{error}</p>}
<details className="yc-skill-source-details"><summary>来源与校验信息</summary><dl><dt>仓库</dt><dd><button type="button" className="yc-skill-recommendation-link" onClick={() => { void window.ycode.resources.openExternal(`https://github.com/${detail.repository}`).catch(() => undefined) }}>{detail.repository}</button></dd><dt>提交</dt><dd>{detail.commit}</dd><dt>安装包</dt><dd>{detail.packagePath}</dd><dt>SHA-256</dt><dd>{detail.sha256}</dd></dl></details>
<InstalledOverview skill={match.skill} cwd={cwd} />
</SkillDetailShell>
})()}
</>
}
@@ -0,0 +1,17 @@
import { useState } from 'react'
import archifyAvatar from './assets/archify-owner-avatar.jpg'
export function skillAvatarUrl(recommendationId?: string): string | undefined {
return recommendationId === 'archify' ? archifyAvatar : undefined
}
export function SkillAvatar({ name, recommendationId, size = 48, borderRadius = 16 }: {
name: string; recommendationId?: string; size?: number; borderRadius?: number
}): React.JSX.Element {
const source = skillAvatarUrl(recommendationId)
const [failed, setFailed] = useState<string>()
const style = { width: size, height: size, borderRadius }
return source && failed !== source
? <img className="yc-skill-avatar" src={source} alt="" style={{ ...style, objectFit: 'cover' }} onError={() => setFailed(source)} />
: <span className="yc-skill-avatar" aria-hidden style={style}>{Array.from(name.trim())[0]?.toUpperCase() ?? 'S'}</span>
}
@@ -0,0 +1,34 @@
import { useRef, useState, type ReactNode } from 'react'
import { CloseIcon } from './skills-source.mjs'
import { SkillModalPortal } from './SkillModalPortal'
import { useModalFocus } from './skill-modal-focus'
import './skill-detail-shell.css'
interface Props {
title: string; description: string; avatar: ReactNode; metadata?: ReactNode; actions?: ReactNode
secondary?: ReactNode; children?: ReactNode; ariaLabel?: string; recommendationId?: string
onClose(): void
}
export function SkillDetailShell(props: Props): React.JSX.Element {
return <SkillModalPortal><DetailPanel {...props} /></SkillModalPortal>
}
function DetailPanel({ title, description, avatar, metadata, actions, secondary, children, ariaLabel, recommendationId, onClose }: Props): React.JSX.Element {
const dialog = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState(false)
useModalFocus(dialog, onClose)
return <div className="sm-skill-detail-overlay" onClick={event => { if (event.target === event.currentTarget) onClose() }}>
<div className="sm-skill-detail-card is-skill-view yc-skill-detail-shell" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={ariaLabel ?? title} data-recommendation-id={recommendationId}>
<div className="sm-skill-detail-header"><div className="sm-skill-detail-icon-wrap">{avatar}</div><div className="sm-skill-detail-header-body">
<div className="sm-skill-detail-title-row"><h2>{title}</h2><div className="sm-skill-detail-secondary">{secondary}<button type="button" className="sm-skill-detail-close" aria-label="关闭技能详情" onClick={onClose}><CloseIcon /></button></div></div>
<div className="sm-skill-detail-action-row">{actions}</div>
</div></div>
<div className="yc-skill-detail-scroll">
<div className="yc-skill-detail-description"><p className={expanded ? '' : 'is-collapsed'}>{description}</p>{description.length > 110 && <button type="button" className="yc-skill-recommendation-link" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>{expanded ? '收起' : '展开'}</button>}</div>
{metadata && <div className="yc-skill-detail-metadata">{metadata}</div>}
{children}
</div>
</div>
</div>
}
@@ -0,0 +1,49 @@
import { useEffect, useLayoutEffect, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
const hosts: HTMLElement[] = []
const saved = new Map<HTMLElement, { inert: boolean; hidden: string | null }>()
function isolateBackground(): void {
const top = hosts.at(-1)
for (const child of document.body.children) {
if (!(child instanceof HTMLElement) || child.tagName === 'SCRIPT') continue
if (!saved.has(child)) saved.set(child, { inert: child.inert, hidden: child.getAttribute('aria-hidden') })
child.inert = child !== top
if (child !== top) child.setAttribute('aria-hidden', 'true')
else child.removeAttribute('aria-hidden')
}
}
/** A body-level themed host escapes the capability center's isolated stacking context. */
export function SkillModalPortal({ children }: { children: ReactNode }): React.JSX.Element {
const [host] = useState(() => {
const element = document.createElement('div')
element.className = 'yc-capability-center yc-skill-modal-host'
return element
})
const [theme, setTheme] = useState(() => document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')
useLayoutEffect(() => {
document.body.append(host); hosts.push(host); isolateBackground()
const observer = new MutationObserver(isolateBackground)
observer.observe(document.body, { childList: true })
return () => {
observer.disconnect()
hosts.splice(hosts.indexOf(host), 1); host.remove()
if (hosts.length) isolateBackground()
else {
for (const [element, previous] of saved) {
element.inert = previous.inert
if (previous.hidden === null) element.removeAttribute('aria-hidden'); else element.setAttribute('aria-hidden', previous.hidden)
}
saved.clear()
}
}
}, [host])
useEffect(() => {
const observer = new MutationObserver(() => setTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'))
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => observer.disconnect()
}, [])
return createPortal(<div className={`expert-center-${theme} yc-skill-modal-theme`}>{children}</div>, host)
}
+121 -3
View File
@@ -1,5 +1,123 @@
import CapabilityMaintenance from './CapabilityMaintenance'
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
import type { SkillDocument, SkillRecord } from '@shared/capabilities'
import { CapabilitySubpageHeader, CapabilityToolbar } from './CapabilityCenter'
import { SkillsIcon, PinIcon, UnpinIcon, SearchIcon, ClearFilledIcon } from './SkillSourceIcons'
import { CardFrame, CardMoreIcon, PlusIcon, TrashIcon, FolderIcon, CloseIcon, UseSkillIcon, DocIcon, setSkillPrimitives } from './skills-source.mjs'
import { RecommendedSkills } from './RecommendedSkills'
import { useModalFocus } from './skill-modal-focus'
import { SkillDetailShell } from './SkillDetailShell'
import { SkillModalPortal } from './SkillModalPortal'
import { SkillAvatar, skillAvatarUrl } from './SkillAvatar'
import MarkdownContent from '../components/MarkdownContent'
import './skills-source.css'
import './skills-host.css'
export default function SkillsPage(): React.JSX.Element {
return <CapabilityMaintenance title="技能" />
export interface SkillsPageProps { cwd?: string; onUseSkill?(skill: SkillRecord): void; onCreateSkill?(): void; onFindSkill?(): void }
function Avatar({ name, iconUrl, size = 48, borderRadius = 16 }: { name: string; iconUrl?: string; size?: number; borderRadius?: number }): React.JSX.Element {
return <SkillAvatar name={name} recommendationId={iconUrl && iconUrl === skillAvatarUrl('archify') ? 'archify' : undefined} size={size} borderRadius={borderRadius} />
}
function Tooltip({ children, content }: { children: ReactElement<{ title?: string }>; content: ReactNode }): React.JSX.Element { return cloneElement(children, { title: typeof content === 'string' ? content : undefined }) }
function BatchCheckbox({ selected }: { selected: boolean }): React.JSX.Element { return <input className="sm-card-batch-checkbox" type="checkbox" checked={selected} readOnly tabIndex={-1} aria-hidden /> }
setSkillPrimitives({ Avatar, Tooltip, Checkbox: BatchCheckbox })
function Button({ children, primary = false, className = '', ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { primary?: boolean }): React.JSX.Element {
return <button type="button" {...props} className={`wb-button wb-button--${primary ? 'primary' : 'secondary'} wb-button--medium ${className}`}><span className="wb-button__content">{children}</span></button>
}
function Switch({ skill, disabled, large, onToggle }: { skill: SkillRecord; disabled?: boolean; large?: boolean; onToggle(): void }): React.JSX.Element {
return <label className={`wb-switch${large ? ' wb-switch--large sm-skill-detail-toggle' : ''}`} title={skill.error ?? (skill.enabled ? '禁用技能' : '启用技能')}><span className="wb-switch__control"><input className="wb-switch__input" type="checkbox" role="switch" aria-label={`${skill.enabled ? '禁用' : '启用'} ${skill.name}`} checked={skill.enabled} disabled={disabled || !!skill.error} onChange={onToggle} /><span className="wb-switch__track"><span className="wb-switch__thumb" /></span></span></label>
}
function Search({ value, onChange }: { value: string; onChange(value: string): void }): React.JSX.Element {
return <div className="ec-search-wrapper"><span className="wb-input-wrapper wb-input-wrapper--medium"><span className="wb-input__prefix"><SearchIcon /></span><input className="wb-input__inner" aria-label="搜索技能" placeholder="搜索技能" value={value} onChange={event => onChange(event.target.value)} /><button type="button" className={`wb-input__icon-button wb-input__clear${value ? '' : ' wb-input__clear--hidden'}`} aria-label="清空技能搜索" tabIndex={value ? 0 : -1} onClick={() => onChange('')}><ClearFilledIcon /></button></span></div>
}
/** Source layout and SVGs are recovered; native dialogs, storage, and accessibility are local host adapters. */
function ImportModal({ busy, error, onClose, onImport }: { busy: boolean; error: string; onClose(): void; onImport(overwrite: boolean, kind: 'directory' | 'zip'): void }): React.JSX.Element {
const dialog = useRef<HTMLDivElement>(null), [overwrite, setOverwrite] = useState(false)
useModalFocus(dialog, () => { if (!busy) onClose() })
return <div className="skill-upload-modal-mask" onClick={event => { if (event.target === event.currentTarget && !busy) onClose() }}><div ref={dialog} className="skill-upload-modal" role="dialog" aria-modal="true" aria-label="导入技能">
<div className="skill-upload-modal-header"><span>导入技能</span><button type="button" className="skill-upload-modal-close" aria-label="关闭导入技能" disabled={busy} onClick={onClose}><CloseIcon /></button></div>
<button type="button" className={'skill-upload-drop-zone' + (busy ? ' skill-upload-drop-zone--disabled' : '')} aria-label="选择文件夹" disabled={busy} onClick={() => onImport(overwrite, 'directory')}>
{busy && <span className="skill-upload-overlay"><span className="skill-upload-overlay-spinner" /><span className="skill-upload-overlay-text">正在导入技能…</span></span>}
<span className="skill-upload-drop-icon"><svg width="32" height="32" viewBox="0 0 32 32" fill="none"><rect x="3" y="6" width="26" height="20" rx="2" stroke="currentColor" strokeWidth="1.6" /><path d="M3 11h26" stroke="currentColor" strokeWidth="1.6" /><path d="M10 6V3M22 6V3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /><path d="M16 17v5M13 20l3-3 3 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg></span><span className="skill-upload-drop-title">选择技能文件夹</span>
</button>
<div className="skill-upload-requirements"><div className="skill-upload-requirements-title">技能文件要求</div><ul className="skill-upload-requirements-list"><li>文件夹根目录包含 SKILL.md</li><li>SKILL.md 包含 name 和 description 元数据</li></ul><button type="button" className="skill-upload-pick-zip-link" disabled={busy} onClick={() => onImport(overwrite, 'zip')}>选择 ZIP 文件</button></div>
<label className="yc-skill-overwrite"><input type="checkbox" checked={overwrite} disabled={busy} onChange={event => setOverwrite(event.target.checked)} />替换已导入的同名技能</label>
<p className="yc-skill-local-note">导入到本机技能库;当前未连接远程安全扫描服务。</p>{error && <p className="yc-skill-error" role="alert">{error}</p>}
</div></div>
}
function Detail({ skill, document, busy, error, onClose, onToggle, onUse, onExport, onRemove }: { skill: SkillRecord; document: SkillDocument | null; busy: boolean; error: string; onClose(): void; onToggle(): void; onUse?(): void; onExport(): void; onRemove(): void }): React.JSX.Element {
return <SkillDetailShell title={skill.name} description={skill.description || skill.error || ''} onClose={onClose}
avatar={<SkillAvatar name={skill.name} recommendationId={skill.origin?.recommendationId} size={68} />}
secondary={<Switch skill={skill} large disabled={busy} onToggle={onToggle} />}
metadata={<><span>{skill.origin ? '推荐安装' : skill.scope === 'workspace' ? '空间技能' : '本地导入'}</span>{skill.version && <span>v{skill.version}</span>}{skill.origin && <span>{skill.origin.repository}</span>}{skill.disableModelInvocation && <span>仅手动调用</span>}{!skill.userInvocable && <span>仅模型调用</span>}</>}
actions={<>{!skill.error && skill.userInvocable && onUse && <button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label="使用技能" disabled={busy} onClick={onUse}><UseSkillIcon />{skill.enabled ? '去使用' : '启用后使用'}</button>}{skill.scope === 'user' && <><Button disabled={busy || !!skill.error} onClick={onExport}><FolderIcon />导出</Button><button type="button" className="sm-skill-detail-more-btn" aria-label="卸载技能" disabled={busy} onClick={onRemove}><TrashIcon /></button></>}</>}>
{error && <p className="yc-skill-error" role="alert">{error}</p>}
<section className="yc-skill-description-body"><h3 className="sm-skill-detail-section-title"><DocIcon />技能说明</h3>{document ? <MarkdownContent content={document.content} imageUrlResolver={async () => undefined} /> : <p>{skill.error || '正在读取技能说明…'}</p>}</section>
{document && <details className="yc-skill-source-details"><summary>技能文件与来源</summary><div className="yc-skill-files">{document.files.map(file => <code key={file}>{file}</code>)}</div><p className="yc-skill-local-note">{skill.path}</p>{skill.origin && <p className="yc-skill-local-note">{skill.origin.repository}@{skill.origin.commit}</p>}</details>}
</SkillDetailShell>
}
export default function SkillsPage({ cwd, onUseSkill, onCreateSkill, onFindSkill }: SkillsPageProps): React.JSX.Element {
const [view, setView] = useState<'market' | 'installed'>('market'), [items, setItems] = useState<SkillRecord[]>([]), [query, setQuery] = useState(''), [category, setCategory] = useState('推荐'), [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState(''), [addOpen, setAddOpen] = useState(false), [importOpen, setImportOpen] = useState(false), [importError, setImportError] = useState(''), [selectedId, setSelectedId] = useState<string | null>(null), [document, setDocument] = useState<SkillDocument | null>(null), [detailError, setDetailError] = useState(''), [menuId, setMenuId] = useState<string | null>(null), [batch, setBatch] = useState(false), [selected, setSelected] = useState<string[]>([]), [pendingRemove, setPendingRemove] = useState<SkillRecord[]>([])
const currentCwd = useRef(cwd); currentCwd.current = cwd
const generation = useRef(0), mounted = useRef(true), add = useRef<HTMLDivElement>(null), removeDialog = useRef<HTMLDivElement>(null)
const key = 'ycode.skills.menu.v1:' + (cwd ?? '').replaceAll('\\', '/').toLowerCase()
const [pinned, setPinned] = useState<string[]>([])
useEffect(() => { if (!notice) return; const timer = window.setTimeout(() => setNotice(''), 3000); return () => window.clearTimeout(timer) }, [notice])
useEffect(() => { try { setPinned(readPinned(key)) } catch { setPinned([]) } }, [key])
useEffect(() => { mounted.current = true; return () => { mounted.current = false; generation.current++ } }, [])
const reload = useCallback(async () => { if (currentCwd.current !== cwd) return; const current = ++generation.current; const values = await window.ycode.skills.list(cwd); if (mounted.current && currentCwd.current === cwd && current === generation.current) setItems(values) }, [cwd])
useEffect(() => { setLoading(true); setQuery(''); setSelectedId(null); setSelected([]); setMenuId(null); setError(''); setNotice(''); void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }).finally(() => { if (mounted.current) setLoading(false) }) }, [reload])
// The host pushes a change after every install, import, removal or enable switch.
useEffect(() => {
const api = window.ycode.skills
if (typeof api.onChanged !== 'function') return
return api.onChanged(() => { void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }) })
}, [reload])
useEffect(() => { if (!addOpen && !menuId) return; const close = (event: PointerEvent): void => { if (add.current?.contains(event.target as Node) || (event.target as Element)?.closest?.('.sm-card-menu-wrap')) return; setAddOpen(false); setMenuId(null) }; const keydown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { setAddOpen(false); setMenuId(null) } }; documentGlobal().addEventListener('pointerdown', close); window.addEventListener('keydown', keydown); return () => { documentGlobal().removeEventListener('pointerdown', close); window.removeEventListener('keydown', keydown) } }, [addOpen, menuId])
const current = items.find(item => item.id === selectedId) ?? null
useEffect(() => { let active = true; setDocument(null); setDetailError(''); if (current && !current.error) void window.ycode.skills.read(current.id, cwd).then(value => { if (active) setDocument(value) }).catch(reason => { if (active) setDetailError(String(reason.message ?? reason)) }); return () => { active = false } }, [current?.id, current?.error, current?.enabled, cwd])
const run = async (operation: () => Promise<unknown>): Promise<void> => { if (busy) return; setBusy(true); setError(''); setNotice(''); try { await operation() } catch (reason) { if (mounted.current) setError(reason instanceof Error ? reason.message : String(reason)) } finally { try { await reload() } catch (reason) { if (mounted.current) setError(reason instanceof Error ? reason.message : String(reason)) }; if (mounted.current) setBusy(false) } }
const matches = useMemo(() => items.filter(item => `${item.name}\n${item.description}`.toLowerCase().includes(query.trim().toLowerCase())).sort((a, b) => Number(pinned.includes(b.id)) - Number(pinned.includes(a.id))), [items, query, pinned])
const pin = (id: string): void => { const next = pinned.includes(id) ? pinned.filter(value => value !== id) : [...pinned, id]; setPinned(next); let memory = {}; try { memory = JSON.parse(localStorage.getItem(key) ?? '{}') } catch { /* Invalid optional local memory is discarded. */ } localStorage.setItem(key, JSON.stringify({ ...memory, pinned: next })) }
const useSkill = (skill: SkillRecord): void => {
if (busy || !skill.userInvocable || skill.error || !onUseSkill) return
if (skill.enabled) { setSelectedId(null); onUseSkill(skill); return }
void run(async () => {
await window.ycode.skills.setEnabled(skill.id, true, cwd)
const refreshed = (await window.ycode.skills.list(cwd)).find(item => item.id === skill.id)
if (!refreshed?.enabled || refreshed.error || !refreshed.userInvocable) throw Error('技能当前不可使用,请刷新后重试')
setSelectedId(null); onUseSkill(refreshed)
})
}
const importPackage = async (overwrite: boolean, kind: 'directory' | 'zip'): Promise<void> => { if (busy) return; setBusy(true); setImportError(''); try { const value = await window.ycode.skills.importPath(undefined, overwrite, kind); if (value) { await reload(); setImportOpen(false); setView('installed'); setNotice(`已导入 ${value.name}`) } } catch (reason) { setImportError(reason instanceof Error ? reason.message : String(reason)) } finally { setBusy(false) } }
const toggle = (skill: SkillRecord): void => { void run(() => window.ycode.skills.setEnabled(skill.id, !skill.enabled, cwd)) }
const exportPackage = (skill: SkillRecord): void => { void run(async () => { const target = await window.ycode.skills.export(skill.id); if (target) setNotice('已导出技能 ZIP') }) }
const toggleSelected = (id: string): void => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : [...values, id])
return <div className="skill-market yc-skills-page">
{view === 'installed' && <CapabilitySubpageHeader title={`已安装${items.length ? ` (${items.length})` : ''}`} onBack={() => { setView('market'); setQuery(''); setBatch(false) }} />}
<CapabilityToolbar><Search value={query} onChange={setQuery} />{view === 'market' ? <Button className="sm-topbar-action-btn" aria-label="已安装技能" onClick={() => { setView('installed'); setQuery('') }}><SkillsIcon />已安装{items.length > 0 && <span className="sm-topbar-installed-count">({items.length})</span>}</Button> : <button className="sm-installed-batch-manage" onClick={() => { setBatch(value => !value); setSelected([]) }}>{batch ? '完成' : '批量管理'}</button>}
<div ref={add} className="yc-skill-add"><Button primary className="sm-topbar-action-btn" aria-label="添加技能" aria-expanded={addOpen} onClick={() => setAddOpen(value => !value)}><PlusIcon />添加技能</Button>{addOpen && <div className="wb-popover wb-dropdown yc-skill-add-menu" role="menu" onKeyDown={event => { if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); const rows = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')]; const index = rows.indexOf(window.document.activeElement as HTMLButtonElement); rows[event.key === 'Home' ? 0 : event.key === 'End' ? rows.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + rows.length) % rows.length]?.focus() }}><div className="wb-dropdown__list">{[{ label: '查找技能', action: () => onFindSkill?.() }, { label: '上传技能', action: () => { setImportError(''); setImportOpen(true) } }, { label: '创建技能', action: () => onCreateSkill?.() }].map(row => <button type="button" role="menuitem" className="wb-dropdown__item" key={row.label} disabled={row.label === '查找技能' && !onFindSkill || row.label === '创建技能' && !onCreateSkill} onClick={() => { setAddOpen(false); add.current?.querySelector('button')?.focus(); row.action() }}><span className="wb-dropdown__label">{row.label}</span></button>)}</div></div>}</div>
</CapabilityToolbar>
<div className="yc-skills-scroll">{error && <div className="yc-skill-error" role="alert">{error}<button onClick={() => { void run(reload) }}>重试</button></div>}{notice && <p className="yc-skill-notice" role="status">{notice}</p>}
{view === 'market' ? <><div className="sm-search-panel"><div className="sm-segment-bar"><div className="sm-segment-group" role="tablist" aria-label="技能来源">{['推荐', 'SkillHub', '套件'].map(value => <button key={value} role="tab" aria-selected={category === value} className={'sm-segment-item' + (category === value ? ' sm-segment-item--active' : '')} onClick={() => setCategory(value)}>{value}</button>)}</div></div>
{category === '推荐'
? <section className="sm-installed-section yc-skill-recommendation-section">
<RecommendedSkills cwd={cwd} installed={items} onUseSkill={onUseSkill} onInstalledChange={() => { void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }) }} onViewInstalled={() => { setView('installed'); setQuery('') }} />
</section>
: <div className="sm-empty yc-skill-market-unavailable"><SkillsIcon width={32} height={32} /><p>当前未连接{category}目录服务</p><p>你可以导入本地技能,或查看已安装技能。</p><Button onClick={() => { setView('installed'); setQuery('') }}>查看已安装技能</Button></div>}
</div></> : <section className="sm-installed-section">
{batch && <div className="sm-batch-toolbar"><div className="sm-batch-toolbar-left"><input type="checkbox" aria-label="全选技能" checked={matches.length > 0 && matches.every(item => selected.includes(item.id))} onChange={event => setSelected(event.target.checked ? matches.map(item => item.id) : [])} />已选择 {selected.length} 项</div><div className="sm-batch-toolbar-right"><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, true, cwd) }) }}>启用</Button><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, false, cwd) }) }}>禁用</Button><Button disabled={busy || !selected.some(id => items.some(item => item.id === id && item.scope === 'user'))} onClick={() => setPendingRemove(items.filter(item => selected.includes(item.id) && item.scope === 'user'))}>卸载</Button></div></div>}
{loading ? <div className="sm-empty" role="status">加载中…</div> : !matches.length ? <div className="sm-empty">{query ? '没有找到相关技能' : '暂无已安装技能'}</div> : <div className="sm-installed-list">{(['workspace', 'user'] as const).map(scope => { const group = matches.filter(item => item.scope === scope); return !!group.length && <section key={scope} className="sm-installed-group"><div className="sm-installed-group-header"><div className="sm-installed-group-title"><span>{scope === 'workspace' ? '空间技能' : '自定义技能'}</span><span className="sm-installed-group-count">({group.length})</span></div></div><div className="sm-grid">{group.map(skill => <div className="yc-skill-card-keyboard" key={skill.id} data-skill-id={skill.id} role={batch ? undefined : 'button'} tabIndex={0} aria-label={`查看 ${skill.name}`} onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); if (batch) toggleSelected(skill.id); else setSelectedId(skill.id) } }}>
<CardFrame iconUrl={skillAvatarUrl(skill.origin?.recommendationId)} title={skill.name} description={skill.error ? `不可用:${skill.error}` : skill.description} installed installedListMode disabled={!skill.enabled} batchMode={batch} selected={selected.includes(skill.id)} onToggleSelect={() => toggleSelected(skill.id)} onOpen={() => setSelectedId(skill.id)} actions={!batch && <div className="sm-status" onClick={event => event.stopPropagation()}><div className="sm-card-menu-wrap"><button type="button" className="sm-card-menu-btn sm-card-more" aria-label={`更多 ${skill.name}`} aria-expanded={menuId === skill.id} onClick={() => setMenuId(menuId === skill.id ? null : skill.id)}><CardMoreIcon /></button>{menuId === skill.id && <div className="sm-card-menu" role="menu">{!skill.error && skill.userInvocable && onUseSkill && <button className="sm-card-menu-item" onClick={() => { setMenuId(null); useSkill(skill) }}><UseSkillIcon /><span>{skill.enabled ? '去使用' : '启用后使用'}</span></button>}<button className="sm-card-menu-item" onClick={() => { setMenuId(null); setSelectedId(skill.id) }}><DocIcon /><span>查看详情</span></button>{skill.scope === 'user' && <><button className="sm-card-menu-item" disabled={busy || !!skill.error} onClick={() => { setMenuId(null); exportPackage(skill) }}><FolderIcon /><span>导出</span></button><button className="sm-card-menu-item sm-card-menu-item--danger" disabled={busy} onClick={() => { setMenuId(null); setPendingRemove([skill]) }}><TrashIcon /><span>卸载技能</span></button></>}</div>}</div><button type="button" className={'sm-card-pin' + (pinned.includes(skill.id) ? ' sm-card-pin--active' : '')} aria-label={pinned.includes(skill.id) ? '取消置顶' : '置顶'} aria-pressed={pinned.includes(skill.id)} onClick={() => pin(skill.id)}>{pinned.includes(skill.id) ? <UnpinIcon /> : <PinIcon />}</button><span className="sm-card-switch-wrap"><Switch skill={skill} disabled={busy} onToggle={() => toggle(skill)} /></span></div>} />
</div>)}</div></section> })}</div>}
</section>}
</div>
{importOpen && <SkillModalPortal><ImportModal busy={busy} error={importError} onClose={() => setImportOpen(false)} onImport={(overwrite, kind) => { void importPackage(overwrite, kind) }} /></SkillModalPortal>}
{current && <Detail skill={current} document={document} busy={busy} error={detailError || error} onClose={() => setSelectedId(null)} onToggle={() => toggle(current)} onUse={onUseSkill ? () => useSkill(current) : undefined} onExport={() => exportPackage(current)} onRemove={() => setPendingRemove([current])} />}
{pendingRemove.length > 0 && <SkillModalPortal><RemoveConfirm skills={pendingRemove} busy={busy} dialogRef={removeDialog} onClose={() => setPendingRemove([])} onConfirm={() => { void run(async () => { for (const skill of pendingRemove) await window.ycode.skills.remove(skill.id); setPendingRemove([]); setSelectedId(null); setSelected([]); setNotice('已卸载技能副本') }) }} /></SkillModalPortal>}
</div>
}
function documentGlobal(): Document { return window.document }
function readPinned(key: string): string[] { try { const values = JSON.parse(localStorage.getItem(key) ?? '{}')?.pinned; return Array.isArray(values) ? values.filter((id): id is string => typeof id === 'string').slice(0, 200) : [] } catch { return [] } }
function RemoveConfirm({ skills, busy, dialogRef, onClose, onConfirm }: { skills: SkillRecord[]; busy: boolean; dialogRef: React.RefObject<HTMLDivElement | null>; onClose(): void; onConfirm(): void }): React.JSX.Element {
useModalFocus(dialogRef, () => { if (!busy) onClose() })
return <div className="sm-update-confirm-modal-mask"><div className="sm-update-confirm-modal" ref={dialogRef} role="dialog" aria-modal="true" aria-label="确认卸载技能"><div className="sm-update-confirm-modal-header"><span>卸载技能</span><button className="sm-update-confirm-modal-close" aria-label="关闭卸载确认" disabled={busy} onClick={onClose}><CloseIcon /></button></div><div className="sm-update-confirm-modal-content"><p className="sm-update-confirm-description">确定卸载{skills.length === 1 ? `「${skills[0].name}」` : `所选 ${skills.length} 项技能`}?仅删除本机导入副本,保留导入源。</p></div><div className="sm-update-confirm-modal-footer"><Button disabled={busy} onClick={onClose}>取消</Button><Button primary disabled={busy} aria-label="确认卸载" onClick={onConfirm}>{busy ? '正在卸载…' : '卸载'}</Button></div></div></div>
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -0,0 +1,16 @@
.yc-skill-install-disclosure{padding:16px;background:var(--wb-bg-secondary);border:1px solid var(--wb-border-subtle);border-radius:12px;font-size:13px;line-height:1.65}
.yc-skill-install-disclosure p{margin:8px 0 0;overflow-wrap:anywhere}
.yc-skill-inline-install{display:flex;flex-direction:column;gap:8px;color:var(--wb-color-text-secondary);font-size:13px}
.yc-skill-inline-install progress{width:100%;height:4px;accent-color:var(--wb-color-text-primary)}
.yc-skill-inline-install .yc-skill-error{margin:0}
.yc-skill-source-details{font-size:13px;line-height:1.6;color:var(--wb-color-text-secondary);border-top:1px solid var(--wb-border-subtle);padding-top:16px}
.yc-skill-source-details summary{cursor:pointer;color:var(--wb-color-text-primary);font-weight:500}
.yc-skill-source-details dl{display:grid;grid-template-columns:64px minmax(0,1fr);gap:8px;margin:12px 0 0;padding:16px;background:var(--wb-bg-secondary);border-radius:12px}
.yc-skill-source-details dt{color:var(--wb-color-text-tertiary)}
.yc-skill-source-details dd{margin:0;overflow-wrap:anywhere}
.yc-skill-installed-overview>.markdown-body{margin-top:12px}
.yc-skill-notice.yc-skill-install-success{display:flex;align-items:center;gap:12px;pointer-events:auto}
.yc-skill-install-success .yc-skill-recommendation-link{display:inline-flex;align-items:center;gap:4px}
.yc-skill-recommendation-meta .yc-skill-recommendation-link{display:inline-flex;align-items:center;gap:4px}
.yc-skill-recommendation-link svg{width:14px;height:14px;flex:none}
.yc-skill-recommendation-link:disabled{cursor:default;opacity:.5}
@@ -0,0 +1,14 @@
.yc-capability-center.yc-skill-modal-host{position:fixed;inset:0;z-index:12000;height:100%;width:100%;display:block;pointer-events:none}
.yc-skill-modal-theme{height:100%;pointer-events:none}
.yc-skill-modal-host .sm-skill-detail-overlay,.yc-skill-modal-host .skill-upload-modal-mask,.yc-skill-modal-host .sm-update-confirm-modal-mask{pointer-events:auto}
.yc-skill-modal-host .yc-skill-detail-shell{display:flex;flex-direction:column;max-height:calc(100dvh - 80px);height:auto;color:var(--wb-color-text-primary);font-family:var(--wb-font-family)}
.yc-skill-detail-shell .sm-skill-detail-header{flex:none}
.yc-skill-detail-scroll{overflow:auto;min-height:0;padding:20px 24px 24px;display:flex;flex-direction:column;gap:20px;overscroll-behavior:contain}
.yc-skill-detail-description p{margin:0;font-size:14px;line-height:1.65;overflow-wrap:anywhere}
.yc-skill-detail-description p.is-collapsed{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.yc-skill-detail-description button{margin-top:8px}
.yc-skill-detail-metadata{display:flex;flex-wrap:wrap;gap:12px 24px;border-top:1px solid var(--wb-border-subtle);border-bottom:1px solid var(--wb-border-subtle);padding:16px 0;font-size:13px;color:var(--wb-color-text-secondary)}
.yc-skill-detail-shell .sm-skill-detail-action-row{flex-wrap:wrap;gap:8px}
.yc-skill-detail-shell .sm-skill-detail-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap}
.yc-skill-detail-shell .yc-skill-description-body{font-size:13px;line-height:1.6;overflow-wrap:anywhere}
@media(max-width:600px){.yc-skill-modal-host .yc-skill-detail-shell{max-height:calc(100dvh - 32px)}.yc-skill-detail-scroll{padding:16px 20px 20px}}
@@ -0,0 +1,37 @@
import { useEffect, useRef } from 'react'
/** Shared focus trap for the recovered source modals. Only the topmost modal reacts to keys. */
const modalStack: HTMLElement[] = []
export function useModalFocus(ref: React.RefObject<HTMLElement | null>, close: () => void): void {
const onClose = useRef(close); onClose.current = close
useEffect(() => {
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null
const element = ref.current
if (element) modalStack.push(element)
ref.current?.querySelector<HTMLElement>('button:not(:disabled),input:not(:disabled)')?.focus()
const keyboard = (event: KeyboardEvent): void => {
if (modalStack.at(-1) !== ref.current) return
if (event.isComposing) return
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose.current() }
if (event.key === 'Tab') {
const targets = [...(ref.current?.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled),select:not(:disabled),summary,[tabindex="0"],a[href]') ?? [])].filter(el => {
if (!el.getClientRects().length || getComputedStyle(el).visibility === 'hidden') return false
// Chromium can retain layout boxes inside a closed details element.
for (let parent = el.parentElement; parent && parent !== ref.current; parent = parent.parentElement) {
if (parent.tagName === 'DETAILS' && !parent.hasAttribute('open') && !parent.querySelector(':scope > summary')?.contains(el)) return false
}
return true
})
const index = targets.indexOf(document.activeElement as HTMLElement)
if (targets.length && (index < 0 || event.shiftKey && index === 0 || !event.shiftKey && index === targets.length - 1)) { event.preventDefault(); targets[event.shiftKey ? targets.length - 1 : 0].focus() }
}
}
document.addEventListener('keydown', keyboard, true)
return () => {
document.removeEventListener('keydown', keyboard, true)
if (element) { const index = modalStack.indexOf(element); if (index >= 0) modalStack.splice(index, 1) }
if (previous?.isConnected) previous.focus()
}
}, [ref])
}
@@ -6,6 +6,24 @@
.yc-skill-add-menu button{width:100%;text-align:left;font:inherit;border:0;background:transparent}
.yc-skill-avatar{display:inline-flex;align-items:center;justify-content:center;flex:none;background:var(--wb-bg-secondary);color:var(--wb-color-text-secondary);font-size:20px;font-weight:600}
.yc-skill-error{color:var(--wb-error-foreground,#d54941);font-size:13px;line-height:1.5;overflow-wrap:anywhere}
.yc-skill-recommendation-section{display:flex;flex-direction:column;gap:16px;width:100%}
.yc-skill-recommendation-grid{width:100%}
.yc-skill-recommendation-unavailable{text-align:center}
.yc-skill-recommendation-source{margin:0 0 8px;color:var(--wb-color-text-secondary);font-size:13px;line-height:1.5;overflow-wrap:anywhere}
.yc-skill-recommendation-link{padding:0;border:0;background:none;color:var(--wb-color-text-primary);font:inherit;text-decoration:underline;cursor:pointer}
.yc-skill-recommendation-card{position:relative}
.yc-skill-recommendation-grid .yc-skill-recommendation-card>.sm-card{height:126px;box-sizing:border-box;padding-bottom:40px}
.yc-skill-recommendation-meta{position:absolute;bottom:14px;left:20px;right:20px;display:flex;align-items:center;gap:8px;margin:0;color:var(--wb-color-text-tertiary);font-size:12px;line-height:20px}
.yc-skill-recommendation-meta>.yc-skill-recommendation-link{margin-left:auto;white-space:nowrap}
.yc-skill-recommendation-meta>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.yc-skill-recommendation-busy{color:var(--wb-color-text-secondary)}
.yc-skill-recommendation-footer{display:flex;justify-content:flex-start;margin-top:4px}
.yc-skill-recommendation-progress{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:12px 16px;border:1px solid var(--wb-border-default);border-radius:12px;background:var(--wb-bg-card);font-size:13px}
.yc-skill-recommendation-progress-text{color:var(--wb-color-text-primary);font-weight:500}
.yc-skill-recommendation-progress .yc-skill-error{flex:1;min-width:0}
.yc-skill-recommendation-spinner{width:12px;height:12px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:yc-skill-recommendation-spin .7s linear infinite}
@keyframes yc-skill-recommendation-spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion: reduce){.yc-skill-recommendation-spinner{animation:none}}
.yc-skill-error button{margin-left:12px;color:inherit;background:none;border:0;text-decoration:underline;cursor:pointer}
.yc-skill-notice{position:absolute;z-index:120;top:8px;right:24px;margin:0;padding:8px 12px;border:1px solid var(--wb-border-subtle);border-radius:8px;background:var(--wb-bg-popover);box-shadow:var(--wb-shadow-dialog-md);font-size:13px;color:var(--wb-color-text-secondary);pointer-events:none}
.yc-skill-market-unavailable{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:320px;gap:12px;color:var(--wb-color-text-tertiary);font-size:14px}
@@ -1,5 +1,5 @@
import type {ComponentType,ReactNode} from 'react';
export const CardFrame:ComponentType<{title:string;description:string;installed?:boolean;installedListMode?:boolean;disabled?:boolean;batchMode?:boolean;selected?:boolean;onToggleSelect?():void;onOpen?():void;actions?:ReactNode;className?:string}>;
export const CardFrame:ComponentType<{title:string;description:string;iconUrl?:string;installed?:boolean;installedListMode?:boolean;disabled?:boolean;batchMode?:boolean;selected?:boolean;onToggleSelect?():void;onOpen?():void;actions?:ReactNode;className?:string}>;
export const CardMoreIcon:ComponentType;
export const PlusIcon:ComponentType;
export const TrashIcon:ComponentType;
@@ -18,7 +18,10 @@
*/
import { useEffect, useRef, useState } from 'react'
import type { ModelMetadataStatus } from '@shared/models'
import { formatUpdateProgress, hasUnfinishedUpdate, primaryUpdateAction, updateStatusText } from './updates/update-ui-model'
import { openUpdateDownloadPage, openUpdateLog, runPrimaryUpdateAction, useUpdatesUi } from './updates/update-ui-store'
import './source-about-help-panels.css'
import './updates/updates-ui.css'
export type AboutHelpSection = 'helpFeedback' | 'getHelp'
@@ -58,6 +61,16 @@ export default function AboutHelpSettingsPanel({ section }: { section: AboutHelp
const [metadataStatus, setMetadataStatus] = useState<ModelMetadataStatus>(), [refreshing, setRefreshing] = useState(false), [feedback, setFeedback] = useState('')
const active = useRef(false), refreshLock = useRef(false)
const about = section === 'helpFeedback'
const updates = useUpdatesUi()
const updateAction = primaryUpdateAction(updates.state)
const updateProgress = formatUpdateProgress(updates.state.progress)
const updateBusy = updates.busy !== null || updateAction.disabled
const updateFeedback = updateStatusText(updates.state)
const unfinished = hasUnfinishedUpdate(updates.state)
const updateLabel = updates.state.status === 'downloading' && updateProgress !== null
? `下载中 ${updateProgress}%`
: updateAction.label
const showUpdateLinks = updates.state.status === 'error' || updates.state.status === 'unsupported' || unfinished
useEffect(() => { active.current = true; return () => { active.current = false } }, [])
useEffect(() => {
let active = true
@@ -88,9 +101,31 @@ export default function AboutHelpSettingsPanel({ section }: { section: AboutHelp
{about ? (
<>
<div className="help-feedback-panel__card-row" id="settings-about-version">
<span className="help-feedback-panel__card-label">当前版本<span className="help-feedback-panel__card-value">v{version}</span></span>
<UnavailableAction label="检查更新" title="暂未接入" />
<span className="help-feedback-panel__card-label">当前版本<span className="help-feedback-panel__card-value">v{updates.state.currentVersion || version}</span></span>
<button type="button" className="wb-button wb-button--secondary wb-button--medium help-feedback-panel__action-button"
data-update-action={updateAction.kind} data-update-status={updates.state.status}
disabled={updateBusy} aria-busy={updateBusy || updates.state.status === 'downloading'}
onClick={() => void runPrimaryUpdateAction()}>
<span className="wb-button__content">{updateLabel}</span>
</button>
</div>
{updateFeedback ? (
<div
className={`update-ui-feedback${updates.state.status === 'error' ? ' update-ui-feedback--error' : ''}${updates.state.status === 'up-to-date' ? ' update-ui-feedback--success' : ''}`}
role="status"
aria-live="polite"
>
<span className="update-ui-feedback__text">{updateFeedback}</span>
{showUpdateLinks ? (
<>
<button type="button" className="update-ui-feedback__link" data-update-manual disabled={updates.busy !== null}
onClick={() => void openUpdateDownloadPage()}>手动下载</button>
<button type="button" className="update-ui-feedback__link" data-update-log disabled={updates.busy !== null}
onClick={() => void openUpdateLog()}>更新日志</button>
</>
) : null}
</div>
) : null}
<div className="help-feedback-panel__card-row" id="settings-about-product">
<span className="help-feedback-panel__card-label">关于 YCode</span>
<UnavailableAction label="前往官网" title="尚未配置" arrow />
+416 -49
View File
@@ -14,12 +14,22 @@ import { SourceModeSubmenu, SourceReferenceFilesSubmenu } from '../workbuddy/com
import type { ResourceEntry } from '@shared/resources'
import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls'
import { ComposerBranchLabel, ContextUsageRing } from '../workbuddy/composer/context-info/ComposerContextInfo'
import { composeComposer } from '../workbuddy/composer/input-composition'
import { PromptQueuePanel, usePromptQueue } from '../workbuddy/composer/PromptQueuePanel'
import { useCapabilityCatalog } from '../workbuddy/composer/useCapabilityCatalog'
import { withCapabilitySelection } from '../workbuddy/composer/capability-selection'
import type { CapabilityPickerItem, CapabilityPickerSelection, CapabilitySelectionIntent } from '../workbuddy/composer/CapabilityPicker'
import { PermissionConfirm, QuestionFloating } from '../workbuddy/conversation'
import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode, UserContentBlock } from '@shared/conversation'
import type { QueuedPrompt } from '@shared/prompt-queue'
import type { UserMessageEditContent } from '../workbuddy/conversation/UserMessageEditor'
import type { ChatMessage } from '@shared/types'
import type { SkillRecord } from '@shared/capabilities'
import { rewindLabel } from '../workbuddy/conversation/rewind-labels'
import { collectWorkbenchArtifacts } from '../workbuddy/workbench/workbench-state'
import { messageCapabilityRefs } from '../workbuddy/conversation/message-capabilities'
import { TaskConfirmDialog } from './task-confirm-dialog'
import { draftFingerprint, draftIsEmpty, mergeFailedDraft } from '../workbuddy/composer/draft-fingerprint'
interface Props {
hidden?: boolean
@@ -29,6 +39,50 @@ interface Props {
darkMode?: boolean
}
interface DraftSkill {
id: string
name: string
uri?: string
}
interface DraftEntry {
text: string
attachments: ChatAttachment[]
resources?: string[]
quotes?: MessageQuote[]
userContent?: UserContentBlock[]
skills?: DraftSkill[]
mcpServerIds?: string[]
command?: 'compact'
queueEditId?: string
autoFilledPrompt?: string
}
const EMPTY_DRAFT: DraftEntry = { text: '', attachments: [] }
function cloneDraft(draft: DraftEntry): DraftEntry {
return structuredClone(draft)
}
function mergeDraftEntries(current: DraftEntry, failed: DraftEntry): { draft: DraftEntry; commandConflict: boolean } {
const merged = mergeFailedDraft(current, failed)
const queueEditId = failed.queueEditId ?? current.queueEditId
return {
draft: {
text: merged.draft.text,
attachments: merged.draft.attachments ?? [],
resources: merged.draft.resources,
quotes: merged.draft.quotes,
userContent: merged.draft.userContent,
skills: merged.draft.skills,
mcpServerIds: merged.draft.mcpServerIds,
command: merged.draft.command,
...(queueEditId ? { queueEditId } : {})
},
commandConflict: merged.commandConflict
}
}
const PERMISSION_MODE_KEY = 'ycode:permission-mode'
function initialPermissionMode(): PermissionMode {
@@ -67,15 +121,54 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
void window.ycode.chat.setSessionVisible(hidden ? null : currentId)
}, [currentId, hidden])
const [drafts, setDrafts] = useState<Record<string, { text: string; attachments: ChatAttachment[]; resources?: string[]; quotes?: MessageQuote[]; userContent?: UserContentBlock[]; editingId?: string; autoFilledPrompt?: string }>>({})
const [drafts, setDrafts] = useState<Record<string, DraftEntry>>({})
const draftsRef = useRef(drafts)
draftsRef.current = drafts
const draftMoves = useRef(new Map<string, string>())
const draftKey = currentId ?? `new-${draftId}`
const entry = drafts[draftKey] ?? { text: '', attachments: [] }
const queueState = usePromptQueue(currentId)
const entry = drafts[draftKey] ?? EMPTY_DRAFT
const queueBusy = (queueState.snapshot?.items.length ?? 0) > 0 || !!queueState.snapshot?.editingDraft
const draft = entry.text
const quotedSourceIds = useMemo(() => (entry.quotes ?? []).map((item) => item.sourceId), [entry.quotes])
const setDraft = useCallback((value: string | ((current: string) => string)): void => {
setDrafts((all) => { const current = all[draftKey] ?? { text: '', attachments: [] }; const text = typeof value === 'function' ? value(current.text) : value; return { ...all, [draftKey]: { ...current, text, autoFilledPrompt: text === current.autoFilledPrompt ? current.autoFilledPrompt : undefined } } })
}, [draftKey])
const updateDraftEntry = useCallback((key: string, value: DraftEntry | ((current: DraftEntry) => DraftEntry)): void => {
setDrafts(all => {
const current = all[key] ?? EMPTY_DRAFT
const next = { ...all, [key]: typeof value === 'function' ? value(current) : value }
draftsRef.current = next
return next
})
}, [])
useEffect(() => {
const activeOwner = (): string => {
const state = useChatStore.getState()
return state.currentSessionId ?? `new-${state.draftId}`
}
const selectSkill = (event: Event): void => {
const detail = (event as CustomEvent<{ skill: SkillRecord; owner: string }>).detail
if (!detail?.skill || detail.owner !== activeOwner()) return
updateDraftEntry(detail.owner, current => ({ ...current, skills: [
...(current.skills ?? []).filter(item => item.id !== detail.skill.id),
{ id: detail.skill.id, name: detail.skill.name }
] }))
}
const intent = (event: Event): void => {
const detail = (event as CustomEvent<{ text: string; owner: string; skill?: DraftSkill }>).detail
if (detail?.owner !== activeOwner() || typeof detail.text !== 'string') return
updateDraftEntry(detail.owner, current => ({ ...current, text: detail.text,
userContent: undefined, ...(detail.skill ? { skills: [detail.skill] } : {}) }))
requestAnimationFrame(() => document.querySelector<HTMLElement>('.wb-composer__input')?.focus())
}
window.addEventListener('ycode:select-skill', selectSkill)
window.addEventListener('ycode:composer-intent', intent)
return () => {
window.removeEventListener('ycode:select-skill', selectSkill)
window.removeEventListener('ycode:composer-intent', intent)
}
}, [updateDraftEntry])
useEffect(() => {
const migrate = (event: Event): void => {
const detail = (event as CustomEvent<{ from: string; to: string }>).detail
@@ -93,6 +186,11 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const [rewinding, setRewinding] = useState(false)
const [editRollback, setEditRollback] = useState<{ variant: 'artifacts' } | null>(null)
const editDecision = useRef<((rollback: boolean | null) => void) | null>(null)
const [queueEditTarget, setQueueEditTarget] = useState<QueuedPrompt | null>(null)
const queueEditDecision = useRef<((confirmed: boolean) => void) | null>(null)
const queueEditHydrated = useRef(new Set<string>())
const [queueEditRestoring, setQueueEditRestoring] = useState(false)
const queueEditRestoringRef = useRef(false)
const editCheckEpoch = useRef(0)
const finishEditDecision = (rollback: boolean | null): void => {
const resolve = editDecision.current
@@ -102,6 +200,16 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
if (rollback === null) requestAnimationFrame(() => document.querySelector<HTMLDivElement>('.cr-user-message-editor [contenteditable]')?.focus())
}
useEffect(() => () => { editDecision.current?.(null); editDecision.current = null }, [])
useEffect(() => () => { queueEditDecision.current?.(false); queueEditDecision.current = null }, [])
const activeSessionPermission = currentId
? sessions.find((session) => session.id === currentId)?.permissionMode
: draftContext.permissionMode
const effectivePermissionMode = currentId
? activeSessionPermission ?? 'default-sandbox'
: activeSessionPermission ?? initialPermissionMode()
useEffect(() => {
setPermissionMode(effectivePermissionMode)
}, [effectivePermissionMode, currentId])
/**
* 原版 resolveRevertEntryVisible(ConversationTimelineImpl.hasRevision):
* 有 requestId 且不是“历史已全部加载时的首条用户消息”才显示回退入口;
@@ -110,7 +218,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
*/
const firstUserRequestId = messages.find((message) => message.role === 'user')?.requestId
const rewindEntryVisible = (message: ChatMessage): boolean => {
if (message.role !== 'user' || running || sending || !message.requestId) return false
if (message.role !== 'user' || running || sending || queueBusy || message.command || !message.requestId) return false
return !firstUserRequestId || message.requestId !== firstUserRequestId
}
/** Claude checkpoints can rewind tracked file edits, but do not expose a preview list to the host. */
@@ -118,6 +226,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const [activeSegment, setActiveSegment] = useState<SourceHomeMode>('work')
const [homeAction, setHomeAction] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const composerStackRef = useRef<HTMLDivElement>(null)
const followBottom = useRef(true)
/** 把表格引用挂到当前草稿(原版 message.quote.add:引用与正文分开保存,不移写正文)。 */
const quoteTable = useCallback((quote: MessageQuote): void => {
@@ -158,6 +267,130 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const effectiveAgentMode = (currentId ? currentSession?.agentMode : draftContext.agentMode) ?? 'craft'
// Left undefined when the session has no explicit choice; the selector then uses the model's own default.
const effectiveThinkingLevel = currentSession?.thinkingLevel ?? draftContext.thinkingLevel
const capabilityCatalog = useCapabilityCatalog(workspaceRoot)
useEffect(() => { if (!hidden) capabilityCatalog.refresh() }, [hidden, capabilityCatalog.refresh])
const selectedCapabilities = useMemo<CapabilityPickerSelection[]>(() => [
...(entry.skills ?? []).map((item): CapabilityPickerSelection => ({ kind: 'skill', id: item.id })),
...(entry.mcpServerIds ?? []).map((id): CapabilityPickerSelection => ({ kind: 'mcp', id })),
...(entry.command ? [{ kind: 'command', id: entry.command } satisfies CapabilityPickerSelection] : [])
], [entry.command, entry.mcpServerIds, entry.skills])
const removeCapability = useCallback((selection: CapabilityPickerSelection): void => {
setDrafts(all => {
const current = all[draftKey]
if (!current) return all
if (selection.kind === 'skill') return { ...all, [draftKey]: { ...current, skills: current.skills?.filter(item => item.id !== selection.id) } }
if (selection.kind === 'mcp') return { ...all, [draftKey]: { ...current, mcpServerIds: current.mcpServerIds?.filter(id => id !== selection.id) } }
return { ...all, [draftKey]: { ...current, command: undefined } }
})
}, [draftKey])
const selectCapability = useCallback((item: CapabilityPickerItem, intent?: CapabilitySelectionIntent): void => {
setDrafts(all => ({ ...all, [draftKey]: withCapabilitySelection(all[draftKey] ?? EMPTY_DRAFT, item, intent) }))
}, [draftKey])
const draftFromQueuedPayload = useCallback((payload: QueuedPrompt['payload']): DraftEntry => ({
text: payload.command ? '' : payload.text,
attachments: payload.attachments ?? [],
resources: payload.resources ?? [],
quotes: payload.quotes ?? [],
userContent: payload.userContent,
skills: payload.skillIds?.map(id => ({
id,
name: payload.capabilityLabels?.[`skill:${id}`] ??
capabilityCatalog.items.find(value => value.kind === 'skill' && value.id === id)?.name ?? id
})),
mcpServerIds: payload.mcpServerIds ?? [],
command: payload.command,
queueEditId: payload.queueEditId
}), [capabilityCatalog.items])
const editQueuedPrompt = useCallback(async (item: QueuedPrompt): Promise<void> => {
const owner = currentId
if (!owner || item.payload.sessionId !== owner) throw new Error('会话已切换,无法编辑该条追加消息')
const current = draftsRef.current[draftKey] ?? EMPTY_DRAFT
if (current.queueEditId || queueState.snapshot?.editingDraft) {
throw new Error('请先提交或放回正在编辑的追加消息')
}
if (!draftIsEmpty(current)) {
const confirmed = await new Promise<boolean>((resolve) => {
queueEditDecision.current = resolve
setQueueEditTarget(item)
})
if (!confirmed) return
}
if (useChatStore.getState().currentSessionId !== owner) throw new Error('会话已切换,无法编辑该条追加消息')
const restoreFingerprint = draftFingerprint(draftsRef.current[draftKey] ?? EMPTY_DRAFT)
queueEditRestoringRef.current = true
setQueueEditRestoring(true)
try {
const taken = await window.ycode.chat.queue.take(owner, item.id)
const payload = taken.payload
const payloadDraft = draftFromQueuedPayload(payload)
const mergePayloadIntoDraft = (): void => {
const current = draftsRef.current[owner] ?? EMPTY_DRAFT
updateDraftEntry(owner, mergeDraftEntries(current, payloadDraft).draft)
}
const restoreQueueItem = async (message: string): Promise<never> => {
try {
await window.ycode.chat.queue.restoreEdit(owner, item.id)
} catch (error) {
mergePayloadIntoDraft()
throw new Error(`${message};恢复原队列项失败,完整内容已并入草稿:${error instanceof Error ? error.message : String(error)}`)
}
throw new Error(`${message};追加消息已恢复原位置`)
}
if (useChatStore.getState().currentSessionId !== owner) {
return await restoreQueueItem('会话已切换,无法继续编辑该条追加消息')
}
if (draftFingerprint(draftsRef.current[draftKey] ?? EMPTY_DRAFT) !== restoreFingerprint) {
return await restoreQueueItem('输入区内容已变化,请重试')
}
updateDraftEntry(draftKey, payloadDraft)
if (payloadDraft.queueEditId) queueEditHydrated.current.add(`${owner}:${payloadDraft.queueEditId}`)
requestAnimationFrame(() => document.querySelector<HTMLTextAreaElement>('.wb-composer__input')?.focus())
} finally {
queueEditRestoringRef.current = false
setQueueEditRestoring(false)
}
}, [currentId, draftFromQueuedPayload, draftKey, queueState.snapshot?.editingDraft, updateDraftEntry])
const restoreQueuedEdit = useCallback(async (): Promise<void> => {
const owner = currentId
const id = draftsRef.current[draftKey]?.queueEditId
if (!owner || !id) return
const fingerprint = draftFingerprint(draftsRef.current[draftKey] ?? EMPTY_DRAFT)
queueEditRestoringRef.current = true
setQueueEditRestoring(true)
try {
await window.ycode.chat.queue.restoreEdit(owner, id)
updateDraftEntry(draftKey, current => {
if (current.queueEditId !== id) return current
if (draftFingerprint(current) === fingerprint) return { text: '', attachments: [] }
const next = { ...current }
delete next.queueEditId
return next
})
} finally {
queueEditRestoringRef.current = false
setQueueEditRestoring(false)
}
}, [currentId, draftKey, updateDraftEntry])
useEffect(() => {
const queued = queueState.snapshot?.editingDraft
const owner = currentId
if (!owner || !queued || queueEditRestoringRef.current) return
const id = queued.payload.queueEditId ?? queued.id
const hydrationKey = `${owner}:${id}`
if (queueEditHydrated.current.has(hydrationKey)) return
queueEditHydrated.current.add(hydrationKey)
const incoming = { ...draftFromQueuedPayload(queued.payload), queueEditId: id }
const current = draftsRef.current[owner] ?? EMPTY_DRAFT
if (current.queueEditId !== id && !draftIsEmpty(current)) {
addMenuError(new Error('检测到未完成的队列编辑,原内容已安全并入当前草稿'))
}
updateDraftEntry(owner, current => {
if (current.queueEditId === id) return current
if (draftIsEmpty(current)) return incoming
return mergeDraftEntries(current, incoming).draft
})
requestAnimationFrame(() => document.querySelector<HTMLTextAreaElement>('.wb-composer__input')?.focus())
}, [currentId, draftFromQueuedPayload, queueEditRestoring, queueState.snapshot?.editingDraft, updateDraftEntry])
const [modeRevision, setModeRevision] = useState(0)
const [referenceFiles, setReferenceFiles] = useState<{ owner: string; items: ResourceEntry[] }>({ owner: '', items: [] })
const referenceScope = `${draftKey}:${workspaceRoot}`
@@ -174,13 +407,31 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const availableReferenceFiles = referenceFiles.owner === referenceScope ? referenceFiles.items : []
const addMenuError = (reason: unknown): void => useChatStore.setState({ error: reason instanceof Error ? reason.message : String(reason) })
const activeInteraction = messages.flatMap((message) => message.interactions ?? []).findLast((item) =>
(item.kind === 'question' || item.kind === 'approval') && ['pending', 'responding', 'error'].includes(item.status))
const composerError = error ?? queueState.error ?? capabilityCatalog.error
const composition = composeComposer({
interaction: activeInteraction ? { id: activeInteraction.id, kind: activeInteraction.kind, status: activeInteraction.status } : null,
notice: composerError ? { kind: 'error', id: composerError } : null,
queueCount: queueState.snapshot?.items.length ?? 0,
queueRevision: queueState.snapshot?.revision ?? 0,
running: !!running,
disabled: queueEditRestoring
})
// The selected model label comes from the local registry.
const modelLabel = models.find((model) => model.id === effectiveModelId)?.name || config?.model || '未配置模型'
useEffect(() => {
const el = scrollRef.current
if (!el) return
if (followBottom.current) el.scrollTop = el.scrollHeight
}, [messages, running])
const keepLatestVisible = (): void => { if (followBottom.current) el.scrollTop = el.scrollHeight }
keepLatestVisible()
const stack = composerStackRef.current
if (!stack || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(keepLatestVisible)
observer.observe(stack)
return () => observer.disconnect()
}, [messages, running, composition.layoutKey, currentId])
useEffect(() => {
setRewindTarget(null)
@@ -191,28 +442,60 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
editDecision.current = null
setEditRollback(null)
resolve?.(null)
const resolveQueueEdit = queueEditDecision.current
queueEditDecision.current = null
setQueueEditTarget(null)
resolveQueueEdit?.(false)
followBottom.current = true
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
}, [currentId])
const submit = async ({ text, attachments, resources, quotes, userContent }: ComposerSendPayload): Promise<void> => {
if ((!text && !attachments.length && !quotes.length && !resources.length) || running || sending) return
useEffect(() => {
if (!queueBusy) return
setRewindTarget(null)
setRewindError(null)
}, [queueBusy])
const submit = async ({ text, attachments, resources, quotes, userContent, skillIds, mcpServerIds, command, queueEditId }: ComposerSendPayload): Promise<void> => {
if (sending) return
if (composition.input.blockedByInteraction) throw new Error('请先处理当前交互')
followBottom.current = true
// 原版发送即清空输入框并立即切到对话页:先清空草稿,失败时再恢复(TODO 08)。
const submitKey = draftKey
const restore = { ...entry, text, attachments, resources, quotes, userContent }
setDrafts((all) => ({ ...all, [submitKey]: { text: '', attachments: [] } }))
const submittedSnapshot = cloneDraft(draftsRef.current[submitKey] ?? EMPTY_DRAFT)
const handoffId = submittedSnapshot.queueEditId ?? queueEditId
if (draftIsEmpty(submittedSnapshot) && !skillIds?.length && !mcpServerIds?.length && !command) return
const submittedFingerprint = draftFingerprint(submittedSnapshot)
if (!useChatStore.getState().currentSessionId) {
useChatStore.setState(state => ({
draftContext: {
...state.draftContext,
permissionMode: state.draftContext.permissionMode ?? permissionMode
}
}))
}
// Detach the submitted snapshot before awaiting the host. Any subsequent
// input is therefore independent from content that is already in flight.
if (draftFingerprint(draftsRef.current[submitKey] ?? EMPTY_DRAFT) === submittedFingerprint) {
const cleared = { ...draftsRef.current, [submitKey]: { text: '', attachments: [] } }
draftsRef.current = cleared
setDrafts(cleared)
}
try {
await send(text, { attachments, resources, quotes, userContent, permissionMode })
// 会话创建后草稿所有权已迁移到会话 id;两处都清空,避免留下旧文本。
const destination = draftMoves.current.get(submitKey) ?? submitKey
await send(text, { attachments, resources, quotes, userContent, skillIds, mcpServerIds, command, queueEditId: handoffId, permissionMode })
draftMoves.current.delete(submitKey)
setDrafts((all) => ({ ...all, [submitKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } }))
} catch (error) {
const destination = draftMoves.current.get(submitKey)
draftMoves.current.delete(submitKey)
setDrafts((all) => ({ ...all, [submitKey]: restore, ...(destination && destination !== submitKey ? { [destination]: restore } : {}) }))
const state = useChatStore.getState()
const targetKey = destination && (state.currentSessionId === destination ||
state.sessions.some(session => session.id === destination)) ? destination : submitKey
const failedSnapshot = cloneDraft(submittedSnapshot)
const merged = mergeDraftEntries(draftsRef.current[targetKey] ?? EMPTY_DRAFT, failedSnapshot)
updateDraftEntry(targetKey, merged.draft)
if (merged.commandConflict) {
addMenuError(new Error('草稿包含未执行的压缩命令;命令标签和正文已保留。请移除命令标签后再发送普通内容。'))
}
throw error
}
}
@@ -220,6 +503,14 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
/** 就地编辑:进入、取消、提交(提交走 send 的 action='edit' 分支,与原版 message.edit.submit 对齐)。 */
const startEdit = (message: ChatMessage): void => {
if (running || sending) return
if (message.command) {
addMenuError(new Error('压缩记录不能编辑;请使用新的原生压缩命令'))
return
}
if (queueBusy) {
addMenuError(new Error('请先处理待发送队列,再编辑历史消息'))
return
}
const owner = currentId
if (!owner) return
const epoch = ++editCheckEpoch.current
@@ -236,6 +527,14 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
}
const cancelEdit = (): void => setEditingMessageId(null)
const submitEdit = async (message: ChatMessage, text: string, edited?: UserMessageEditContent): Promise<void> => {
if (message.command) {
addMenuError(new Error('压缩记录不能编辑;请使用新的原生压缩命令'))
return
}
if (queueBusy) {
addMenuError(new Error('请先处理待发送队列,再编辑历史消息'))
return
}
const owner = currentId
const preview = rewindPreview()
let rollbackArtifacts = true
@@ -246,18 +545,36 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
if (decision === null) return
rollbackArtifacts = decision
if (!owner || useChatStore.getState().currentSessionId !== owner) return
const capabilities = messageCapabilityRefs(message)
await send(text, { attachments: edited?.attachments ?? message.attachments, resources: edited?.resources ?? message.resources, quotes: edited?.quotes ?? message.quotes,
userContent: edited?.userContent ?? message.userContent, permissionMode, fromMessageId: message.id, action: 'edit', rollbackArtifacts })
skillIds: edited?.skillIds ?? capabilities.skillIds, mcpServerIds: edited?.mcpServerIds ?? capabilities.mcpServerIds,
command: edited?.command ?? capabilities.command, userContent: edited?.userContent ?? message.userContent,
permissionMode, fromMessageId: message.id, action: 'edit', rollbackArtifacts })
setEditingMessageId(null)
}
const precedingUserMessage = (message: ChatMessage): ChatMessage | undefined => {
const index = messages.findIndex(item => item.id === message.id)
return index < 0 ? undefined : messages.slice(0, index).findLast(item => item.role === 'user')
}
const regenerate = (_requestId: string, message: ChatMessage): Promise<void> => {
const preceding = messages.slice(0, messages.findIndex((item) => item.id === message.id)).findLast((item) => item.role === 'user')
return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes, userContent: preceding.userContent, permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve()
if (running || sending || queueBusy) {
if (queueBusy) addMenuError(new Error('请先处理待发送队列,再重新生成消息'))
return Promise.resolve()
}
const preceding = precedingUserMessage(message)
if (preceding?.command) {
addMenuError(new Error('压缩记录不能重新生成;请使用新的原生压缩命令'))
return Promise.resolve()
}
return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes,
...messageCapabilityRefs(preceding),
userContent: preceding.userContent, permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve()
}
const canRegenerateMessage = (message: ChatMessage): boolean => {
const preceding = precedingUserMessage(message)
return !running && !sending && !queueBusy && !!preceding && !preceding.command
}
const lastAssistantId = messages.findLast((message) => message.role === 'assistant')?.id
const activeInteraction = messages.flatMap((message) => message.interactions ?? []).findLast((item) =>
(item.kind === 'question' || item.kind === 'approval') && ['pending', 'responding', 'error'].includes(item.status))
const bindDraftWorkspace = (ownerDraftId: number, workspaceId = useSettingsStore.getState().selectedWorkspaceId): void => {
const settings = useSettingsStore.getState()
const selected = settings.workspaces.find((item) => item.id === workspaceId)
@@ -287,14 +604,51 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
bindDraftWorkspace(owner.draftId)
}
const floatingSurface = activeInteraction?.kind === 'question'
? <QuestionFloating key={activeInteraction.id} config={{ questions: activeInteraction.questions, title: activeInteraction.title, toolCallId: activeInteraction.toolCallId }}
responding={activeInteraction.status === 'responding'} error={activeInteraction.error}
labels={{ title: '需要你确认', skip: '跳过', next: '下一题', send: '发送回答', customAnswer: '输入其他回答', skipAll: '跳过全部', previousQuestion: '上一题', nextQuestion: '下一题', dismiss: '收起', notAnswered: '未回答' }}
onSelect={(_text, answer) => respond(activeInteraction.id, { kind: 'question', answer })}
onSkip={() => void respond(activeInteraction.id, { kind: 'question', answer: { questions: activeInteraction.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true }).catch(() => undefined)} />
: activeInteraction?.kind === 'approval'
? <PermissionConfirm key={activeInteraction.id} className="permission-floating"
pendingId={activeInteraction.id} toolName={activeInteraction.toolCallId} title={activeInteraction.title}
options={activeInteraction.options} status={activeInteraction.status} error={activeInteraction.error}
labels={{ confirm: '允许一次', alwaysAllow: '始终允许', reject: '拒绝', working: '提交中', approved: '已允许', rejected: '已拒绝', cancelled: '已取消', failed: '提交失败' }}
resolve={(id, decision) => respond(id, { kind: 'approval', optionId: decision.optionId })}
reject={(id) => {
const option = activeInteraction.options.find((item) => item.kind.startsWith('reject'))
return option ? respond(id, { kind: 'approval', optionId: option.optionId }) : Promise.reject(new Error('审批选项不可用'))
}} />
: composition.notice?.kind === 'error' && composerError ? <div className="wb-composer__error" role="alert">{composerError}</div> : undefined
const queuePanel = composition.stack.queue && queueState.snapshot
? <PromptQueuePanel key={currentId} snapshot={queueState.snapshot} onEdit={editQueuedPrompt}
sendNowAvailable={!!running && !composition.input.blockedByInteraction} />
: undefined
const queuedEditReference = entry.queueEditId
? <div className="yc-capability-references"><span className="yc-capability-reference">
队列消息
<button type="button" disabled={queueEditRestoring} aria-label="将当前编辑中的队列消息放回原位置"
title="放回原队列消息并清空本次编辑" onClick={() => void restoreQueuedEdit().catch(addMenuError)}>x</button>
</span></div>
: undefined
/** 输入框(首页与会话内共用;首页编辑区更高,由 CSS 的 data-variant 区分) */
const composer = (
<>
<ComposerControls key={draftKey} className={isHome ? 'wb-composer--home' : undefined} sessionId={currentId} value={draft} onChange={setDraft} attachments={entry.attachments} resources={entry.resources ?? []}
skills={(entry.skills ?? []).map(item => ({ id: item.id, name: item.name }))}
onRemoveSkill={(id) => removeCapability({ kind: 'skill', id })}
capabilityItems={capabilityCatalog.items}
selectedCapabilities={selectedCapabilities}
onSelectCapability={selectCapability}
onRemoveCapability={removeCapability}
capabilityReferences={queuedEditReference}
disabled={sending || composition.input.disabled}
branchLabelSlot={<ComposerBranchLabel sessionId={currentId} />}
contextUsageSlot={<ContextUsageRing sessionId={currentId} hasMessages={messages.length > 0} running={sending || !!running} />}
addMenuScopeKey={`${draftKey}:${hidden ? 'hidden' : 'visible'}`}
onOpenAddMenu={loadReferenceFiles}
onOpenAddMenu={() => { loadReferenceFiles(); capabilityCatalog.refresh() }}
addMenuEntries={[{ id: 'add-files', children: [{ id: 'local-files', label: '本地文件' }] },
...(availableReferenceFiles.length ? [{ id: 'reference-files' }] : []), { id: 'mode' }]}
addMenuSlots={{
@@ -309,23 +663,10 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
} }} />
}}
userContent={entry.userContent}
onUserContentChange={userContent => setDrafts(all => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '', attachments: [] }), userContent } }))}
floatingSlot={activeInteraction?.kind === 'question' ? <QuestionFloating key={activeInteraction.id} config={{ questions: activeInteraction.questions, title: activeInteraction.title, toolCallId: activeInteraction.toolCallId }}
responding={activeInteraction.status === 'responding'} error={activeInteraction.error}
labels={{ title: '需要你确认', skip: '跳过', next: '下一题', send: '发送回答', customAnswer: '输入其他回答', skipAll: '跳过全部', previousQuestion: '上一题', nextQuestion: '下一题', dismiss: '收起', notAnswered: '未回答' }}
onSelect={(_text, answer) => respond(activeInteraction.id, { kind: 'question', answer })}
onSkip={() => void respond(activeInteraction.id, { kind: 'question', answer: { questions: activeInteraction.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true }).catch(() => undefined)} />
: activeInteraction?.kind === 'approval' ? <PermissionConfirm key={activeInteraction.id} className="permission-floating"
pendingId={activeInteraction.id} toolName={activeInteraction.toolCallId} title={activeInteraction.title}
options={activeInteraction.options} status={activeInteraction.status} error={activeInteraction.error}
labels={{ confirm: '允许一次', alwaysAllow: '始终允许', reject: '拒绝', working: '提交中', approved: '已允许', rejected: '已拒绝', cancelled: '已取消', failed: '提交失败' }}
resolve={(id, decision) => respond(id, { kind: 'approval', optionId: decision.optionId })}
reject={(id) => {
const option = activeInteraction.options.find((item) => item.kind.startsWith('reject'))
return option ? respond(id, { kind: 'approval', optionId: option.optionId }) : Promise.reject(new Error('审批选项不可用'))
}} />
: undefined}
onResourcesChange={(resources) => setDrafts((all) => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '', attachments: [] }), resources } }))}
onUserContentChange={userContent => setDrafts(all => ({ ...all, [draftKey]: { ...(all[draftKey] ?? EMPTY_DRAFT), userContent } }))}
floatingSlot={floatingSurface}
queueSlot={queuePanel}
onResourcesChange={(resources) => setDrafts((all) => ({ ...all, [draftKey]: { ...(all[draftKey] ?? EMPTY_DRAFT), resources } }))}
quotes={entry.quotes ?? []}
onRemoveQuote={removeQuote}
onActivateQuote={(quote) => {
@@ -338,28 +679,40 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
}}
onAttachmentsChange={(attachments) => setDrafts((all) => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '' }), attachments } }))}
attachmentHost={{ pick: () => window.ycode.attachments.pick(), stage: ({ filename, ...input }) => window.ycode.attachments.stage({ name: filename, ...input }), remove: (id) => window.ycode.attachments.remove(id) }}
onRemoveAttachment={(id) => messages.some((message) => message.attachments?.some((item) => item.id === id)) ? Promise.resolve() : window.ycode.attachments.remove(id)}
onRemoveAttachment={(id) => entry.queueEditId || messages.some((message) => message.attachments?.some((item) => item.id === id)) ||
queueState.snapshot?.items.some(item => item.payload.attachments?.some(attachment => attachment.id === id))
? Promise.resolve() : window.ycode.attachments.remove(id)}
onReferencePath={async (path) => {
const kind = await window.ycode.resources.classify(path, currentId ?? undefined)
if (kind === 'unknown') throw new Error('无法引用该工作区资源')
}}
speech={{ transcribe: ({ wavBase64, mimeType }) => window.ycode.speech.transcribe({ audioBase64: wavBase64, mimeType }) }}
onTranscript={(text) => setDraft((current) => `${current}${current ? ' ' : ''}${text}`)}
permissionPlacement={isHome ? 'footer' : 'toolbar'}
permissionSlot={<SourcePermissionControl mode={permissionMode} disabled={sending || !!running}
permissionPlacement="toolbar"
permissionSlot={<SourcePermissionControl mode={permissionMode} disabled={sending || !!running || composition.input.disabled}
onChange={async (mode) => {
const owner = useChatStore.getState().currentSessionId
if (owner) {
const session = await window.ycode.session.updateContext(owner, { permissionMode: mode })
useChatStore.setState((state) => ({ sessions: state.sessions.map((item) => item.id === owner ? session : item) }))
if (useChatStore.getState().currentSessionId !== owner) return
setPermissionMode(mode)
try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ }
return
}
useChatStore.setState((state) => ({ draftContext: { ...state.draftContext, permissionMode: mode } }))
setPermissionMode(mode)
try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ }
}}
onError={(message) => useChatStore.setState({ error: message })} />}
onSend={submit} sending={sending || !!running} onStop={running ? cancel : undefined}
modelSlot={<SourceModelSelector models={models} selectedId={effectiveModelId} label={modelLabel} disabled={sending || !!running} darkMode={darkMode}
onSend={submit} sending={sending} onStop={running ? cancel : undefined}
modelSlot={<span title={running || sending ? '下次发送生效' : undefined}><SourceModelSelector models={models} selectedId={effectiveModelId} label={modelLabel} darkMode={darkMode}
thinkingLevel={effectiveThinkingLevel}
onThinkingLevelChange={(level) => useChatStore.getState().selectThinkingLevel(level)}
onSelect={async (id) => { await selectChatModel(id); await useSettingsStore.getState().selectModel(id) }} onManage={() => {
if (window.ycode.app.openSettings) void window.ycode.app.openSettings('model')
else onOpenSettings()
}} />}
}} /></span>}
workspaceFooter={isHome ? <SourceWorkspacePicker key={draftKey} workspaces={workspaces} selectedId={effectiveWorkspaceId}
disabled={currentId !== null || sending || !!running} darkMode={darkMode} onSelect={selectDraftWorkspace}
onCreate={createDraftWorkspace} onOpenLocalFolder={attachDraftWorkspace} /> : undefined}
@@ -377,7 +730,6 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
useChatStore.setState({ sessions })
}} onShare={() => { window.dispatchEvent(new CustomEvent('ycode:not-implemented', { detail: '云端分享' })) }}
onActionError={(error) => useChatStore.setState({ error: error instanceof Error ? error.message : String(error) })} />}
{error && <div className="msg__status msg__status--error" role="alert">{error}</div>}
{isHome ? (
<SourceHome mode={activeSegment} onModeChange={setActiveSegment} composer={composer} selectedAction={homeAction}
onActionChange={(action) => setHomeAction(action?.id ?? null)} onPromptPick={setDraft} robotSrc={homeRobot} />
@@ -402,12 +754,14 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
}}
actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: rewindEntryVisible(message) && !rewinding,
rollbackPending: rewinding,
canRegenerate: !running && !sending, isSessionActive: true, isLastAssistant: message.id === lastAssistantId,
canRegenerate: canRegenerateMessage(message), isSessionActive: true, isLastAssistant: message.id === lastAssistantId,
requestId: message.requestId, requestMessages: message.requestId ? messages.filter((item) => item.requestId === message.requestId) : undefined,
onRegenerate: regenerate,
onRevert: rewindEntryVisible(message) && !rewinding ? () => { setRewindError(null); setRewindTarget(message) } : undefined,
onFeedback: (target, value) => feedback(target.id, value) }}
editingMessageId={editingMessageId} onStartEdit={startEdit} onCancelEdit={cancelEdit} onSubmitEdit={submitEdit} />
editingMessageId={editingMessageId}
onStartEdit={message.command || queueBusy ? undefined : startEdit}
onCancelEdit={cancelEdit} onSubmitEdit={submitEdit} />
))}
</div></div>
</div>
@@ -415,7 +769,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
<div className="composer">
<div className="composer__area">
<div className="composer__input-stack">
<div className="composer__input-stack" ref={composerStackRef}>
{composer}
</div>
</div>
@@ -462,6 +816,19 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
</div>
</section>
</div>}
{queueEditTarget && <TaskConfirmDialog kind="queue-edit" running={false}
onConfirm={async () => {
const resolve = queueEditDecision.current
queueEditDecision.current = null
setQueueEditTarget(null)
resolve?.(true)
}}
onClose={() => {
const resolve = queueEditDecision.current
queueEditDecision.current = null
setQueueEditTarget(null)
resolve?.(false)
}} />}
</section>
)
}
+3 -1
View File
@@ -24,6 +24,7 @@ import WorkspaceDialog from './WorkspaceDialog'
import AppDiscoverPopover, { type DiscoverCapabilityTab } from './AppDiscoverPopover'
import ProjectMenu from '../projects/ProjectMenu'
import { AccountMenu } from '../workbuddy/user-menu/AccountMenu'
import { UpdateSidebarCard } from './updates/UpdateSidebarCard'
import ArchivedTasksDialog from './ArchivedTasksDialog'
import { TaskContextMenu } from './task-menu'
import { TaskConfirmDialog } from './task-confirm-dialog'
@@ -542,7 +543,8 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
{/* ---- 底部账号区(原版 .conversation-list-footer:57 高、padding 8 12 8 8、border-top 1px #e6e6e6)----
原版该行只有「账号 / 通知 / 小程序」三个入口;收起侧栏位于顶栏 leading 区,不在此处。
数据为 mock(见 TODO 09)。 ---- */}
<div className="sidebar__footer conversation-list-footer">
<div className="sidebar__footer conversation-list-footer sidebar__footer--update-host">
<UpdateSidebarCard />
<div className="user-menu user-menu-trigger-wrapper">
<button
className="user-menu-trigger user-menu-trigger--workbuddy"
@@ -36,7 +36,6 @@ export const SHORTCUT_ROWS: ReadonlyArray<ShortcutRow> = [
{ id: 'send-message', label: '发送消息', keys: 'Enter', editable: true, supported: true, action: '输入框内 Enter 发送' },
{ id: 'newline', label: '输入时换行', keys: 'Shift+Enter', editable: true, supported: true, action: '输入框内 Shift+Enter 换行' },
{ id: 'new-conversation', label: '新建对话', keys: 'Ctrl+N', editable: true, supported: true, action: '新建任务并切到对话页' },
{ id: 'stop-generation', label: '停止生成', keys: 'Esc', editable: true, supported: true, action: '生成中按 Esc 停止本次运行' },
{ id: 'previous-task', label: '上一个任务', keys: 'Ctrl+[', editable: true, supported: false },
{ id: 'next-task', label: '下一个任务', keys: 'Ctrl+]', editable: true, supported: false },
{ id: 'locate-task', label: '定位当前任务', keys: 'Ctrl+Shift+E', editable: true, supported: false },
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
import { IconClose } from './icons'
import './task-confirm-dialog.css'
export type TaskConfirmKind = 'archive' | 'delete'
export type TaskConfirmKind = 'archive' | 'delete' | 'queue-edit'
interface Props {
kind: TaskConfirmKind
@@ -19,6 +19,13 @@ interface Copy {
}
function copyFor(kind: TaskConfirmKind, running: boolean): Copy {
if (kind === 'queue-edit') {
return {
title: '替换当前草稿?',
description: '输入框已有草稿。替换后会把这条追加消息完整取回到输入框;取消时草稿和队列消息都会保留。',
confirm: '替换并编辑'
}
}
if (kind === 'archive') {
return {
title: '归档任务',
@@ -0,0 +1,191 @@
import { useEffect, useState } from 'react'
import { IconChevronDown, IconClose } from '../icons'
import { IconAccountUpdate } from '../../workbuddy/user-menu/account-menu-icons'
import {
dismissUpdate,
openUpdateDownloadPage,
openUpdateLog,
runPrimaryUpdateAction,
useUpdatesUi
} from './update-ui-store'
import {
formatUpdateProgress,
formatUpdateVersion,
hasUnfinishedUpdate,
isUpdateVersionDismissed,
primaryUpdateAction,
shouldShowSidebarUpdate,
updateCardKey,
updateStatusText
} from './update-ui-model'
import './updates-ui.css'
function cardTitle(state: ReturnType<typeof useUpdatesUi>['state'], action: ReturnType<typeof primaryUpdateAction>, version: string, unfinished: boolean): string {
if (state.status === 'error') return '检查更新失败'
if (state.status === 'unsupported') return '自动更新不可用'
if (action.kind === 'download') return version ? `发现新版本 ${version}` : '发现新版本'
if (action.kind === 'install') return version ? `新版本 ${version} 已就绪` : '新版本已就绪'
if (state.status === 'downloading') return version ? `正在下载 ${version}` : '正在下载新版本'
if (state.status === 'installing') return '正在安装新版本'
if (unfinished) return '上次升级未完成'
if (isUpdateVersionDismissed(state)) return version ? `已忽略新版本 ${version}` : '已忽略此版本'
return updateStatusText(state) || '软件更新'
}
export function UpdateSidebarCard(): React.JSX.Element | null {
const updates = useUpdatesUi()
const { state } = updates
const [collapsedKey, setCollapsedKey] = useState<string | null>(null)
const [revealedKey, setRevealedKey] = useState<string | null>(null)
const visible = shouldShowSidebarUpdate(state, updates.explicit)
const key = updateCardKey(state)
const version = formatUpdateVersion(state.version)
const progress = formatUpdateProgress(state.progress)
const action = primaryUpdateAction(state)
const remoteDismissed = isUpdateVersionDismissed(state)
const unfinished = hasUnfinishedUpdate(state)
const collapsed = collapsedKey === key || (remoteDismissed && revealedKey !== key)
const busy = updates.busy !== null || action.disabled
useEffect(() => {
if (visible) return
setCollapsedKey(null)
setRevealedKey(null)
}, [visible, key])
if (!visible) return null
const collapse = (dismiss: boolean): void => {
setCollapsedKey(key)
setRevealedKey(null)
if (dismiss && state.version) void dismissUpdate()
}
if (collapsed) {
return (
<div className="update-sidebar-card-host update-sidebar-card-host--collapsed">
<button
type="button"
className="update-sidebar-card-collapsed"
data-update-reveal
title={version ? `查看新版本 ${version}` : '查看更新'}
aria-label={version ? `查看新版本 ${version}` : '查看更新'}
onClick={() => {
setRevealedKey(key)
setCollapsedKey(null)
}}
>
<IconAccountUpdate />
<span className="update-sidebar-card-collapsed__dot" aria-hidden="true" />
</button>
</div>
)
}
const downloadProgress = state.status === 'downloading'
const showManual = state.status === 'error' || state.status === 'unsupported' || unfinished
const title = cardTitle(state, action, version, unfinished)
|| (state.status === 'installing' ? '正在安装新版本' : updateStatusText(state) || '软件更新')
const description = state.status === 'ready'
? state.notes?.trim() || '重启应用后完成升级'
: updateStatusText(state) || ''
return (
<div className="update-sidebar-card-host">
<section className="update-sidebar-card" data-status={state.status} data-unfinished={unfinished} aria-label="软件更新">
<div className="update-sidebar-card__header">
<span className="update-sidebar-card__icon" aria-hidden="true"><IconAccountUpdate /></span>
<div className="update-sidebar-card__copy">
<div className="update-sidebar-card__title">{title}</div>
{description ? <div className="update-sidebar-card__description" title={description}>{description}</div> : null}
</div>
<button
type="button"
className="update-sidebar-card__icon-button"
data-update-collapse
title="收起更新提示"
aria-label="收起更新提示"
disabled={updates.busy === 'install'}
onClick={() => collapse(false)}
>
<IconChevronDown />
</button>
<button
type="button"
className="update-sidebar-card__icon-button"
data-update-dismiss
title="关闭此版本更新提示"
aria-label="关闭此版本更新提示"
disabled={updates.busy === 'install'}
onClick={() => collapse(true)}
>
<IconClose />
</button>
</div>
{state.status === 'downloading' ? (
<div
className="update-sidebar-card__progress"
role="progressbar"
aria-label="更新下载进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress ?? undefined}
>
<span
className={progress === null ? 'update-sidebar-card__progress-fill update-sidebar-card__progress-fill--indeterminate' : 'update-sidebar-card__progress-fill'}
style={progress === null ? undefined : { width: `${progress}%` }}
/>
</div>
) : null}
<div className="update-sidebar-card__actions">
{action.kind !== 'none' ? (
<button
type="button"
className="update-sidebar-card__primary"
data-update-action={action.kind}
disabled={busy || updates.busy !== null}
aria-busy={updates.busy !== null || downloadProgress}
onClick={() => void runPrimaryUpdateAction()}
>
{downloadProgress && progress !== null ? `下载中 ${progress}%` : action.label}
</button>
) : downloadProgress ? (
<button type="button" className="update-sidebar-card__primary" disabled aria-busy="true">
{progress === null ? '下载中' : `下载中 ${progress}%`}
</button>
) : (
<button type="button" className="update-sidebar-card__primary" disabled aria-busy="true">
{action.label}
</button>
)}
{showManual ? (
<>
<button
type="button"
className="update-sidebar-card__link"
data-update-manual
disabled={updates.busy !== null}
onClick={() => void openUpdateDownloadPage()}
>
手动下载
</button>
<button
type="button"
className="update-sidebar-card__link"
data-update-log
disabled={updates.busy !== null}
onClick={() => void openUpdateLog()}
>
更新日志
</button>
</>
) : null}
</div>
</section>
</div>
)
}
export default UpdateSidebarCard
@@ -0,0 +1,105 @@
import type { UpdateState } from '@shared/updates'
export type UpdatePrimaryKind = 'check' | 'download' | 'install' | 'manual' | 'none'
export interface UpdatePrimaryAction {
kind: UpdatePrimaryKind
label: string
disabled: boolean
}
const SIDEBAR_STATUSES = new Set<UpdateState['status']>([
'available',
'downloading',
'ready',
'installing'
])
export function primaryUpdateAction(state: UpdateState): UpdatePrimaryAction {
const unfinished = formatUpdateVersion(state.unfinishedVersion)
switch (state.status) {
case 'available':
return { kind: 'download', label: '下载更新', disabled: false }
case 'ready':
return { kind: 'install', label: '重启升级', disabled: false }
case 'checking':
return { kind: 'none', label: '检查中', disabled: true }
case 'downloading':
return { kind: 'none', label: '下载中', disabled: true }
case 'installing':
return { kind: 'none', label: '正在升级', disabled: true }
case 'unsupported':
return { kind: 'manual', label: '手动下载', disabled: false }
case 'error':
return { kind: 'check', label: '重试', disabled: false }
case 'idle':
case 'up-to-date':
return { kind: 'check', label: unfinished ? '重试更新' : '检查更新', disabled: false }
}
}
export function formatUpdateVersion(version?: string): string {
const value = version?.trim()
if (!value) return ''
return /^v/i.test(value) ? value : `v${value}`
}
export function formatUpdateProgress(progress?: number): number | null {
if (typeof progress !== 'number' || !Number.isFinite(progress)) return null
return Math.min(100, Math.max(0, Math.round(progress)))
}
export function updateStatusText(state: UpdateState): string | null {
const version = formatUpdateVersion(state.version)
const unfinished = formatUpdateVersion(state.unfinishedVersion)
const progress = formatUpdateProgress(state.progress)
switch (state.status) {
case 'idle':
if (isUpdateVersionDismissed(state)) {
return version ? `已忽略新版本 ${version},可随时重新检查` : '已忽略此版本更新'
}
return unfinished
? `上次升级至 ${unfinished} 未完成,请重试或查看更新日志`
: null
case 'checking':
return '正在检查更新...'
case 'up-to-date':
return unfinished
? `上次升级至 ${unfinished} 未完成,请重试或查看更新日志`
: '已是最新版本'
case 'available':
return version ? `发现新版本 ${version}` : '发现新版本'
case 'downloading':
return progress === null ? '正在下载新版本...' : `正在下载新版本 ${progress}%`
case 'ready':
return version ? `新版本 ${version} 已就绪` : '新版本已就绪'
case 'installing':
return '正在安装新版本'
case 'error':
return state.error?.trim() || '检查更新失败,请稍后再试'
case 'unsupported':
return state.error?.trim() || '当前环境不支持自动更新'
}
}
export function shouldShowSidebarUpdate(state: UpdateState, explicit = false): boolean {
return SIDEBAR_STATUSES.has(state.status)
|| (state.status === 'idle' && isUpdateVersionDismissed(state))
|| Boolean(state.unfinishedVersion?.trim())
|| (state.status === 'error' && explicit)
|| (state.status === 'unsupported' && explicit)
}
export function updateCardKey(state: UpdateState): string {
if (state.version?.trim()) return state.version.trim()
return `status:${state.status}`
}
export function isUpdateVersionDismissed(state: UpdateState): boolean {
return Boolean(state.version?.trim() && state.dismissedVersion === state.version)
}
export function hasUnfinishedUpdate(state: UpdateState): boolean {
const unfinished = state.unfinishedVersion?.trim()
return Boolean(unfinished && unfinished !== state.currentVersion)
}
@@ -0,0 +1,243 @@
import { useEffect, useSyncExternalStore } from 'react'
import type { UpdateState, UpdatesApi } from '@shared/updates'
import { primaryUpdateAction } from './update-ui-model'
export type UpdateOperation = 'check' | 'download' | 'install' | 'dismiss' | 'manual' | 'log'
export interface UpdateUiSnapshot {
state: UpdateState
busy: UpdateOperation | null
initialized: boolean
/** True only for the terminal result of a user-initiated check. */
explicit: boolean
}
const INITIAL_STATE: UpdateState = {
status: 'idle',
currentVersion: ''
}
let snapshot: UpdateUiSnapshot = {
state: INITIAL_STATE,
busy: null,
initialized: false,
explicit: false
}
const listeners = new Set<() => void>()
let bridgeStarted = false
let stateListener: (() => void) | undefined
let explicitCheckPending = false
const TERMINAL_STATUSES = new Set<UpdateState['status']>([
'up-to-date',
'available',
'downloading',
'ready',
'error',
'unsupported'
])
function emit(): void {
for (const listener of listeners) listener()
}
function setSnapshot(next: UpdateUiSnapshot): void {
snapshot = next
emit()
}
function setBridgeState(state: UpdateState): void {
if (!state || typeof state.status !== 'string') return
let explicit = snapshot.explicit
if (typeof state.lastExplicit === 'boolean') {
explicit = state.lastExplicit
if (state.status !== 'checking') explicitCheckPending = false
} else {
if (state.status === 'checking') {
if (!explicitCheckPending) explicit = false
} else if (TERMINAL_STATUSES.has(state.status) && explicitCheckPending) {
explicitCheckPending = false
explicit = true
}
}
setSnapshot({ ...snapshot, state, initialized: true, explicit })
}
function api(): UpdatesApi | undefined {
if (typeof window === 'undefined') return undefined
return window.ycode?.updates
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function fail(error: unknown): void {
const explicit = snapshot.explicit || explicitCheckPending
explicitCheckPending = false
setSnapshot({
...snapshot,
initialized: true,
explicit,
state: {
...snapshot.state,
status: 'error',
error: errorMessage(error)
}
})
}
function startBridge(): void {
if (bridgeStarted) return
bridgeStarted = true
const updates = api()
if (!updates) {
setSnapshot({
...snapshot,
initialized: true,
state: {
...snapshot.state,
status: 'unsupported',
error: '当前版本尚未提供更新接口'
}
})
return
}
try {
stateListener = updates.onState(setBridgeState)
} catch (error) {
fail(error)
return
}
void updates.getState()
.then(setBridgeState)
.catch(fail)
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
function getSnapshot(): UpdateUiSnapshot {
return snapshot
}
function getServerSnapshot(): UpdateUiSnapshot {
return snapshot
}
async function invoke(
operation: UpdateOperation,
optimistic: UpdateState | null,
run: (updates: UpdatesApi) => Promise<UpdateState>
): Promise<UpdateUiSnapshot> {
const updates = api()
if (!updates || snapshot.busy) return snapshot
setSnapshot({
...snapshot,
busy: operation,
state: optimistic ?? snapshot.state
})
try {
const next = await run(updates)
if (next?.status) setBridgeState(next)
} catch (error) {
fail(error)
} finally {
setSnapshot({ ...snapshot, busy: null })
}
return snapshot
}
export function useUpdatesUi(): UpdateUiSnapshot {
useEffect(() => {
startBridge()
}, [])
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
export function checkForUpdates(): Promise<UpdateUiSnapshot> {
explicitCheckPending = true
return invoke('check', {
...snapshot.state,
status: 'checking',
error: undefined
}, updates => updates.check())
}
export function downloadUpdate(): Promise<UpdateUiSnapshot> {
return invoke('download', {
...snapshot.state,
status: 'downloading',
error: undefined
}, updates => updates.download())
}
export function installUpdate(): Promise<UpdateUiSnapshot> {
return invoke('install', {
...snapshot.state,
status: 'installing',
error: undefined
}, updates => updates.install())
}
export function dismissUpdate(): Promise<UpdateUiSnapshot> {
return invoke('dismiss', null, updates => updates.dismiss())
}
export async function openUpdateDownloadPage(): Promise<void> {
const updates = api()
if (!updates || snapshot.busy) return
setSnapshot({ ...snapshot, busy: 'manual' })
try {
await updates.openDownloadPage()
} catch (error) {
fail(error)
} finally {
setSnapshot({ ...snapshot, busy: null })
}
}
export async function openUpdateLog(): Promise<void> {
const updates = api()
if (!updates || snapshot.busy) return
setSnapshot({ ...snapshot, busy: 'log' })
try {
await updates.openLog()
} catch (error) {
fail(error)
} finally {
setSnapshot({ ...snapshot, busy: null })
}
}
export function runPrimaryUpdateAction(): Promise<void> {
const action = primaryUpdateAction(snapshot.state)
switch (action.kind) {
case 'check':
return checkForUpdates().then(() => undefined)
case 'download':
return downloadUpdate().then(() => undefined)
case 'install':
return installUpdate().then(() => undefined)
case 'manual':
return openUpdateDownloadPage()
case 'none':
return Promise.resolve()
}
}
export function resetStateListenerForTests(): void {
stateListener?.()
stateListener = undefined
bridgeStarted = false
explicitCheckPending = false
setSnapshot({ state: INITIAL_STATE, busy: null, initialized: false, explicit: false })
}
@@ -0,0 +1,286 @@
.sidebar__footer--update-host {
flex-wrap: wrap;
}
.update-sidebar-card-host {
box-sizing: border-box;
flex: 0 0 100%;
width: 100%;
margin-bottom: var(--yc-space-3);
}
.update-sidebar-card-host--collapsed {
display: flex;
justify-content: flex-start;
margin-bottom: var(--yc-space-2);
}
.update-sidebar-card {
box-sizing: border-box;
width: 100%;
padding: var(--yc-space-3);
color: var(--yc-text-primary);
background: var(--yc-bg-primary);
border: var(--yc-border-width) solid var(--yc-border-secondary);
border-radius: var(--yc-radius-xl);
}
.update-sidebar-card[data-status='error'] .update-sidebar-card__icon {
color: var(--yc-status-error);
}
.update-sidebar-card[data-unfinished='true'] .update-sidebar-card__icon {
color: var(--yc-status-warning);
}
.update-sidebar-card__header {
display: flex;
align-items: flex-start;
gap: var(--yc-space-3);
}
.update-sidebar-card__icon {
display: inline-flex;
flex: none;
color: var(--yc-brand-8);
}
.update-sidebar-card__icon svg {
width: var(--yc-size-4);
height: var(--yc-size-4);
}
.update-sidebar-card__copy {
flex: 1;
min-width: 0;
}
.update-sidebar-card__title {
overflow: hidden;
color: var(--yc-text-primary);
font-size: var(--yc-font-size-4);
font-weight: var(--yc-font-weight-500);
line-height: var(--yc-line-height-4-2);
text-overflow: ellipsis;
white-space: nowrap;
}
.update-sidebar-card__description {
display: -webkit-box;
overflow: hidden;
margin-top: var(--yc-space-1);
color: var(--yc-text-tertiary);
font-size: var(--yc-font-size-3);
line-height: var(--yc-line-height-4-2);
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.update-sidebar-card__icon-button,
.update-sidebar-card__link,
.update-sidebar-card__primary,
.update-sidebar-card-collapsed {
font: inherit;
cursor: pointer;
appearance: none;
}
.update-sidebar-card__icon-button {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: var(--yc-control-sm);
height: var(--yc-control-sm);
padding: 0;
color: var(--yc-text-tertiary);
background: transparent;
border: 0;
border-radius: var(--yc-radius-md);
}
.update-sidebar-card__icon-button:hover:not(:disabled),
.update-sidebar-card__icon-button:focus-visible {
color: var(--yc-text-primary);
background: var(--yc-bg-hover);
}
.update-sidebar-card__icon-button:focus-visible,
.update-sidebar-card__link:focus-visible,
.update-sidebar-card__primary:focus-visible,
.update-sidebar-card-collapsed:focus-visible {
outline: var(--yc-border-width) solid var(--yc-brand-8);
outline-offset: var(--yc-space-1);
}
.update-sidebar-card__icon-button svg {
width: var(--yc-size-4);
height: var(--yc-size-4);
}
.update-sidebar-card__progress {
overflow: hidden;
height: var(--yc-space-1);
margin-top: var(--yc-space-3);
background: var(--yc-bg-tertiary);
border-radius: var(--yc-radius-full);
}
.update-sidebar-card__progress-fill {
display: block;
height: 100%;
background: var(--yc-brand-8);
border-radius: inherit;
transition: width var(--yc-motion-fast) var(--yc-motion-easing);
}
.update-sidebar-card__progress-fill--indeterminate {
width: 40%;
animation: update-sidebar-progress 1.2s var(--yc-motion-easing) infinite;
}
.update-sidebar-card__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--yc-space-2);
margin-top: var(--yc-space-3);
}
.update-sidebar-card__primary {
min-height: var(--yc-size-7);
padding: 0 var(--yc-space-4);
color: var(--yc-white-100);
font-size: var(--yc-font-size-3);
font-weight: var(--yc-font-weight-500);
line-height: var(--yc-line-height-5);
background: var(--yc-brand-8);
border: 0;
border-radius: var(--yc-radius-md);
}
.update-sidebar-card__primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--yc-brand-8) 88%, var(--yc-black-100));
}
.update-sidebar-card__primary:disabled {
cursor: default;
color: var(--yc-text-disabled);
background: var(--yc-bg-tertiary);
}
.update-sidebar-card__link {
min-height: var(--yc-control-sm);
padding: 0 var(--yc-space-2);
color: var(--yc-text-secondary);
font-size: var(--yc-font-size-3);
line-height: var(--yc-line-height-5);
background: transparent;
border: 0;
border-radius: var(--yc-radius-sm);
}
.update-sidebar-card__link:hover:not(:disabled) {
color: var(--yc-text-primary);
background: var(--yc-bg-hover);
}
.update-sidebar-card__link:disabled {
cursor: default;
color: var(--yc-text-disabled);
}
.update-sidebar-card-collapsed {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--yc-control-ms);
height: var(--yc-control-ms);
padding: 0;
color: var(--yc-brand-8);
background: var(--yc-bg-primary);
border: var(--yc-border-width) solid var(--yc-border-secondary);
border-radius: var(--yc-radius-full);
}
.update-sidebar-card-collapsed:hover {
background: var(--yc-bg-hover);
}
.update-sidebar-card-collapsed svg {
width: var(--yc-size-4);
height: var(--yc-size-4);
}
.update-sidebar-card-collapsed__dot {
position: absolute;
top: calc(-1 * var(--yc-space-1));
right: calc(-1 * var(--yc-space-1));
width: var(--yc-space-2);
height: var(--yc-space-2);
background: var(--yc-status-error);
border: var(--yc-border-width) solid var(--yc-bg-primary);
border-radius: var(--yc-radius-full);
}
.update-ui-feedback {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--yc-space-2);
color: var(--yc-text-secondary);
font-size: var(--yc-font-size-3);
line-height: var(--yc-line-height-4-2);
}
.update-ui-feedback--error {
color: var(--yc-status-error);
}
.update-ui-feedback--success {
color: var(--yc-status-success);
}
.update-ui-feedback__text {
min-width: 0;
overflow-wrap: anywhere;
}
.update-ui-feedback__link {
padding: 0;
color: inherit;
font: inherit;
line-height: inherit;
text-decoration: underline;
text-underline-offset: var(--yc-space-1);
cursor: pointer;
background: transparent;
border: 0;
}
.update-ui-feedback__link:disabled {
cursor: default;
color: var(--yc-text-disabled);
}
@keyframes update-sidebar-progress {
from {
transform: translateX(-100%);
}
to {
transform: translateX(250%);
}
}
@media (prefers-reduced-motion: reduce) {
.update-sidebar-card__progress-fill {
transition: none;
}
.update-sidebar-card__progress-fill--indeterminate {
width: 100%;
animation: none;
}
}
+65 -39
View File
@@ -30,7 +30,8 @@ function optimisticStartMessages(draftOwner: string, text: string, options: Omit
const startedAt = Date.now()
return [
{ id: `local-user-${draftOwner}`, role: 'user', content: text, createdAt: startedAt, status: 'done',
attachments: options.attachments, resources: options.resources, quotes: options.quotes, userContent: options.userContent },
attachments: options.attachments, resources: options.resources, quotes: options.quotes, userContent: options.userContent,
skills: options.skillIds?.map(id => ({ id, name: id, uri: '' })), mcpServerIds: options.mcpServerIds, command: options.command },
{ id: `local-assistant-${draftOwner}`, role: 'assistant', content: '', tools: [], parts: [],
createdAt: startedAt, turnStartedAt: startedAt, status: 'streaming', prepareStage: PENDING_START_STAGE }
]
@@ -116,10 +117,12 @@ function contextUsageState(
// Events may beat their IPC response. Keep their session ownership throughout a switch.
const pendingEvents = new Map<string, ChatEvent[]>()
const pendingSnapshots = new Map<string, ActiveChatRun[]>()
const sessionHydrations = new Map<string, SessionHydration>()
let selectionVersion = 0
let expertSelectionVersion = 0
let expertWrites: Promise<unknown> = Promise.resolve()
let requestSettingsWrites: Promise<unknown> = Promise.resolve()
const activatingExperts = new Map<string, Promise<ExpertActivation>>()
const cancelRequestedRuns = new Set<string>()
function prepareExpert(id: string): Promise<ExpertActivation> {
@@ -140,6 +143,16 @@ function sortSessions(sessions: SessionMeta[]): SessionMeta[] {
}
export const useChatStore = create<ChatState>((set, get) => {
const saveRequestSettings = async (patch: Pick<SessionContext, 'modelId' | 'thinkingLevel'>): Promise<void> => {
const owner = get().currentSessionId
if (!owner) { set(state => ({ draftContext: { ...state.draftContext, ...patch } })); return }
const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(owner, patch))
requestSettingsWrites = write.catch(() => {})
await write
set(state => ({ sessions: state.sessions.map(session => session.id === owner ? { ...session, ...patch } : session),
...contextUsageState(state.sessionUsage, state.sessionContextWindow, owner,
lastContextUsage(state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? [])) }))
}
const streamCommitter = new ChatStreamCommitter((key, events) => {
const first = events[0]
if (!first) return
@@ -250,7 +263,7 @@ export const useChatStore = create<ChatState>((set, get) => {
const active = typeof window.ycode.chat.activeRun === 'function' ? await window.ycode.chat.activeRun(id) : null
if (version !== selectionVersion || get().currentSessionId !== id) return
const latestStarted = hydration.started.at(-1)
let snapshot = latestStarted && (!active || latestStarted.runId !== active.runId) ? latestStarted : active
let snapshot = latestStarted && (!active || latestStarted.runId !== active.runId || latestStarted.sequence > active.sequence) ? latestStarted : active
if (!snapshot) {
// activeRun being null means any finish has already crossed the persistence
// barrier. Read only after that check so a late terminal cannot be missed.
@@ -289,19 +302,7 @@ export const useChatStore = create<ChatState>((set, get) => {
draftContext: context, draftId: state.draftId + 1 }))
},
async selectModel(id) {
const owner = get().currentSessionId
if (!owner) { set((state) => ({ draftContext: { ...state.draftContext, modelId: id } })); return }
if (get().running || get().sending) throw new Error('请先停止当前运行')
const session = await window.ycode.session.updateContext(owner, { modelId: id })
set((state) => {
const messages = state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? []
// Existing history keeps the denominator frozen into each assistant sample.
// The next real usage event replaces this sample only when it arrives.
return {
sessions: state.sessions.map((item) => item.id === owner ? session : item),
...contextUsageState(state.sessionUsage, state.sessionContextWindow, owner, lastContextUsage(messages))
}
})
await saveRequestSettings({ modelId: id })
},
async selectExpert(id) {
await get().summonExpert(id)
@@ -327,22 +328,7 @@ export const useChatStore = create<ChatState>((set, get) => {
if (isCurrent() && agentMode !== 'craft' && typeof window.dispatchEvent === 'function') window.dispatchEvent(new CustomEvent('ycode:expert-input', { detail: { owner, clear: true } }))
},
async selectThinkingLevel(thinkingLevel) {
const before = get(), owner = before.currentSessionId, draftOwner = before.draftId
if (before.running || before.sending) throw new Error('请先停止当前运行')
const version = ++expertSelectionVersion
const isCurrent = (): boolean => version === expertSelectionVersion && get().currentSessionId === owner && get().draftId === draftOwner
if (!owner) {
set(state => ({ draftContext: { ...state.draftContext, thinkingLevel } }))
return
}
const write = expertWrites.catch(() => {}).then(async () => {
if (!isCurrent()) throw new Error('任务或思考强度选择已切换')
if (get().running || get().sending) throw new Error('请先停止当前运行')
const session = await window.ycode.session.updateContext(owner, { thinkingLevel })
set(state => ({ sessions: state.sessions.map(item => item.id === owner ? session : item) }))
})
expertWrites = write
await write
await saveRequestSettings({ thinkingLevel })
},
async summonExpert(id, options = {}) {
const before = get(), owner = before.currentSessionId, draftOwner = before.draftId
@@ -386,7 +372,19 @@ export const useChatStore = create<ChatState>((set, get) => {
const previousSessionId = get().currentSessionId
if (previousSessionId) streamCommitter.flushSession(previousSessionId)
const version = ++selectionVersion
const created = await window.ycode.session.create(undefined, context)
let created = await window.ycode.session.create(undefined, context)
if (version === selectionVersion && get().currentSessionId === null) {
const latest = get().draftContext
const patch: SessionContext = {}
if (latest.modelId !== context?.modelId && latest.modelId !== undefined) patch.modelId = latest.modelId
if (latest.thinkingLevel !== context?.thinkingLevel && latest.thinkingLevel !== undefined) patch.thinkingLevel = latest.thinkingLevel
if (Object.keys(patch).length) {
const id = created.id
const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(id, patch))
requestSettingsWrites = write
created = { ...created, ...patch }
}
}
void window.ycode.chat.setSessionVisible(created.id)
set((state) => ({
sessions: [created, ...state.sessions],
@@ -452,14 +450,15 @@ export const useChatStore = create<ChatState>((set, get) => {
}
},
async send(text, options = {}) {
const trimmed = text.trim()
const trimmed = (text.trim() ? text : (options.userContent ?? []).flatMap(item => item.type === 'text' ? [item.text] : []).join('')).trim()
if (get().loading) throw new Error('会话正在加载,请稍后发送')
if (options.userContent) options = { ...options, userContent: trimUserContent(options.userContent) }
if ((!trimmed && !options.attachments?.length && !options.quotes?.length && !options.resources?.length && !options.skillIds?.length) || get().loading) return
if (!trimmed && !options.attachments?.length && !options.quotes?.length && !options.resources?.length && !options.skillIds?.length && !options.mcpServerIds?.length && !options.command) return
let sessionId = get().currentSessionId
let createdSessionId: string | null = null
let optimisticStart: ChatMessage[] | null = null
if (!sessionId) {
if (get().sending) return
if (get().sending) throw new Error('任务正在创建,请稍后发送')
// 原版在新任务发送后立即切到对话页:先渲染乐观的「用户消息 + 助手占位」,
// 等主进程建好会话与回合(可能包含运行环境启动)后再用真实消息替换(TODO 08)。
const draftOwner = `new-${get().draftId}`
@@ -477,12 +476,24 @@ export const useChatStore = create<ChatState>((set, get) => {
throw error
}
}
if (!sessionId || get().sendingSessions.includes(sessionId) || currentRun(get().runs, sessionId)) return
if (sessionId && currentRun(get().runs, sessionId)) {
if (options.action || options.command) throw new Error('运行期间不能重新发送或压缩上下文')
await window.ycode.chat.queue.enqueue({ ...options, sessionId, text: trimmed })
return
}
if (!sessionId || get().sendingSessions.includes(sessionId)) throw new Error('消息正在提交,请稍后重试')
const queue = window.ycode.chat.queue ? await window.ycode.chat.queue.get(sessionId) : undefined
if (queue && (queue.items.length || queue.editingDraft && (options.command || queue.editingDraft.id !== options.queueEditId))) {
if (options.action || options.command) throw new Error('请先处理待发送队列')
await window.ycode.chat.queue.enqueue({ ...options, sessionId, text: trimmed })
return
}
const owner = sessionId
pendingEvents.set(owner, [])
set((state) => ({ sendingSessions: [...state.sendingSessions, owner],
...(state.currentSessionId === owner ? { sending: true, error: null } : {}) }))
try {
await requestSettingsWrites
const result = await window.ycode.chat.send({ ...options, sessionId: owner, text: trimmed })
const placeholder: ChatMessage = { id: result.assistantMessageId, requestId: result.runId, role: 'assistant',
content: '', tools: [], parts: [], modelId: result.modelId, modelName: result.modelName,
@@ -500,12 +511,22 @@ export const useChatStore = create<ChatState>((set, get) => {
sessions: state.sessions.map((session) => session.id === owner ? { ...session, updatedAt: Date.now() } : session) }
})
const earlyEvents = pendingEvents.get(owner) ?? []
const earlySnapshots = pendingSnapshots.get(owner) ?? []
pendingEvents.delete(owner)
earlyEvents.filter((event) => event.runId === result.runId).forEach((event) => get().applyEvent(event))
pendingSnapshots.delete(owner)
const buffered = [
...earlyEvents.filter(event => event.runId === result.runId).map(event => ({ sequence: event.sequence ?? 0, event })),
...earlySnapshots.filter(snapshot => snapshot.runId === result.runId).map(snapshot => ({ sequence: snapshot.sequence, snapshot }))
].sort((left, right) => left.sequence - right.sequence)
for (const item of buffered) {
if ('event' in item) get().applyEvent(item.event)
else get().observeRun(item.snapshot)
}
void window.ycode.session.list().then((sessions) => set({ sessions })).catch(() => undefined)
} catch (error) {
const bufferedEvents = pendingEvents.get(owner) ?? []
pendingEvents.delete(owner)
pendingSnapshots.delete(owner)
const failureMessage = errorText(error)
if (createdSessionId === owner) {
let backendSessions: SessionMeta[] | null = null
@@ -664,12 +685,17 @@ export const useChatStore = create<ChatState>((set, get) => {
const knownSequence = state.runSequences[snapshot.runId]
if (knownSequence !== undefined && snapshot.sequence <= knownSequence) return
const hydration = sessionHydrations.get(snapshot.sessionId)
if (state.sendingSessions.includes(snapshot.sessionId) || pendingEvents.has(snapshot.sessionId)) return
if (state.sendingSessions.includes(snapshot.sessionId) || pendingEvents.has(snapshot.sessionId)) {
const buffered = pendingSnapshots.get(snapshot.sessionId) ?? []
buffered.push(snapshot)
pendingSnapshots.set(snapshot.sessionId, buffered)
return
}
if (hydration) {
hydration.started.push(snapshot)
return
}
if (state.runs[snapshot.runId]) return
if (state.runs[snapshot.runId]?.assistantId === snapshot.assistantMessageId && !snapshot.segmentBoundary) return
adoptRunSnapshot(snapshot)
},
applyEvent(event) {
@@ -0,0 +1,262 @@
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import './capability-picker.css'
import type { UserContentBlock } from '@shared/conversation'
export interface CapabilitySelectionIntent {
source: 'slash' | 'menu'
text?: string
userContent?: UserContentBlock[]
}
export type CapabilityKind = 'skill' | 'mcp' | 'command'
export interface CapabilityPickerItem {
id: string
name: string
description?: string
kind: CapabilityKind
}
export interface CapabilityPickerSelection {
id: string
kind: CapabilityKind
}
export interface CapabilityPickerProps {
items: readonly CapabilityPickerItem[]
selected: readonly CapabilityPickerSelection[]
onSelect(item: CapabilityPickerItem): void
onClose(): void
/** Externally seeded text; editing remains local to the picker. */
query?: string
}
export interface CapabilitySlashTrigger {
start: number
end: number
query: string
}
export type CapabilityPickerKeyAction =
| { type: 'none' }
| { type: 'move'; index: number }
| { type: 'select'; index: number }
| { type: 'close' }
const TYPE_LABELS: Record<CapabilityKind, string> = {
skill: '技能',
mcp: 'MCP',
command: '命令'
}
export const capabilitySelectionKey = (value: CapabilityPickerSelection): string =>
`${value.kind}:${value.id}`
/** Stable registration order; every whitespace-separated term must match. */
export function filterCapabilityItems(
items: readonly CapabilityPickerItem[],
query: string | undefined
): CapabilityPickerItem[] {
const terms = (query ?? '').trim().replace(/^\//, '').toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (!terms.length) return [...items]
return items.filter(item => {
const haystack = [item.name, item.id, item.description ?? '', TYPE_LABELS[item.kind]]
.join('\n')
.toLocaleLowerCase()
return terms.every(term => haystack.includes(term))
})
}
/**
* Detect a slash command only at the start or after whitespace. Known items
* disambiguate a leading absolute path such as `/Users/me` from `/compact`.
*/
export function detectCapabilitySlashTrigger(
text: string,
caret: number,
knownItems: readonly Pick<CapabilityPickerItem, 'id' | 'name'>[]
): CapabilitySlashTrigger | null {
const end = Math.max(0, Math.min(Number.isFinite(caret) ? caret : text.length, text.length))
let start = end
while (start > 0 && !/\s/.test(text[start - 1])) start--
if (start > 0 && !/\s/.test(text[start - 1])) return null
const token = text.slice(start, end)
if (!token.startsWith('/') || token.startsWith('//')) return null
const query = token.slice(1)
if (query.length > 128 || /[\\/?#]/.test(query) || /^[.~]/.test(query)) return null
if (!knownItems.length) return null
const needle = query.toLocaleLowerCase()
const matches = knownItems.some(item => {
const name = item.name.startsWith('/') ? item.name.slice(1) : item.name
return item.id.toLocaleLowerCase().startsWith(needle) || name.toLocaleLowerCase().startsWith(needle)
})
if (!matches) return null
return { start, end, query }
}
/** Pure keyboard policy so composition, wrapping and selection can be tested without a DOM. */
export function resolveCapabilityPickerKey(input: {
key: string
isComposing?: boolean
keyCode?: number
itemCount: number
activeIndex: number
}): CapabilityPickerKeyAction {
if (input.isComposing || input.keyCode === 229 || !input.itemCount) {
return input.key === 'Escape' ? { type: 'close' } : { type: 'none' }
}
const count = input.itemCount
const active = Math.min(Math.max(input.activeIndex, 0), count - 1)
if (input.key === 'ArrowDown') return { type: 'move', index: (active + 1) % count }
if (input.key === 'ArrowUp') return { type: 'move', index: (active - 1 + count) % count }
if (input.key === 'Home') return { type: 'move', index: 0 }
if (input.key === 'End') return { type: 'move', index: count - 1 }
if (input.key === 'Enter') return { type: 'select', index: active }
if (input.key === 'Escape') return { type: 'close' }
return { type: 'none' }
}
export function CapabilityPicker({
items,
selected,
onSelect,
onClose,
query
}: CapabilityPickerProps): React.JSX.Element {
const [search, setSearch] = useState(query ?? '')
const [activeIndex, setActiveIndex] = useState(0)
const composing = useRef(false)
const inputRef = useRef<HTMLInputElement>(null)
const returnFocus = useRef<HTMLElement | null>(null)
const rowRefs = useRef<Array<HTMLButtonElement | null>>([])
const baseId = useId().replace(/:/g, '')
const listId = `${baseId}-list`
const filtered = useMemo(() => filterCapabilityItems(items, search), [items, search])
const selectedKeys = useMemo(() => new Set(selected.map(capabilitySelectionKey)), [selected])
const effectiveIndex = filtered.length ? Math.min(activeIndex, filtered.length - 1) : -1
const optionId = (index: number): string => `${baseId}-option-${index}`
useEffect(() => {
if (query !== undefined && query !== search) setSearch(query)
// Only an external query change seeds the local editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
useEffect(() => {
setActiveIndex(0)
}, [search])
useEffect(() => {
if (typeof document !== 'undefined') {
returnFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
}
inputRef.current?.focus()
}, [])
useEffect(() => {
if (effectiveIndex >= 0) rowRefs.current[effectiveIndex]?.scrollIntoView?.({ block: 'nearest' })
}, [effectiveIndex])
const close = (): void => {
onClose()
const target = returnFocus.current
if (!target?.isConnected) return
const focus = (): void => target.focus()
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focus)
else queueMicrotask(focus)
}
const onKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>): void => {
const action = resolveCapabilityPickerKey({
key: event.key,
isComposing: composing.current || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229,
keyCode: event.nativeEvent.keyCode,
itemCount: filtered.length,
activeIndex: Math.max(0, effectiveIndex)
})
if (action.type === 'none') return
event.preventDefault()
if (action.type === 'move') setActiveIndex(action.index)
else if (action.type === 'select') {
const item = filtered[action.index]
if (item) onSelect(item)
} else close()
}
return (
<section className="yc-capability-picker" role="dialog" aria-label="选择能力">
<div className="yc-capability-picker__search">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.4" />
<path d="m10.3 10.3 3.2 3.2" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
className="yc-capability-picker__search-input"
value={search}
placeholder="搜索技能、MCP 与命令"
autoComplete="off"
spellCheck={false}
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={effectiveIndex >= 0 ? optionId(effectiveIndex) : undefined}
onChange={event => setSearch(event.target.value)}
onKeyDown={onKeyDown}
onCompositionStart={() => { composing.current = true }}
onCompositionEnd={() => { composing.current = false }}
/>
{search ? (
<button
type="button"
className="yc-capability-picker__clear"
aria-label="清除搜索"
onClick={() => { setSearch(''); inputRef.current?.focus() }}
>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="m3 3 8 8M11 3l-8 8" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
</button>
) : null}
</div>
<div id={listId} className="yc-capability-picker__list" role="listbox" aria-label="可用能力">
{filtered.map((item, index) => {
const active = index === effectiveIndex
const isSelected = selectedKeys.has(capabilitySelectionKey(item))
return (
<button
key={capabilitySelectionKey(item)}
ref={node => { rowRefs.current[index] = node }}
id={optionId(index)}
type="button"
role="option"
aria-selected={isSelected}
data-kind={item.kind}
data-active={active || undefined}
className="yc-capability-picker__item"
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={event => event.preventDefault()}
onClick={() => onSelect(item)}
>
<span className="yc-capability-picker__kind" data-kind={item.kind}>{TYPE_LABELS[item.kind]}</span>
<span className="yc-capability-picker__body">
<span className="yc-capability-picker__name">{item.name}</span>
{item.description ? <span className="yc-capability-picker__description">{item.description}</span> : null}
</span>
{isSelected ? (
<svg className="yc-capability-picker__check" width="16" height="16" viewBox="0 0 16 16" aria-label="已选择">
<path d="m3.5 8.2 2.8 2.8 6.2-6.2" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : null}
</button>
)
})}
{!filtered.length ? <div className="yc-capability-picker__empty" role="status">没有匹配的能力</div> : null}
</div>
</section>
)
}
export default CapabilityPicker
@@ -11,6 +11,8 @@ import { SourceFilesSubmenu } from './add-menu/SourceSubmenus'
import type { MessageQuote } from '@shared/conversation'
import { ReferenceContentEditor, type ReferenceContentEditorHandle } from '../references/ReferenceContentEditor'
import { useReferenceViews, type DraftAttachment } from '../references/reference-views'
import { CapabilityPicker, detectCapabilitySlashTrigger, type CapabilityPickerItem, type CapabilityPickerSelection, type CapabilitySelectionIntent } from './CapabilityPicker'
import { draftPlainText, hasDraftUserContent } from './draft-fingerprint'
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
@@ -21,6 +23,10 @@ export interface AttachmentHost {
}
export interface ComposerSendPayload {
queueEditId?: string
skillIds?: string[]
mcpServerIds?: string[]
command?: 'compact'
text: string
attachments: ChatAttachment[]
resources: string[]
@@ -61,6 +67,11 @@ export interface ComposerControlsProps {
permissionSlot?: ReactNode
expertSlot?: ReactNode
floatingSlot?: ReactNode
queueSlot?: ReactNode
capabilityItems?: readonly CapabilityPickerItem[]
selectedCapabilities?: readonly CapabilityPickerSelection[]
onSelectCapability?(item: CapabilityPickerItem, intent?: CapabilitySelectionIntent): void
onRemoveCapability?(item: CapabilityPickerSelection): void
capabilityReferences?: ReactNode
onOpenSkills?(anchor: HTMLElement): void
onOpenExperts?(anchor: HTMLElement): void
@@ -158,6 +169,11 @@ export function ComposerControls({
permissionSlot,
expertSlot,
floatingSlot,
queueSlot,
capabilityItems = [],
selectedCapabilities = [],
onSelectCapability,
onRemoveCapability,
capabilityReferences,
onOpenSkills,
onOpenExperts,
@@ -175,9 +191,59 @@ export function ComposerControls({
className
}: ComposerControlsProps): React.JSX.Element {
const fileInputRef = useRef<HTMLInputElement>(null)
const queueRef = useRef<HTMLDivElement>(null)
const [queueHeight, setQueueHeight] = useState(0)
useEffect(() => {
const element = queueRef.current
if (!element || typeof ResizeObserver === 'undefined') { setQueueHeight(0); return }
const update = (): void => setQueueHeight(element.getBoundingClientRect().height)
update()
const observer = new ResizeObserver(update)
observer.observe(element)
return () => observer.disconnect()
}, [queueSlot, floatingSlot])
const editorRef = useRef<HTMLTextAreaElement>(null)
const richEditorRef = useRef<ReferenceContentEditorHandle>(null)
const cursorRef = useRef(0)
const composingRef = useRef(false)
const [compositionVersion, setCompositionVersion] = useState(0)
const canSelectCapabilities = !!onSelectCapability
const [slash, setSlash] = useState<ReturnType<typeof detectCapabilitySlashTrigger>>(null)
const dismissedSlashValue = useRef<string | null>(null)
const restoreCapabilityFocus = useRef(false)
useEffect(() => {
if (slash || !restoreCapabilityFocus.current) return
// The picker input must be removed before restoring Slate's native selection.
const timer = window.setTimeout(() => {
restoreCapabilityFocus.current = false
editorRef.current?.focus()
editorRef.current?.setSelectionRange(cursorRef.current, cursorRef.current)
richEditorRef.current?.focus()
}, 0)
return () => window.clearTimeout(timer)
}, [slash, value, skills.length])
useEffect(() => {
if (!canSelectCapabilities || composingRef.current) return
if (dismissedSlashValue.current === value) return
dismissedSlashValue.current = null
setSlash(detectCapabilitySlashTrigger(value, cursorRef.current, capabilityItems))
}, [value, capabilityItems, canSelectCapabilities, compositionVersion])
const selectCapability = (item: CapabilityPickerItem): void => {
if (slash) {
const text = value.slice(0, slash.start) + value.slice(slash.end)
const content = richEditorRef.current?.deleteTextRange(slash.start, slash.end) ?? [{ type: 'text' as const, text }]
cursorRef.current = slash.start
dismissedSlashValue.current = text
restoreCapabilityFocus.current = true
setSlash(null)
onChange(text)
onUserContentChange?.(content)
onSelectCapability?.(item, { source: 'slash', text, userContent: content })
editorRef.current?.focus()
richEditorRef.current?.focus()
requestAnimationFrame(() => { editorRef.current?.focus(); editorRef.current?.setSelectionRange(slash.start, slash.start); richEditorRef.current?.focus() })
} else onSelectCapability?.(item, { source: 'menu' })
}
const mounted = useRef(true)
const cancelledAttachments = useRef(new Set<string>())
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
@@ -355,7 +421,11 @@ export function ComposerControls({
}
const submit = async (): Promise<void> => {
if (sendInFlightRef.current || disabled || sending || busyCountRef.current > 0 || pendingAttachments.length || (!value.trim() && attachments.length === 0 && resourcesRef.current.length === 0 && quotesRef.current.length === 0 && !skills.length)) return
const editorContent = richEditorRef.current?.content() ?? userContent
const plainText = draftPlainText({ text: value, userContent: editorContent })
if (sendInFlightRef.current || disabled || sending || busyCountRef.current > 0 || pendingAttachments.length ||
(!plainText.trim() && attachments.length === 0 && resourcesRef.current.length === 0 && quotesRef.current.length === 0 &&
!skills.length && !selectedCapabilities.length && !hasDraftUserContent({ userContent: editorContent }))) return
const tooLarge = attachments.filter((attachment) => attachment.size > MAX_ATTACHMENT_BYTES)
if (tooLarge.length) {
setError(`文件超过 20 MB:${tooLarge.map((item) => item.name).join('、')}`)
@@ -365,7 +435,15 @@ export function ComposerControls({
sendInFlightRef.current = true
beginBusy()
try {
await onSend(buildComposerSendPayload(value, attachments, resourcesRef.current, quotesRef.current, richEditorRef.current?.content() ?? userContent))
const command = selectedCapabilities.some(item => item.kind === 'command' && item.id === 'compact') ? 'compact' : undefined
if (command && (plainText.trim() || attachments.length || resourcesRef.current.length || quotesRef.current.length ||
skills.length || selectedCapabilities.some(item => item.kind !== 'command') ||
hasDraftUserContent({ userContent: editorContent }))) {
throw new Error('上下文压缩不能与普通任务、附件或能力引用混发')
}
await onSend({ ...buildComposerSendPayload(command ? '/compact' : plainText, attachments, resourcesRef.current, quotesRef.current, editorContent),
skillIds: selectedCapabilities.filter(item => item.kind === 'skill').map(item => item.id),
mcpServerIds: selectedCapabilities.filter(item => item.kind === 'mcp').map(item => item.id), command })
} catch (reason) {
setError(errorText(reason))
} finally {
@@ -402,9 +480,10 @@ export function ComposerControls({
const homeFooter = workspaceFooter && (
<div className="wb-composer__workspace-footer">
<div className="wb-composer__workspace-slot">{workspaceFooter}</div>
{permissionPlacement === 'footer' && permissionSlot}
</div>
)
const hasSendableContent = !!(draftPlainText({ text: value, userContent }).trim() || attachments.length || resourcePaths.length || quotes.length ||
skills.length || selectedCapabilities.length || hasDraftUserContent({ userContent }))
return (
<div
@@ -420,6 +499,8 @@ export function ComposerControls({
setDragging(false)
void receiveResources(event.dataTransfer).catch((reason) => setError(errorText(reason)))
}}
onCompositionStartCapture={() => { composingRef.current = true; setSlash(null) }}
onCompositionEndCapture={() => { composingRef.current = false; setCompositionVersion(version => version + 1) }}
onPaste={(event) => {
const files = event.clipboardData.files
const hasReferences = readReferenceUris(event.clipboardData).length > 0
@@ -431,11 +512,19 @@ export function ComposerControls({
>
{floatingSlot && <div className="conversation-input-area__custom-floating">{floatingSlot}</div>}
<div className={'wb-composer__input-stack conversation-input-area__input-stack' + (permissionPlacement === 'footer' ? ' wb-home-route__input' : '')}>
{!floatingSlot && queueSlot && <div style={{ height: Math.max(0, queueHeight - 36), position: 'relative' }}>
<div ref={queueRef} className="conversation-input-area__prompt-queue-overlay" style={{ bottom: 0 }}>{queueSlot}</div>
</div>}
<div className="conversation-input">
<div className={'wb-composer__surface conversation-input__box cr-input-box' + (permissionPlacement === 'footer' ? ' wb-home-route__input-box' : '')}>
{dragging && <div className="wb-composer__drop-overlay" aria-hidden="true">放下文件以添加附件</div>}
<div className="cr-input-box__main">
{capabilityReferences}
{selectedCapabilities.some(item => item.kind !== 'skill') && <div className="yc-capability-references">{selectedCapabilities.filter(item => item.kind !== 'skill').map(item => <span key={`${item.kind}:${item.id}`} className="yc-capability-reference">
{item.kind === 'command' ? '/' : 'MCP: '}{capabilityItems.find(value => value.id === item.id && value.kind === item.kind)?.name ?? item.id}
<button type="button" aria-label={`移除 ${item.id}`} onClick={() => onRemoveCapability?.(item)}>x</button>
</span>)}</div>}
{slash && <div className="yc-composer-slash-picker"><CapabilityPicker items={capabilityItems} selected={selectedCapabilities} query={slash.query} onSelect={selectCapability} onClose={() => { dismissedSlashValue.current = value; restoreCapabilityFocus.current = true; setSlash(null) }} /></div>}
<div className="wb-composer__input-container cr-input-container">
<div className="cr-input-editor-host">
{referenceViews.length ? <ReferenceContentEditor ref={richEditorRef} value={value} content={userContent} references={referenceViews}
@@ -475,20 +564,22 @@ export function ComposerControls({
<div className="wb-composer__toolbar cr-input-toolbar">
<div className="wb-composer__group cr-input-toolbar__left">
<SourceComposerAddMenu scopeKey={addMenuScopeKey ?? sessionId ?? 'pending'} disabled={disabled || busy} onOpen={onOpenAddMenu}
entries={addMenuEntries ?? [{ id: 'add-files', children: [{ id: 'local-files', label: '本地文件' }] }, ...(onOpenExperts ? [{ id: 'experts' }] : []), ...(onOpenSkills ? [{ id: 'skills' }] : [])]}
slots={{ 'add-files': ({ closeMenu }) => <SourceFilesSubmenu provider={{ onPickLocalFiles: () => void pickAttachments() }} onCloseMenu={closeMenu} />, ...addMenuSlots }} onAction={id => {
entries={[...(addMenuEntries ?? [{ id: 'add-files', children: [{ id: 'local-files', label: '本地文件' }] }, ...(onOpenExperts ? [{ id: 'experts' }] : [])]),
...(onSelectCapability ? [{ id: 'skills', label: '技能' }, { id: 'connectors', label: 'MCP 服务' }] : onOpenSkills ? [{ id: 'skills' }] : [])]}
slots={{ 'add-files': ({ closeMenu }) => <SourceFilesSubmenu provider={{ onPickLocalFiles: () => void pickAttachments() }} onCloseMenu={closeMenu} />, ...addMenuSlots,
...(onSelectCapability ? Object.fromEntries((['skill', 'mcp'] as const).map(kind => [kind === 'skill' ? 'skills' : 'connectors', ({ closeMenu }: SubmenuContext) => <CapabilityPicker items={capabilityItems.filter(item => item.kind === kind)} selected={selectedCapabilities} onSelect={selectCapability} onClose={closeMenu} />])) : {}) }} onAction={id => {
if (id === 'local-files' || id === 'add-files') void pickAttachments()
else if (id === 'experts' || id === 'skills') {
const anchor = editorRef.current?.closest('.wb-composer')?.querySelector<HTMLElement>('.wb-composer__add-button')
if (anchor) (id === 'experts' ? onOpenExperts : onOpenSkills)?.(anchor)
}
}} />
{permissionSlot}
{branchLabelSlot}
{expertSlot}
</div>
<div className="wb-composer__group cr-input-toolbar__right">
{permissionPlacement === 'toolbar' && permissionSlot}
{contextUsageSlot}
{modelSlot && <div className="wb-composer__model-slot">{modelSlot}</div>}
<VoiceInputControl
@@ -497,12 +588,13 @@ export function ComposerControls({
sessionId={sessionId}
onTranscript={(text) => onTranscript?.(text)}
/>
{onStop ? (
{onStop && hasSendableContent && <button type="button" className="wb-composer__queue-stop" title="停止当前运行" aria-label="停止当前运行" onClick={() => void Promise.resolve(onStop()).catch(reason => setError(errorText(reason)))}><IconStop /></button>}
{onStop && !hasSendableContent ? (
<button type="button" className="wb-composer__send cr-send-button cr-send-button--stop cr-input-toolbar__send" title="停止" aria-label="停止" onClick={() => void Promise.resolve(onStop()).catch((reason) => setError(errorText(reason)))}>
<span className="cr-send-button__icon"><IconStop /></span>
</button>
) : (
<button type="button" className="wb-composer__send cr-send-button cr-input-toolbar__send" title="发送" aria-label="发送" onClick={() => void submit()} disabled={disabled || sending || busy || pendingAttachments.length > 0 || (!value.trim() && attachments.length === 0 && resourcePaths.length === 0 && quotes.length === 0 && !skills.length)}>
<button type="button" className="wb-composer__send cr-send-button cr-input-toolbar__send" title="发送" aria-label="发送" onClick={() => void submit()} disabled={disabled || sending || busy || pendingAttachments.length > 0 || !hasSendableContent}>
<span className="cr-send-button__icon"><RuntimeSendIcon /></span>
</button>
)}
@@ -0,0 +1,89 @@
import { useEffect, useRef, useState } from 'react'
import type { PromptQueueSnapshot, QueuedPrompt } from '@shared/prompt-queue'
import { IconClose, IconChevronRight, IconNewTask } from '../../components/icons'
import { EditMessageIcon } from '../message-actions/RecoveredActionIcons'
import { EditorSpinnerIcon } from '../conversation/MessageEditorIcons'
import './prompt-queue.css'
export function usePromptQueue(sessionId: string | null) {
const [snapshot, setSnapshot] = useState<PromptQueueSnapshot>()
const [error, setError] = useState<string>()
const owner = useRef(sessionId); owner.current = sessionId
useEffect(() => {
let active = true
setSnapshot(undefined); setError(undefined)
if (!sessionId || !window.ycode.chat.queue) return
const apply = (next: PromptQueueSnapshot): void => {
if (!active || owner.current !== sessionId || next.sessionId !== sessionId) return
setSnapshot(previous => previous?.sessionId === sessionId && previous.revision > next.revision ? previous : next)
}
const unsubscribe = window.ycode.chat.queue.onChanged(apply)
void window.ycode.chat.queue.get(sessionId).then(apply).catch(reason => { if (active) setError(String(reason)) })
return () => { active = false; unsubscribe() }
}, [sessionId])
return { snapshot: snapshot?.sessionId === sessionId ? snapshot : undefined, error }
}
interface Props {
snapshot: PromptQueueSnapshot
onEdit(item: QueuedPrompt): Promise<void>
sendNowAvailable?: boolean
}
export function queuePreview(item: QueuedPrompt): string {
const payload = item.payload
return [payload.quotes?.length ? '[引用]' : '', payload.resources?.length ? '[文件]' : '',
payload.attachments?.some(file => file.mimeType.startsWith('image/')) ? '[图片]' : payload.attachments?.length ? '[附件]' : '',
payload.text].filter(Boolean).join(' ') || '能力引用'
}
export function PromptQueuePanel({ snapshot, onEdit, sendNowAvailable = false }: Props): React.JSX.Element | null {
const [error, setError] = useState('')
const [pending, setPending] = useState<Set<string>>(new Set())
const pendingRef = useRef(new Set<string>())
const [dragging, setDragging] = useState<string>()
const [insertAt, setInsertAt] = useState<number>()
const content = useRef<HTMLDivElement>(null)
const lastId = snapshot.items.at(-1)?.id
useEffect(() => { const element = content.current; if (element) element.scrollTop = element.scrollHeight }, [lastId])
const act = async (id: string, operation: () => Promise<unknown>): Promise<void> => {
if (pendingRef.current.has(id)) return
pendingRef.current.add(id)
setPending(new Set(pendingRef.current)); setError('')
try { await operation() } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) }
finally { pendingRef.current.delete(id); setPending(new Set(pendingRef.current)) }
}
if (!snapshot.items.length) return null
const reorder = (id: string, target: number): void => {
const ids = snapshot.items.map(item => item.id), index = ids.indexOf(id)
if (index < 0) return
ids.splice(index, 1); ids.splice(target > index ? target - 1 : target, 0, id)
setDragging(undefined); setInsertAt(undefined)
if (ids.some((value, position) => value !== snapshot.items[position].id)) void act(id, () => window.ycode.chat.queue.reorder(snapshot.sessionId, ids))
}
return <div className="conversation-queue-panel cr-theme">
<div className="conversation-message-queue">
{snapshot.paused && <div className="yc-queue-notice" role="status">{snapshot.reason}<button type="button" disabled={pending.size > 0} onClick={() => void act('resume', () => window.ycode.chat.queue.resume(snapshot.sessionId))}>恢复发送</button></div>}
{error && <div className="yc-queue-notice" role="alert">{error}</div>}
<div className="conversation-message-queue-content" ref={content}>
{snapshot.items.map((item, index) => <div key={item.id}
className={'conversation-message-queue-item' + (snapshot.items.length === 1 ? ' conversation-message-queue-item-single' : '') + (dragging === item.id ? ' conversation-message-queue-item-dragging' : '') + (insertAt === index ? ' conversation-message-queue-item-drag-over-before' : '') + (insertAt === snapshot.items.length && index === snapshot.items.length - 1 ? ' conversation-message-queue-item-drag-over-after' : '')}
draggable={snapshot.items.length > 1 && item.status === 'pending' && pending.size === 0}
onDragStart={event => { setDragging(item.id); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', item.id) }}
onDragEnd={() => { setDragging(undefined); setInsertAt(undefined) }}
onDragOver={event => { if (!dragging) return; event.preventDefault(); const bounds = event.currentTarget.getBoundingClientRect(); setInsertAt(index + (event.clientY >= bounds.top + bounds.height / 2 ? 1 : 0)) }}
onDrop={event => { if (!dragging || insertAt === undefined) return; event.preventDefault(); reorder(dragging, insertAt) }}>
<div className="conversation-message-queue-item-left"><span className="prompt-icon"><IconNewTask /></span><div className="content">
{item.payload.skillIds?.map(id => <span key={id} className="conversation-queue-skill-chip" title={id}>{item.payload.capabilityLabels?.[`skill:${id}`] ?? id}</span>)}
<span className="conversation-queue-preview-text" title={item.error ?? queuePreview(item)}>{item.status === 'unknown' ? '[交付结果不明] ' : ''}{queuePreview(item)}</span>
</div></div>
<div className="conversation-message-queue-item-actions">
{item.status === 'delivering' ? <EditorSpinnerIcon /> : <button type="button" className="icon-btn" disabled={!sendNowAvailable || pending.has(item.id) || item.status !== 'pending'} title={sendNowAvailable ? '立即发送,不打断模型' : '当前没有可引导的活动运行'} aria-label="立即发送" onClick={() => void act(item.id, () => window.ycode.chat.queue.sendNow(snapshot.sessionId, item.id))}><IconChevronRight /></button>}
<button type="button" className="icon-btn" disabled={pending.size > 0 || item.status === 'delivering'} title="编辑" aria-label="编辑追加消息" onClick={() => void act(item.id, () => onEdit(item))}><EditMessageIcon /></button>
<button type="button" className="icon-btn" disabled={pending.size > 0 || item.status === 'delivering'} title="删除" aria-label="删除追加消息" onClick={() => void act(item.id, () => window.ycode.chat.queue.remove(snapshot.sessionId, item.id))}><IconClose /></button>
</div>
</div>)}
</div>
</div>
</div>
}
@@ -0,0 +1,213 @@
/* Reuses the restored composer popover, search and list tokens. */
.yc-capability-picker {
box-sizing: border-box;
width: min(360px, calc(100vw - 32px));
max-height: min(420px, calc(100vh - 120px));
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
overflow: hidden;
color: var(--cr-text-primary, var(--wb-color-text-primary, #000000e6));
background: var(--cr-popover-bg, var(--wb-bg-popover, #fff));
border: .5px solid var(--cr-popover-border, var(--wb-border-popover, #dedede));
border-radius: 16px;
box-shadow: var(--cr-shadow-popover, var(--wb-popover-shadow, 0 6px 24px #0000000a));
font-family: var(--cr-internal-font-family, "PingFang SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
font-size: 14px;
line-height: 22px;
user-select: none;
}
.yc-capability-picker__search {
box-sizing: border-box;
height: 32px;
flex: 0 0 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
color: var(--cr-text-secondary, var(--wb-color-text-secondary, #000000b3));
background: var(--cr-bg-secondary, var(--wb-dropdown-search-input-bg, #f7f7f7));
border-radius: 8px;
}
.yc-capability-picker__search > svg {
width: 16px;
height: 16px;
flex: none;
}
.yc-capability-picker__search-input {
min-width: 0;
flex: 1;
padding: 0;
color: var(--cr-text-primary, var(--wb-color-text-primary, #000000e6));
caret-color: var(--cr-brand-primary, var(--wb-brand-primary, #00c29a));
background: transparent;
border: 0;
outline: 0;
font: inherit;
}
.yc-capability-picker__search-input::placeholder {
color: var(--cr-text-tertiary, var(--wb-color-text-tertiary, #00000080));
}
.yc-capability-picker__clear {
width: 16px;
height: 16px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--cr-text-tertiary, var(--wb-color-text-tertiary, #00000080));
background: none;
border: 0;
border-radius: 4px;
cursor: pointer;
}
.yc-capability-picker__clear:hover {
color: var(--cr-text-primary, var(--wb-color-text-primary, #000000e6));
}
.yc-capability-picker__clear:focus-visible {
outline: 2px solid var(--cr-focus-border, var(--wb-border-focus, #007fd4));
outline-offset: 1px;
}
.yc-capability-picker__list {
min-height: 0;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.yc-capability-picker:hover .yc-capability-picker__list {
scrollbar-color: var(--cr-scrollbar-thumb, var(--wb-dropdown-scrollbar-thumb-color, #0003)) transparent;
}
.yc-capability-picker__list::-webkit-scrollbar {
width: 4px;
}
.yc-capability-picker__list::-webkit-scrollbar-track {
background: transparent;
}
.yc-capability-picker__list::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 12px;
}
.yc-capability-picker:hover .yc-capability-picker__list::-webkit-scrollbar-thumb {
background: var(--cr-scrollbar-thumb, var(--wb-dropdown-scrollbar-thumb-color, #0003));
}
.yc-capability-picker__item {
box-sizing: border-box;
width: 100%;
min-height: 52px;
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
color: var(--cr-text-primary, var(--wb-color-text-primary, #000000e6));
text-align: left;
background: transparent;
border: 0;
border-radius: 8px;
cursor: pointer;
font: inherit;
}
.yc-capability-picker__item:hover,
.yc-capability-picker__item[data-active="true"] {
background: var(--cr-item-hover-bg, var(--wb-dropdown-item-bg-hover, #f2f2f2));
}
.yc-capability-picker__item:focus-visible {
outline: 2px solid var(--cr-focus-border, var(--wb-border-focus, #007fd4));
outline-offset: -2px;
}
.yc-capability-picker__item[aria-selected="true"] {
background: var(--cr-item-selected-bg, var(--wb-dropdown-item-bg-selected, #f7f7f7));
}
.yc-capability-picker__kind {
box-sizing: border-box;
min-width: 34px;
flex: 0 0 auto;
padding: 0 4px;
color: var(--cr-text-tertiary, var(--wb-color-text-tertiary, #00000080));
background: var(--cr-tag-bg, var(--wb-bg-secondary, #f7f7f7));
border-radius: 4px;
text-align: center;
font-size: 11px;
line-height: 16px;
}
.yc-capability-picker__kind[data-kind="skill"] {
background: var(--cr-tag-skill-bg, var(--wb-tag-skill-bg, #f2f2f2));
}
.yc-capability-picker__kind[data-kind="mcp"] {
background: var(--cr-bg-secondary, var(--wb-bg-secondary, #f7f7f7));
}
.yc-capability-picker__kind[data-kind="command"] {
background: transparent;
border: 1px solid var(--cr-border-default, var(--wb-border-default, #ebebeb));
}
.yc-capability-picker__body {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.yc-capability-picker__name,
.yc-capability-picker__description {
display: block;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.yc-capability-picker__description {
color: var(--cr-text-tertiary, var(--wb-color-text-tertiary, #00000080));
font-size: 12px;
line-height: 20px;
}
.yc-capability-picker__check {
width: 16px;
height: 16px;
flex: 0 0 16px;
color: var(--cr-brand-primary, var(--wb-brand-primary, #00c29a));
}
.yc-capability-picker__empty {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
color: var(--cr-text-tertiary, var(--wb-color-text-tertiary, #00000080));
}
@media (max-width: 420px) {
.yc-capability-picker {
width: calc(100vw - 24px);
}
}
@@ -0,0 +1,26 @@
import type { UserContentBlock } from '@shared/conversation'
import type { CapabilityPickerItem, CapabilitySelectionIntent } from './CapabilityPicker'
interface CapabilityDraft {
text: string
userContent?: UserContentBlock[]
skills?: Array<{ id: string; name: string }>
mcpServerIds?: string[]
command?: 'compact'
}
/** Slash insertion consumes its token and adds once; the add menu remains a toggle. */
export function withCapabilitySelection<T extends CapabilityDraft>(previous: T, item: CapabilityPickerItem, intent?: CapabilitySelectionIntent): T {
const current = intent?.source === 'slash' ? { ...previous, text: intent.text ?? previous.text, userContent: intent.userContent ?? previous.userContent } : previous
const selected = item.kind === 'skill' ? current.skills?.some(value => value.id === item.id)
: item.kind === 'mcp' ? current.mcpServerIds?.includes(item.id) : current.command === item.id
if (selected) {
if (intent?.source === 'slash') return current
if (item.kind === 'skill') return { ...current, skills: current.skills?.filter(value => value.id !== item.id) }
if (item.kind === 'mcp') return { ...current, mcpServerIds: current.mcpServerIds?.filter(value => value !== item.id) }
return { ...current, command: undefined }
}
if (item.kind === 'skill') return { ...current, skills: [...(current.skills ?? []), { id: item.id, name: item.name }] }
if (item.kind === 'mcp') return { ...current, mcpServerIds: [...new Set([...(current.mcpServerIds ?? []), item.id])] }
return { ...current, command: item.id === 'compact' ? 'compact' : undefined }
}
@@ -3,6 +3,7 @@
display: flex;
flex-direction: column;
width: 100%;
container: composer / inline-size;
color: var(--cr-text-primary, var(--yc-text-primary, #222));
}
@@ -11,6 +12,11 @@
min-width: 0;
}
.yc-composer-slash-picker { position:absolute; bottom:100%; left:0; z-index:60; max-width:100%; }
.yc-capability-references { display:flex; flex-wrap:wrap; gap:4px; padding:4px 12px; }
.yc-capability-reference { display:inline-flex; align-items:center; gap:4px; max-width:100%; border-radius:6px; padding:2px 6px; background:var(--cr-tag-skill-bg,var(--wb-color-bg-primary-active,#f3f3f3)); font-size:12px; overflow-wrap:anywhere; }
.yc-capability-reference button { color:inherit; background:none; border:0; padding:0 2px; cursor:pointer; }
.wb-composer__surface {
position: relative;
display: flex;
@@ -58,6 +64,16 @@
.wb-composer__toolbar { justify-content: space-between; }
/* Keep the permission entry reachable when the sidebar leaves a narrow editor. */
@container composer (max-width: 560px) {
.wb-composer .cr-input-toolbar__left ._branchLabel_jdvlk_244 { display: none; }
.wb-composer .yc-source-permission-trigger { flex: none; }
.wb-composer .yc-source-permission-trigger .cr-permission-setting { width: 32px; min-width: 32px; padding: 0; justify-content: center; }
.wb-composer .yc-source-permission-trigger .cr-input-footer-item__label,
.wb-composer .yc-source-permission-trigger .cr-input-footer-item__chevron { display: none; }
.wb-composer .yc-source-model-selector .cr-model-selector__trigger-label { max-width: 100px; }
}
.wb-composer__button,
.wb-composer__icon-button,
.wb-composer__menu button,
@@ -28,6 +28,15 @@ const BRANCH_REFRESH_INTERVAL_MS = 20_000
/** 圆环半径 7(原版 SVG `r="7"`)。 */
const RING_RADIUS = 7
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
const contextFlights = new Map<string, Promise<ContextUsageInfo>>()
function queryContext(sessionId: string): Promise<ContextUsageInfo> {
const existing = contextFlights.get(sessionId)
if (existing) return existing
const request = window.ycode.session.contextUsage(sessionId)
contextFlights.set(sessionId, request)
void request.finally(() => { if (contextFlights.get(sessionId) === request) contextFlights.delete(sessionId) }).catch(() => {})
return request
}
/** 原版 `formatTokenCount`:不足 1K 原样显示,达到 1K 后固定一位小数并以 K 结尾。 */
export function formatContextTokenCount(value: number): string {
@@ -117,7 +126,7 @@ export function ContextUsageRing({ sessionId, hasMessages, running }: ContextUsa
if(!sessionId||!window.ycode.session.contextUsage)return
let active=true
setLoading(true)
void window.ycode.session.contextUsage(sessionId).then(value=>{
void queryContext(sessionId).then(value=>{
if(active&&currentKey.current===key&&queryVersion.current===version)setQueried({key,info:value})
}).catch(()=>{
if(active&&currentKey.current===key)setQueried({key,info:{...baseInfo,reason:'未能读取统计,点击重新获取'}})
@@ -127,6 +136,11 @@ export function ContextUsageRing({ sessionId, hasMessages, running }: ContextUsa
},[key,refresh])
useEffect(()=>{if(open)setRefresh(value=>value+1)},[open])
useEffect(() => {
if (!running || !sessionId) return
const timer = window.setInterval(() => setRefresh(value => value + 1), 5000)
return () => window.clearInterval(timer)
}, [running, sessionId])
useEffect(() => {
setOpen(false)
@@ -155,7 +169,7 @@ export function ContextUsageRing({ sessionId, hasMessages, running }: ContextUsa
const usedText = used !== undefined ? `${estimated?'≈':''}${formatContextTokenCount(used)}` : null
const totalText = total !== undefined ? formatContextTokenCount(total) : '未知'
const dashOffset = ringPercent === null ? RING_CIRCUMFERENCE : RING_CIRCUMFERENCE * (1 - ringPercent / 100)
const previous = info.status==='previous'||(selectedModel&&latest?.modelId&&selectedModel!==latest.modelId)||(running&&latest?.status!=='streaming')
const previous = latest?.status !== 'streaming' && (info.status==='previous'||(selectedModel&&latest?.modelId&&selectedModel!==latest.modelId)||running)
const tooltipText = used !== undefined
? `${percent===null?'':`${estimated?'≈':''}${percentage}% · `}${usedText} / ${totalText} ${previous?'上次请求':estimated?'上下文估计':'上下文已使用'}`
: undefined
@@ -0,0 +1,108 @@
import type { ChatAttachment, MessageQuote, UserContentBlock } from '@shared/conversation'
import { normalizeUserContent } from '@shared/user-content'
export interface ComposerDraftSnapshot {
text: string
attachments?: ChatAttachment[]
resources?: string[]
quotes?: MessageQuote[]
userContent?: UserContentBlock[]
skills?: Array<{ id: string; name: string; uri?: string }>
mcpServerIds?: string[]
command?: 'compact'
/** Durable queue-to-editor handoff; intentionally excluded from the content fingerprint. */
queueEditId?: string
}
function stable(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stable)
if (!value || typeof value !== 'object') return value
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, stable(item)])
)
}
/** Fingerprint only submitted content, excluding transient editor metadata. */
export function draftFingerprint(draft: ComposerDraftSnapshot): string {
return JSON.stringify(stable({
text: draft.text,
attachments: draft.attachments ?? [],
resources: draft.resources ?? [],
quotes: draft.quotes ?? [],
userContent: draft.userContent ?? [],
skills: draft.skills ?? [],
mcpServerIds: draft.mcpServerIds ?? [],
command: draft.command ?? null
}))
}
export function hasDraftUserContent(draft: Pick<ComposerDraftSnapshot, 'userContent'>): boolean {
return (draft.userContent ?? []).some(item =>
item.type === 'text' ? item.text.trim().length > 0 : true
)
}
export function draftPlainText(draft: Pick<ComposerDraftSnapshot, 'text' | 'userContent'>): string {
if (draft.text.trim()) return draft.text
return (draft.userContent ?? [])
.flatMap(item => item.type === 'text' ? [item.text] : [])
.join('')
}
export function draftIsEmpty(draft: ComposerDraftSnapshot): boolean {
return !draft.text.trim() &&
!(draft.attachments?.length) &&
!(draft.resources?.length) &&
!(draft.quotes?.length) &&
!(draft.skills?.length) &&
!(draft.mcpServerIds?.length) &&
!hasDraftUserContent(draft) &&
!draft.command
}
function uniqueBy<T>(items: readonly T[], key: (item: T) => string): T[] {
const seen = new Set<string>()
return items.filter(item => {
const value = key(item)
if (seen.has(value)) return false
seen.add(value)
return true
})
}
/**
* Preserve unsent content when a submit fails after the composer has already
* detached the submitted snapshot. The failed content stays before text typed
* subsequently, so the user can remove a command tag and continue without
* silently sending the failed body again.
*/
export function mergeFailedDraft(
current: ComposerDraftSnapshot,
failed: ComposerDraftSnapshot
): { draft: ComposerDraftSnapshot; commandConflict: boolean } {
const text = [draftPlainText(failed), draftPlainText(current)].filter(value => value.length > 0).join('\n')
const command = failed.command ?? current.command
const queueEditId = failed.queueEditId ?? current.queueEditId
const draft: ComposerDraftSnapshot = {
text,
attachments: uniqueBy([...(failed.attachments ?? []), ...(current.attachments ?? [])], item => item.id),
resources: [...new Set([...(failed.resources ?? []), ...(current.resources ?? [])])],
quotes: uniqueBy([...(failed.quotes ?? []), ...(current.quotes ?? [])], item => item.id),
skills: uniqueBy([...(failed.skills ?? []), ...(current.skills ?? [])], item => item.id),
mcpServerIds: [...new Set([...(failed.mcpServerIds ?? []), ...(current.mcpServerIds ?? [])])],
...(command ? { command } : {}),
...(queueEditId ? { queueEditId } : {})
}
const failedText = draftPlainText(failed), currentText = draftPlainText(current)
draft.userContent = normalizeUserContent([
...normalizeUserContent(failed.userContent, failedText, failed),
...(failedText.length && currentText.length ? [{ type: 'text' as const, text: '\n' }] : []),
...normalizeUserContent(current.userContent, currentText, current)
], text, draft)
return {
draft,
commandConflict: !!command && !draftIsEmpty({ ...draft, command: undefined })
}
}
@@ -0,0 +1,206 @@
/**
* Pure scheduling for everything that shares the space immediately above the
* composer. Rendering stays in React; this module only decides precedence and
* which data must survive while an interaction temporarily owns the surface.
*/
export type ComposerInteractionKind = 'question' | 'approval' | 'plan' | 'mcp'
export type ComposerInteractionStatus =
| 'pending'
| 'responding'
| 'answered'
| 'rejected'
| 'cancelled'
| 'error'
export interface ComposerInteractionSnapshot {
id: string
kind: ComposerInteractionKind
status: ComposerInteractionStatus
}
export type ComposerNoticeKind = 'error' | 'quota'
export interface ComposerNoticeSnapshot {
kind: ComposerNoticeKind
id?: string
}
export interface ComposerTaskSnapshot {
id?: string
active: boolean
}
export interface ComposerCompositionInput {
interaction?: ComposerInteractionSnapshot | null
/** A global error/quota message. Interaction-local errors are rendered by the interaction itself. */
notice?: ComposerNoticeSnapshot | null
task?: ComposerTaskSnapshot | null
queueCount?: number
queueRevision?: number
running?: boolean
disabled?: boolean
}
export type ComposerPanelKind =
| ComposerInteractionKind
| ComposerNoticeKind
| 'task'
| 'queue'
export interface ComposerPanelRef {
kind: ComposerPanelKind
id?: string
}
export interface ComposerQueueComposition {
count: number
revision: number
visible: boolean
/** Interaction takeover only hides the queue; it never deletes its items. */
hiddenBy: 'interaction' | 'notice' | 'empty' | null
preservesItems: true
}
export interface ComposerInputComposition {
/** The editor stays mounted so draft, attachments and capability selections survive takeover. */
mounted: true
disabled: boolean
blockedByInteraction: boolean
destination: 'send' | 'queue' | 'blocked'
preservesDraft: true
}
export interface ComposerToolbarComposition {
/** Permission mode is always the first left-side control after the add button. */
permissionPlacement: 'after-add'
primaryAction: 'send' | 'stop'
}
export interface ComposerStackComposition {
beforeQueue: ComposerPanelRef[]
queue: ComposerPanelRef | null
beforeInput: ComposerPanelRef[]
hasFloatingContent: boolean
}
export interface ComposerComposition {
interaction: ComposerInteractionSnapshot | null
notice: ComposerNoticeSnapshot | null
task: ComposerTaskSnapshot | null
queue: ComposerQueueComposition
input: ComposerInputComposition
toolbar: ComposerToolbarComposition
stack: ComposerStackComposition
/** Escape never stops a running generation; it only closes menus/transient surfaces. */
escapeStopsGeneration: false
/** Changes whenever the reserved composer-overlay height may change. */
layoutKey: string
}
const ACTIVE_INTERACTION_STATUSES = new Set<ComposerInteractionStatus>(['pending', 'responding', 'error'])
export function isActiveComposerInteraction(
interaction: ComposerInteractionSnapshot | null | undefined
): interaction is ComposerInteractionSnapshot {
return Boolean(interaction && ACTIVE_INTERACTION_STATUSES.has(interaction.status))
}
function normalizedCount(value: number | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return 0
return Math.floor(value)
}
function normalizedRevision(value: number | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return 0
return Math.floor(value)
}
function panelId(value: ComposerInteractionSnapshot | ComposerNoticeSnapshot | ComposerTaskSnapshot): string | undefined {
return typeof value.id === 'string' && value.id.length > 0 ? value.id : undefined
}
/**
* Resolve the single composer composition used by both the home and
* conversation routes.
*
* Precedence:
* 1. An active question, approval, plan confirmation or MCP confirmation takes
* over interaction submission and hides the queue without clearing it.
* 2. Errors suppress quota notices; both are suppressed by an active
* interaction so the same failure is not shown twice.
* 3. A notice or interaction owns the accessory surface. Queue and task
* placements stay hidden while either is present, without discarding their
* data.
* 4. Tasks move into the queue header when the queue is visible, otherwise
* they stay in the unified stack above the editor.
* 5. The running generation keeps its stop action reachable in every state.
*/
export function composeComposer(input: ComposerCompositionInput = {}): ComposerComposition {
const interaction = isActiveComposerInteraction(input.interaction) ? input.interaction : null
const interactionTakeover = interaction !== null
const notice = interactionTakeover
? null
: input.notice?.kind === 'error' || input.notice?.kind === 'quota'
? input.notice
: null
const accessoryTakeover = interactionTakeover || notice !== null
const queueCount = normalizedCount(input.queueCount)
const queueRevision = normalizedRevision(input.queueRevision)
const task = input.task?.active ? input.task : null
const queueVisible = queueCount > 0 && !accessoryTakeover
const queue: ComposerQueueComposition = {
count: queueCount,
revision: queueRevision,
visible: queueVisible,
hiddenBy: queueVisible ? null : interactionTakeover ? 'interaction' : notice ? 'notice' : 'empty',
preservesItems: true
}
const running = input.running === true
const disabled = input.disabled === true || interactionTakeover
const taskPlacement = task && !accessoryTakeover ? queueVisible ? 'queue-header' : 'above-input' : null
const beforeQueue: ComposerPanelRef[] = taskPlacement === 'queue-header'
? [{ kind: 'task', id: panelId(task!) }]
: []
const beforeInput: ComposerPanelRef[] = []
if (taskPlacement === 'above-input') beforeInput.push({ kind: 'task', id: panelId(task!) })
if (interaction) beforeInput.push({ kind: interaction.kind, id: panelId(interaction) })
if (notice) beforeInput.push({ kind: notice.kind, id: panelId(notice) })
const stack: ComposerStackComposition = {
beforeQueue,
queue: queueVisible ? { kind: 'queue' } : null,
beforeInput,
hasFloatingContent: beforeQueue.length > 0 || queueVisible || beforeInput.length > 0
}
const layoutKey = [
interaction ? `${interaction.kind}:${interaction.id}:${interaction.status}` : 'interaction:none',
`queue:${queueCount}:${queueRevision}:${queueVisible ? 'visible' : queue.hiddenBy}`,
notice ? `${notice.kind}:${notice.id ?? 'unidentified'}` : 'notice:none',
task ? `task:${task.id ?? 'unidentified'}:${taskPlacement}` : 'task:none'
].join('|')
return {
interaction,
notice,
task,
queue,
input: {
mounted: true,
disabled,
blockedByInteraction: interactionTakeover,
destination: interactionTakeover ? 'blocked' : running ? 'queue' : 'send',
preservesDraft: true
},
toolbar: {
permissionPlacement: 'after-add',
primaryAction: running ? 'stop' : 'send'
},
stack,
escapeStopsGeneration: false,
layoutKey
}
}
@@ -81,7 +81,7 @@ export default function SourcePermissionControl({ mode, disabled, labels, confir
return <div className="wb-composer__menu-anchor yc-source-permission-trigger">
<SourceSelectPopover open={open} onOpenChange={next => { if (!confirming) setOpen(next) }} placement="top-start" portalRoot="body" offset={4}
role="dialog" className="cr-permission-setting-popover" disabled={disabled || pending}
trigger={<button ref={trigger} type="button" disabled={disabled || pending}
trigger={<button ref={trigger} type="button" disabled={disabled || pending} aria-label={label} title={label}
className={['wb-composer__button', 'cr-input-footer-item', 'cr-permission-setting', full && 'cr-permission-setting--danger', open && 'cr-input-footer-item--open'].filter(Boolean).join(' ')}>
<span className="cr-input-footer-item__icon" aria-hidden="true">{full ? <ShieldDangerIcon /> : <ShieldSecuredIcon />}</span>
<span className="cr-input-footer-item__label" data-text={label}><span>{label}</span></span>
@@ -0,0 +1,28 @@
/* Layout projected from retained WorkBuddy 5.8.2 REA JS/CSS evidence.
Queue action glyph parity still needs original runtime verification. */
.conversation-input-area__prompt-queue-overlay { position:absolute; left:0; right:0; width:100%; display:flex; flex-direction:column; align-items:stretch; }
.conversation-queue-panel { z-index:15; width:100%; position:relative; }
.wb-composer__input-stack > .conversation-input { position:relative; z-index:16; }
.conversation-queue-panel .conversation-message-queue { --conversation-message-queue-max-height:224px; --conversation-message-queue-padding-block-start:var(--wb-spacing-3,12px); --conversation-message-queue-padding-block-end:calc(var(--wb-spacing-9,36px) + var(--wb-spacing-2,8px)); background:var(--wb-color-bg-primary-default,var(--yc-bg-primary,#fff)); color:var(--wb-color-text-primary,var(--yc-text-primary,#222)); border-radius:var(--wb-radius-4xl,24px) var(--wb-radius-4xl,24px) 0 0; bottom:calc(-1 * var(--wb-spacing-9,36px)); padding:var(--conversation-message-queue-padding-block-start) var(--wb-spacing-3,12px) var(--conversation-message-queue-padding-block-end); max-height:var(--conversation-message-queue-max-height); font-size:var(--wb-font-size-5,14px); position:relative; box-sizing:border-box; }
.conversation-queue-panel .conversation-message-queue-content { max-height:calc(224px - var(--conversation-message-queue-padding-block-start) - var(--conversation-message-queue-padding-block-end)); padding:0 var(--wb-spacing-3,12px); display:flex; flex-direction:column; overflow-y:auto; }
.conversation-queue-panel .conversation-message-queue-content::-webkit-scrollbar { width:6px; }
.conversation-queue-panel .conversation-message-queue-content::-webkit-scrollbar-thumb { background:var(--wb-color-text-disabled,#aaa); border-radius:3px; }
.conversation-queue-panel .conversation-message-queue-item { padding:var(--wb-spacing-4,16px) 0; line-height:var(--wb-font-line-height-5-2,20px); display:flex; justify-content:space-between; align-items:center; gap:var(--wb-spacing-3,12px); height:var(--conversation-queue-item-height,var(--wb-spacing-10,40px)); box-sizing:border-box; border-bottom:1px solid var(--wb-color-border-tertiary,var(--yc-border,#eee)); cursor:grab; flex-shrink:0; position:relative; }
.conversation-queue-panel .conversation-message-queue-item:last-child { border-bottom:none; }
.conversation-queue-panel .conversation-message-queue-item-single { cursor:default; }
.conversation-queue-panel .conversation-message-queue-item-single .prompt-icon { opacity:.4; }
.conversation-queue-panel .conversation-message-queue-item-left { display:flex; align-items:center; gap:8px; min-width:0; flex:1; overflow:hidden; }
.conversation-queue-panel .conversation-message-queue-item-left .content { display:flex; align-items:center; gap:4px; flex:1; min-width:0; }
.conversation-queue-panel .prompt-icon, .conversation-queue-panel .prompt-icon svg { width:16px; height:16px; flex-shrink:0; }
.conversation-queue-panel .conversation-queue-skill-chip { background:var(--wb-color-bg-primary-active,var(--yc-bg-secondary,#eee)); max-width:50%; flex:0 auto; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.conversation-queue-panel .conversation-queue-preview-text { text-overflow:ellipsis; white-space:nowrap; flex:auto; min-width:0; overflow:hidden; }
.conversation-queue-panel .conversation-message-queue-item-actions { display:flex; align-items:center; gap:4px; flex-shrink:0; }
.conversation-queue-panel .icon-btn, .wb-composer__queue-stop { display:inline-flex; align-items:center; justify-content:center; padding:4px; width:24px; height:24px; border:0; border-radius:4px; background:transparent; color:inherit; cursor:pointer; }
.conversation-queue-panel .icon-btn svg, .wb-composer__queue-stop svg { width:16px; height:16px; }
.conversation-queue-panel .icon-btn:disabled { opacity:.4; cursor:default; }
.conversation-queue-panel .icon-btn:hover:not(:disabled), .wb-composer__queue-stop:hover { background:var(--wb-color-bg-primary-active,var(--yc-bg-secondary,#eee)); }
.conversation-queue-panel .conversation-message-queue-item-dragging { opacity:.4; }
.conversation-queue-panel .conversation-message-queue-item-drag-over-before { border-top:2px solid var(--cr-brand-primary,#3370ff); }
.conversation-queue-panel .conversation-message-queue-item-drag-over-after { border-bottom:2px solid var(--cr-brand-primary,#3370ff); }
.yc-queue-notice { display:flex; gap:8px; align-items:center; padding:0 12px 8px; font-size:12px; }
.yc-queue-notice button { flex-shrink:0; color:inherit; background:none; border:0; text-decoration:underline; cursor:pointer; }
@@ -0,0 +1,29 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { CapabilityPickerItem } from './CapabilityPicker'
const command: CapabilityPickerItem = { id: 'compact', name: 'compact', kind: 'command', description: '原生上下文压缩;仅在已有历史且无运行或待发送消息时可用' }
export function useCapabilityCatalog(cwd: string | undefined) {
const [state, setState] = useState<{ cwd?: string; items: CapabilityPickerItem[]; error?: string }>({ cwd, items: [command] })
const epoch = useRef(0)
const refresh = useCallback((): void => {
const version = ++epoch.current
void Promise.allSettled([window.ycode.skills.list(cwd), window.ycode.mcp.list()]).then(([skills, mcp]) => {
if (epoch.current !== version) return
const items: CapabilityPickerItem[] = [command]
const errors: string[] = []
if (skills.status === 'fulfilled') items.push(...skills.value.filter(item => item.enabled && item.userInvocable && !item.error).map(item => ({ id: item.id, name: item.name, description: item.description, kind: 'skill' as const })))
else errors.push(String(skills.reason))
if (mcp.status === 'fulfilled') items.push(...mcp.value.filter(item => item.enabled).map(item => ({ id: item.id, name: item.name, description: item.status === 'connected' ? '已连接的 MCP 服务' : item.error ?? '尚未连接;请在连接器设置中批准并连接', kind: 'mcp' as const })))
else errors.push(String(mcp.reason))
setState({ cwd, items, error: errors.join(';') || undefined })
})
}, [cwd])
useEffect(() => {
refresh()
const unsubscribe = window.ycode.skills.onChanged?.(refresh)
window.addEventListener('focus', refresh)
return () => { ++epoch.current; unsubscribe?.(); window.removeEventListener('focus', refresh) }
}, [refresh])
return { items: state.cwd === cwd ? state.items : [command], error: state.cwd === cwd ? state.error : undefined, refresh }
}
@@ -26,6 +26,9 @@ export interface UserMessageEditContent {
resources: string[]
quotes: MessageQuote[]
skillIds: string[]
/** Capability references are preserved through an edit even though they are edited in the composer. */
mcpServerIds: string[]
command?: 'compact'
userContent: UserContentBlock[]
}
interface Props {
@@ -109,7 +112,8 @@ export function UserMessageEditor({ message, sessionId, onSubmit, onCancel }: Pr
setSubmitting(true)
setSubmitError(null)
void onSubmit(current, { attachments: collections.attachments, resources: collections.resources, quotes: collections.quotes,
skillIds: collections.skills.map(item => item.id), userContent: normalizeUserContent(richRef.current?.content() ?? content, current, collections) })
skillIds: collections.skills.map(item => item.id), mcpServerIds: message.mcpServerIds ?? [], command: message.command,
userContent: normalizeUserContent(richRef.current?.content() ?? content, current, collections) })
.catch((reason: unknown) => {
const detail = reason instanceof Error ? reason.message : String(reason)
setSubmitError(/正在运行|尚未结束|in progress/i.test(detail)
@@ -0,0 +1,16 @@
import type { ChatMessage } from '@shared/types'
export interface MessageCapabilityRefs {
skillIds?: string[]
mcpServerIds?: string[]
command?: 'compact'
}
/** Preserve message-scoped capability references when editing or regenerating history. */
export function messageCapabilityRefs(message: ChatMessage): MessageCapabilityRefs {
return {
skillIds: message.skills?.length ? message.skills.map(item => item.id) : undefined,
mcpServerIds: message.mcpServerIds?.length ? [...message.mcpServerIds] : undefined,
command: message.command
}
}
@@ -43,6 +43,7 @@ export function slateToUserContent(nodes: readonly Descendant[]): UserContentBlo
export interface ReferenceContentEditorHandle {
content(): UserContentBlock[]
deleteTextRange(start: number, end: number): UserContentBlock[]
replaceReference(from: string, next: ReferenceBlock): void
focus(): void
}
@@ -87,7 +88,33 @@ export const ReferenceContentEditor = forwardRef<ReferenceContentEditorHandle, P
}, [editor])
useImperativeHandle(ref, () => ({
content: () => slateToUserContent(editor.children),
focus: () => ReactEditor.focus(editor),
deleteTextRange: (start, end) => {
// Delete text leaves only: inline references are not part of slash offsets.
let offset = 0
const ranges: Array<{ path: number[]; start: number; end: number }> = []
editor.children.forEach((_paragraph, index) => {
if (index) offset++
for (const [node, path] of Editor.nodes(editor, { at: [index], match: Text.isText })) {
if (!Text.isText(node)) continue
const from = Math.max(0, start - offset), to = Math.min(node.text.length, end - offset)
if (to > from) ranges.push({ path, start: from, end: to })
offset += node.text.length
}
})
const caret = ranges[0] ? Editor.pointRef(editor, { path: ranges[0].path, offset: ranges[0].start }, { affinity: 'backward' }) : null
Editor.withoutNormalizing(editor, () => {
for (const range of ranges.reverse()) Transforms.delete(editor, { at: { anchor: { path: range.path, offset: range.start }, focus: { path: range.path, offset: range.end } } })
})
const point = caret?.unref()
if (point) Transforms.select(editor, point)
return slateToUserContent(editor.children)
},
focus: () => {
// Removing a focused picker can leave Slate's focus cache stale; restore
// native focus as well instead of relying only on ReactEditor's cache.
ReactEditor.toDOMNode(editor, editor).focus({ preventScroll: true })
ReactEditor.focus(editor)
},
replaceReference: (from, next) => {
const match = [...Editor.nodes(editor, { at: [], match: node => SlateElement.isElement(node) && node.type === 'reference' && contentReferenceKey(node.reference) === from })][0]
if (match) Transforms.setNodes(editor, { reference: next }, { at: match[1] })
@@ -8,13 +8,15 @@
*
* 内容按用户指定范围:头像与名称、套餐、余额、升级套餐、购买积分、设置、外观、记忆与进化、
* 检查更新、退出登录(不逐项照搬原版的 Buddy加油站/去邀约/成长计划/意见反馈)。
* YCode 没有账号与计费服务:套餐/余额/升级套餐/购买积分/退出登录/检查更新
* 明确标注未接入,不用虚构数值或成功提示代替真实能力;设置、外观与记忆与进化接入真实设置窗口。
* YCode 没有账号与计费服务:套餐/余额/升级套餐/购买积分/退出登录
* 明确标注未接入,不用虚构数值或成功提示代替真实能力;设置、外观、记忆与进化、检查更新接入真实能力。
*/
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { IconAccountAppearance, IconAccountLogout, IconAccountMemory, IconAccountSettings, IconAccountUpdate } from './account-menu-icons'
import { formatUpdateProgress, formatUpdateVersion, hasUnfinishedUpdate, primaryUpdateAction } from '../../components/updates/update-ui-model'
import { runPrimaryUpdateAction, useUpdatesUi } from '../../components/updates/update-ui-store'
import './account-menu-source.css'
export interface AccountMenuAnchor {
@@ -39,6 +41,29 @@ export function AccountMenu({ anchor, name, initial, onClose, onOpenSettings, on
const ref = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{ left: number; bottom: number }>({ left: anchor.left, bottom: 0 })
const [theme, setTheme] = useState<'light' | 'dark'>(() => document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')
const updates = useUpdatesUi()
const updateAction = primaryUpdateAction(updates.state)
const updateProgress = formatUpdateProgress(updates.state.progress)
const updateVersion = formatUpdateVersion(updates.state.version)
const unfinished = hasUnfinishedUpdate(updates.state)
const updateDisabled = updateAction.disabled || updates.busy !== null
const updateValue = updates.state.status === 'downloading'
? (updateProgress === null ? '下载中' : `${updateProgress}%`)
: updates.state.status === 'checking'
? '检查中'
: updates.state.status === 'ready'
? updateVersion || '待安装'
: updates.state.status === 'up-to-date'
? unfinished ? '上次升级未完成' : '已是最新'
: updates.state.status === 'error'
? '失败'
: updates.state.status === 'unsupported'
? '需手动下载'
: updates.state.status === 'available'
? updateVersion || '可下载'
: updates.state.status === 'idle' && updates.state.version && updates.state.dismissedVersion === updates.state.version
? '已忽略'
: unfinished ? '上次升级未完成' : ''
// 「外观」行显示当前主题;主题切换由 useSharedTheme 写在 documentElement.dataset.theme 上。
useEffect(() => {
@@ -127,12 +152,16 @@ export function AccountMenu({ anchor, name, initial, onClose, onOpenSettings, on
<span className="user-menu-item-icon"><IconAccountMemory /></span>
<span className="user-menu-item-label">记忆与进化</span>
</button>
<button type="button" aria-disabled="true" title="尚未接入更新检查"
className="user-menu-item user-menu-item--pending user-menu-item--disabled" role="button"
onClick={() => onUnavailable('「检查更新」尚未接入')}>
<button type="button" aria-disabled={updateDisabled}
title={updates.state.error || (updates.state.status === 'up-to-date' ? '已是最新版本' : '检查并安装 YCode 更新')}
className={`user-menu-item user-menu-item--pending${updateDisabled ? ' user-menu-item--disabled' : ''}`}
data-update-action={updateAction.kind}
data-update-status={updates.state.status}
role="button"
onClick={() => { if (!updateDisabled) void runPrimaryUpdateAction() }}>
<span className="user-menu-item-icon"><IconAccountUpdate /></span>
<span className="user-menu-item-label">检查更新</span>
<span className="user-menu-item-value">未接入</span>
<span className="user-menu-item-label">{updateAction.label}</span>
{updateValue ? <span className="user-menu-item-value">{updateValue}</span> : null}
</button>
</div>
<div className="user-menu-footer">
@@ -4,8 +4,7 @@ export type WorkbenchEscapeAction = 'none' | 'close-menu' | 'exit-fullscreen'
* Resolve Escape precedence for the workbench.
*
* Menus consume Escape first. A focused fullscreen workbench consumes it next.
* Other focused editors retain Escape for their own handling and the application
* generation shortcut.
* Other focused editors retain Escape for their own handling.
*/
export function resolveWorkbenchEscapeAction(input: {
key: string
+42
View File
@@ -50,10 +50,52 @@ export interface SkillRecord {
disableModelInvocation: boolean
source: 'imported' | 'discovered'
userModified: boolean
origin?: SkillInstallOrigin
error?: string
}
export interface SkillInstallOrigin {
recommendationId: string
repository: string
commit: string
packagePath: string
archiveSha256: string
version: string
installedAt: number
}
export interface RecommendedSkill {
id: string
name: string
description: string
author: string
license: string
repository: string
version: string
commit: string
packagePath: string
sha256: string
expectedBytes: number
containsScripts: boolean
avatarResource?: 'archify-owner'
}
export type SkillInstallPhase = 'downloading' | 'validating' | 'installing' | 'installed' | 'cancelled' | 'failed'
export interface SkillInstallProgress {
operationId: string
recommendationId: string
phase: SkillInstallPhase
receivedBytes: number
totalBytes?: number
error?: string
updatedAt: number
}
export interface RecommendedSkillInstallOptions { operationId: string; overwrite?: boolean }
export interface SkillDocument { skill: SkillRecord; content: string; files: string[] }
export interface SkillsApi {
recommendations(): Promise<RecommendedSkill[]>
installRecommended(id: string, options: RecommendedSkillInstallOptions): Promise<SkillRecord>
cancelInstall(operationId: string): Promise<{ cancelled: boolean }>
installState(): Promise<SkillInstallProgress | null>
onInstallProgress(listener: (progress: SkillInstallProgress) => void): () => void
onChanged(listener: () => void): () => void
list(cwd?: string): Promise<SkillRecord[]>
importPath(path?: string, overwrite?: boolean, kind?: 'directory' | 'zip'): Promise<SkillRecord | null>
read(id: string, cwd?: string): Promise<SkillDocument>
+1 -1
View File
@@ -42,7 +42,7 @@ export function normalizeContextUsageBreakdown(value: unknown): ContextUsageBrea
const item = value as Partial<ContextUsageBreakdown>
const categories = ['system','tools','conversation','mcp','skills','other'] as const
if (typeof item.requestId !== 'string' || !nonnegative(item.sequence) || !nonnegative(item.sampledAt)
|| !['model-tokenizer','o200k_base','utf8-estimate'].includes(String(item.method))
|| !['model-tokenizer','o200k_base','utf8-estimate','native-summary'].includes(String(item.method))
|| !['provider-request','prepared-request'].includes(String(item.scope)) || !item.counts
|| categories.some(key=>!nonnegative(item.counts?.[key])) || !nonnegative(item.inputTokens)) return undefined
const counts = Object.fromEntries(categories.map(key=>[key,item.counts![key]])) as ContextUsageBreakdown['counts']
+11
View File
@@ -33,6 +33,7 @@ import type { ExpertsApi, McpApi, SkillsApi } from './capabilities'
import type { ProjectInput, ProjectRecord } from './projects'
import type { AutomationApi } from './automation-api'
import type { LibraryApi } from './library'
import type { UpdatesApi } from './updates'
export interface ConfigPatchLike {
speechBaseUrl?: string
@@ -56,6 +57,12 @@ export interface EditInspection {
}
export interface SendPayload {
/** A durable queue-to-editor handoff, removed only after successful submission. */
queueEditId?: string
/** Display-only labels; the host validates capability IDs again before execution. */
capabilityLabels?: Record<string, string>
mcpServerIds?: string[]
command?: 'compact'
skillIds?: string[]
planId?: string
resources?: string[]
@@ -85,6 +92,8 @@ export interface SendResult {
}
export interface ActiveChatRun extends SendResult {
/** Authoritative history boundary when one native run advances to another input group. */
segmentBoundary?: boolean
sessionId: string
sequence: number
messages: ChatMessage[]
@@ -101,6 +110,7 @@ export interface WorkspaceAttachResult extends WorkspaceRegistryView {
/** preload 暴露给渲染进程的 API 形状 */
export interface YCodeApi {
updates: UpdatesApi
automation: AutomationApi
library: LibraryApi
projects: {
@@ -229,6 +239,7 @@ export interface YCodeApi {
openWindow(sessionId: string): Ipc<void>
}
chat: {
queue: import('./prompt-queue').PromptQueueApi
activeRun(sessionId: string): Ipc<ActiveChatRun | null>
onStarted(listener: (run: ActiveChatRun) => void): () => void
setSessionVisible(sessionId: string | null): Ipc<{ accepted: boolean }>
+28
View File
@@ -0,0 +1,28 @@
import type { SendPayload } from './ipc'
export interface QueuedPrompt {
id: string
createdAt: number
status: 'pending' | 'delivering' | 'unknown'
payload: SendPayload
error?: string
}
export interface PromptQueueSnapshot {
sessionId: string
revision: number
paused: boolean
reason?: string
items: QueuedPrompt[]
editingDraft?: QueuedPrompt
}
export interface PromptQueueApi {
get(sessionId: string): Promise<PromptQueueSnapshot>
enqueue(payload: SendPayload): Promise<PromptQueueSnapshot>
take(sessionId: string, id: string): Promise<QueuedPrompt>
restoreEdit(sessionId: string, id: string): Promise<PromptQueueSnapshot>
remove(sessionId: string, id: string): Promise<PromptQueueSnapshot>
reorder(sessionId: string, ids: string[]): Promise<PromptQueueSnapshot>
resume(sessionId: string): Promise<PromptQueueSnapshot>
sendNow(sessionId: string, id: string): Promise<PromptQueueSnapshot>
onChanged(listener: (snapshot: PromptQueueSnapshot) => void): () => void
}
+4 -1
View File
@@ -23,6 +23,8 @@ export interface ChatMessage {
/** Ordered inline presentation, validated against content and its actual payload collections. */
userContent?: UserContentBlock[]
skills?: Array<{ id: string; name: string; uri: string }>
mcpServerIds?: string[]
command?: 'compact'
assistantDisplay?: import('./capabilities').ExpertBinding
/** The model selected when this assistant turn started; kept with history. */
modelId?: string
@@ -258,7 +260,7 @@ export interface ContextUsageBreakdown {
sampledAt: number
modelId?: string
contextWindow?: number
method: 'model-tokenizer' | 'o200k_base' | 'utf8-estimate'
method: 'model-tokenizer' | 'o200k_base' | 'utf8-estimate' | 'native-summary'
scope: 'provider-request' | 'prepared-request'
counts: Record<ContextUsageCategory, number>
inputTokens: number
@@ -292,6 +294,7 @@ export interface GitBranchInfo {
/** 一次会话的元信息 */
export interface SessionMeta {
permissionMode?: import('./conversation').PermissionMode
projectId?: string
id: string
title: string
+25
View File
@@ -0,0 +1,25 @@
export type UpdateStatus = 'idle' | 'checking' | 'up-to-date' | 'available' | 'downloading' | 'ready' | 'installing' | 'error' | 'unsupported'
export interface UpdateState {
status: UpdateStatus
currentVersion: string
version?: string
notes?: string
progress?: number
error?: string
lastChecked?: string
dismissedVersion?: string
unfinishedVersion?: string
lastExplicit?: boolean
}
export interface UpdatesApi {
getState(): Promise<UpdateState>
check(): Promise<UpdateState>
download(): Promise<UpdateState>
install(): Promise<UpdateState>
dismiss(): Promise<UpdateState>
openDownloadPage(): Promise<void>
openLog(): Promise<void>
onState(listener: (state: UpdateState) => void): () => void
}
+1
View File
@@ -15,6 +15,7 @@ export interface WorkspaceRegistryView {
export type ThinkingLevel = 'off' | 'auto' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
export interface SessionContext {
permissionMode?: import('./conversation').PermissionMode
projectId?: string
workspaceId?: string
cwd?: string