Spaces:
Running
Running
| (function (global) { | |
| 'use strict'; | |
| const STATE_KEY = 'convoreader_chat_git_v1'; | |
| const TOKEN_KEY = 'convoreader_github_token'; | |
| let syncQueue = Promise.resolve(); | |
| let commitQueue = Promise.resolve(); | |
| function initialState() { | |
| return { | |
| schemaVersion: 1, | |
| activeBranch: 'main', | |
| branches: { main: null }, | |
| commits: [], | |
| config: { owner: '', repo: '', githubBranch: 'main', autoSync: false }, | |
| sync: { status: 'disconnected', lastCommit: null, error: null } | |
| }; | |
| } | |
| function read() { | |
| try { | |
| const state = { ...initialState(), ...JSON.parse(localStorage.getItem(STATE_KEY) || '{}') }; | |
| state.branches = state.branches || { main: null }; | |
| state.commits = Array.isArray(state.commits) ? state.commits : []; | |
| state.config = { ...initialState().config, ...(state.config || {}) }; | |
| return state; | |
| } catch { return initialState(); } | |
| } | |
| function write(state) { | |
| localStorage.setItem(STATE_KEY, JSON.stringify(state)); | |
| global.dispatchEvent(new CustomEvent('chatgit:change', { detail: state })); | |
| return state; | |
| } | |
| async function hash(value) { | |
| const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(value))); | |
| return [...new Uint8Array(bytes)].map(v => v.toString(16).padStart(2, '0')).join(''); | |
| } | |
| function token() { return sessionStorage.getItem(TOKEN_KEY) || ''; } | |
| function safePart(value) { return String(value || 'stream').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 100); } | |
| function utf8Base64(value) { | |
| const bytes = new TextEncoder().encode(value); | |
| let binary = ''; | |
| for (const byte of bytes) binary += String.fromCharCode(byte); | |
| return btoa(binary); | |
| } | |
| async function github(path, options = {}) { | |
| const state = read(); | |
| if (!token()) throw new Error('GitHub is not connected for this browser tab'); | |
| if (!state.config.owner || !state.config.repo) throw new Error('GitHub owner and repository are required'); | |
| const response = await fetch(`https://api.github.com/repos/${encodeURIComponent(state.config.owner)}/${encodeURIComponent(state.config.repo)}${path}`, { | |
| ...options, | |
| headers: { | |
| Accept: 'application/vnd.github+json', | |
| Authorization: `Bearer ${token()}`, | |
| 'X-GitHub-Api-Version': '2022-11-28', | |
| ...(options.headers || {}) | |
| } | |
| }); | |
| const data = response.status === 204 ? {} : await response.json().catch(() => ({})); | |
| if (!response.ok) throw new Error(data.message || `GitHub HTTP ${response.status}`); | |
| return data; | |
| } | |
| async function connect({ owner, repo, githubBranch = 'main', accessToken, autoSync = true }) { | |
| if (!/^[A-Za-z0-9_.-]+$/.test(owner || '') || !/^[A-Za-z0-9_.-]+$/.test(repo || '')) throw new Error('Invalid repository identifier'); | |
| if (!accessToken) throw new Error('A fine-grained GitHub token is required'); | |
| sessionStorage.setItem(TOKEN_KEY, accessToken); | |
| const state = read(); | |
| state.config = { owner, repo, githubBranch, autoSync }; | |
| state.sync = { status: 'connecting', lastCommit: state.sync?.lastCommit || null, error: null }; | |
| write(state); | |
| try { | |
| const repository = await github(''); | |
| const next = read(); | |
| next.sync.status = 'connected'; | |
| next.sync.error = null; | |
| write(next); | |
| return { fullName: repository.full_name, defaultBranch: repository.default_branch, private: repository.private }; | |
| } catch (error) { | |
| sessionStorage.removeItem(TOKEN_KEY); | |
| const next = read(); | |
| next.sync = { status: 'error', lastCommit: next.sync?.lastCommit || null, error: error.message }; | |
| write(next); | |
| throw error; | |
| } | |
| } | |
| function disconnect() { | |
| sessionStorage.removeItem(TOKEN_KEY); | |
| const state = read(); | |
| state.sync.status = 'disconnected'; | |
| state.sync.error = null; | |
| return write(state); | |
| } | |
| async function commitNow({ streamId = 'workspace', type = 'chat', message, snapshot, codemap = null, parents = null }) { | |
| const state = read(); | |
| const branch = state.activeBranch; | |
| const body = { | |
| schemaVersion: 1, | |
| streamId, | |
| branch, | |
| type, | |
| message: String(message || type).slice(0, 240), | |
| parents: parents || (state.branches[branch] ? [state.branches[branch]] : []), | |
| snapshot, | |
| codemap, | |
| createdAt: new Date().toISOString() | |
| }; | |
| body.id = await hash(body); | |
| state.commits.push(body); | |
| if (state.commits.length > 2000) state.commits = state.commits.slice(-2000); | |
| state.branches[branch] = body.id; | |
| write(state); | |
| if (state.config.autoSync && token()) queueSync(streamId); | |
| return body; | |
| } | |
| function commit(input) { | |
| commitQueue = commitQueue.then(() => commitNow(input)); | |
| return commitQueue; | |
| } | |
| function createBranch(name, from = null) { | |
| const clean = safePart(name); | |
| const state = read(); | |
| if (state.branches[clean] !== undefined) throw new Error('Branch already exists'); | |
| state.branches[clean] = from || state.branches[state.activeBranch] || null; | |
| state.activeBranch = clean; | |
| return write(state); | |
| } | |
| function switchBranch(name) { | |
| const state = read(); | |
| if (state.branches[name] === undefined) throw new Error('Unknown branch'); | |
| state.activeBranch = name; | |
| return write(state); | |
| } | |
| async function merge(source, target = null, snapshot = null) { | |
| const state = read(); | |
| const destination = target || state.activeBranch; | |
| if (state.branches[source] === undefined || state.branches[destination] === undefined) throw new Error('Unknown branch'); | |
| const prior = state.activeBranch; | |
| state.activeBranch = destination; | |
| write(state); | |
| const result = await commit({ | |
| streamId: 'branch-merge', type: 'merge', message: `Merge ${source} into ${destination}`, | |
| snapshot: snapshot || { source, destination }, | |
| parents: [state.branches[destination], state.branches[source]].filter(Boolean) | |
| }); | |
| if (prior !== destination) switchBranch(prior); | |
| return result; | |
| } | |
| async function syncStream(streamId) { | |
| const state = read(); | |
| const streamCommits = state.commits.filter(c => c.streamId === streamId || c.type === 'merge'); | |
| const payload = { | |
| schemaVersion: 1, | |
| generatedAt: new Date().toISOString(), | |
| activeBranch: state.activeBranch, | |
| branches: state.branches, | |
| streamId, | |
| commits: streamCommits | |
| }; | |
| const filePath = `.convoreader/streams/${safePart(streamId)}.json`; | |
| const branch = state.config.githubBranch || 'main'; | |
| let existing = null; | |
| try { existing = await github(`/contents/${filePath}?ref=${encodeURIComponent(branch)}`); } | |
| catch (error) { if (!/not found/i.test(error.message)) throw error; } | |
| const latest = streamCommits.at(-1); | |
| const body = { | |
| message: `chat(${safePart(streamId)}): ${latest?.message || 'sync version graph'}`, | |
| content: utf8Base64(JSON.stringify(payload, null, 2) + '\n'), | |
| branch, | |
| ...(existing?.sha ? { sha: existing.sha } : {}) | |
| }; | |
| const result = await github(`/contents/${filePath}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); | |
| const next = read(); | |
| next.sync = { status: 'connected', lastCommit: result.commit?.sha || null, error: null }; | |
| write(next); | |
| return result; | |
| } | |
| function queueSync(streamId) { | |
| syncQueue = syncQueue.then(() => syncStream(streamId)).catch(error => { | |
| const state = read(); | |
| state.sync = { status: 'error', lastCommit: state.sync?.lastCommit || null, error: error.message }; | |
| write(state); | |
| }); | |
| return syncQueue; | |
| } | |
| async function searchRepositories(query) { | |
| const response = await fetch(`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=5`, { | |
| headers: { | |
| Accept: 'application/vnd.github+json', | |
| ...(token() ? { Authorization: `Bearer ${token()}` } : {}), | |
| 'X-GitHub-Api-Version': '2022-11-28' | |
| } | |
| }); | |
| const data = await response.json().catch(() => ({})); | |
| if (!response.ok) throw new Error(data.message || `GitHub search HTTP ${response.status}`); | |
| return { total: data.total_count || 0, items: (data.items || []).map(item => ({ name: item.full_name, url: item.html_url, stars: item.stargazers_count, description: item.description })) }; | |
| } | |
| global.ChatGit = { read, commit, connect, disconnect, createBranch, switchBranch, merge, syncStream, queueSync, searchRepositories, hasSessionToken: () => Boolean(token()) }; | |
| })(window); | |