Skip to content
Merged
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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
name: 테스트 및 Worker 빌드
runs-on: ubuntu-latest
timeout-minutes: 15
env:
NEXT_TELEMETRY_DISABLED: "1"
WRANGLER_SEND_METRICS: "false"
CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV: "false"
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm test
- run: npm run lint
- run: npx next typegen
- run: npx tsc --noEmit
- run: npm run build
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ npm run dev
Open <http://localhost:3000>. English is served without a prefix and Korean is
served under `/ko`.

`next dev` does not initialize Cloudflare bindings, so local development does not
require Cloudflare authentication or invoke remote Workers AI. The assistant
uses its deterministic fallback without the live inference configuration.

## OMM AI assistant

`/assistant` and `/ko/assistant` provide a constrained OMM command selector.
Expand All @@ -36,6 +40,18 @@ npx tsc --noEmit
npm run build
```

`npm run build` runs the Next.js build once through OpenNext and produces the
Cloudflare Worker bundle in `.open-next/`.

Pull requests and pushes to `main` run tests, lint, type checking and the Worker
build in GitHub Actions. The workflow uses no Cloudflare credentials and does
not deploy or call Workers AI.

The navigation badge reads the published `omm-model` version from PyPI. It is
hidden if that request fails; the development version on GitHub `main` is not
presented as a release. The footer commit is embedded at build time from
Cloudflare Workers/Pages, GitHub Actions, Vercel, or the local Git checkout.

To compare the website command manifest against a trusted current local OMM
checkout without network access:

Expand Down
9 changes: 7 additions & 2 deletions docs/assistant-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

The assistant is a constrained command selector, not a general chatbot. The
browser sends `{ locale, question, turnCount }`. The server narrows the
question to at most five entries derived from the existing command docs. A
question to at most eight entries derived from the existing command docs. A
Workers AI model may return only one candidate `commandId` or `clarify`.
Options, examples, risk labels, links, and shell text always come from the
static OMM command catalog.

## Runtime contract

- `question`: 1–480 Unicode code points.
- Request bodies are bounded while streaming, including uploads without a
`Content-Length` header. Invalid or interrupted uploads receive HTTP 400.
- `turnCount`: integer 0–2 (three browser questions at most).
- Model output: at most 48 completion tokens, non-streaming, temperature 0, five-second
application timeout, no retry.
Expand All @@ -20,14 +22,17 @@ static OMM command catalog.
- `429`, capacity, timeout, model, binding, database, and invalid-JSON failures
fall back to ordinary static command search without a retry.

`wrangler.jsonc` contains only the Workers AI binding:
`wrangler.jsonc` declares the Workers AI binding alongside the D1 and OpenNext
bindings:

```json
"ai": { "binding": "AI" }
```

The route reads the binding through OpenNext's `getCloudflareContext()` and
calls `env.AI.run()`. No browser-visible API key exists.
`next dev` does not initialize Cloudflare bindings, so it neither starts a remote preview
session nor invokes paid inference during ordinary local development.

## Model choice

Expand Down
15 changes: 10 additions & 5 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import type { NextConfig } from "next";
import { execFileSync } from "node:child_process";

import { buildCommitSha } from "./src/lib/site-metadata";

const nextConfig: NextConfig = {
/* config options here */
// Cloudflare bindings are supplied by the Worker. Plain `next dev` uses the
// assistant route's deterministic fallback without opening remote sessions.
env: {
OMM_BUILD_SHA: buildCommitSha(process.env, () =>
execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }),
),
},
};

export default nextConfig;

if (process.env.NODE_ENV === "development") {
import('@opennextjs/cloudflare').then(m => m.initOpenNextCloudflareForDev());
}
5 changes: 2 additions & 3 deletions open-next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ const cloudflareConfig = {
incrementalCache: staticAssetsIncrementalCache,
enableCacheInterception: true,
}),
// Without this, `opennextjs-cloudflare build` re-runs the project's own
// "build" npm script (`next build && opennextjs-cloudflare build`),
// recursing into itself forever.
// Keep the framework build explicit: the package's "build" script invokes
// OpenNext, so falling back to that script would recurse into itself.
buildCommand: "next build",
};

Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build && opennextjs-cloudflare build",
"build": "opennextjs-cloudflare build",
"dev": "next dev",
"start": "next start",
"lint": "eslint",
Expand Down
16 changes: 3 additions & 13 deletions src/app/api/assistant/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,10 @@ import {
D1AssistantStore,
type D1DatabaseLike,
} from "../../../lib/assistant/budget";
import { parseAssistantRequestText } from "../../../lib/assistant/request";
import { parseAssistantRequest } from "../../../lib/assistant/request";
import { rateLimitIdentity } from "../../../lib/assistant/security";
import { answerAssistantQuestion } from "../../../lib/assistant/service";
import {
ASSISTANT_LIMITS,
type AssistantResponse,
} from "../../../lib/assistant/types";
import type { AssistantResponse } from "../../../lib/assistant/types";
import type { WorkersAiBinding } from "../../../lib/assistant/workers-ai";

type AssistantEnv = CloudflareEnv & {
Expand Down Expand Up @@ -42,14 +39,7 @@ function json(result: AssistantResponse, status = 200): Response {
}

export async function POST(request: Request): Promise<Response> {
const declaredLength = Number(request.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > ASSISTANT_LIMITS.maxBodyCharacters * 4
) {
return json(invalidRequest(), 400);
}
const parsed = parseAssistantRequestText(await request.text());
const parsed = await parseAssistantRequest(request);
if (!parsed.ok) return json(invalidRequest(), 400);

let env: AssistantEnv | undefined;
Expand Down
6 changes: 2 additions & 4 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,8 @@ function Column({
export default function Footer({ locale }: { locale: Locale }) {
const t = getDictionary(locale).footer;

/* DIRECTION.md §4.7: the bottom row carries the build's real commit
short-SHA. Vercel injects it at build time; locally it is absent, and the
row then renders repo + license only rather than a fake placeholder. */
const sha = process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7);
// next.config resolves the Cloudflare/CI/local commit while building.
const sha = process.env.OMM_BUILD_SHA?.slice(0, 7);

return (
<footer className="border-t border-line-0 bg-bg-0 py-16">
Expand Down
46 changes: 18 additions & 28 deletions src/components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,37 +16,27 @@ import {
type Locale,
} from "@/i18n/config";
import { getDictionary } from "@/i18n/dictionaries";
import { publishedVersion, PYPI_RELEASE_URL } from "@/lib/site-metadata";

/** Fallback shown until the live fetch below resolves, or if it fails. Keep
* this roughly current — it's a fallback, not a source of truth. */
const FALLBACK_VERSION = "v0.2.148";
const REPO = "https://github.com/omm-hippo/omm";
const PYPROJECT_RAW_URL =
"https://raw.githubusercontent.com/omm-hippo/omm/main/pyproject.toml";

/** Reads `version = "X.Y.Z"` straight off origin/main's `pyproject.toml` so
* the badge never drifts from what's actually published. Client-side only:
* raw.githubusercontent.com is a CDN, not the rate-limited GitHub API, so a
* per-visitor fetch is fine. Falls back to FALLBACK_VERSION on any failure. */
function useLiveVersion(): string {
const [version, setVersion] = useState(FALLBACK_VERSION);
/** Main can be ahead of release. Show only PyPI's confirmed package version. */
function useLiveVersion(): string | null {
const [version, setVersion] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
const controller = new AbortController();

fetch(PYPROJECT_RAW_URL, { cache: "no-store" })
.then((res) => (res.ok ? res.text() : Promise.reject(res.status)))
.then((text) => {
const match = text.match(/^version\s*=\s*"([^"]+)"/m);
if (match && !cancelled) setVersion(`v${match[1]}`);
fetch(PYPI_RELEASE_URL, { signal: controller.signal })
.then((res) => (res.ok ? res.json() : Promise.reject(res.status)))
.then((data: unknown) => {
if (!controller.signal.aborted) setVersion(publishedVersion(data));
})
.catch(() => {
/* keep FALLBACK_VERSION */
// A missing badge is preferable to claiming an unverified version.
});

return () => {
cancelled = true;
};
return () => controller.abort();
}, []);

return version;
Expand All @@ -60,10 +50,8 @@ const LINK =
"border-b border-transparent pb-0.5 text-small text-ink-2 transition-colors duration-[120ms] ease-micro hover:border-accent hover:text-ink-0";

/**
* Remembers the reader's choice so the `Accept-Language` redirect in
* `src/proxy.ts` never overrides it on a later visit. Written from the click
* rather than from a server action: the toggle is a plain link, so the
* navigation itself is what renders the other language.
* Records the explicit language choice. The link itself selects the locale;
* middleware keeps first visits in English regardless of Accept-Language.
*/
function rememberLocale(locale: Locale) {
document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=${LOCALE_COOKIE_MAX_AGE}; samesite=lax`;
Expand Down Expand Up @@ -146,9 +134,11 @@ export default function Nav({ locale }: { locale: Locale }) {
prefetch={false}
>
<span className="font-mono text-[15px] font-medium lowercase text-ink-0">omm</span>
<span className="rounded-sm border border-line-1 px-1.5 py-0.5 font-mono text-[11px] leading-none text-ink-3">
{version}
</span>
{version ? (
<span className="rounded-sm border border-line-1 px-1.5 py-0.5 font-mono text-[11px] leading-none text-ink-3">
{version}
</span>
) : null}
</Link>

<nav className="ml-8 hidden items-center gap-6 lg:flex">
Expand Down
16 changes: 10 additions & 6 deletions src/components/assistant/AssistantClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import type {
import type { Slug } from "@/components/commands/commands";
import { localeHref, type Locale } from "@/i18n/config";
import { fill, getDictionary } from "@/i18n/dictionaries";
import { ASSISTANT_LIMITS } from "@/lib/assistant/types";

const MAX_QUESTION_LENGTH = 480;
const MAX_TURNS = 3;
const MAX_QUESTION_LENGTH = ASSISTANT_LIMITS.maxQuestionCharacters;
const MAX_TURNS = ASSISTANT_LIMITS.maxTurns;
const REQUEST_TIMEOUT_MS = 7_000;
const KINDS = new Set<AssistantKind>(["command", "clarify", "fallback"]);
const SOURCES = new Set<AssistantSource>(["workers-ai", "deterministic"]);
Expand Down Expand Up @@ -76,7 +77,11 @@ function parseResponse(
if (!(value.commandId === null || typeof value.commandId === "string")) {
return null;
}
if (!Array.isArray(value.candidateIds) || value.candidateIds.length > 3) {
if (
!Array.isArray(value.candidateIds) ||
value.candidateIds.length > ASSISTANT_LIMITS.maxSuggestions ||
value.candidateIds.some((id) => typeof id !== "string" || !allowedIds.has(id))
) {
return null;
}

Expand Down Expand Up @@ -191,11 +196,10 @@ export default function AssistantClient({
id="assistant-question"
value={question}
onChange={(event) => {
setQuestion(event.target.value.slice(0, MAX_QUESTION_LENGTH));
setQuestion(Array.from(event.target.value).slice(0, MAX_QUESTION_LENGTH).join(""));
if (error) setError(null);
}}
placeholder={t.placeholder}
maxLength={MAX_QUESTION_LENGTH}
rows={6}
aria-describedby="assistant-privacy assistant-counter"
disabled={turnLimitReached}
Expand All @@ -204,7 +208,7 @@ export default function AssistantClient({
<div className="flex items-center justify-between border-t border-line-0 px-4 py-3">
<span id="assistant-counter" className="text-label">
{fill(t.counter, {
count: String(question.length),
count: String(Array.from(question).length),
max: String(MAX_QUESTION_LENGTH),
})}
</span>
Expand Down
28 changes: 8 additions & 20 deletions src/components/assistant/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { Slug } from "@/components/commands/commands";
import type { CommandRisk } from "@/i18n/commands/base";
import type { AssistantResponse } from "@/lib/assistant/types";

export type {
AssistantKind,
AssistantSource,
AssistantReason,
} from "@/lib/assistant/types";

export type AssistantCommandCard = {
readonly id: Slug;
Expand All @@ -12,25 +19,6 @@ export type AssistantCommandCard = {
readonly risk: CommandRisk;
};

export type AssistantKind = "command" | "clarify" | "fallback";
export type AssistantSource = "workers-ai" | "deterministic";
export type AssistantReason =
| "matched"
| "ambiguous"
| "no_match"
| "sensitive_input"
| "unsafe_input"
| "openai_model_ambiguous"
| "rate_limited"
| "daily_cap"
| "not_configured"
| "provider_unavailable"
| "invalid_response"
| "invalid_request";

export type AssistantResult = {
readonly kind: AssistantKind;
readonly source: AssistantSource;
readonly reason: AssistantReason;
export type AssistantResult = Pick<AssistantResponse, "kind" | "source" | "reason"> & {
readonly commandIds: readonly Slug[];
};
Loading
Loading