123 lines
6.4 KiB
JavaScript
123 lines
6.4 KiB
JavaScript
import { createHash } from 'node:crypto'
|
||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||
import { join } from 'node:path'
|
||
import { ChromeGatewayClient } from './chrome-bridge.mjs'
|
||
|
||
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||
const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,160}$/.test(value)
|
||
|
||
// Reserved BEFORE the RPC: a crash/timeout is indeterminate, never safe to resend.
|
||
// Journal stores only hashes and delivery state, no prompt, token, or result.
|
||
export class DeliveryJournal {
|
||
constructor(directory) { this.directory = directory }
|
||
async reserve(deviceId, taskId, envelope) {
|
||
if (!identifier(deviceId) || !identifier(taskId)) throw new Error('Geçersiz görev kimliği.')
|
||
await mkdir(this.directory, { recursive: true })
|
||
const file = join(this.directory, `${digest([deviceId, taskId])}.json`)
|
||
const hash = digest(envelope)
|
||
try {
|
||
await writeFile(file, JSON.stringify({ hash, state: 'unknown' }), { flag: 'wx', mode: 0o600, flush: true })
|
||
return { file, hash, fresh: true }
|
||
} catch (error) {
|
||
if (error.code !== 'EEXIST') throw error
|
||
let previous
|
||
try { previous = JSON.parse(await readFile(file, 'utf8')) }
|
||
catch { return { file, hash, fresh: false, state: 'unknown' } }
|
||
if (previous.hash !== hash) throw new Error('Bu görev kimliği farklı bir içerikle kullanılmış.')
|
||
return { file, hash, fresh: false, state: previous.state }
|
||
}
|
||
}
|
||
async accepted(reservation) {
|
||
await writeFile(reservation.file, JSON.stringify({ hash: reservation.hash, state: 'accepted' }), { mode: 0o600 })
|
||
}
|
||
}
|
||
|
||
export class MobileGatewayClient extends ChromeGatewayClient {
|
||
constructor(resolveBackend, emit, { deviceId, journal, authorize, webSocket } = {}) {
|
||
super(resolveBackend, emit, webSocket)
|
||
this.deviceId = deviceId
|
||
this.journal = journal
|
||
this.authorize = authorize
|
||
this.approval = null
|
||
this.model = null
|
||
this.chain = Promise.resolve()
|
||
}
|
||
|
||
onEvent(event) {
|
||
if (!this.selected || event?.session_id !== this.selected.runtimeId) return
|
||
if (event.type === 'approval.request') {
|
||
this.approval = this.approvalView(event.payload)
|
||
this.emit({ event: 'approval.request', sessionId: this.selected.storedId, approval: this.approval })
|
||
return
|
||
}
|
||
if (event.type === 'message.complete') this.approval = null
|
||
super.onEvent(event)
|
||
}
|
||
|
||
approvalView(payload) {
|
||
if (!identifier(payload?.request_id) || typeof payload?.command !== 'string') return null
|
||
// Retain full backend-redacted operation; never approve a clipped preview.
|
||
if (payload.command.length > 48000) return null
|
||
return { requestId: payload.request_id, command: payload.command,
|
||
reason: typeof payload.reason === 'string' ? payload.reason : '',
|
||
choices: (payload.choices || []).filter(x => ['once', 'deny'].includes(x)) }
|
||
}
|
||
|
||
handle(message) {
|
||
const result = this.chain.then(() => this.perform(message))
|
||
this.chain = result.catch(() => {})
|
||
return result
|
||
}
|
||
|
||
async perform(message) {
|
||
if (!message || !identifier(message.id)) throw new Error('Geçersiz istek.')
|
||
await this.authorize()
|
||
const params = message.params || {}
|
||
if (message.action === 'session.open') {
|
||
const list = await super.handle({ id: message.id, action: 'sessions.list' })
|
||
if (!list.sessions.some(row => row.id === params.sessionId)) throw new Error('Bu IDE konuşması erişilebilir değil.')
|
||
const result = await super.handle(message)
|
||
this.model = result.model
|
||
const resumed = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
|
||
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
|
||
this.approval = this.approvalView(resumed.pending_approval)
|
||
return { ...result, approval: this.approval }
|
||
}
|
||
if (message.action === 'task.submit') {
|
||
if (this.selected?.storedId !== params.sessionId || !params.model || params.model !== this.model)
|
||
throw new Error('Konuşma veya iş modeli değişti. Hedefi tekrar seçin.')
|
||
if (typeof params.text !== 'string' || !params.text.trim() || params.text.length > 48000)
|
||
throw new Error('Görev boş olamaz ve 48.000 karakteri aşamaz.')
|
||
if (!['research', 'execute'].includes(params.intent)) throw new Error('Görev türü gerekli.')
|
||
// A model change by the desktop between selection and delivery fails closed.
|
||
const info = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
|
||
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
|
||
if (info.info?.model !== params.model || info.session_id !== this.selected.runtimeId)
|
||
throw new Error('IDE hedefi değişti. Konuşmayı yeniden seçin.')
|
||
const envelope = { sessionId: params.sessionId, profile: this.backend.profile || 'default',
|
||
model: params.model, text: params.text, intent: params.intent }
|
||
const reservation = await this.journal.reserve(this.deviceId, params.taskId, envelope)
|
||
if (!reservation.fresh) return { taskId: params.taskId, state: reservation.state, duplicate: true }
|
||
await this.authorize()
|
||
await this.rpc('prompt.submit', { session_id: this.selected.runtimeId, text: params.text, queued: true })
|
||
await this.journal.accepted(reservation)
|
||
return { taskId: params.taskId, state: 'accepted' }
|
||
}
|
||
if (message.action === 'approval.respond') {
|
||
if (this.selected?.storedId !== params.sessionId || !this.approval ||
|
||
params.requestId !== this.approval.requestId || !['once', 'deny'].includes(params.choice) ||
|
||
!this.approval.choices.includes(params.choice)) throw new Error('Onay artık geçerli değil. IDE durumunu yenileyin.')
|
||
const result = await this.rpc('approval.respond', { session_id: this.selected.runtimeId,
|
||
request_id: params.requestId, choice: params.choice, all: false })
|
||
if (!result.resolved) throw new Error('İşlem onayı daha önce yanıtlanmış veya sona ermiş.')
|
||
this.approval = null
|
||
return { resolved: true }
|
||
}
|
||
if (!['status', 'sessions.list', 'session.history', 'message.stop'].includes(message.action))
|
||
throw new Error('Bu mobil işlem desteklenmiyor.')
|
||
if (message.action === 'session.history' && this.selected?.storedId !== params.sessionId)
|
||
throw new Error('Önce konuşmayı seçin.')
|
||
return super.handle(message)
|
||
}
|
||
}
|