diff --git a/.openai/hosting.json b/.openai/hosting.json index 8d1ff98..3dd0ec5 100644 --- a/.openai/hosting.json +++ b/.openai/hosting.json @@ -1,3 +1,4 @@ { - "project_id": "appgprj_6ab265f0f7dc81919b4e281000e6f7a9" + "project_id": "appgprj_6ab265f0f7dc81919b4e281000e6f7a9", + "d1": "DB" } diff --git a/README.md b/README.md index 5b607f4..2009a40 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Your life, running as an operating system. -一个本地优先、以桌面操作系统为交互隐喻的个人记录网站。它不是传统日记本:今天被归档、记忆沿时间线回看、重要片段进入人生博物馆。 +一个以桌面操作系统为交互隐喻、支持 ChatGPT 登录和云端同步的个人记录网站。它不是传统日记本:今天被归档、记忆沿时间线回看、重要片段进入人生博物馆。 ## 功能 @@ -16,10 +16,13 @@ - **Windows 式操作**:桌面端单击选择、双击打开;右键桌面或 App 图标可使用快捷菜单。 - **快捷键与草稿恢复**:`Ctrl / ⌘ + K` 打开启动器,`Ctrl / ⌘ + F` 搜索记忆,`Ctrl / ⌘ + S` 归档 Today;当天未归档文字会自动恢复。 - **移动端**:App 以全屏方式打开,保留任务栏恢复入口。 +- **个人云端**:使用 ChatGPT 登录后,数据按账号隔离并同步到 SQLite 兼容的 D1 数据库。 ## 数据与备份 -所有内容默认保存在浏览器 `localStorage`,不会上传到服务器。请在 **System → Export Data** 定期导出 JSON 备份;导入时支持合并现有数据或完整覆盖恢复。 +登录后,Life OS 会将资料同步到云端数据库;浏览器 `localStorage` 保留为本地缓存、临时草稿与迁移来源。首次登录时,现有本地记录可以迁移到云端;云端已有记录时,会优先恢复云端资料,并在本机保留迁移前副本。 + +请在 **System → Export Data** 定期导出 JSON 备份;导入时支持合并现有数据或完整覆盖恢复,导入后的资料也会同步。 主要存储键: @@ -49,14 +52,15 @@ npm run validate ```text public/index.html # 界面、样式与本地数据逻辑 public/addons.js # 窗口与辅助交互 -worker/index.js # 同源 Bing 每日壁纸代理 +worker/index.js # ChatGPT 登录门槛、云端数据 API 与 Bing 壁纸代理 +drizzle/ # SQLite / D1 数据库迁移 scripts/build.mjs # 生成 Worker 部署产物 .openai/hosting.json # Sites 托管配置 ``` ## 隐私说明 -Life OS 的记录只存在于当前浏览器的本地存储中。清除浏览器数据、切换浏览器或设备前,请先导出备份。 +Life OS 使用 ChatGPT 的稳定用户标识隔离云端数据,不保存 ChatGPT 密码。浏览器本地缓存不作为唯一副本;仍建议定期导出 JSON 备份。 ## License diff --git a/drizzle/0000_lifeos_cloud_state.sql b/drizzle/0000_lifeos_cloud_state.sql new file mode 100644 index 0000000..ee4bcb2 --- /dev/null +++ b/drizzle/0000_lifeos_cloud_state.sql @@ -0,0 +1,12 @@ +CREATE TABLE lifeos_states ( + user_id TEXT PRIMARY KEY, + profile_json TEXT NOT NULL DEFAULT '{}', + memories_json TEXT NOT NULL DEFAULT '[]', + museum_json TEXT NOT NULL DEFAULT '[]', + settings_json TEXT NOT NULL DEFAULT '{}', + system_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_lifeos_states_updated_at +ON lifeos_states(updated_at); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..2dca654 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1790389200000, + "tag": "0000_lifeos_cloud_state", + "breakpoints": true + } + ] +} diff --git a/package.json b/package.json index 3b902bd..1d2eea5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "life-os", - "version": "1.0.0", + "version": "1.1.0", "private": true, "type": "module", "scripts": { diff --git a/public/addons.js b/public/addons.js index 5afc460..6fdbb05 100644 --- a/public/addons.js +++ b/public/addons.js @@ -1,7 +1,7 @@ /* Life OS reliability and productivity add-ons. */ (() => { const DRAFT_KEY = 'lifeos_today_draft_v1'; - const APP_VERSION = '1.0.0'; + const APP_VERSION = '1.1.0'; const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'system']; const zh = () => (typeof lang === 'function' ? lang() : 'en') === 'zh'; const copy = (en, cn) => (zh() ? cn : en); @@ -96,6 +96,68 @@ link.download = `life-os-backup-${day()}.json`; link.click(); const state = settings(); state.lastBackup = new Date().toISOString(); save('lifeos_settings', state); renderAll(); toast(copy('BACKUP CREATED', '备份已创建')); } + const CLOUD_KEYS = { profile: 'lifeos_profile', memories: 'lifeos_memories', museum: 'lifeos_museum', settings: 'lifeos_settings', system: 'lifeos_system' }; + let cloudReady = false, applyingCloud = false, syncTimer = null, syncErrorShown = false; + const cloudFallback = { profile: {}, memories: [], museum: [], settings: {}, system: {} }; + function cloudSnapshot() { + return Object.fromEntries(Object.entries(CLOUD_KEYS).map(([field, key]) => { + try { return [field, JSON.parse(localStorage.getItem(key) || JSON.stringify(cloudFallback[field]))]; } catch { return [field, cloudFallback[field]]; } + })); + } + function hasLocalLife(state) { return state.memories.length || state.museum.length || Object.keys(state.profile).length || Object.keys(state.settings).length > 2; } + function applyCloudState(state) { + applyingCloud = true; + Object.entries(CLOUD_KEYS).forEach(([field, key]) => localStorage.setItem(key, JSON.stringify(state[field] ?? cloudFallback[field]))); + applyingCloud = false; + todayDraft = null; renderIcons(); renderAll(); applyLanguage(); + } + async function uploadCloudState() { + if (!cloudReady) return; + const response = await fetch('/api/lifeos-state', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: cloudSnapshot() }) }); + if (!response.ok) throw new Error('sync failed'); + } + function scheduleCloudSync() { + if (!cloudReady || applyingCloud) return; + clearTimeout(syncTimer); + syncTimer = setTimeout(() => uploadCloudState().catch(() => { + if (!syncErrorShown) { syncErrorShown = true; toast(copy('CLOUD SYNC PAUSED — CHANGES STAY ON THIS DEVICE', '云端同步暂停——更改仍保存在此设备')); } + }), 450); + } + function migrationPrompt(state) { + const dialog = document.createElement('div'); + dialog.id = 'lifeos-cloud-migration'; + dialog.innerHTML = `
${copy('CLOUD SYNC READY', '云端同步已就绪')}

${copy('Move this browser’s Life OS to the cloud?', '将此浏览器的 Life OS 迁移到云端?')}

${copy('Your existing local records will become the first synced copy for this ChatGPT account.', '现有本地记录将成为这个 ChatGPT 账号的第一份云端同步数据。')}

`; + document.body.append(dialog); + dialog.querySelector('[data-cloud="move"]').addEventListener('click', async () => { dialog.remove(); cloudReady = true; await uploadCloudState(); toast(copy('LOCAL LIFE OS MIGRATED', '本地 Life OS 已迁移')); }); + dialog.querySelector('[data-cloud="empty"]').addEventListener('click', async () => { dialog.remove(); applyCloudState(cloudFallback); cloudReady = true; await uploadCloudState(); }); + } + async function startCloudSync() { + try { + const response = await fetch('/api/lifeos-state', { cache: 'no-store' }); + if (!response.ok) throw new Error('cloud unavailable'); + const { state } = await response.json(); + const local = cloudSnapshot(); + if (state) { + if (hasLocalLife(local)) localStorage.setItem('lifeos_precloud_backup_v1', JSON.stringify({ exportedAt: new Date().toISOString(), state: local })); + applyCloudState(state); cloudReady = true; toast(copy('CLOUD LIFE OS RESTORED', '云端 Life OS 已恢复')); + } else if (hasLocalLife(local)) { + migrationPrompt(local); + } else { + cloudReady = true; await uploadCloudState(); + } + } catch { + if (!syncErrorShown) { syncErrorShown = true; toast(copy('CLOUD STORAGE UNAVAILABLE — LOCAL MODE ACTIVE', '云端存储暂不可用——已使用本地模式')); } + } + } + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = function(key, value) { + originalSetItem.call(this, key, value); + if (this === localStorage && Object.values(CLOUD_KEYS).includes(key)) scheduleCloudSync(); + }; + const cloudStyle = document.createElement('style'); + cloudStyle.textContent = `#lifeos-cloud-migration{position:fixed;inset:0;z-index:9000;background:#071008b8;display:grid;place-items:center;padding:20px}.lifeos-cloud-card{width:min(470px,100%);background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:25px;box-shadow:var(--shadow)}.lifeos-cloud-card h2{margin:10px 0;font-size:25px;letter-spacing:-.04em}.lifeos-cloud-card p{color:var(--muted);line-height:1.65;margin:0 0 20px}`; + document.head.append(cloudStyle); + startCloudSync(); new MutationObserver(() => { wireToday(); wireLanguageControl(); polishSystemChrome(); }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['title'] }); wireToday(); wireLanguageControl(); diff --git a/scripts/build.mjs b/scripts/build.mjs index e77bfaa..15bc99f 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -8,7 +8,7 @@ const meta = new URL("../dist/.openai/", import.meta.url); await rm(dist, { recursive: true, force: true }); await Promise.all([mkdir(server, { recursive: true }), mkdir(meta, { recursive: true })]); const [rawIndexHtml, addonsJs] = await Promise.all([text("index.html"), text("addons.js")]); -const release = "1.0.0"; +const release = "1.1.0"; const indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${release}"`); await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\n`); await copyFile(new URL("../worker/index.js", import.meta.url), new URL("index.js", server)); diff --git a/worker/index.js b/worker/index.js index cade2ba..667d24c 100644 --- a/worker/index.js +++ b/worker/index.js @@ -1,29 +1,57 @@ import { addonsJs, indexHtml } from "./assets.js"; const BING_ARCHIVE = "https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US"; +const STATE_FIELDS = ["profile", "memories", "museum", "settings", "system"]; async function bingMetadata() { const response = await fetch(BING_ARCHIVE, { headers: { accept: "application/json" } }); if (!response.ok) throw new Error(`Bing metadata request failed: ${response.status}`); const image = (await response.json()).images?.[0]; if (!image?.url) throw new Error("Bing did not return today's image"); - return { - title: image.copyright || "Bing Daily Image", - url: new URL(image.url, "https://www.bing.com").toString(), - }; + return { title: image.copyright || "Bing Daily Image", url: new URL(image.url, "https://www.bing.com").toString() }; } -function headers(type, cache = "public, max-age=3600") { - return { "content-type": type, "cache-control": cache }; +function headers(type, cache = "public, max-age=3600") { return { "content-type": type, "cache-control": cache }; } +function userId(request) { return request.headers.get("oai-authenticated-user-id"); } + +function loginPage() { + return `Life OS
LIFE OS

Your life,
in sync.

Sign in with ChatGPT to open your personal Life OS and keep your memories safely synced across devices.

CONTINUE WITH CHATGPT

PERSONAL SYSTEM · CLOUD SYNC

`; +} + +function validState(value) { return value && typeof value === "object" && !Array.isArray(value) && STATE_FIELDS.every((field) => Object.hasOwn(value, field)); } +function stateFromRow(row) { return row ? Object.fromEntries(STATE_FIELDS.map((field) => [field, JSON.parse(row[`${field}_json`])])) : null; } + +async function stateApi(request, env, id) { + if (!id) return Response.json({ error: "Authentication required" }, { status: 401 }); + if (!env.DB) return Response.json({ error: "Cloud storage is unavailable" }, { status: 503 }); + try { + if (request.method === "GET") { + const row = await env.DB.prepare("SELECT profile_json, memories_json, museum_json, settings_json, system_json, updated_at FROM lifeos_states WHERE user_id = ?").bind(id).first(); + return Response.json({ state: stateFromRow(row), updatedAt: row?.updated_at ?? null }, { headers: { "cache-control": "no-store" } }); + } + if (request.method === "PUT") { + const payload = await request.json(); + if (!validState(payload?.state)) return Response.json({ error: "Invalid Life OS state" }, { status: 400 }); + const state = payload.state; + const serialized = STATE_FIELDS.map((field) => JSON.stringify(state[field])); + if (serialized.some((value) => value.length > 1_000_000)) return Response.json({ error: "Life OS state is too large" }, { status: 413 }); + const updatedAt = new Date().toISOString(); + await env.DB.prepare("INSERT INTO lifeos_states (user_id, profile_json, memories_json, museum_json, settings_json, system_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET profile_json = excluded.profile_json, memories_json = excluded.memories_json, museum_json = excluded.museum_json, settings_json = excluded.settings_json, system_json = excluded.system_json, updated_at = excluded.updated_at").bind(id, ...serialized, updatedAt).run(); + return Response.json({ ok: true, updatedAt }, { headers: { "cache-control": "no-store" } }); + } + return Response.json({ error: "Method not allowed" }, { status: 405, headers: { allow: "GET, PUT" } }); + } catch (error) { + return Response.json({ error: "Cloud storage request failed", detail: String(error) }, { status: 500 }); + } } export default { - async fetch(request) { + async fetch(request, env) { const url = new URL(request.url); + const id = userId(request); + if (url.pathname === "/api/lifeos-state") return stateApi(request, env, id); try { - if (url.pathname === "/api/bing-wallpaper") { - return Response.json(await bingMetadata(), { headers: { "cache-control": "public, max-age=3600" } }); - } + if (url.pathname === "/api/bing-wallpaper") return Response.json(await bingMetadata(), { headers: { "cache-control": "public, max-age=3600" } }); if (url.pathname === "/api/bing-wallpaper-image") { const image = await bingMetadata(); const response = await fetch(image.url); @@ -34,7 +62,10 @@ export default { return Response.json({ error: "Bing wallpaper is temporarily unavailable", detail: String(error) }, { status: 502 }); } if (url.pathname === "/addons.js") return new Response(addonsJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); - if (url.pathname === "/" || url.pathname === "/index.html") return new Response(indexHtml, { headers: headers("text/html; charset=utf-8", "no-cache") }); + if (url.pathname === "/" || url.pathname === "/index.html") { + if (!id) return new Response(loginPage(), { headers: headers("text/html; charset=utf-8", "no-cache") }); + return new Response(indexHtml, { headers: headers("text/html; charset=utf-8", "no-cache") }); + } return new Response("Not found", { status: 404 }); }, };