feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
@@ -0,0 +1,300 @@
|
||||
import type { PreviewArtifact } from './artifacts'
|
||||
import type { ChatMessage, SessionMeta } from './types'
|
||||
import { projectWorkBuddyHistoryTool } from './workbuddy-history-projection'
|
||||
|
||||
export type ArtifactSourceKind = 'artifact' | 'present_files' | 'write' | 'edit'
|
||||
export type ArtifactAvailability = 'available' | 'unavailable' | 'unknown'
|
||||
export type LocalArtifactKind =
|
||||
| 'document'
|
||||
| 'spreadsheet'
|
||||
| 'webpage'
|
||||
| 'presentation'
|
||||
| 'pdf'
|
||||
| 'image'
|
||||
| 'audio'
|
||||
| 'video'
|
||||
| 'archive'
|
||||
| 'code'
|
||||
| 'other'
|
||||
|
||||
/** Session metadata and its persisted messages, supplied by the main-process session store. */
|
||||
export interface ArtifactSource extends SessionMeta {
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
export interface ArtifactExtractionContext {
|
||||
sessionId: string
|
||||
cwd: string
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
projectId?: string
|
||||
sourceSessionTitle?: string
|
||||
}
|
||||
|
||||
export interface ArtifactCandidate {
|
||||
id: string
|
||||
sessionId: string
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
workspaceRoot?: string
|
||||
projectId?: string
|
||||
sourceSessionTitle?: string
|
||||
messageId: string
|
||||
sourceKind: ArtifactSourceKind
|
||||
sourceArtifactId?: string
|
||||
sourceToolCallId?: string
|
||||
name: string
|
||||
path: string
|
||||
mimeType?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
preview?: PreviewArtifact
|
||||
}
|
||||
|
||||
export interface ArtifactIndexEntry extends Omit<ArtifactCandidate, 'preview'> {
|
||||
kind: LocalArtifactKind
|
||||
availability: ArtifactAvailability
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface ArtifactPathProbeResult {
|
||||
availability: Exclude<ArtifactAvailability, 'unknown'>
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface ArtifactPathProbeContext {
|
||||
sessionId: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export type ArtifactPathProbe = (
|
||||
path: string,
|
||||
context: ArtifactPathProbeContext
|
||||
) => Promise<ArtifactPathProbeResult>
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
|
||||
const firstString = (...values: unknown[]): string =>
|
||||
(values.find((value) => typeof value === 'string' && value.length) as string | undefined) ?? ''
|
||||
|
||||
const WRITE_TOOLS = new Set(['write_to_file', 'write_file'])
|
||||
const EDIT_TOOLS = new Set(['replace_in_file', 'edit_file', 'append_to_file'])
|
||||
const SOURCE_PRIORITY: Record<ArtifactSourceKind, number> = {
|
||||
artifact: 4,
|
||||
present_files: 3,
|
||||
edit: 2,
|
||||
write: 1
|
||||
}
|
||||
|
||||
/** Normalizes equivalent local paths for identity while preserving the path used to open the file. */
|
||||
export function artifactPathKey(path: string): string {
|
||||
let value = path.replace(/^agent:\/\/(?:files\/)?/, '')
|
||||
if (value.startsWith('file://')) {
|
||||
try {
|
||||
value = decodeURIComponent(new URL(value).pathname).replace(/^\/([a-z]:)/i, '$1')
|
||||
} catch {
|
||||
// Keep the original URI when it cannot be normalized.
|
||||
}
|
||||
}
|
||||
value = value.replace(/\\/g, '/').replace(/\/$/, '')
|
||||
return /^[a-z]:\//i.test(value) ? value.toLowerCase() : value
|
||||
}
|
||||
|
||||
export function joinArtifactPath(cwd: string, path: string): string {
|
||||
return /^(?:[a-z]:[\\/]|\/|[a-z][a-z0-9+.-]*:)/i.test(path)
|
||||
? path
|
||||
: `${cwd.replace(/[\\/]$/, '')}/${path}`
|
||||
}
|
||||
|
||||
export function artifactEntryId(sessionId: string, path: string): string {
|
||||
return `artifact:${sessionId}:${artifactPathKey(path)}`
|
||||
}
|
||||
|
||||
export function artifactFileName(path: string): string {
|
||||
const clean = path.split(/[?#]/, 1)[0].replace(/[\\/]$/, '')
|
||||
return clean.split(/[\\/]/).pop() || clean || '未命名文件'
|
||||
}
|
||||
|
||||
export function classifyArtifact(path: string, mimeType?: string): LocalArtifactKind {
|
||||
const mime = mimeType?.toLowerCase() ?? ''
|
||||
const ext = artifactFileName(path).includes('.')
|
||||
? artifactFileName(path).split('.').pop()?.toLowerCase() ?? ''
|
||||
: ''
|
||||
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'tif', 'avif', 'heic'].includes(ext)) return 'image'
|
||||
if (mime.startsWith('audio/') || ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'wma', 'opus'].includes(ext)) return 'audio'
|
||||
if (mime.startsWith('video/') || ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'flv', 'wmv'].includes(ext)) return 'video'
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'xz', 'zst', 'lz', 'lzma', 'cab', 'iso'].includes(ext)) return 'archive'
|
||||
if (['xls', 'xlt', 'xlsx', 'xltx', 'xlsm', 'xltm', 'csv', 'tsv', 'ods', 'numbers'].includes(ext)) return 'spreadsheet'
|
||||
if (['html', 'htm', 'mhtml'].includes(ext)) return 'webpage'
|
||||
if (['ppt', 'pptx', 'pptm', 'potx', 'potm', 'pot', 'dpt', 'dps', 'pps', 'ppsx', 'odp', 'key'].includes(ext)) return 'presentation'
|
||||
if (ext === 'pdf') return 'pdf'
|
||||
if (['doc', 'dot', 'wps', 'wpt', 'docx', 'dotx', 'docm', 'dotm', 'rtf', 'odt', 'pages', 'md', 'markdown', 'mdx', 'txt', 'text', 'log', 'rst'].includes(ext)) return 'document'
|
||||
if (['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs', 'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'swift', 'c', 'cc', 'cpp', 'cxx', 'h', 'hh', 'hpp', 'cs', 'php', 'lua', 'pl', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'css', 'scss', 'sass', 'less', 'styl', 'json', 'json5', 'yaml', 'yml', 'toml', 'xml', 'env', 'sql', 'graphql', 'gql', 'proto', 'vue', 'svelte', 'astro', 'r', 'm', 'scala', 'dart', 'ex', 'exs', 'erl', 'wasm', 'wxml', 'wxss'].includes(ext)) return 'code'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function candidatePriority(candidate: ArtifactCandidate): number {
|
||||
return SOURCE_PRIORITY[candidate.sourceKind]
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared whitelist for locally produced artifacts.
|
||||
*
|
||||
* Reads, attachments, resources, and directory scans never enter the index. The only accepted
|
||||
* sources are explicit artifact parts, successful present_files calls, and successful writes/edits.
|
||||
*/
|
||||
export function extractArtifactCandidates(
|
||||
messages: readonly ChatMessage[],
|
||||
context: ArtifactExtractionContext
|
||||
): ArtifactCandidate[] {
|
||||
const candidates = new Map<string, ArtifactCandidate>()
|
||||
|
||||
const add = (candidate: ArtifactCandidate): void => {
|
||||
const key = `${candidate.sessionId}\0${artifactPathKey(candidate.path)}`
|
||||
const previous = candidates.get(key)
|
||||
if (!previous
|
||||
|| candidate.updatedAt > previous.updatedAt
|
||||
|| (candidate.updatedAt === previous.updatedAt && candidatePriority(candidate) >= candidatePriority(previous))) {
|
||||
candidates.set(key, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
// Explicit presentation blocks and tool calls are assistant output. User-supplied parts or
|
||||
// attachments must never become artifacts merely because they share the message shape.
|
||||
if (message.role !== 'assistant') continue
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type !== 'artifact') continue
|
||||
const path = joinArtifactPath(context.cwd, part.artifact.path)
|
||||
add({
|
||||
id: artifactEntryId(context.sessionId, path),
|
||||
sessionId: context.sessionId,
|
||||
workspaceId: context.workspaceId,
|
||||
workspaceName: context.workspaceName,
|
||||
workspaceRoot: context.cwd || undefined,
|
||||
projectId: context.projectId,
|
||||
sourceSessionTitle: context.sourceSessionTitle,
|
||||
messageId: message.id,
|
||||
sourceKind: 'artifact',
|
||||
sourceArtifactId: part.artifact.id,
|
||||
name: part.artifact.name || artifactFileName(path),
|
||||
path,
|
||||
mimeType: part.artifact.mimeType,
|
||||
createdAt: message.createdAt,
|
||||
updatedAt: message.createdAt,
|
||||
preview: part.artifact.preview
|
||||
})
|
||||
}
|
||||
|
||||
for (const original of message.tools ?? []) {
|
||||
const tool = projectWorkBuddyHistoryTool(original)
|
||||
if (tool.status !== 'ok') continue
|
||||
const args = record(tool.args)
|
||||
|
||||
if (tool.name === 'present_files' && Array.isArray(args.files)) {
|
||||
for (const file of args.files) {
|
||||
const item = record(file)
|
||||
const rawPath = typeof file === 'string'
|
||||
? file
|
||||
: firstString(item.path, item.filePath, item.file_path)
|
||||
if (!rawPath) continue
|
||||
const path = joinArtifactPath(firstString(args.cwd, context.cwd), rawPath)
|
||||
add({
|
||||
id: artifactEntryId(context.sessionId, path),
|
||||
sessionId: context.sessionId,
|
||||
workspaceId: context.workspaceId,
|
||||
workspaceName: context.workspaceName,
|
||||
workspaceRoot: context.cwd || undefined,
|
||||
projectId: context.projectId,
|
||||
sourceSessionTitle: context.sourceSessionTitle,
|
||||
messageId: message.id,
|
||||
sourceKind: 'present_files',
|
||||
sourceToolCallId: tool.id,
|
||||
name: firstString(item.name, item.title) || artifactFileName(path),
|
||||
path,
|
||||
mimeType: firstString(item.mimeType, item.mime_type) || undefined,
|
||||
createdAt: message.createdAt,
|
||||
updatedAt: message.createdAt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!WRITE_TOOLS.has(tool.name) && !EDIT_TOOLS.has(tool.name)) continue
|
||||
const file = firstString(args.file_path, args.path, args.filePath)
|
||||
const before = firstString(args.old_string, args.oldString, args.old_text)
|
||||
const after = firstString(args.new_string, args.newString, args.new_text, args.content)
|
||||
if (!file || (!before && !after)) continue
|
||||
|
||||
const path = joinArtifactPath(context.cwd, file)
|
||||
const key = artifactPathKey(path)
|
||||
const sourceKind: ArtifactSourceKind = WRITE_TOOLS.has(tool.name) ? 'write' : 'edit'
|
||||
const previous = candidates.get(`${context.sessionId}\0${key}`)
|
||||
const preview: PreviewArtifact = {
|
||||
id: `change:${key}`,
|
||||
type: 'file-changes',
|
||||
title: artifactFileName(path),
|
||||
uri: path,
|
||||
originalContent: previous?.preview?.originalContent ?? before,
|
||||
modifiedContent: after,
|
||||
fileChangeInfo: {
|
||||
uri: path,
|
||||
changeType: tool.name === 'write_to_file' || tool.name === 'write_file' ? 'added' : 'modified'
|
||||
}
|
||||
}
|
||||
add({
|
||||
id: artifactEntryId(context.sessionId, path),
|
||||
sessionId: context.sessionId,
|
||||
workspaceId: context.workspaceId,
|
||||
workspaceName: context.workspaceName,
|
||||
workspaceRoot: context.cwd || undefined,
|
||||
projectId: context.projectId,
|
||||
sourceSessionTitle: context.sourceSessionTitle,
|
||||
messageId: message.id,
|
||||
sourceKind,
|
||||
sourceToolCallId: tool.id,
|
||||
name: artifactFileName(path),
|
||||
path,
|
||||
createdAt: message.createdAt,
|
||||
updatedAt: message.createdAt,
|
||||
preview
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...candidates.values()].sort((a, b) =>
|
||||
b.updatedAt - a.updatedAt || a.path.localeCompare(b.path)
|
||||
)
|
||||
}
|
||||
|
||||
export async function indexArtifactSources(
|
||||
sources: readonly ArtifactSource[],
|
||||
probePath?: ArtifactPathProbe
|
||||
): Promise<ArtifactIndexEntry[]> {
|
||||
const candidates = sources.flatMap((source) => extractArtifactCandidates(source.messages, {
|
||||
sessionId: source.id,
|
||||
cwd: source.cwd ?? '',
|
||||
workspaceId: source.workspaceId,
|
||||
workspaceName: source.cwd ? artifactFileName(source.cwd) : undefined,
|
||||
projectId: source.projectId,
|
||||
sourceSessionTitle: source.title
|
||||
}).map((candidate) => ({
|
||||
candidate,
|
||||
context: { sessionId: source.id, cwd: source.cwd } satisfies ArtifactPathProbeContext
|
||||
})))
|
||||
|
||||
return Promise.all(candidates.map(async ({ candidate, context }): Promise<ArtifactIndexEntry> => {
|
||||
const probe: { availability: ArtifactAvailability; size?: number } = probePath
|
||||
? await probePath(candidate.path, context).catch(() => ({ availability: 'unavailable' }))
|
||||
: { availability: 'unknown' }
|
||||
const { preview: _preview, ...entry } = candidate
|
||||
return {
|
||||
...entry,
|
||||
kind: classifyArtifact(candidate.path, candidate.mimeType),
|
||||
availability: probe.availability,
|
||||
...(typeof probe.size === 'number' && Number.isFinite(probe.size) ? { size: probe.size } : {})
|
||||
}
|
||||
})).then((entries) => entries.sort((a, b) =>
|
||||
b.updatedAt - a.updatedAt || a.path.localeCompare(b.path)
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
AutomationDefinition, AutomationDefinitionCreateInput, AutomationDefinitionPatch,
|
||||
AutomationRun, AutomationRunFilter
|
||||
} from './automation'
|
||||
|
||||
export interface AutomationDefinitionView extends AutomationDefinition {
|
||||
nextRunAt?: string
|
||||
expired: boolean
|
||||
}
|
||||
|
||||
export interface AutomationApi {
|
||||
list(): Promise<AutomationDefinitionView[]>
|
||||
create(input: AutomationDefinitionCreateInput): Promise<AutomationDefinition>
|
||||
update(id: string, patch: AutomationDefinitionPatch): Promise<AutomationDefinition>
|
||||
setEnabled(id: string, enabled: boolean): Promise<AutomationDefinition>
|
||||
remove(id: string): Promise<void>
|
||||
listRuns(filter?: AutomationRunFilter): Promise<AutomationRun[]>
|
||||
/** Acknowledges the claimed run; execution and its session continue in the main process. */
|
||||
runNow(id: string): Promise<AutomationRun>
|
||||
onChanged(listener: () => void): () => void
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { PermissionMode } from './conversation'
|
||||
import type { ThinkingLevel } from './workspaces'
|
||||
|
||||
export const AUTOMATION_RECURRING_FREQUENCIES = [
|
||||
'interval',
|
||||
'daily',
|
||||
'weekly',
|
||||
'biweekly',
|
||||
'monthly',
|
||||
'yearly'
|
||||
] as const
|
||||
|
||||
export type AutomationRecurringFrequency = typeof AUTOMATION_RECURRING_FREQUENCIES[number]
|
||||
export type AutomationFrequency = 'once' | AutomationRecurringFrequency
|
||||
export type AutomationDefinitionStatus = 'ACTIVE' | 'PAUSED'
|
||||
export type AutomationRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'missed'
|
||||
export type AutomationRunTrigger = 'scheduled' | 'manual'
|
||||
|
||||
export interface AutomationOnceSchedule {
|
||||
type: 'once'
|
||||
scheduledAt: string
|
||||
timeZone: string
|
||||
}
|
||||
|
||||
export interface AutomationRecurringSchedule {
|
||||
type: 'recurring'
|
||||
frequency: AutomationRecurringFrequency
|
||||
rrule: string
|
||||
timeZone: string
|
||||
anchorAt: string
|
||||
}
|
||||
|
||||
export type AutomationSchedule = AutomationOnceSchedule | AutomationRecurringSchedule
|
||||
|
||||
/** Execution values frozen into a definition and then into each claimed run. */
|
||||
export interface AutomationExecutionConfig {
|
||||
workspaceId?: string
|
||||
projectId?: string
|
||||
modelId?: string
|
||||
permissionMode?: PermissionMode
|
||||
thinkingLevel?: ThinkingLevel
|
||||
}
|
||||
|
||||
export interface AutomationDefinition {
|
||||
id: string
|
||||
name: string
|
||||
prompt: string
|
||||
status: AutomationDefinitionStatus
|
||||
schedule: AutomationSchedule
|
||||
execution: AutomationExecutionConfig
|
||||
validFrom?: string
|
||||
validUntil?: string
|
||||
/** Monotonic metadata revision. Updates affect future runs only. */
|
||||
revision: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AutomationDefinitionCreateInput {
|
||||
name: string
|
||||
prompt: string
|
||||
status?: AutomationDefinitionStatus
|
||||
schedule: AutomationSchedule
|
||||
execution?: AutomationExecutionConfig
|
||||
validFrom?: string
|
||||
validUntil?: string
|
||||
}
|
||||
|
||||
export interface AutomationDefinitionPatch {
|
||||
name?: string
|
||||
prompt?: string
|
||||
status?: AutomationDefinitionStatus
|
||||
schedule?: AutomationSchedule
|
||||
execution?: AutomationExecutionConfig
|
||||
/** null clears an existing bound. */
|
||||
validFrom?: string | null
|
||||
validUntil?: string | null
|
||||
}
|
||||
|
||||
export interface AutomationDefinitionSnapshot {
|
||||
name: string
|
||||
prompt: string
|
||||
status: AutomationDefinitionStatus
|
||||
schedule: AutomationSchedule
|
||||
execution: AutomationExecutionConfig
|
||||
validFrom?: string
|
||||
validUntil?: string
|
||||
}
|
||||
|
||||
export interface AutomationRun {
|
||||
/** Stable automation-run identity. */
|
||||
id: string
|
||||
automationId: string
|
||||
definitionRevision: number
|
||||
definitionSnapshot: AutomationDefinitionSnapshot
|
||||
trigger: AutomationRunTrigger
|
||||
scheduledFor: string
|
||||
status: AutomationRunStatus
|
||||
/** Chat session created by the injected executor. */
|
||||
sessionId?: string
|
||||
/** Chat turn identity returned by the chat execution service. */
|
||||
runId?: string
|
||||
error?: string
|
||||
createdAt: string
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
}
|
||||
|
||||
export interface AutomationRunClaimInput {
|
||||
automationId: string
|
||||
trigger: AutomationRunTrigger
|
||||
scheduledFor: string
|
||||
}
|
||||
|
||||
export interface AutomationRunFilter {
|
||||
automationId?: string
|
||||
statuses?: AutomationRunStatus[]
|
||||
triggers?: AutomationRunTrigger[]
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface AutomationRunFinishInput {
|
||||
status: Extract<AutomationRunStatus, 'succeeded' | 'failed' | 'cancelled' | 'missed'>
|
||||
sessionId?: string
|
||||
runId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Attach the real chat identity as soon as execution creates it. */
|
||||
export interface AutomationRunSessionAttachment {
|
||||
sessionId?: string
|
||||
runId?: string
|
||||
}
|
||||
|
||||
/** Persisted together so definition and run state always share one atomic write. */
|
||||
export interface AutomationStoreSnapshot {
|
||||
version: 1
|
||||
definitions: AutomationDefinition[]
|
||||
runs: AutomationRun[]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ChatMessage, ContextUsageBreakdown, ContextUsageInfo, ContextUsageSnapshot } from './types'
|
||||
|
||||
const nonnegative = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
const belongsToMessage=(sample:{requestId?:string;modelId?:string}|undefined,message:ChatMessage|undefined):boolean=>!!sample&&!!message
|
||||
&& !(sample.requestId&&message.requestId&&sample.requestId!==message.requestId)
|
||||
&& !(sample.modelId&&message.modelId&&sample.modelId!==message.modelId)
|
||||
|
||||
/** Persisted history is optional and may predate usage support; unknown stays unknown. */
|
||||
export function normalizeContextUsageSnapshot(value: unknown): ContextUsageSnapshot | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const sample = value as Partial<ContextUsageSnapshot>
|
||||
const usage = sample.usage
|
||||
if (!usage || !nonnegative(usage.inputTokens) || !nonnegative(usage.outputTokens) || !nonnegative(usage.totalTokens)) return undefined
|
||||
return {
|
||||
usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, totalTokens: usage.totalTokens,
|
||||
...(nonnegative(usage.cachedTokens) ? { cachedTokens: usage.cachedTokens } : {}) },
|
||||
...(typeof sample.contextWindow === 'number' && Number.isFinite(sample.contextWindow) && sample.contextWindow > 0
|
||||
? { contextWindow: sample.contextWindow } : {}),
|
||||
...(['upstream','native-history','native-state'].includes(String(sample.source)) ? {source:sample.source} : {}),
|
||||
...(nonnegative(sample.sampledAt) ? {sampledAt:sample.sampledAt} : {}),
|
||||
...(typeof sample.requestId === 'string' ? {requestId:sample.requestId} : {}),
|
||||
...(typeof sample.modelId === 'string' ? {modelId:sample.modelId} : {}),
|
||||
...(typeof sample.nativeEntryId === 'string' ? {nativeEntryId:sample.nativeEntryId} : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Read only the currently selected history branch; no estimates or cross-model denominators. */
|
||||
export function lastContextUsage(messages: readonly ChatMessage[]): ContextUsageSnapshot | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index]
|
||||
if (message.role === 'user') return undefined
|
||||
if (message.role !== 'assistant') continue
|
||||
// An older request is not the current request merely because the latter omitted usage.
|
||||
const sample=normalizeContextUsageSnapshot(message.contextUsage)
|
||||
return belongsToMessage(sample,message)?sample:undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeContextUsageBreakdown(value: unknown): ContextUsageBreakdown | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
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))
|
||||
|| !['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']
|
||||
if (Math.abs(Object.values(counts).reduce((sum,count)=>sum+count,0)-item.inputTokens)>1) return undefined
|
||||
return {requestId:item.requestId,sequence:item.sequence,sampledAt:item.sampledAt,method:item.method!,scope:item.scope!,counts,inputTokens:item.inputTokens,partial:item.partial===true,
|
||||
...(typeof item.modelId==='string'?{modelId:item.modelId}:{}),
|
||||
...(nonnegative(item.contextWindow)&&item.contextWindow>0?{contextWindow:item.contextWindow}:{})}
|
||||
}
|
||||
|
||||
export function contextInfoFromMessages(messages: readonly ChatMessage[]): ContextUsageInfo {
|
||||
const latest = [...messages].reverse().find(message=>message.role==='assistant')
|
||||
const normalizedSample = normalizeContextUsageSnapshot(latest?.contextUsage)
|
||||
const normalizedBreakdown = normalizeContextUsageBreakdown(latest?.contextBreakdown)
|
||||
const sample=belongsToMessage(normalizedSample,latest)?normalizedSample:undefined
|
||||
const breakdown=belongsToMessage(normalizedBreakdown,latest)?normalizedBreakdown:undefined
|
||||
const pendingUser=messages.findLastIndex(message=>message.role==='user')>messages.findLastIndex(message=>message.role==='assistant')
|
||||
return {sample,breakdown,status:sample||breakdown?(pendingUser?'previous':'latest'):'unavailable',
|
||||
...(pendingUser?{reason:'新消息尚无对应统计'}:{})}
|
||||
}
|
||||
@@ -11,8 +11,15 @@ export interface ChatAttachment {
|
||||
size: number
|
||||
previewUrl?: string
|
||||
path?: string
|
||||
/** Original selected file path, for display only. Never used to read attachment contents. */
|
||||
sourcePath?: string
|
||||
}
|
||||
|
||||
/** Ordered presentation only. Referenced payloads stay in their existing, typed collections. */
|
||||
export type UserContentBlock =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'attachment' | 'quote' | 'resource' | 'skill'; id: string }
|
||||
|
||||
/**
|
||||
* 消息引用(WorkBuddy `message.quote.add`)。
|
||||
* 表格引用保存 markdown 快照,发送后随用户消息一起持久化。
|
||||
@@ -28,7 +35,7 @@ export interface MessageQuote {
|
||||
markdown: string
|
||||
/** 引用来源标题(原版 display.title);未采集到原版取值时留空,不臆造文本。 */
|
||||
title?: string
|
||||
/** 输入区与消息气泡中的展示摘要。 */
|
||||
/** Legacy snapshot summary; table labels are resolved by kind, never from raw Markdown. */
|
||||
preview: string
|
||||
}
|
||||
|
||||
|
||||
+68
-5
@@ -17,15 +17,23 @@ import type {
|
||||
PersonalizationPatch,
|
||||
PersonalizationSettings,
|
||||
AgentSettings,
|
||||
AgentSettingsPatch
|
||||
AgentSettingsPatch,
|
||||
MemoryPreferencesPatch,
|
||||
MemorySettingsView,
|
||||
TokenUsage
|
||||
} from './types'
|
||||
import type { ContextUsageInfo } from './types'
|
||||
import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources'
|
||||
import type { ChatAttachment, InteractionResponse, MessageQuote, PermissionMode } from './conversation'
|
||||
import type { CustomModelInput, ModelRegistryView, ModelTestResult } from './models'
|
||||
import type { SandboxStatus, SandboxPolicyDocument, SandboxPolicyUpdate, SandboxSelfTestSummary, SandboxOperationView, SandboxBrokerOperationView, SandboxWorkspaceView, SandboxGitHostView, SandboxGitRemoteResult } from './sandbox'
|
||||
import type { CustomModelInput, ModelRegistryView, ModelTestResult, ModelDiscoveryInput, ModelDiscoveryResult, ModelMetadata, ModelMetadataStatus, ModelProtocol } from './models'
|
||||
import type { SessionContext, WorkspaceRegistryView } from './workspaces'
|
||||
import type { BrowserHostApi } from './browser-host'
|
||||
import type { TerminalHostApi } from './terminal-host'
|
||||
import type { ExpertsApi, McpApi, SkillsApi } from './capabilities'
|
||||
import type { ProjectInput, ProjectRecord } from './projects'
|
||||
import type { AutomationApi } from './automation-api'
|
||||
import type { LibraryApi } from './library'
|
||||
|
||||
export interface ConfigPatchLike {
|
||||
speechBaseUrl?: string
|
||||
@@ -39,6 +47,15 @@ export interface ConfigPatchLike {
|
||||
|
||||
export type EditAction = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'selectAll'
|
||||
|
||||
export interface RewindOptions {
|
||||
rollbackArtifacts?: boolean
|
||||
}
|
||||
|
||||
export interface EditInspection {
|
||||
editable: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface SendPayload {
|
||||
skillIds?: string[]
|
||||
planId?: string
|
||||
@@ -48,10 +65,13 @@ export interface SendPayload {
|
||||
attachments?: ChatAttachment[]
|
||||
/** 表格等内容的引用快照,随用户消息持久化。 */
|
||||
quotes?: MessageQuote[]
|
||||
userContent?: import('./conversation').UserContentBlock[]
|
||||
permissionMode?: PermissionMode
|
||||
/** Retry/edit from this message; descendants are preserved in an archived branch. */
|
||||
fromMessageId?: string
|
||||
action?: 'edit' | 'resend' | 'regenerate'
|
||||
/** Editing can retain the current files after an explicit user choice. */
|
||||
rollbackArtifacts?: boolean
|
||||
}
|
||||
|
||||
export interface SendResult {
|
||||
@@ -65,6 +85,13 @@ export interface SendResult {
|
||||
messages?: ChatMessage[]
|
||||
}
|
||||
|
||||
export interface ActiveChatRun extends SendResult {
|
||||
sessionId: string
|
||||
sequence: number
|
||||
messages: ChatMessage[]
|
||||
usage?: TokenUsage
|
||||
}
|
||||
|
||||
/** 统一的返回包装,便于渲染进程处理错误而不炸掉 UI */
|
||||
export type Ipc<T> = Promise<T>
|
||||
|
||||
@@ -75,6 +102,13 @@ export interface WorkspaceAttachResult extends WorkspaceRegistryView {
|
||||
|
||||
/** preload 暴露给渲染进程的 API 形状 */
|
||||
export interface YCodeApi {
|
||||
automation: AutomationApi
|
||||
library: LibraryApi
|
||||
projects: {
|
||||
list(): Ipc<ProjectRecord[]>
|
||||
create(input: ProjectInput): Ipc<ProjectRecord>
|
||||
update(projectId: string, patch: Partial<ProjectInput>): Ipc<ProjectRecord>
|
||||
}
|
||||
experts: ExpertsApi
|
||||
skills: SkillsApi
|
||||
mcp: McpApi
|
||||
@@ -93,10 +127,23 @@ export interface YCodeApi {
|
||||
}
|
||||
app: {
|
||||
info(): Ipc<AppInfo>
|
||||
sandboxStatus(): Ipc<SandboxStatus>
|
||||
sandboxPolicy(): Ipc<SandboxPolicyDocument>
|
||||
setSandboxPolicy(update: Pick<SandboxPolicyUpdate, 'expectedRevision' | 'rules'>): Ipc<SandboxPolicyDocument>
|
||||
sandboxSelfTest(): Ipc<SandboxSelfTestSummary>
|
||||
sandboxOperation(id: string): Ipc<SandboxOperationView>
|
||||
sandboxBrokerOperation(runId: string, requestId: string): Ipc<SandboxBrokerOperationView>
|
||||
sandboxWorkspace(sessionId: string): Ipc<SandboxWorkspaceView>
|
||||
sandboxGit(sessionId: string): Ipc<SandboxGitHostView>
|
||||
sandboxGitRemoteOperation(sessionId: string, operationId: string): Ipc<SandboxGitRemoteResult>
|
||||
onSandboxChanged(listener: () => void): () => void
|
||||
openConfigDir(): Ipc<void>
|
||||
openSettings(section?: string, action?: 'new'): Ipc<void>
|
||||
onSettingsChanged(listener: () => void): () => void
|
||||
onSettingsNavigate(listener: (section: string, action?: 'new') => void): () => void
|
||||
settingsReady(): Ipc<void>
|
||||
settingsRendered(requestId: number): Ipc<void>
|
||||
onSettingsClosed(listener: () => void): () => void
|
||||
onSettingsNavigate(listener: (section: string, action?: 'new', requestId?: number) => void): () => void
|
||||
}
|
||||
git: {
|
||||
/** 读取某个会话工作目录的真实分支信息;目录不在仓库内或 git 不可用时返回 repo:false/unavailable。 */
|
||||
@@ -127,6 +174,12 @@ export interface YCodeApi {
|
||||
get(): Ipc<AgentSettings>
|
||||
update(patch: AgentSettingsPatch): Ipc<AgentSettings>
|
||||
}
|
||||
/** 设置 → 记忆与进化:偏好、长期记忆正文与导入。 */
|
||||
memory: {
|
||||
get(): Ipc<MemorySettingsView>
|
||||
update(patch: MemoryPreferencesPatch): Ipc<MemorySettingsView>
|
||||
import(input: { content: string }): Ipc<MemorySettingsView>
|
||||
}
|
||||
workspace: {
|
||||
get(): Ipc<WorkspaceInfo>
|
||||
choose(): Ipc<WorkspaceInfo & { changed: boolean }>
|
||||
@@ -139,6 +192,10 @@ export interface YCodeApi {
|
||||
open(id: string): Ipc<void>
|
||||
}
|
||||
models: {
|
||||
fetch(input: ModelDiscoveryInput): Ipc<ModelDiscoveryResult>
|
||||
metadata(model: string, protocol?: ModelProtocol): Ipc<ModelMetadata | null>
|
||||
metadataStatus(): Ipc<ModelMetadataStatus>
|
||||
refreshMetadata(): Ipc<ModelMetadataStatus>
|
||||
list(): Ipc<ModelRegistryView>
|
||||
save(input: CustomModelInput, overwrite?: boolean): Ipc<ModelRegistryView>
|
||||
remove(id: string): Ipc<ModelRegistryView>
|
||||
@@ -158,6 +215,7 @@ export interface YCodeApi {
|
||||
attachments: {
|
||||
pick(): Ipc<ChatAttachment[]>
|
||||
stage(payload: { name: string; mimeType: string; dataBase64: string }): Ipc<ChatAttachment>
|
||||
preview(id: string): Ipc<WorkspaceResource>
|
||||
remove(id: string): Ipc<void>
|
||||
}
|
||||
speech: {
|
||||
@@ -169,8 +227,9 @@ export interface YCodeApi {
|
||||
create(title?: string, context?: SessionContext): Ipc<SessionMeta>
|
||||
updateContext(id: string, context: SessionContext): Ipc<SessionMeta>
|
||||
messages(sessionId: string): Ipc<ChatMessage[]>
|
||||
contextUsage(sessionId: string): Ipc<ContextUsageInfo>
|
||||
rename(sessionId: string, title: string): Ipc<void>
|
||||
remove(sessionId: string): Ipc<void>
|
||||
remove(sessionId: string, options?: { onlyIfEmpty?: boolean }): Ipc<void>
|
||||
/** 置顶/取消置顶;返回刷新后的元信息。 */
|
||||
setPinned(sessionId: string, pinned: boolean): Ipc<SessionMeta>
|
||||
/** 归档/取消归档;不删除消息与工作目录。 */
|
||||
@@ -181,10 +240,14 @@ export interface YCodeApi {
|
||||
openWindow(sessionId: string): Ipc<void>
|
||||
}
|
||||
chat: {
|
||||
revert(sessionId: string, userMessageId: string): Ipc<{ messages: ChatMessage[] }>
|
||||
activeRun(sessionId: string): Ipc<ActiveChatRun | null>
|
||||
onStarted(listener: (run: ActiveChatRun) => void): () => void
|
||||
inspectEdit(sessionId: string, userMessageId: string): Ipc<EditInspection>
|
||||
revert(sessionId: string, userMessageId: string, options?: RewindOptions): Ipc<{ messages: ChatMessage[] }>
|
||||
feedback(sessionId: string, messageId: string, value: 'like' | 'dislike'): Ipc<void>
|
||||
send(payload: SendPayload): Ipc<SendResult>
|
||||
cancel(runId: string): Ipc<{ cancelled: boolean }>
|
||||
cancelAgent(runId: string, agentId: string): Ipc<{ cancelled: boolean }>
|
||||
respond(runId: string, interactionId: string, response: InteractionResponse): Ipc<{ accepted: boolean }>
|
||||
/** 返回取消订阅函数 */
|
||||
onEvent(listener: (event: ChatEvent) => void): () => void
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ArtifactIndexEntry } from './artifact-index'
|
||||
|
||||
export type LibraryView = 'recent' | 'local'
|
||||
|
||||
/** Artifact index data enriched with the user's local library state. */
|
||||
export interface LibraryArtifactEntry extends ArtifactIndexEntry {
|
||||
favorite: boolean
|
||||
recentAt?: number
|
||||
}
|
||||
|
||||
export interface LibrarySnapshot {
|
||||
artifacts: LibraryArtifactEntry[]
|
||||
generatedAt: number
|
||||
}
|
||||
|
||||
export interface LibraryRecentRecord {
|
||||
artifactId: string
|
||||
openedAt: number
|
||||
}
|
||||
|
||||
/** Renderer-visible persisted state. Missing artifacts stay recorded but are excluded until indexed again. */
|
||||
export interface LibraryUserState {
|
||||
version: 1
|
||||
favoriteArtifactIds: string[]
|
||||
recent: LibraryRecentRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Main coordinator contract.
|
||||
*
|
||||
* `getSnapshot` rebuilds or refreshes the derived artifact index. Mutations persist only library
|
||||
* state and never rewrite session history or artifact files.
|
||||
*/
|
||||
export interface LibraryApi {
|
||||
getSnapshot(): Promise<LibrarySnapshot>
|
||||
setFavorite(artifactId: string, favorite: boolean): Promise<LibraryUserState>
|
||||
recordRecent(artifactId: string, openedAt?: number): Promise<LibraryUserState>
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { MemoryPreferences } from './types'
|
||||
|
||||
/** REA 回收自 WorkBuddy 5.8.3 zh-cn-C7BTHfrQ.js。 */
|
||||
export const MEMORY_IMPORT_PROMPT_TEMPLATE = `请帮我整理一份我的个人使用画像,用途是让我在不同 AI 工具之间保持一致的协作体验。请基于你当前能访问到的、与我相关的长期信息和本次会话上下文进行整理。在涉及我的指令和偏好时,请尽量保留我原本的表述方式,不要过度改写。
|
||||
|
||||
分类(按以下顺序输出)
|
||||
指令:我明确要求遵循的规则,包括语气、格式、风格、"始终做 X"、"绝不做 Y" 以及对助手行为的纠正。仅整理可从长期记忆中明确识别并客观存在的规则,不临时新增、不强加、不脑补未明确提出的要求。
|
||||
|
||||
身份:姓名、年龄、所在地、教育背景、家庭、人际关系、语言能力和个人兴趣(仅包含我主动分享过的非敏感信息,不输出证件号、联系方式、账号等隐私数据)。
|
||||
|
||||
职业:当前和过往的职位、公司以及主要技能领域。
|
||||
|
||||
项目:我实际参与构建或投入精力的项目。每个项目一条。包含项目功能、当前状态以及关键决策。以项目名称或简短描述作为条目开头。
|
||||
|
||||
偏好:广泛适用的观点、品味和工作风格偏好。
|
||||
|
||||
格式
|
||||
使用分类标题作为每个类别的节标题。每个类别内,每行一条记录,按日期从早到晚排列。每行格式:
|
||||
|
||||
[YYYY-MM-DD] - 条目内容
|
||||
|
||||
如果日期未知,使用 [unknown] 代替。
|
||||
|
||||
输出
|
||||
将整个画像包裹在一个代码块中,方便我复制。
|
||||
代码块之后,简要说明:这是否已覆盖你当前能整理出的全部相关信息;若还有未纳入的维度或你不确定的条目,请列出来,由我判断是否补充。`
|
||||
|
||||
export const MEMORY_IMPORT_MIN_LENGTH = 20
|
||||
|
||||
export const DEFAULT_MEMORY_PREFERENCES: MemoryPreferences = {
|
||||
conversationMemory: true,
|
||||
localMemorySkills: false
|
||||
}
|
||||
|
||||
export function normalizeMemoryPreferences(value: Partial<MemoryPreferences> | null | undefined): MemoryPreferences {
|
||||
return {
|
||||
conversationMemory: typeof value?.conversationMemory === 'boolean'
|
||||
? value.conversationMemory
|
||||
: DEFAULT_MEMORY_PREFERENCES.conversationMemory,
|
||||
localMemorySkills: typeof value?.localMemorySkills === 'boolean'
|
||||
? value.localMemorySkills
|
||||
: DEFAULT_MEMORY_PREFERENCES.localMemorySkills
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge a partial stored view/patch without allowing an omitted key to overwrite the other preference. */
|
||||
export function mergeMemoryPreferences(
|
||||
stored: Partial<MemoryPreferences> | null | undefined,
|
||||
patch: Partial<MemoryPreferences> | null | undefined = {}
|
||||
): MemoryPreferences {
|
||||
const next = normalizeMemoryPreferences({ ...DEFAULT_MEMORY_PREFERENCES, ...(stored ?? {}) })
|
||||
if (typeof patch?.conversationMemory === 'boolean') next.conversationMemory = patch.conversationMemory
|
||||
if (typeof patch?.localMemorySkills === 'boolean') next.localMemorySkills = patch.localMemorySkills
|
||||
return next
|
||||
}
|
||||
|
||||
export function validateImportedMemory(value: unknown): string | null {
|
||||
const content = typeof value === 'string' ? value.trim() : ''
|
||||
if (!content) return '请先粘贴要导入的内容'
|
||||
if (content.length < MEMORY_IMPORT_MIN_LENGTH) return '内容过短,请粘贴完整的记忆内容'
|
||||
return null
|
||||
}
|
||||
|
||||
/** 导入内容是完整画像时替换空记忆;已有内容时保留旧记录并追加。 */
|
||||
export function appendImportedMemory(existing: string, imported: string): string {
|
||||
const next = imported.trim()
|
||||
const current = existing.trim()
|
||||
if (!current) return next
|
||||
return `${current}\n\n${next}`
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChatEvent, ChatMessage, MessagePart, ToolCallRecord } from './types'
|
||||
import { normalizeContextUsageSnapshot, normalizeContextUsageBreakdown } from './context-usage'
|
||||
|
||||
/** 老会话只有合并正文与工具数组;不臆造其已经丢失的发生顺序。 */
|
||||
export function getMessageParts(message: ChatMessage): MessagePart[] {
|
||||
@@ -12,6 +13,18 @@ export function getMessageParts(message: ChatMessage): MessagePart[] {
|
||||
/** 主进程持久化和渲染进程使用同一事件归约,避免重载前后内容顺序不同。 */
|
||||
export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): ChatMessage {
|
||||
switch (event.type) {
|
||||
case 'run_usage': return { ...message, runUsage: event.usage }
|
||||
case 'usage': {
|
||||
const contextUsage = normalizeContextUsageSnapshot({ usage: event.usage, contextWindow: event.contextWindow,
|
||||
source:event.source,sampledAt:event.sampledAt,requestId:event.runId,modelId:event.modelId,nativeEntryId:event.nativeEntryId })
|
||||
return contextUsage ? { ...message, contextUsage } : message
|
||||
}
|
||||
case 'context_breakdown': {
|
||||
const contextBreakdown = normalizeContextUsageBreakdown(event.breakdown)
|
||||
if (!contextBreakdown || contextBreakdown.requestId!==event.runId || (message.requestId&&message.requestId!==event.runId)
|
||||
|| (message.contextBreakdown?.sequence ?? -1) >= contextBreakdown.sequence) return message
|
||||
return {...message,contextBreakdown,contextUsage:undefined}
|
||||
}
|
||||
case 'part': {
|
||||
const parts = [...getMessageParts(message)]
|
||||
const identity = (part: MessagePart): string | undefined => {
|
||||
@@ -19,6 +32,7 @@ export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): Cha
|
||||
if (part.type === 'plan') return `plan:${part.plan.id}`
|
||||
if (part.type === 'task') return `task:${part.task.id}`
|
||||
if (part.type === 'artifact') return `artifact:${part.artifact.id}`
|
||||
if (part.type === 'subagent') return `subagent:${part.agent.id}`
|
||||
return undefined
|
||||
}
|
||||
const key = identity(event.part)
|
||||
@@ -80,7 +94,11 @@ export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): Cha
|
||||
tool.status === 'running' ? { ...tool, status } : tool
|
||||
)
|
||||
const interactions = message.interactions?.map((item) => item.status === 'pending' || item.status === 'responding' ? { ...item, status: 'cancelled' as const } : item)
|
||||
return { ...message, tools, interactions, status, error: event.type === 'error' ? event.message : message.error, ...terminalDuration(event) }
|
||||
const parts = message.parts?.map((part): MessagePart => part.type === 'subagent' && ['queued', 'running'].includes(part.agent.status)
|
||||
? { ...part, agent: { ...part.agent, status: 'cancelled', finishedAt: Date.now(),
|
||||
tools: part.agent.tools.map(tool => tool.status === 'running' ? { ...tool, status: 'cancelled' } : tool),
|
||||
events: part.agent.events.map(item => item.status === 'running' ? { ...item, status: 'cancelled' } : item) } } : part)
|
||||
return { ...message, tools, interactions, parts, status, error: event.type === 'error' ? event.message : message.error, ...terminalDuration(event) }
|
||||
}
|
||||
default:
|
||||
return message
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ModelApi, ModelEndpoint, ModelProtocol } from './models'
|
||||
|
||||
export function normalizeModelAddress(raw: string): string {
|
||||
const value = raw.trim()
|
||||
if (!value) throw new Error('请输入接口域名')
|
||||
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `${/^(localhost(?=[:/]|$)|127\.\d+\.\d+\.\d+(?=[:/]|$)|\[::1\](?=[:/]|$))/i.test(value) ? 'http' : 'https'}://${value}`)
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('接口地址需要 http/https 域名,不能包含账户密码、查询参数或片段')
|
||||
return url.href.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function apiProtocol(api: ModelApi): Exclude<ModelProtocol, 'auto'> {
|
||||
return api === 'anthropic-messages' ? 'anthropic' : api === 'google-generative-ai' ? 'gemini' : 'openai'
|
||||
}
|
||||
|
||||
export function inferModelApi(address: string, protocol: ModelProtocol = 'auto'): ModelApi {
|
||||
const url = new URL(normalizeModelAddress(address))
|
||||
if (protocol === 'anthropic' || protocol === 'auto' && (/anthropic\.com$/i.test(url.hostname) || /\/messages$/i.test(url.pathname))) return 'anthropic-messages'
|
||||
if (protocol === 'gemini' || protocol === 'auto' && (/generativelanguage\.googleapis\.com$/i.test(url.hostname) || /\/v1beta(?:\/|$)/i.test(url.pathname))) return 'google-generative-ai'
|
||||
return /\/chat\/completions$/i.test(url.pathname) ? 'openai-completions' : 'openai-responses'
|
||||
}
|
||||
|
||||
export function modelEndpoint(address: string, api: ModelApi, rootIsResolved = false): ModelEndpoint {
|
||||
const url = new URL(normalizeModelAddress(address))
|
||||
let path = url.pathname.replace(/\/+$/, '').replace(/\/(chat\/completions|responses|messages|models)$/i, '')
|
||||
path = path.replace(/\/models\/[^/]+:(?:streamGenerateContent|generateContent)$/i, '')
|
||||
if (!path && !rootIsResolved) path = api === 'google-generative-ai' ? '/v1beta' : '/v1'
|
||||
url.pathname = path || '/'
|
||||
return { api, baseUrl: url.href.replace(/\/$/, ''), checkedAt: Date.now() }
|
||||
}
|
||||
|
||||
export function endpointUrl(endpoint: ModelEndpoint, operation: 'models' | 'generate', model = '', stream = false): string {
|
||||
const suffix = operation === 'models' ? '/models' : endpoint.api === 'anthropic-messages' ? '/messages'
|
||||
: endpoint.api === 'google-generative-ai' ? `/models/${encodeURIComponent(model.replace(/^models\//, ''))}:${stream ? 'streamGenerateContent' : 'generateContent'}`
|
||||
: endpoint.api === 'openai-responses' ? '/responses' : '/chat/completions'
|
||||
const url = new URL(endpoint.baseUrl + suffix)
|
||||
if (operation === 'generate' && endpoint.api === 'google-generative-ai' && stream) url.searchParams.set('alt', 'sse')
|
||||
return url.href
|
||||
}
|
||||
|
||||
export function endpointCandidates(address: string, protocol: ModelProtocol = 'auto'): ModelEndpoint[] {
|
||||
const normalized = normalizeModelAddress(address), inferred = inferModelApi(normalized, protocol)
|
||||
const apis: ModelApi[] = protocol === 'auto' ? [inferred, 'openai-responses', 'openai-completions', 'anthropic-messages', 'google-generative-ai']
|
||||
: protocol === 'openai' ? [inferred, inferred === 'openai-responses' ? 'openai-completions' : 'openai-responses'] : [inferred]
|
||||
const seen = new Set<string>(), result: ModelEndpoint[] = []
|
||||
for (const api of apis) {
|
||||
const primary = modelEndpoint(normalized, api), bare = modelEndpoint(normalized, api, true)
|
||||
const version = api === 'google-generative-ai' ? '/v1beta' : '/v1'
|
||||
const versioned = /\/v1(?:beta)?$/i.test(bare.baseUrl) ? [] : [{ ...bare, baseUrl: bare.baseUrl + version }]
|
||||
for (const root of [primary, ...versioned, bare]) {
|
||||
const key = root.api + ':' + root.baseUrl
|
||||
if (!seen.has(key)) { seen.add(key); result.push(root) }
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function modelHeaders(api: ModelApi, key: string): Record<string, string> {
|
||||
return { 'content-type': 'application/json', ...(api === 'anthropic-messages'
|
||||
? { 'anthropic-version': '2023-06-01', ...(key ? { 'x-api-key': key } : {}) }
|
||||
: api === 'google-generative-ai' ? key ? { 'x-goog-api-key': key } : {} : key ? { authorization: `Bearer ${key}` } : {}) }
|
||||
}
|
||||
|
||||
/** Modern OpenAI reasoning models require the completion budget field. */
|
||||
export function openAICompletionTokenField(model: string): 'max_tokens' | 'max_completion_tokens' {
|
||||
return /^(?:o[134](?:-|$)|gpt-(?:[5-9]|\d{2,})(?:[.-]|$))/i.test(model.split('/').at(-1) ?? '') ? 'max_completion_tokens' : 'max_tokens'
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ModelMetadata, ModelPrice, ModelProtocol, ModelReasoningOption } from './models'
|
||||
|
||||
export interface CatalogModel {
|
||||
id: string; name?: string; canonical_model_id?: string; reasoning?: boolean; tool_call?: boolean; temperature?: boolean
|
||||
modalities?: { input?: string[] }; reasoning_options?: ModelReasoningOption[]
|
||||
limit?: { context?: number; input?: number; output?: number }
|
||||
cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number; context_over_200k?: CatalogModel['cost']; tiers?: Array<NonNullable<CatalogModel['cost']> & { tier: { type: string; size: number } }> }
|
||||
}
|
||||
export type ModelCatalog = Record<string, { id: string; models: Record<string, CatalogModel> }>
|
||||
const positive = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : undefined
|
||||
const order = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
||||
|
||||
/** Context bands are lower thresholds; cap the default window at the next band boundary. */
|
||||
function modelPriceTiers(model: CatalogModel): ModelPrice[] {
|
||||
const cost = model.cost
|
||||
if (!cost || ![cost.input, cost.output].every(value => typeof value === 'number' && Number.isFinite(value) && value >= 0)) return []
|
||||
const tiers = [...(cost.tiers ?? []), ...(!cost.tiers?.length && cost.context_over_200k ? [{ ...cost.context_over_200k, tier: { type: 'context', size: 200000 } }] : [])]
|
||||
.filter(tier => tier.tier?.type === 'context' && positive(tier.tier.size) && [tier.input, tier.output].every(value => typeof value === 'number' && Number.isFinite(value) && value >= 0) && tier.tier.size < (positive(model.limit?.context) ?? Infinity))
|
||||
.sort((a, b) => a.tier.size - b.tier.size)
|
||||
const bands = [{ ...cost, lower: 0 }, ...tiers.map(tier => ({ ...tier, lower: tier.tier.size }))]
|
||||
return bands.map((band, index): ModelPrice => ({ input: band.input!, output: band.output!, cacheRead: band.cache_read, cacheWrite: band.cache_write,
|
||||
minContext: band.lower || undefined, maxContext: tiers[index]?.tier.size ?? positive(model.limit?.context) }))
|
||||
}
|
||||
|
||||
export function cheapestModelPrice(model: CatalogModel): ModelPrice | undefined {
|
||||
const candidates = modelPriceTiers(model)
|
||||
if (!candidates.length) return undefined
|
||||
const cheapest = candidates.find(candidate => candidates.every(other => candidate.input <= other.input && candidate.output <= other.output))
|
||||
// Crossed input/output prices have no universally cheapest tier. Avoid
|
||||
// expanding the default context based on an invented input/output ratio.
|
||||
return cheapest ?? { ...candidates[0], crossedTiers: true }
|
||||
}
|
||||
|
||||
export function lookupModelMetadata(catalog: ModelCatalog, modelId: string, protocol: ModelProtocol, source: { fetchedAt: string; sha256: string }): ModelMetadata | null {
|
||||
const normalized = modelId.trim().replace(/^models\//, '')
|
||||
if (!normalized) return null
|
||||
const prefix = normalized.split('/')[0]
|
||||
const explicit = Object.hasOwn(catalog, prefix) ? prefix : undefined
|
||||
const localId = explicit ? normalized.slice(prefix.length + 1) : normalized
|
||||
const preferred = protocol === 'anthropic' ? 'anthropic' : protocol === 'gemini' ? 'google'
|
||||
: /^(claude[-/])/i.test(localId) ? 'anthropic' : /^gemini[-/]/i.test(localId) ? 'google' : /^(gpt-|o[134][-/]|chatgpt-)/i.test(localId) ? 'openai' : undefined
|
||||
const matches = Object.entries(catalog).flatMap(([providerId, provider]) => {
|
||||
const model = Object.hasOwn(provider.models, normalized) ? provider.models[normalized]
|
||||
: Object.hasOwn(provider.models, localId) ? provider.models[localId] : undefined
|
||||
return model && typeof model === 'object' && typeof model.id === 'string' ? [{ providerId, model }] : []
|
||||
}).sort((a, b) => Number(b.providerId === explicit) - Number(a.providerId === explicit) || Number(b.providerId === preferred) - Number(a.providerId === preferred) || a.providerId.localeCompare(b.providerId))
|
||||
let match = matches[0]
|
||||
if (!match) return null
|
||||
// Aggregators expose the originating catalog ID. Prefer that model's own
|
||||
// metadata instead of an alphabetically earlier reseller or subscription.
|
||||
if (!explicit && !matches.some(candidate => candidate.providerId === preferred)) {
|
||||
const canonicalIds = [...new Set(matches.map(candidate => candidate.model.canonical_model_id).filter((id): id is string => !!id))]
|
||||
if (canonicalIds.length === 1) {
|
||||
const canonical = canonicalIds[0], slash = canonical.indexOf('/'), providerId = canonical.slice(0, slash)
|
||||
const model = catalog[providerId]?.models[canonical.slice(slash + 1)]
|
||||
if (slash > 0 && model) match = { providerId, model }
|
||||
}
|
||||
}
|
||||
const m = match.model, options = (m.reasoning_options ?? []).filter(option => ['effort', 'budget_tokens', 'toggle'].includes(option.type))
|
||||
const values = options.flatMap(option => option.type === 'effort' ? option.values ?? [] : [])
|
||||
const budget = options.find(option => option.type === 'budget_tokens')
|
||||
// OMP's minimal native budget is 1024 tokens (verified for Anthropic/Gemini).
|
||||
const efforts = values.length ? order.filter(value => values.includes(value)) : budget && (budget.min ?? 0) <= 1024 && (budget.max ?? Infinity) >= 1024 ? ['minimal'] : []
|
||||
const canDisable = !m.reasoning || options.some(option => option.type === 'toggle' || option.type === 'budget_tokens' && option.min === 0) || values.includes('none')
|
||||
const price = cheapestModelPrice(m)
|
||||
const context = Math.min(positive(m.limit?.context) ?? Infinity, price?.maxContext ?? Infinity)
|
||||
const window = Number.isFinite(context) ? context : undefined
|
||||
const outputLimit = positive(m.limit?.output) ? Math.min(m.limit!.output!, Math.max(1, (window ?? Infinity) - 1)) : undefined
|
||||
const inputLimit = Math.min(positive(m.limit?.input) ?? Infinity, window === undefined ? Infinity : window - (outputLimit ?? Math.min(8192, Math.max(1, Math.floor(window / 4)))))
|
||||
return { providerId: match.providerId, modelId: m.id, name: m.name || m.id, ...source,
|
||||
supportsToolCall: !!m.tool_call, supportsImages: m.modalities?.input?.includes('image') ?? false, supportsReasoning: !!m.reasoning,
|
||||
supportedEfforts: efforts, defaultEffort: efforts[0], onlyReasoning: !!m.reasoning && !canDisable, canDisableThinking: canDisable,
|
||||
reasoningOptions: options, contextWindowTokens: window,
|
||||
maxInputTokens: Number.isFinite(inputLimit) ? inputLimit : undefined,
|
||||
maxOutputTokens: outputLimit,
|
||||
limits: { contextWindowTokens: positive(m.limit?.context), maxInputTokens: positive(m.limit?.input), maxOutputTokens: positive(m.limit?.output) },
|
||||
priceTiers: modelPriceTiers(m), price, temperature: m.temperature }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { CustomModelProfile } from './models'
|
||||
import type { ThinkingLevel } from './workspaces'
|
||||
|
||||
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
||||
|
||||
/** Keep the selector and the actual request on the same model-supported level. */
|
||||
export function modelThinkingLevel(model: CustomModelProfile | undefined, requested?: ThinkingLevel): ThinkingLevel {
|
||||
if (!model?.supportsReasoning) return 'off'
|
||||
const supported = (model.supportedEfforts ?? []).filter(value => efforts.includes(value))
|
||||
const fallback = model.defaultEffort === '' ? 'auto'
|
||||
: model.defaultEffort && efforts.includes(model.defaultEffort) && (!supported.length || supported.includes(model.defaultEffort))
|
||||
? model.defaultEffort as ThinkingLevel : supported[0] as ThinkingLevel | undefined ?? 'auto'
|
||||
if (requested === 'off') return model.canDisableThinking === false || model.onlyReasoning ? fallback : 'off'
|
||||
if (!requested) return fallback
|
||||
if (requested === 'auto') return 'auto'
|
||||
return supported.length || model.metadata ? supported.includes(requested) ? requested : fallback : requested
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ModelMetadata, ModelPrice, ModelTokenLimits } from './models'
|
||||
|
||||
interface TokenSettings extends ModelTokenLimits { tokenBudgetVersion?: 2; metadata?: ModelMetadata; price?: ModelPrice }
|
||||
export interface ResolvedTokenSettings extends ModelTokenLimits {
|
||||
tokenBudgetVersion?: 2; price?: ModelPrice; error?: string
|
||||
compaction?: { reserveTokens: number; thresholdTokens: number }
|
||||
}
|
||||
const positive = (value: number | undefined): number | undefined => value !== undefined && Number.isSafeInteger(value) && value > 0 ? value : undefined
|
||||
|
||||
/** Total context is authoritative; output and catalog ceilings determine the input budget. */
|
||||
export function resolveModelTokenLimits(model: TokenSettings): ResolvedTokenSettings {
|
||||
const metadata = model.metadata, ceilings = metadata?.limits
|
||||
// Earlier versions exposed maxInputTokens as the first editable field. Interpret
|
||||
// that saved choice as the total now, discarding the hidden input+output expansion.
|
||||
const requestedContext = model.tokenBudgetVersion === 2 ? model.contextWindowTokens : model.maxInputTokens ?? model.contextWindowTokens
|
||||
const context = positive(requestedContext) ?? positive(metadata?.contextWindowTokens) ?? 128000
|
||||
const output = positive(model.maxOutputTokens) ?? positive(metadata?.maxOutputTokens) ?? Math.min(8192, Math.max(1, Math.floor(context / 4)))
|
||||
for (const [name, value, maximum] of [
|
||||
['上下文', requestedContext, ceilings?.contextWindowTokens],
|
||||
['输出', model.maxOutputTokens, ceilings?.maxOutputTokens ?? ceilings?.contextWindowTokens]
|
||||
] as const) {
|
||||
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) return { error: `${name}上限需要填写非负整数` }
|
||||
if (value && maximum && value > maximum) return { error: `${name}上限不能超过模型支持的 ${maximum.toLocaleString('en-US')} tokens` }
|
||||
}
|
||||
if (output >= context) return { error: '输出上限必须小于总上下文长度' }
|
||||
const input = Math.min(context - output, positive(ceilings?.maxInputTokens) ?? Infinity)
|
||||
// OMP 18.6.3 defaults to a 15% reserve with a 16,384-token floor, recovering
|
||||
// proportional reserve on tiny windows. Also reserve the full output budget.
|
||||
const proportional = Math.max(1, Math.floor(context * 0.15))
|
||||
const normalReserve = Math.max(proportional, 16384)
|
||||
const defaultReserve = normalReserve >= context - proportional ? proportional : normalReserve
|
||||
const reserveTokens = Math.max(defaultReserve, output, context - input)
|
||||
const thresholdTokens = context - reserveTokens
|
||||
const price = context === metadata?.contextWindowTokens ? metadata.price : metadata?.priceTiers?.length
|
||||
? metadata.priceTiers.find(tier => context > (tier.minContext ?? 0) && (!tier.maxContext || context <= tier.maxContext))
|
||||
: model.price ?? metadata?.price
|
||||
return { tokenBudgetVersion: 2, maxInputTokens: input, maxOutputTokens: output, contextWindowTokens: context, price,
|
||||
compaction: { reserveTokens, thresholdTokens } }
|
||||
}
|
||||
@@ -1,5 +1,25 @@
|
||||
import type { GatewayConfig } from './types'
|
||||
|
||||
export type ModelProtocol = 'auto' | 'openai' | 'anthropic' | 'gemini'
|
||||
export type ModelApi = 'openai-completions' | 'openai-responses' | 'anthropic-messages' | 'google-generative-ai'
|
||||
export interface ModelEndpoint { api: ModelApi; baseUrl: string; checkedAt: number; verifiedBy?: 'models' | 'generation' }
|
||||
export interface ModelPrice { input: number; output: number; cacheRead?: number; cacheWrite?: number; minContext?: number; maxContext?: number; crossedTiers?: boolean }
|
||||
export interface ModelReasoningOption { type: 'effort' | 'budget_tokens' | 'toggle'; values?: string[]; min?: number; max?: number }
|
||||
export interface ModelTokenLimits { contextWindowTokens?: number; maxInputTokens?: number; maxOutputTokens?: number }
|
||||
export interface ModelMetadata {
|
||||
providerId: string; modelId: string; name: string; fetchedAt: string; sha256: string
|
||||
supportsToolCall: boolean; supportsImages: boolean; supportsReasoning: boolean
|
||||
supportedEfforts: string[]; defaultEffort?: string; onlyReasoning: boolean; canDisableThinking: boolean
|
||||
reasoningOptions: ModelReasoningOption[]; contextWindowTokens?: number; maxInputTokens?: number; maxOutputTokens?: number
|
||||
/** Catalog ceilings and all price bands, before applying the cheapest-band defaults. */
|
||||
limits?: ModelTokenLimits; priceTiers?: ModelPrice[]
|
||||
price?: ModelPrice; temperature?: boolean
|
||||
}
|
||||
export interface ModelMetadataStatus { fetchedAt: string; sha256: string; providers: number; models: number; source: string }
|
||||
export interface ModelDiscoveryInput { baseUrl: string; protocol?: ModelProtocol; apiKey?: string; previousId?: string; authMode?: 'api-key' | 'none' }
|
||||
export interface DiscoveredModel { id: string; name: string }
|
||||
export interface ModelDiscoveryResult { models: DiscoveredModel[]; endpoint: ModelEndpoint }
|
||||
|
||||
export interface CustomModelProfile {
|
||||
id: string
|
||||
name: string
|
||||
@@ -9,6 +29,14 @@ export interface CustomModelProfile {
|
||||
hasApiKey: boolean
|
||||
keyStorage: 'encrypted' | 'plaintext' | 'none'
|
||||
authMode?: 'api-key' | 'none'
|
||||
protocol?: ModelProtocol
|
||||
endpoint?: ModelEndpoint
|
||||
metadata?: ModelMetadata
|
||||
contextWindowTokens?: number
|
||||
/** Version 2: contextWindowTokens is the configured total; maxInputTokens is derived. */
|
||||
tokenBudgetVersion?: 2
|
||||
reasoningBudget?: number
|
||||
price?: ModelPrice
|
||||
supportsToolCall?: boolean
|
||||
supportsImages?: boolean
|
||||
supportsReasoning?: boolean
|
||||
@@ -39,6 +67,7 @@ export interface ModelTestResult {
|
||||
success: boolean
|
||||
message: string
|
||||
durationMs: number
|
||||
endpoint?: ModelEndpoint
|
||||
}
|
||||
|
||||
/** Main-process-only resolved credentials; never expose this through preload. */
|
||||
|
||||
@@ -94,10 +94,13 @@ function clipped(value: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼出每轮附加的个性化上下文(随该轮用户消息一起发送)。默认值(空身份、默认风格、未编辑的人设)不产生多余内容,
|
||||
* 避免把原版默认人设重复注入每轮上下文;用户自定义后才进入提示词。
|
||||
* 拼出每轮附加的个性化上下文(随该轮用户消息一起发送)。
|
||||
* 人设区块始终反映当前生效文本,包括 UI 中显示的默认人设。
|
||||
*/
|
||||
export function buildPersonalizationPrompt(settings: PersonalizationSettings): string {
|
||||
export function buildPersonalizationPrompt(
|
||||
settings: PersonalizationSettings,
|
||||
options: { includeLongTermMemory?: boolean } = {}
|
||||
): string {
|
||||
const lines: string[] = []
|
||||
if (settings.userCallName) lines.push(`- 用户希望被称呼为:${settings.userCallName}`)
|
||||
if (settings.aiName) lines.push(`- 你的名字是:${settings.aiName};自我介绍时使用这个名字。`)
|
||||
@@ -106,9 +109,8 @@ export function buildPersonalizationPrompt(settings: PersonalizationSettings): s
|
||||
const customPrompt = clipped(settings.customPrompt)
|
||||
if (customPrompt) lines.push('### 用户指令', customPrompt)
|
||||
const persona = clipped(settings.persona)
|
||||
const defaultPersona = clipped(DEFAULT_PERSONA)
|
||||
if (persona && persona !== defaultPersona) lines.push('### 人设设定', persona)
|
||||
const memory = clipped(settings.longTermMemory)
|
||||
if (persona) lines.push('### 人设设定', persona)
|
||||
const memory = options.includeLongTermMemory === false ? '' : clipped(settings.longTermMemory)
|
||||
if (memory) lines.push('### 长期记忆', memory)
|
||||
return lines.length ? ['## 个性化偏好(用户设置,始终遵守)', ...lines].join('\n') : ''
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Local project configuration. Cloud membership and authorization are separate services. */
|
||||
export interface ProjectCapabilityRef {
|
||||
id: string
|
||||
source: 'local' | 'market'
|
||||
provider?: string
|
||||
}
|
||||
export interface ProjectInput {
|
||||
workspaceId?: string
|
||||
name: string
|
||||
instructions: string
|
||||
templateId?: string
|
||||
experts: ProjectCapabilityRef[]
|
||||
skills: ProjectCapabilityRef[]
|
||||
connectors: string[]
|
||||
}
|
||||
export interface ProjectRecord extends ProjectInput {
|
||||
projectId: string
|
||||
transport: 'local'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChatMessage } from './types'
|
||||
export interface RevertChange { path: string; before: string | null; after: string }
|
||||
const readOnly = new Set(['read_file', 'list_dir', 'ask_user_question', 'ask_followup_question', 'todo_write', 'create_plan'])
|
||||
const readOnly = new Set(['read_file', 'list_dir', 'ask_user_question', 'ask_followup_question', 'todo_write', 'create_plan',
|
||||
'spawn_agent', 'list_agents', 'wait_agent', 'send_agent_message', 'cancel_agent', 'ycode_load_skill', 'ycode_read_skill_resource', 'ycode_expert_resource'])
|
||||
const writes = new Set(['write_file', 'edit_file'])
|
||||
/** Only host writes with complete before/after records can offer a real rollback. */
|
||||
export function getRevertChanges(messages: ChatMessage[], userMessageId: string): RevertChange[] {
|
||||
@@ -9,7 +10,12 @@ export function getRevertChanges(messages: ChatMessage[], userMessageId: string)
|
||||
const changes = new Map<string, RevertChange>()
|
||||
for (const message of messages.slice(index)) {
|
||||
if (message.status === 'streaming') throw new Error('请先停止当前运行')
|
||||
for (const tool of message.tools ?? []) {
|
||||
const children = (message.parts ?? []).flatMap(part => part.type === 'subagent' ? part.agent.tools : [])
|
||||
const tools = [...(message.tools ?? []), ...children]
|
||||
if (children.length) tools.sort((left, right) =>
|
||||
Number(left.metadata?.completedAt ?? 0) - Number(right.metadata?.completedAt ?? 0)
|
||||
|| Number(left.metadata?.completionOrder ?? 0) - Number(right.metadata?.completionOrder ?? 0))
|
||||
for (const tool of tools) {
|
||||
if (readOnly.has(tool.name)) continue
|
||||
if (!writes.has(tool.name)) throw new Error('此版本包含无法完整回滚的工具操作')
|
||||
const data = tool.result as Partial<{ path: string; oldContent: string | null; newContent: string }> | undefined
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
export interface SandboxStatus {
|
||||
backend: 'windows-appcontainer'; protocol: 1; state: 'installed' | 'unavailable'; reason?: string
|
||||
network: 'deny'; filesystem: 'workspace-snapshot'; writeback: 'approval'; limits: { files: number; bytes: number; fileBytes: number; timeoutMs: number }
|
||||
policy?: { revision: string; commandsEnabled: boolean; writeback: 'ask' | 'deny' }
|
||||
capabilities?: SandboxCapability[]
|
||||
selfTest?: SandboxSelfTestSummary
|
||||
toolchains?: SandboxToolchainView[]
|
||||
}
|
||||
export interface SandboxLimits { files: number; bytes: number; fileBytes: number; timeoutMs: number }
|
||||
export interface SandboxPolicyRules {
|
||||
commandsEnabled: boolean
|
||||
writeback: 'ask' | 'deny'
|
||||
limits: SandboxLimits
|
||||
deniedPaths: string[]
|
||||
maxChanges: number
|
||||
}
|
||||
export type SandboxPolicyOverride = Partial<Omit<SandboxPolicyRules, 'limits'>> & { limits?: Partial<SandboxLimits> }
|
||||
export interface SandboxPolicyDocument {
|
||||
version: 1; revision: string; updatedAt: number
|
||||
defaults: SandboxPolicyRules
|
||||
workspaces: Record<string, { root: string; rules: SandboxPolicyOverride }>
|
||||
}
|
||||
export interface SandboxPolicyUpdate { expectedRevision: string; rules: SandboxPolicyOverride; workspaceRoot?: string }
|
||||
export interface SandboxEffectivePolicy extends SandboxPolicyRules { revision: string; fingerprint: string; workspaceRoot: string }
|
||||
export interface SandboxCapability {
|
||||
id: 'command-isolation' | 'network-deny' | 'file-transactions' | 'persistent-workspace' | 'workspace-read-bridge' | 'node' | 'python' | 'git' | 'git-host-workflow' | 'git-remote-transport' | 'network-egress' | 'filesystem-broker' | 'registry-policy' | 'mcp-isolation' | 'terminal-isolation'
|
||||
state: 'implemented' | 'unavailable'; detail: string
|
||||
}
|
||||
export interface SandboxSelfTestSummary { passed: boolean; checkedAt: number; binarySha256: string; policyRevision: string; policyFingerprint: string; runId: string }
|
||||
export type SandboxToolchainKind = 'node' | 'python' | 'git'
|
||||
export type SandboxDependencyKind = 'node' | 'python'
|
||||
export interface SandboxToolchainIdentity { kind: SandboxToolchainKind; id: string; manifestSha256: string }
|
||||
export interface SandboxToolchainView { kind: SandboxToolchainKind; state: 'registered' | 'missing' | 'invalid'; id?: string; files?: number; bytes?: number; features?: string[]; reason?: string; source?: 'bundled' | 'imported'; version?: string }
|
||||
export interface SandboxDependencyIdentity { kind: SandboxDependencyKind; id: string; lockHash: string; manifestSha256: string; runtimeId: string; runtimeManifestSha256: string }
|
||||
export interface SandboxGitState {
|
||||
mode: 'private-snapshot' | 'host-mirror' | 'remote-clone' | 'new-repository'; baseline: 'filtered-workspace' | 'host-repository' | 'remote-repository' | 'empty-repository'; initialized: boolean
|
||||
revision: string; manifestSha256: string
|
||||
persistence: 'unchanged' | 'saved' | 'rejected' | 'not-applied' | 'failed'; error?: string
|
||||
scope?: 'draft' | 'host-draft' | 'clone-draft' | 'new-draft'
|
||||
}
|
||||
export interface SandboxWorkspaceView {
|
||||
mode: 'persistent'; state: 'missing' | 'ready' | 'conflict' | 'stale' | 'interrupted' | 'busy' | 'invalid'
|
||||
sessionId: string; revision?: string; policyRevision?: string; files?: number; bytes?: number
|
||||
changes: SandboxChangedFile[]; conflicts?: string[]; reason?: string; lastTransactionId?: string
|
||||
}
|
||||
export type SandboxWorkspaceAction = 'status' | 'changes' | 'commit' | 'discard' | 'recover'
|
||||
export interface SandboxGitHostView {
|
||||
mode: 'host-mirror' | 'remote-clone' | 'new-repository'; state: 'unimported' | 'ready' | 'busy' | 'stale' | 'interrupted'; sessionId: string
|
||||
revision?: string; importedAt?: number; linkedWorktree?: boolean; pendingMetadata?: boolean
|
||||
writeback?: 'committed' | 'unchanged' | 'rejected' | 'not-applied' | 'unknown'; groupId?: string; transactionId?: string; reason?: string
|
||||
workspace?: SandboxWorkspaceView
|
||||
}
|
||||
export interface SandboxGitRemoteResult {
|
||||
operationId: string; action: 'ls-remote' | 'clone' | 'fetch' | 'pull' | 'push'; state: 'denied' | 'completed' | 'failed' | 'unknown'
|
||||
remote: string; stdout?: string; stderr?: string; revision?: string; needsCheckout?: boolean; needsMerge?: boolean
|
||||
intendedRefs?: Record<string, string>; writeback: 'not-applied' | 'remote-committed' | 'unknown'; reason?: string; cleanupRequired?: boolean
|
||||
}
|
||||
export interface SandboxWorkspaceActionResult extends SandboxWorkspaceView {
|
||||
git?: SandboxGitHostView
|
||||
action: SandboxWorkspaceAction; writeback?: 'committed' | 'unchanged' | 'rejected' | 'not-applied' | 'unknown'
|
||||
transactionId?: string; authorizationId?: string; operationId?: string
|
||||
}
|
||||
export interface SandboxOperationView {
|
||||
id: string; workspaceRoot: string; createdAt: number; completedAt?: number
|
||||
state: 'completed' | 'failed' | 'incomplete'; status: string
|
||||
writeback: 'committed' | 'unchanged' | 'rejected' | 'not-applied' | 'deferred' | 'unknown'
|
||||
transactionId?: string; identity: { runId?: string; toolCallId?: string; sessionId?: string; policyRevision?: string; policyFingerprint?: string }
|
||||
git?: SandboxGitState
|
||||
workspace?: SandboxWorkspaceView
|
||||
}
|
||||
export interface SandboxBrokerOperationView {
|
||||
version: 1; requestId: string; operation: string; state: 'pending' | 'running' | 'succeeded' | 'denied' | 'failed' | 'cancelled' | 'unknown'
|
||||
owner: { sandboxRunId: string; runId?: string; toolCallId?: string; sessionId?: string; policyRevision?: string; policyFingerprint?: string }
|
||||
createdAt: number; completedAt?: number; result?: Record<string, unknown>; error?: { code: string; message: string }
|
||||
}
|
||||
export interface SandboxChangedFile { path: string; operation: 'create' | 'update' | 'delete' | 'mkdir' | 'rmdir'; beforeHash: string | null; afterHash: string | null }
|
||||
export interface SandboxCommandData {
|
||||
gitConflictState?: true
|
||||
stdout: string; stderr: string; exitCode: number | null; sandbox: 'windows-appcontainer'; isolated: true
|
||||
runId: string; status: 'completed' | 'timeout' | 'cancelled' | 'parent-exited' | 'error'
|
||||
changes: SandboxChangedFile[]; writeback: 'unchanged' | 'committed' | 'rejected' | 'not-applied' | 'deferred'; transactionId?: string
|
||||
excluded: number; outputTruncated: boolean; profileRemoved: boolean; driveRemoved: boolean; auditSaved: boolean
|
||||
policyRevision?: string; authorizationId?: string
|
||||
policyFingerprint?: string
|
||||
toolchains?: SandboxToolchainIdentity[]
|
||||
dependencies?: SandboxDependencyIdentity[]
|
||||
outcomeSaved?: boolean
|
||||
brokerTransport?: 'bound-stdio-v1'
|
||||
workspaceReadBridge?: true
|
||||
git?: SandboxGitState
|
||||
workspace?: SandboxWorkspaceView
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TokenUsage, ToolCallRecord } from './types'
|
||||
|
||||
export type SubagentStatus = 'queued' | 'running' | 'completed' | 'error' | 'cancelled'
|
||||
export interface SubagentEvent {
|
||||
id: string
|
||||
kind: 'text' | 'reasoning' | 'tool'
|
||||
text: string
|
||||
name?: string
|
||||
status?: ToolCallRecord['status']
|
||||
}
|
||||
export interface SubagentSnapshot {
|
||||
id: string
|
||||
name: string
|
||||
task: string
|
||||
status: SubagentStatus
|
||||
access: 'read-only' | 'write'
|
||||
files: string[]
|
||||
modelName: string
|
||||
createdAt: number
|
||||
startedAt?: number
|
||||
finishedAt?: number
|
||||
events: SubagentEvent[]
|
||||
tools: ToolCallRecord[]
|
||||
result?: string
|
||||
error?: string
|
||||
usage?: TokenUsage
|
||||
truncated?: boolean
|
||||
}
|
||||
/** Sum of usage actually reported by upstream requests; separate from the latest context sample. */
|
||||
export interface RunTokenUsage {
|
||||
main: TokenUsage
|
||||
subagents: TokenUsage
|
||||
total: TokenUsage
|
||||
reportedOnly: true
|
||||
}
|
||||
export function zeroUsage(): TokenUsage { return { inputTokens: 0, outputTokens: 0, totalTokens: 0 } }
|
||||
export function addUsage(left: TokenUsage, right: TokenUsage): TokenUsage {
|
||||
return { inputTokens: left.inputTokens + right.inputTokens, outputTokens: left.outputTokens + right.outputTokens,
|
||||
totalTokens: left.totalTokens + right.totalTokens,
|
||||
...(left.cachedTokens !== undefined || right.cachedTokens !== undefined ? { cachedTokens: (left.cachedTokens ?? 0) + (right.cachedTokens ?? 0) } : {}) }
|
||||
}
|
||||
+80
-5
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
/** 会话内的一条消息(对外视图) */
|
||||
import type { ArtifactContent, ChatAttachment, ChatInteraction, MessageQuote, PlanContent, TaskContent } from './conversation'
|
||||
import type { ArtifactContent, ChatAttachment, ChatInteraction, MessageQuote, PlanContent, TaskContent, UserContentBlock } from './conversation'
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
@@ -20,6 +20,8 @@ export interface ChatMessage {
|
||||
resources?: string[]
|
||||
/** 发送时引用进对话的表格等内容块快照(WorkBuddy messageQuote)。 */
|
||||
quotes?: MessageQuote[]
|
||||
/** Ordered inline presentation, validated against content and its actual payload collections. */
|
||||
userContent?: UserContentBlock[]
|
||||
skills?: Array<{ id: string; name: string; uri: string }>
|
||||
/** Native-history reconstruction; never includes connector credentials. */
|
||||
capabilityContext?: string
|
||||
@@ -28,6 +30,12 @@ export interface ChatMessage {
|
||||
/** The model selected when this assistant turn started; kept with history. */
|
||||
modelId?: string
|
||||
modelName?: string
|
||||
/** Last actual upstream request sample, with that turn's frozen window. Never a session sum. */
|
||||
contextUsage?: ContextUsageSnapshot
|
||||
/** Independent local counts of one prepared provider input; never a split of billed usage. */
|
||||
contextBreakdown?: ContextUsageBreakdown
|
||||
/** Reported request totals, including separately measured child agents. */
|
||||
runUsage?: import('./subagents').RunTokenUsage
|
||||
interactions?: ChatInteraction[]
|
||||
requestId?: string
|
||||
planId?: string
|
||||
@@ -74,6 +82,7 @@ export type MessagePart =
|
||||
| { type: 'task'; task: TaskContent }
|
||||
| { type: 'artifact'; artifact: ArtifactContent }
|
||||
| { type: 'image'; id: string; url: string; alt?: string }
|
||||
| { type: 'subagent'; agent: import('./subagents').SubagentSnapshot }
|
||||
|
||||
/** 面向模型暴露的工具描述(OpenAI function tool 格式) */
|
||||
export interface ToolSpec {
|
||||
@@ -85,8 +94,14 @@ export interface ToolSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/** 网关配置:一个 OpenAI 兼容的上游 */
|
||||
/** Main-process resolved upstream protocol and credentials. */
|
||||
export interface GatewayConfig {
|
||||
api?: import('./models').ModelApi
|
||||
supportsReasoning?: boolean
|
||||
temperatureSupported?: boolean
|
||||
thinkingLevel?: import('./workspaces').ThinkingLevel
|
||||
reasoningBudget?: number
|
||||
reasoningOptions?: import('./models').ModelReasoningOption[]
|
||||
speechBaseUrl?: string
|
||||
speechModel?: string
|
||||
/** 上游 baseUrl,例如 https://api.deepseek.com/v1 */
|
||||
@@ -174,7 +189,7 @@ export type PersonalizationPatch = Partial<PersonalizationSettings>
|
||||
|
||||
/**
|
||||
* 设置 → 智能体(TODO 15)。
|
||||
* 本轮只接入「智能体团队管理」这一个偏好;经济模式与浏览器操控仅按原版布局呈现并明确标注未接入。
|
||||
* 智能体团队管理为本地子智能体调度总开关;经济模式与浏览器操控已按用户要求移除。
|
||||
*/
|
||||
export interface AgentSettings {
|
||||
/** 开启后复杂任务自动拆分多个智能体协作完成(原版默认开启)。 */
|
||||
@@ -183,15 +198,38 @@ export interface AgentSettings {
|
||||
|
||||
export type AgentSettingsPatch = Partial<AgentSettings>
|
||||
|
||||
/**
|
||||
* 设置 → 记忆与进化(TODO 14)。
|
||||
*
|
||||
* 原版把“对话记忆”保存到账户侧,“本地记忆与技能”保存在本地存储
|
||||
* (REA 回收 MemorySettingsPanel-BU1K5nn4.js / local-skills-memory-Bb3Kqaxi.js)。
|
||||
* YCode 将两项偏好统一写入 config.json;页面展示的数据不会补造。
|
||||
*/
|
||||
export interface MemoryPreferences {
|
||||
/** 允许已保存的长期记忆进入后续对话上下文。 */
|
||||
conversationMemory: boolean
|
||||
/** 本地记忆与技能偏好;自动记录执行器尚未接入。 */
|
||||
localMemorySkills: boolean
|
||||
}
|
||||
|
||||
export interface MemorySettingsView extends MemoryPreferences {
|
||||
/** 当前长期记忆正文(由个性化设置的真实长期记忆字段提供)。 */
|
||||
content: string
|
||||
}
|
||||
|
||||
export type MemoryPreferencesPatch = Partial<MemoryPreferences>
|
||||
|
||||
/** 主进程 → 渲染进程的流式事件 */
|
||||
export type ChatEvent = { sessionId?: string; turnDurationMs?: number } & (
|
||||
export type ChatEvent = { sessionId?: string; turnDurationMs?: number; sequence?: number } & (
|
||||
| { runId: string; type: 'started'; messageId: string }
|
||||
| { runId: string; type: 'text'; delta: string }
|
||||
| { runId: string; type: 'part'; part: Exclude<MessagePart, { type: 'text' } | { type: 'tool' }> }
|
||||
| { runId: string; type: 'interaction'; interaction: ChatInteraction }
|
||||
| { runId: string; type: 'tool_call'; call: ToolCallRecord }
|
||||
| { runId: string; type: 'tool_result'; call: ToolCallRecord }
|
||||
| { runId: string; type: 'usage'; usage: TokenUsage }
|
||||
| { runId: string; type: 'usage'; usage: TokenUsage; contextWindow?: number; sampledAt?: number; modelId?: string; source?: ContextUsageSnapshot['source']; nativeEntryId?: string }
|
||||
| { runId: string; type: 'context_breakdown'; breakdown: ContextUsageBreakdown }
|
||||
| { runId: string; type: 'run_usage'; usage: import('./subagents').RunTokenUsage }
|
||||
| { runId: string; type: 'done'; content: string; tools: ToolCallRecord[] }
|
||||
| { runId: string; type: 'cancelled' }
|
||||
| { runId: string; type: 'error'; message: string }
|
||||
@@ -208,6 +246,42 @@ export interface TokenUsage {
|
||||
cachedTokens?: number
|
||||
}
|
||||
|
||||
export interface ContextUsageSnapshot {
|
||||
usage: TokenUsage
|
||||
/** Missing when the selected profile never declared a real context window. */
|
||||
contextWindow?: number
|
||||
source?: 'upstream' | 'native-history' | 'native-state'
|
||||
sampledAt?: number
|
||||
requestId?: string
|
||||
modelId?: string
|
||||
nativeEntryId?: string
|
||||
}
|
||||
|
||||
export type ContextUsageCategory = 'system' | 'tools' | 'conversation' | 'mcp' | 'skills' | 'other'
|
||||
|
||||
export interface ContextUsageBreakdown {
|
||||
requestId: string
|
||||
/** Monotonic within a turn, so a late older request cannot replace a newer one. */
|
||||
sequence: number
|
||||
sampledAt: number
|
||||
modelId?: string
|
||||
contextWindow?: number
|
||||
method: 'model-tokenizer' | 'o200k_base' | 'utf8-estimate'
|
||||
scope: 'provider-request' | 'prepared-request'
|
||||
counts: Record<ContextUsageCategory, number>
|
||||
inputTokens: number
|
||||
/** Images or an unavailable provider layer mean these counts are partial. */
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
/** A read-only query never creates or switches an agent to obtain a number. */
|
||||
export interface ContextUsageInfo {
|
||||
sample?: ContextUsageSnapshot
|
||||
breakdown?: ContextUsageBreakdown
|
||||
status: 'latest' | 'previous' | 'unavailable'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话工作目录的 Git 信息(用于对话框的分支标签)。
|
||||
* `repo: false` 表示该目录不在 Git 仓库内;`detached: true` 时 `branch` 为空、`head` 为短 SHA。
|
||||
@@ -226,6 +300,7 @@ export interface GitBranchInfo {
|
||||
|
||||
/** 一次会话的元信息 */
|
||||
export interface SessionMeta {
|
||||
projectId?: string
|
||||
id: string
|
||||
title: string
|
||||
createdAt: number
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ChatAttachment, MessageQuote, UserContentBlock } from './conversation'
|
||||
|
||||
export interface UserContentCollections {
|
||||
attachments?: readonly Pick<ChatAttachment, 'id'>[]
|
||||
quotes?: readonly Pick<MessageQuote, 'id'>[]
|
||||
resources?: readonly string[]
|
||||
skills?: readonly { id: string }[]
|
||||
}
|
||||
|
||||
export function contentReferenceKey(block: Exclude<UserContentBlock, { type: 'text' }>): string {
|
||||
return `${block.type}:${block.id}`
|
||||
}
|
||||
|
||||
export function userContentReferences(collections: UserContentCollections): Exclude<UserContentBlock, { type: 'text' }>[] {
|
||||
return [
|
||||
...(collections.quotes ?? []).map(item => ({ type: 'quote' as const, id: item.id })),
|
||||
...(collections.attachments ?? []).map(item => ({ type: 'attachment' as const, id: item.id })),
|
||||
...(collections.resources ?? []).map(id => ({ type: 'resource' as const, id })),
|
||||
...(collections.skills ?? []).map(item => ({ type: 'skill' as const, id: item.id }))
|
||||
]
|
||||
}
|
||||
|
||||
/** Validate only the view projection. It cannot introduce a payload, change the prompt, or duplicate a reference. */
|
||||
export function normalizeUserContent(input: unknown, text: string, collections: UserContentCollections): UserContentBlock[] {
|
||||
const references = userContentReferences(collections)
|
||||
const allowed = new Map(references.map(item => [contentReferenceKey(item), item]))
|
||||
const result: UserContentBlock[] = [], seen = new Set<string>()
|
||||
let length = 0
|
||||
if (Array.isArray(input) && input.length <= 4096) {
|
||||
for (const item of input) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
if (item.type === 'text' && typeof item.text === 'string') {
|
||||
length += item.text.length
|
||||
if (length > text.length) break
|
||||
const previous = result.at(-1)
|
||||
if (previous?.type === 'text') previous.text += item.text
|
||||
else result.push({ type: 'text', text: item.text })
|
||||
} else if (typeof item.id === 'string') {
|
||||
const key = `${item.type}:${item.id}`, reference = allowed.get(key)
|
||||
if (reference && !seen.has(key)) { result.push({ ...reference }); seen.add(key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
const plain = result.filter(item => item.type === 'text').map(item => item.text).join('')
|
||||
if (plain !== text) return [...references, { type: 'text', text }]
|
||||
// New collections from pickers append to this projection; the live editor inserts at its saved caret.
|
||||
for (const reference of references) if (!seen.has(contentReferenceKey(reference))) result.push({ ...reference })
|
||||
return result.length ? result : [{ type: 'text', text }]
|
||||
}
|
||||
|
||||
export function trimUserContent(input: readonly UserContentBlock[]): UserContentBlock[] {
|
||||
const result = input.map(item => ({ ...item }))
|
||||
for (const item of result) {
|
||||
if (item.type !== 'text') continue
|
||||
item.text = item.text.trimStart()
|
||||
if (item.text) break
|
||||
}
|
||||
for (let n = result.length - 1; n >= 0; n--) {
|
||||
const item = result[n]
|
||||
if (item.type !== 'text') continue
|
||||
item.text = item.text.trimEnd()
|
||||
if (item.text) break
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -10,12 +10,12 @@ export interface WorkspaceRegistryView {
|
||||
}
|
||||
/**
|
||||
* Reasoning effort for one session. `off` disables thinking entirely; the rest mirror OMP's
|
||||
* `--thinking` levels. OMP additionally accepts `auto` and `minimal`, which this host does not
|
||||
* currently surface in the selector.
|
||||
* `--thinking` levels. `auto` leaves intensity to the model; `minimal` is also exposed for models declaring it in models.dev.
|
||||
*/
|
||||
export type ThinkingLevel = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
|
||||
export type ThinkingLevel = 'off' | 'auto' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
|
||||
|
||||
export interface SessionContext {
|
||||
projectId?: string
|
||||
workspaceId?: string
|
||||
cwd?: string
|
||||
modelId?: string
|
||||
|
||||
Reference in new issue
Block a user