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 OSLIFE 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 CHATGPTPERSONAL 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 });
},
};