Make Thinkloom native-only
This commit is contained in:
+6
-46
@@ -1,48 +1,8 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.vinext/
|
||||
/out/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
next-env.d.ts
|
||||
/dist/
|
||||
/.wrangler/
|
||||
/outputs/
|
||||
/work/
|
||||
|
||||
# Thinkloom generated artifacts
|
||||
/desktop-dist/
|
||||
/node_modules/
|
||||
/dist/
|
||||
/src-tauri/target/
|
||||
*.tsbuildinfo
|
||||
|
||||
.env*
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
/.wrangler/
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"project_id": "appgprj_6a59ce11a2688191a982245d4f9a82db",
|
||||
"d1": null,
|
||||
"r2": null
|
||||
}
|
||||
+11
-12
@@ -1,26 +1,27 @@
|
||||
# Thinkloom implementation status
|
||||
|
||||
Status date: 2026-07-16
|
||||
Status date: 2026-07-17
|
||||
|
||||
## Completed locally
|
||||
|
||||
- M0 engineering baseline: shared React/TypeScript interface, Tauri 2 shell, typed command errors, Windows packaging configuration, browser/Sites companion, build/type/lint/unit checks.
|
||||
- M1 project and provenance foundation: project layout, SQLite schema, atomic canonical writes, rotating snapshots, SHA-256 event chain, chain verification, hidden Git checkpoints, canonical rebuild inputs.
|
||||
- M2 provider/privacy boundary: Ollama, OpenAI and compatible request paths; OS vault secrets; provider test; local/cloud status; first-cloud-use approval; retry-preserving failure state.
|
||||
- M3 ideation and idea curation: typed turns, one focused question, challenge modes, suggestion review, direct save, edits, variants, merge, tags, archive/rejected history, drafting sets.
|
||||
- M4 drafting workspace: TipTap structured editor, dominant three-panel layout, selective context, staged generation, all acceptance destinations, partial acceptance, manual editing, undo/redo, autosave.
|
||||
- M5 finalization/style/history: editorial action previews, editable style traits and disallowed habits, saved versions, comparison summary, restore, release checkpoints, provenance timeline and contribution relationships.
|
||||
- M7 export/backup/evidence: Markdown, HTML, PDF, text, backup ZIP, evidence ZIP, sanitization disclosure, hashes, manifest, atomic finalization, strict ZIP import validation.
|
||||
- M0 engineering baseline: native-only React/TypeScript interface, Tauri 2 shell, typed command errors, Windows packaging configuration, and build/type/lint/unit checks.
|
||||
- M1 project and provenance foundation: project layout, SQLite schema, atomic canonical writes, rotating snapshots, SHA-256 event chain, chain verification, hidden Git checkpoints, and canonical rebuild inputs.
|
||||
- M2 provider/privacy boundary: Ollama, OpenAI and compatible request paths; OS vault secrets; provider test; local/cloud status; first-cloud-use approval; and retry-preserving failure state.
|
||||
- M3 ideation and idea curation: typed turns, one focused question, challenge modes, suggestion review, direct save, edits, variants, merge, tags, archive/rejected history, and drafting sets.
|
||||
- M4 drafting workspace: TipTap structured editor, dominant three-panel layout, selective context, staged generation, all acceptance destinations, partial acceptance, manual editing, undo/redo, and autosave.
|
||||
- M5 finalization/style/history: editorial action previews, editable style traits and disallowed habits, saved versions, comparison summary, restore, release checkpoints, provenance timeline, and contribution relationships.
|
||||
- M7 export/backup/evidence: Markdown, HTML, PDF, text, backup ZIP, evidence ZIP, sanitization disclosure, hashes, manifest, atomic finalization, and strict ZIP import validation.
|
||||
- Product surface: Sites, Next, Vinext, Cloudflare, and browser-companion code and configuration have been removed. Tauri is the sole application target.
|
||||
|
||||
## External release gates
|
||||
|
||||
These items cannot be honestly certified from this workspace alone and need project-owner decisions or assets:
|
||||
|
||||
1. Voice runtime: provide/approve the faster-whisper model size and Silero VAD ONNX model distribution. The interface currently uses ephemeral browser speech recognition as a functional fallback and retains no audio, but the required bundled local voice pipeline is not yet present.
|
||||
1. Voice runtime: provide or approve the faster-whisper model size and Silero VAD ONNX model distribution. The interface currently uses ephemeral webview speech recognition as a functional fallback and retains no audio, but the required bundled local voice pipeline is not yet present.
|
||||
2. Local-model acceptance baseline: confirm the Ollama model used for release testing. The implementation default is `llama3.2`.
|
||||
3. Signing and distribution: provide the Windows code-signing certificate and choose the installer distribution channel. MSI/NSIS configuration is present, but signed release artifacts cannot be produced without credentials.
|
||||
4. Secondary platforms: decide whether macOS and Linux are part of the first signed release. Their keychain, microphone, installer, and packaging gates require platform runners.
|
||||
5. PDF typography: the native exporter creates a valid dependency-free PDF. Approve whether the release should instead bundle a browser-print engine for richer typography.
|
||||
5. PDF typography: the native exporter creates a valid dependency-free PDF. Approve whether the release should instead bundle a native print engine for richer typography.
|
||||
6. Diagnostics policy: confirm whether opt-in diagnostic reports are allowed. Current diagnostics are local, redacted, and exclude prompts, responses, credentials, and audio.
|
||||
|
||||
## Remaining hardening
|
||||
@@ -28,5 +29,3 @@ These items cannot be honestly certified from this workspace alone and need proj
|
||||
- Run microphone/VAD/transcription integration tests once the approved model assets are supplied.
|
||||
- Run signed clean-install, upgrade, downgrade-warning, and uninstall tests on each release platform.
|
||||
- Run full 20,000-word interaction and fault-injection profiling on packaged release hardware.
|
||||
- Resolve the upstream moderate PostCSS advisory inherited by the pinned Next/Vinext preview toolchain when a compatible patch is available. Native packaged assets do not execute the Sites server dependency.
|
||||
- Deploy the Sites companion after a deployment credential is issued by the connected Sites service.
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
# Thinkloom
|
||||
|
||||
Thinkloom is a desktop-first, local-first writing studio that helps one writer move from conversation to ideas, draft, revision, and a versioned release while preserving an inspectable creative-process record.
|
||||
Thinkloom is a native, local-first writing studio that helps one writer move from conversation to ideas, draft, revision, and a versioned release while preserving an inspectable creative-process record.
|
||||
|
||||
This repository contains two builds of the same React interface:
|
||||
|
||||
- a Tauri 2 desktop application backed by Rust, SQLite, canonical Markdown/JSON/JSONL files, hidden Git checkpoints, operating-system credentials, and atomic exports;
|
||||
- a Sites companion build for product preview and browser-based evaluation.
|
||||
The application is built with Tauri 2, React, TypeScript, Rust, SQLite, canonical Markdown/JSON/JSONL files, hidden Git checkpoints, operating-system credentials, and atomic exports. There is no hosted or browser companion build.
|
||||
|
||||
## Implemented workflow
|
||||
|
||||
- Reversible Ideation, Drafting, and Finalization phases
|
||||
- Typed conversation, challenge levels, push-to-talk browser transcription, and optional visible speech output
|
||||
- Typed conversation, challenge levels, push-to-talk transcription, and optional visible speech output
|
||||
- Suggested ideas with explicit accept/reject, editing, variants, archiving, source links, drafting sets, and merges
|
||||
- TipTap/ProseMirror structured manuscript editor with canonical Markdown round-tripping, undo/redo, headings, lists, selection replacement, and cursor insertion
|
||||
- Persisted preview-first generation states with retry-safe provider errors and partial acceptance
|
||||
@@ -30,9 +27,8 @@ Requirements: Node.js 22.13 or newer, Rust 1.77.2 or newer, Git, and the Windows
|
||||
```powershell
|
||||
npm install
|
||||
npm run dev
|
||||
npm run desktop:dev
|
||||
npm run build
|
||||
npm run desktop:build
|
||||
npm run tauri -- dev
|
||||
npm run tauri -- build
|
||||
```
|
||||
|
||||
@@ -49,10 +45,10 @@ cargo test
|
||||
|
||||
## Project storage
|
||||
|
||||
A desktop project is self-contained. Canonical files live under `manuscript/`, `ideas/`, `conversations/`, `provenance/`, and `style/`. Live SQLite state and rotating snapshots are under `.thinkloom/` and are excluded from the project’s hidden Git history. Audio retention is always false; no audio file extension is created by the native service.
|
||||
A Thinkloom project is self-contained. Canonical files live under `manuscript/`, `ideas/`, `conversations/`, `provenance/`, and `style/`. Live SQLite state and rotating snapshots are under `.thinkloom/` and are excluded from the project's hidden Git history. Audio retention is always false; no audio file extension is created by the native service.
|
||||
|
||||
## Provider setup
|
||||
|
||||
Ollama defaults to `http://127.0.0.1:11434` and model `llama3.2`. OpenAI and compatible credentials are entered in Settings and saved through Windows Credential Manager, macOS Keychain, or Linux Secret Service. The first cloud operation in each project requires explicit approval.
|
||||
|
||||
See [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) for release gates that require external models, signing credentials, or additional platform validation.
|
||||
See [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) for release gates that require external models, signing credentials, or additional platform validation.
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export type ChatGPTUser = {
|
||||
displayName: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
};
|
||||
|
||||
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
|
||||
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
|
||||
const USER_FULL_NAME_ENCODING_HEADER =
|
||||
"oai-authenticated-user-full-name-encoding";
|
||||
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
|
||||
const SIGN_IN_PATH = "/signin-with-chatgpt";
|
||||
const SIGN_OUT_PATH = "/signout-with-chatgpt";
|
||||
const CALLBACK_PATH = "/callback";
|
||||
|
||||
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
|
||||
const requestHeaders = await headers();
|
||||
const email = requestHeaders.get(USER_EMAIL_HEADER);
|
||||
if (!email) return null;
|
||||
|
||||
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
|
||||
const fullName =
|
||||
encodedFullName &&
|
||||
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
|
||||
? safeDecodeURIComponent(encodedFullName)
|
||||
: null;
|
||||
|
||||
return {
|
||||
displayName: fullName ?? email,
|
||||
email,
|
||||
fullName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireChatGPTUser(
|
||||
returnTo: string,
|
||||
): Promise<ChatGPTUser> {
|
||||
const user = await getChatGPTUser();
|
||||
if (user) return user;
|
||||
|
||||
redirect(chatGPTSignInPath(returnTo));
|
||||
}
|
||||
|
||||
export function chatGPTSignInPath(returnTo: string): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
export function chatGPTSignOutPath(returnTo = "/"): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
function safeRelativeReturnPath(value: string): string {
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, "https://app.local");
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
if (url.origin !== "https://app.local") return "/";
|
||||
if (isReservedAuthPath(url.pathname)) return "/";
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function isReservedAuthPath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === SIGN_IN_PATH ||
|
||||
pathname === SIGN_OUT_PATH ||
|
||||
pathname === CALLBACK_PATH
|
||||
);
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(value: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const requestHeaders = await headers();
|
||||
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost";
|
||||
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
const image = new URL("/og.png", `${protocol}://${host}`).toString();
|
||||
return {
|
||||
title: "Thinkloom — ideas into writing",
|
||||
description: "A local-first writing studio for exploring ideas, shaping drafts, and preserving your creative process.",
|
||||
icons: { icon: "/icon.png", shortcut: "/icon.png", apple: "/icon.png" },
|
||||
openGraph: { title: "Thinkloom — ideas into writing", description: "Explore, shape, and publish thoughtful writing without losing the thread of how it came together.", type: "website", images: [{ url: image, width: 1732, height: 909, alt: "Thinkloom — ideas into writing, without losing the thread." }] },
|
||||
twitter: { card: "summary_large_image", images: [image] },
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return <html lang="en"><body>{children}</body></html>; }
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import Thinkloom from "./thinkloom";
|
||||
export default function Home() { return <Thinkloom />; }
|
||||
@@ -1,45 +0,0 @@
|
||||
import { access, cp, mkdir, rm } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Packages Sites metadata and migrations after Vite finishes compiling.
|
||||
export function sites(): Plugin {
|
||||
let root = process.cwd();
|
||||
|
||||
return {
|
||||
name: "sites",
|
||||
apply: "build",
|
||||
configResolved(config) {
|
||||
root = config.root;
|
||||
},
|
||||
async closeBundle() {
|
||||
const outputDirectory = resolve(root, "dist", ".openai");
|
||||
const hostingConfig = resolve(root, ".openai", "hosting.json");
|
||||
const drizzleSource = resolve(root, "drizzle");
|
||||
|
||||
await rm(outputDirectory, { recursive: true, force: true });
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
if (await exists(hostingConfig)) {
|
||||
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
|
||||
}
|
||||
if (await exists(drizzleSource)) {
|
||||
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
import * as schema from "./schema";
|
||||
|
||||
/** Create a typed database client from the D1 binding supplied by the worker. */
|
||||
export function getDb(binding: D1Database) {
|
||||
return drizzle(binding, { schema });
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Intentionally empty by default.
|
||||
// Add Drizzle tables here when the site actually needs a database.
|
||||
// See examples/d1/db/schema.ts for an opt-in example.
|
||||
export {};
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f4f1e9" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'; base-uri 'self'" />
|
||||
<title>Thinkloom</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/main.tsx"></script></body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import Thinkloom from "../app/thinkloom";
|
||||
import "../app/globals.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<React.StrictMode><Thinkloom /></React.StrictMode>);
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": []
|
||||
}
|
||||
+22
-18
@@ -1,18 +1,22 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist/**", "src-tauri/target/**", "node_modules/**"] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}", "vite.config.ts"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
plugins: { "react-hooks": reactHooks },
|
||||
rules: { ...reactHooks.configs.recommended.rules },
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.mjs"],
|
||||
languageOptions: { globals: globals.node },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import { getDb } from "../../../../../db";
|
||||
import { notes } from "../../../db/schema";
|
||||
|
||||
function toRouteErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unexpected error";
|
||||
const detail =
|
||||
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
|
||||
const combined = `${message}\n${detail}`;
|
||||
|
||||
if (combined.includes("no such table") || combined.includes('from "notes"')) {
|
||||
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.orderBy(desc(notes.createdAt), desc(notes.id))
|
||||
.limit(20);
|
||||
|
||||
return Response.json({ notes: rows });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: toRouteErrorMessage(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const payload = (await request.json()) as {
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
const title = payload.title?.trim() ?? "";
|
||||
const content = payload.content?.trim() ?? "";
|
||||
|
||||
if (!title) {
|
||||
return Response.json({ error: "title is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [note] = await db.insert(notes).values({ title, content }).returning();
|
||||
return Response.json({ note }, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: toRouteErrorMessage(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const notes = sqliteTable("notes", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
title: text("title").notNull(),
|
||||
content: text("content").notNull().default(""),
|
||||
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f4f1e9" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'; base-uri 'self'" />
|
||||
<link rel="icon" href="/icon.png" />
|
||||
<title>Thinkloom — ideas into writing</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+3906
-12198
File diff suppressed because it is too large
Load Diff
+38
-51
@@ -1,51 +1,38 @@
|
||||
{
|
||||
"name": "thinkloom",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vinext dev",
|
||||
"build": "vinext build",
|
||||
"start": "vinext start",
|
||||
"test": "npm run typecheck && node --test tests/*.test.mjs",
|
||||
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern desktop-dist --ignore-pattern src-tauri/target",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"desktop:dev": "vite --config vite.desktop.config.ts",
|
||||
"desktop:build": "vite build --config vite.desktop.config.ts",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/markdown": "^3.28.0",
|
||||
"@tiptap/pm": "^3.28.0",
|
||||
"@tiptap/react": "^3.28.0",
|
||||
"@tiptap/starter-kit": "^3.28.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "1.37.1",
|
||||
"@cloudflare/workers-types": "^4.20260702.1",
|
||||
"@tailwindcss/postcss": "4.2.1",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "22.19.19",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"@vitejs/plugin-rsc": "0.5.26",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"react-server-dom-webpack": "19.2.6",
|
||||
"tailwindcss": "4.2.1",
|
||||
"typescript": "5.9.3",
|
||||
"vinext": "0.0.50",
|
||||
"vite": "8.0.13",
|
||||
"wrangler": "4.92.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
{
|
||||
"name": "thinkloom",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": { "node": ">=22.13.0" },
|
||||
"scripts": {
|
||||
"dev": "vite --port 1420 --strictPort",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src tests vite.config.ts",
|
||||
"test": "npm run typecheck && node tests/native-app.test.mjs",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/markdown": "^3.28.0",
|
||||
"@tiptap/pm": "^3.28.0",
|
||||
"@tiptap/react": "^3.28.0",
|
||||
"@tiptap/starter-kit": "^3.28.0",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.4",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "22.19.19",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "14.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.59.3",
|
||||
"vite": "8.0.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
|
||||
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
|
||||
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
|
||||
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 718 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
File diff suppressed because it is too large
Load Diff
+44
-44
@@ -1,44 +1,44 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Thinkloom",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.thinkloom.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run desktop:dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run desktop:build",
|
||||
"frontendDist": "../desktop-dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Thinkloom",
|
||||
"width": 1440,
|
||||
"height": 920,
|
||||
"minWidth": 960,
|
||||
"minHeight": 680,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"msi",
|
||||
"nsis"
|
||||
],
|
||||
"shortDescription": "Ideas into writing, without losing the thread.",
|
||||
"longDescription": "A local-first writing studio for exploring ideas, shaping drafts, and preserving creative process history.",
|
||||
"category": "Productivity",
|
||||
"copyright": "Copyright © 2026 Thinkloom",
|
||||
"icon": [
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Thinkloom",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.thinkloom.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Thinkloom",
|
||||
"width": 1440,
|
||||
"height": 920,
|
||||
"minWidth": 960,
|
||||
"minHeight": 680,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* https://api.openai.com; media-src 'self' blob:; object-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"msi",
|
||||
"nsis"
|
||||
],
|
||||
"shortDescription": "Ideas into writing, without losing the thread.",
|
||||
"longDescription": "A local-first writing studio for exploring ideas, shaping drafts, and preserving creative process history.",
|
||||
"category": "Productivity",
|
||||
"copyright": "Copyright © 2026 Thinkloom",
|
||||
"icon": [
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
|
||||
type Phase = "ideation" | "drafting" | "finalization";
|
||||
@@ -73,13 +73,13 @@ const StructuredEditor = forwardRef<StructuredEditorHandle, { value: string; onC
|
||||
});
|
||||
export default function Thinkloom() {
|
||||
const [project, setProject] = useState<State>(initial); const [hydrated, setHydrated] = useState(false); const [view, setView] = useState<View>("ideation"); const [message, setMessage] = useState(""); const [notice, setNotice] = useState("Project ready. History is being recorded."); const [busy, setBusy] = useState(false); const [listening, setListening] = useState(false); const [showRejected, setShowRejected] = useState(false); const [editingIdea, setEditingIdea] = useState<string | null>(null); const [newHabit, setNewHabit] = useState(""); const [sanitized, setSanitized] = useState(true); const [credential, setCredential] = useState(""); const [projectPath, setProjectPath] = useState(""); const editor = useRef<StructuredEditorHandle>(null); const finalEditor = useRef<HTMLTextAreaElement>(null); const conversationEnd = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { queueMicrotask(() => { try { const raw = localStorage.getItem("thinkloom-project-v1"); if (raw) setProject({ ...initial, ...JSON.parse(raw) as State }); } catch {} setHydrated(true); }); }, []);
|
||||
useEffect(() => { queueMicrotask(() => { try { const raw = localStorage.getItem("thinkloom-project-v1"); if (raw) setProject({ ...initial, ...JSON.parse(raw) as State }); } catch { /* Ignore invalid local recovery state. */ } setHydrated(true); }); }, []);
|
||||
useEffect(() => { if (hydrated) localStorage.setItem("thinkloom-project-v1", JSON.stringify(project)); }, [project, hydrated]);
|
||||
const mutate = useCallback((type: string, summary: string, update: (current: State) => State, actor: Actor = "user") => { setProject((current) => { const previousHash = current.events.at(-1)?.hash ?? null; const event: Event = { id: uid("event"), type, actor, at: now(), summary, previousHash, hash: hash(`${previousHash}|${type}|${summary}|${Date.now()}`), provider: actor === "assistant" ? current.provider.name : undefined }; const next = { ...update(current), events: [...current.events, event], updatedAt: event.at }; void invokeNative("persist_state", { appState: next }).catch(() => undefined); return next; }); setNotice(summary); }, []);
|
||||
const navigate = (next: View) => { setView(next); if (["ideation", "drafting", "finalization"].includes(next) && project.phase !== next) mutate("PHASE_CHANGED", `Moved to ${next}`, (current) => ({ ...current, phase: next as Phase })); };
|
||||
useEffect(() => { const shortcut = (e: KeyboardEvent) => { if (!e.altKey) return; if (e.key === "1") document.getElementById("ideas-panel")?.focus(); if (e.key === "2") editor.current?.focus(); if (e.key === "3") document.getElementById("assistant-panel")?.focus(); }; window.addEventListener("keydown", shortcut); return () => window.removeEventListener("keydown", shortcut); }, []);
|
||||
const visibleIdeas = useMemo(() => project.ideas.filter((idea) => showRejected || !["rejected", "archived"].includes(idea.status)), [project.ideas, showRejected]);
|
||||
const draftingIdeas = project.ideas.filter((idea) => idea.status === "accepted"); const selectedIdeas = draftingIdeas.filter((idea) => idea.selected);
|
||||
const draftingIdeas = project.ideas.filter((idea) => idea.status === "accepted"); const selectedIdeas = draftingIdeas.filter((idea) => idea.selected);
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = message.trim(); if (!text || busy) return; setMessage(""); setBusy(true);
|
||||
@@ -119,7 +119,7 @@ export default function Thinkloom() {
|
||||
const createNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) { setNotice("Choose a folder when you are ready to create the desktop project."); return; } const result = await invokeNative<{ path: string }>("create_project", { path: folder, title: project.title }); if (!result) { setNotice("Project folders are available in the installed desktop app."); return; } setProjectPath(result.path); await invokeNative("persist_state", { appState: project }); setNotice("Self-contained project created and ready."); } catch (error) { setNotice(`Project could not be created: ${String(error)}`); } };
|
||||
const openNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) return; const result = await invokeNative<{ path: string }>("open_project", { path: folder }); if (!result) { setNotice("Opening project folders is available in the installed desktop app."); return; } setProjectPath(result.path); const restored = await invokeNative<State>("load_project_state"); if (restored) setProject(restored); setNotice("Project reopened and its history verified."); } catch (error) { setNotice(`Project could not be opened: ${String(error)}`); } };
|
||||
|
||||
if (!hydrated) return <main className="loading"><div className="brand-mark">T</div><p>Gathering your threads…</p></main>;
|
||||
if (!hydrated) return <main className="loading"><div className="brand-mark">T</div><p>Gathering your threads…</p></main>;
|
||||
return <main className="app-shell">
|
||||
<a className="skip-link" href="#workspace">Skip to workspace</a>
|
||||
<header className="project-bar"><button className="brand" onClick={() => navigate("project")} aria-label="Open project overview"><span className="brand-mark">T</span><span>Thinkloom</span></button><div className="project-identity"><strong>{project.title}</strong><span>{project.subtitle}</span></div><div className="project-status"><Mark tone={project.privacy === "Local" ? "good" : project.privacy === "Cloud" ? "cloud" : "warm"}><span className="status-dot" />{project.privacy}</Mark><button className="history-state" onClick={() => navigate("provenance")}>◇ History recorded</button><span className="save-state">Saved</span></div></header>
|
||||
@@ -139,7 +139,7 @@ export default function Thinkloom() {
|
||||
<aside id="ideas-panel" className="draft-ideas panel" tabIndex={-1} aria-label="Selected ideas"><div className="panel-heading"><div><span className="eyebrow">Drafting set</span><h2>Selected threads</h2></div></div><div className="relation"><label htmlFor="relation">Relation</label><select id="relation" value={project.generation.relation} onChange={(event) => setProject((current) => ({ ...current, generation: { ...current.generation, relation: event.target.value } }))}><option>synthesize</option><option>compare</option><option>contrast</option><option>sequence</option><option>support with evidence</option><option>separate into sections</option></select></div>{draftingIdeas.map((idea) => <label className={`draft-idea ${idea.selected ? "selected" : ""}`} key={idea.id}><input type="checkbox" checked={idea.selected} onChange={() => updateIdea(idea.id, { selected: !idea.selected }, `${idea.selected ? "Deselected" : "Selected"} “${idea.title}”`)} /><span><strong>{idea.title}</strong><small>{idea.summary}</small></span></label>)}<button className="secondary-button full" onClick={mergeSelected}>Merge selected</button><p className="shortcut">Focus: Alt+1</p></aside>
|
||||
<section className="manuscript-panel panel" aria-label="Manuscript editor"><div className="editor-toolbar"><div><span className="eyebrow">Manuscript</span><h1>{project.title}</h1></div><div><button onClick={() => saveCheckpoint()}>Save version</button><span>{countWords(project.manuscript).toLocaleString()} words</span></div></div><StructuredEditor ref={editor} value={project.manuscript} onChange={(manuscript) => setProject((current) => ({ ...current, manuscript, updatedAt: now() }))} onCommit={() => mutate("MANUSCRIPT_TEXT_EDITED", "Autosaved manuscript edits", (current) => current)} /><div className="editor-footer"><span>Markdown structure · Autosaved locally</span><span>Focus: Alt+2</span></div></section>
|
||||
<aside id="assistant-panel" className="draft-assistant panel" tabIndex={-1} aria-label="Generation assistant"><div className="panel-heading"><div><span className="eyebrow">Writing room</span><h2>Shape a passage</h2></div></div><p className="assistant-context">Using {selectedIdeas.length} selected idea{selectedIdeas.length === 1 ? "" : "s"} and your project voice. Your manuscript will not change until you choose.</p><button className="primary-button full" onClick={() => void startGeneration()} disabled={busy || !selectedIdeas.length}>{busy ? "Gathering threads…" : "Draft a passage"}</button>{project.generation.state === "staged" ? <div className="generation-preview"><div className="preview-label"><span>Preview</span><Mark tone="warm">AI draft · not inserted</Mark></div><p>{project.generation.text}</p><div className="preview-actions"><button className="primary-button" onClick={() => acceptGeneration("cursor")}>Insert at cursor</button><button onClick={() => acceptGeneration("replace")}>Replace selection</button><button onClick={() => acceptGeneration("append")}>Append</button><button onClick={() => acceptGeneration("section")}>New section</button><button onClick={() => acceptGeneration("cursor", true)}>Insert first 2 sentences</button><button onClick={() => void startGeneration()}>Regenerate</button><button className="danger-text" onClick={discardGeneration}>Discard</button></div></div> : <Empty eyebrow="Preview first" title="Nothing enters unseen" body="Choose your ideas, set their relationship, and generate a passage to review here." />}<p className="shortcut">Focus: Alt+3</p></aside>
|
||||
</section>}
|
||||
</section>}
|
||||
{view === "finalization" && <section className="finalization-layout">
|
||||
<aside className="editorial-rail panel" aria-label="Editorial actions"><span className="eyebrow">Editorial pass</span><h2>Refine with intent</h2><p>Select text in the manuscript, then preview an action.</p><div className="action-stack">{["Clarity", "Rewrite", "Shorten", "Expand", "Transition", "Repetition", "Consistency", "Tone", "User voice", "Proofread"].map((action) => <button key={action} onClick={() => void startGeneration(action)}>{action}<span>→</span></button>)}</div></aside>
|
||||
<section className="manuscript-panel panel final-manuscript"><div className="editor-toolbar"><div><span className="eyebrow">Release manuscript</span><h1>{project.title}</h1></div><Mark tone={project.finalized ? "good" : "warm"}>{project.finalized ? "Release ready" : "Working version"}</Mark></div><textarea ref={finalEditor} className="manuscript-editor" value={project.manuscript} onChange={(event) => setProject((current) => ({ ...current, manuscript: event.target.value }))} aria-label="Final manuscript" /><div className="editor-footer"><span>{countWords(project.manuscript)} words</span><span>{project.checkpoints.length} saved versions</span></div></section>
|
||||
@@ -150,7 +150,7 @@ export default function Thinkloom() {
|
||||
|
||||
{view === "history" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Restorable moments</span><h1>Version history</h1><p>Compare or return to meaningful stages without technical terminology.</p></div><div className="history-layout"><div className="version-list panel">{[...project.checkpoints].reverse().map((point, index) => <article className="version-row" key={point.id}><div className="version-symbol">{index === 0 ? "◆" : "◇"}</div><div><strong>{point.name}</strong><span>{fmt(point.at)} · {point.words} words</span></div><div><button onClick={() => setNotice(`Compared with “${point.name}”: ${countWords(project.manuscript) - point.words >= 0 ? "+" : ""}${countWords(project.manuscript) - point.words} words.`)}>Compare</button><button onClick={() => restoreCheckpoint(point)}>Restore</button></div></article>)}</div><aside className="panel history-aside"><span className="eyebrow">Current work</span><h2>{countWords(project.manuscript)} words</h2><p>{project.checkpoints.length ? `${countWords(project.manuscript) - project.checkpoints.at(-1)!.words >= 0 ? "+" : ""}${countWords(project.manuscript) - project.checkpoints.at(-1)!.words} words since ${project.checkpoints.at(-1)!.name}.` : "Save your first version when the draft reaches a meaningful moment."}</p><button className="primary-button full" onClick={() => saveCheckpoint()}>Save current version</button></aside></div></section>}
|
||||
|
||||
{view === "provenance" && <section className="page-layout"><div className="page-heading provenance-heading"><div><span className="eyebrow">Creative process record</span><h1>How this work took shape</h1><p>A linked record of decisions and contributions—not a score or legal conclusion.</p></div><button className="secondary-button" onClick={verifyChain}>Verify linked history</button></div><div className="provenance-layout"><div className="timeline panel">{[...project.events].reverse().map((event) => <article className="event-row" key={event.id}><div className={`actor-mark actor-${event.actor}`}>{event.actor === "user" ? "Y" : event.actor === "assistant" ? "A" : "S"}</div><div><div className="event-top"><strong>{event.summary}</strong><time>{fmt(event.at)}</time></div><p>{event.type.replaceAll("_", " ").toLowerCase()}{event.provider ? ` · ${event.provider}` : ""}</p><code>{event.hash}</code></div></article>)}</div><aside className="panel contribution"><span className="eyebrow">Contribution threads</span><h2>Relationships, not percentages</h2><div className="contribution-chain"><span>You explored a question</span><i>↓</i><span>Ideas were suggested</span><i>↓</i><span>You accepted and shaped them</span><i>↓</i><span>Drafts were previewed</span><i>↓</i><span>You decided what entered the work</span></div><p>Every accepted passage remains connected to source ideas and later revisions.</p></aside></div></section>}
|
||||
{view === "provenance" && <section className="page-layout"><div className="page-heading provenance-heading"><div><span className="eyebrow">Creative process record</span><h1>How this work took shape</h1><p>A linked record of decisions and contributions—not a score or legal conclusion.</p></div><button className="secondary-button" onClick={verifyChain}>Verify linked history</button></div><div className="provenance-layout"><div className="timeline panel">{[...project.events].reverse().map((event) => <article className="event-row" key={event.id}><div className={`actor-mark actor-${event.actor}`}>{event.actor === "user" ? "Y" : event.actor === "assistant" ? "A" : "S"}</div><div><div className="event-top"><strong>{event.summary}</strong><time>{fmt(event.at)}</time></div><p>{event.type.replaceAll("_", " ").toLowerCase()}{event.provider ? ` · ${event.provider}` : ""}</p><code>{event.hash}</code></div></article>)}</div><aside className="panel contribution"><span className="eyebrow">Contribution threads</span><h2>Relationships, not percentages</h2><div className="contribution-chain"><span>You explored a question</span><i>↓</i><span>Ideas were suggested</span><i>↓</i><span>You accepted and shaped them</span><i>↓</i><span>Drafts were previewed</span><i>↓</i><span>You decided what entered the work</span></div><p>Every accepted passage remains connected to source ideas and later revisions.</p></aside></div></section>}
|
||||
{view === "export" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Take your work with you</span><h1>Publish, preserve, or share</h1><p>Exports are created from the current release manuscript and checked before completion.</p></div><div className="export-grid"><article className="export-card"><span className="export-type">Publication</span><h2>Reading files</h2><p>Clean files for editors, websites, and your own archive.</p><div className="export-buttons"><button onClick={() => void exportFile("markdown")}>Markdown <span>.md</span></button><button onClick={() => void exportFile("html")}>Web page <span>.html</span></button><button onClick={() => void exportFile("text")}>Plain text <span>.txt</span></button><button onClick={() => setNotice("PDF export is available in the installed desktop app.")}>Print-ready <span>.pdf</span></button></div></article><article className="export-card featured"><span className="export-type">Preservation</span><h2>Project backup</h2><p>A complete, restorable package with manuscript, ideas, history, and recovery data.</p><button className="primary-button" onClick={() => void invokeNative<string>("create_backup").then((path) => setNotice(path ? `Backup created at ${path}` : "Project Backup ZIP is available in the desktop app."))}>Create project backup</button></article><article className="export-card"><span className="export-type">Creative process</span><h2>Authorship evidence</h2><p>A human-readable and machine-verifiable record of how the publication developed.</p><label className="toggle-row"><input type="checkbox" checked={sanitized} onChange={(event) => setSanitized(event.target.checked)} /><span><strong>Share a sanitized subset</strong><small>Excludes private conversations, provider details, identifiers, and internal paths.</small></span></label><button className="secondary-button" onClick={() => void exportFile("evidence")}>Create evidence package</button></article></div></section>}
|
||||
|
||||
{view === "settings" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Private by design</span><h1>Settings</h1><p>Choose where thinking happens and how Thinkloom supports the work.</p></div><div className="settings-grid">
|
||||
@@ -161,9 +161,4 @@ export default function Thinkloom() {
|
||||
</section>
|
||||
<footer className="app-footer"><span>Thinkloom 0.1.0</span><span>Local-first · audio retention always off</span><span>{project.events.at(-1)?.hash ?? "No history"}</span></footer>
|
||||
</main>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import Thinkloom from "./Thinkloom";
|
||||
import "./globals.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode><Thinkloom /></React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
|
||||
test("defines a native-only Thinkloom application shell", async () => {
|
||||
const [html, packageJson, tauriConfig] = await Promise.all([
|
||||
readFile(new URL("../index.html", import.meta.url), "utf8"),
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src-tauri/tauri.conf.json", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(html, /Thinkloom — ideas into writing/i);
|
||||
assert.match(html, /\/src\/main\.tsx/);
|
||||
assert.doesNotMatch(`${html}\n${packageJson}`, /sites|next|vinext|wrangler|cloudflare/i);
|
||||
assert.match(tauriConfig, /"beforeDevCommand": "npm run dev"/);
|
||||
assert.match(tauriConfig, /"beforeBuildCommand": "npm run build"/);
|
||||
assert.match(tauriConfig, /"frontendDist": "\.\.\/dist"/);
|
||||
});
|
||||
|
||||
test("implements the control and privacy contracts", async () => {
|
||||
const [source, css] = await Promise.all([
|
||||
readFile(new URL("../src/Thinkloom.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/globals.css", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
for (const phrase of ["Insert at cursor", "Replace selection", "New section", "Discard", "History recorded", "No audio retained", "Approve for this project", "Relationships, not percentages"]) {
|
||||
assert.match(source, new RegExp(phrase, "i"));
|
||||
}
|
||||
assert.match(source, /GENERATION_PARTIALLY_ACCEPTED/);
|
||||
assert.match(source, /CLOUD_PROCESSING_APPROVED/);
|
||||
assert.match(source, /store_provider_secret/);
|
||||
assert.match(css, /prefers-reduced-motion/);
|
||||
assert.match(css, /:focus-visible/);
|
||||
});
|
||||
|
||||
test("never ships retained audio assets", async () => {
|
||||
async function walk(url) {
|
||||
const entries = await readdir(url, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (["node_modules", "dist", "target", ".git"].includes(entry.name)) continue;
|
||||
const child = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, url);
|
||||
if (entry.isDirectory()) files.push(...await walk(child));
|
||||
else files.push(entry.name);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const files = await walk(root);
|
||||
assert.equal(files.some((name) => /\.(wav|mp3|m4a|ogg|webm)$/i.test(name)), false);
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
|
||||
async function render() {
|
||||
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
|
||||
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
|
||||
const { default: worker } = await import(workerUrl.href);
|
||||
return worker.fetch(new Request("http://localhost/", { headers: { accept: "text/html" } }), { ASSETS: { fetch: async () => new Response("Not found", { status: 404 }) } }, { waitUntil() {}, passThroughOnException() {} });
|
||||
}
|
||||
|
||||
test("serves the Thinkloom product shell", async () => {
|
||||
const response = await render();
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
||||
const html = await response.text();
|
||||
assert.match(html, /Thinkloom — ideas into writing/i);
|
||||
assert.match(html, /Gathering your threads/i);
|
||||
assert.doesNotMatch(html, /codex-preview|starter project|react-loading-skeleton/i);
|
||||
});
|
||||
|
||||
test("implements the control and privacy contracts", async () => {
|
||||
const [source, css, layout] = await Promise.all([
|
||||
readFile(new URL("../app/thinkloom.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
for (const phrase of ["Insert at cursor", "Replace selection", "New section", "Discard", "History recorded", "No audio retained", "Approve for this project", "Relationships, not percentages"]) assert.match(source, new RegExp(phrase, "i"));
|
||||
assert.match(source, /GENERATION_PARTIALLY_ACCEPTED/);
|
||||
assert.match(source, /CLOUD_PROCESSING_APPROVED/);
|
||||
assert.match(source, /store_provider_secret/);
|
||||
assert.match(css, /prefers-reduced-motion/);
|
||||
assert.match(css, /:focus-visible/);
|
||||
assert.match(layout, /Thinkloom — ideas into writing/);
|
||||
});
|
||||
|
||||
test("never ships retained audio assets", async () => {
|
||||
async function walk(url) { const entries = await readdir(url, { withFileTypes: true }); const files = []; for (const entry of entries) { if (["node_modules", "dist", "desktop-dist", "target", ".git"].includes(entry.name)) continue; const child = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, url); if (entry.isDirectory()) files.push(...await walk(child)); else files.push(entry.name); } return files; }
|
||||
const files = await walk(root);
|
||||
assert.equal(files.some((name) => /\.(wav|mp3|m4a|ogg|webm)$/i.test(name)), false);
|
||||
});
|
||||
+20
-38
@@ -1,38 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules", "examples"]
|
||||
}
|
||||
|
||||
|
||||
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "src-tauri/target"]
|
||||
}
|
||||
|
||||
+9
-59
@@ -1,59 +1,9 @@
|
||||
import vinext from "vinext";
|
||||
import { defineConfig } from "vite";
|
||||
import hostingConfig from "./.openai/hosting.json";
|
||||
import { sites } from "./build/sites-vite-plugin";
|
||||
|
||||
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
|
||||
"00000000-0000-4000-8000-000000000000";
|
||||
|
||||
const { d1, r2 } = hostingConfig;
|
||||
|
||||
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
|
||||
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
|
||||
|
||||
const localBindingConfig = {
|
||||
main: "./worker/index.ts",
|
||||
compatibility_flags: ["nodejs_compat"],
|
||||
d1_databases: d1
|
||||
? [
|
||||
{
|
||||
binding: d1,
|
||||
database_name: "site-creator-d1",
|
||||
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
r2_buckets: r2
|
||||
? [
|
||||
{
|
||||
binding: r2,
|
||||
bucket_name: "site-creator-r2",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
export default defineConfig(async () => {
|
||||
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
|
||||
// settings; application environment belongs in ignored `.env*` files.
|
||||
process.env.WRANGLER_WRITE_LOGS ??= "false";
|
||||
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
|
||||
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
|
||||
|
||||
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
|
||||
const { cloudflare } = await import("@cloudflare/vite-plugin");
|
||||
|
||||
return {
|
||||
server: isCodexSeatbeltSandbox
|
||||
? { watch: { useFsEvents: false, usePolling: true } }
|
||||
: undefined,
|
||||
plugins: [
|
||||
vinext(),
|
||||
sites(),
|
||||
cloudflare({
|
||||
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
|
||||
config: localBindingConfig,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 1420, strictPort: true },
|
||||
clearScreen: false,
|
||||
build: { outDir: "dist", emptyOutDir: true },
|
||||
});
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: "desktop",
|
||||
build: { outDir: "../desktop-dist", emptyOutDir: true },
|
||||
server: { port: 1420, strictPort: true },
|
||||
clearScreen: false,
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
/** Cloudflare Worker entry point for the vinext-starter template. */
|
||||
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
|
||||
import handler from "vinext/server/app-router-entry";
|
||||
|
||||
interface Env {
|
||||
ASSETS: Fetcher;
|
||||
DB: D1Database;
|
||||
IMAGES: {
|
||||
input(stream: ReadableStream): {
|
||||
transform(options: Record<string, unknown>): {
|
||||
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecutionContext {
|
||||
waitUntil(promise: Promise<unknown>): void;
|
||||
passThroughOnException(): void;
|
||||
}
|
||||
|
||||
// Image security config. SVG sources with .svg extension auto-skip the
|
||||
// optimization endpoint on the client side (served directly, no proxy).
|
||||
// To route SVGs through the optimizer (with security headers), set
|
||||
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
|
||||
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
|
||||
|
||||
const worker = {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/_vinext/image") {
|
||||
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
|
||||
return handleImageOptimization(request, {
|
||||
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
|
||||
transformImage: async (body, { width, format, quality }) => {
|
||||
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
|
||||
return result.response();
|
||||
},
|
||||
}, allowedWidths);
|
||||
}
|
||||
|
||||
return handler.fetch(request, env, ctx);
|
||||
},
|
||||
};
|
||||
|
||||
export default worker;
|
||||
Reference in New Issue
Block a user