diff --git a/.env.example b/.env.example index 2e2f212e..76456d30 100644 --- a/.env.example +++ b/.env.example @@ -119,6 +119,10 @@ ORA_ENV=production ORA_WEB_API_TOKEN= ORA_REQUIRE_WEB_API_TOKEN=0 ORA_TRUST_PROXY_HEADERS_FOR_LOCAL_AUTH=0 +# Managed Cloud MVP chat limits +# - guest limit is only meaningful when guest chat is explicitly enabled +ORA_WEB_GUEST_DAILY_LIMIT=3 +ORA_WEB_GOOGLE_DAILY_LIMIT=20 ADMIN_DASHBOARD_TOKEN= ALLOW_INSECURE_ADMIN_DASHBOARD=0 diff --git a/.gitignore b/.gitignore index b54c2ae7..8d2bb7b8 100644 --- a/.gitignore +++ b/.gitignore @@ -57,12 +57,38 @@ AGENT_MEMORY.md # Private / non-distribution content (keep out of public releases) # Website/marketing drafts and platform console pages live here during development. src/web/static/* +!src/web/static/about.html +!src/web/static/architecture.html +!src/web/static/clients.html +!src/web/static/contact.html +!src/web/static/desktop-discord.html +!src/web/static/developers.html !src/web/static/index.html +!src/web/static/linked.html +!src/web/static/login.html +!src/web/static/platform-preview.html +!src/web/static/privacy.html +!src/web/static/relay-model.html +!src/web/static/roadmap.html +!src/web/static/security-model.html +!src/web/static/security.html +!src/web/static/terms.html +!src/web/static/trust-safety.html +!src/web/static/web-mobile.html +!src/web/static/chat.html !src/web/static/operator.html !src/web/static/remote.html +!src/web/static/site.css +!src/web/static/site.js !src/web/static/setup.html !src/web/static/setup.css !src/web/static/setup.js +!src/web/static/vendor/ +!src/web/static/vendor/marked.min.js +!src/web/static/vendor/purify.min.js +!src/web/static/diagrams/ +!src/web/static/diagrams/e2e_request_path_sequence_en_dark.png +!src/web/static/diagrams/e2e_request_path_sequence_jp_dark.png # Local packaging/export helpers (generated during repo-splitting) scripts/export_*_v6.ps1 diff --git a/README.md b/README.md index fc7baf24..37f8f6f0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
# YonerAI -### **The Artificial Lifeform AI System (Node + Clients + Relay + Core)** +### **Public Distribution Core For YonerAI's 3-Mode Architecture** ![YonerAI Banner](docs/images/yonerai_banner.svg) @@ -10,7 +10,7 @@ [![Discord](https://img.shields.io/badge/Discord-Join-7289DA?style=for-the-badge&logo=discord)](https://discord.gg/YoneRai12) [![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)](LICENSE) -[**[Manual]**](docs/USER_GUIDE.md) | [**[Env Templates]**](docs/ENV_FILES.md) | [**[Release Notes]**](docs/RELEASE_NOTES.md) | [**[Web Chat]**](http://localhost:3000) | [**[Dashboard]**](http://localhost:3333) +[**[Manual]**](docs/USER_GUIDE.md) | [**[Env Templates]**](docs/ENV_FILES.md) | [**[Route Freeze]**](docs/ROUTE_FREEZE_MVP.md) | [**[Public/Private Boundary]**](docs/PUBLIC_PRIVATE_BOUNDARY.md) | [**[Release Notes]**](docs/RELEASE_NOTES.md) --- @@ -31,10 +31,27 @@ At its core, YonerAI is a **Node-style runtime** that can combine: - an optional Core process for deeper routing / reasoning - relay-compatible networking for hybrid or remote-assisted setups -This public repository is the **distributable YonerAI side**: the part that should be able to run on a user's PC, be extended locally, and remain useful without private production infrastructure. +This public repository is **not** the official `yonerai.com` production runtime itself. +Its role is the **public distribution core**: the part that can run on a user's PC, stay useful without private production infrastructure, and later branch into multiple product modes. Note: many internal paths/env vars still use legacy `ORA_*` prefixes for compatibility. Product/release branding is controlled by `PRODUCT_NAME`. +## How This Repo Fits The 3-Mode Architecture + +The wider YonerAI system is organized as three operating modes: + +1. `Full Private Self-Host` + - the user hosts runtime, storage, domain, app, API, memory, and files +2. `Official Hybrid Private` + - official identity/orchestration connects to user-side node execution and local state +3. `Official Managed Cloud` + - official infrastructure hosts runtime, core, memory, files, and orchestration + +These are **not three separate codebases**. +They are three modes branching from one common distribution foundation through profiles, connectors, permissions, service dependencies, and trust boundaries. + +This public repo is that shared foundation. + ### Scope Of This Public Repo What this repo is for: @@ -52,7 +69,7 @@ What this repo is **not** trying to be on its own: - billing / production moderation / internal admin stacks - private production secrets, runbooks, or internal-only services -In other words: this repo is the public YonerAI foundation you can run, inspect, and extend. The heavier private operations side is intentionally separate. +In other words: this repo is the **public side of YonerAI's shared distribution core**, while the heavier official runtime and ops layer remain intentionally private. ### Current Status Of The Split @@ -63,6 +80,25 @@ The long-term direction is a clearer split between: That separation is still being formalized. Because of that, this repo currently still contains some broader shared foundation code and docs, especially around hybrid deployment and future-facing architecture. +### Current Strongest Implementation Direction + +The strongest surviving implementation evidence points to a split like this: + +- `yonerai.com` + - official top page and user-facing entry +- `api.yonerai.com` + - web chat, auth, and public API +- `files.yonerai.com` + - controlled file delivery and share links +- `hooks.yonerai.com` + - webhook or gateway ingress + +This section describes the **current strongest implementation direction**, not a final canonical production sign-off. +For the current MVP freeze, see: + +- `docs/ROUTE_FREEZE_MVP.md` +- `docs/PUBLIC_PRIVATE_BOUNDARY.md` + ### What You Can Do With YonerAI With the current public repo, you can: @@ -118,6 +154,7 @@ If you want the deeper docs: - `docs/VPS_DEPLOYMENT.md` (run the always-on control plane on a VPS; hybrid mode) - `docs/DOMAIN_ROUTES.md` (recommended `yonerai.com` subdomains and API path design) - `docs/PLATFORM_PLAN.md` (product direction: Node + Clients + Relay + Cloud) +- `docs/ROUTE_FREEZE_MVP.md` (Official Managed Cloud MVP route freeze) - `docs/PLATFORM_REVIEW_AND_RISKS.md` (devil's advocate review / risks) - `ORA_SYSTEM_SPEC.md` - `AGENTS.md` (Codex/agent workspace instructions for this repo) diff --git a/clients/web/.env.example b/clients/web/.env.example index d49eb395..645c2244 100644 --- a/clients/web/.env.example +++ b/clients/web/.env.example @@ -25,3 +25,9 @@ AUTH_TRUST_HOST=true # Optional: explicit canonical URL for NextAuth (use when deployed) # NEXTAUTH_URL=https://your-domain.example + +# Optional OpenAI CUA sidecar integration +# Run the official sample app separately and point YonerAI at it. +NEXT_PUBLIC_CUA_WEB_URL=http://127.0.0.1:3100 +NEXT_PUBLIC_CUA_RUNNER_URL=http://127.0.0.1:4100 +NEXT_PUBLIC_CUA_DEFAULT_MODEL=gpt-5.4 diff --git a/clients/web/app/cua/page.tsx b/clients/web/app/cua/page.tsx new file mode 100644 index 00000000..e094d52d --- /dev/null +++ b/clients/web/app/cua/page.tsx @@ -0,0 +1,139 @@ +const cuaWebUrl = process.env.NEXT_PUBLIC_CUA_WEB_URL || "http://127.0.0.1:3100"; +const cuaRunnerUrl = process.env.NEXT_PUBLIC_CUA_RUNNER_URL || "http://127.0.0.1:4100"; +const cuaModel = process.env.NEXT_PUBLIC_CUA_DEFAULT_MODEL || "gpt-5.4"; + +const steps = [ + "Run the official OpenAI CUA sample as a separate sidecar service.", + "Keep it on its own Node + pnpm + Playwright runtime.", + "Point YonerAI at the sidecar instead of merging the sample into this web app.", +]; + +const requirements = [ + "Node.js 22.20.0", + "pnpm 10.26.0", + "Playwright Chromium install", + "OPENAI_API_KEY configured in the sample app", +]; + +export default function CuaPage() { + return ( +
+
+
+

+ OpenAI CUA Sidecar +

+

+ GPT-5.4 browser operator, connected to YonerAI the safe way. +

+

+ YonerAI already has its own Python backend and browser APIs. The official + OpenAI CUA sample is best introduced as a separate sidecar app, then linked + into YonerAI for operator workflows and experiments. +

+
+ + Open CUA console + + + Open runner base URL + + + View official repo + +
+
+ +
+
+

Current sidecar target

+
+
+
Demo web
+
{cuaWebUrl}
+
+
+
Runner
+
{cuaRunnerUrl}
+
+
+
Default model
+
{cuaModel}
+
+
+
+ +
+

Sample requirements

+
    + {requirements.map((item) => ( +
  • + {item} +
  • + ))} +
+
+
+ +
+

Why not merge it directly?

+
+
+

Runtime split

+

+ The official sample expects a Node 22 + pnpm workspace, while YonerAI is already + split across Python, FastAPI, Next.js, and Playwright helpers. +

+
+
+

Safer adoption

+

+ Sidecar adoption keeps the sample reversible. If it fails or drifts, it does not + break the public YonerAI runtime. +

+
+
+

Future path

+

+ Once the runner proves useful, YonerAI can integrate replay links, auth, or task + bridges without importing the whole sample monorepo. +

+
+
+
+ +
+

Recommended first steps

+
    + {steps.map((item, index) => ( +
  1. + + {index + 1} + + {item} +
  2. + ))} +
+

+ See also: docs/OPENAI_CUA_SIDECAR_ADOPTION.md +

+
+
+
+ ); +} diff --git a/clients/web/app/layout.tsx b/clients/web/app/layout.tsx index 9dc845d2..8657ca40 100644 --- a/clients/web/app/layout.tsx +++ b/clients/web/app/layout.tsx @@ -1,6 +1,6 @@ import "./globals.css"; import { Inter } from "next/font/google"; -import { Plus, MessageSquare, Settings, User, LogOut } from "lucide-react"; +import { Plus, MessageSquare, Settings, User } from "lucide-react"; const inter = Inter({ subsets: ["latin"] }); @@ -12,7 +12,7 @@ export default function RootLayout({ return ( - ORA Chat + YonerAI diff --git a/clients/web/app/page.tsx b/clients/web/app/page.tsx index 0326155b..021545e8 100644 --- a/clients/web/app/page.tsx +++ b/clients/web/app/page.tsx @@ -1,287 +1,402 @@ "use client"; +import Link from "next/link"; import { useState } from "react"; -import { SendHorizontal, Paperclip, ChevronDown, ChevronRight, Check, Activity } from "lucide-react"; +import { + Activity, + Check, + ChevronDown, + ChevronRight, + Paperclip, + SendHorizontal, +} from "lucide-react"; + +type Message = { + role: string; + content: string; +}; export default function Home() { - const [messages, setMessages] = useState<{ role: string, content: string }[]>([]); - const [input, setInput] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - const handleSend = async () => { - if (!input.trim()) return; - - const userMsg = input; - setMessages(prev => [...prev, { role: "user", content: userMsg }]); - setInput(""); - setIsLoading(true); - - // Initial placeholder for assistant - setMessages(prev => [...prev, { role: "assistant", content: "" }]); - + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSend = async () => { + if (!input.trim()) return; + + const userMsg = input; + setMessages((prev) => [...prev, { role: "user", content: userMsg }]); + setInput(""); + setIsLoading(true); + setMessages((prev) => [...prev, { role: "assistant", content: "" }]); + + try { + const res = await fetch("/api/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: userMsg, + user_identity: { + provider: "web", + id: "web-guest", + display_name: "Guest User", + }, + }), + }); + + if (!res.ok) { + throw new Error("API Error"); + } + + const data = await res.json(); + const runId = data.run_id; + if (!runId) { + throw new Error("No Run ID returned"); + } + + const evtSource = new EventSource(`/api/runs/${runId}/events`); + + evtSource.onmessage = (event) => { try { - const res = await fetch("/api/messages", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - content: userMsg, - user_identity: { provider: "web", id: "web-guest", display_name: "Guest User" } - }) - }); - - if (!res.ok) throw new Error("API Error"); - - const data = await res.json(); - const runId = data.run_id; - - if (!runId) { - throw new Error("No Run ID returned"); - } - - // Start SSE Stream - console.log(`Listening to events for run: ${runId}`); - const evtSource = new EventSource(`/api/runs/${runId}/events`); - - evtSource.onmessage = (event) => { - try { - // The stream yields { data: "JSON_STRING" } - // We need to parse event.data first - const payload = JSON.parse(event.data); - // payload is { event: "delta", data: {...} } - const type = payload.event; - const innerData = payload.data; // { text: "..." } - - if (type === "delta") { - const text = innerData.text || ""; - setMessages(prev => { - const newArr = [...prev]; - const last = newArr[newArr.length - 1]; - if (last.role === "assistant") { - last.content += text; - } - return newArr; - }); - } else if (type === "final" || type === "error") { - evtSource.close(); - setIsLoading(false); - } - } catch (e) { - console.error("SSE Parse Error", e); - } - }; - - evtSource.onerror = (err) => { - console.error("SSE Error", err); - evtSource.close(); - setIsLoading(false); - }; - - } catch (e) { - console.error(e); - setMessages(prev => { - const newArr = [...prev]; - // Remove empty placeholder if failed immediately - if (newArr.length > 0 && newArr[newArr.length - 1].content === "") { - newArr[newArr.length - 1].content = "⚠️ Error: Could not connect to ORA Brain."; - } - return newArr; + const payload = JSON.parse(event.data); + const type = payload.event; + const innerData = payload.data; + + if (type === "delta") { + const text = innerData.text || ""; + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant") { + last.content += text; + } + return next; }); + } else if (type === "final" || type === "error") { + evtSource.close(); setIsLoading(false); + } + } catch (error) { + console.error("SSE Parse Error", error); } - }; - - return ( -
- - {/* Top Model Selector (Mock) */} -
- ORA Model - + }; + + evtSource.onerror = (error) => { + console.error("SSE Error", error); + evtSource.close(); + setIsLoading(false); + }; + } catch (error) { + console.error(error); + setMessages((prev) => { + const next = [...prev]; + if (next.length > 0 && next[next.length - 1].content === "") { + next[next.length - 1].content = "Error: Could not connect to YonerAI."; + } + return next; + }); + setIsLoading(false); + } + }; + + return ( +
+
+
+
+
+

+ Maintenance Notice +

+

+ yonerai.com is currently under maintenance. The maintenance is planned to continue + until March 31, 2026 at the latest, but it may also end earlier on an irregular + schedule. +

+

+ Public web access and operator features are being prepared in stages. +

- - {/* Model Version Dropdown (Desktop) */} -
- +
+

Contact

+

+ + hello@yonerai.com + +

+

+ + yonerai.com + +

+

+ + Open CUA sidecar guide + +

- - - {/* Chat Area */} -
- {messages.length === 0 ? ( -
-
- -
-

How can I help you today?

- - {/* Suggestion Grid */} -
- setInput("Write a Python script to automate detailed daily reports")} /> - setInput("Explain quantum computing in simple terms")} /> - setInput("Draft an email requesting a deadline extension")} /> - setInput("Brainstorm ideas for a cyberpunk novel setting")} /> -
-
- ) : ( -
- {messages.map((m, i) => ( -
- {m.role === 'assistant' && ( -
- -
- )} -
-

{m.content}

-
-
- ))} -
- )} +
+
+
+ +
+ YonerAI + +
+ +
+ +
+ +
+ {messages.length === 0 ? ( +
+
+
- - {/* Input Area */} -
-
-
- - +
+
+ + Ctrl + Enter to send +
+ +
+ +
+ +
+
Up to 5,000 characters and 4 images. Enter adds a new line. Ctrl + Enter sends the message.
+
0 / 5000
+
+ + + +
+ + + + + + + + + diff --git a/src/web/static/clients.html b/src/web/static/clients.html new file mode 100644 index 00000000..4185612b --- /dev/null +++ b/src/web/static/clients.html @@ -0,0 +1,128 @@ + + + + + + YonerAI Clients + + + + + + + + + + YonerAI +
+
+ +
+ +
+
+
+

Multi-platform clients

+

One identity, many interfaces.

+

YonerAI aims to provide a consistent assistant experience on web, mobile, desktop, and Discord.

+
+
+
Web
Primary hub
+
Discord
Shared assistant
+
Mobile
iOS / Android path
+
Desktop
Win / Mac integration
+
+
+ +
+

Client design principles

+

Each client should feel native, but still speak the same platform contract.

+
+

Consistent semantics

Approvals, roles, and responses should not differ by client surface.

+

Input-first workflows

Mention, URL, media, and command paths should be predictable in every interface.

+

Graceful degradation

If a capability is unavailable on one client, fallback behavior stays explicit and safe.

+
+
+ +
+
+

Clients evolve fast. Contracts must stay stable.

+

See current implementation details in the repository.

+
+ +
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/src/web/static/contact.html b/src/web/static/contact.html new file mode 100644 index 00000000..90089f62 --- /dev/null +++ b/src/web/static/contact.html @@ -0,0 +1,109 @@ + + + + + + YonerAI Contact + + + + + + + + + + YonerAI +
+
+ +
+ +
+
+
+

Contact

+

Reach YonerAI through public channels.

+

Questions, bug reports, and collaboration requests are handled through issue-driven and mail channels.

+
+
+ + +
GitHub
Issues
+ +
+
+ +
+

What to include

+
+

Reproduction details

Environment, error logs, and exact steps greatly reduce response time.

+

Expected behavior

Describe what result you expected and where behavior diverged.

+

Security reports

Do not post secrets in public issues. Use mail for sensitive incidents.

+
+
+
+ + +
+ + + + + + + + + + + + + + + diff --git a/src/web/static/desktop-discord.html b/src/web/static/desktop-discord.html new file mode 100644 index 00000000..2071f351 --- /dev/null +++ b/src/web/static/desktop-discord.html @@ -0,0 +1,109 @@ + + + + + + YonerAI Windows / Mac / Discord + + + + + + + + + + YonerAI +
+
+ +
+ +
+
+
+

Desktop + Discord

+

High-control operators and shared assistant channels.

+

Windows and Mac are optimized for operator workflows, while Discord extends YonerAI to teams and communities.

+
+
+
Windows
Local operator control
+
Mac
Client parity target
+
Discord
Shared mode surface
+
Moderation
Roles + approvals
+
+
+ +
+

Key behaviors

+
+

Mention-first UX

Discord mention and URL workflows are treated as first-class request inputs.

+

Role-aware execution

Shared-mode users can execute low-risk actions directly, while high-risk actions route to approvals.

+

Audit-friendly operations

Critical actions leave explicit traces so incidents can be diagnosed and replay errors avoided.

+
+
+
+ + +
+ + + + + + + + + + + + + + + diff --git a/src/web/static/developers.html b/src/web/static/developers.html new file mode 100644 index 00000000..df2d708a --- /dev/null +++ b/src/web/static/developers.html @@ -0,0 +1,171 @@ + + + + + + YonerAI Developers + + + + + + + + + + YonerAI + +
+ +
+ +
+
+
+

Developer resources

+

Integrate with YonerAI safely.

+

This page collects the minimal, stable API entrypoints and extension mechanisms (Skills / MCP).

+
+
+
Auth
Token or OAuth
+
Transport
HTTP + SSE
+
Extensions
Skills / MCP
+
Principle
Safe-by-default
+
+
+ +
+

Quick start

+

The public managed-cloud MVP exposes chat only. API issuance and management endpoints stay outside the public surface.

+ +
+ +
+

Managed Cloud MVP routes

+

The public MVP keeps the surface intentionally small: one chat shell, session check, message post, and SSE stream.

+ +
+
+

Chat shell

+

GET /jp/chat

+
+
+

Session check

+

GET /api/auth/me

+
+
+

Message stream

+

POST /api/public/chat/messages + SSE

+
+
+ +

Current public entrypoint

+
yonerai.com         -> official top / explanation / CTA
+api.yonerai.com     -> /jp/chat
+api.yonerai.com     -> /api/auth/*
+api.yonerai.com     -> /api/public/chat/messages
+api.yonerai.com     -> /api/public/chat/runs/{run_id}/events
+

Public API issuance and management pages are intentionally withheld from this MVP.

+
+ +
+

Extensions: Skills & MCP

+

YonerAI can be extended in two ways: native Skills (Python plugins) and MCP tool servers. Both are designed to stay behind policy/approvals.

+ +
+
+

Skills

+

Drop-in Python tools under src/skills/.

+
+
+

MCP

+

Register external tools via ORA_MCP_SERVERS_JSON.

+
+
+

Policy first

+

Unknown tools are treated as HIGH by default in shared/guest contexts.

+
+
+
+ +
+
+

Need a new integration?

+

Open a GitHub issue or propose a Skill/MCP module.

+
+ +
+ + +
+ + + + + diff --git a/src/web/static/diagrams/e2e_request_path_sequence_en_dark.png b/src/web/static/diagrams/e2e_request_path_sequence_en_dark.png new file mode 100644 index 00000000..5cb53798 Binary files /dev/null and b/src/web/static/diagrams/e2e_request_path_sequence_en_dark.png differ diff --git a/src/web/static/diagrams/e2e_request_path_sequence_jp_dark.png b/src/web/static/diagrams/e2e_request_path_sequence_jp_dark.png new file mode 100644 index 00000000..e96da8ed Binary files /dev/null and b/src/web/static/diagrams/e2e_request_path_sequence_jp_dark.png differ diff --git a/src/web/static/index.html b/src/web/static/index.html index b10650bc..8717c0c7 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -1,104 +1,192 @@ - + - - - - ORA Remote Loader - + + + YonerAI + + + + + + - - -
-
Connecting to ORA Core...
- - + + + YonerAI + +
+ +
+ +
+ + +
+
+
+
+ +

YonerAI Platform Preview

+

A local-first AI platform that scales from your PC to cloud workers without losing control boundaries.

+
+ Product + Read time: 7 min +
+
+ +
+
+ +

M2 Relay Hardening

+

Multiplex, timeout cleanup, and one-time pairing are now baseline behavior.

+
+
+ +

Unified Public Surface

+

One-page public experience with language persistence and accessibility-ready structure.

+
+
+
+ +
+
+

Security Model

+

Safe-by-default controls: profile separation, risk scoring, approvals, and token boundaries.

+
+
+

Operational Discipline

+

Architecture changes are documented and exposed publicly to keep decisions auditable.

+
+
+

Cross-Platform Reach

+

Web / iOS / Android / Windows / Mac / Discord under one behavioral contract.

+
+
+ + +
+
+

Developer Direction

+

Prioritize reliability and policy boundaries, then scale workers and advanced automation.

+
+
+

Business Path

+

Personal node first, then shared mode, then managed cloud execution and subscriptions.

+
+
+

Agent Evolution

+

Hybrid control plane + sandbox workers to run autonomous workflows safely.

+
+
+ + +
+
+

Build in public. Ship with discipline.

+

Track releases and architecture decisions on GitHub.

+
+ +
+ + +
+ + + + +
+
+ + + + + + + - \ No newline at end of file diff --git a/src/web/static/linked.html b/src/web/static/linked.html new file mode 100644 index 00000000..d18a97a4 --- /dev/null +++ b/src/web/static/linked.html @@ -0,0 +1,44 @@ + + + + + + YonerAI Linked + + + + + + + + + + YonerAI + +
+ +
+ +
+
+

Account linked successfully

+

Your existing Discord identity is now connected. Continue to chat.

+ +
+
+ + + + + diff --git a/src/web/static/login.html b/src/web/static/login.html new file mode 100644 index 00000000..eca457aa --- /dev/null +++ b/src/web/static/login.html @@ -0,0 +1,307 @@ + + + + + + YonerAI | Sign in + + + + + + + + + +
+
+ YonerAI + + +
+ +

Sign in

+

+ + +

+ + +
+ + + + + diff --git a/src/web/static/operator.html b/src/web/static/operator.html index dfeed7ae..68406af6 100644 --- a/src/web/static/operator.html +++ b/src/web/static/operator.html @@ -1,4 +1,4 @@ - + @@ -251,7 +251,7 @@

Operator Panel

- +
@@ -497,7 +497,7 @@

Operator Panel

try { const data = await api("state"); if (data.ok && data.observation) { - metaEl.textContent = `${data.observation.title || "Untitled"} — ${data.observation.url || ""}`; + metaEl.textContent = `${data.observation.title || "Untitled"} 窶・${data.observation.url || ""}`; if (data.warning === "cdp_fallback_missing_url") { showBanner("CDP URL is not set. Using a server-launched browser."); } else if (data.warning === "cdp_fallback_connect_failed") { @@ -575,7 +575,7 @@

Operator Panel

tabs.forEach((tab) => { const option = document.createElement("option"); option.value = tab.tab_id; - option.textContent = `${tab.active ? "● " : ""}${tab.title || tab.url || "Untitled"}`; + option.textContent = `${tab.active ? "笳・" : ""}${tab.title || tab.url || "Untitled"}`; option.title = tab.url || ""; if (tab.active) { activeId = tab.tab_id; @@ -828,3 +828,4 @@

Operator Panel

+ diff --git a/src/web/static/platform-preview.html b/src/web/static/platform-preview.html new file mode 100644 index 00000000..29b056f5 --- /dev/null +++ b/src/web/static/platform-preview.html @@ -0,0 +1,178 @@ + + + + + + YonerAI Platform + + + + + + + + + + YonerAI +
+
+ +
+ +
+
+
+

Platform preview

+

How one click becomes a safe response.

+

This page explains the YonerAI request pipeline: where context is normalized, where policy blocks dangerous actions, and how results return to users with auditability.

+
+
+
Input
Chat / Mention / API
+
Policy gate
Risk score + role checks
+
Execution
Skills / MCP / tools
+
Output
Audited final response
+
+
+ +
+
+
+

End-to-End request path (actual system sequence)

+

This is the real sequence diagram used in docs and release materials, not a simulated graph.

+
+ Open full resolution +
+ +
+ YonerAI end-to-end request sequence diagram +
Sequence: User -> Discord/Web -> ChatHandler -> Policy Gate -> Core Run Owner -> Tools/MCP -> Storage/Audit.
+
+
+ +
+

Why this architecture was designed this way

+

YonerAI is designed for long-term operation where feature growth does not destroy trust boundaries.

+
+
+

Boundary first

+

Execution, routing, and policy are separated so one failure does not compromise the whole system.

+
+
+

Safe-by-default

+

Unknown or risky actions do not run automatically; they require explicit policy approval paths.

+
+
+

Auditable operation

+

Decision points and tool execution traces are retained to support debugging and accountability.

+
+
+
+ +
+

Processing contract by layer

+

Each layer has one job. This keeps behavior predictable under load and while adding new tools.

+
+

Client Layer

Collect intent and show result. Keep UI thin.

+

Bot + Handler

Normalize context and dispatch deterministic requests.

+

Core Run Owner

Own run state and idempotent tool-result loop.

+

Tool Plane

Execute bounded actions through skills/MCP/workers.

+
+
+ +
+
+

Build features fast, but keep boundaries explicit.

+

This is the contract that keeps YonerAI extensible without becoming unsafe.

+
+ +
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/src/web/static/privacy.html b/src/web/static/privacy.html new file mode 100644 index 00000000..811026c2 --- /dev/null +++ b/src/web/static/privacy.html @@ -0,0 +1,175 @@ + + + + + + YonerAI Privacy Policy + + + + + + + + + + YonerAI +
+
+ +
+ +
+
+

Privacy Policy

+

Privacy Policy

+ +

+ Effective date: + 2026-04-02 +

+ +
+

This Privacy Policy explains how YonerAI collects, uses, shares, and retains information when you use the website, Discord integration, and API features.

+ +

1. Operator and contact

+

YonerAI is operated by an individual developer (YoneRai12). The service is currently run by a minor individual developer rather than a staffed corporation.

+ + +

2. Information we collect

+
    +
  • Account identifiers: OAuth provider, provider user ID (sub), email (if provided), display name, and account-linking identifiers.
  • +
  • + Session data: + we use an HttpOnly session cookie after sign-in. + yonerai_session +
  • +
  • Conversation content: prompts, attachments you submit, and the responses generated.
  • +
  • Operational logs: timestamps, request paths, status codes, latency, sizes, key prefix, and security/audit events. Prompt text is not stored in API usage logs.
  • +
  • API key records: we store API keys as hashes and prefixes only. Raw keys are shown once at creation.
  • +
  • Technical data: IP address, user agent, and similar metadata may appear in server logs for security and debugging.
  • +
+ +

3. How we use information

+
    +
  • Provide the service: authenticate users, process requests, run tools, and deliver results.
  • +
  • Safety and security: prevent abuse, investigate incidents, and enforce limits.
  • +
  • Reliability: debugging, monitoring, and improving quality and performance.
  • +
  • Support: respond to user inquiries and requests (including deletion requests).
  • +
  • Compliance: comply with applicable laws and protect our rights.
  • +
  • Defensive response: preserve, review, and correlate logs or account records when we reasonably believe it is necessary to investigate abuse, violence, fraud, threats, or other harmful conduct.
  • +
+ +

4. Cookies and local storage

+
    +
  • + Essential cookies: + session and security-related cookies used for sign-in and preference storage. + yonerai_session + yonerai_cookie_consent_v1 +
  • +
  • + Optional cookie: + language preference (only stored if you allow optional cookies). + yonerai_lang +
  • +
  • Local storage: the web chat UI may store a local copy of threads and UI state in your browser. You can clear it via your browser settings.
  • +
+ +

5. Sharing and third parties

+
    +
  • OAuth providers (Google, Apple, Discord, Microsoft, X) process authentication and share basic profile data with YonerAI according to your provider settings.
  • +
  • AI providers: depending on deployment settings, your prompts/attachments may be sent to third-party AI providers (including OpenAI) to generate responses. Those providers process the content under their own terms and policies.
  • +
  • We may disclose information when required by law, legal process, court order, or when we reasonably believe disclosure is necessary to prevent imminent harm, investigate crime, protect users, or defend our rights.
  • +
+

Do not submit passwords, API keys, or other secrets.

+ +

6. Data retention

+
    +
  • Sessions: web sessions expire automatically (default up to 30 days).
  • +
  • Operational logs: retained for a limited period for security and reliability. Some audit tables may be pruned automatically based on server configuration.
  • +
  • Conversation content: retained to provide conversation continuity and service operation. You can request deletion via the contact page.
  • +
  • If we reasonably believe an account or request is connected to serious abuse, threats, violence, or crime, we may retain relevant logs and records for longer than usual while investigating, responding, or complying with law.
  • +
+ +

7. Your choices

+
    +
  • You can revoke API keys from the API Platform.
  • +
  • You can clear browser local storage and cookies via your browser settings.
  • +
  • You can request access or deletion of your data via the contact page.
  • +
+ +

8. Changes to this policy

+

We may update this policy as the service evolves. The effective date at the top of this page indicates the latest version.

+

Because YonerAI is operated by a minor individual developer, support and legal-process handling may be limited to reasonable efforts rather than enterprise-scale response commitments.

+
+
+
+ + +
+ + + + + + diff --git a/src/web/static/relay-model.html b/src/web/static/relay-model.html new file mode 100644 index 00000000..961464a5 --- /dev/null +++ b/src/web/static/relay-model.html @@ -0,0 +1,109 @@ + + + + + + YonerAI Relay Model + + + + + + + + + + YonerAI +
+
+ +
+ +
+
+
+

Relay model

+

Outbound-only nodes, routed by a hardened relay.

+

Client and node both connect to relay by WebSocket. Requests are multiplexed by request IDs and resolved safely on disconnect/timeout.

+
+
+
Transport
WebSocket
+
Pairing
One-time code + TTL
+
Routing
id -> Future
+
Hardening
pending limit / timeout / cleanup
+
+
+ +
+

Operational guarantees

+
+

No inbound ports on user PC

Nodes only dial out to relay, reducing home-network exposure.

+

Message safety limits

Body size caps and WS frame limits prevent memory abuse.

+

Clean failure behavior

Pending futures are always cleaned to avoid leaked state.

+
+
+
+ + +
+ + + + + + + + + + + + + + + diff --git a/src/web/static/remote.html b/src/web/static/remote.html index 6f6d7928..d3ba8e15 100644 --- a/src/web/static/remote.html +++ b/src/web/static/remote.html @@ -1,4 +1,4 @@ - + @@ -122,3 +122,4 @@ + diff --git a/src/web/static/roadmap.html b/src/web/static/roadmap.html new file mode 100644 index 00000000..310c4553 --- /dev/null +++ b/src/web/static/roadmap.html @@ -0,0 +1,110 @@ + + + + + + YonerAI Roadmap + + + + + + + + + + YonerAI +
+
+ +
+ +
+
+
+

Roadmap

+

Ship in phases, keep contracts stable.

+

YonerAI roadmap prioritizes reliability and safe operations first, then expands workers, clients, and managed cloud features.

+
+
+
M1
Profile isolation
+
M1.5
Approval UX
+
M2
Relay MVP
+
M3
Policy hardening
+
+
+ +
+

Current sequence

+
+

01

Public surface quality

Stabilize website, navigation, language flow, and docs discoverability.

+

02

Shared-mode policy lock

Keep guest behavior bounded with explicit risk and approvals.

+

03

Relay + worker expansion

Scale request routing and isolated worker execution paths.

+

04

Managed cloud options

Add optional subscription execution for low-spec users.

+
+
+
+ + +
+ + + + + + + + + + + + + + + diff --git a/src/web/static/security-model.html b/src/web/static/security-model.html new file mode 100644 index 00000000..93417de8 --- /dev/null +++ b/src/web/static/security-model.html @@ -0,0 +1,109 @@ + + + + + + YonerAI Security Model + + + + + + + + + + YonerAI +
+
+ +
+ +
+
+
+

Security model

+

Secure-by-default, operator-controlled.

+

YonerAI treats risky tool execution as an auditable workflow with explicit decisions, expiration windows, and policy boundaries.

+
+
+
Profiles
private / shared
+
Risk levels
LOW / HIGH / CRITICAL
+
Approval gate
Out-of-band owner decision
+
Audit trail
Tool + decision logs
+
+
+ +
+

Control set

+
+

Role policy

Guest capabilities are restricted and unknown tools are never treated as low-risk by default.

+

Approval TTL

Approvals expire quickly. Stale approvals cannot be replayed later.

+

Token separation

Public web APIs require tokens and secrets are isolated from user-editable settings.

+
+
+
+ + +
+ + + + + + + + + + + + + + + diff --git a/src/web/static/security.html b/src/web/static/security.html new file mode 100644 index 00000000..04ed7900 --- /dev/null +++ b/src/web/static/security.html @@ -0,0 +1,128 @@ + + + + + + YonerAI Security + + + + + + + + + + YonerAI +
+
+ +
+ +
+
+
+

Security model

+

Safe-by-default is a feature, not a limitation.

+

YonerAI uses policy gates, approvals, profile separation, and token boundaries to reduce blast radius.

+
+
+
Profiles
private / shared
+
Approvals
HIGH / CRITICAL
+
Web API
Token required
+
Audit
Decision traces
+
+
+ +
+

Key controls

+

These controls are designed to prevent dangerous defaults during growth.

+
+

Role + profile policy

Access differs by owner/guest and private/shared profile boundaries.

+

Approval TTL

High-risk actions require valid approvals within strict expiration windows.

+

Token boundaries

Public APIs are token-gated; secrets are separated from editable configuration.

+
+
+ +
+
+

Ship faster by fixing boundaries first.

+

Security docs and review notes are maintained in the repository.

+
+ +
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/src/web/static/setup.html b/src/web/static/setup.html index f46929e4..d68be9d2 100644 --- a/src/web/static/setup.html +++ b/src/web/static/setup.html @@ -1,4 +1,4 @@ - + @@ -9,7 +9,7 @@
- +

YonerAI Setup

Tokens, roles, permissions, approvals, MCP, relay. Everything from UI.

@@ -29,7 +29,7 @@

YonerAI Setup

@@ -64,7 +64,7 @@

YonerAI Setup

@@ -133,3 +133,4 @@

Loading...

+ diff --git a/src/web/static/site.css b/src/web/static/site.css new file mode 100644 index 00000000..afc048f3 --- /dev/null +++ b/src/web/static/site.css @@ -0,0 +1,4139 @@ +:root { + --bg: #f3f6fc; + --surface: rgba(255, 255, 255, 0.72); + --surface-solid: rgba(255, 255, 255, 0.92); + --text: #101a2a; + --muted: #4b5a70; + --line: rgba(16, 26, 42, 0.14); + --brand: #0d6efd; + --brand-deep: #074fc2; + --brand-soft: #4db0ff; + --mega-ink: rgba(16, 26, 42, 0.2); + --ok: #109a7f; + --focus: #22a8ff; + --radius-xl: 30px; + --radius-lg: 20px; + --radius-md: 14px; + --shadow: 0 32px 92px rgba(7, 16, 31, 0.16); + --shell: min(3400px, 98vw); + + /* Unified, unobtrusive scrollbars (overridden by themed pages like body.console) */ + --sb-size: 6px; + --sb-track: transparent; + --sb-thumb: rgba(16, 26, 42, 0.14); + --sb-thumb-hover: rgba(16, 26, 42, 0.22); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #060e1a; + --surface: rgba(10, 17, 31, 0.64); + --surface-solid: rgba(10, 17, 31, 0.9); + --text: #edf4ff; + --muted: #9fb2c8; + --line: rgba(172, 198, 228, 0.2); + --brand: #4da8ff; + --brand-deep: #2b86e8; + --brand-soft: #8ad1ff; + --mega-ink: rgba(237, 244, 255, 0.18); + --ok: #26c49d; + --focus: #80d8ff; + --shadow: 0 42px 120px rgba(0, 0, 0, 0.54); + + --sb-track: transparent; + --sb-thumb: rgba(255, 255, 255, 0.06); + --sb-thumb-hover: rgba(255, 255, 255, 0.12); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +html { + scrollbar-width: thin; + scrollbar-color: var(--sb-thumb) var(--sb-track); +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--sb-thumb) var(--sb-track); +} + +*::-webkit-scrollbar { + width: var(--sb-size); + height: var(--sb-size); +} + +*::-webkit-scrollbar-track { + background: var(--sb-track); +} + +*::-webkit-scrollbar-thumb { + background-color: var(--sb-thumb); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: var(--sb-thumb-hover); +} + +*::-webkit-scrollbar-corner { + background: transparent; +} + +body { + font-family: "Sora", "IBM Plex Sans JP", "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at 12% 14%, rgba(13, 110, 253, 0.2), transparent 34%), + radial-gradient(circle at 86% 7%, rgba(14, 165, 233, 0.18), transparent 36%), + radial-gradient(circle at 78% 88%, rgba(16, 154, 127, 0.16), transparent 34%), + var(--bg); + line-height: 1.58; + letter-spacing: 0.01em; + overflow-x: hidden; +} + +body::before { + content: ""; + position: fixed; + inset: -24% -12% auto -12%; + height: 62vh; + background: radial-gradient(closest-side, rgba(77, 176, 255, 0.2), transparent 74%); + filter: blur(48px); + pointer-events: none; + z-index: -1; + animation: floatGlow 12s ease-in-out infinite; +} + +@keyframes floatGlow { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } + 50% { transform: translate3d(0, 3%, 0) scale(1.04); } +} + +a { + color: inherit; + text-decoration: none; +} + +.skip-link { + position: absolute; + left: -999px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; +} + +.skip-link:focus-visible { + left: 1rem; + top: 1rem; + width: auto; + height: auto; + padding: 0.72rem 1rem; + border-radius: 0.75rem; + border: 2px solid var(--focus); + background: var(--surface-solid); + z-index: 1000; +} + +.shell { + width: var(--shell); + margin-inline: auto; + padding: clamp(1rem, 2vw, 2.3rem) clamp(0.8rem, 1.8vw, 2.4rem) clamp(2.2rem, 4vw, 4.4rem); +} + +.app-shell { + width: 100vw; + margin-inline: auto; + padding: clamp(0.5rem, 1.2vw, 1.1rem); + display: grid; + grid-template-columns: clamp(248px, 17vw, 360px) minmax(0, 1fr); + gap: clamp(0.65rem, 1.25vw, 1.2rem); + align-items: start; +} + +.left-dock { + position: sticky; + top: clamp(0.4rem, 0.8vw, 0.8rem); + height: calc(100dvh - clamp(0.8rem, 1.6vw, 1.6rem)); + border-radius: 22px; + padding: clamp(0.72rem, 1.2vw, 1rem); + display: flex; + flex-direction: column; + min-height: 0; +} + +.dock-brand { + display: inline-flex; + align-items: center; + gap: 0.56rem; + font-size: 0.95rem; + font-weight: 700; +} + +.dock-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.7rem; +} + +.dock-divider { + margin: 0.68rem 0; + border: 0; + border-top: 1px solid var(--line); +} + +.dock-desc { + margin: 0; + color: var(--muted); + font-size: 0.82rem; + line-height: 1.45; +} + +.dock-scroll { + margin-top: 0.62rem; + min-height: 0; + overflow: auto; + padding-right: 0.12rem; +} + +.content-shell { + min-width: 0; +} + +.content-shell .topbar { + top: clamp(0.4rem, 0.8vw, 0.8rem); +} + +.page-grid { + margin-top: clamp(1.1rem, 2.4vw, 2rem); + display: grid; + grid-template-columns: clamp(240px, 16vw, 360px) minmax(0, 1fr); + gap: clamp(0.8rem, 1.8vw, 1.4rem); + align-items: start; +} + +.left-rail { + position: sticky; + top: 5.3rem; + border-radius: var(--radius-xl); + padding: clamp(0.86rem, 1.4vw, 1.15rem); +} + +.left-rail h2 { + margin: 0; + font-size: 1.04rem; +} + +.left-rail .desc { + margin: 0.38rem 0 0.8rem; + color: var(--muted); + font-size: 0.88rem; +} + +.rail-nav { + display: grid; + gap: 0.45rem; + align-content: start; +} + +.rail-link { + border-radius: 12px; + border: 1px solid var(--line); + background: var(--surface-solid); + min-height: 2.2rem; + display: inline-flex; + align-items: center; + padding: 0.46rem 0.72rem; + color: var(--muted); + font-size: 0.88rem; + transition: border-color 140ms ease, color 140ms ease, transform 140ms ease, background 140ms ease; +} + +.rail-link:hover { + color: var(--text); + transform: translateY(-1px); +} + +.rail-link.is-active { + color: var(--text); + border-color: color-mix(in srgb, var(--brand) 44%, var(--line)); + background: color-mix(in srgb, var(--brand) 18%, var(--surface-solid)); +} + +.rail-note-card { + margin-top: 0.72rem; + border-radius: var(--radius-lg); + border: 1px solid var(--line); + background: var(--surface-solid); + padding: 0.66rem; +} + +.rail-note-card label { + display: block; + font-size: 0.8rem; + font-weight: 700; + color: var(--muted); + margin-bottom: 0.45rem; +} + +.rail-note-card textarea { + width: 100%; + min-height: 148px; + max-height: 320px; + resize: vertical; + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.62rem 0.66rem; + color: var(--text); + background: color-mix(in srgb, var(--surface-solid) 90%, transparent); + font: inherit; + font-size: 0.88rem; + line-height: 1.45; +} + +.rail-note-card p { + margin: 0.45rem 0 0; + color: var(--muted); + font-size: 0.76rem; +} + +.main-stack > :first-child { + margin-top: 0; +} + +.glass { + background: var(--surface); + border: 1px solid var(--line); + backdrop-filter: blur(22px) saturate(138%); + box-shadow: var(--shadow); +} + +.policy-doc { + border-radius: var(--radius-xl); + padding: clamp(1.2rem, 2.4vw, 1.8rem); +} + +.policy-meta { + margin: 0.35rem 0 1.2rem; + color: var(--muted); + font-size: 0.95rem; +} + +.policy-prose { + max-width: 78ch; +} + +.policy-prose h2 { + margin: 2rem 0 0.6rem; + font-size: 1.2rem; + letter-spacing: -0.01em; +} + +.policy-prose h3 { + margin: 1.4rem 0 0.45rem; + font-size: 1.05rem; + letter-spacing: -0.01em; +} + +.policy-prose p { + margin: 0.65rem 0; +} + +.policy-prose ul, +.policy-prose ol { + margin: 0.65rem 0; + padding-left: 1.2rem; +} + +.policy-prose li { + margin: 0.25rem 0; +} + +.policy-prose code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.95em; + background: color-mix(in srgb, var(--surface-solid) 70%, transparent); + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.08rem 0.35rem; +} + +.topbar { + position: sticky; + top: 0.7rem; + z-index: 20; + display: flex; + gap: 1rem; + align-items: center; + justify-content: space-between; + border-radius: var(--radius-xl); + padding: 0.75rem 1rem; + backdrop-filter: blur(20px) saturate(145%); + box-shadow: 0 20px 46px rgba(0, 0, 0, 0.2); +} + +.brand { + display: inline-flex; + gap: 0.75rem; + align-items: center; + font-weight: 700; + font-size: 1.04rem; + letter-spacing: -0.015em; + white-space: nowrap; +} + +.brand-mark { + display: none; +} + +.nav { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 0.62rem; +} + +.nav-link { + display: inline-flex; + align-items: center; + min-height: 2.15rem; + padding: 0.45rem 0.72rem; + border-radius: 999px; + border: 1px solid transparent; + color: var(--muted); + font-size: 0.9rem; + transition: border-color 140ms ease, color 140ms ease, background 140ms ease, transform 140ms ease; +} + +.nav-link:hover { + border-color: var(--line); + color: var(--text); + background: var(--surface-solid); + transform: translateY(-1px); +} + +.nav-link.is-active { + border-color: var(--line); + color: var(--text); + background: var(--surface-solid); +} + +.lang-switch { + display: inline-grid; + grid-template-columns: 1fr 1fr; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--surface-solid); + padding: 0.2rem; + min-width: 5.25rem; +} + +.lang-btn { + appearance: none; + border: 0; + background: transparent; + color: var(--muted); + min-height: 1.92rem; + padding: 0 0.6rem; + border-radius: 999px; + cursor: pointer; + font-family: inherit; + font-size: 0.84rem; + font-weight: 700; + letter-spacing: 0.04em; + transition: background 140ms ease, color 140ms ease; +} + +.lang-btn.is-active { + background: linear-gradient(120deg, var(--brand-deep), var(--brand)); + color: #eef5ff; +} + +.oa-cookie-consent { + position: fixed; + right: clamp(0.72rem, 1.4vw, 1.2rem); + bottom: clamp(0.72rem, 1.4vw, 1.2rem); + z-index: 120; + width: min(430px, calc(100vw - 1rem)); + pointer-events: none; + opacity: 1; + transform: translateY(0); + transition: opacity 180ms ease, transform 180ms ease; +} + +.oa-cookie-consent.is-hide { + opacity: 0; + transform: translateY(10px); +} + +.oa-cookie-card { + pointer-events: auto; + border-radius: 18px; + border: 1px solid var(--line); + background: var(--surface-solid); + backdrop-filter: blur(18px) saturate(145%); + box-shadow: 0 20px 44px rgba(0, 0, 0, 0.22); + padding: 0.9rem 0.95rem; +} + +.oa-cookie-card h2 { + margin: 0; + font-size: 1rem; + letter-spacing: -0.01em; + line-height: 1.2; + color: var(--text); +} + +.oa-cookie-card p { + margin: 0.5rem 0 0; + font-size: 0.88rem; + line-height: 1.45; + color: var(--muted); +} + +.oa-cookie-actions { + display: flex; + justify-content: flex-end; + gap: 0.44rem; + margin-top: 0.74rem; +} + +.oa-cookie-btn { + appearance: none; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--surface-solid); + color: var(--text); + min-height: 2rem; + padding: 0 0.78rem; + cursor: pointer; + font-family: inherit; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: -0.01em; + transition: border-color 140ms ease, background 140ms ease, color 140ms ease; +} + +.oa-cookie-btn:hover, +.oa-cookie-btn:focus-visible { + border-color: color-mix(in srgb, var(--brand) 36%, var(--line)); +} + +.oa-cookie-btn.is-primary { + border-color: transparent; + background: linear-gradient(124deg, #063b88, var(--brand)); + color: #eef6ff; +} + +.oa-cookie-btn.is-ghost { + background: transparent; + color: var(--muted); +} + +.hero { + margin-top: clamp(1.2rem, 2.8vw, 2.2rem); + border-radius: var(--radius-xl); + padding: clamp(1.3rem, 2.7vw, 2.5rem); + display: grid; + grid-template-columns: 1.14fr 0.86fr; + align-items: center; + gap: clamp(1rem, 2vw, 2.1rem); +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 0.45rem; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.28rem 0.72rem; + color: var(--muted); + font-size: 0.82rem; + margin-bottom: 0.95rem; +} + +h1 { + margin: 0; + font-size: clamp(2rem, 4.9vw, 4.7rem); + line-height: 1.03; + letter-spacing: -0.02em; + text-wrap: balance; +} + +.lead { + margin: 1rem 0 0; + color: var(--muted); + font-size: clamp(1rem, 1.34vw, 1.2rem); + max-width: 62ch; +} + +.actions { + margin-top: 1.2rem; + display: flex; + flex-wrap: wrap; + gap: 0.72rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.45rem; + padding: 0.55rem 1rem; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--surface-solid); + color: var(--text); + font: inherit; + font-size: 0.93rem; + font-weight: 700; + cursor: pointer; + white-space: nowrap; + transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease; +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: 0 14px 26px rgba(7, 16, 31, 0.16); +} + +.btn.primary { + border-color: transparent; + background: linear-gradient(124deg, #063b88, var(--brand)); + color: #eef6ff; +} + +.hero-panel { + border-radius: var(--radius-lg); + border: 1px solid var(--line); + background: var(--surface-solid); + padding: clamp(1rem, 1.8vw, 1.4rem); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.68rem; +} + +.metric { + border-radius: var(--radius-md); + border: 1px solid var(--line); + padding: 0.72rem 0.78rem; + background: rgba(255, 255, 255, 0.04); +} + +.metric dt { + margin: 0; + font-size: 0.75rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.metric dd { + margin: 0.34rem 0 0; + font-size: 1.06rem; + font-weight: 700; +} + +.section { + margin-top: clamp(1rem, 2vw, 1.8rem); + border-radius: var(--radius-xl); + padding: clamp(1.04rem, 2vw, 1.7rem); +} + +.section h2 { + margin: 0; + font-size: clamp(1.24rem, 1.9vw, 1.95rem); + letter-spacing: -0.01em; +} + +.sub { + margin: 0.56rem 0 1.1rem; + color: var(--muted); + max-width: 72ch; +} + +.grid-3 { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.74rem; +} + +.grid-4 { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.74rem; +} + +.card { + border-radius: var(--radius-lg); + border: 1px solid var(--line); + background: var(--surface-solid); + padding: 0.95rem; + min-height: 10.2rem; +} + +.card h3 { + margin: 0 0 0.5rem; + font-size: 1.02rem; + letter-spacing: -0.01em; +} + +.card p { + margin: 0; + color: var(--muted); + font-size: 0.93rem; +} + +.timeline { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.74rem; +} + +.flow-visualizer { + overflow: hidden; +} + +.flow-toolbar { + margin-bottom: 0.5rem; +} + +.flow-diagram .flow-toolbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.9rem; +} + +.flow-diagram-figure { + margin: 0; + border-radius: 18px; + border: 1px solid rgba(134, 191, 255, 0.2); + background: + radial-gradient(920px 340px at 12% 0%, rgba(57, 110, 220, 0.16), transparent 72%), + linear-gradient(170deg, rgba(6, 12, 22, 0.82), rgba(3, 7, 14, 0.92)); + padding: clamp(0.5rem, 1vw, 0.74rem); +} + +.flow-diagram-image { + display: block; + width: 100%; + height: auto; + border-radius: 12px; + border: 1px solid rgba(125, 184, 255, 0.18); + background: #02050d; +} + +.flow-diagram-caption { + margin: 0.62rem 0 0; + color: #a8bdd8; + font-size: 0.84rem; +} + +.flow-btn { + min-height: 2.32rem; + padding: 0.5rem 0.88rem; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.06); + color: #dce6f6; + font-size: 0.84rem; + font-weight: 650; +} + +.flow-btn:hover, +.flow-btn:focus-visible { + border-color: rgba(132, 198, 255, 0.44); + box-shadow: 0 0 0 1px rgba(132, 198, 255, 0.2); +} + +.flow-status { + min-height: 2.06rem; + display: inline-flex; + align-items: center; + width: fit-content; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.14); + padding: 0 0.68rem; + font-size: 0.8rem; + color: #c2d1e5; + background: rgba(255, 255, 255, 0.03); +} + +.flow-lab { + border-radius: 20px; + border: 1px solid rgba(121, 181, 255, 0.24); + background: + radial-gradient(1200px 420px at 8% -10%, rgba(60, 117, 236, 0.23), transparent 62%), + radial-gradient(980px 340px at 90% 0%, rgba(73, 160, 255, 0.16), transparent 68%), + linear-gradient(170deg, rgba(6, 11, 22, 0.8), rgba(5, 9, 17, 0.9)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 18px 48px rgba(0, 0, 0, 0.34); + padding: clamp(0.82rem, 1.8vw, 1.26rem); +} + +.flow-lab-controls { + display: grid; + gap: 0.56rem; +} + +.flow-input-label { + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.02em; + color: #c8d6ea; +} + +.flow-input-wrap { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 0.52rem; + align-items: center; +} + +.flow-scenarios { + display: inline-flex; + gap: 0.44rem; + flex-wrap: wrap; +} + +.flow-scenario { + appearance: none; + border: 1px solid rgba(134, 191, 255, 0.24); + background: rgba(7, 14, 26, 0.66); + color: #cedcf1; + min-height: 2.02rem; + padding: 0 0.66rem; + border-radius: 999px; + font: inherit; + font-size: 0.79rem; + font-weight: 620; + letter-spacing: 0.01em; + cursor: pointer; + transition: border-color 150ms ease, color 150ms ease, background 150ms ease, box-shadow 150ms ease; +} + +.flow-scenario:hover, +.flow-scenario:focus-visible { + border-color: rgba(158, 213, 255, 0.44); + color: #edf5ff; +} + +.flow-scenario.is-active { + border-color: rgba(154, 211, 255, 0.52); + color: #ffffff; + background: linear-gradient(124deg, rgba(24, 78, 172, 0.86), rgba(33, 123, 228, 0.8)); + box-shadow: 0 8px 18px rgba(47, 119, 221, 0.32); +} + +.flow-request-input { + width: 100%; + min-height: 2.34rem; + border-radius: 12px; + border: 1px solid rgba(141, 196, 255, 0.26); + background: rgba(5, 13, 26, 0.78); + color: #edf3ff; + font: inherit; + font-size: 0.9rem; + letter-spacing: -0.005em; + padding: 0.52rem 0.72rem; + outline: none; + transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; +} + +.flow-request-input::placeholder { + color: #7f95b4; +} + +.flow-request-input:focus-visible { + border-color: rgba(151, 208, 255, 0.56); + box-shadow: 0 0 0 2px rgba(103, 176, 255, 0.22); + background: rgba(7, 16, 31, 0.88); +} + +.flow-btn.is-run { + border-color: transparent; + background: linear-gradient(124deg, #0f58cb, #3f9dff); + color: #f4f8ff; + box-shadow: 0 10px 26px rgba(49, 127, 236, 0.3); +} + +.flow-lab-grid { + margin-top: 0.78rem; + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.6fr); + gap: 0.78rem; + align-items: stretch; +} + +.flow-canvas-shell { + position: relative; + margin-top: 0.76rem; + border-radius: 16px; + border: 1px solid rgba(140, 197, 255, 0.18); + background: + radial-gradient(700px 320px at 16% 12%, rgba(50, 111, 226, 0.22), transparent 76%), + radial-gradient(840px 360px at 92% 20%, rgba(72, 156, 255, 0.15), transparent 78%), + linear-gradient(170deg, rgba(5, 11, 20, 0.96), rgba(2, 5, 11, 0.98)); + min-height: clamp(280px, 34vw, 430px); + overflow: hidden; + isolation: isolate; +} + +.flow-canvas { + display: block; + width: 100%; + height: 100%; + min-height: inherit; +} + +.flow-hud { + position: absolute; + left: 0.82rem; + right: 0.82rem; + bottom: 0.82rem; + border-radius: 12px; + border: 1px solid rgba(132, 196, 255, 0.22); + background: linear-gradient(180deg, rgba(10, 17, 29, 0.8), rgba(6, 10, 19, 0.86)); + padding: 0.56rem 0.68rem; + backdrop-filter: blur(8px); +} + +.flow-hud-k { + margin: 0; + color: #8ea2bf; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.flow-hud-v { + margin: 0.32rem 0 0; + color: #e9f0fd; + font-size: 0.9rem; + line-height: 1.42; +} + +.flow-node-chip { + position: absolute; + top: 0.82rem; + left: 0.82rem; + border-radius: 999px; + border: 1px solid rgba(143, 203, 255, 0.36); + background: rgba(14, 23, 38, 0.78); + color: #dde8f8; + font-size: 0.78rem; + letter-spacing: 0.02em; + padding: 0.3rem 0.58rem; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.26); +} + +.flow-trace { + border-radius: 16px; + border: 1px solid rgba(140, 197, 255, 0.18); + background: linear-gradient(170deg, rgba(5, 11, 20, 0.92), rgba(2, 5, 11, 0.96)); + padding: 0.7rem; + display: grid; + grid-template-rows: auto 1fr; + min-height: clamp(280px, 34vw, 430px); +} + +.flow-trace-k { + margin: 0; + color: #9eb4d3; + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.flow-trace-list { + list-style: none; + margin: 0.6rem 0 0; + padding: 0; + display: grid; + gap: 0.42rem; + align-content: start; + max-height: 100%; + overflow: auto; +} + +.flow-trace-item { + border-radius: 10px; + border: 1px solid rgba(136, 192, 255, 0.14); + background: rgba(7, 15, 28, 0.7); + padding: 0.42rem 0.5rem; + display: grid; + gap: 0.16rem; + opacity: 0.66; + transition: opacity 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease, transform 180ms ease; +} + +.flow-trace-item .k { + color: #89a2c6; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.flow-trace-item .v { + color: #deebff; + font-size: 0.82rem; + line-height: 1.35; +} + +.flow-trace-item.is-pending { + opacity: 0.55; +} + +.flow-trace-item.is-active { + opacity: 1; + border-color: rgba(154, 210, 255, 0.52); + background: linear-gradient(160deg, rgba(17, 39, 67, 0.84), rgba(10, 25, 43, 0.9)); + box-shadow: 0 10px 24px rgba(38, 98, 182, 0.32); + transform: translateY(-1px); +} + +.flow-trace-item.is-complete { + opacity: 0.92; + border-color: rgba(122, 178, 240, 0.26); + background: rgba(12, 27, 46, 0.78); +} + +.flow-trace-item.is-active .k { + color: #b9d8ff; +} + +.flow-progress { + margin: 0.52rem 0 0.92rem; + width: 100%; + height: 7px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.flow-progress > span { + display: block; + height: 100%; + width: 0%; + border-radius: inherit; + background: linear-gradient(90deg, #4e9eff, #7cb7ff); + box-shadow: 0 0 18px rgba(116, 181, 255, 0.42); + transition: width 420ms ease; +} + +.flow-track { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.72rem; +} + +.flow-stage { + border-radius: var(--radius-lg); + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(10, 15, 22, 0.76); + min-height: 8.8rem; + padding: 0.82rem; + opacity: 0.68; + transition: opacity 220ms ease, border-color 220ms ease, box-shadow 220ms ease, transform 220ms ease; +} + +.flow-stage h3 { + margin: 0.28rem 0 0; + font-size: 1rem; + line-height: 1.24; + letter-spacing: -0.01em; +} + +.flow-stage p { + margin: 0.52rem 0 0; + color: #b6c5da; + font-size: 0.84rem; + line-height: 1.44; +} + +.flow-step-index { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 1.42rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.18); + color: #d8e2f0; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + background: rgba(255, 255, 255, 0.04); +} + +.flow-stage.is-active { + opacity: 1; + border-color: rgba(140, 201, 255, 0.5); + box-shadow: 0 0 0 1px rgba(122, 188, 255, 0.24), 0 18px 34px rgba(0, 0, 0, 0.34); + transform: translateY(-2px); +} + +.flow-stage.is-active .flow-step-index { + border-color: rgba(161, 214, 255, 0.58); + box-shadow: 0 0 16px rgba(129, 198, 255, 0.34); +} + +.flow-stage.is-complete { + opacity: 0.9; + border-color: rgba(125, 176, 234, 0.24); +} + +.step { + border-radius: var(--radius-lg); + border: 1px solid var(--line); + background: var(--surface-solid); + padding: 0.85rem; +} + +.step .k { + margin: 0; + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.step .v { + margin: 0.4rem 0 0; + font-weight: 700; + font-size: 1rem; +} + +.step .d { + margin: 0.46rem 0 0; + color: var(--muted); + font-size: 0.9rem; +} + +.bottom-cta { + margin-top: clamp(1rem, 2vw, 1.7rem); + border-radius: var(--radius-xl); + padding: clamp(1.1rem, 2.1vw, 1.8rem); + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 0.8rem; +} + +.bottom-cta .copy { + min-width: 0; +} + +.bottom-cta .actions { + margin: 0; + justify-content: flex-end; +} + +footer { + margin-top: 1.2rem; + color: var(--muted); + font-size: 0.9rem; + display: flex; + gap: 0.7rem; + flex-wrap: wrap; + justify-content: space-between; +} + +.mega-word { + margin-top: clamp(1.6rem, 4.2vw, 4.2rem); + border-top: 1px solid var(--line); + padding-top: clamp(0.7rem, 1.4vw, 1.6rem); + overflow: hidden; + width: 100vw; + margin-left: calc(50% - 50vw); + margin-right: calc(50% - 50vw); +} + +.mega-word p { + margin: 0; + font-size: clamp(7.2rem, 44vw, 136rem); + line-height: 0.74; + letter-spacing: -0.06em; + font-weight: 800; + color: var(--mega-ink); + text-align: center; + white-space: nowrap; + user-select: none; + pointer-events: none; +} + +:target { + scroll-margin-top: 6.4rem; +} + +.reveal { + opacity: 0; + transform: translateY(16px); + transition: opacity 520ms ease, transform 520ms ease; +} + +.reveal.is-visible { + opacity: 1; + transform: translateY(0); +} + +:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; + border-radius: 0.64rem; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +@media (max-width: 1120px) { + .app-shell { + grid-template-columns: 1fr; + padding: clamp(0.48rem, 2.4vw, 0.9rem); + } + + .left-dock { + position: static; + top: auto; + height: auto; + } + + .dock-scroll { + overflow: visible; + } + + .page-grid { + grid-template-columns: 1fr; + } + + .left-rail { + position: static; + top: auto; + } + + .hero { + grid-template-columns: 1fr; + } + + .grid-3 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .grid-4, + .timeline { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .flow-track { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .flow-input-wrap { + grid-template-columns: minmax(0, 1fr); + } + + .flow-lab-grid { + grid-template-columns: 1fr; + } + + .flow-trace { + min-height: 220px; + } + + .flow-status { + width: 100%; + } +} + +@media (max-width: 760px) { + .shell { + width: 100vw; + padding-inline: clamp(0.62rem, 3.8vw, 1rem); + } + + .app-shell { + padding-inline: clamp(0.45rem, 3.4vw, 0.72rem); + } + + .topbar { + align-items: stretch; + flex-direction: column; + } + + .nav { + justify-content: flex-start; + } + + .hero-panel { + grid-template-columns: 1fr; + } + + .grid-3, + .grid-4, + .timeline, + .bottom-cta { + grid-template-columns: 1fr; + } + + .flow-track { + grid-template-columns: 1fr; + } + + .flow-toolbar { + align-items: flex-start; + } + + .flow-diagram .flow-toolbar { + flex-direction: column; + align-items: stretch; + } + + .flow-input-wrap { + width: 100%; + grid-template-columns: 1fr; + } + + .flow-scenarios { + width: 100%; + } + + .flow-scenario { + flex: 1 1 auto; + justify-content: center; + } + + .flow-lab-grid { + grid-template-columns: 1fr; + gap: 0.62rem; + } + + .flow-canvas-shell, + .flow-trace { + min-height: 250px; + } + + .flow-trace-list { + max-height: 220px; + } + + .flow-hud-v { + font-size: 0.82rem; + } + + .bottom-cta .actions { + justify-content: flex-start; + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 1ms !important; + transition-duration: 1ms !important; + } + + .reveal, + .reveal.is-visible { + opacity: 1; + transform: none; + } +} + +/* -------------------------------------------------------------------------- */ +/* Home (OpenAI-like) */ +/* -------------------------------------------------------------------------- */ +body.home-openai { + background: + radial-gradient(1200px 480px at 10% -10%, rgba(74, 127, 255, 0.2), transparent 72%), + radial-gradient(980px 360px at 90% -18%, rgba(86, 178, 255, 0.12), transparent 76%), + #050505; + color: #f2f4f8; + letter-spacing: 0; + --oa-topbar-top: clamp(0.22rem, 0.5vw, 0.42rem); + --oa-topbar-h-fixed: 56px; + --oa-topbar-w-fixed: 920px; + --oa-topbar-h: var(--oa-topbar-h-fixed); + --oa-gutter: clamp(0.48rem, 1.08vw, 1.5rem); + --oa-shell: min(1800px, calc(100vw - (var(--oa-gutter) * 2))); + --oa-content-w: calc(var(--oa-shell) - (var(--oa-gutter) * 2)); + --oa-grid-gap: clamp(0.68rem, 1.32vw, 1.26rem); + --oa-rail-ratio: 0.14; + --oa-rail-w: clamp(132px, calc((100vw - (var(--oa-gutter) * 2)) * var(--oa-rail-ratio)), 220px); + --oa-topbar-w: var(--oa-topbar-w-fixed); +} + +.home-openai .oa-cookie-card { + border-color: rgba(255, 255, 255, 0.16); + background: linear-gradient(180deg, rgba(14, 20, 32, 0.8), rgba(7, 11, 19, 0.86)); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.42); +} + +.home-openai .oa-cookie-card h2 { + color: #edf4ff; +} + +.home-openai .oa-cookie-card p { + color: #b7c3d6; +} + +.home-openai .oa-cookie-btn { + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.04); + color: #dfe8f6; +} + +.home-openai .oa-cookie-btn.is-ghost { + background: transparent; + color: #9fb2c8; +} + +.home-openai .oa-cookie-btn.is-primary { + border-color: transparent; + background: linear-gradient(124deg, #0c4eb2, #3d91ff); + color: #f3f8ff; +} + +body.home-openai::before { + display: none; +} + +.home-openai .glass { + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.08); + box-shadow: none; + backdrop-filter: none; +} + +.oa-layout { + width: 100%; + margin-inline: 0; + padding: var(--oa-gutter); + margin-top: calc(var(--oa-topbar-h) + var(--oa-gutter)); + display: block; +} + +.home-openai .shell { + width: var(--oa-shell); + margin-inline: auto; + padding: calc(var(--oa-topbar-h) + var(--oa-gutter)) var(--oa-gutter) clamp(2rem, 3vw, 3.2rem); +} + +.oa-global-topbar { + position: fixed; + top: var(--oa-topbar-top); + left: 50%; + transform: translateX(-50%) scale(0.93); + transform-origin: center top; + z-index: 40; + display: inline-flex; + width: auto; + min-width: 0; + max-width: calc(100vw - (var(--oa-gutter) * 2)); + min-height: calc(var(--oa-topbar-h) - 8px); + height: calc(var(--oa-topbar-h) - 8px); + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: linear-gradient(180deg, rgba(16, 21, 31, 0.62), rgba(8, 12, 19, 0.44)); + -webkit-backdrop-filter: blur(14px) saturate(155%); + backdrop-filter: blur(14px) saturate(155%); + padding: 0.14rem 0.28rem; + gap: 0; + align-items: center; + justify-content: center; + transition: + transform 260ms cubic-bezier(0.22, 1, 0.36, 1), + min-height 260ms cubic-bezier(0.22, 1, 0.36, 1), + height 260ms cubic-bezier(0.22, 1, 0.36, 1), + padding 260ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 220ms ease, + background 220ms ease, + box-shadow 220ms ease; + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.3); +} + +.home-openai.preload .oa-global-topbar { + opacity: 0; + pointer-events: none; +} + +.oa-corner-brand { + position: absolute; + left: var(--oa-gutter); + top: var(--oa-topbar-top); + z-index: 35; + height: var(--oa-topbar-h); + display: inline-flex; + align-items: center; + color: #f3f7ff; + font-weight: 800; + letter-spacing: -0.02em; + font-size: clamp(1.02rem, 1.34vw, 1.5rem); + line-height: 1; +} + +.oa-topbar-brand { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: flex-start; + min-width: 0; + color: #f3f7ff; + font-size: clamp(0.9rem, 0.9vw, 1.02rem); + font-weight: 700; + letter-spacing: -0.015em; + white-space: nowrap; + flex: 0 0 auto; + padding-inline: 0.16rem; +} + +#research, +#safety, +#business, +#developers, +#chat, +#agents, +#news { + scroll-margin-top: calc(var(--oa-topbar-h) + clamp(0.8rem, 1.6vw, 1.4rem)); +} + +.oa-anchor { + display: block; + height: 1px; +} + +.oa-global-topbar.is-scrolled { + border-color: rgba(255, 255, 255, 0.18); + background: linear-gradient(180deg, rgba(16, 21, 31, 0.7), rgba(8, 12, 19, 0.5)); + backdrop-filter: blur(16px) saturate(165%); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.38); +} + +.oa-global-nav { + position: static; + width: auto; + min-width: 0; + max-width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 0.22rem; + flex-wrap: nowrap; + flex: 0 0 auto; + overflow-x: auto; + scrollbar-width: none; +} + +.oa-global-nav::-webkit-scrollbar { + display: none; +} + +.oa-topbar-brand, +.oa-topbar-right { + position: relative; + z-index: 1; +} + +.oa-topbar-right { + display: none; + margin-left: 0; + flex: 0 0 auto; + padding-left: 0.08rem; +} + +.oa-global-topbar .oa-topbar-right { + display: none !important; +} + +.home-openai .oa-global-nav .nav-link { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + overflow: hidden; + isolation: isolate; + border-radius: 999px; + border: 1px solid transparent; + min-height: 1.68rem; + color: #d7dfec; + font-size: 0.76rem; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1; + white-space: nowrap; + word-break: keep-all; + writing-mode: horizontal-tb; + padding: 0.14rem 0.56rem; + transform: none; +} + +.home-openai .oa-global-nav .nav-link::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: 999px; + background: radial-gradient( + 120px 48px at var(--mx, 50%) var(--my, 50%), + rgba(114, 185, 255, 0.3) 0%, + rgba(114, 185, 255, 0.1) 34%, + rgba(114, 185, 255, 0) 70% + ); + opacity: 0; + transition: opacity 180ms ease; + z-index: -1; + pointer-events: none; +} + +.home-openai .oa-global-nav .nav-link:hover, +.home-openai .oa-global-nav .nav-link:focus-visible { + background: transparent; + border-color: transparent; + color: #e5ebf5; + transform: none; +} + +.home-openai .oa-global-nav .nav-link.is-active { + background: rgba(255, 255, 255, 0.11); + border-color: rgba(132, 198, 255, 0.34); + color: #ffffff; + box-shadow: 0 0 14px rgba(98, 173, 255, 0.2); + transform: none; +} + +.home-openai .oa-global-nav .nav-link.is-active::before, +.home-openai .oa-global-nav .nav-link:focus-visible::before { + opacity: 1; +} + +.oa-sidebar { + position: fixed; + left: var(--oa-gutter); + top: clamp(4.2rem, 8vh, 6rem); + z-index: 30; + width: var(--oa-rail-w); + height: calc(100dvh - clamp(4.2rem, 8vh, 6rem) - var(--oa-gutter)); + border-radius: 0; + border: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; + padding: 0; + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; +} + +.oa-menu { + margin-top: 0; + display: grid; + width: fit-content; + max-width: 100%; + --oa-rail-link-w: auto; + gap: 0.42rem; +} + +.home-openai .rail-link { + position: relative; + display: flex; + align-items: center; + width: var(--oa-rail-link-w); + max-width: 100%; + overflow: hidden; + isolation: isolate; + border-radius: 10px; + border: 0; + background: rgba(255, 255, 255, 0.01); + color: #d5dbe5; + font-size: 0.94rem; + font-weight: 500; + min-height: 1.82rem; + line-height: 1; + white-space: nowrap; + word-break: keep-all; + padding: 0.3rem 0.44rem; + transition: color 160ms ease, background 160ms ease, text-shadow 160ms ease; + transform: none; +} + +.home-openai .rail-link::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(90deg, rgba(111, 181, 255, 0.28), rgba(111, 181, 255, 0.06)); + opacity: 0; + transition: opacity 170ms ease; + z-index: -1; + pointer-events: none; +} + +.home-openai .rail-link:hover { + color: #e3eaf6; + background: rgba(255, 255, 255, 0.01); + text-shadow: none; + text-decoration: none; + transform: none; +} + +.home-openai .rail-link:focus-visible::before, +.home-openai .rail-link.is-active::before { + opacity: 1; +} + +.home-openai .rail-link.is-active { + color: #ffffff; + border: 0; + background: rgba(19, 42, 71, 0.42); + text-shadow: 0 0 14px rgba(128, 196, 255, 0.28); + font-weight: 500; + text-decoration: none; + transform: none; +} + +.oa-main { + min-width: 0; + margin-left: calc(var(--oa-rail-w) + var(--oa-grid-gap)); + width: calc(100% - (var(--oa-rail-w) + var(--oa-grid-gap))); +} + +.oa-topbar { + position: sticky; + top: 0.4rem; + z-index: 20; + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.68); + backdrop-filter: blur(12px); + padding: 0.58rem 0.8rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.oa-topbar-left, +.oa-topbar-right { + display: flex; + align-items: center; + gap: 0.62rem; +} + +.oa-mini { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.35rem 0.72rem; + border-radius: 999px; + color: #d8dfe9; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + font-size: 0.82rem; +} + +.oa-login { + display: inline-flex; + align-items: center; + min-height: 1.72rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font-size: 0.76rem; + font-weight: 700; + padding: 0.2rem 0.62rem; +} + +.home-openai .lang-switch { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.06); + min-width: 4.52rem; + padding: 0.14rem; +} + +.home-openai .lang-btn { + color: #d7deeb; + min-height: 1.46rem; + font-size: 0.72rem; + padding: 0 0.44rem; +} + +.home-openai .lang-btn.is-active { + background: #ffffff; + color: #0b0b0b; +} + +.oa-main main { + margin-top: 0.24rem; +} + +.oa-hero { + display: grid; + grid-template-columns: minmax(0, 1.72fr) minmax(0, 0.9fr); + gap: clamp(0.66rem, 1.25vw, 1rem); +} + +.oa-side-stack { + display: grid; + gap: clamp(0.66rem, 1.1vw, 0.9rem); +} + +.oa-card { + position: relative; + overflow: hidden; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: + radial-gradient(140% 100% at 50% -24%, rgba(126, 173, 255, 0.1), transparent 58%), + linear-gradient(180deg, rgba(23, 27, 36, 0.84), rgba(10, 11, 15, 0.9)); + padding: clamp(0.88rem, 1.42vw, 1.16rem); + min-height: 172px; +} + +.oa-card::after { + content: ""; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 1px; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0.04)); + pointer-events: none; +} + +.oa-card.is-clickable { + cursor: pointer; + transition: transform 150ms ease, border-color 150ms ease, box-shadow 150ms ease; +} + +.oa-card.is-clickable:hover, +.oa-card.is-clickable:focus-visible { + transform: translateY(-2px); + border-color: rgba(143, 191, 255, 0.46); + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.34); +} + +.oa-card h1 { + margin: 0.76rem 0 0; + font-size: clamp(1.7rem, 3.4vw, 3.25rem); + line-height: 1.05; + letter-spacing: -0.03em; +} + +.oa-card h2 { + margin: 0.54rem 0 0; + font-size: clamp(1rem, 1.36vw, 1.38rem); + line-height: 1.22; + letter-spacing: -0.01em; +} + +.oa-card p { + margin: 0.58rem 0 0; + color: #c3cddb; + font-size: 0.92rem; + line-height: 1.5; +} + +.oa-feature { + min-height: clamp(360px, 44vw, 710px); +} + +.oa-feature-visual { + border-radius: 14px; + min-height: clamp(188px, 28vw, 430px); + background: + radial-gradient(circle at 8% 2%, rgba(228, 237, 255, 0.8), transparent 32%), + radial-gradient(circle at 90% 90%, rgba(166, 132, 255, 0.76), transparent 44%), + radial-gradient(circle at 58% 32%, rgba(78, 130, 255, 0.9), transparent 54%), + linear-gradient(138deg, #0f3fcb, #232ddd 48%, #5a38c9 100%); +} + +.oa-thumb { + border-radius: 12px; + min-height: 118px; +} + +.oa-thumb-blue { + background: + radial-gradient(circle at 14% 20%, rgba(220, 229, 255, 0.74), transparent 34%), + radial-gradient(circle at 86% 84%, rgba(147, 115, 255, 0.7), transparent 42%), + linear-gradient(130deg, #3a6af2, #2c32e0 58%, #5844b8 100%); +} + +.oa-thumb-orange { + background: + radial-gradient(circle at 10% 14%, rgba(255, 238, 184, 0.7), transparent 42%), + radial-gradient(circle at 90% 84%, rgba(255, 167, 94, 0.64), transparent 48%), + linear-gradient(122deg, #d58b1d, #f28b19 56%, #da5d22 100%); +} + +.oa-meta { + margin-top: 0.8rem; + display: flex; + gap: 0.76rem; + flex-wrap: wrap; + color: #95a5bd; + font-size: 0.78rem; +} + +.oa-grid { + margin-top: clamp(0.76rem, 1.24vw, 1rem); + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: clamp(0.66rem, 1.1vw, 0.9rem); +} + +.oa-cta { + margin-top: clamp(0.78rem, 1.24vw, 1rem); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.11); + background: linear-gradient(180deg, rgba(15, 18, 27, 0.88), rgba(7, 9, 14, 0.92)); + padding: clamp(0.9rem, 1.35vw, 1.16rem); + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.8rem; +} + +.oa-cta h2 { + margin: 0; + font-size: clamp(1.1rem, 1.54vw, 1.58rem); +} + +.oa-cta p { + margin: 0.35rem 0 0; + color: #b7c2d3; +} + +.home-openai .btn.primary { + background: linear-gradient(122deg, #2c80ff, #2867ff); + border-color: rgba(122, 190, 255, 0.5); +} + +.home-openai .mega-word { + margin-top: clamp(1.2rem, 2.4vw, 2.2rem); + padding-top: clamp(0.6rem, 1.2vw, 1.2rem); + border-top: 1px solid rgba(255, 255, 255, 0.09); + overflow: hidden; + display: flex; + justify-content: center; +} + +.home-openai .mega-word p { + margin: 0; + width: 100%; + text-align: center; + font-size: clamp(10rem, 22vw, 44rem); + line-height: 0.88; + letter-spacing: -0.045em; + white-space: nowrap; + transform: none; + color: rgba(243, 248, 255, 0.28); +} + +.oa-footer { + margin-top: 0.8rem; + padding-bottom: 0.3rem; + color: #9aa8be; + display: flex; + justify-content: space-between; + gap: 0.64rem; + flex-wrap: wrap; + font-size: 0.84rem; +} + +.oa-site-footer { + margin-top: clamp(1rem, 2.2vw, 1.8rem); + padding-top: clamp(0.8rem, 1.6vw, 1.4rem); + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.oa-footer-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: clamp(0.86rem, 1.8vw, 1.6rem); +} + +.oa-footer-col { + display: grid; + gap: 0.44rem; + align-content: start; +} + +.oa-footer-col h3 { + margin: 0 0 0.22rem; + font-size: 0.9rem; + letter-spacing: -0.01em; + color: #eaf0fa; +} + +.oa-footer-col a { + color: #9cadc5; + font-size: 0.84rem; + line-height: 1.35; + transition: color 140ms ease; +} + +.oa-footer-col a:hover, +.oa-footer-col a:focus-visible { + color: #ffffff; +} + +.oa-footer-lang { + display: inline-flex; + align-items: center; + gap: 0.54rem; +} + +.oa-footer-lang > span { + font-size: 0.8rem; + color: #9cadc5; +} + +@media (max-width: 1180px) { + body.home-openai { + --oa-shell: calc(100vw - (var(--oa-gutter) * 2)); + --oa-content-w: calc(var(--oa-shell) - (var(--oa-gutter) * 2)); + --oa-rail-ratio: 0.24; + } + + .oa-global-topbar { + left: 50%; + transform: translateX(-50%); + width: fit-content; + min-width: 0; + max-width: calc(100vw - (var(--oa-gutter) * 2)); + min-height: var(--oa-topbar-h); + max-height: var(--oa-topbar-h); + border-radius: 999px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + gap: 0.28rem; + padding: 0.22rem 0.34rem; + } + + .oa-topbar-brand { + min-width: auto; + font-size: 0.9rem; + } + + .oa-corner-brand { + left: 0.74rem; + font-size: 1.16rem; + } + + .oa-global-nav { + position: static; + left: auto; + transform: none; + max-width: none; + min-height: 1.8rem; + flex: 0 1 auto; + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; + justify-content: flex-start; + gap: 0.2rem; + } + + .oa-topbar-right { + display: none; + } + + .oa-login { + display: none; + } + + .oa-sidebar { + display: none; + } + + .oa-main { + margin-left: 0; + width: 100%; + } + + .oa-hero { + grid-template-columns: 1fr; + } + + .oa-footer-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1020px) { + .oa-global-topbar { + width: auto; + min-width: 0; + max-width: calc(100vw - (var(--oa-gutter) * 2)); + } +} + +@media (max-width: 760px) { + body.home-openai { + --oa-gutter: 0.42rem; + --oa-content-w: calc(100vw - (var(--oa-gutter) * 2)); + } + + .oa-layout { + width: 100%; + margin-top: calc(var(--oa-topbar-h) + 0.28rem); + padding: 0.3rem; + } + + .home-openai .shell { + width: 100%; + padding: calc(var(--oa-topbar-h) + 0.52rem) var(--oa-gutter) 1.1rem; + } + + .oa-corner-brand { + left: 0.62rem; + top: 0.24rem; + height: 2rem; + font-size: 1.02rem; + } + + .oa-global-topbar { + top: 0.24rem; + width: auto; + min-width: 0; + max-width: calc(100vw - (var(--oa-gutter) * 2)); + min-height: var(--oa-topbar-h); + max-height: var(--oa-topbar-h); + border-radius: 999px; + overflow: hidden; + padding: 0.24rem 0.34rem; + } + + .oa-global-nav { + width: 100%; + justify-content: flex-start; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + scroll-snap-type: x proximity; + touch-action: pan-x; + padding-inline: 0.04rem; + } + + .oa-global-nav::-webkit-scrollbar { + display: none; + } + + .oa-topbar-brand { + font-size: 0.84rem; + } + + .home-openai .oa-global-nav .nav-link { + flex: 0 0 auto; + font-size: 0.72rem; + min-height: 1.46rem; + line-height: 1; + white-space: nowrap; + word-break: keep-all; + padding: 0.14rem 0.38rem; + scroll-snap-align: start; + } + + .oa-topbar-right .lang-switch { + min-width: 4.3rem; + } + + .oa-grid { + grid-template-columns: 1fr; + } + + .oa-footer-grid { + grid-template-columns: 1fr; + } + + .oa-footer { + flex-direction: column; + align-items: flex-start; + } + + .oa-cookie-consent { + left: 0.5rem; + right: 0.5rem; + width: auto; + } + + .oa-cookie-card { + border-radius: 14px; + padding: 0.78rem 0.82rem; + } + + .oa-cookie-actions { + justify-content: stretch; + } + + .oa-cookie-btn { + flex: 1 1 0; + min-width: 0; + } + + .oa-cta { + flex-direction: column; + align-items: flex-start; + } + + .home-openai .mega-word p { + font-size: clamp(7rem, 24vw, 18rem); + line-height: 0.8; + } +} + +/* -------------------------------------------------------------------------- */ +/* Chat / Admin / Code blocks */ +/* -------------------------------------------------------------------------- */ + +.oa-pre { + margin: 0; + padding: 0.9rem 1rem; + border-radius: 16px; + border: 1px solid rgba(141, 196, 255, 0.18); + background: + radial-gradient(800px 240px at 12% 0%, rgba(70, 140, 255, 0.16), transparent 66%), + linear-gradient(180deg, rgba(12, 15, 22, 0.92), rgba(5, 6, 10, 0.94)); + color: #d9e6fb; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.84rem; + line-height: 1.45; + overflow: auto; + max-width: 100%; +} + +.oa-pre code { + font-family: inherit; + white-space: pre; +} + +.chat-shell { + display: grid; + gap: 1rem; +} + +.chat-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.9rem; + flex-wrap: wrap; +} + +.chat-title { + margin: 0; + font-size: clamp(1.5rem, 2.6vw, 2.2rem); + letter-spacing: -0.02em; +} + +.chat-user { + display: inline-flex; + align-items: center; + gap: 0.56rem; + padding: 0.22rem 0.26rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.03); +} + +.chat-user span { + color: #c9d6ea; + font-size: 0.84rem; + font-weight: 650; + padding-inline: 0.55rem; + white-space: nowrap; +} + +.chat-auth { + border-radius: 18px; + padding: 1rem; +} + +.chat-panel { + display: grid; + gap: 0.72rem; +} + +.chat-log { + display: flex; + flex-direction: column; + gap: 0.6rem; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: + radial-gradient(900px 320px at 12% 0%, rgba(70, 140, 255, 0.12), transparent 70%), + linear-gradient(180deg, rgba(14, 16, 23, 0.82), rgba(7, 7, 10, 0.9)); + padding: 0.9rem; + min-height: clamp(260px, 34vh, 520px); + max-height: min(60vh, 760px); + overflow: auto; +} + +.chat-bubble { + width: fit-content; + max-width: min(76ch, 100%); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 0.7rem 0.8rem; + background: rgba(255, 255, 255, 0.04); + color: #e7eefb; + white-space: pre-wrap; + word-break: break-word; +} + +.chat-bubble.is-user { + align-self: flex-end; + border-color: rgba(133, 195, 255, 0.26); + background: linear-gradient(135deg, rgba(16, 83, 191, 0.42), rgba(16, 22, 40, 0.55)); +} + +.chat-bubble.is-assistant { + align-self: flex-start; + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.03); +} + +.chat-form { + display: flex; + gap: 0.6rem; + align-items: center; +} + +.chat-form .flow-request-input { + flex: 1 1 auto; + min-width: 0; +} + +.admin-shell { + display: grid; + gap: 1rem; +} + +.admin-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.9rem; + flex-wrap: wrap; +} + +.admin-title { + margin: 0; + font-size: clamp(1.5rem, 2.6vw, 2.1rem); + letter-spacing: -0.02em; +} + +.admin-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.admin-card { + min-height: 0; +} + +.admin-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.7rem; + flex-wrap: wrap; +} + +.admin-token { + padding: 1rem; +} + +.admin-token-row { + display: flex; + gap: 0.6rem; + align-items: center; + flex-wrap: wrap; +} + +.admin-token-row .flow-request-input { + flex: 1 1 320px; + min-width: min(340px, 100%); +} + +.admin-token-meta { + margin-top: 0.62rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + flex-wrap: wrap; +} + +.admin-approvals { + display: grid; + gap: 0.66rem; +} + +.admin-approval { + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + padding: 0.9rem; +} + +.admin-approval-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.7rem; + flex-wrap: wrap; +} + +.admin-approval-title { + font-weight: 800; + letter-spacing: -0.01em; +} + +.admin-approval-badges { + display: inline-flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.admin-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 1.5rem; + padding: 0 0.6rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.04); + color: #dfe8f6; + font-size: 0.74rem; + font-weight: 800; + letter-spacing: 0.06em; +} + +.admin-badge.is-warn { + border-color: rgba(255, 189, 92, 0.36); + background: rgba(255, 189, 92, 0.12); + color: #ffd7a8; +} + +.admin-approval-meta { + margin-top: 0.5rem; + color: #98abc4; + font-size: 0.82rem; +} + +.admin-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.78rem; +} + +.admin-approval-body { + margin-top: 0.55rem; + color: #d7e2f4; + line-height: 1.55; +} + +.admin-approval-actions { + margin-top: 0.7rem; + display: flex; + gap: 0.6rem; + align-items: center; + flex-wrap: wrap; +} + +.admin-code { + flex: 1 1 180px; + min-width: 180px; + min-height: 2.45rem; + border-radius: 12px; + border: 1px solid rgba(141, 196, 255, 0.18); + background: rgba(5, 13, 26, 0.78); + color: #edf3ff; + padding: 0.52rem 0.72rem; + font: inherit; + font-size: 0.9rem; + outline: none; +} + +.admin-code:disabled { + opacity: 0.55; +} + +@media (max-width: 980px) { + .admin-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 680px) { + .chat-form { + flex-direction: column; + align-items: stretch; + } + + .chat-form .btn { + width: 100%; + } +} + +/* ------------------------------------------------------------------------- + Console UI (Chat + API Platform) + Scoped to body.console so marketing pages stay unchanged. + ------------------------------------------------------------------------- */ + +body.console { + --c-bg: #0b0f14; + --c-surface: rgba(255, 255, 255, 0.06); + --c-surface-2: rgba(255, 255, 255, 0.085); + --c-border: rgba(255, 255, 255, 0.12); + --c-text: rgba(255, 255, 255, 0.92); + --c-muted: rgba(255, 255, 255, 0.66); + --c-dimmer: rgba(255, 255, 255, 0.46); + --c-accent: #7cc6ff; + --c-accent-2: #4da8ff; + --c-danger: #ff5b5b; + --c-radius-xl: 22px; + --c-radius-lg: 16px; + --c-radius-md: 12px; + --c-shadow: 0 30px 90px rgba(0, 0, 0, 0.55); + + /* Console is always dark regardless of system theme; keep scrollbars subtle. */ + --sb-size: 5px; + --sb-track: transparent; + --sb-thumb: rgba(255, 255, 255, 0.045); + --sb-thumb-hover: rgba(255, 255, 255, 0.09); + + background: + radial-gradient(900px 600px at 18% 12%, rgba(77, 168, 255, 0.12), transparent 55%), + radial-gradient(900px 600px at 84% 16%, rgba(124, 198, 255, 0.08), transparent 62%), + radial-gradient(900px 600px at 72% 92%, rgba(38, 196, 157, 0.06), transparent 60%), + var(--c-bg); + color: var(--c-text); +} + +body.console::before { + display: none; +} + +body.console a { + color: inherit; +} + +body.console .muted { + color: var(--c-muted); +} + +body.console .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +body.console .console-app { + height: 100dvh; + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + background: transparent; + transition: grid-template-columns 240ms cubic-bezier(0.22, 1, 0.36, 1); +} + +/* Desktop sidebar collapse (ChatGPT-like). */ +body.console.sidebar-collapsed .console-app { + grid-template-columns: 0 minmax(0, 1fr); +} + +body.console.sidebar-collapsed .console-sidebar { + width: 0; + min-width: 0; + padding: 0; + overflow: hidden; + border-right: 0; + pointer-events: none; + opacity: 0; + transform: translateX(-18px); +} + +/* Avoid showing the "X" close button on desktop; hamburger toggles collapse. */ +@media (min-width: 961px) { + body.console .console-sidebar [data-sidebar-close] { + display: none; + } +} + +body.console .console-sidebar { + height: 100dvh; + overflow: auto; + padding: 18px 16px 18px; + border-right: 1px solid var(--c-border); + background: linear-gradient(180deg, rgba(12, 16, 24, 0.78), rgba(11, 15, 20, 0.56)); + backdrop-filter: blur(16px) saturate(140%); + display: flex; + flex-direction: column; + opacity: 1; + transform: translateX(0); + transition: + width 240ms cubic-bezier(0.22, 1, 0.36, 1), + min-width 240ms cubic-bezier(0.22, 1, 0.36, 1), + padding 240ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 180ms ease, + transform 240ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 180ms ease; +} + +body.console .console-brand { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 4px 2px 10px; +} + +body.console .console-logo { + font-size: 16px; + font-weight: 800; + letter-spacing: 0.01em; +} + +body.console .console-icon { + appearance: none; + border: 1px solid var(--c-border); + background: rgba(255, 255, 255, 0.03); + color: var(--c-text); + width: 36px; + height: 36px; + border-radius: 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: + background 180ms ease, + border-color 180ms ease, + transform 180ms ease, + box-shadow 180ms ease; +} + +body.console .console-icon:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); +} + +body.console .console-icon:focus-visible { + outline: 2px solid color-mix(in srgb, var(--c-accent) 72%, transparent); + outline-offset: 2px; +} + +body.console .console-toggle { + position: relative; + overflow: hidden; +} + +body.console .console-toggle-glyph { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 4px; +} + +body.console .console-toggle-panel { + width: 5px; + height: 14px; + border-radius: 999px; + background: rgba(124, 198, 255, 0.92); + box-shadow: 0 0 0 1px rgba(124, 198, 255, 0.14); + transform-origin: center; + transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease, background 180ms ease; +} + +body.console .console-toggle-lines { + width: 9px; + height: 14px; + position: relative; + display: inline-flex; + flex-direction: column; + justify-content: center; + gap: 4px; +} + +body.console .console-toggle-lines span { + display: block; + width: 100%; + height: 2px; + border-radius: 999px; + background: currentColor; + transform-origin: right center; + transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease, width 180ms ease; +} + +body.console .console-toggle[data-state="open"] .console-toggle-panel { + transform: translateX(1px); +} + +body.console .console-toggle[data-state="open"] .console-toggle-lines span:first-child { + transform: translateY(3px) rotate(-36deg); + width: 8px; +} + +body.console .console-toggle[data-state="open"] .console-toggle-lines span:last-child { + transform: translateY(-3px) rotate(36deg); + width: 8px; +} + +body.console .console-toggle[data-state="closed"] .console-toggle-panel { + transform: translateX(-1px); + background: rgba(255, 255, 255, 0.34); +} + +body.console .console-toggle[data-state="closed"] .console-toggle-lines span:first-child { + transform: translateY(0) rotate(0); +} + +body.console .console-toggle[data-state="closed"] .console-toggle-lines span:last-child { + transform: translateY(0) rotate(0); +} + +body.console .console-actions { + margin: 6px 0 14px; +} + +body.console .console-newchat { + width: 100%; + appearance: none; + border: 1px solid var(--c-border); + background: rgba(255, 255, 255, 0.035); + color: var(--c-text); + border-radius: 14px; + padding: 10px 12px; + cursor: pointer; + font: inherit; + font-weight: 700; + display: inline-flex; + align-items: center; + gap: 10px; + transition: background 140ms ease, border-color 140ms ease, transform 140ms ease; +} + +body.console .console-newchat:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-1px); +} + +body.console .console-plus { + width: 22px; + height: 22px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(124, 198, 255, 0.14); + border: 1px solid rgba(124, 198, 255, 0.22); +} + +body.console .console-section { + margin-top: 10px; +} + +body.console .console-section-label { + font-size: 12px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--c-dimmer); + margin: 10px 2px 10px; +} + +body.console .console-nav, +body.console .thread-list { + display: grid; + gap: 6px; +} + +body.console .console-nav-item, +body.console .thread-item { + appearance: none; + border: 1px solid transparent; + background: transparent; + color: var(--c-muted); + text-align: left; + border-radius: 12px; + padding: 10px 10px; + cursor: pointer; + font: inherit; + font-weight: 650; + transition: background 140ms ease, color 140ms ease, border-color 140ms ease; +} + +body.console .console-nav-item:hover, +body.console .thread-item:hover { + background: rgba(255, 255, 255, 0.04); + color: var(--c-text); +} + +body.console .console-nav-item.is-active, +body.console .thread-item.is-active { + background: rgba(124, 198, 255, 0.12); + border-color: rgba(124, 198, 255, 0.22); + color: var(--c-text); +} + +body.console .console-mini-nav { + display: grid; + gap: 8px; + margin: 14px 0 10px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +body.console .mini-link { + color: var(--c-muted); + font-size: 13px; + font-weight: 650; + padding: 6px 6px; + border-radius: 10px; + transition: color 140ms ease, background 140ms ease; +} + +body.console .mini-link:hover { + background: rgba(255, 255, 255, 0.04); + color: var(--c-text); +} + +body.console .console-spacer { + height: 1px; + flex: 1; +} + +body.console .console-auth { + border-top: 1px solid rgba(255, 255, 255, 0.08); + margin-top: 14px; + padding-top: 12px; + position: sticky; + bottom: 0; + background: linear-gradient(180deg, rgba(11, 15, 20, 0.0), rgba(11, 15, 20, 0.88) 22%, rgba(11, 15, 20, 0.96)); + backdrop-filter: blur(10px) saturate(140%); +} + +body.console .console-user-label { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--c-dimmer); +} + +body.console .console-user-id { + margin-top: 6px; + font-size: 13px; + color: var(--c-text); + word-break: break-word; +} + +body.console .console-user-sub { + margin-top: 6px; + font-size: 13px; + color: var(--c-muted); + line-height: 1.45; +} + +body.console .console-userchip-wrap { + position: relative; +} + +body.console .console-userchip { + width: 100%; + appearance: none; + border: 0; + background: transparent; + color: var(--c-text); + border-radius: 12px; + padding: 8px 6px; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + font: inherit; + font-weight: 750; + transition: background 140ms ease, border-color 140ms ease; +} + +body.console .console-userchip:hover { + background: rgba(255, 255, 255, 0.055); +} + +body.console .console-userchip:focus-visible { + outline: 2px solid color-mix(in srgb, var(--c-accent) 76%, transparent); + outline-offset: 2px; +} + +body.console .console-avatar { + width: 30px; + height: 30px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 900; + letter-spacing: -0.02em; + color: rgba(255, 255, 255, 0.92); + background: + radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.0) 55%), + hsl(var(--avatar-hue, 210) 82% 56% / 0.22); + border: 1px solid hsl(var(--avatar-hue, 210) 82% 56% / 0.35); +} + +body.console .console-avatar.has-image { + background-size: cover; + background-position: center; + background-repeat: no-repeat; + color: transparent; + border-color: rgba(255, 255, 255, 0.18); +} + +body.console .console-userchip-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +body.console .console-usermenu { + position: absolute; + left: 0; + bottom: calc(100% + 10px); + width: min(260px, 100%); + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(10, 15, 23, 0.92); + backdrop-filter: blur(14px) saturate(140%); + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.42); + padding: 6px; +} + +body.console .console-usermenu-item { + width: 100%; + appearance: none; + border: 0; + background: transparent; + color: rgba(255, 255, 255, 0.86); + border-radius: 12px; + padding: 10px 10px; + cursor: pointer; + font: inherit; + font-weight: 750; + text-align: left; + transition: background 140ms ease, color 140ms ease; +} + +body.console .console-usermenu-item:hover, +body.console .console-usermenu-item:focus-visible { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.94); + outline: none; +} + +body.console .console-auth-actions { + margin-top: 10px; + display: grid; + gap: 8px; +} + +body.console .console-btn { + appearance: none; + border: 1px solid var(--c-border); + background: rgba(255, 255, 255, 0.03); + color: var(--c-text); + border-radius: 14px; + padding: 10px 12px; + cursor: pointer; + font: inherit; + font-weight: 700; + transition: background 140ms ease, border-color 140ms ease, transform 140ms ease; +} + +body.console .console-btn:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-1px); +} + +body.console .console-btn.primary { + border-color: rgba(124, 198, 255, 0.22); + background: linear-gradient(120deg, rgba(77, 168, 255, 0.22), rgba(124, 198, 255, 0.14)); +} + +body.console .console-btn.danger { + border-color: rgba(255, 91, 91, 0.35); + background: rgba(255, 91, 91, 0.06); +} + +body.console .console-btn:focus-visible { + outline: 2px solid color-mix(in srgb, var(--c-accent) 76%, transparent); + outline-offset: 2px; +} + +body.console .console-auth-msg, +body.console .console-inline-msg { + font-size: 13px; + color: var(--c-muted); +} + +body.console .console-main { + height: 100dvh; + overflow: auto; + min-width: 0; + display: flex; + flex-direction: column; +} + +body.console [data-console-page="chat"] .console-main { + overflow: hidden; +} + +body.console .console-topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(180deg, rgba(11, 15, 20, 0.92), rgba(11, 15, 20, 0.72)); + backdrop-filter: blur(14px) saturate(140%); + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.12); +} + +body.console .console-topbar-title { + font-weight: 800; + letter-spacing: 0.01em; +} + +body.console .console-topbar-right { + display: inline-flex; + align-items: center; + gap: 10px; +} + +body.console .console-pill { + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.03); + border-radius: 999px; + padding: 9px 12px; + font-size: 13px; + font-weight: 750; + color: var(--c-text); + transition: background 140ms ease, border-color 140ms ease; +} + +body.console .console-pill:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.22); +} + +body.console .console-container { + width: min(1060px, 100%); + margin: 0 auto; + padding: 18px 16px 28px; +} + +body.console .console-hero { + padding: 10px 2px 4px; +} + +body.console .console-h1 { + margin: 0; + font-size: clamp(1.7rem, 2.4vw, 2.2rem); + line-height: 1.18; + letter-spacing: -0.02em; +} + +body.console .console-lede { + margin: 10px 0 0; + color: var(--c-muted); + max-width: 70ch; +} + +body.console .console-h2 { + margin: 0 0 10px; + font-size: 16px; + letter-spacing: -0.01em; +} + +body.console .console-text { + margin: 0 0 12px; + color: var(--c-muted); +} + +body.console .console-grid { + margin-top: 14px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +body.console .console-split { + margin-top: 12px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +body.console .console-stack { + display: grid; + gap: 12px; +} + +body.console .console-card { + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.035); + border-radius: var(--c-radius-xl); + padding: 14px; + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.26); +} + +body.console .console-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +body.console .console-card-title { + margin: 0; + font-size: 15px; + font-weight: 800; + letter-spacing: -0.01em; +} + +body.console .console-card-text { + margin: 8px 0 0; + color: var(--c-muted); +} + +body.console .console-badge { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.03); + padding: 6px 10px; + border-radius: 999px; + color: var(--c-muted); + font-size: 12px; + font-weight: 750; +} + +body.console .console-link { + margin-top: 12px; + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 800; + color: color-mix(in srgb, var(--c-accent) 82%, white); +} + +body.console .console-link:hover { + text-decoration: underline; + text-underline-offset: 4px; +} + +body.console .console-section-block { + margin-top: 18px; +} + +body.console .console-list { + margin: 0; + padding-left: 1.1rem; + color: var(--c-muted); + line-height: 1.6; +} + +body.console .oa-pre { + margin: 10px 0 0; + border-radius: 14px; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.25); + overflow: auto; +} + +body.console .oa-pre code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + color: rgba(255, 255, 255, 0.88); +} + +body.console code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.92em; +} + +body.console .console-form-row { + display: grid; + gap: 6px; + margin-top: 10px; +} + +body.console .console-label { + font-size: 13px; + font-weight: 750; + color: var(--c-muted); +} + +body.console .console-input { + appearance: none; + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.03); + color: var(--c-text); + border-radius: 14px; + padding: 10px 12px; + font: inherit; + outline: none; + transition: border-color 140ms ease, background 140ms ease; +} + +body.console .console-input:focus { + border-color: rgba(124, 198, 255, 0.48); + background: rgba(255, 255, 255, 0.045); +} + +body.console .console-actions-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + +body.console.modal-open .console-main { + overflow: hidden; +} + +body.console .console-modal { + position: fixed; + inset: 0; + z-index: 120; + display: grid; + place-items: center; + padding: 16px; +} + +body.console .console-modal[hidden] { + display: none; +} + +body.console .console-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.58); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +body.console .console-modal-card { + position: relative; + width: min(760px, calc(100vw - 24px)); + max-height: min(720px, calc(100dvh - 24px)); + overflow: auto; + border-radius: 26px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(11, 15, 20, 0.94); + box-shadow: 0 38px 120px rgba(0, 0, 0, 0.6); +} + +body.console .console-modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 14px 14px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +body.console .console-modal-title { + margin: 0; + font-size: 15px; + font-weight: 900; + letter-spacing: -0.01em; +} + +body.console .console-modal-content { + padding: 14px; + display: grid; + gap: 12px; +} + +body.console .console-modal-text { + margin: 0; + color: var(--c-muted); +} + +body.console .console-modal-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px 14px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +body.console .console-modal-actions { + display: inline-flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +body.console .console-checkline { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: 13px; + color: var(--c-text); +} + +body.console .console-checkline input { + margin-top: 3px; + accent-color: var(--c-accent); +} + +body.console .console-alert { + border-radius: 18px; + border: 1px solid rgba(124, 198, 255, 0.22); + background: rgba(124, 198, 255, 0.1); + padding: 12px 12px; +} + +body.console .console-alert-text { + margin: 0; + color: rgba(255, 255, 255, 0.88); + line-height: 1.55; +} + +body.console .console-check { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--c-muted); + font-size: 13px; +} + +body.console .console-key-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; +} + +body.console .console-table-wrap { + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + overflow: auto; +} + +body.console .console-table { + width: 100%; + border-collapse: collapse; + min-width: 720px; +} + +body.console .console-table th, +body.console .console-table td { + padding: 10px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + vertical-align: top; + color: var(--c-muted); + font-size: 13px; +} + +body.console .console-table th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(11, 15, 20, 0.92); + color: rgba(255, 255, 255, 0.78); + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +body.console .console-table td { + color: rgba(255, 255, 255, 0.76); +} + +body.console .console-table tr:hover td { + background: rgba(255, 255, 255, 0.02); +} + +body.console .console-empty { + color: var(--c-dimmer); + text-align: center; + padding: 18px 12px; +} + +body.console .console-filter-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +body.console .console-metric { + padding: 14px 16px; +} + +body.console .console-metric-label { + color: var(--c-muted); + font-size: 13px; + font-weight: 750; +} + +body.console .console-metric-value { + margin-top: 8px; + font-size: 26px; + font-weight: 900; + letter-spacing: -0.03em; +} + +body.console .chat-shell { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 14px; + --chat-col: min(980px, 100%); +} + +body.console .chat-status { + width: var(--chat-col); + max-width: 100%; + margin: 0 auto 8px; + min-height: 38px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.11); + background: rgba(255, 255, 255, 0.03); + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + color: rgba(255, 255, 255, 0.84); + animation: consoleStatusIn 220ms cubic-bezier(0.22, 1, 0.36, 1); +} + +body.console .chat-status[hidden] { + display: none !important; +} + +body.console .chat-status-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.36); +} + +body.console .chat-status-text { + flex: 1; + min-width: 0; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; +} + +body.console .chat-status-retry { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.88); + border-radius: 10px; + min-height: 28px; + padding: 0 10px; + font: inherit; + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +body.console .chat-status-retry:hover, +body.console .chat-status-retry:focus-visible { + background: rgba(124, 198, 255, 0.12); + border-color: rgba(124, 198, 255, 0.32); +} + +body.console .chat-status.is-working .chat-status-dot, +body.console .chat-status.is-streaming .chat-status-dot { + background: rgba(124, 198, 255, 0.9); + animation: activityPulse 900ms ease-in-out infinite; +} + +body.console .chat-status.is-error { + border-color: rgba(255, 92, 92, 0.28); + background: rgba(255, 92, 92, 0.08); +} + +body.console .chat-status.is-error .chat-status-dot { + background: rgba(255, 92, 92, 0.92); +} + +body.console .chat-status.is-ok .chat-status-dot { + background: rgba(38, 196, 157, 0.92); +} + +body.console .chat-empty { + margin: 8vh auto 0; + text-align: center; + max-width: 62ch; + animation: consoleRise 360ms cubic-bezier(0.22, 1, 0.36, 1); +} + +body.console .chat-empty h1 { + font-size: clamp(1.8rem, 3vw, 2.4rem); + margin: 0; +} + +body.console .chat-scroll { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px 0 10px; + display: flex; + flex-direction: column; + gap: 10px; + width: var(--chat-col); + max-width: 100%; + margin: 0 auto; +} + +body.console .msg { + display: flex; + width: 100%; + animation: messageIn 220ms cubic-bezier(0.22, 1, 0.36, 1); +} + +body.console .msg.is-user { + justify-content: flex-end; +} + +body.console .msg-bubble { + max-width: min(76ch, 92%); + border-radius: 18px; + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.035); + color: rgba(255, 255, 255, 0.9); + white-space: normal; + word-break: break-word; +} + +body.console .msg.is-user .msg-bubble { + background: linear-gradient(120deg, rgba(77, 168, 255, 0.20), rgba(124, 198, 255, 0.10)); + border-color: rgba(124, 198, 255, 0.24); +} + +body.console .msg-bubble.is-error { + background: rgba(255, 92, 92, 0.08); + border-color: rgba(255, 92, 92, 0.22); + color: rgba(255, 255, 255, 0.92); +} + +body.console .msg-content { + white-space: normal; +} + +body.console .msg-content.is-plain { + white-space: pre-wrap; +} + +body.console .msg-content > *:first-child { + margin-top: 0; +} + +body.console .msg-content > *:last-child { + margin-bottom: 0; +} + +body.console .msg-content p { + margin: 0 0 10px; +} + +body.console .msg-content h1, +body.console .msg-content h2, +body.console .msg-content h3 { + margin: 14px 0 10px; + line-height: 1.2; + letter-spacing: -0.02em; +} + +body.console .msg-content h1 { font-size: 1.22rem; } +body.console .msg-content h2 { font-size: 1.12rem; } +body.console .msg-content h3 { font-size: 1.02rem; } + +body.console .msg-content ul, +body.console .msg-content ol { + margin: 0 0 10px 1.2em; + padding: 0; +} + +body.console .msg-content li { + margin: 2px 0; +} + +body.console .msg-content code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.92em; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 2px 6px; +} + +body.console .msg-content pre { + margin: 0 0 12px; + padding: 12px 12px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.03); + overflow: auto; +} + +body.console .msg-content pre code { + background: transparent; + border: 0; + padding: 0; +} + +/* Fancy code blocks (header + copy) */ +body.console .codeblock { + margin: 0 0 12px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + overflow: hidden; + background: rgba(255, 255, 255, 0.02); +} + +body.console .codeblock-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.10); + background: rgba(255, 255, 255, 0.03); + color: var(--c-muted); + font-size: 12px; +} + +body.console .codeblock-lang { + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +body.console .codeblock-copy { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.02); + color: rgba(255, 255, 255, 0.82); + border-radius: 10px; + padding: 6px 10px; + font: inherit; + font-size: 12px; + cursor: pointer; + transition: background 140ms ease, border-color 140ms ease; +} + +body.console .codeblock-copy:hover, +body.console .codeblock-copy:focus-visible { + background: rgba(124, 198, 255, 0.10); + border-color: rgba(124, 198, 255, 0.24); +} + +body.console .codeblock pre { + margin: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +body.console .msg-content table { + width: 100%; + border-collapse: collapse; + margin: 0 0 12px; + border-radius: 14px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.12); +} + +body.console .msg-content th, +body.console .msg-content td { + padding: 10px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + vertical-align: top; +} + +body.console .msg-content th { + text-align: left; + font-weight: 800; + background: rgba(255, 255, 255, 0.04); +} + +body.console .msg-content tr:last-child td { + border-bottom: 0; +} + +body.console .msg-activity { + margin-top: 10px; + display: grid; + gap: 6px; + font-size: 12px; + color: var(--c-muted); +} + +body.console .activity-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.02); + border-radius: 12px; +} + +body.console .activity-left { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +body.console .activity-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +body.console .activity-right { + font-size: 11px; + color: var(--c-dimmer); + white-space: nowrap; +} + +body.console .activity-dot { + width: 9px; + height: 9px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.22); + box-shadow: 0 0 0 rgba(0, 0, 0, 0); +} + +body.console .activity-dot.is-running { + background: rgba(124, 198, 255, 0.88); + animation: activityPulse 900ms ease-in-out infinite; +} + +body.console .activity-dot.is-done { + background: rgba(38, 196, 157, 0.92); +} + +body.console .activity-dot.is-error { + background: rgba(255, 92, 92, 0.92); +} + +@keyframes activityPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(124, 198, 255, 0.0); } + 50% { box-shadow: 0 0 0 6px rgba(124, 198, 255, 0.10); } +} + +@keyframes consoleRise { + 0% { + opacity: 0; + transform: translateY(12px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes consoleStatusIn { + 0% { + opacity: 0; + transform: translateY(-8px) scale(0.98); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes messageIn { + 0% { + opacity: 0; + transform: translateY(8px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +body.console .thinking { + display: inline-flex; + align-items: center; + gap: 10px; + color: rgba(255, 255, 255, 0.72); +} + +body.console .thinking-dots { + display: inline-flex; + gap: 4px; +} + +body.console .thinking-dots span { + width: 6px; + height: 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.6); + opacity: 0.3; + animation: dotPulse 900ms ease-in-out infinite; +} + +body.console .thinking-dots span:nth-child(2) { animation-delay: 120ms; } +body.console .thinking-dots span:nth-child(3) { animation-delay: 240ms; } + +@keyframes dotPulse { + 0%, 100% { transform: translateY(0); opacity: 0.28; } + 50% { transform: translateY(-2px); opacity: 0.85; } +} + +body.console .chat-compose { + padding-top: 10px; + width: var(--chat-col); + max-width: 100%; + margin: 0 auto; + animation: consoleRise 420ms cubic-bezier(0.22, 1, 0.36, 1); +} + +body.console .chat-compose.is-disabled { + opacity: 0.55; +} + +body.console .chat-compose.is-guest .compose-wrap { + border-color: rgba(124, 198, 255, 0.22); + background: + linear-gradient(180deg, rgba(124, 198, 255, 0.06), rgba(255, 255, 255, 0.03)); + box-shadow: inset 0 0 0 1px rgba(124, 198, 255, 0.05); +} + +body.console .compose-wrap { + border-radius: 26px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.02)); + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 16px 10px; + transition: + border-color 180ms ease, + background 180ms ease, + transform 180ms ease, + box-shadow 180ms ease; +} + +body.console .compose-wrap:focus-within { + border-color: rgba(124, 198, 255, 0.34); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.03)); + transform: translateY(-1px); + box-shadow: 0 18px 34px rgba(0, 0, 0, 0.16), 0 0 0 1px rgba(124, 198, 255, 0.08); +} + +body.console .compose-input { + resize: none; + border: 0; + outline: none; + background: transparent; + color: rgba(255, 255, 255, 0.9); + font: inherit; + font-size: 15px; + line-height: 1.45; + max-height: 34vh; + min-height: 44px; + padding: 0; +} + +body.console .compose-input::placeholder { + color: rgba(255, 255, 255, 0.44); +} + +body.console .compose-input:disabled { + cursor: wait; +} + +body.console .compose-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +body.console .compose-actions { + display: inline-flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +body.console .compose-attach { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.9); + width: 44px; + height: 44px; + border-radius: 999px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: + background 180ms ease, + border-color 180ms ease, + transform 180ms ease, + box-shadow 180ms ease, + opacity 180ms ease; +} + +body.console .compose-attach span { + font-size: 26px; + line-height: 1; + transform: translateY(-1px); +} + +body.console .compose-attach:hover, +body.console .compose-attach:focus-visible { + border-color: rgba(124, 198, 255, 0.28); + background: rgba(124, 198, 255, 0.10); + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(39, 121, 255, 0.14); +} + +body.console .compose-attach:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +body.console .compose-shortcut { + font-size: 12px; + color: rgba(255, 255, 255, 0.52); + white-space: nowrap; +} + +body.console .compose-send { + appearance: none; + border: 1px solid rgba(124, 198, 255, 0.2); + background: linear-gradient(135deg, rgba(82, 151, 255, 0.22), rgba(47, 108, 223, 0.32)); + color: rgba(255, 255, 255, 0.94); + min-width: 92px; + height: 46px; + border-radius: 999px; + padding: 0 18px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + opacity: 0.96; + align-self: end; + transition: + background 180ms ease, + border-color 180ms ease, + opacity 180ms ease, + transform 180ms ease, + box-shadow 180ms ease; +} + +body.console .compose-send-label { + font-size: 14px; + font-weight: 800; + letter-spacing: 0.02em; + transform: none; + transition: transform 180ms ease; +} + +body.console .compose-wrap:focus-within .compose-send, +body.console .compose-send:hover, +body.console .compose-send:focus-visible { + opacity: 1; + border-color: rgba(124, 198, 255, 0.34); + background: linear-gradient(135deg, rgba(92, 162, 255, 0.32), rgba(47, 108, 223, 0.46)); + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(39, 121, 255, 0.18); +} + +body.console .compose-wrap:focus-within .compose-send .compose-send-label, +body.console .compose-send:hover .compose-send-label, +body.console .compose-send:focus-visible .compose-send-label { + transform: translateX(1px); +} + +body.console .compose-send:active { + background: linear-gradient(135deg, rgba(92, 162, 255, 0.26), rgba(47, 108, 223, 0.4)); +} + +body.console .compose-send:disabled { + opacity: 0.38; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +body.console .compose-attachments { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +body.console .compose-attachment-item { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; + padding: 6px 10px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); +} + +body.console .compose-attachment-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +body.console .compose-attachment-remove { + appearance: none; + border: 0; + background: transparent; + color: rgba(255, 255, 255, 0.64); + cursor: pointer; + font: inherit; + font-size: 18px; + line-height: 1; +} + +body.console .compose-note { + font-size: 12px; + color: var(--c-dimmer); + text-align: left; +} + +body.console .compose-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 10px; +} + +body.console .compose-counter { + flex: 0 0 auto; + font-size: 12px; + color: rgba(255, 255, 255, 0.48); + text-align: right; +} + +body.console .compose-counter.is-near-limit { + color: rgba(255, 186, 103, 0.95); +} + +@media (max-width: 960px) { + body.console .console-app { + grid-template-columns: 1fr; + } + + body.console .console-sidebar { + position: fixed; + inset: 0 auto 0 0; + width: min(330px, 86vw); + transform: translateX(-105%); + transition: transform 180ms ease; + z-index: 60; + border-right: 1px solid rgba(255, 255, 255, 0.12); + } + + body.console.sidebar-open .console-sidebar { + transform: translateX(0); + } + + body.console.sidebar-open::after { + content: ""; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 50; + } + + body.console .console-grid { + grid-template-columns: 1fr; + } + + body.console .console-split { + grid-template-columns: 1fr; + } + + body.console .console-filter-grid { + grid-template-columns: 1fr; + } + + body.console .console-table { + min-width: 640px; + } +} + +@media (prefers-reduced-motion: reduce) { + body.console *, + body.console *::before, + body.console *::after { + animation: none !important; + transition: none !important; + } +} + diff --git a/src/web/static/site.js b/src/web/static/site.js new file mode 100644 index 00000000..4a0f0a30 --- /dev/null +++ b/src/web/static/site.js @@ -0,0 +1,1392 @@ +(() => { + "use strict"; + const COOKIE_CONSENT_KEY = "yonerai_cookie_consent_v1"; + const COOKIE_LANG_KEY = "yonerai_lang"; + const COOKIE_TTL_SEC = 31536000; + const TOPBAR_HINT_KEY = "yonerai_topbar_scroll_hint_seen_v1"; + const COOKIE_COPY = { + en: { + title: "Cookie preferences", + body: "YonerAI uses an essential session cookie (yonerai_session) for sign-in, and one optional cookie (yonerai_lang) to remember language across visits.", + accept: "Allow optional cookie", + essential: "Essential only", + }, + ja: { + title: "Cookie設定", + body: "YonerAI はログインのために必須Cookie (yonerai_session) を使用し、言語設定を保持するために任意Cookie (yonerai_lang) を使用します。", + accept: "任意Cookieを許可", + essential: "必須のみ", + }, + }; + + function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) { + return parts.pop().split(";").shift() || ""; + } + return ""; + } + + function setCookie(name, value, maxAgeSec) { + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAgeSec}; SameSite=Lax`; + } + + function clearCookie(name) { + document.cookie = `${name}=; path=/; max-age=0; SameSite=Lax`; + } + + function normalizeLang(v) { + const s = String(v || "").toLowerCase(); + return s.startsWith("ja") ? "ja" : "en"; + } + + function langFromPath() { + const p = (window.location.pathname || "/").toLowerCase(); + if (p === "/jp" || p.startsWith("/jp/")) { + return "ja"; + } + if (p === "/en" || p.startsWith("/en/")) { + return "en"; + } + return ""; + } + + function localePrefix(lang) { + return normalizeLang(lang) === "ja" ? "/jp" : "/en"; + } + + function resolveChatHostBase() { + const host = String(window.location.hostname || "").toLowerCase(); + const proto = String(window.location.protocol || "https:"); + if (host === "yonerai.com" || host === "www.yonerai.com") { + return `${proto}//api.yonerai.com`; + } + return `${proto}//${window.location.host}`; + } + + function resolveLocalizedChatHref(lang) { + return `${resolveChatHostBase()}${localePrefix(lang)}/chat`; + } + + function resolveLocalizedLoginHref(lang) { + const chatHref = resolveLocalizedChatHref(lang); + const chatUrl = new URL(chatHref, window.location.origin); + return `${resolveChatHostBase()}/auth/login?returnTo=${encodeURIComponent(chatUrl.pathname)}`; + } + + function stripLocalePrefix(path) { + const p = path || "/"; + if (p === "/jp" || p.startsWith("/jp/")) { + const rest = p.slice(3); + return rest ? rest : "/"; + } + if (p === "/en" || p.startsWith("/en/")) { + const rest = p.slice(3); + return rest ? rest : "/"; + } + return p; + } + + function localizedPath(lang) { + const base = stripLocalePrefix(window.location.pathname || "/"); + const prefix = localePrefix(lang); + const normalizedBase = base.startsWith("/") ? base : `/${base}`; + const targetPath = normalizedBase === "/" ? prefix : `${prefix}${normalizedBase}`; + return `${targetPath}${window.location.search || ""}${window.location.hash || ""}`; + } + + function detectLang() { + const params = new URLSearchParams(window.location.search); + const forced = params.get("lang"); + if (forced) { + return normalizeLang(forced); + } + + const fromPath = langFromPath(); + if (fromPath) { + return normalizeLang(fromPath); + } + + const fromCookie = getCookie(COOKIE_LANG_KEY); + if (fromCookie) { + return normalizeLang(fromCookie); + } + + const langs = Array.isArray(navigator.languages) && navigator.languages.length + ? navigator.languages + : [navigator.language || "en"]; + return langs.some((x) => String(x).toLowerCase().startsWith("ja")) ? "ja" : "en"; + } + + function getConsentState() { + const v = getCookie(COOKIE_CONSENT_KEY); + return v === "accepted" || v === "essential" ? v : ""; + } + + function canStoreOptionalCookies() { + return getConsentState() === "accepted"; + } + + function setLangCookie(lang) { + if (!canStoreOptionalCookies()) { + return; + } + setCookie(COOKIE_LANG_KEY, normalizeLang(lang), COOKIE_TTL_SEC); + } + + function updateCookieConsentText(lang) { + const root = document.getElementById("oa-cookie-consent"); + if (!root) { + return; + } + const copy = COOKIE_COPY[normalizeLang(lang)]; + const title = root.querySelector("[data-cookie-title]"); + const body = root.querySelector("[data-cookie-body]"); + const accept = root.querySelector("[data-cookie-accept]"); + const essential = root.querySelector("[data-cookie-essential]"); + if (title) title.textContent = copy.title; + if (body) body.textContent = copy.body; + if (accept) accept.textContent = copy.accept; + if (essential) essential.textContent = copy.essential; + } + + function hideCookieConsent() { + const root = document.getElementById("oa-cookie-consent"); + if (!root) { + return; + } + root.classList.add("is-hide"); + window.setTimeout(() => { + root.remove(); + }, 220); + } + + function showCookieConsent(lang) { + if (getConsentState()) { + return; + } + if (document.getElementById("oa-cookie-consent")) { + updateCookieConsentText(lang); + return; + } + + const wrap = document.createElement("section"); + wrap.id = "oa-cookie-consent"; + wrap.className = "oa-cookie-consent"; + wrap.setAttribute("role", "dialog"); + wrap.setAttribute("aria-live", "polite"); + wrap.setAttribute("aria-modal", "false"); + wrap.innerHTML = ` + + `; + document.body.appendChild(wrap); + updateCookieConsentText(lang); + + const acceptBtn = wrap.querySelector("[data-cookie-accept]"); + const essentialBtn = wrap.querySelector("[data-cookie-essential]"); + if (acceptBtn) { + acceptBtn.addEventListener("click", () => { + const activeLang = normalizeLang(document.documentElement.lang || lang || "en"); + setCookie(COOKIE_CONSENT_KEY, "accepted", COOKIE_TTL_SEC); + setCookie(COOKIE_LANG_KEY, activeLang, COOKIE_TTL_SEC); + hideCookieConsent(); + }); + } + + if (essentialBtn) { + essentialBtn.addEventListener("click", () => { + setCookie(COOKIE_CONSENT_KEY, "essential", COOKIE_TTL_SEC); + clearCookie(COOKIE_LANG_KEY); + hideCookieConsent(); + }); + } + } + + function translateElement(el, lang) { + const key = lang === "ja" ? "i18nJa" : "i18nEn"; + if (Object.prototype.hasOwnProperty.call(el.dataset, key)) { + el.textContent = el.dataset[key] || ""; + } + } + + function applyLangAssetBindings(lang) { + const srcKey = lang === "ja" ? "langSrcJa" : "langSrcEn"; + const hrefKey = lang === "ja" ? "langHrefJa" : "langHrefEn"; + + document.querySelectorAll("[data-lang-src-en]").forEach((el) => { + const src = el.dataset[srcKey] || el.dataset.langSrcEn || ""; + if (src && el.getAttribute("src") !== src) { + el.setAttribute("src", src); + } + }); + + document.querySelectorAll("[data-lang-href-en]").forEach((el) => { + const href = el.dataset[hrefKey] || el.dataset.langHrefEn || ""; + if (href && el.getAttribute("href") !== href) { + el.setAttribute("href", href); + } + }); + } + + function applyLang(lang) { + document.documentElement.lang = lang; + document.querySelectorAll("[data-i18n-en]").forEach((el) => translateElement(el, lang)); + applyLangAssetBindings(lang); + + document.querySelectorAll("[data-lang-btn]").forEach((btn) => { + const active = (btn.getAttribute("data-lang-btn") || "") === lang; + btn.classList.toggle("is-active", active); + btn.setAttribute("aria-pressed", active ? "true" : "false"); + }); + + const note = document.getElementById("lang-note"); + if (note) { + const canStore = canStoreOptionalCookies(); + if (lang === "ja") { + note.textContent = canStore + ? "言語設定は Cookie (yonerai_lang) に保存されます。" + : "言語設定は任意Cookie (yonerai_lang) を許可した場合のみ保存されます。"; + } else { + note.textContent = canStore + ? "Language preference is stored in cookie (yonerai_lang)." + : "Language preference is stored only if you allow the optional cookie (yonerai_lang)."; + } + } + updateCookieConsentText(lang); + window.dispatchEvent(new CustomEvent("yonerai:lang-changed", { detail: { lang } })); + } + + function fitRailLinksToLongest() { + const menu = document.querySelector(".oa-menu"); + if (!menu) { + return; + } + + const links = Array.from(menu.querySelectorAll(".rail-link")); + if (!links.length) { + return; + } + + if (window.matchMedia("(max-width: 1180px)").matches) { + menu.style.removeProperty("--oa-rail-link-w"); + document.body.style.removeProperty("--oa-rail-w"); + return; + } + + let maxWidth = 0; + links.forEach((el) => { + const prevWidth = el.style.width; + const prevMinWidth = el.style.minWidth; + el.style.width = "max-content"; + el.style.minWidth = "0"; + maxWidth = Math.max(maxWidth, Math.ceil(el.getBoundingClientRect().width)); + el.style.width = prevWidth; + el.style.minWidth = prevMinWidth; + }); + + if (maxWidth <= 0) { + return; + } + + const target = Math.min(Math.max(maxWidth, 112), 220); + menu.style.setProperty("--oa-rail-link-w", `${target}px`); + document.body.style.setProperty("--oa-rail-w", `${target + 10}px`); + } + + function setupLangControls() { + document.querySelectorAll("[data-lang-btn]").forEach((btn) => { + btn.addEventListener("click", () => { + const lang = normalizeLang(btn.getAttribute("data-lang-btn") || "en"); + setLangCookie(lang); + const target = localizedPath(lang); + const current = `${window.location.pathname || "/"}${window.location.search || ""}${window.location.hash || ""}`; + if (target !== current) { + window.location.assign(target); + return; + } + applyLang(lang); + fitRailLinksToLongest(); + }); + }); + } + + function setupAuthEntries() { + const nodes = Array.from(document.querySelectorAll("[data-auth-entry]")); + if (!nodes.length) { + return; + } + let isAuthed = false; + + const resolveText = (node, lang) => { + const ja = normalizeLang(lang) === "ja"; + if (isAuthed) { + return ja + ? (node.dataset.authAuthedJa || "チャットを開く") + : (node.dataset.authAuthedEn || "Open Chat"); + } + return ja + ? (node.dataset.authLoginJa || "ログイン") + : (node.dataset.authLoginEn || "Sign in"); + }; + + const applyState = () => { + const lang = normalizeLang(document.documentElement.lang || detectLang()); + nodes.forEach((node) => { + const configuredLoginHref = node.dataset.authLoginHref || ""; + const configuredAuthedHref = node.dataset.authAuthedHref || ""; + const loginHref = + !configuredLoginHref || configuredLoginHref === "/auth/login?returnTo=/chat" + ? resolveLocalizedLoginHref(lang) + : configuredLoginHref; + const authedHref = + !configuredAuthedHref || configuredAuthedHref === "/chat" + ? resolveLocalizedChatHref(lang) + : configuredAuthedHref; + node.setAttribute("href", isAuthed ? authedHref : loginHref); + node.textContent = resolveText(node, lang); + node.classList.toggle("is-authenticated", isAuthed); + }); + markActiveNav(); + }; + + applyState(); + window.addEventListener("yonerai:lang-changed", applyState); + + fetch("/api/auth/me", { credentials: "include" }) + .then((res) => { + isAuthed = Boolean(res && res.ok); + }) + .catch(() => { + isAuthed = false; + }) + .finally(() => { + applyState(); + }); + } + + function markActiveNav() { + const currentPath = window.location.pathname || "/"; + const path = (stripLocalePrefix(currentPath).replace(/\/$/, "") || "/").toLowerCase(); + const hash = (window.location.hash || "#research").toLowerCase(); + document.querySelectorAll(".nav-link, .rail-link").forEach((a) => { + const raw = (a.getAttribute("href") || "").trim(); + const href = (raw.replace(/\/$/, "") || "/").toLowerCase(); + let active = false; + if (raw.startsWith("#")) { + active = raw.toLowerCase() === hash; + } else if (raw.startsWith("/#")) { + active = path === "/" && raw.slice(1).toLowerCase() === hash; + } else { + active = (href === "/" && path === "/") || (href !== "/" && path.startsWith(href)); + } + a.classList.toggle("is-active", active); + }); + } + + function setupReveal() { + const nodes = Array.from(document.querySelectorAll(".reveal")); + if (!nodes.length) { + return; + } + + const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduce || typeof IntersectionObserver === "undefined") { + nodes.forEach((n) => n.classList.add("is-visible")); + return; + } + + const obs = new IntersectionObserver((entries) => { + entries.forEach((e) => { + if (e.isIntersecting) { + e.target.classList.add("is-visible"); + obs.unobserve(e.target); + } + }); + }, { threshold: 0.16 }); + + nodes.forEach((n) => obs.observe(n)); + } + + function setupTopbarScrollState() { + const topbar = document.querySelector(".oa-global-topbar"); + if (!topbar) { + return; + } + + const sync = () => { + topbar.classList.toggle("is-scrolled", window.scrollY > 10); + }; + + sync(); + window.addEventListener("scroll", sync, { passive: true }); + window.addEventListener("resize", sync); + } + + function setupAnchorNavigation() { + const links = document.querySelectorAll(".rail-link[href^=\"#\"], .nav-link[href^=\"#\"]"); + if (!links.length) { + return; + } + + links.forEach((link) => { + link.addEventListener("click", (ev) => { + const raw = link.getAttribute("href") || ""; + const id = raw.replace(/^#/, "").trim(); + if (!id) { + return; + } + const target = document.getElementById(id); + if (!target) { + return; + } + + ev.preventDefault(); + target.scrollIntoView({ behavior: "smooth", block: "start" }); + window.history.replaceState(null, "", `#${id}`); + markActiveNav(); + }); + }); + } + + function setupPointerGlow() { + // Disabled by design: + // glow should represent active selection, not hover pointer position. + return; + } + + function setupCardLinks() { + const cards = document.querySelectorAll(".oa-card[data-card-href]"); + if (!cards.length) { + return; + } + + const openTarget = (href, target) => { + if (!href) { + return; + } + if (href.startsWith("#")) { + const id = href.slice(1).trim(); + const node = id ? document.getElementById(id) : null; + if (node) { + node.scrollIntoView({ behavior: "smooth", block: "start" }); + window.history.replaceState(null, "", `#${id}`); + markActiveNav(); + } + return; + } + if (target === "_blank") { + window.open(href, "_blank", "noopener,noreferrer"); + } else { + window.location.assign(href); + } + }; + + cards.forEach((card) => { + const href = card.getAttribute("data-card-href") || ""; + const target = card.getAttribute("data-card-target") || ""; + if (!href) { + return; + } + + card.classList.add("is-clickable"); + card.setAttribute("role", "link"); + card.setAttribute("tabindex", "0"); + + card.addEventListener("click", (ev) => { + const t = ev.target; + if (t && t.closest && t.closest("a, button, input, textarea, select, label")) { + return; + } + openTarget(href, target); + }); + + card.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + openTarget(href, target); + } + }); + }); + } + + function createFlowCanvasRuntime({ canvas, stageCount, getLabels, onStep }) { + const ctx = canvas.getContext("2d", { alpha: true }); + if (!ctx) { + return null; + } + + const clamp = (v, min, max) => Math.min(max, Math.max(min, v)); + const lerp = (a, b, t) => a + (b - a) * t; + const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); + const hexToRgba = (hex, alpha) => { + const m = String(hex).replace("#", ""); + if (m.length !== 6) { + return `rgba(255,255,255,${alpha})`; + } + const r = Number.parseInt(m.slice(0, 2), 16); + const g = Number.parseInt(m.slice(2, 4), 16); + const b = Number.parseInt(m.slice(4, 6), 16); + return `rgba(${r},${g},${b},${alpha})`; + }; + + const palette = ["#76ceff", "#7dbbff", "#8aabff", "#f8b36a", "#7cf0da", "#b8bfff", "#82d4ff"]; + const center = (stageCount - 1) / 2; + const points = Array.from({ length: stageCount }, (_, i) => ({ + x: (i - center) * 2.2, + y: (i % 2 === 0 ? -1 : 1) * (0.88 + ((i % 3) * 0.12)), + z: Math.sin(i * 0.85) * 1.05, + color: palette[i % palette.length], + })); + + const defaultRoute = Array.from({ length: stageCount }, (_, i) => i); + const normalizeRoute = (route) => { + if (!Array.isArray(route) || !route.length) { + return [...defaultRoute]; + } + const out = []; + route.forEach((value) => { + const idx = Number(value); + if (!Number.isInteger(idx)) { + return; + } + if (idx < 0 || idx >= stageCount) { + return; + } + if (!out.length || out[out.length - 1] !== idx) { + out.push(idx); + } + }); + return out.length >= 2 ? out : [...defaultRoute]; + }; + + const routeOrderMap = (route) => { + const map = new Map(); + route.forEach((idx, order) => { + if (!map.has(idx)) { + map.set(idx, order); + } + }); + return map; + }; + const state = { + activeIndex: 0, + running: false, + runStart: 0, + runRequest: "", + requestText: "", + phase: 0, + pointerX: 0, + pointerY: 0, + paused: false, + route: [...defaultRoute], + routeOrder: routeOrderMap(defaultRoute), + routeProgress: 0, + segmentMs: 1100, + }; + + let raf = 0; + let width = 0; + let height = 0; + let dpr = 1; + let ro = null; + const destroyers = []; + + const notifyStep = (idx, routeStep = 0, routeLength = Math.max(1, state.route.length - 1), route = state.route) => { + const labels = getLabels(); + const safe = clamp(idx, 0, stageCount - 1); + onStep({ + stageIndex: safe, + label: labels[safe] || `Step ${safe + 1}`, + routeStep, + routeLength, + progress: routeLength > 0 ? clamp(routeStep / routeLength, 0, 1) : 1, + route: Array.isArray(route) ? [...route] : [...state.route], + }); + }; + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) { + return; + } + dpr = clamp(window.devicePixelRatio || 1, 1, 2); + width = rect.width; + height = rect.height; + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + + const project = (pt, now) => { + const drift = Math.sin(now * 0.00022) * 0.16; + const angY = drift + state.pointerX * 0.14; + const angX = Math.sin(now * 0.00019) * 0.05 + state.pointerY * 0.08; + + const cosY = Math.cos(angY); + const sinY = Math.sin(angY); + const cosX = Math.cos(angX); + const sinX = Math.sin(angX); + + const y1 = pt.y * cosX - pt.z * sinX; + const z1 = pt.y * sinX + pt.z * cosX; + const x2 = pt.x * cosY - z1 * sinY; + const z2 = pt.x * sinY + z1 * cosY; + + const depth = 580 + z2 * 90; + const perspective = 620 / Math.max(260, depth); + const sx = width * 0.5 + x2 * 112 * perspective; + const sy = height * 0.5 + y1 * 86 * perspective; + const r = 4 + 8.6 * perspective; + return { x: sx, y: sy, r, p: perspective, z: z2 }; + }; + + const cubicPoint = (a, b, t) => { + const c1 = { x: lerp(a.x, b.x, 0.32), y: a.y - 0.7, z: a.z + 0.65 }; + const c2 = { x: lerp(a.x, b.x, 0.68), y: b.y + 0.7, z: b.z + 0.65 }; + const mt = 1 - t; + return { + x: (mt * mt * mt * a.x) + (3 * mt * mt * t * c1.x) + (3 * mt * t * t * c2.x) + (t * t * t * b.x), + y: (mt * mt * mt * a.y) + (3 * mt * mt * t * c1.y) + (3 * mt * t * t * c2.y) + (t * t * t * b.y), + z: (mt * mt * mt * a.z) + (3 * mt * mt * t * c1.z) + (3 * mt * t * t * c2.z) + (t * t * t * b.z), + }; + }; + + const drawBackground = () => { + const grad = ctx.createLinearGradient(0, 0, width, height); + grad.addColorStop(0, "rgba(7, 16, 31, 0.16)"); + grad.addColorStop(1, "rgba(5, 11, 22, 0.02)"); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, height); + + const lines = 9; + ctx.save(); + ctx.strokeStyle = "rgba(123, 178, 240, 0.11)"; + ctx.lineWidth = 1; + for (let i = 1; i < lines; i += 1) { + const y = (height / lines) * i; + ctx.beginPath(); + ctx.moveTo(18, y); + ctx.lineTo(width - 18, y); + ctx.stroke(); + } + ctx.restore(); + }; + + const drawLink = (a, b, now) => { + const pa = project(a, now); + const pb = project(b, now); + const g = ctx.createLinearGradient(pa.x, pa.y, pb.x, pb.y); + g.addColorStop(0, "rgba(126, 184, 255, 0.2)"); + g.addColorStop(1, "rgba(92, 146, 238, 0.14)"); + ctx.strokeStyle = g; + ctx.lineWidth = Math.max(1, (pa.p + pb.p) * 1.7); + ctx.beginPath(); + ctx.moveTo(pa.x, pa.y); + ctx.lineTo(pb.x, pb.y); + ctx.stroke(); + }; + + const drawNode = (pt, idx, now) => { + const p = project(pt, now); + const active = idx === state.activeIndex; + const order = state.routeOrder.get(idx); + const inRoute = Number.isInteger(order); + const complete = inRoute && order < state.routeProgress; + const glow = active ? 0.52 : complete ? 0.26 : inRoute ? 0.14 : 0.08; + const ringColor = active ? "#a8dcff" : complete ? "#85b4ff" : inRoute ? "#4f5f80" : "#34455f"; + + ctx.save(); + ctx.shadowColor = hexToRgba(pt.color, glow); + ctx.shadowBlur = active ? 30 : 18; + ctx.fillStyle = hexToRgba(pt.color, complete ? 0.82 : inRoute ? 0.62 : 0.36); + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fill(); + + ctx.lineWidth = active ? 2.4 : 1.4; + ctx.strokeStyle = ringColor; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r + 2.1, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + }; + + const drawPackets = (now) => { + if (!state.running) { + return; + } + + const route = state.route; + const segmentCount = Math.max(1, route.length - 1); + const totalMs = Math.max(state.segmentMs, segmentCount * state.segmentMs); + const elapsed = clamp(now - state.runStart, 0, totalMs + 1200); + const progress = clamp(elapsed / totalMs, 0, 1); + const easedProgress = easeInOut(progress); + const pathPos = easedProgress * segmentCount; + const routeStep = clamp(Math.floor(pathPos), 0, segmentCount); + + if (routeStep !== state.routeProgress) { + state.routeProgress = routeStep; + state.activeIndex = route[routeStep]; + notifyStep(state.activeIndex, routeStep, segmentCount, route); + } + + for (let i = 0; i < 6; i += 1) { + const offset = i * 0.07; + const t = pathPos - offset; + if (t <= 0 || t >= segmentCount) { + continue; + } + const seg = clamp(Math.floor(t), 0, segmentCount - 1); + const localT = t - seg; + const from = points[route[seg]]; + const to = points[route[seg + 1]]; + const world = cubicPoint(from, to, localT); + const pp = project(world, now); + const intensity = 1 - offset * 0.95; + + ctx.save(); + ctx.shadowColor = `rgba(113, 190, 255, ${0.48 * intensity})`; + ctx.shadowBlur = 24 * intensity; + ctx.fillStyle = `rgba(146, 212, 255, ${0.88 * intensity})`; + ctx.beginPath(); + ctx.arc(pp.x, pp.y, Math.max(2.6, pp.r * 0.46), 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + if (elapsed >= totalMs + 1000) { + state.running = false; + state.routeProgress = segmentCount; + state.activeIndex = route[segmentCount]; + notifyStep(state.activeIndex, state.routeProgress, segmentCount, route); + } + }; + + const draw = (now) => { + if (!width || !height) { + resize(); + } + ctx.clearRect(0, 0, width, height); + drawBackground(); + + for (let i = 0; i < points.length - 1; i += 1) { + drawLink(points[i], points[i + 1], now); + } + + for (let i = 0; i < state.route.length - 1; i += 1) { + const from = points[state.route[i]]; + const to = points[state.route[i + 1]]; + const pa = project(from, now); + const pb = project(to, now); + const g = ctx.createLinearGradient(pa.x, pa.y, pb.x, pb.y); + g.addColorStop(0, "rgba(168, 221, 255, 0.34)"); + g.addColorStop(1, "rgba(117, 176, 255, 0.28)"); + ctx.strokeStyle = g; + ctx.lineWidth = Math.max(1.2, (pa.p + pb.p) * 2.2); + ctx.beginPath(); + ctx.moveTo(pa.x, pa.y); + ctx.lineTo(pb.x, pb.y); + ctx.stroke(); + } + + drawPackets(now); + + points.forEach((pt, idx) => drawNode(pt, idx, now)); + + if (!state.running) { + const pulse = 0.5 + Math.sin(now * 0.0022) * 0.5; + const origin = project(points[state.route[0]], now); + ctx.save(); + ctx.strokeStyle = `rgba(116, 194, 255, ${0.14 + pulse * 0.2})`; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(origin.x, origin.y, origin.r + 8 + pulse * 8, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + }; + + const frame = (now) => { + if (!state.paused) { + state.phase += 0.008; + draw(now); + } + raf = window.requestAnimationFrame(frame); + }; + + const onMove = (ev) => { + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) { + return; + } + const nx = ((ev.clientX - rect.left) / rect.width) * 2 - 1; + const ny = ((ev.clientY - rect.top) / rect.height) * 2 - 1; + state.pointerX = clamp(nx, -1, 1); + state.pointerY = clamp(ny, -1, 1); + }; + + const onLeave = () => { + state.pointerX = 0; + state.pointerY = 0; + }; + + resize(); + if ("ResizeObserver" in window) { + ro = new ResizeObserver(resize); + ro.observe(canvas); + } else { + window.addEventListener("resize", resize); + destroyers.push(() => window.removeEventListener("resize", resize)); + } + + canvas.addEventListener("pointermove", onMove); + canvas.addEventListener("pointerleave", onLeave); + destroyers.push(() => canvas.removeEventListener("pointermove", onMove)); + destroyers.push(() => canvas.removeEventListener("pointerleave", onLeave)); + + notifyStep(defaultRoute[0], 0, Math.max(1, defaultRoute.length - 1), defaultRoute); + raf = window.requestAnimationFrame(frame); + + return { + play(input) { + const opts = (input && typeof input === "object") + ? input + : { requestText: input }; + const cleaned = String(opts.requestText || "").trim(); + const route = normalizeRoute(opts.route); + state.requestText = cleaned || "Analyze this request path"; + state.runRequest = state.requestText; + state.route = route; + state.routeOrder = routeOrderMap(route); + state.segmentMs = clamp(Number(opts.segmentMs) || 1100, 640, 2400); + state.runStart = performance.now(); + state.running = true; + state.routeProgress = 0; + state.activeIndex = route[0]; + notifyStep(route[0], 0, Math.max(1, route.length - 1), route); + }, + pause() { + state.paused = true; + }, + resume() { + state.paused = false; + }, + destroy() { + window.cancelAnimationFrame(raf); + if (ro) { + ro.disconnect(); + } + destroyers.forEach((fn) => fn()); + }, + }; + } + + function setupFlowVisualizers() { + const roots = Array.from(document.querySelectorAll("[data-flow-visualizer]")); + if (!roots.length) { + return; + } + + const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const scenarioDefs = { + safe: { + route: [0, 1, 2, 3, 4, 5], + segmentMs: 1020, + title: { en: "Standard path", ja: "通常ルート" }, + trace: { + en: [ + "Request normalized (source/user/channel).", + "Context + selector build the execution plan.", + "Risk scored as LOW.", + "Policy allows execution without owner gate.", + "Tool executes and artifacts are collected.", + "Final response and audit trail returned.", + ], + ja: [ + "入力を正規化(source/user/channel)。", + "文脈構築とセレクタで実行計画を作成。", + "リスク判定はLOW。", + "承認なしで実行可能とポリシー判定。", + "ツールを実行して成果物を収集。", + "最終応答と監査ログを返却。", + ], + }, + }, + approval: { + route: [0, 1, 2, 3, 4, 5], + segmentMs: 1160, + title: { en: "Approval required", ja: "承認必須ルート" }, + trace: { + en: [ + "Request normalized.", + "High-impact action detected by selector.", + "Risk scored as HIGH/CRITICAL.", + "Approval request created (pending).", + "Owner decision received within TTL.", + "Tool executes after approval.", + "Result submitted back to Core run.", + "Final response + audit completed.", + ], + ja: [ + "入力を正規化。", + "高影響アクションをセレクタが検知。", + "リスク判定はHIGH/CRITICAL。", + "承認リクエストを作成(pending)。", + "TTL内にOwner判断を受信。", + "承認後にツールを実行。", + "結果をCore実行へ返却。", + "最終応答と監査を確定。", + ], + }, + }, + blocked: { + route: [0, 1, 2, 3, 5], + segmentMs: 980, + title: { en: "Blocked path", ja: "ブロックルート" }, + trace: { + en: [ + "Request normalized.", + "Risky action identified.", + "Risk scored as HIGH/CRITICAL.", + "Policy denies request (or approval expires).", + "Tool execution blocked.", + "Blocked response returned with reason.", + ], + ja: [ + "入力を正規化。", + "危険操作を検知。", + "リスク判定はHIGH/CRITICAL。", + "ポリシー拒否(または承認期限切れ)。", + "ツール実行をブロック。", + "理由付きでブロック応答を返却。", + ], + }, + }, + }; + + roots.forEach((root) => { + const stages = Array.from(root.querySelectorAll("[data-flow-stage]")); + const progress = root.querySelector("[data-flow-progress]"); + const status = root.querySelector("[data-flow-status]"); + const replayButton = root.querySelector("[data-flow-replay]"); + const runButton = root.querySelector("[data-flow-run]"); + const requestInput = root.querySelector("[data-flow-request]"); + const requestEcho = root.querySelector("[data-flow-request-echo]"); + const nodeInfo = root.querySelector("[data-flow-node-info]"); + const canvas = root.querySelector("[data-flow-canvas]"); + const traceList = root.querySelector("[data-flow-trace]"); + const scenarioButtons = Array.from(root.querySelectorAll("[data-flow-scenario]")); + if (!stages.length) { + return; + } + + let timer = 0; + let traceTimer = 0; + let scene = null; + let activeScenarioKey = "safe"; + let traceStep = 0; + let flowState = { + stageIndex: 0, + routeStep: 0, + routeLength: Math.max(1, stages.length - 1), + route: Array.from({ length: stages.length }, (_, i) => i), + label: "", + }; + + const currentLang = () => normalizeLang(document.documentElement.lang || detectLang()); + const labels = () => stages.map((stage, index) => { + const lang = currentLang(); + const key = lang === "ja" ? "flowLabelJa" : "flowLabelEn"; + const fallback = stage.getAttribute("data-flow-label") || `Step ${index + 1}`; + return stage.dataset[key] || fallback; + }); + const getScenario = () => scenarioDefs[activeScenarioKey] || scenarioDefs.safe; + + const updateRequestUI = (value) => { + const text = String(value || "").trim(); + if (requestEcho) { + requestEcho.textContent = text || (currentLang() === "ja" ? "入力待機中..." : "Waiting for request..."); + } + }; + + const traceItems = () => Array.from(root.querySelectorAll(".flow-trace-item")); + const setTraceStep = (idx) => { + const items = traceItems(); + if (!items.length) { + return; + } + const safe = Math.max(0, Math.min(idx, items.length - 1)); + items.forEach((item, n) => { + item.classList.toggle("is-complete", n < safe); + item.classList.toggle("is-active", n === safe); + item.classList.toggle("is-pending", n > safe); + }); + traceStep = safe; + }; + + const buildTrace = () => { + if (!traceList) { + return; + } + const scenario = getScenario(); + const lines = scenario.trace[currentLang()] || scenario.trace.en; + traceList.innerHTML = lines + .map((line, index) => `
  • ${String(index + 1).padStart(2, "0")}${line}
  • `) + .join(""); + setTraceStep(0); + }; + + const render = () => { + const scenario = getScenario(); + const route = Array.isArray(flowState.route) && flowState.route.length ? flowState.route : scenario.route; + const routeMap = new Map(route.map((stageIndex, order) => [stageIndex, order])); + const routeStep = Math.max(0, Math.min(flowState.routeStep, flowState.routeLength || 0)); + + stages.forEach((stage, index) => { + const order = routeMap.has(index) ? routeMap.get(index) : -1; + stage.classList.toggle("is-active", index === flowState.stageIndex); + stage.classList.toggle("is-complete", order !== -1 && order < routeStep); + }); + + if (progress) { + const ratio = flowState.routeLength > 0 + ? Math.max(0, Math.min(flowState.routeStep / flowState.routeLength, 1)) + : 1; + const pct = ratio * 100; + progress.style.width = `${pct}%`; + } + + const label = flowState.label || labels()[flowState.stageIndex] || `Step ${flowState.stageIndex + 1}`; + const title = scenario.title[currentLang()] || scenario.title.en; + if (status) { + status.textContent = `${title} · ${label}`; + } + if (nodeInfo) { + nodeInfo.textContent = label; + } + scenarioButtons.forEach((btn) => { + const key = btn.getAttribute("data-flow-scenario") || ""; + btn.classList.toggle("is-active", key === activeScenarioKey); + }); + }; + + const stopFallback = () => { + if (timer) { + window.clearInterval(timer); + timer = 0; + } + }; + + const stopTraceTimeline = () => { + if (traceTimer) { + window.clearInterval(traceTimer); + traceTimer = 0; + } + }; + + const startFallback = () => { + stopFallback(); + if (reduceMotion) { + return; + } + const scenario = getScenario(); + const route = scenario.route; + let routeStep = 0; + flowState = { + stageIndex: route[0], + routeStep: 0, + routeLength: Math.max(1, route.length - 1), + route, + label: labels()[route[0]] || `Step ${route[0] + 1}`, + }; + render(); + timer = window.setInterval(() => { + routeStep += 1; + const maxStep = Math.max(1, route.length - 1); + const safeStep = Math.max(0, Math.min(routeStep, maxStep)); + flowState = { + stageIndex: route[safeStep], + routeStep: safeStep, + routeLength: maxStep, + route, + label: labels()[route[safeStep]] || `Step ${route[safeStep] + 1}`, + }; + render(); + const nextTrace = Math.round((safeStep / maxStep) * Math.max(0, traceItems().length - 1)); + if (nextTrace > traceStep) { + setTraceStep(nextTrace); + } + if (safeStep >= maxStep) { + stopFallback(); + } + }, scenario.segmentMs); + }; + + const runTraceTimeline = () => { + stopTraceTimeline(); + const items = traceItems(); + if (items.length <= 1) { + return; + } + const scenario = getScenario(); + const totalMs = Math.max(1200, scenario.segmentMs * Math.max(1, scenario.route.length - 1) + 800); + const tickMs = Math.max(340, Math.round(totalMs / items.length)); + let idx = 0; + traceTimer = window.setInterval(() => { + idx += 1; + if (idx >= items.length) { + setTraceStep(items.length - 1); + stopTraceTimeline(); + return; + } + setTraceStep(idx); + }, tickMs); + }; + + const onSceneStep = (event) => { + if (!event || typeof event !== "object") { + return; + } + flowState = { + stageIndex: Math.max(0, Math.min(Number(event.stageIndex) || 0, stages.length - 1)), + routeStep: Math.max(0, Number(event.routeStep) || 0), + routeLength: Math.max(0, Number(event.routeLength) || 0), + route: Array.isArray(event.route) ? event.route : getScenario().route, + label: event.label || "", + }; + render(); + const itemsCount = traceItems().length; + if (itemsCount > 1) { + const ratio = flowState.routeLength > 0 + ? Math.max(0, Math.min(flowState.routeStep / flowState.routeLength, 1)) + : 1; + const next = Math.round(ratio * (itemsCount - 1)); + if (next > traceStep) { + setTraceStep(next); + } + } + }; + + const replay = () => { + stopFallback(); + stopTraceTimeline(); + buildTrace(); + const scenario = getScenario(); + const requestText = requestInput ? requestInput.value : ""; + updateRequestUI(requestText); + flowState = { + stageIndex: scenario.route[0], + routeStep: 0, + routeLength: Math.max(1, scenario.route.length - 1), + route: scenario.route, + label: labels()[scenario.route[0]] || `Step ${scenario.route[0] + 1}`, + }; + render(); + + if (scene) { + scene.play({ + requestText, + route: scenario.route, + segmentMs: scenario.segmentMs, + }); + } else { + startFallback(); + } + runTraceTimeline(); + }; + + if (canvas && !reduceMotion) { + scene = createFlowCanvasRuntime({ + canvas, + stageCount: stages.length, + getLabels: labels, + onStep: onSceneStep, + }); + } + + const applyInputPlaceholder = () => { + if (!requestInput) { + return; + } + const lang = currentLang(); + const key = lang === "ja" ? "placeholderJa" : "placeholderEn"; + const placeholder = requestInput.dataset[key] || requestInput.getAttribute("placeholder") || ""; + requestInput.setAttribute("placeholder", placeholder); + }; + + const setScenario = (key) => { + activeScenarioKey = scenarioDefs[key] ? key : "safe"; + buildTrace(); + render(); + }; + + setScenario("safe"); + applyInputPlaceholder(); + replay(); + + if (runButton) { + runButton.addEventListener("click", replay); + } + if (replayButton) { + replayButton.addEventListener("click", replay); + } + if (requestInput) { + requestInput.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + replay(); + } + }); + requestInput.addEventListener("input", () => { + updateRequestUI(requestInput.value); + }); + } + scenarioButtons.forEach((btn) => { + btn.addEventListener("click", () => { + const key = btn.getAttribute("data-flow-scenario") || "safe"; + setScenario(key); + replay(); + }); + }); + + root.addEventListener("mouseenter", stopFallback); + root.addEventListener("mouseleave", () => { + if (!scene) { + startFallback(); + } + }); + + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + stopFallback(); + stopTraceTimeline(); + if (scene) { + scene.pause(); + } + } else { + if (scene) { + scene.resume(); + } else { + startFallback(); + } + } + }); + + window.addEventListener("yonerai:lang-changed", () => { + applyInputPlaceholder(); + buildTrace(); + render(); + }); + }); + } + + function animateHorizontalScroll(el, to, durationMs) { + return new Promise((resolve) => { + const from = el.scrollLeft; + const start = performance.now(); + + const step = (now) => { + const t = Math.min(1, (now - start) / durationMs); + const eased = t < 0.5 + ? 4 * t * t * t + : 1 - Math.pow(-2 * t + 2, 3) / 2; + el.scrollLeft = from + (to - from) * eased; + if (t < 1) { + requestAnimationFrame(step); + } else { + resolve(); + } + }; + + requestAnimationFrame(step); + }); + } + + async function setupMobileTopbarGuide() { + try { + const isMobile = window.matchMedia("(max-width: 760px)").matches; + if (!isMobile) { + return; + } + + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduce) { + return; + } + + const nav = document.querySelector(".oa-global-nav"); + if (!nav) { + return; + } + + if (localStorage.getItem(TOPBAR_HINT_KEY) === "1") { + return; + } + + const max = Math.max(0, nav.scrollWidth - nav.clientWidth); + if (max < 40) { + localStorage.setItem(TOPBAR_HINT_KEY, "1"); + return; + } + + localStorage.setItem(TOPBAR_HINT_KEY, "1"); + await new Promise((r) => setTimeout(r, 900)); + await animateHorizontalScroll(nav, Math.min(max, Math.round(max * 0.45)), 2400); + await new Promise((r) => setTimeout(r, 220)); + await animateHorizontalScroll(nav, 0, 2100); + } catch { + // best-effort onboarding hint + } + } + + function init() { + const lang = detectLang(); + setLangCookie(lang); + applyLang(lang); + showCookieConsent(lang); + setupLangControls(); + setupAuthEntries(); + markActiveNav(); + window.addEventListener("hashchange", markActiveNav); + setupTopbarScrollState(); + setupAnchorNavigation(); + setupPointerGlow(); + setupCardLinks(); + setupFlowVisualizers(); + setupReveal(); + fitRailLinksToLongest(); + window.requestAnimationFrame(fitRailLinksToLongest); + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(fitRailLinksToLongest).catch(() => {}); + } + let railResizeTimer = 0; + window.addEventListener("resize", () => { + window.clearTimeout(railResizeTimer); + railResizeTimer = window.setTimeout(fitRailLinksToLongest, 120); + }, { passive: true }); + setupMobileTopbarGuide(); + window.requestAnimationFrame(() => { + document.body.classList.remove("preload"); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } + + window.addEventListener("pageshow", () => { + document.body.classList.remove("preload"); + }); +})(); + diff --git a/src/web/static/terms.html b/src/web/static/terms.html new file mode 100644 index 00000000..e25ab36a --- /dev/null +++ b/src/web/static/terms.html @@ -0,0 +1,167 @@ + + + + + + YonerAI Terms of Service + + + + + + + + + + YonerAI +
    +
    + +
    + +
    +
    +

    Terms of Service

    +

    Terms of Service

    + +

    + Effective date: + 2026-04-02 +

    + +
    +

    These Terms govern your use of YonerAI's website, Discord integration, and API features. If you do not agree, do not use the service.

    + +

    1. Acceptance

    +

    + By using YonerAI, you agree to these Terms and the + Privacy Policy + . +

    + +

    2. Service description

    +
      +
    • YonerAI is an experimental AI assistant platform. Features may change, be removed, or be unavailable at times.
    • +
    • Outputs may be incorrect or incomplete. You are responsible for verifying results before relying on them.
    • +
    • YonerAI is a general-purpose assistant service and is not a substitute for emergency response, legal advice, medical advice, or law-enforcement decision making.
    • +
    + +

    3. Accounts and security

    +
      +
    • Sign-in uses OAuth providers. You are responsible for your account and for keeping your authentication credentials secure.
    • +
    • If you suspect unauthorized access, contact us promptly.
    • +
    + +

    4. Acceptable use

    +
      +
    • Do not use YonerAI for illegal activities, malware, phishing, harassment, privacy invasion, or other harmful behavior.
    • +
    • Do not attempt to bypass security controls, permission checks, approvals, or usage limits.
    • +
    • Do not submit passwords, API keys, or other secrets.
    • +
    • Do not use YonerAI to plan, encourage, assist, or conceal violence, murder, terrorism, weapon construction, human trafficking, child exploitation, stalking, fraud, or other serious crime.
    • +
    • You may not rely on YonerAI output as authorization, instruction, or legal justification for any real-world act.
    • +
    + +

    5. User responsibility and prohibited harm

    +
      +
    • You are solely responsible for your inputs, your decisions, and any actions you take based on the service or its outputs.
    • +
    • YonerAI, its operator, and its developer do not direct, endorse, or participate in illegal, violent, or harmful conduct by users.
    • +
    • If you use the service for unlawful or dangerous purposes, you alone bear the resulting civil, criminal, and administrative consequences.
    • +
    + +

    6. API keys

    +
      +
    • API keys are secrets. Keep them secure. You are responsible for any requests made with your keys.
    • +
    • You can revoke keys from the API Platform. We may revoke keys if we detect abuse or security risks.
    • +
    + +

    7. Limits and enforcement

    +
      +
    • We may apply rate limits, daily limits, or other restrictions to protect reliability and control costs.
    • +
    • We may suspend or block access if you violate these Terms or if your activity risks the service or other users.
    • +
    • We may preserve logs, freeze accounts, revoke access, refuse service, or report activity to third parties or authorities when we reasonably believe it is necessary to investigate abuse, prevent harm, or comply with law.
    • +
    + +

    8. Third-party services

    +
      +
    • YonerAI integrates with third-party providers (OAuth and AI providers). Your use of those providers may be subject to their terms and policies.
    • +
    + +

    9. Content and outputs

    +
      +
    • You retain rights to your inputs. You grant YonerAI a license to process your inputs to operate and improve the service.
    • +
    • Outputs are generated automatically and may be similar for different users. Do not treat outputs as professional advice.
    • +
    • Outputs may include unsafe, offensive, or mistaken content despite safeguards. You must independently review outputs before use or disclosure.
    • +
    + +

    10. Disclaimers

    +

    YonerAI is provided on an "as is" and "as available" basis, without warranties of any kind.

    +

    YonerAI is operated by an individual minor developer. Enterprise-grade support, uninterrupted operation, and immediate incident response are not guaranteed.

    + +

    11. Limitation of liability

    +

    To the maximum extent permitted by law, the operator is not liable for indirect, incidental, special, consequential, or punitive damages.

    +

    To the maximum extent permitted by law, YonerAI's operator and developer are not liable for unlawful, violent, abusive, or otherwise harmful acts committed by users, including acts that are planned, attempted, or justified by misuse of the service or its outputs.

    + +

    12. Changes and contact

    +

    + We may update these Terms. The effective date at the top of this page indicates the latest version. For questions, use + the contact page + . +

    +
    +
    +
    + + +
    + + + + + + diff --git a/src/web/static/trust-safety.html b/src/web/static/trust-safety.html new file mode 100644 index 00000000..c11bee5f --- /dev/null +++ b/src/web/static/trust-safety.html @@ -0,0 +1,128 @@ + + + + + + YonerAI Trust & Safety + + + + + + + + + + YonerAI +
    +
    + +
    + +
    +
    +

    Trust & Safety

    +

    Trust & Safety

    + +

    + Effective date: + 2026-04-02 +

    + +
    +

    This page explains YonerAI's public trust and safety stance. It describes the boundaries of the service, the kinds of use we prohibit, and the kinds of actions we may take when we detect abuse, danger, or legal risk.

    + +

    1. Safety commitments

    +
      +
    • YonerAI is designed to be safe-by-default. Public surfaces are intentionally narrower than internal or private operational systems.
    • +
    • High-risk capabilities should require explicit authorization, and public chat surfaces should not silently gain internal control privileges.
    • +
    • We rely on logs, audits, limits, and fail-closed behavior to reduce harm and narrow the blast radius when something goes wrong.
    • +
    + +

    2. Prohibited and high-risk use

    +
      +
    • YonerAI must not be used to facilitate, coordinate, justify, or conceal violence, murder, terrorism, weapon construction, child exploitation, human trafficking, stalking, fraud, or evasion of law enforcement.
    • +
    • The service is not intended to provide autonomous authority over real-world systems, law-enforcement decisions, or safety-critical operations.
    • +
    • Users remain solely responsible for how they interpret and act on outputs. Outputs do not create permission, authorization, immunity, or legal justification.
    • +
    + +

    3. Response and enforcement

    +
      +
    • We may rate-limit, suspend, revoke, isolate, or refuse service when abuse, credible danger, or policy violations are detected.
    • +
    • Relevant logs, session records, and audit data may be preserved to investigate incidents, prevent harm, protect users, or comply with law.
    • +
    • We may cooperate with hosting providers, platforms, security partners, or authorities where reasonably necessary to respond to abuse, legal requests, or serious safety concerns.
    • +
    + +

    4. Operator status and limits

    +

    YonerAI is currently operated by a minor individual developer. Safety measures are implemented in good faith and improved over time, but the service should not be treated as a guaranteed enterprise-grade safety or legal-response program.

    +

    If users misuse the service for unlawful, violent, or otherwise harmful conduct, responsibility remains with those users. YonerAI and its developer do not endorse, direct, or participate in such conduct.

    + +

    5. Ongoing changes

    +

    Trust and safety controls may change as the service evolves, threats change, or new infrastructure is introduced. This page may be updated without advance notice when we believe doing so is necessary to protect users or the service.

    +
    +
    +
    + + +
    + + + + + + + + + + + + + + + diff --git a/src/web/static/vendor/marked.min.js b/src/web/static/vendor/marked.min.js new file mode 100644 index 00000000..833414a3 Binary files /dev/null and b/src/web/static/vendor/marked.min.js differ diff --git a/src/web/static/vendor/purify.min.js b/src/web/static/vendor/purify.min.js new file mode 100644 index 00000000..ed16b4a9 Binary files /dev/null and b/src/web/static/vendor/purify.min.js differ diff --git a/src/web/static/web-mobile.html b/src/web/static/web-mobile.html new file mode 100644 index 00000000..16887f1d --- /dev/null +++ b/src/web/static/web-mobile.html @@ -0,0 +1,109 @@ + + + + + + YonerAI Web / iOS / Android + + + + + + + + + + YonerAI +
    +
    + +
    + +
    +
    +
    +

    Client surface

    +

    One product, optimized for browser and mobile.

    +

    Web is the fastest deployment path; iOS and Android follow the same API contracts and role model.

    +
    +
    +
    Web
    Primary console
    +
    iOS
    Native client plan
    +
    Android
    Native client plan
    +
    Contract
    Shared API behavior
    +
    +
    + +
    +

    UX priorities

    +
    +

    Fast setup

    Language choice, auth flow, and basic configuration should complete in minutes.

    +

    Accessibility by default

    Keyboard navigation, screen-reader labels, and contrast are first-class requirements.

    +

    Consistent controls

    Approval status and risk indicators should appear consistently across all client types.

    +
    +
    +
    + + +
    + + + + + + + + + + + + + + + diff --git a/tests/test_managed_cloud_mvp.py b/tests/test_managed_cloud_mvp.py new file mode 100644 index 00000000..9d86588a --- /dev/null +++ b/tests/test_managed_cloud_mvp.py @@ -0,0 +1,649 @@ +# ruff: noqa: E402 +from __future__ import annotations + +import asyncio +import base64 +import os +import secrets + +from fastapi.testclient import TestClient + +os.environ["ORA_BOT_DB"] = "test_managed_cloud_mvp.db" +os.environ["ORA_DISABLE_WEB_BG_TASKS"] = "1" + +from src.web import endpoints +from src.web.app import app, get_store + +_TINY_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0MsAAAAASUVORK5CYII=" +) + + +def _attach_session_cookie(client: TestClient, *, user_id: str = "web:test-user") -> str: + raw_token = secrets.token_urlsafe(32) + store = get_store() + asyncio.run(store.ensure_user_id(user_id=user_id, display_name="Test User")) + asyncio.run( + store.upsert_user_identity( + provider="google", + provider_sub=f"google-sub:{user_id}", + user_id=user_id, + email="test@example.com", + display_name="Test User", + avatar_url="https://example.com/avatar.png", + ) + ) + asyncio.run( + store.create_web_session( + session_hash=endpoints._session_hash(raw_token), + user_id=user_id, + provider="google", + ttl_sec=3600, + ) + ) + client.cookies.set(endpoints.WEB_SESSION_COOKIE, raw_token) + return user_id + + +def _enable_guest_chat(monkeypatch) -> None: + monkeypatch.setenv("ORA_WEB_GUEST_CHAT", "1") + monkeypatch.setattr(endpoints, "_can_use_unauthenticated_loopback", lambda _request: True) + + +def test_managed_cloud_pages_render(): + with TestClient(app) as client: + root = client.get("/") + assert root.status_code == 200 + assert "YonerAI Platform Preview" in root.text + + jp = client.get("/jp") + assert jp.status_code == 200 + assert "YonerAI Platform Preview" in jp.text + + chat = client.get("/jp/chat") + assert chat.status_code == 200 + assert "YonerAI Chat" in chat.text + + terms = client.get("/terms") + assert terms.status_code == 200 + assert "Terms of Service" in terms.text + + privacy = client.get("/privacy") + assert privacy.status_code == 200 + assert "Privacy Policy" in privacy.text + + login = client.get("/auth/login?returnTo=/jp/chat") + assert login.status_code == 200 + assert "Continue with Google" in login.text + assert "Sign in with Microsoft" not in login.text + assert "Sign in with Discord" not in login.text + assert "Sign in with X" not in login.text + + +def test_chat_redirect_prefers_cf_country_then_accept_language(): + with TestClient(app) as client: + jp = client.get("/chat", headers={"cf-ipcountry": "JP"}, follow_redirects=False) + assert jp.status_code == 307 + assert jp.headers["location"] == "/jp/chat" + + en = client.get("/chat", headers={"cf-ipcountry": "DE"}, follow_redirects=False) + assert en.status_code == 307 + assert en.headers["location"] == "/en/chat" + + +def test_auth_me_requires_session_then_returns_identity(): + with TestClient(app) as client: + unauth = client.get("/api/auth/me") + assert unauth.status_code == 401 + + user_id = _attach_session_cookie(client) + authed = client.get("/api/auth/me") + assert authed.status_code == 200 + data = authed.json() + assert data["authenticated"] is True + assert data["user_id"] == user_id + assert data["provider"] == "google" + + +def test_auth_me_issues_guest_session_on_loopback(monkeypatch): + _enable_guest_chat(monkeypatch) + + with TestClient(app) as client: + first = client.get("/api/auth/me", headers={"accept-language": "ja"}) + assert first.status_code == 200 + guest_a = first.json() + assert guest_a["provider"] == "guest" + assert guest_a["guest"] is True + assert guest_a["display_name"] == "ゲスト" + assert guest_a["user_id"].startswith("web:guest:") + + second = client.get("/api/auth/me", headers={"accept-language": "ja"}) + assert second.status_code == 200 + guest_b = second.json() + assert guest_b["user_id"] == guest_a["user_id"] + assert guest_b["provider"] == "guest" + + +def test_public_chat_message_uses_session_actor(monkeypatch): + endpoints._RUN_QUEUES.clear() + endpoints._RUN_TOOL_OUTPUTS.clear() + endpoints._RUN_OWNERS.clear() + endpoints._PUBLIC_CHAT_IDEMPOTENCY.clear() + + seen: dict[str, object] = {} + + def fake_start_agent_run(*, content: str, available_tools, provider_id: str, attachments, conversation_id=None, runtime_kind: str = "default"): + assert content == "hello" + seen["provider_id"] = provider_id + assert available_tools == [] + assert isinstance(conversation_id, str) + assert runtime_kind == "managed_cloud_web" + return "run-public-1" + + monkeypatch.setattr(endpoints, "_start_agent_run", fake_start_agent_run) + + with TestClient(app) as client: + user_id = _attach_session_cookie(client, user_id=f"web:test-user:{secrets.token_hex(4)}") + response = client.post( + "/api/public/chat/messages", + json={"content": "hello", "idempotency_key": "abc-123"}, + headers={"Idempotency-Key": "abc-123"}, + ) + assert response.status_code == 200 + assert response.json()["run_id"] == "run-public-1" + assert seen["provider_id"] == user_id + assert endpoints._RUN_OWNERS["run-public-1"] == user_id + + +def test_public_chat_message_allows_guest_session(monkeypatch): + endpoints._RUN_QUEUES.clear() + endpoints._RUN_TOOL_OUTPUTS.clear() + endpoints._RUN_OWNERS.clear() + endpoints._PUBLIC_CHAT_IDEMPOTENCY.clear() + _enable_guest_chat(monkeypatch) + + seen: dict[str, str] = {} + + def fake_start_agent_run(*, content: str, available_tools, provider_id: str, attachments, conversation_id=None, runtime_kind: str = "default"): + seen["provider_id"] = provider_id + seen["conversation_id"] = conversation_id + return "run-guest-1" + + monkeypatch.setattr(endpoints, "_start_agent_run", fake_start_agent_run) + + with TestClient(app) as client: + response = client.post( + "/api/public/chat/messages", + json={"content": "hello guest", "idempotency_key": "guest-123"}, + headers={"Idempotency-Key": "guest-123"}, + ) + assert response.status_code == 200 + assert response.json()["run_id"] == "run-guest-1" + assert seen["provider_id"].startswith("web:guest:") + assert endpoints._RUN_OWNERS["run-guest-1"] == seen["provider_id"] + + me = client.get("/api/auth/me") + assert me.status_code == 200 + assert me.json()["user_id"] == seen["provider_id"] + + +def test_public_chat_upload_accepts_guest_images(monkeypatch): + endpoints._PUBLIC_CHAT_ATTACHMENTS.clear() + _enable_guest_chat(monkeypatch) + + with TestClient(app) as client: + response = client.post( + "/api/public/attachments/upload", + files=[("files", ("tiny.png", _TINY_PNG, "image/png"))], + ) + assert response.status_code == 200 + data = response.json() + assert data["max_count"] == 4 + assert len(data["attachments"]) == 1 + item = data["attachments"][0] + assert item["mime_type"] == "image/png" + assert item["size"] == len(_TINY_PNG) + attachment_id = item["attachment_id"] + assert attachment_id in endpoints._PUBLIC_CHAT_ATTACHMENTS + + +def test_public_chat_upload_rejects_more_than_four_images(monkeypatch): + endpoints._PUBLIC_CHAT_ATTACHMENTS.clear() + _enable_guest_chat(monkeypatch) + + with TestClient(app) as client: + files = [("files", (f"tiny-{idx}.png", _TINY_PNG, "image/png")) for idx in range(5)] + response = client.post("/api/public/attachments/upload", files=files) + assert response.status_code == 400 + detail = response.json()["detail"] + assert detail["code"] == "ATTACHMENT_TOO_MANY" + assert detail["max_count"] == 4 + + +def test_public_chat_message_rejects_content_over_5000(monkeypatch): + endpoints._RUN_QUEUES.clear() + endpoints._RUN_TOOL_OUTPUTS.clear() + endpoints._RUN_OWNERS.clear() + endpoints._PUBLIC_CHAT_IDEMPOTENCY.clear() + _enable_guest_chat(monkeypatch) + + response = TestClient(app).post( + "/api/public/chat/messages", + json={"content": "a" * 5001}, + ) + assert response.status_code == 400 + detail = response.json()["detail"] + assert detail["code"] == "CHAT_CONTENT_TOO_LONG" + assert detail["max_chars"] == 5000 + + +def test_public_chat_message_resolves_uploaded_image_refs(monkeypatch): + endpoints._RUN_QUEUES.clear() + endpoints._RUN_TOOL_OUTPUTS.clear() + endpoints._RUN_OWNERS.clear() + endpoints._PUBLIC_CHAT_IDEMPOTENCY.clear() + endpoints._PUBLIC_CHAT_ATTACHMENTS.clear() + _enable_guest_chat(monkeypatch) + + seen: dict[str, object] = {} + + def fake_start_agent_run(*, content: str, available_tools, provider_id: str, attachments, conversation_id=None, runtime_kind: str = "default"): + seen["content"] = content + seen["attachments"] = attachments + seen["provider_id"] = provider_id + return "run-vision-1" + + monkeypatch.setattr(endpoints, "_start_agent_run", fake_start_agent_run) + + with TestClient(app) as client: + upload = client.post( + "/api/public/attachments/upload", + files=[("files", ("tiny.png", _TINY_PNG, "image/png"))], + ) + assert upload.status_code == 200 + attachment_id = upload.json()["attachments"][0]["attachment_id"] + + response = client.post( + "/api/public/chat/messages", + json={ + "content": "", + "attachments": [{"type": "image_ref", "attachment_id": attachment_id}], + }, + ) + assert response.status_code == 200 + assert response.json()["run_id"] == "run-vision-1" + assert seen["content"] == "" + resolved = seen["attachments"] + assert isinstance(resolved, list) + assert len(resolved) == 1 + assert resolved[0]["type"] == "image_url" + assert str(resolved[0]["image_url"]["url"]).startswith("data:image/png;base64,") + + +def test_public_chat_usage_reports_google_limit(): + with TestClient(app) as client: + user_id = _attach_session_cookie(client, user_id=f"web:quota-google:{secrets.token_hex(4)}") + store = get_store() + asyncio.run( + store.record_api_usage( + user_id=user_id, + api_key_id=None, + method="POST", + path="/api/public/chat/messages", + status=200, + latency_ms=12, + request_bytes=10, + response_bytes=20, + meta_json='{"source":"test"}', + ) + ) + asyncio.run( + store.record_api_usage( + user_id=user_id, + api_key_id=None, + method="POST", + path="/api/public/chat/messages", + status=200, + latency_ms=15, + request_bytes=11, + response_bytes=21, + meta_json='{"source":"test"}', + ) + ) + + response = client.get("/api/public/chat/usage") + assert response.status_code == 200 + data = response.json() + assert data["provider"] == "google" + assert data["limit"] == 20 + assert data["used"] == 2 + assert data["remaining"] == 18 + assert data["used_session_raw"] == 2 + assert data["used_api_key_raw"] == 0 + + +def test_public_chat_usage_issues_guest_limit(monkeypatch): + _enable_guest_chat(monkeypatch) + + with TestClient(app) as client: + first = client.get("/api/public/chat/usage") + assert first.status_code == 200 + data = first.json() + assert data["provider"] == "guest" + assert data["limit"] == 3 + assert data["used"] == 0 + assert data["remaining"] == 3 + + +def test_public_chat_title_guest_returns_summary(monkeypatch): + _enable_guest_chat(monkeypatch) + + async def fake_title(*, content: str, max_len: int, lang: str) -> str: + assert "YonerAI" in content + assert max_len == 20 + assert lang == "ja" + return "YonerAIって何者?" + + monkeypatch.setattr(endpoints, "_generate_public_chat_title", fake_title) + + with TestClient(app) as client: + response = client.post( + "/api/public/chat/title", + json={"content": "君は誰 YonerAI ってどんなの?", "max_len": 20, "lang": "ja"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["title"] == "YonerAIって何者?" + assert data["max_len"] == 20 + me = client.get("/api/auth/me") + assert me.status_code == 200 + assert me.json()["provider"] == "guest" + + +def test_public_chat_message_rejects_when_daily_limit_reached(monkeypatch): + endpoints._RUN_QUEUES.clear() + endpoints._RUN_TOOL_OUTPUTS.clear() + endpoints._RUN_OWNERS.clear() + endpoints._PUBLIC_CHAT_IDEMPOTENCY.clear() + + def fail_if_started(**_kwargs): + raise AssertionError("run should not start after quota is exhausted") + + monkeypatch.setattr(endpoints, "_start_agent_run", fail_if_started) + + with TestClient(app) as client: + user_id = _attach_session_cookie(client, user_id=f"web:quota-hit:{secrets.token_hex(4)}") + store = get_store() + for _ in range(20): + asyncio.run( + store.record_api_usage( + user_id=user_id, + api_key_id=None, + method="POST", + path="/api/public/chat/messages", + status=200, + latency_ms=10, + request_bytes=9, + response_bytes=18, + meta_json='{"source":"test"}', + ) + ) + + response = client.post( + "/api/public/chat/messages", + json={"content": "limit?"}, + ) + assert response.status_code == 429 + detail = response.json()["detail"] + assert detail["code"] == "CHAT_LIMIT_EXCEEDED" + assert detail["limit"] == 20 + assert detail["used"] == 20 + assert detail["remaining"] == 0 + + +def test_public_chat_events_require_owner(): + endpoints._RUN_QUEUES.clear() + endpoints._RUN_TOOL_OUTPUTS.clear() + endpoints._RUN_OWNERS.clear() + + queue: asyncio.Queue = asyncio.Queue() + queue.put_nowait({"event": "final", "data": {"output_text": "ok"}}) + queue.put_nowait(None) + endpoints._RUN_QUEUES["run-owned"] = queue + endpoints._RUN_OWNERS["run-owned"] = "web:owner" + + with TestClient(app) as client: + _attach_session_cookie(client, user_id="web:other") + response = client.get("/api/public/chat/runs/run-owned/events") + assert response.status_code == 403 + + +def test_public_chat_events_stream_for_owner(): + endpoints._RUN_QUEUES.clear() + endpoints._RUN_TOOL_OUTPUTS.clear() + endpoints._RUN_OWNERS.clear() + + queue: asyncio.Queue = asyncio.Queue() + queue.put_nowait({"event": "progress", "data": {"stage": "compose"}}) + queue.put_nowait({"event": "final", "data": {"output_text": "done"}}) + queue.put_nowait(None) + endpoints._RUN_QUEUES["run-owned"] = queue + endpoints._RUN_OWNERS["run-owned"] = "web:owner" + + with TestClient(app) as client: + _attach_session_cookie(client, user_id="web:owner") + response = client.get("/api/public/chat/runs/run-owned/events") + assert response.status_code == 200 + assert '"event": "progress"' in response.text + assert '"event": "final"' in response.text + assert '"output_text": "done"' in response.text + + +def test_public_chat_message_binds_conversation_id(monkeypatch): + endpoints._RUN_QUEUES.clear() + endpoints._RUN_TOOL_OUTPUTS.clear() + endpoints._RUN_OWNERS.clear() + endpoints._PUBLIC_CHAT_IDEMPOTENCY.clear() + + seen: dict[str, object] = {} + + def fake_start_agent_run(*, content: str, available_tools, provider_id: str, attachments, conversation_id=None, runtime_kind: str = "default"): + seen["provider_id"] = provider_id + seen["conversation_id"] = conversation_id + return "run-conv-1" + + monkeypatch.setattr(endpoints, "_start_agent_run", fake_start_agent_run) + + with TestClient(app) as client: + user_id = _attach_session_cookie(client, user_id=f"web:test-user:{secrets.token_hex(4)}") + response = client.post( + "/api/public/chat/messages", + json={"content": "hello", "conversation_id": "thread-123"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["run_id"] == "run-conv-1" + assert payload["conversation_id"] == "thread-123" + assert seen["provider_id"] == user_id + assert seen["conversation_id"] == "thread-123" + + +def test_managed_cloud_runtime_uses_yonerai_prompt_and_thread_memory(monkeypatch): + endpoints._RUN_QUEUES.clear() + endpoints._RUN_TOOL_OUTPUTS.clear() + endpoints._RUN_OWNERS.clear() + endpoints._PUBLIC_CHAT_IDEMPOTENCY.clear() + + captured: dict[str, object] = {} + + class FakeLLMClient: + def __init__(self, base_url, api_key, model, session=None): + captured["base_url"] = base_url + captured["api_key"] = api_key + captured["model"] = model + + async def chat(self, messages, temperature=0.7, **kwargs): + captured["messages"] = messages + return "new answer", None, {} + + monkeypatch.setattr("src.utils.llm_client.LLMClient", FakeLLMClient) + monkeypatch.setattr( + endpoints, + "_load_agent_runtime_config", + lambda *, runtime_kind: endpoints.SimpleNamespace( + llm_base_url="http://example.invalid/v1", + llm_api_key="EMPTY", + llm_model="gpt-5-mini", + openai_base_url="http://example.invalid/v1", + profile="private", + admin_user_id=None, + sub_admin_ids=set(), + ), + ) + + with TestClient(app): + store = get_store() + asyncio.run( + store.add_conversation( + user_id="web:memory-user", + conversation_id="conv-a", + platform="web", + message="first question", + response="first answer", + ) + ) + asyncio.run( + store.add_conversation( + user_id="web:memory-user", + conversation_id="conv-b", + platform="web", + message="other question", + response="other answer", + ) + ) + run_id = "run-memory-scoped" + endpoints._RUN_QUEUES[run_id] = asyncio.Queue() + endpoints._RUN_TOOL_OUTPUTS[run_id] = asyncio.Queue() + + asyncio.run( + endpoints.run_agent_loop( + run_id, + "what did we discuss?", + [], + "web:memory-user", + [], + "conv-a", + "managed_cloud_web", + ) + ) + + messages = captured["messages"] + system_message = str(messages[0]["content"]) + assert "YonerAI" in system_message + assert "ORA (Unified GPT-5 Environment)" not in system_message + assert any( + m.get("role") == "system" and "[Current Thread Memory]" in str(m.get("content") or "") + for m in messages + ) + assert any(m.get("role") == "user" and m.get("content") == "first question" for m in messages) + assert any(m.get("role") == "assistant" and m.get("content") == "first answer" for m in messages) + assert not any(m.get("role") == "user" and m.get("content") == "other question" for m in messages) + + rows = asyncio.run( + store.get_conversations(user_id="web:memory-user", conversation_id="conv-a", limit=5) + ) + assert any(row["message"] == "what did we discuss?" and row["response"] == "new answer" for row in rows) + + +def test_managed_cloud_runtime_prefers_openai_web_model(monkeypatch): + monkeypatch.delenv("LLM_BASE_URL", raising=False) + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "test-openai-key") + monkeypatch.setenv("LLM_MODEL", "mistralai/ministral-3-14b-reasoning") + monkeypatch.delenv("OPENAI_DEFAULT_MODEL", raising=False) + monkeypatch.delenv("OPENAI_MODEL", raising=False) + monkeypatch.delenv("MANAGED_CLOUD_LLM_MODEL", raising=False) + monkeypatch.delenv("WEB_LLM_MODEL", raising=False) + + cfg = endpoints._load_managed_cloud_runtime_config() + assert cfg.llm_base_url == "https://api.openai.com/v1" + assert cfg.llm_model == "gpt-5-mini" + + +def test_managed_cloud_runtime_persists_and_reuses_conversation(monkeypatch): + endpoints._RUN_QUEUES.clear() + endpoints._RUN_TOOL_OUTPUTS.clear() + endpoints._RUN_OWNERS.clear() + endpoints._PUBLIC_CHAT_IDEMPOTENCY.clear() + + captured: dict[str, object] = {} + + class FakeLLMClient: + def __init__(self, base_url, api_key, model, session=None): + captured["base_url"] = base_url + captured["api_key"] = api_key + captured["model"] = model + + async def chat(self, messages, temperature=0.7, **kwargs): + captured["messages"] = messages + return "新しい返答", None, {} + + monkeypatch.setattr("src.utils.llm_client.LLMClient", FakeLLMClient) + monkeypatch.setattr( + endpoints, + "_load_agent_runtime_config", + lambda *, runtime_kind: endpoints.SimpleNamespace( + llm_base_url="http://example.invalid/v1", + llm_api_key="EMPTY", + llm_model="gpt-5-mini", + openai_base_url="http://example.invalid/v1", + profile="private", + admin_user_id=None, + sub_admin_ids=set(), + ), + ) + + with TestClient(app): + store = get_store() + asyncio.run( + store.add_conversation( + user_id="web:memory-user", + platform="web", + message="前の質問", + response="前の返答", + ) + ) + run_id = "run-memory" + endpoints._RUN_QUEUES[run_id] = asyncio.Queue() + endpoints._RUN_TOOL_OUTPUTS[run_id] = asyncio.Queue() + + asyncio.run( + endpoints.run_agent_loop( + run_id=run_id, + content="今回の質問", + available_tools=[], + provider_id="web:memory-user", + attachments=[], + conversation_id=None, + runtime_kind="managed_cloud_web", + ) + ) + + messages = captured["messages"] + assert any(m.get("role") == "user" and m.get("content") == "前の質問" for m in messages) + assert any(m.get("role") == "assistant" and m.get("content") == "前の返答" for m in messages) + + rows = asyncio.run(store.get_conversations(user_id="web:memory-user", limit=5)) + assert any(row["message"] == "今回の質問" and row["response"] == "新しい返答" for row in rows) + + +def teardown_module(): + endpoints._RUN_QUEUES.clear() + endpoints._RUN_TOOL_OUTPUTS.clear() + endpoints._RUN_OWNERS.clear() + endpoints._PUBLIC_CHAT_IDEMPOTENCY.clear() + try: + if os.path.exists("test_managed_cloud_mvp.db"): + os.remove("test_managed_cloud_mvp.db") + except Exception: + pass