Make Thinkloom native-only
This commit is contained in:
+5
-45
@@ -1,48 +1,8 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
/node_modules/
|
||||||
|
|
||||||
# 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/
|
/dist/
|
||||||
/.wrangler/
|
|
||||||
/outputs/
|
|
||||||
/work/
|
|
||||||
|
|
||||||
# Thinkloom generated artifacts
|
|
||||||
/desktop-dist/
|
|
||||||
/src-tauri/target/
|
/src-tauri/target/
|
||||||
*.tsbuildinfo
|
*.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
|
# Thinkloom implementation status
|
||||||
|
|
||||||
Status date: 2026-07-16
|
Status date: 2026-07-17
|
||||||
|
|
||||||
## Completed locally
|
## 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.
|
- 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, canonical rebuild inputs.
|
- 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; retry-preserving failure state.
|
- 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, drafting sets.
|
- 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, autosave.
|
- 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.
|
- 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.
|
- 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
|
## External release gates
|
||||||
|
|
||||||
These items cannot be honestly certified from this workspace alone and need project-owner decisions or assets:
|
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`.
|
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.
|
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.
|
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.
|
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
|
## 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 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 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.
|
- 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
|
||||||
|
|
||||||
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:
|
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.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Implemented workflow
|
## Implemented workflow
|
||||||
|
|
||||||
- Reversible Ideation, Drafting, and Finalization phases
|
- 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
|
- 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
|
- 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
|
- 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
|
```powershell
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
npm run desktop:dev
|
|
||||||
npm run build
|
npm run build
|
||||||
npm run desktop:build
|
npm run tauri -- dev
|
||||||
npm run tauri -- build
|
npm run tauri -- build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -49,7 +45,7 @@ cargo test
|
|||||||
|
|
||||||
## Project storage
|
## 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
|
## Provider setup
|
||||||
|
|
||||||
|
|||||||
@@ -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": []
|
|
||||||
}
|
|
||||||
+21
-17
@@ -1,18 +1,22 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import js from "@eslint/js";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import globals from "globals";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
export default tseslint.config(
|
||||||
...nextVitals,
|
{ ignores: ["dist/**", "src-tauri/target/**", "node_modules/**"] },
|
||||||
...nextTs,
|
js.configs.recommended,
|
||||||
// Override default ignores of eslint-config-next.
|
...tseslint.configs.recommended,
|
||||||
globalIgnores([
|
{
|
||||||
// Default ignores of eslint-config-next:
|
files: ["src/**/*.{ts,tsx}", "vite.config.ts"],
|
||||||
".next/**",
|
languageOptions: {
|
||||||
"out/**",
|
globals: { ...globals.browser, ...globals.node },
|
||||||
"build/**",
|
},
|
||||||
"next-env.d.ts",
|
plugins: { "react-hooks": reactHooks },
|
||||||
]),
|
rules: { ...reactHooks.configs.recommended.rules },
|
||||||
]);
|
},
|
||||||
|
{
|
||||||
export default eslintConfig;
|
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
+14
-8306
File diff suppressed because it is too large
Load Diff
+13
-26
@@ -2,19 +2,15 @@
|
|||||||
"name": "thinkloom",
|
"name": "thinkloom",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"type": "module",
|
||||||
"node": ">=22.13.0"
|
"engines": { "node": ">=22.13.0" },
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vinext dev",
|
"dev": "vite --port 1420 --strictPort",
|
||||||
"build": "vinext build",
|
"build": "npm run typecheck && vite build",
|
||||||
"start": "vinext start",
|
"preview": "vite preview",
|
||||||
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"desktop:dev": "vite --config vite.desktop.config.ts",
|
"lint": "eslint src tests vite.config.ts",
|
||||||
"desktop:build": "vite build --config vite.desktop.config.ts",
|
"test": "npm run typecheck && node tests/native-app.test.mjs",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -22,30 +18,21 @@
|
|||||||
"@tiptap/pm": "^3.28.0",
|
"@tiptap/pm": "^3.28.0",
|
||||||
"@tiptap/react": "^3.28.0",
|
"@tiptap/react": "^3.28.0",
|
||||||
"@tiptap/starter-kit": "^3.28.0",
|
"@tiptap/starter-kit": "^3.28.0",
|
||||||
"drizzle-orm": "0.45.2",
|
|
||||||
"next": "16.2.6",
|
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6"
|
"react-dom": "19.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.37.1",
|
"@eslint/js": "9.39.4",
|
||||||
"@cloudflare/workers-types": "^4.20260702.1",
|
|
||||||
"@tailwindcss/postcss": "4.2.1",
|
|
||||||
"@tauri-apps/cli": "^2.11.4",
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"@types/node": "22.19.19",
|
"@types/node": "22.19.19",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
"@vitejs/plugin-rsc": "0.5.26",
|
|
||||||
"drizzle-kit": "0.31.10",
|
|
||||||
"eslint": "9.39.4",
|
"eslint": "9.39.4",
|
||||||
"eslint-config-next": "16.2.6",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
"react-server-dom-webpack": "19.2.6",
|
"globals": "14.0.0",
|
||||||
"tailwindcss": "4.2.1",
|
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vinext": "0.0.50",
|
"typescript-eslint": "8.59.3",
|
||||||
"vite": "8.0.13",
|
"vite": "8.0.13"
|
||||||
"wrangler": "4.92.0"
|
}
|
||||||
},
|
|
||||||
"type": "module"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -4,10 +4,10 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.thinkloom.desktop",
|
"identifier": "com.thinkloom.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run desktop:dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"beforeBuildCommand": "npm run desktop:build",
|
"beforeBuildCommand": "npm run build",
|
||||||
"frontendDist": "../desktop-dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const StructuredEditor = forwardRef<StructuredEditorHandle, { value: string; onC
|
|||||||
});
|
});
|
||||||
export default function Thinkloom() {
|
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);
|
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]);
|
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 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 })); };
|
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 })); };
|
||||||
@@ -162,8 +162,3 @@ export default function Thinkloom() {
|
|||||||
<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>
|
<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>;
|
</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);
|
|
||||||
});
|
|
||||||
+9
-27
@@ -1,38 +1,20 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2022",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"useDefineForClassFields": true,
|
||||||
"allowJs": true,
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"types": ["@cloudflare/workers-types"],
|
"allowJs": false,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "Bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"types": ["vite/client", "node"]
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", "vite.config.ts"],
|
||||||
"next-env.d.ts",
|
"exclude": ["node_modules", "dist", "src-tauri/target"]
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts",
|
|
||||||
"**/*.mts"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules", "examples"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+6
-56
@@ -1,59 +1,9 @@
|
|||||||
import vinext from "vinext";
|
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import hostingConfig from "./.openai/hosting.json";
|
import react from "@vitejs/plugin-react";
|
||||||
import { sites } from "./build/sites-vite-plugin";
|
|
||||||
|
|
||||||
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
|
export default defineConfig({
|
||||||
"00000000-0000-4000-8000-000000000000";
|
plugins: [react()],
|
||||||
|
server: { port: 1420, strictPort: true },
|
||||||
const { d1, r2 } = hostingConfig;
|
clearScreen: false,
|
||||||
|
build: { outDir: "dist", emptyOutDir: true },
|
||||||
// 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,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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