Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=AI...
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ node_modules/
.env
out/
.shape-build/
.vercel
9 changes: 9 additions & 0 deletions .notmagic.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
notmagic:
mode: advisory
checks:
release_readiness: true
ownership_claims: true
observability: true
rollback_scope_split: true
comment: true
check_run: true
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!-- VERCEL BEST PRACTICES START -->
## Best practices for developing on Vercel

These defaults are optimized for AI coding agents (and humans) working on apps that deploy to Vercel.

- Treat Vercel Functions as stateless + ephemeral (no durable RAM/FS, no background daemons), use Blob or marketplace integrations for preserving state
- Edge Functions (standalone) are deprecated; prefer Vercel Functions
- Don't start new projects on Vercel KV/Postgres (both discontinued); use Marketplace Redis/Postgres instead
- Store secrets in Vercel Env Variables; not in git or `NEXT_PUBLIC_*`
- Provision Marketplace native integrations with `vercel integration add` (CI/agent-friendly)
- Sync env + project settings with `vercel env pull` / `vercel pull` when you need local/offline parity
- Use `waitUntil` for post-response work; avoid the deprecated Function `context` parameter
- Set Function regions near your primary data source; avoid cross-region DB/service roundtrips
- Tune Fluid Compute knobs (e.g., `maxDuration`, memory/CPU) for long I/O-heavy calls (LLMs, APIs)
- Use Runtime Cache for fast **regional** caching + tag invalidation (don't treat it as global KV)
- Use Cron Jobs for schedules; cron runs in UTC and triggers your production URL via HTTP GET
- Use Vercel Blob for uploads/media; Use Edge Config for small, globally-read config
- If Enable Deployment Protection is enabled, use a bypass secret to directly access them
- Add OpenTelemetry via `@vercel/otel` on Node; don't expect OTEL support on the Edge runtime
- Enable Web Analytics + Speed Insights early
- Use AI Gateway for model routing, set AI_GATEWAY_API_KEY, using a model string (e.g. 'anthropic/claude-sonnet-4.6'), Gateway is already default in AI SDK
needed. Always curl https://ai-gateway.vercel.sh/v1/models first; never trust model IDs from memory
- For durable agent loops or untrusted code: use Workflow (pause/resume/state) + Sandbox; use Vercel MCP for secure infra access
<!-- VERCEL BEST PRACTICES END -->
16 changes: 14 additions & 2 deletions app/api/shape/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { shape } from "@/lib/shape";
import type { ShapeEngine, ShapeProfile } from "@/lib/types";

const VALID_PROFILES = ["narrative_segment_v0", "concept_blob_v0"];
const VALID_ENGINES = ["openai", "local"];
const VALID_ENGINES = ["openai", "anthropic", "gemini", "local"];

export async function POST(request: NextRequest) {
try {
Expand All @@ -28,7 +28,19 @@ export async function POST(request: NextRequest) {
? (engine as ShapeEngine)
: "openai";

const result = await shape(text, profileOverride, engineOverride);
let result;
try {
result = await shape(text, profileOverride, engineOverride);
} catch (primaryErr) {
// If an LLM engine failed, fall back to local with a note
if (engineOverride !== "local") {
result = await shape(text, profileOverride, "local");
result.engine = "local" as ShapeEngine;
result.fallback_reason = primaryErr instanceof Error ? primaryErr.message : "LLM unavailable";
} else {
throw primaryErr;
}
}
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
Expand Down
133 changes: 133 additions & 0 deletions app/history/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"use client";

import { useEffect, useState } from "react";
import { AuthProvider, useAuth } from "@/components/auth-provider";
import { UserMenu } from "@/components/user-menu";
import { listShapes } from "@/lib/save-shape";
import type { ShapeResult } from "@/lib/types";

interface ShapeRow {
id: string;
title: string;
profile: string;
engine: string;
signal_level: string;
created_at: string;
result: ShapeResult;
}

function HistoryList() {
const { user, loading: authLoading } = useAuth();
const [shapes, setShapes] = useState<ShapeRow[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
if (authLoading) return;
if (!user) {
setLoading(false);
return;
}
listShapes()
.then((data) => setShapes(data as ShapeRow[]))
.finally(() => setLoading(false));
}, [user, authLoading]);

if (authLoading || loading) {
return <p className="text-sm text-neutral-600">Loading…</p>;
}

if (!user) {
return (
<p className="text-sm text-neutral-500">
<a href="/" className="text-neutral-400 hover:text-neutral-200 transition-colors underline">
Sign in
</a>{" "}
to see your saved shapes.
</p>
);
}

if (shapes.length === 0) {
return (
<p className="text-sm text-neutral-500">
No shapes yet.{" "}
<a href="/" className="text-neutral-400 hover:text-neutral-200 transition-colors underline">
Shape something
</a>.
</p>
);
}

return (
<div className="space-y-4">
{shapes.map((s) => {
const spine = s.result?.spine ?? [];
return (
<a
key={s.id}
href={`/s/${s.id}`}
className="block border border-neutral-800 rounded-lg p-4 hover:border-neutral-600 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-neutral-200 truncate mr-4">{s.title}</h3>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-mono text-neutral-600">
{s.profile === "narrative_segment_v0" ? "narrative" : "concept"}
</span>
<span className={`text-xs font-mono ${
s.signal_level === "strong" ? "text-green-500" :
s.signal_level === "weak" ? "text-yellow-500" : "text-red-500"
}`}>
{s.signal_level}
</span>
<span className="text-xs font-mono text-neutral-700">
{s.engine === "local" ? "local" : s.engine === "anthropic" ? "claude" : s.engine === "gemini" ? "gemini" : "gpt-4.1"}
</span>
</div>
</div>
{spine.length > 0 && (
<div className="space-y-1 mb-2">
{spine.slice(0, 3).map((line, i) => (
<p key={i} className="text-xs text-neutral-500 leading-relaxed border-l border-neutral-800 pl-2 truncate">
{line}
</p>
))}
{spine.length > 3 && (
<p className="text-xs text-neutral-700 pl-2">+{spine.length - 3} more</p>
)}
</div>
)}
<p className="text-xs text-neutral-700">
{new Date(s.created_at).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit",
})}
</p>
</a>
);
})}
</div>
);
}

export default function HistoryPage() {
return (
<AuthProvider>
<main className="min-h-screen flex flex-col items-center px-4 py-16">
<UserMenu />
<div className="w-full max-w-3xl">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold text-neutral-100">History</h1>
<a
href="/"
className="text-xs font-mono text-neutral-600 hover:text-neutral-400 transition-colors"
>
back to shape
</a>
</div>
<HistoryList />
</div>
</main>
</AuthProvider>
);
}
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import "./globals.css";

export const metadata: Metadata = {
title: "Shape",
description: "Paste text. Get structure. See what survived.",
description: "Paste text, get structured meaning.",
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
};

export default function RootLayout({
Expand Down
85 changes: 51 additions & 34 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,65 @@
import { useState } from "react";
import { ShapeForm } from "@/components/shape-form";
import { ShapeResult as ShapeResultView } from "@/components/shape-result";
import { AuthProvider } from "@/components/auth-provider";
import { UserMenu } from "@/components/user-menu";
import type { ShapeResult } from "@/lib/types";

export default function Home() {
const [result, setResult] = useState<ShapeResult | null>(null);
const [sourceText, setSourceText] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

return (
<main className="min-h-screen flex flex-col items-center px-4 py-16 relative">
{/* Top progress bar */}
{loading && (
<div className="fixed top-0 left-0 right-0 h-0.5 z-50">
<div className="h-full bg-neutral-400 animate-pulse" style={{ width: "100%" }} />
<AuthProvider>
<main className="min-h-screen flex flex-col items-center px-3 sm:px-4 py-8 sm:py-16 relative">
{loading && (
<div className="fixed top-0 left-0 right-0 h-0.5 z-50">
<div className="h-full bg-neutral-400 animate-pulse" style={{ width: "100%" }} />
</div>
)}

<UserMenu />

<div className="w-full max-w-3xl mb-6 sm:mb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-100">
Shape
</h1>
<p className="text-neutral-500 mt-1 sm:mt-2 text-sm sm:text-base">
Paste text, get structured meaning.
</p>
</div>
<a
href="/history"
className="text-xs font-mono px-3 py-1.5 rounded border border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-600 transition-colors"
>
History
</a>
</div>
</div>
)}

<div className="w-full max-w-3xl text-center mb-12">
<h1 className="text-4xl font-bold tracking-tight text-neutral-100">
Shape
</h1>
<p className="text-neutral-500 mt-3 text-lg">
Paste text. Get structure. See what survived.
</p>
</div>

<ShapeForm
onResult={(data) => {
setResult(data as ShapeResult);
setError("");
}}
onError={(msg) => {
setError(msg);
setResult(null);
}}
onLoading={setLoading}
/>

{error && (
<p className="mt-6 text-sm text-red-400 text-center">{error}</p>
)}

{result && <ShapeResultView result={result} />}
</main>

<ShapeForm
onResult={(data, text) => {
setResult(data as ShapeResult);
setSourceText(text);
setError("");
}}
onError={(msg) => {
setError(msg);
setResult(null);
}}
onLoading={setLoading}
/>

{error && (
<p className="mt-6 text-sm text-red-400 text-center">{error}</p>
)}

{result && <ShapeResultView result={result} sourceText={sourceText} />}
</main>
</AuthProvider>
);
}
52 changes: 52 additions & 0 deletions app/s/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ShapeResult as ShapeResultView } from "@/components/shape-result";
import { AuthProvider } from "@/components/auth-provider";
import { UserMenu } from "@/components/user-menu";
import type { ShapeResult } from "@/lib/types";
import { getSupabase } from "@/lib/supabase";

export default function ShapePermalink() {
const { id } = useParams<{ id: string }>();
const [result, setResult] = useState<ShapeResult | null>(null);
const [sourceText, setSourceText] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);

useEffect(() => {
if (!id) return;
const supabase = getSupabase();
supabase
.from("shapes")
.select("result, source_text")
.eq("id", id)
.single()
.then(({ data, error: err }: { data: { result: ShapeResult; source_text: string } | null; error: unknown }) => {
if (err || !data) {
setError("Shape not found");
} else {
setResult(data.result as ShapeResult);
setSourceText(data.source_text);
}
setLoading(false);
});
}, [id]);

return (
<AuthProvider>
<main className="min-h-screen flex flex-col items-center px-4 py-16 relative">
<UserMenu />
<div className="w-full max-w-3xl text-center mb-8">
<a href="/" className="text-2xl font-bold tracking-tight text-neutral-100 hover:text-white transition-colors">
Shape
</a>
</div>
{loading && <p className="text-sm text-neutral-600">Loading…</p>}
{error && <p className="text-sm text-red-400">{error}</p>}
{result && <ShapeResultView result={result} sourceText={sourceText} />}
</main>
</AuthProvider>
);
}
Loading