Files
aiturk-hermes-ide/apps/mobile/src/app.mjs
T

216 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AccountClient, openPhoneConnection } from './transport.mjs'
import { VoiceSession } from './voice.mjs'
import { DEFAULT_VOICE_LIMITS, voiceLimits, usageLabel } from './voice-usage.mjs'
import { taskState } from './behavior.mjs'
import { parseInvite } from './invite.mjs'
import { InviteScanner, drawQr } from './qr.mjs'
import { createDeviceIdentity } from './peer-identity.mjs'
const $ = id => document.getElementById(id)
if (globalThis.Capacitor?.isNativePlatform()) globalThis.Capacitor.registerPlugin('MobileSecrets')
let credentials = {}, account, connection, selected, approval, voice, pendingTask, voiceGeneration = 0
let connectedDesktopId = null
let connectionEpoch = 0, selectionEpoch = 0
const status = text => { $('status').textContent = text }
const native = () => window.Capacitor?.Plugins?.MobileSecrets
const scanner = new InviteScanner($('qr-video'), async raw => {
if (!account || credentials.deviceId) throw new Error('Önce giriş yapın; kayıtlı cihaz varsa önce mevcut eşleşmeyi kontrol edin.')
const targetAccount = account, epoch = connectionEpoch
const invite = parseInvite(raw)
const result = await account.call(`/v1/aiturk/mobile/invites/${invite.inviteId}/claim`, {
secret: invite.secret, display_name: $('device-name').value.trim() })
if (account !== targetAccount || epoch !== connectionEpoch) return
const identity = await createDeviceIdentity(result.device_id)
if (account !== targetAccount || epoch !== connectionEpoch) return
Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token,
pairingId: result.pairing_id, preferredDesktopId: invite.desktopId,
desktopFingerprints: { [invite.desktopId]: invite.desktopFingerprint }, localFingerprint: identity.fingerprint })
await saveCredentials()
showPhoneIdentity()
$('pair-code').textContent = `Bilgisayarda bekleyen telefonu getirip şu kodu onaylayın: ${result.user_code}`
status('QR okundu. Bilgisayardaki açık telefon onayı bekleniyor.')
})
scanner.onError = error => status(error.message)
action('scan-invite', async () => { if (!account || credentials.deviceId) throw new Error('Önce giriş yapın; bu telefon henüz eşleştirilmemiş olmalı.'); await scanner.start() })
action('stop-scan', async () => scanner.stop())
$('qr-file').onchange = async event => { try { await scanner.image(event.target.files[0]) } catch (error) { status(error.message) } finally { event.target.value = '' } }
async function saveCredentials() { if (native()) await native().save({ value: JSON.stringify(credentials) }) }
function showPhoneIdentity() {
$('phone-identity').value = credentials.localFingerprint ? JSON.stringify({ kind: 'aiturk-phone-identity', version: 1,
deviceId: credentials.deviceId, pairingId: credentials.pairingId, fingerprint: credentials.localFingerprint }) : ''
$('phone-identity-qr').hidden = !$('phone-identity').value
if ($('phone-identity').value) drawQr($('phone-identity-qr'), $('phone-identity').value)
}
function addText(container, role, text) { const p = document.createElement('p'); p.textContent = `${role}: ${text}`; $(container).append(p); while ($(container).children.length > 150) $(container).firstChild.remove() }
function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } }
function rpc(action, params) { if (!connection) throw new Error('Önce bilgisayara bağlanın.'); return connection.requests.call(action, params) }
function requireSession() { if (!selected) throw new Error('Önce IDE konuşmasını seçin.'); return selected }
function setApproval(value) { approval = value; $('approval').hidden = !value; $('approval-detail').textContent = value ? `${value.command}\n\n${value.reason}` : ''; $('allow').disabled = !value?.choices.includes('once'); $('deny').disabled = !value?.choices.includes('deny') }
function event(frame) {
if (frame.event === 'disconnected') { stopVoice(); status('IDE bağlantısı kesildi. Yeniden bağlanın.'); setApproval(null) }
const next = taskState(frame); if (next) $('work-status').textContent = next
if (frame.event === 'approval.request') setApproval(frame.approval)
if (frame.event === 'message.complete') { setApproval(null); addText('result', 'IDE konuşmasındaki yanıt', frame.text || 'Geçmişi yenileyin.') }
if (['task.start', 'task.complete'].includes(frame.event) && credentials.outbox?.taskId === frame.taskId &&
credentials.outbox.sessionId === frame.sessionId && credentials.outbox.desktopId === connectedDesktopId) {
$('work-status').textContent = frame.event === 'task.start' ? 'Gönderdiğiniz görev çalışıyor.' : `Göreviniz sona erdi: ${frame.status}.`
if (frame.event === 'task.complete') {
addText('result', `Görev ${frame.taskId}`, frame.text || frame.status)
voice?.result(frame)
delete credentials.outbox; pendingTask = null; $('retry').hidden = true
void saveCredentials().catch(() => status('Görev tamamlandı ancak yerel kayıt güncellenemedi.'))
}
}
}
action('login-start', async () => {
if (credentials.accountToken || connection) throw new Error('Kayıtlı hesabınız var; önce mevcut cihaz erişimini iptal edin.')
const result = await new AccountClient({}).call('/v1/aiturk/mobile-auth/start', {})
if (!/^https:\/\/turkservis\.online\/account\/aiturk\/mobile-login\/[0-9a-f-]{36}$/.test(result.verificationUri)) throw new Error('Geçersiz giriş sayfası.')
credentials.pendingLogin = result; await saveCredentials()
$('login-code').textContent = `Yalnız kendi hesabınızda bu kodu onaylayın: ${result.userCode}`
$('login-link').href = result.verificationUri; $('login-link').hidden = false
if (native()) await native().openLogin({ url: result.verificationUri })
status('Tarayıcıda giriş yaptıktan sonra buraya dönüp girişi kontrol edin.')
})
action('login-check', async () => {
if (credentials.accountToken) throw new Error('Hesaba zaten giriş yapılmış; mevcut hesap değiştirilmeyecek.')
if (!credentials.pendingLogin) throw new Error('Önce giriş başlatın.')
const result = await new AccountClient({ accountToken: credentials.pendingLogin.deviceCode }).call('/v1/aiturk/mobile-auth/poll', {})
if (result.state !== 'approved') { status('Tarayıcıdaki kod onayı bekleniyor.'); return }
credentials.accountToken = result.accountToken; delete credentials.pendingLogin; await saveCredentials()
account = new AccountClient(credentials); $('login-code').textContent = 'Hesaba giriş yapıldı.'; $('login-link').hidden = true
status('Hesap bağlı. Şimdi telefonun eşleştirme kodunu oluşturun.')
})
action('logout', async () => {
scanner.stop()
const token = credentials.accountToken || credentials.pendingLogin?.deviceCode
// Revoke the app-specific credential before dropping the local copy. A
// network failure leaves it available for an explicit retry.
if (token?.startsWith('tsml_')) await new AccountClient({ accountToken: token }).call('/v1/aiturk/mobile-auth/logout', {})
connectionEpoch++; stopVoice(); connection?.close(); connection = null; selected = null; account = null
credentials = {}; pendingTask = null; await saveCredentials()
showPhoneIdentity()
$('work').hidden = true; $('login-link').hidden = true; $('login-code').textContent = ''; $('pair-code').textContent = ''
status('Bu uygulamanın oturumu kapatıldı. IDE’de başlamış iş kendi çalışma kurallarıyla devam edebilir.')
})
action('pair', async () => {
if (credentials.deviceId) throw new Error('Bu telefon zaten eşleşme başlatmış. Bilgisayarları bulun veya mevcut erişimi iptal edin.')
const manual = $('account-token').value.trim(); $('account-token').value = ''
if (manual && credentials.accountToken && manual !== credentials.accountToken) throw new Error('Kayıtlı hesap sessizce değiştirilemez.')
if (manual) credentials.accountToken = manual
if (!credentials.accountToken) throw new Error('Önce AITURK hesabınıza giriş yapın.')
account = new AccountClient(credentials)
const result = await account.call('/v1/aiturk/pairings', { display_name: $('device-name').value.trim(), platform: 'android' })
const identity = await createDeviceIdentity(result.device_id)
Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token, pairingId: result.pairing_id,
localFingerprint: identity.fingerprint })
await saveCredentials()
showPhoneIdentity()
$('pair-code').textContent = `Eşleştirme kimliği:\n${result.pairing_id}\n\nKod: ${result.user_code}\nSon geçerlilik: ${result.expires_at}`
status('Bilgisayardaki açık onay bekleniyor.')
})
action('devices', async () => {
if (!account) throw new Error('Önce hesabınızı eşleştirin.')
const data = await account.call('/v1/aiturk/mobile/devices'); $('desktop').replaceChildren()
for (const device of data.devices.filter(d => d.platform.startsWith('windows'))) { const option = document.createElement('option'); option.value = device.id; option.textContent = device.name; $('desktop').append(option) }
status('Hesabınızdaki onaylı bilgisayarlar bulundu.')
})
action('connect', async () => {
if (!account || !$('desktop').value) throw new Error('Önce bir bilgisayar seçin.')
const epoch = ++connectionEpoch
stopVoice(); connection?.close(); connection = null; selected = null; setApproval(null)
status('Bilgisayara bağlanılıyor…')
connectedDesktopId = $('desktop').value
const opened = await openPhoneConnection(account, connectedDesktopId, frame => { if (epoch === connectionEpoch) event(frame) })
if (epoch !== connectionEpoch) { opened.close(); return }
connection = opened
$('work').hidden = false; status('Bilgisayar bağlı. IDE konuşmasını seçin.')
})
action('sessions', async () => {
const data = await rpc('sessions.list'); $('session').replaceChildren()
for (const item of data.sessions) { const option = document.createElement('option'); option.value = item.id; option.textContent = `${item.title} · ${item.model || 'Model bilinmiyor'}`; $('session').append(option) }
})
action('select-session', async () => {
const id = $('session').value; if (!id) throw new Error('Bir konuşma seçin.')
const epoch = ++selectionEpoch, targetConnection = connection
stopVoice(); selected = null; setApproval(null)
const result = await rpc('session.open', { sessionId: id })
if (epoch !== selectionEpoch || connection !== targetConnection) return
selected = { id, model: result.model }
pendingTask = credentials.outbox?.sessionId === id && credentials.outbox?.desktopId === connectedDesktopId ? credentials.outbox : null
$('retry').hidden = !pendingTask
if (pendingTask) { $('task').value = pendingTask.text; $('intent').value = pendingTask.intent; $('preview').textContent = pendingTask.text }
$('target').textContent = `Bilgisayar: ${$('desktop').selectedOptions[0]?.textContent} · Konuşma: ${$('session').selectedOptions[0]?.textContent} · İş modeli: ${result.model || 'Bilinmiyor'}`
$('result').replaceChildren(); for (const message of result.messages) addText('result', message.role, message.text)
setApproval(result.approval); $('work-status').textContent = result.running ? 'IDE çalışıyor.' : 'IDE konuşması açıldı.'
})
for (const [id, choice] of [['allow', 'once'], ['deny', 'deny']]) action(id, async () => { const target = requireSession(); await rpc('approval.respond', { sessionId: target.id, requestId: approval?.requestId, choice }); setApproval(null); status('Yanıt gerçek IDE onayına iletildi.') })
action('save-google', async () => { const key = $('google-key').value.trim(); $('google-key').value = ''; await rpc('google.key.save', { key }); status('Google anahtarı bilgisayarın güvenli kasasına kaydedildi.') })
action('delete-google', async () => { stopVoice(); await rpc('google.key.delete'); status('Google anahtarı bilgisayar kasasından silindi.') })
function stopVoice() { voiceGeneration++; voice?.stop(); voice = null; $('voice-status').textContent = 'Mikrofon kapalı. IDE işi devam edebilir.' }
async function startVoice(microphone) {
if (!$('consent').checked) throw new Error('Önce Google veri ve ücret bilgisini okuyup kabul edin.')
const limits = voiceLimits({ minutes: $('voice-minutes').value, usd: $('voice-budget').value })
// Only non-secret preferences; account and API credentials remain in the
// native vault. No usage history or transcripts are stored here.
try { localStorage.setItem('aiturk.voice-limits', JSON.stringify(limits)) } catch {}
const target = { ...requireSession() }, targetConnection = connection, desktopId = connectedDesktopId
stopVoice(); const generation = voiceGeneration; const token = await rpc('voice.token')
if (generation !== voiceGeneration || document.hidden) return
voice = new VoiceSession({ ...token, limits,
onUsage: usage => { if (generation === voiceGeneration) $('usage').textContent = usageLabel(usage, usage.seconds) },
onText: (role, text) => addText('discussion', role, text),
onDraft: (text, intent) => { $('task').value = text; $('intent').value = intent; updatePreview(); status('Asistan görev taslağını hazırladı. Henüz IDE’ye gönderilmedi.') },
onSubmit: async (text, intent) => {
if (generation !== voiceGeneration || connection !== targetConnection || selected?.id !== target.id || selected?.model !== target.model)
throw new Error('IDE hedefi veya ses görüşmesi değişti; gönderim yapılmadı.')
if (credentials.outbox) throw new Error('Önceki gönderimi kontrol edin; ikinci bir iş otomatik başlatılmadı.')
$('task').value = text; $('intent').value = intent; $('preview').textContent = text
pendingTask = { taskId: crypto.randomUUID(), sessionId: target.id, model: target.model, desktopId, intent, text }
return deliver()
},
onError: status, onState: text => { $('voice-status').textContent = text } })
await voice.start(microphone)
}
action('voice-start', () => startVoice(true)); action('voice-text', () => startVoice(false))
action('voice-stop', async () => stopVoice()); action('silence', async () => voice?.interruptPlayback())
action('discuss', async () => { if (!voice?.ready) throw new Error('Önce asistanla yazış veya sesle konuş bağlantısını açın.'); const text = $('idea').value.trim(); if (!text) return; voice.text(text); addText('discussion', 'Siz', text); $('idea').value = '' })
function visibleTask() { const text = $('task').value.trim(); return text ? ($('intent').value === 'research' ? 'Araştırma / inceleme isteği. Değişiklik yapma.\n\n' : '') + text : '' }
function updatePreview() { $('preview').textContent = visibleTask(); pendingTask = null; $('retry').hidden = !credentials.outbox }
$('task').oninput = updatePreview; $('intent').onchange = updatePreview
async function deliver() {
if (!pendingTask) throw new Error('Kontrol edilecek gönderim yok.')
if (pendingTask.desktopId !== connectedDesktopId || pendingTask.sessionId !== selected?.id || pendingTask.model !== selected?.model)
throw new Error('Bekleyen görevin bilgisayarı veya konuşması farklı. Önce doğru hedefi seçin.')
credentials.outbox = pendingTask; await saveCredentials()
const result = await rpc('task.submit', pendingTask)
if (['complete', 'error', 'interrupted'].includes(result.state)) {
$('work-status').textContent = `Görev daha önce sona ermiş: ${result.state}. Sonuç için IDE geçmişini açın.`
delete credentials.outbox; pendingTask = null; $('retry').hidden = true; await saveCredentials()
return { taskId: result.taskId, completed: result.state === 'complete', state: result.state }
}
if (!credentials.outbox) return { taskId: result.taskId, accepted: true, completed: false, state: 'terminal_event_received' }
$('work-status').textContent = result.state === 'accepted' ? 'IDE görevi kabul etti. Tamamlanma sonucu bekleniyor.' : 'Gönderimin sonucu belirsiz. IDE geçmişini kontrol edin; yeniden çalıştırma yapılmadı.'
$('retry').hidden = false
return { taskId: result.taskId, accepted: result.state === 'accepted', completed: false, state: result.state }
}
action('submit', async () => {
const target = requireSession(), text = visibleTask(); if (!text) throw new Error('Görev metni boş olamaz.')
if (credentials.outbox && credentials.outbox.taskId !== pendingTask?.taskId) throw new Error('Önce önceki gönderimi kontrol edin; bekleyen görev kaydı silinmedi.')
if (!pendingTask) pendingTask = { taskId: crypto.randomUUID(), sessionId: target.id, model: target.model, desktopId: connectedDesktopId, intent: $('intent').value, text }
$('retry').hidden = false; await deliver()
})
action('retry', async () => { pendingTask = credentials.outbox || pendingTask; return deliver() })
action('cancel-draft', async () => { $('task').value = ''; updatePreview(); status('Gönderilmemiş görev taslağı iptal edildi. Başlamış IDE işi devam eder.') })
action('interrupt', async () => { const target = requireSession(); await rpc('message.stop', { sessionId: target.id }); $('work-status').textContent = 'IDE’ye durdurma isteği iletildi; gerçek sonucu bekleyin.' })
action('history', async () => { const result = await rpc('session.history', { sessionId: requireSession().id }); $('result').replaceChildren(); for (const message of result.messages) addText('result', message.role, message.text) })
action('clear-history', async () => { $('discussion').replaceChildren(); $('result').replaceChildren(); status('Telefondaki görünür metin temizlendi. Google bağlantı bağlamını da temizlemek için görüşmeyi kapatın.') })
action('disconnect', async () => { connectionEpoch++; stopVoice(); connection?.close(); connection = null; selected = null; $('work').hidden = true; status('Telefon bağlantısı kapalı. IDE işi devam edebilir.') })
action('revoke', async () => { const data = await account.call('/v1/aiturk/mobile/devices'); const self = data.devices.find(d => d.self); await account.call(`/v1/aiturk/devices/${self.id}/revoke`, { expected_version: self.version }); stopVoice(); connection?.close(); connection = null; credentials = {}; await saveCredentials(); account = null; $('work').hidden = true; status('Bu telefonun erişimi iptal edildi.') })
document.addEventListener('visibilitychange', () => { if (document.hidden) { stopVoice(); scanner.stop() } })
window.addEventListener('pagehide', () => { stopVoice(); scanner.stop(); connection?.close() })
let savedLimits = DEFAULT_VOICE_LIMITS
try { const raw = localStorage.getItem('aiturk.voice-limits'); if (raw) savedLimits = voiceLimits(JSON.parse(raw)) } catch {}
$('voice-minutes').value = savedLimits.minutes; $('voice-budget').value = savedLimits.usd
try { const saved = await native()?.load(); if (saved?.value) { credentials = JSON.parse(saved.value); if (credentials.accountToken) { account = new AccountClient(credentials); status('Kayıtlı cihaz bulundu. Bilgisayarları yenileyerek bağlanın.') } } } catch { status('Android güvenli kasası açılamadı. Hesabınızı yeniden eşleştirin.') }
showPhoneIdentity()