chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -0,0 +1,30 @@
|
||||
/** View contracts recovered from REA ev_717fa5e… / src-Cng7axVa.js.
|
||||
* Optional host services are deliberately separate from the shipped client dispatcher.
|
||||
*/
|
||||
export interface ArtifactFileChange {
|
||||
uri?: string
|
||||
diff?: string
|
||||
changeType?: string
|
||||
language?: string
|
||||
diffStatus?: string
|
||||
cachedContent?: string
|
||||
}
|
||||
|
||||
export interface PreviewArtifact {
|
||||
id: string
|
||||
type: string
|
||||
title?: string
|
||||
category?: string
|
||||
customType?: string
|
||||
uri?: string
|
||||
url?: string
|
||||
content?: string
|
||||
contentType?: string
|
||||
mimeType?: string
|
||||
language?: string
|
||||
originalContent?: string
|
||||
modifiedContent?: string
|
||||
enableEdit?: boolean
|
||||
size?: number
|
||||
fileChangeInfo?: ArtifactFileChange
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CustomModelProfile } from './models'
|
||||
|
||||
/** Freeze the selected profile's identity before a turn can outlive its settings. */
|
||||
export function freezeAssistantModelMetadata(model: Pick<CustomModelProfile, 'id' | 'name'>): { modelId: string; modelName: string } {
|
||||
return { modelId: model.id, modelName: model.name }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface BrowserHostBounds { x: number; y: number; width: number; height: number }
|
||||
export interface BrowserHostView {
|
||||
id: string
|
||||
sessionId?: string
|
||||
url: string
|
||||
title: string
|
||||
loading: boolean
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
visible: boolean
|
||||
error?: string
|
||||
}
|
||||
export interface BrowserHostApi {
|
||||
create(sessionId?: string, initialUrl?: string): Promise<BrowserHostView>
|
||||
get(id: string, sessionId?: string): Promise<BrowserHostView>
|
||||
navigate(id: string, url: string, sessionId?: string): Promise<BrowserHostView>
|
||||
back(id: string, sessionId?: string): Promise<BrowserHostView>
|
||||
forward(id: string, sessionId?: string): Promise<BrowserHostView>
|
||||
reload(id: string, sessionId?: string): Promise<BrowserHostView>
|
||||
bounds(id: string, bounds: BrowserHostBounds | null, sessionId?: string): Promise<void>
|
||||
close(id: string, sessionId?: string): Promise<void>
|
||||
rebind(id: string, previousSessionId: string | undefined, nextSessionId: string): Promise<BrowserHostView>
|
||||
}
|
||||
|
||||
export function normalizeBrowserHostUrl(value: string): string {
|
||||
if (typeof value !== 'string') throw new Error('网页地址无效')
|
||||
const text = value.trim()
|
||||
if (text === 'about:blank') return text
|
||||
const url = new URL(text)
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('仅支持 HTTP 或 HTTPS 网页地址')
|
||||
return url.toString()
|
||||
}
|
||||
/** Source address normalization prefers HTTP for local loopback and HTTPS for bare hosts. */
|
||||
export function normalizeBrowserAddressInput(value: string): string {
|
||||
const text = value.trim()
|
||||
if (!text) throw new Error('请输入网址')
|
||||
// REA media-preview-DMz4TW4q.js looksLikeUrl / buildSearchUrl defaults to Sogou.
|
||||
const scheme = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(text)
|
||||
const looksLikeUrl = !/\s/.test(text) && (scheme || /^(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(text) || /^[^\s]+\.[^\s]{2,}/.test(text))
|
||||
if (!looksLikeUrl && !scheme) return `https://www.sogou.com/web?query=${encodeURIComponent(text)}`
|
||||
return normalizeBrowserHostUrl(/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(text) ? text
|
||||
: `${/^(localhost|127\.0\.0\.1)(:\d+)?/i.test(text) ? 'http' : 'https'}://${text}`)
|
||||
}
|
||||
export function clampBrowserHostBounds(value: BrowserHostBounds, width: number, height: number): BrowserHostBounds {
|
||||
for (const field of ['x', 'y', 'width', 'height'] as const) if (!Number.isInteger(value[field]) || !Number.isFinite(value[field])) throw new Error('浏览器区域必须使用有限整数坐标')
|
||||
if (value.width < 0 || value.height < 0) throw new Error('浏览器区域尺寸无效')
|
||||
const x = Math.min(Math.max(0, value.x), width), y = Math.min(Math.max(0, value.y), height)
|
||||
return { x, y, width: Math.max(0, Math.min(value.x + value.width, width) - x),
|
||||
height: Math.max(0, Math.min(value.y + value.height, height) - y) }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/** Local capability contracts. Cloud catalogs and authorization are separate services. */
|
||||
export type LocalizedExpertText = string | { zh?: string; en?: string }
|
||||
export interface ExpertRecord {
|
||||
id: string
|
||||
name: string
|
||||
displayName?: LocalizedExpertText
|
||||
profession?: LocalizedExpertText
|
||||
description: string
|
||||
displayDescription?: LocalizedExpertText
|
||||
version: string
|
||||
expertType: 'agent' | 'team' | 'skill' | 'plugin'
|
||||
agentName?: string
|
||||
plugin: string
|
||||
pluginRegisteredName: string
|
||||
marketplace: 'my-experts'
|
||||
isCustomExpert: true
|
||||
expertRootDir: string
|
||||
avatar?: string
|
||||
defaultInitPrompt?: LocalizedExpertText
|
||||
tags?: LocalizedExpertText[]
|
||||
quickPrompts?: LocalizedExpertText[]
|
||||
skills?: string[]
|
||||
categoryId?: string
|
||||
members?: Array<{ id: string; displayName?: LocalizedExpertText; profession?: LocalizedExpertText; avatar?: string; role?: 'lead' | 'member' }>
|
||||
createdBySession?: string
|
||||
updatedAt: number
|
||||
error?: string
|
||||
}
|
||||
export interface ExpertDocument { expert: ExpertRecord; files: Record<string, string>; prompt: string }
|
||||
export interface ExpertBinding { id: string; runtimeIdentity: string; name: string; profession?: string; avatarUrl?: string; expertType: ExpertRecord['expertType'] }
|
||||
/** Activation metadata comes from the same validated package as the binding. */
|
||||
export interface ExpertActivation extends ExpertBinding { defaultInitPrompt?: LocalizedExpertText }
|
||||
export interface ExpertsApi {
|
||||
list(): Promise<ExpertRecord[]>
|
||||
read(id: string): Promise<ExpertDocument>
|
||||
activate(id: string): Promise<ExpertActivation>
|
||||
remove(id: string): Promise<void>
|
||||
openPath(id: string): Promise<void>
|
||||
}
|
||||
export function expertText(value: LocalizedExpertText | undefined): string { return typeof value === 'string' ? value : value?.zh || value?.en || '' }
|
||||
export interface SkillRecord {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
version?: string
|
||||
path: string
|
||||
scope: 'user' | 'workspace'
|
||||
enabled: boolean
|
||||
userInvocable: boolean
|
||||
disableModelInvocation: boolean
|
||||
source: 'imported' | 'discovered'
|
||||
userModified: boolean
|
||||
error?: string
|
||||
}
|
||||
export interface SkillDocument { skill: SkillRecord; content: string; files: string[] }
|
||||
export interface SkillsApi {
|
||||
list(cwd?: string): Promise<SkillRecord[]>
|
||||
importPath(path?: string, overwrite?: boolean, kind?: 'directory' | 'zip'): Promise<SkillRecord | null>
|
||||
read(id: string, cwd?: string): Promise<SkillDocument>
|
||||
setEnabled(id: string, enabled: boolean, cwd?: string): Promise<void>
|
||||
remove(id: string): Promise<void>
|
||||
export(id: string, destination?: string): Promise<string | null>
|
||||
}
|
||||
export type McpStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
export interface McpServerInput {
|
||||
id?: string
|
||||
name: string
|
||||
transport: 'stdio' | 'http'
|
||||
command?: string
|
||||
args?: string[]
|
||||
cwd?: string
|
||||
url?: string
|
||||
/** Empty existing values preserve stored secrets. */
|
||||
env?: Record<string, string>
|
||||
headers?: Record<string, string>
|
||||
enabled?: boolean
|
||||
}
|
||||
export interface McpToolView { name: string; description: string; inputSchema: Record<string, unknown>; enabled: boolean }
|
||||
export interface McpServerView extends Omit<McpServerInput, 'id' | 'env' | 'headers'> {
|
||||
id: string
|
||||
enabled: boolean
|
||||
status: McpStatus
|
||||
error?: string
|
||||
tools: McpToolView[]
|
||||
envKeys: string[]
|
||||
headerKeys: string[]
|
||||
configHash: string
|
||||
trusted: boolean
|
||||
}
|
||||
export interface McpApi {
|
||||
list(): Promise<McpServerView[]>
|
||||
save(input: McpServerInput): Promise<McpServerView>
|
||||
trust(id: string, configHash: string): Promise<void>
|
||||
connect(id: string): Promise<McpServerView>
|
||||
disconnect(id: string): Promise<void>
|
||||
setEnabled(id: string, enabled: boolean): Promise<void>
|
||||
setToolEnabled(id: string, toolName: string, enabled: boolean): Promise<void>
|
||||
remove(id: string): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/** YCode transport types for the restored WorkBuddy view contracts. */
|
||||
import type { PreviewArtifact } from './artifacts'
|
||||
export type PermissionMode = 'default-sandbox' | 'full-access'
|
||||
/** Conversation behavior, independent of filesystem approval/permission mode. */
|
||||
export type AgentMode = 'craft' | 'plan' | 'ask'
|
||||
|
||||
export interface ChatAttachment {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
size: number
|
||||
previewUrl?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
id?: string
|
||||
option?: string
|
||||
label?: string
|
||||
question?: string
|
||||
description?: string
|
||||
hint?: string
|
||||
}
|
||||
export interface QuestionItem {
|
||||
id: string
|
||||
question: string
|
||||
options: Array<string | QuestionOption>
|
||||
multiSelect?: boolean
|
||||
}
|
||||
export interface QuestionAnswer {
|
||||
title?: string
|
||||
questions: Array<{ id: string; question: string; answers: string[] }>
|
||||
}
|
||||
export interface PermissionOption { optionId: string; name: string; kind: string }
|
||||
export type InteractionStatus = 'pending' | 'responding' | 'answered' | 'rejected' | 'cancelled' | 'error'
|
||||
interface InteractionBase {
|
||||
id: string
|
||||
runId: string
|
||||
toolCallId: string
|
||||
title: string
|
||||
status: InteractionStatus
|
||||
error?: string
|
||||
}
|
||||
export type ChatInteraction =
|
||||
| (InteractionBase & { kind: 'question'; questions: QuestionItem[]; answer?: QuestionAnswer; skipped?: boolean })
|
||||
| (InteractionBase & { kind: 'approval'; requestId: string; options: PermissionOption[]; selectedOptionId?: string; scope?: string })
|
||||
export type InteractionResponse =
|
||||
| { kind: 'question'; answer: QuestionAnswer; skipped?: boolean }
|
||||
| { kind: 'approval'; optionId: string }
|
||||
|
||||
export interface PlanStep { id: string; text: string; status: 'pending' | 'in_progress' | 'completed' | 'cancelled' }
|
||||
export interface PlanContent { id: string; title: string; steps: PlanStep[]; filePath?: string }
|
||||
export interface TaskContent {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
status: 'pending' | 'running' | 'success' | 'error' | 'cancelled'
|
||||
summary?: string
|
||||
toolCount?: number
|
||||
durationMs?: number
|
||||
}
|
||||
export interface ArtifactContent { id: string; name: string; path: string; mimeType?: string; description?: string; preview?: PreviewArtifact }
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* IPC 契约(主进程与 preload 共享)。
|
||||
* 放在 shared 下,避免主进程反向依赖 electron 类型。
|
||||
*/
|
||||
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatEvent,
|
||||
SessionMeta,
|
||||
GatewayConfigView,
|
||||
WorkspaceInfo,
|
||||
WindowState,
|
||||
AppInfo
|
||||
} from './types'
|
||||
import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources'
|
||||
import type { ChatAttachment, InteractionResponse, PermissionMode } from './conversation'
|
||||
import type { CustomModelInput, ModelRegistryView, ModelTestResult } 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'
|
||||
|
||||
export interface ConfigPatchLike {
|
||||
speechBaseUrl?: string
|
||||
speechModel?: string
|
||||
baseUrl?: string
|
||||
model?: string
|
||||
temperature?: number
|
||||
maxToolRounds?: number
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export type EditAction = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'selectAll'
|
||||
|
||||
export interface SendPayload {
|
||||
skillIds?: string[]
|
||||
planId?: string
|
||||
resources?: string[]
|
||||
sessionId: string
|
||||
text: string
|
||||
attachments?: ChatAttachment[]
|
||||
permissionMode?: PermissionMode
|
||||
/** Retry/edit from this message; descendants are preserved in an archived branch. */
|
||||
fromMessageId?: string
|
||||
action?: 'edit' | 'resend' | 'regenerate'
|
||||
}
|
||||
|
||||
export interface SendResult {
|
||||
runId: string
|
||||
assistantMessageId: string
|
||||
/** Frozen identity of the model selected when this assistant turn started. */
|
||||
modelId?: string
|
||||
modelName?: string
|
||||
assistantDisplay?: import('./capabilities').ExpertBinding
|
||||
userMessage: ChatMessage
|
||||
messages?: ChatMessage[]
|
||||
}
|
||||
|
||||
/** 统一的返回包装,便于渲染进程处理错误而不炸掉 UI */
|
||||
export type Ipc<T> = Promise<T>
|
||||
|
||||
/** A cancelled folder picker has no attached ID, even when the registry has a selection. */
|
||||
export interface WorkspaceAttachResult extends WorkspaceRegistryView {
|
||||
attachedWorkspaceId?: string
|
||||
}
|
||||
|
||||
/** preload 暴露给渲染进程的 API 形状 */
|
||||
export interface YCodeApi {
|
||||
experts: ExpertsApi
|
||||
skills: SkillsApi
|
||||
mcp: McpApi
|
||||
browser: BrowserHostApi
|
||||
terminal: TerminalHostApi
|
||||
window: {
|
||||
minimize(): Ipc<void>
|
||||
toggleMaximize(): Ipc<{ maximized: boolean }>
|
||||
close(): Ipc<void>
|
||||
getState(): Ipc<WindowState>
|
||||
onState(listener: (state: WindowState) => void): () => void
|
||||
onFocus(listener: (focused: boolean) => void): () => void
|
||||
}
|
||||
edit: {
|
||||
action(action: EditAction): Ipc<void>
|
||||
}
|
||||
app: {
|
||||
info(): Ipc<AppInfo>
|
||||
openConfigDir(): Ipc<void>
|
||||
openSettings(section?: string, action?: 'new'): Ipc<void>
|
||||
onSettingsChanged(listener: () => void): () => void
|
||||
onSettingsNavigate(listener: (section: string, action?: 'new') => void): () => void
|
||||
}
|
||||
config: {
|
||||
get(): Ipc<GatewayConfigView>
|
||||
update(patch: ConfigPatchLike): Ipc<GatewayConfigView>
|
||||
}
|
||||
workspace: {
|
||||
get(): Ipc<WorkspaceInfo>
|
||||
choose(): Ipc<WorkspaceInfo & { changed: boolean }>
|
||||
list(): Ipc<WorkspaceRegistryView>
|
||||
generate(name: string): Ipc<WorkspaceRegistryView>
|
||||
attach(path?: string): Ipc<WorkspaceAttachResult>
|
||||
select(id: string | null): Ipc<WorkspaceRegistryView>
|
||||
rename(id: string, name: string): Ipc<WorkspaceRegistryView>
|
||||
remove(id: string): Ipc<WorkspaceRegistryView>
|
||||
open(id: string): Ipc<void>
|
||||
}
|
||||
models: {
|
||||
list(): Ipc<ModelRegistryView>
|
||||
save(input: CustomModelInput, overwrite?: boolean): Ipc<ModelRegistryView>
|
||||
remove(id: string): Ipc<ModelRegistryView>
|
||||
select(id: string | null): Ipc<ModelRegistryView>
|
||||
test(input: CustomModelInput): Ipc<ModelTestResult>
|
||||
}
|
||||
resources: {
|
||||
list(sessionId?: string, workspaceId?: string): Ipc<ResourceEntry[]>
|
||||
openExternal(url: string): Ipc<void>
|
||||
read(path: string, range?: ResourceRange, sessionId?: string): Ipc<WorkspaceResource>
|
||||
readText(path: string, sessionId?: string): Ipc<string>
|
||||
classify(path: string, sessionId?: string): Ipc<'file' | 'directory' | 'unknown'>
|
||||
resolveImage(source: string, sessionId?: string): Ipc<string | undefined>
|
||||
download(payload: DownloadPayload): Ipc<DownloadResult>
|
||||
downloadImage(source: string, filename?: string, sessionId?: string): Ipc<DownloadResult>
|
||||
}
|
||||
attachments: {
|
||||
pick(): Ipc<ChatAttachment[]>
|
||||
stage(payload: { name: string; mimeType: string; dataBase64: string }): Ipc<ChatAttachment>
|
||||
remove(id: string): Ipc<void>
|
||||
}
|
||||
speech: {
|
||||
transcribe(payload: { audioBase64: string; mimeType: string }): Ipc<{ text: string }>
|
||||
}
|
||||
session: {
|
||||
list(): Ipc<SessionMeta[]>
|
||||
create(title?: string, context?: SessionContext): Ipc<SessionMeta>
|
||||
updateContext(id: string, context: SessionContext): Ipc<SessionMeta>
|
||||
messages(sessionId: string): Ipc<ChatMessage[]>
|
||||
rename(sessionId: string, title: string): Ipc<void>
|
||||
remove(sessionId: string): Ipc<void>
|
||||
}
|
||||
chat: {
|
||||
revert(sessionId: string, userMessageId: string): Ipc<{ messages: ChatMessage[] }>
|
||||
feedback(sessionId: string, messageId: string, value: 'like' | 'dislike'): Ipc<void>
|
||||
send(payload: SendPayload): Ipc<SendResult>
|
||||
cancel(runId: string): Ipc<{ cancelled: boolean }>
|
||||
respond(runId: string, interactionId: string, response: InteractionResponse): Ipc<{ accepted: boolean }>
|
||||
/** 返回取消订阅函数 */
|
||||
onEvent(listener: (event: ChatEvent) => void): () => void
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ChatEvent, ChatMessage, MessagePart, ToolCallRecord } from './types'
|
||||
|
||||
/** 老会话只有合并正文与工具数组;不臆造其已经丢失的发生顺序。 */
|
||||
export function getMessageParts(message: ChatMessage): MessagePart[] {
|
||||
if (message.parts?.length) return message.parts
|
||||
return [
|
||||
...(message.content ? [{ type: 'text' as const, text: message.content }] : []),
|
||||
...(message.tools ?? []).map((tool) => ({ type: 'tool' as const, toolId: tool.id }))
|
||||
]
|
||||
}
|
||||
|
||||
/** 主进程持久化和渲染进程使用同一事件归约,避免重载前后内容顺序不同。 */
|
||||
export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): ChatMessage {
|
||||
switch (event.type) {
|
||||
case 'part': {
|
||||
const parts = [...getMessageParts(message)]
|
||||
const identity = (part: MessagePart): string | undefined => {
|
||||
if (part.type === 'reasoning' || part.type === 'image') return `${part.type}:${part.id}`
|
||||
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}`
|
||||
return undefined
|
||||
}
|
||||
const key = identity(event.part)
|
||||
const index = parts.findIndex((part) => identity(part) === key)
|
||||
if (index < 0) parts.push(event.part)
|
||||
else parts[index] = event.part
|
||||
return { ...message, parts }
|
||||
}
|
||||
case 'interaction': {
|
||||
const interactions = [...(message.interactions ?? [])]
|
||||
const index = interactions.findIndex((item) => item.id === event.interaction.id)
|
||||
if (index < 0) interactions.push(event.interaction)
|
||||
else interactions[index] = event.interaction
|
||||
return { ...message, interactions }
|
||||
}
|
||||
case 'text': {
|
||||
if (!event.delta) return message
|
||||
const parts = getMessageParts(message).map((part) => ({ ...part }))
|
||||
const last = parts.at(-1)
|
||||
if (last?.type === 'text') last.text += event.delta
|
||||
else parts.push({ type: 'text', text: event.delta })
|
||||
return { ...message, content: message.content + event.delta, parts }
|
||||
}
|
||||
case 'tool_call':
|
||||
case 'tool_result': {
|
||||
const tools = [...(message.tools ?? [])]
|
||||
const index = tools.findIndex((tool) => tool.id === event.call.id)
|
||||
if (index < 0) tools.push({ ...event.call })
|
||||
else tools[index] = { ...tools[index], ...event.call }
|
||||
const parts = [...getMessageParts(message)]
|
||||
if (!parts.some((part) => part.type === 'tool' && part.toolId === event.call.id)) {
|
||||
parts.push({ type: 'tool', toolId: event.call.id })
|
||||
}
|
||||
return { ...message, tools, parts }
|
||||
}
|
||||
case 'done': {
|
||||
let parts = getMessageParts(message)
|
||||
const text = parts.filter((part) => part.type === 'text').map((part) => part.text).join('')
|
||||
// 没有增量的回合仍能消费完整终态;正常流式回合保留已知顺序。
|
||||
if (text !== event.content) {
|
||||
// Correct the plain text while retaining independently ordered rich content.
|
||||
const firstTextIndex = parts.findIndex((part) => part.type === 'text')
|
||||
parts = parts.filter((part) => part.type !== 'text')
|
||||
if (event.content) parts.splice(firstTextIndex < 0 ? 0 : Math.min(firstTextIndex, parts.length), 0, { type: 'text', text: event.content })
|
||||
} else {
|
||||
parts = [...parts]
|
||||
}
|
||||
for (const tool of event.tools) {
|
||||
if (!parts.some((part) => part.type === 'tool' && part.toolId === tool.id)) {
|
||||
parts.push({ type: 'tool', toolId: tool.id })
|
||||
}
|
||||
}
|
||||
return { ...message, content: event.content, tools: event.tools, parts, status: 'done', ...terminalDuration(event) }
|
||||
}
|
||||
case 'cancelled':
|
||||
case 'error': {
|
||||
const status = event.type === 'cancelled' ? 'cancelled' : 'error'
|
||||
const tools = message.tools?.map((tool): ToolCallRecord =>
|
||||
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) }
|
||||
}
|
||||
default:
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
function terminalDuration(event: ChatEvent): Pick<ChatMessage, 'turnDurationMs' | 'turnStartedAt'> {
|
||||
return {
|
||||
...(typeof event.turnDurationMs === 'number' && Number.isFinite(event.turnDurationMs) && event.turnDurationMs >= 0
|
||||
? { turnDurationMs: event.turnDurationMs } : {}),
|
||||
turnStartedAt: undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { GatewayConfig } from './types'
|
||||
|
||||
export interface CustomModelProfile {
|
||||
id: string
|
||||
name: string
|
||||
providerId: string
|
||||
model: string
|
||||
baseUrl: string
|
||||
hasApiKey: boolean
|
||||
keyStorage: 'encrypted' | 'plaintext' | 'none'
|
||||
authMode?: 'api-key' | 'none'
|
||||
supportsToolCall?: boolean
|
||||
supportsImages?: boolean
|
||||
supportsReasoning?: boolean
|
||||
onlyReasoning?: boolean
|
||||
canDisableThinking?: boolean
|
||||
defaultEffort?: string
|
||||
supportedEfforts?: string[]
|
||||
useCustomProtocol?: boolean
|
||||
maxInputTokens?: number
|
||||
maxOutputTokens?: number
|
||||
temperature?: number
|
||||
maxToolRounds?: number
|
||||
}
|
||||
|
||||
export interface CustomModelInput extends Omit<CustomModelProfile, 'hasApiKey' | 'keyStorage'> {
|
||||
/** Empty or omitted during editing preserves the existing secret. */
|
||||
apiKey?: string
|
||||
clearApiKey?: boolean
|
||||
previousId?: string
|
||||
}
|
||||
|
||||
export interface ModelRegistryView {
|
||||
models: CustomModelProfile[]
|
||||
selectedModelId: string | null
|
||||
}
|
||||
|
||||
export interface ModelTestResult {
|
||||
success: boolean
|
||||
message: string
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/** Main-process-only resolved credentials; never expose this through preload. */
|
||||
export interface ResolvedModel {
|
||||
profile: CustomModelProfile
|
||||
config: GatewayConfig
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface OpenAIEndpoint {
|
||||
api: 'openai-completions' | 'openai-responses'
|
||||
/** SDK root; the provider appends its protocol path. Stored user settings stay intact. */
|
||||
baseUrl: string
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
/** A complete Responses URL selects that protocol; ordinary API roots retain Chat compatibility. */
|
||||
export function resolveOpenAIEndpoint(raw: string): OpenAIEndpoint {
|
||||
const url = new URL(raw.trim())
|
||||
const path = url.pathname.replace(/\/+$/, '')
|
||||
const responses = /\/responses$/i.test(path)
|
||||
const complete = responses || /\/chat\/completions$/i.test(path)
|
||||
const protocolPath = responses ? '/responses' : '/chat/completions'
|
||||
const root = complete ? path.slice(0, -protocolPath.length) : path
|
||||
const base = new URL(url)
|
||||
base.pathname = root || '/'
|
||||
const target = new URL(url)
|
||||
target.pathname = root + protocolPath
|
||||
return { api: responses ? 'openai-responses' : 'openai-completions',
|
||||
baseUrl: base.href.replace(/\/$/, ''), endpoint: target.href }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PreviewArtifact } from './artifacts'
|
||||
|
||||
export interface ResourceRange { start: number; end?: number }
|
||||
export interface ResourceEntry { name: string; path: string; type: 'file' | 'directory' }
|
||||
export type WorkspaceResource =
|
||||
| { kind: 'artifact'; path: string; name: string; content: string; artifactType?: string; artifact?: PreviewArtifact }
|
||||
| { kind: 'text'; path: string; name: string; content: string; firstLine: number; totalLines: number; range?: ResourceRange; truncated: boolean }
|
||||
| { kind: 'image'; path: string; name: string; url: string }
|
||||
| { kind: 'directory'; path: string; name: string; entries: ResourceEntry[]; truncated: boolean }
|
||||
|
||||
export interface DownloadPayload {
|
||||
filename: string
|
||||
mimeType: string
|
||||
data: string
|
||||
encoding: 'utf8' | 'base64'
|
||||
}
|
||||
export interface DownloadResult { saved: boolean; path?: string }
|
||||
@@ -0,0 +1,26 @@
|
||||
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 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[] {
|
||||
const index = messages.findIndex((message) => message.id === userMessageId && message.role === 'user')
|
||||
if (index < 0) throw new Error('未找到用户消息')
|
||||
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 ?? []) {
|
||||
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
|
||||
if (!data || typeof data.path !== 'string' || !(data.oldContent === null || typeof data.oldContent === 'string') || typeof data.newContent !== 'string') {
|
||||
if (tool.status === 'error' || tool.status === 'cancelled') continue
|
||||
throw new Error('此版本缺少文件修改记录')
|
||||
}
|
||||
const existing = changes.get(data.path)
|
||||
if (existing && existing.after !== data.oldContent) throw new Error('文件在两次工具修改之间发生过外部编辑')
|
||||
changes.set(data.path, { path: data.path, before: existing ? existing.before : data.oldContent, after: data.newContent })
|
||||
}
|
||||
}
|
||||
return [...changes.values()]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Local PTY handles are scoped to their creating window and conversation. */
|
||||
export interface TerminalReplayChunk { data: string; cols: number; rows: number }
|
||||
export interface TerminalHostView {
|
||||
id: string
|
||||
sessionId?: string
|
||||
cwd: string
|
||||
title: string
|
||||
shell: string
|
||||
cols: number
|
||||
rows: number
|
||||
/** Bounded ANSI output replay; credentials are never added to the shell environment. */
|
||||
buffer: string
|
||||
replay: TerminalReplayChunk[]
|
||||
sequence: number
|
||||
exited: boolean
|
||||
exitCode?: number
|
||||
}
|
||||
export type TerminalHostEvent = {
|
||||
id: string
|
||||
sessionId?: string
|
||||
sequence: number
|
||||
} & ({ type: 'data'; data: string } | { type: 'exit'; exitCode: number })
|
||||
|
||||
export interface TerminalHostApi {
|
||||
create(sessionId?: string, cols?: number, rows?: number): Promise<TerminalHostView>
|
||||
get(id: string, sessionId?: string): Promise<TerminalHostView>
|
||||
write(id: string, data: string, sessionId?: string): Promise<void>
|
||||
resize(id: string, cols: number, rows: number, sessionId?: string): Promise<void>
|
||||
clear(id: string, sessionId?: string): Promise<void>
|
||||
rebind(id: string, previousSessionId: string | undefined, nextSessionId: string): Promise<TerminalHostView>
|
||||
close(id: string, sessionId?: string): Promise<void>
|
||||
onEvent(listener: (event: TerminalHostEvent) => void): () => void
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 主进程 / 渲染进程共享的类型与 IPC 契约。
|
||||
*
|
||||
* 已存 API Key 不返回渲染进程;用户可在模型表单编辑地址和新密钥。
|
||||
* 模型网络请求经由主进程网关或 OMP 子进程完成。
|
||||
*/
|
||||
|
||||
/** 会话内的一条消息(对外视图) */
|
||||
import type { ArtifactContent, ChatAttachment, ChatInteraction, PlanContent, TaskContent } from './conversation'
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
/** 助手消息携带的工具调用记录 */
|
||||
tools?: ToolCallRecord[]
|
||||
/** 按事件顺序记录正文与工具位置;旧会话没有此字段时按兼容方式渲染。 */
|
||||
parts?: MessagePart[]
|
||||
attachments?: ChatAttachment[]
|
||||
resources?: string[]
|
||||
skills?: Array<{ id: string; name: string; uri: string }>
|
||||
/** Native-history reconstruction; never includes connector credentials. */
|
||||
capabilityContext?: string
|
||||
expertId?: string
|
||||
assistantDisplay?: import('./capabilities').ExpertBinding
|
||||
/** The model selected when this assistant turn started; kept with history. */
|
||||
modelId?: string
|
||||
modelName?: string
|
||||
interactions?: ChatInteraction[]
|
||||
requestId?: string
|
||||
planId?: string
|
||||
nativeEntryId?: string
|
||||
nativeSessionPath?: string
|
||||
feedback?: 'like' | 'dislike'
|
||||
interrupted?: boolean
|
||||
createdAt: number
|
||||
/** User-submit timestamp shared by the live timer and terminal turn duration. */
|
||||
turnStartedAt?: number
|
||||
/** Measured user-send to terminal-event duration, used by the source completed fold. */
|
||||
turnDurationMs?: number
|
||||
/** 本回合的终态 */
|
||||
status?: 'streaming' | 'done' | 'error' | 'cancelled'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ToolCallRecord {
|
||||
id: string
|
||||
name: string
|
||||
/** Human-readable action description supplied by the tool runtime, when available. */
|
||||
intent?: string
|
||||
/** 已解析的参数(原始 JSON 字符串的解析结果,解析失败则保留原文) */
|
||||
args: unknown
|
||||
argsRaw: string
|
||||
status: 'running' | 'ok' | 'error' | 'cancelled'
|
||||
/** 完整的文本输出;summary 仅供折叠态摘要,不能替代原文。 */
|
||||
output?: string
|
||||
/** 展示用摘要(截断后的结果) */
|
||||
summary?: string
|
||||
durationMs?: number
|
||||
/** Native structured result and renderer metadata are retained separately from text. */
|
||||
result?: unknown
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type MessagePart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'tool'; toolId: string }
|
||||
| { type: 'reasoning'; id: string; text: string; finished?: boolean }
|
||||
| { type: 'plan'; plan: PlanContent }
|
||||
| { type: 'task'; task: TaskContent }
|
||||
| { type: 'artifact'; artifact: ArtifactContent }
|
||||
| { type: 'image'; id: string; url: string; alt?: string }
|
||||
|
||||
/** 面向模型暴露的工具描述(OpenAI function tool 格式) */
|
||||
export interface ToolSpec {
|
||||
type: 'function'
|
||||
function: {
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
/** 网关配置:一个 OpenAI 兼容的上游 */
|
||||
export interface GatewayConfig {
|
||||
speechBaseUrl?: string
|
||||
speechModel?: string
|
||||
/** 上游 baseUrl,例如 https://api.deepseek.com/v1 */
|
||||
baseUrl: string
|
||||
/** 模型名,例如 deepseek-chat */
|
||||
model: string
|
||||
/** API Key(仅存在于主进程) */
|
||||
apiKey: string
|
||||
/** 采样温度 */
|
||||
temperature: number
|
||||
/** 单回合最大工具轮次 */
|
||||
maxToolRounds: number
|
||||
authMode?: 'api-key' | 'none'
|
||||
maxOutputTokens?: number
|
||||
}
|
||||
|
||||
/** 渲染进程可见的配置视图(不含 apiKey) */
|
||||
export interface GatewayConfigView {
|
||||
speechBaseUrl?: string
|
||||
speechModel?: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
temperature: number
|
||||
maxToolRounds: number
|
||||
/** 是否已配置 Key(不回传明文) */
|
||||
hasApiKey: boolean
|
||||
/** Key 来源:safeStorage 加密 / 明文降级 */
|
||||
keyStorage: 'encrypted' | 'plaintext' | 'none'
|
||||
}
|
||||
|
||||
/** 主进程 → 渲染进程的流式事件 */
|
||||
export type ChatEvent = { sessionId?: string; turnDurationMs?: 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: 'done'; content: string; tools: ToolCallRecord[] }
|
||||
| { runId: string; type: 'cancelled' }
|
||||
| { runId: string; type: 'error'; message: string }
|
||||
)
|
||||
|
||||
/** 一次会话的元信息 */
|
||||
export interface SessionMeta {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
workspaceId?: string
|
||||
cwd?: string
|
||||
modelId?: string
|
||||
expertId?: string | null
|
||||
agentMode?: import('./conversation').AgentMode
|
||||
thinkingLevel?: import('./workspaces').ThinkingLevel
|
||||
}
|
||||
|
||||
/** 工作目录信息 */
|
||||
export interface WorkspaceInfo {
|
||||
root: string
|
||||
}
|
||||
|
||||
/** 无边框窗口状态 */
|
||||
export interface WindowState {
|
||||
maximized: boolean
|
||||
fullscreen: boolean
|
||||
}
|
||||
|
||||
/** 应用信息(关于对话框用) */
|
||||
export interface AppInfo {
|
||||
name: string
|
||||
version: string
|
||||
electron: string
|
||||
chrome: string
|
||||
node: string
|
||||
configDir: string
|
||||
/** OMP 内核状态(未探测到时 available 为 false) */
|
||||
omp: {
|
||||
available: boolean
|
||||
exePath: string | null
|
||||
profile: string
|
||||
model: string | null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Inert display projection from REA ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb.
|
||||
* workbuddy-provider-DkH2YFYM.js: canonicalizeToolName, unwrapDeferExecuteTool,
|
||||
* TodoToolAdapter.writeTodosToTool and buildQuestionAnswerFromResult.
|
||||
* Never changes the stored invocation or makes a tool executable. */
|
||||
import type { ToolCallRecord } from './types'
|
||||
|
||||
const aliases: Record<string, string> = {
|
||||
Read: 'read_file', Write: 'write_to_file', Edit: 'replace_in_file', MultiEdit: 'replace_in_file',
|
||||
Glob: 'search_file', Grep: 'search_content', LS: 'list_dir', Bash: 'execute_command',
|
||||
BashOutput: 'execute_command', KillShell: 'execute_command', TodoWrite: 'todo_write', Agent: 'task',
|
||||
WebFetch: 'web_fetch', WebSearch: 'web_search', ImageGen: 'image_gen', VideoGen: 'video_gen',
|
||||
Skill: 'use_skill', NotebookEdit: 'notebook_edit', TaskCreate: 'todo_write', TaskGet: 'task_get',
|
||||
TaskUpdate: 'todo_write', TaskList: 'todo_write', TeamCreate: 'team_create', TeamDelete: 'team_delete',
|
||||
SendMessage: 'send_message', AskUserQuestion: 'askuserquestion', EnterPlanMode: 'enter_plan_mode',
|
||||
ExitPlanMode: 'exit_plan_mode', ToolSearch: 'tool_search', DeferExecuteTool: 'defer_execute_tool'
|
||||
}
|
||||
const record = (value: unknown): Record<string, unknown> | undefined =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
|
||||
|
||||
export function canonicalizeWorkBuddyToolName(name: string): string {
|
||||
if (name === 'automation_update' || name.endsWith('__automation_update')) return 'automation_update'
|
||||
return aliases[name] ?? Object.entries(aliases).find(([alias]) => alias.toLowerCase() === name.toLowerCase())?.[1] ?? name
|
||||
}
|
||||
|
||||
export function isWorkBuddyCancellationMarker(text: unknown): boolean {
|
||||
return typeof text === 'string' && ['Interrupted by user', '[User Cancelled]'].includes(text.trim())
|
||||
}
|
||||
|
||||
export function projectWorkBuddyHistoryTool(tool: ToolCallRecord): ToolCallRecord {
|
||||
let name = canonicalizeWorkBuddyToolName(tool.name)
|
||||
let args = record(tool.args)
|
||||
if (name === 'defer_execute_tool' && typeof args?.toolName === 'string'
|
||||
&& args.toolName.length <= 128 && /^[A-Za-z0-9_.-]+$/.test(args.toolName)) {
|
||||
name = canonicalizeWorkBuddyToolName(args.toolName)
|
||||
args = record(args.params) ?? args
|
||||
}
|
||||
const result = record(tool.result)
|
||||
if (name === 'todo_write') {
|
||||
const snapshot = record(result?.acpV2PlanSnapshot)
|
||||
const entries = snapshot?.entries
|
||||
// The recorded ACP plan contains the whole list, unlike TaskCreate/TaskUpdate input.
|
||||
// Keep each historical snapshot; provider cross-request removal requires request identity.
|
||||
if (Array.isArray(entries)) args = { ...args, todos: entries }
|
||||
}
|
||||
let metadata = tool.metadata
|
||||
if (name === 'askuserquestion' && tool.status === 'ok' && !metadata?.questionAnswer && !metadata?.questionSkipped) {
|
||||
const questions = args?.questions
|
||||
const resultText = typeof result?.text === 'string' ? result.text
|
||||
: typeof result?.content === 'string' ? result.content : tool.output ?? ''
|
||||
if (Array.isArray(questions) && questions.length > 0) {
|
||||
const answers = new Map<string, string>()
|
||||
for (const line of resultText.split('\n')) {
|
||||
const match = line.match(/^\s*(?:[\u00B7\u2022*-]\s*)?(.+?)\s*(?:\u2192|->)\s*(.+)\s*$/)
|
||||
if (match) answers.set(match[1].trim(), match[2].trim())
|
||||
}
|
||||
metadata = { ...metadata, questionAnswer: { title: args?.title, questions: questions.map((value, index) => {
|
||||
const question = record(value) ?? {}
|
||||
const id = typeof question.id === 'string' && question.id ? question.id : `q-${index}`
|
||||
const text = typeof question.question === 'string' ? question.question : ''
|
||||
const answer = answers.get(id) ?? answers.get(id.replace('_', '-')) ?? answers.get(id.replace('-', '_')) ?? answers.get(text)
|
||||
return { id, question: text, answers: answer ? [answer] : [] }
|
||||
}) }, questionSkipped: false }
|
||||
}
|
||||
}
|
||||
return { ...tool, name, args: args ?? tool.args, metadata }
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/** Offline conversion of REA-exported WorkBuddy history. This module never executes tools. */
|
||||
import type { ChatMessage, SessionMeta, ToolCallRecord } from './types'
|
||||
import { isWorkBuddyCancellationMarker } from './workbuddy-history-projection'
|
||||
|
||||
export interface WorkBuddyImportOptions {
|
||||
/** A fresh YCode session ID, unrelated to WorkBuddy's native session ID. */
|
||||
sessionId: string
|
||||
title: string
|
||||
sourceSessionId: string
|
||||
evidenceId: string
|
||||
sourceSha256: string
|
||||
importedAt: number
|
||||
idPrefix?: string
|
||||
/** Archive preserves every recorded branch. Visible applies explicit resend-fork notices. */
|
||||
branchMode?: 'archive' | 'visible'
|
||||
}
|
||||
|
||||
export interface WorkBuddyImportProvenance {
|
||||
application: 'WorkBuddy'
|
||||
sourceSessionId: string
|
||||
evidenceId: string
|
||||
sourceSha256: string
|
||||
importedAt: number
|
||||
/** Only inert history is imported; no native session or approval is resumed. */
|
||||
executableStateImported: false
|
||||
sourceEntries: Array<{ recordIndex: number; sourceId?: string; messageId?: string }>
|
||||
branchMode: 'archive' | 'visible'
|
||||
referencedAttachments: Array<{
|
||||
messageId: string; attachmentId: string; sourceBlobId?: string; sourceBlobPath?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface WorkBuddyImportResult {
|
||||
meta: SessionMeta
|
||||
messages: ChatMessage[]
|
||||
provenance: WorkBuddyImportProvenance
|
||||
warnings: string[]
|
||||
stats: {
|
||||
sourceRecords: number
|
||||
importedMessages: number
|
||||
toolCalls: number
|
||||
unmatchedToolResults: number
|
||||
unresolvedToolCalls: number
|
||||
forksApplied: number
|
||||
excludedBranchRecords: number
|
||||
hiddenInjectedUserRecords: number
|
||||
normalizedUserTexts: number
|
||||
}
|
||||
}
|
||||
|
||||
/** Fail the entire import on malformed JSON rather than silently dropping history. */
|
||||
export function parseWorkBuddyJsonl(text: string): unknown[] {
|
||||
const records: unknown[] = []
|
||||
for (const [index, line] of text.replace(/^\uFEFF/, '').split(/\r?\n/).entries()) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const record: unknown = JSON.parse(line)
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) throw new Error('Expected an object')
|
||||
records.push(record)
|
||||
} catch {
|
||||
throw new Error(`WorkBuddy history contains invalid JSON at line ${index + 1}`)
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
type RecordValue = Record<string, unknown>
|
||||
const object = (value: unknown): RecordValue | undefined => value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as RecordValue : undefined
|
||||
const string = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined
|
||||
const time = (value: unknown, fallback: number): number => typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
? value : typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value) && Number.isFinite(Number(value)) ? Number(value)
|
||||
: typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : fallback
|
||||
|
||||
function textContent(content: unknown): string {
|
||||
if (typeof content === 'string') return content
|
||||
if (Array.isArray(content)) return content.filter((value) => typeof value === 'string'
|
||||
|| ['input_text', 'output_text', 'text'].includes(String(object(value)?.type))).map(textContent).join('\n')
|
||||
const block = object(content)
|
||||
return block && (block.type === 'input_text' || block.type === 'output_text' || block.type === 'text')
|
||||
? string(block.text) ?? '' : ''
|
||||
}
|
||||
|
||||
// Original common-DY2kXmis.js stripPromptContextXml, recovered by REA
|
||||
// ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb.
|
||||
export function stripWorkBuddyPromptContext(text: string): string {
|
||||
return text.replace(/<system-reminder\b[^>]*>[\s\S]*?<\/system-reminder>\s*/g, '')
|
||||
.replace(/<user_query>([\s\S]*?)<\/user_query>/g, '$1').trim()
|
||||
}
|
||||
|
||||
// Original contexts-BMeWSUHk.js excludes complete task notifications from visible
|
||||
// prompts. Stored isMeta records also append system execution instructions after
|
||||
// the XML block; the import adapter hides this explicitly marked injected form.
|
||||
function isInjectedTaskNotification(record: RecordValue, text: string): boolean {
|
||||
return object(record.providerData)?.isMeta === true
|
||||
&& /^\s*<task-notification>\s*[\s\S]*?<\/task-notification>/i.test(text)
|
||||
}
|
||||
|
||||
function toolOutput(output: unknown): string {
|
||||
if (typeof output === 'string') return output
|
||||
if (Array.isArray(output)) return output.map(toolOutput).join('\n')
|
||||
const block = object(output)
|
||||
if (block && typeof block.text === 'string') return block.text
|
||||
return output === undefined ? '' : JSON.stringify(output)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the explicit JSONL record types observed in REA session evidence.
|
||||
* Assistant text, reasoning and tool calls remain in source order within each turn.
|
||||
* Provider payloads, rawContent, native branch IDs and live interactions are excluded.
|
||||
*/
|
||||
export function convertWorkBuddyTranscript(records: readonly unknown[], options: WorkBuddyImportOptions): WorkBuddyImportResult {
|
||||
if (!options.sessionId || options.sessionId === options.sourceSessionId) throw new Error('A fresh YCode session ID is required')
|
||||
if (!/^ev_[a-f0-9]+$/i.test(options.evidenceId) || !/^[a-f0-9]{64}$/i.test(options.sourceSha256)) {
|
||||
throw new Error('REA evidence ID and source SHA-256 are required')
|
||||
}
|
||||
if (!Number.isFinite(options.importedAt)) throw new Error('A finite import timestamp is required')
|
||||
const prefix = options.idPrefix ?? `wb-import-${options.sessionId}`
|
||||
const warnings: string[] = []
|
||||
const warningCounts = new Map<string, number>()
|
||||
const warn = (warning: string): void => { warningCounts.set(warning, (warningCounts.get(warning) ?? 0) + 1) }
|
||||
const branchMode = options.branchMode ?? 'archive'
|
||||
let forksApplied = 0
|
||||
let excludedBranchRecords = 0
|
||||
let selected: Array<{ record: RecordValue; index: number }> = []
|
||||
for (const [index, value] of records.entries()) {
|
||||
const record = object(value)
|
||||
if (!record) throw new Error(`WorkBuddy record ${index + 1} is not an object`)
|
||||
if (record.type === 'resend-fork-notice') {
|
||||
if (branchMode === 'visible') {
|
||||
const cut = selected.findIndex((entry) => entry.record.id === record.editedUserItemId)
|
||||
const targets = selected.filter((entry) => entry.record.id === record.editedUserItemId)
|
||||
if (cut >= 0 && targets.length === 1 && targets[0].record.type === 'message' && targets[0].record.role === 'user') {
|
||||
excludedBranchRecords += selected.length - cut
|
||||
selected = selected.slice(0, cut)
|
||||
forksApplied++
|
||||
} else throw new Error(`Resend fork target was absent or ambiguous at source record ${index + 1}; visible branch import was stopped`)
|
||||
} else warn('Resend forks were retained as full append-only history; this is not the currently visible branch')
|
||||
}
|
||||
selected.push({ record, index })
|
||||
}
|
||||
const messages: ChatMessage[] = []
|
||||
const provenance: WorkBuddyImportProvenance = {
|
||||
application: 'WorkBuddy', sourceSessionId: options.sourceSessionId,
|
||||
evidenceId: options.evidenceId, sourceSha256: options.sourceSha256, importedAt: options.importedAt,
|
||||
executableStateImported: false, sourceEntries: [], branchMode, referencedAttachments: []
|
||||
}
|
||||
const calls = new Map<string, Array<{ tool: ToolCallRecord; message: ChatMessage; createdAt: number }>>()
|
||||
const orphanResults: Array<{ record: RecordValue; index: number }> = []
|
||||
let assistant: ChatMessage | undefined
|
||||
let lastUserAt: number | undefined
|
||||
let assistantEnd = 0
|
||||
let toolCount = 0
|
||||
let unresolvedToolCalls = 0
|
||||
let hiddenInjectedUserRecords = 0
|
||||
let normalizedUserTexts = 0
|
||||
let lastAssistantStatus: unknown
|
||||
let lastAssistantError: string | undefined
|
||||
let lastAssistantCancelled = false
|
||||
const freshMessage = (role: 'user' | 'assistant', createdAt: number): ChatMessage => {
|
||||
const message: ChatMessage = { id: `${prefix}-message-${messages.length + 1}`, role, content: '', createdAt, status: 'done' }
|
||||
if (role === 'assistant') { message.parts = []; message.tools = [] }
|
||||
messages.push(message)
|
||||
return message
|
||||
}
|
||||
const finishAssistant = (): void => {
|
||||
if (!assistant) return
|
||||
assistant.content = (assistant.parts ?? []).filter((part) => part.type === 'text').map((part) => part.text).join('\n\n')
|
||||
assistant.status = lastAssistantCancelled ? 'cancelled' : lastAssistantError ? 'error' : lastAssistantStatus === 'incomplete' || lastAssistantStatus === 'cancelled' ? 'cancelled'
|
||||
: lastAssistantStatus === 'failed' || lastAssistantStatus === 'error' ? 'error' : 'done'
|
||||
if (lastAssistantError && !lastAssistantCancelled) assistant.error = lastAssistantError
|
||||
if (lastUserAt !== undefined && assistantEnd >= lastUserAt) assistant.turnDurationMs = assistantEnd - lastUserAt
|
||||
assistant = undefined
|
||||
lastAssistantStatus = undefined
|
||||
lastAssistantError = undefined
|
||||
lastAssistantCancelled = false
|
||||
}
|
||||
const getAssistant = (createdAt: number): ChatMessage => {
|
||||
assistant ??= freshMessage('assistant', createdAt)
|
||||
assistantEnd = Math.max(assistantEnd, createdAt)
|
||||
return assistant
|
||||
}
|
||||
const applyResult = (record: RecordValue, index: number): boolean => {
|
||||
const callId = string(record.callId)
|
||||
const candidates = callId ? calls.get(callId) : undefined
|
||||
const bound = candidates?.findLast((entry) => entry.tool.status === 'running')
|
||||
if (!bound) return false
|
||||
const { tool, message, createdAt } = bound
|
||||
const nativeResult = object(object(record.providerData)?.toolResult)
|
||||
tool.status = nativeResult?.error ? 'error' : record.status === 'completed' || record.status === 'success' ? 'ok'
|
||||
: record.status === 'cancelled' || record.status === 'incomplete' ? 'cancelled' : 'error'
|
||||
tool.output = toolOutput(record.output)
|
||||
tool.summary = tool.output.length > 500 ? `${tool.output.slice(0, 500)}…` : tool.output
|
||||
if (nativeResult) {
|
||||
const displayResult: RecordValue = {}
|
||||
for (const key of ['title', 'content', 'renderer', 'rawResponse', 'error', 'acpV2PlanSnapshot']) {
|
||||
if (nativeResult[key] !== undefined) displayResult[key] = nativeResult[key]
|
||||
}
|
||||
tool.result = displayResult
|
||||
// Existing specialized views read recorded raw responses from this display-only namespace.
|
||||
if (nativeResult.rawResponse !== undefined) tool.metadata = {
|
||||
...tool.metadata, _meta: { 'codebuddy.ai/rawResponse': nativeResult.rawResponse }
|
||||
}
|
||||
} else if (record.output !== undefined) tool.result = record.output
|
||||
const finishedAt = time(record.timestamp, createdAt)
|
||||
if (finishedAt >= createdAt) tool.durationMs = finishedAt - createdAt
|
||||
tool.metadata = { ...tool.metadata, importedWorkBuddyResultId: string(record.id) }
|
||||
provenance.sourceEntries.push({ recordIndex: index, sourceId: string(record.id), messageId: message.id })
|
||||
assistantEnd = Math.max(assistantEnd, finishedAt)
|
||||
return true
|
||||
}
|
||||
for (const { record, index } of selected) {
|
||||
const timestamp = time(record.timestamp, options.importedAt)
|
||||
const sourceId = string(record.id)
|
||||
if (record.type === 'message') {
|
||||
if (record.role !== 'user' && record.role !== 'assistant') { warn('Unsupported message roles were omitted'); continue }
|
||||
if (record.role === 'user') {
|
||||
const originalText = textContent(record.content)
|
||||
if (isInjectedTaskNotification(record, originalText)) {
|
||||
hiddenInjectedUserRecords++
|
||||
provenance.sourceEntries.push({ recordIndex: index, sourceId })
|
||||
warn('Injected isMeta task notifications were excluded from visible user prompts; full records remain in the REA archive')
|
||||
continue
|
||||
}
|
||||
finishAssistant()
|
||||
const message = freshMessage('user', timestamp)
|
||||
message.content = stripWorkBuddyPromptContext(originalText)
|
||||
if (message.content !== originalText) normalizedUserTexts++
|
||||
lastUserAt = timestamp
|
||||
for (const [blockIndex, value] of (Array.isArray(record.content) ? record.content : []).entries()) {
|
||||
const block = object(value)
|
||||
if (block?.type !== 'input_image') continue
|
||||
const image = object(block.image)
|
||||
const attachmentId = `${prefix}-attachment-${index}-${blockIndex}`
|
||||
const name = string(block.original_filename) ?? string(image?.original_filename) ?? 'WorkBuddy image'
|
||||
message.attachments ??= []
|
||||
message.attachments.push({ id: attachmentId, name, mimeType: string(image?.mime) ?? 'image/png', size: typeof image?.size === 'number' ? image.size : 0 })
|
||||
provenance.referencedAttachments.push({ messageId: message.id, attachmentId, sourceBlobId: string(image?.blob_id), sourceBlobPath: string(image?.blob_path) })
|
||||
warn('Image attachment references were retained, but their bytes were not included in this JSONL export')
|
||||
}
|
||||
provenance.sourceEntries.push({ recordIndex: index, sourceId, messageId: message.id })
|
||||
} else {
|
||||
const message = getAssistant(timestamp)
|
||||
const text = textContent(record.content)
|
||||
lastAssistantCancelled = isWorkBuddyCancellationMarker(text)
|
||||
if (text && !lastAssistantCancelled) message.parts!.push({ type: 'text', text })
|
||||
lastAssistantStatus = record.status
|
||||
lastAssistantError = string(object(object(record.providerData)?.error)?.message)
|
||||
provenance.sourceEntries.push({ recordIndex: index, sourceId, messageId: message.id })
|
||||
}
|
||||
} else if (record.type === 'reasoning') {
|
||||
const text = textContent(record.content)
|
||||
if (text) {
|
||||
const message = getAssistant(timestamp)
|
||||
message.parts!.push({ type: 'reasoning', id: `${prefix}-reasoning-${index}`, text, finished: true })
|
||||
provenance.sourceEntries.push({ recordIndex: index, sourceId, messageId: message.id })
|
||||
} else if (record.rawContent !== undefined) warn('Reasoning rawContent was excluded; only explicit plain content was imported')
|
||||
} else if (record.type === 'function_call') {
|
||||
const message = getAssistant(timestamp)
|
||||
const id = `${prefix}-tool-${++toolCount}`
|
||||
const argsRaw = typeof record.arguments === 'string' ? record.arguments : JSON.stringify(record.arguments ?? {})
|
||||
let args: unknown = argsRaw
|
||||
try { args = JSON.parse(argsRaw) } catch { warn('Malformed tool arguments were preserved as original text') }
|
||||
const callId = string(record.callId)
|
||||
const tool: ToolCallRecord = {
|
||||
id, name: string(record.name) ?? 'unknown', args, argsRaw, status: 'running',
|
||||
metadata: { importedHistory: true, importedWorkBuddyCallId: callId, importedWorkBuddyEntryId: sourceId }
|
||||
}
|
||||
message.tools!.push(tool)
|
||||
message.parts!.push({ type: 'tool', toolId: id })
|
||||
if (callId) calls.set(callId, [...(calls.get(callId) ?? []), { tool, message, createdAt: timestamp }])
|
||||
else warn('A tool call had no callId and could not be paired with a result')
|
||||
provenance.sourceEntries.push({ recordIndex: index, sourceId, messageId: message.id })
|
||||
} else if (record.type === 'function_call_result') {
|
||||
if (!applyResult(record, index)) orphanResults.push({ record, index })
|
||||
} else if (!['session-meta', 'file-history-snapshot', 'ai-title', 'resend-fork-notice'].includes(String(record.type))) {
|
||||
warn('Unsupported source record types were omitted')
|
||||
}
|
||||
}
|
||||
finishAssistant()
|
||||
// Results normally follow calls. A second pass retains results exported out of order.
|
||||
const unmatched = orphanResults.filter(({ record, index }) => !applyResult(record, index))
|
||||
for (const message of messages) for (const tool of message.tools ?? []) if (tool.status === 'running') {
|
||||
tool.status = 'cancelled'
|
||||
unresolvedToolCalls++
|
||||
}
|
||||
if (unresolvedToolCalls) warn('Tools without terminal results were archived as cancelled; no work or approvals were resumed')
|
||||
if (unmatched.length) warn('Tool results without matching callIds were not attached to an unrelated tool')
|
||||
for (const [warning, count] of warningCounts) warnings.push(`${warning} (${count})`)
|
||||
const createdAt = messages.length ? Math.min(...messages.map((message) => message.createdAt)) : options.importedAt
|
||||
const updatedAt = Math.max(createdAt, options.importedAt)
|
||||
return {
|
||||
meta: { id: options.sessionId, title: options.title, createdAt, updatedAt }, messages, provenance, warnings,
|
||||
stats: { sourceRecords: records.length, importedMessages: messages.length, toolCalls: toolCount, unmatchedToolResults: unmatched.length, unresolvedToolCalls, forksApplied, excludedBranchRecords, hiddenInjectedUserRecords, normalizedUserTexts }
|
||||
}
|
||||
}
|
||||
|
||||
export function convertWorkBuddyJsonl(text: string, options: WorkBuddyImportOptions): WorkBuddyImportResult {
|
||||
return convertWorkBuddyTranscript(parseWorkBuddyJsonl(text), options)
|
||||
}
|
||||
|
||||
/** Repair only the old importer duplicate cancellation tail. No source reimport or raw mutation.
|
||||
* Caller persists the returned messages through its normal serialized session store. */
|
||||
export function migrateImportedWorkBuddyMessages(
|
||||
messages: ChatMessage[], provenance: unknown
|
||||
): { messages: ChatMessage[]; changed: boolean; repairedCancellations: number } {
|
||||
const origin = object(provenance)
|
||||
if (origin?.application !== 'WorkBuddy' || origin.executableStateImported !== false
|
||||
|| !/^ev_[a-f0-9]+$/i.test(String(origin.evidenceId))
|
||||
|| !/^[a-f0-9]{64}$/i.test(String(origin.sourceSha256)) || !Array.isArray(origin.sourceEntries)) {
|
||||
return { messages, changed: false, repairedCancellations: 0 }
|
||||
}
|
||||
const importedIds = new Set(origin.sourceEntries.flatMap(value => {
|
||||
const entry = object(value)
|
||||
return typeof entry?.sourceId === 'string' && typeof entry.messageId === 'string' ? [entry.messageId] : []
|
||||
}))
|
||||
let repairedCancellations = 0
|
||||
const repaired = messages.map(message => {
|
||||
if (message.role !== 'assistant' || !importedIds.has(message.id)
|
||||
|| message.status !== 'error' || !isWorkBuddyCancellationMarker(message.error)) return message
|
||||
const tail = message.parts?.at(-1)
|
||||
if (tail?.type !== 'text' || !isWorkBuddyCancellationMarker(tail.text)) return message
|
||||
const parts = message.parts!.slice(0, -1)
|
||||
// Preserve user-edited content that no longer agrees with the old import projection.
|
||||
const oldContent = message.parts!.filter(part => part.type === 'text').map(part => part.text).join('\n\n')
|
||||
if (message.content !== oldContent) return message
|
||||
const { error: _error, ...rest } = message
|
||||
repairedCancellations++
|
||||
return { ...rest, status: 'cancelled' as const, parts,
|
||||
content: parts.filter(part => part.type === 'text').map(part => part.text).join('\n\n') }
|
||||
})
|
||||
return { messages: repairedCancellations ? repaired : messages, changed: repairedCancellations > 0, repairedCancellations }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface WorkspaceRecord {
|
||||
id: string
|
||||
name: string
|
||||
root: string
|
||||
createdAt: number
|
||||
}
|
||||
export interface WorkspaceRegistryView {
|
||||
workspaces: WorkspaceRecord[]
|
||||
selectedWorkspaceId: string | null
|
||||
}
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export type ThinkingLevel = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
|
||||
|
||||
export interface SessionContext {
|
||||
workspaceId?: string
|
||||
cwd?: string
|
||||
modelId?: string
|
||||
expertId?: string | null
|
||||
agentMode?: import('./conversation').AgentMode
|
||||
thinkingLevel?: ThinkingLevel
|
||||
}
|
||||
Reference in new issue
Block a user