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
27 changes: 27 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,33 @@ Status: **Phase 1 (Web) + Phase 2 (Android alarm) selesai 2026-05-29.** Core req
- [x] **Signed release APK** — keystore di `.archive/`, config di `keystore.properties`
- [x] **First-launch MIUI onboarding** — auto-open PermissionsSheet sekali per-user via SharedPreferences

### AI
- [x] **Pindah upstream AI dari GitHub Models ke Groq** — done 2026-07-29
- **Kenapa**: GitHub Models mengumumkan pensiun per **2026-07-30** (header `sunset`,
ketahuan waktu ngerjain `notulen`). Fitur AI Jadwal bakal mati diam-diam kalau dibiarkan.
Kebetulan PAT `models:read`-nya sendiri belum pernah dibuat, jadi tidak ada yang hilang.
- Worker: `UPSTREAM` → `https://api.groq.com/openai/v1/chat/completions`,
model dipin `openai/gpt-oss-120b`, secret `GITHUB_MODELS_TOKEN` → `GROQ_API_KEY`.
Sisa worker (verifikasi Firebase ID token, CORS allowlist, pin model) tidak berubah —
Groq OpenAI-compatible.
- Pilihan model diuji langsung: `llama-3.3-70b-versatile` dan `qwen/qwen3.6-27b`
**menolak** `response_format: json_schema`. Dari dua yang bisa, 120b benar soal tanggal
relatif ("Selasa terdekat" → 2026-08-04) sementara 20b menjawab hari Sabtu.
- **Bug decoder Groq yang ketemu & diakali**: array integer tanpa constraint kehilangan
pemisah antar elemen. "ingatkan 1 hari dan 1 jam sebelum" konsisten balik `[144060]`
(1440 dan 60 nyambung) — lalu di-snap `normalizeEvent` jadi 1440 doang, pengingat kedua
**hilang diam-diam**. Diuji: salah di tiap run dengan `items: {type: integer}`, benar di
tiap run dengan enum string, dan benar juga tanpa `response_format` sama sekali → jadi
decoder-nya, bukan modelnya. Schema sekarang pakai enum string; `normalizeEvent` sudah
`Number()`-in jadi tidak ada perubahan hilir.
- `worker/verify-upstream.sh` — cek yang bisa dijalankan ulang: nembak Groq langsung
(tanpa worker/Firebase) dan meng-assert schema diterima, tanggal relatif benar, dan
pengingat tidak nyambung. Hijau 2026-07-29.
- **BUTUH RAVI**: `npx wrangler secret put GROQ_API_KEY` (key dari console.groq.com,
free tier) lalu `npx wrangler deploy`, dan rebuild + deploy web. Sampai itu dijalankan
fitur AI Jadwal tetap mati — sama seperti sebelumnya, cuma sekarang penyebabnya bukan
layanan yang sudah pensiun.

## Belum

### Operasional / distribusi
Expand Down
5 changes: 5 additions & 0 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# myKalender - Deployment Guide

> **Dokumen ini menggambarkan arsitektur yang sudah ditinggalkan** (Next.js di Vercel +
> Turso + GitHub Models). Yang jalan sekarang: web Vite di Firebase Hosting, data di
> Firestore, AI lewat Cloudflare Worker ke Groq. Untuk langkah deploy yang berlaku lihat
> "Catatan teknis" di `ROADMAP.md` dan `worker/README.md`. Disimpan sebagai catatan sejarah.

Deploy myKalender ke Vercel (free tier). Estimasi waktu: 15-30 menit.

---
Expand Down
4 changes: 4 additions & 0 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# myKalender - Implementation Plan

> **Rencana awal, bukan deskripsi sistem sekarang.** Stack berubah di tengah jalan:
> Next.js/Vercel/Turso/GitHub Models → Vite + Firebase (Hosting, Firestore, Auth) +
> Android native, dengan AI lewat Cloudflare Worker ke Groq. `ROADMAP.md` yang berlaku.

> Step-by-step implementation plan untuk MVP. Mengacu ke [PRD](./PRD.md) dan [DESIGN](../DESIGN.md). Estimasi total 2-3 minggu.

---
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/AiChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ export function AiChatPanel({ onClose }: AiChatPanelProps) {
AI Jadwal
</p>
<p className="text-[10px] text-muted-soft mt-0.5">
gpt-4o-mini via GitHub Models
gpt-oss-120b via Groq
</p>
</div>
</div>
Expand Down
34 changes: 27 additions & 7 deletions web/src/lib/ai-schedule.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// Client for natural-language schedule parsing.
// The GitHub Models PAT is NOT in the client bundle: requests go through a
// Cloudflare Worker proxy (see /worker) that holds the PAT server-side and
// The upstream API key is NOT in the client bundle: requests go through a
// Cloudflare Worker proxy (see /worker) that holds the key server-side and
// verifies the caller's Firebase ID token. VITE_AI_PROXY_URL is the worker URL
// (public, not a secret).

import { auth } from "./firebase";

const MODEL = "openai/gpt-4o-mini";
// Groq, since GitHub Models was retired on 2026-07-30. The worker pins the
// model server-side too; this constant only has to agree with it.
const MODEL = "openai/gpt-oss-120b";
const TIMEZONE = "Asia/Makassar";

export type AiRecurrence = "none" | "daily" | "weekdays" | "weekly" | "monthly";
Expand All @@ -22,6 +24,11 @@ export interface AiParsedEvent {
reminderOffsetsMinutes: number[]; // each 0/5/10/20/30/60/1440
}

/** What the model actually returns: reminder offsets arrive as strings. */
type AiRawEvent = Omit<AiParsedEvent, "reminderOffsetsMinutes"> & {
reminderOffsetsMinutes: string[];
};

interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
Expand Down Expand Up @@ -55,11 +62,24 @@ const EVENT_SCHEMA = {
type: "string",
enum: ["none", "daily", "weekdays", "weekly", "monthly"],
},
// Strings, not integers, and that is deliberate. Groq's constrained
// decoder drops the separator between elements of an unconstrained
// integer array: asking for two reminders ("1 hari dan 1 jam
// sebelum") reliably returns [144060] instead of [1440, 60], which
// normalizeEvent then snaps to a single 1440 and the second reminder
// is silently lost. Verified 2026-07-29: reproducible on every run
// with `items: { type: "integer" }`, correct on every run with an
// enum of strings, and correct with no response_format at all, so it
// is the decoder rather than the model. normalizeEvent already
// coerces with Number(), so nothing downstream cares.
reminderOffsetsMinutes: {
type: "array",
items: { type: "integer" },
items: {
type: "string",
enum: ["0", "5", "10", "20", "30", "60", "1440"],
},
description:
"One or more reminders, minutes before start (each: 0, 5, 10, 20, 30, 60, or 1440). Usually one; use multiple only when the user asks for several alerts.",
"One or more reminders, minutes before start, one entry per reminder. Usually one; use multiple only when the user asks for several alerts.",
},
},
required: [
Expand Down Expand Up @@ -180,7 +200,7 @@ export async function parseSchedulePrompt(
const content = json.choices?.[0]?.message?.content;
if (!content) throw new Error("Respons AI kosong");

let parsed: { events?: AiParsedEvent[] };
let parsed: { events?: AiRawEvent[] };
try {
parsed = JSON.parse(content);
} catch {
Expand All @@ -195,7 +215,7 @@ const VALID_OFFSETS = [0, 5, 10, 20, 30, 60, 1440];
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const TIME_RE = /^\d{2}:\d{2}$/;

function normalizeEvent(e: AiParsedEvent): AiParsedEvent {
function normalizeEvent(e: AiRawEvent): AiParsedEvent {
if (!DATE_RE.test(e.startDate)) {
throw new Error(`AI mengembalikan tanggal tidak valid: ${e.startDate}`);
}
Expand Down
2 changes: 1 addition & 1 deletion web/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ function Features() {
icon: <Sparkles size={20} />,
title: "AI Schedule Generator",
body:
"Ketik aktivitas mingguan kamu, AI parse jadi event terstruktur. Powered by gpt-4o-mini via GitHub Models.",
"Ketik aktivitas mingguan kamu, AI parse jadi event terstruktur. Powered by gpt-oss-120b via Groq.",
},
{
icon: <Users size={20} />,
Expand Down
49 changes: 40 additions & 9 deletions worker/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
# mykalender-ai — AI proxy (Cloudflare Worker)

Server-side proxy for the AI Schedule Generator. Keeps the GitHub Models PAT
off the client: the web app sends a Firebase ID token, the worker verifies it
and forwards the request to GitHub Models with the secret PAT.
Server-side proxy for the AI Schedule Generator. Keeps the upstream API key off
the client: the web app sends a Firebase ID token, the worker verifies it and
forwards the request upstream with the secret key.

Upstream is **Groq** (`openai/gpt-oss-120b`). It was GitHub Models until
2026-07-29; GitHub announced that service's retirement for 2026-07-30, so it
moved. Groq is also free-tier and OpenAI-compatible, so only the base URL, the
model name and the secret name changed.

## Deploy (one-time)

Needs a (free) Cloudflare account and a GitHub PAT with scope `models:read`
(create at https://github.com/settings/tokens).
Needs a (free) Cloudflare account and a Groq API key from
https://console.groq.com (free tier, no card).

```bash
cd worker
Expand All @@ -16,8 +21,8 @@ npm install
# 1. Log in to Cloudflare (opens a browser)
npx wrangler login

# 2. Store the GitHub PAT as a secret (paste when prompted)
npx wrangler secret put GITHUB_MODELS_TOKEN
# 2. Store the Groq key as a secret (paste when prompted)
npx wrangler secret put GROQ_API_KEY

# 3. Deploy — prints the worker URL, e.g.
# https://mykalender-ai.<your-subdomain>.workers.dev
Expand All @@ -37,11 +42,37 @@ firebase deploy --only hosting`).

- `wrangler.toml [vars]` — `FIREBASE_PROJECT_ID` and `ALLOWED_ORIGINS`
(comma-separated CORS allowlist). Not secret.
- `GITHUB_MODELS_TOKEN` — the PAT, stored as a Wrangler secret.
- `GROQ_API_KEY` — the Groq key, stored as a Wrangler secret.

## Local dev

```bash
echo "GITHUB_MODELS_TOKEN=ghp_xxx" > .dev.vars # gitignored
echo "GROQ_API_KEY=gsk_xxx" > .dev.vars # gitignored
npx wrangler dev
```

## Checking the upstream

`./verify-upstream.sh` sends the real schema and a real Indonesian prompt
straight to Groq, bypassing the worker and Firebase. It is the fastest way to
tell "the model or the API changed" apart from "our auth broke".

```bash
GROQ_API_KEY=gsk_xxx ./verify-upstream.sh
```

## Two things that look wrong until you know why

**Reminder offsets are strings in the JSON schema, not integers.** Groq's
constrained decoder drops the separator between elements of an unconstrained
integer array. Asking for two reminders returns `[144060]` instead of
`[1440, 60]`, and the client then snaps that to a single 1440 — the second
reminder disappears silently. With an enum of strings it is correct on every
run, and with no `response_format` at all it is also correct, so the fault is
in the decoder rather than the model. `normalizeEvent` coerces with `Number()`,
so nothing downstream noticed the change. Do not "tidy" these back to integers.

**Only the gpt-oss models are candidates.** `llama-3.3-70b-versatile` and
`qwen/qwen3.6-27b` reject `response_format: json_schema` outright. Between the
two that work, 120b got relative dates ("Selasa terdekat") right where 20b
returned a Saturday.
22 changes: 15 additions & 7 deletions worker/src/index.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
// Cloudflare Worker: server-side proxy for the GitHub Models AI feature.
// Cloudflare Worker: server-side proxy for the AI Schedule Generator.
//
// Keeps the GitHub PAT (GITHUB_MODELS_TOKEN secret) off the client. The web
// Keeps the upstream API key (GROQ_API_KEY secret) off the client. The web
// app sends a Firebase ID token instead; this worker verifies it against
// Google's public keys so only signed-in users of *this* Firebase project can
// use the proxy (the worker URL itself is public, in the client bundle).

const UPSTREAM = "https://models.github.ai/inference/chat/completions";
const MODEL = "openai/gpt-4o-mini";
//
// Upstream was GitHub Models until 2026-07-29. GitHub Models announced its
// retirement for 2026-07-30 (a `sunset` response header), so this moved to
// Groq's OpenAI-compatible endpoint, which is also free-tier and already holds
// the key used elsewhere. Do not go back.

const UPSTREAM = "https://api.groq.com/openai/v1/chat/completions";
// Of the models Groq serves, the gpt-oss pair are the ones that accept
// `response_format: json_schema`. 120b beat 20b on relative-date arithmetic
// ("Selasa terdekat") in side-by-side runs, so it is the one pinned here.
const MODEL = "openai/gpt-oss-120b";
const JWK_URL =
"https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com";

Expand All @@ -21,7 +29,7 @@ export default {
if (request.method !== "POST") {
return json({ error: "Method not allowed" }, 405, cors);
}
if (!env.GITHUB_MODELS_TOKEN) {
if (!env.GROQ_API_KEY) {
return json({ error: "Proxy belum dikonfigurasi (secret kosong)" }, 500, cors);
}

Expand Down Expand Up @@ -49,7 +57,7 @@ export default {
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: {
Authorization: `Bearer ${env.GITHUB_MODELS_TOKEN}`,
Authorization: `Bearer ${env.GROQ_API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
},
Expand Down
118 changes: 118 additions & 0 deletions worker/verify-upstream.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Send the real schema and a real Indonesian prompt straight to Groq, skipping
# the worker and Firebase, then assert the parts that have actually broken
# before: the model must accept `response_format: json_schema`, and multiple
# reminders must come back as separate entries rather than one concatenated
# number (see README, "Two things that look wrong until you know why").
#
# Usage: GROQ_API_KEY=gsk_xxx ./verify-upstream.sh
set -euo pipefail

: "${GROQ_API_KEY:?set GROQ_API_KEY (console.groq.com) before running}"
MODEL="${MODEL:-openai/gpt-oss-120b}"

# A Wednesday, so "Selasa terdekat" is unambiguous and checkable.
TODAY="2026-07-29"

request=$(MODEL="$MODEL" TODAY="$TODAY" python3 - <<'PY'
import json, os

schema = {
"type": "object",
"additionalProperties": False,
"properties": {
"events": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"title": {"type": "string"},
"description": {"type": ["string", "null"]},
"startDate": {"type": "string"},
"startTime": {"type": "string"},
"endTime": {"type": "string"},
"allDay": {"type": "boolean"},
"recurrence": {
"type": "string",
"enum": ["none", "daily", "weekdays", "weekly", "monthly"],
},
"reminderOffsetsMinutes": {
"type": "array",
"items": {
"type": "string",
"enum": ["0", "5", "10", "20", "30", "60", "1440"],
},
},
},
"required": [
"title", "description", "startDate", "startTime",
"endTime", "allDay", "recurrence", "reminderOffsetsMinutes",
],
},
}
},
"required": ["events"],
}

system = (
"Kamu adalah AI scheduling assistant untuk myKalender, aplikasi kalender "
"personal user Indonesia.\n"
f"Tanggal hari ini: {os.environ['TODAY']} (Rabu), zona waktu Asia/Makassar "
"(WITA, UTC+8).\n"
"Mode: FRESH. Parse deskripsi natural language dari user jadi daftar event "
"terstruktur.\n\n"
"Aturan:\n"
"- Output WAJIB valid JSON sesuai schema. Field tambahan dilarang.\n"
"- startDate: ISO YYYY-MM-DD di zona waktu user.\n"
"- startTime/endTime: HH:mm 24-jam. Untuk allDay, set startTime=00:00 endTime=23:59.\n"
"- Jika user nyebut hari tanpa tanggal (misal 'Senin'), pakai Senin terdekat ke depan.\n"
"- reminderOffsetsMinutes: menit sebelum mulai, satu entri per pengingat. Default ['20'].\n"
)
user = (
"kuliah STKI tiap Selasa jam 10 pagi sampai 12 siang di ruang 301, "
"ingatkan 1 hari dan 1 jam sebelum."
)

print(json.dumps({
"model": os.environ["MODEL"],
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"response_format": {
"type": "json_schema",
"json_schema": {"name": "schedule_events", "strict": True, "schema": schema},
},
"temperature": 0.2,
}))
PY
)

response=$(curl -sS https://api.groq.com/openai/v1/chat/completions \
-H "Authorization: Bearer $GROQ_API_KEY" \
-H "Content-Type: application/json" \
-d "$request" --max-time 90)

echo "$response" | python3 -c '
import json, sys

body = json.load(sys.stdin)
if "choices" not in body:
sys.exit("upstream rejected the request: " + json.dumps(body)[:500])

events = json.loads(body["choices"][0]["message"]["content"])["events"]
print(json.dumps(events, indent=2, ensure_ascii=False))

assert events, "no events parsed"
first = events[0]
assert first["startDate"] == "2026-08-04", (
"relative date wrong: nearest Selasa after 2026-07-29 is 2026-08-04, got "
+ first["startDate"]
)
offsets = first["reminderOffsetsMinutes"]
assert sorted(offsets, key=int) == ["60", "1440"], (
"two reminders should stay two entries, got " + repr(offsets)
)
print("\nOK: schema accepted, relative date correct, reminders not concatenated.")
'
4 changes: 2 additions & 2 deletions worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ name = "mykalender-ai"
main = "src/index.js"
compatibility_date = "2025-04-01"

# Non-secret config. The GitHub PAT is stored as a secret instead:
# npx wrangler secret put GITHUB_MODELS_TOKEN
# Non-secret config. The Groq API key is stored as a secret instead:
# npx wrangler secret put GROQ_API_KEY
[vars]
FIREBASE_PROJECT_ID = "mykalender-cad8f"
ALLOWED_ORIGINS = "https://mykalender-cad8f.web.app,https://mykalender-cad8f.firebaseapp.com,http://localhost:5173"
Loading