woiceatus commited on
Commit
2671e04
·
1 Parent(s): e43a4a9

add a chatclient for test

Browse files
doc/common_mistakes.md CHANGED
@@ -6,4 +6,5 @@
6
  - For audio input, send base64 on `input_audio.data` with `format: "mp3"` or `"wav"`, or send `input_audio.url` and let the proxy download and convert it to mp3.
7
  - Streamed chat completions are passed through directly, so proxy-hosted media URLs are only added on non-stream responses.
8
  - Proxy-hosted media files are stored in memory and expire after `MEDIA_TTL_SECONDS`.
 
9
  - Keep modules small and focused; this project follows the principle of simple modules with clear responsibility.
 
6
  - For audio input, send base64 on `input_audio.data` with `format: "mp3"` or `"wav"`, or send `input_audio.url` and let the proxy download and convert it to mp3.
7
  - Streamed chat completions are passed through directly, so proxy-hosted media URLs are only added on non-stream responses.
8
  - Proxy-hosted media files are stored in memory and expire after `MEDIA_TTL_SECONDS`.
9
+ - The demo UI in `public/chatclient/` assumes the proxy is available on the same origin unless you change the endpoint field manually.
10
  - Keep modules small and focused; this project follows the principle of simple modules with clear responsibility.
doc/overview.md CHANGED
@@ -3,7 +3,7 @@
3
  ## Current Status
4
 
5
  - Project type: Node.js OpenAI-compatible chat completions proxy.
6
- - Status: working baseline implemented and verified.
7
  - Verified with `npm test`, `npm run build`, and a live `GET /v1/health` startup check.
8
  - Build output folder: `C:\Users\a\AppData\Local\Temp\oapix-build`
9
 
@@ -16,6 +16,7 @@
16
  - Exposes audio output base64 as a temporary proxy URL on non-stream responses.
17
  - Exposes image data URLs returned by compatible upstreams as temporary proxy URLs on non-stream responses.
18
  - Exposes `GET /v1/media/:mediaId` for temporary media retrieval and `GET /v1/health` for health checks.
 
19
 
20
  ## Runtime Notes
21
 
@@ -23,6 +24,7 @@
23
  - Media files are stored in memory and expire after `MEDIA_TTL_SECONDS`.
24
  - Stream responses are passed through directly and do not get extra proxy media URLs added.
25
  - The build script writes a bundled server file and deployment manifest files into the temp build folder.
 
26
 
27
  ## Project Structure
28
 
@@ -38,6 +40,18 @@ oapix/
38
  │ ├─ overview.md # Current project status and structure
39
  │ ├─ update.md # Latest finished-task summary
40
  │ └─ updates.md # Historical task log, newest first
 
 
 
 
 
 
 
 
 
 
 
 
41
  ├─ scripts/
42
  │ └─ build.mjs # Temp-folder build output script
43
  ├─ src/
 
3
  ## Current Status
4
 
5
  - Project type: Node.js OpenAI-compatible chat completions proxy.
6
+ - Status: working baseline implemented and verified, with a browser demo client in `public/chatclient/`.
7
  - Verified with `npm test`, `npm run build`, and a live `GET /v1/health` startup check.
8
  - Build output folder: `C:\Users\a\AppData\Local\Temp\oapix-build`
9
 
 
16
  - Exposes audio output base64 as a temporary proxy URL on non-stream responses.
17
  - Exposes image data URLs returned by compatible upstreams as temporary proxy URLs on non-stream responses.
18
  - Exposes `GET /v1/media/:mediaId` for temporary media retrieval and `GET /v1/health` for health checks.
19
+ - Serves a landing page from `/` and an interactive demo chat client from `/chatclient/`.
20
 
21
  ## Runtime Notes
22
 
 
24
  - Media files are stored in memory and expire after `MEDIA_TTL_SECONDS`.
25
  - Stream responses are passed through directly and do not get extra proxy media URLs added.
26
  - The build script writes a bundled server file and deployment manifest files into the temp build folder.
27
+ - Static frontend files are served directly from `public/` with a service worker for cached HTML, CSS, and JS.
28
 
29
  ## Project Structure
30
 
 
40
  │ ├─ overview.md # Current project status and structure
41
  │ ├─ update.md # Latest finished-task summary
42
  │ └─ updates.md # Historical task log, newest first
43
+ ├─ public/
44
+ │ ├─ app.js # Landing-page script and service-worker registration
45
+ │ ├─ index.html # Basic usage page and API example
46
+ │ ├─ styles.css # Landing-page styles
47
+ │ ├─ sw-register.js # Shared service-worker registration helper
48
+ │ ├─ sw.js # Offline cache and timed refresh logic
49
+ │ └─ chatclient/
50
+ │ ├─ app.js # Browser chat client controller
51
+ │ ├─ index.html # Chat UI
52
+ │ ├─ media.js # Attachment and file/base64 helpers
53
+ │ ├─ render.js # Response rendering and toast UI helpers
54
+ │ └─ styles.css # Chat UI styles
55
  ├─ scripts/
56
  │ └─ build.mjs # Temp-folder build output script
57
  ├─ src/
public/app.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ import { registerServiceWorker } from "/sw-register.js";
2
+
3
+ registerServiceWorker();
public/chatclient/app.js ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { registerServiceWorker } from "/sw-register.js";
2
+ import { buildAttachmentPart } from "/chatclient/media.js";
3
+ import { renderResponse, setStatus, showError } from "/chatclient/render.js";
4
+
5
+ const form = document.querySelector("#chat-form");
6
+ const endpointInput = document.querySelector("#endpoint");
7
+ const attachmentTypeInput = document.querySelector("#attachment-type");
8
+ const fileInput = document.querySelector("#attachment-file");
9
+ const statusLine = document.querySelector("#status-line");
10
+ const submitButton = document.querySelector("#submit-button");
11
+ const rawJson = document.querySelector("#raw-json");
12
+ const messageList = document.querySelector("#message-list");
13
+ const errorToast = document.querySelector("#error-toast");
14
+
15
+ registerServiceWorker();
16
+ endpointInput.value = `${window.location.origin}/v1/chat/completions`;
17
+ updateAttachmentFields();
18
+ attachmentTypeInput.addEventListener("change", updateAttachmentFields);
19
+ form.addEventListener("submit", handleSubmit);
20
+
21
+ function updateAttachmentFields() {
22
+ const value = attachmentTypeInput.value;
23
+ for (const field of document.querySelectorAll(".attachment-field")) {
24
+ const kinds = field.dataset.kind.split(" ");
25
+ field.hidden = !kinds.includes(value);
26
+ }
27
+
28
+ fileInput.accept = value.startsWith("image") ? "image/*" : value.startsWith("audio") ? "audio/*" : "";
29
+ }
30
+
31
+ async function handleSubmit(event) {
32
+ event.preventDefault();
33
+ submitButton.disabled = true;
34
+ setStatus(statusLine, "Sending request...");
35
+
36
+ try {
37
+ const payload = await buildPayload(new FormData(form));
38
+ const response = await fetch(endpointInput.value, {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json" },
41
+ body: JSON.stringify(payload)
42
+ });
43
+
44
+ const data = await readResponseBody(response);
45
+ rawJson.textContent = JSON.stringify(data, null, 2);
46
+
47
+ if (!response.ok) {
48
+ throw new Error(data?.error?.message ?? `HTTP ${response.status}`);
49
+ }
50
+
51
+ renderResponse(messageList, payload, data);
52
+ setStatus(statusLine, "Response received.", true);
53
+ } catch (error) {
54
+ setStatus(statusLine, "Request failed.");
55
+ showError(errorToast, error.message);
56
+ } finally {
57
+ submitButton.disabled = false;
58
+ }
59
+ }
60
+
61
+ async function buildPayload(formData) {
62
+ const attachmentType = formData.get("attachmentType");
63
+ const userText = String(formData.get("userText") || "").trim();
64
+ const systemPrompt = String(formData.get("systemPrompt") || "").trim();
65
+ const content = [];
66
+
67
+ if (userText) {
68
+ content.push({ type: "text", text: userText });
69
+ }
70
+
71
+ if (attachmentType !== "none") {
72
+ content.push(await buildAttachmentPart(attachmentType, formData));
73
+ }
74
+
75
+ if (content.length === 0) {
76
+ throw new Error("Add a user message or attachment before sending.");
77
+ }
78
+
79
+ const payload = {
80
+ model: String(formData.get("model") || "").trim(),
81
+ messages: []
82
+ };
83
+
84
+ if (systemPrompt) {
85
+ payload.messages.push({ role: "system", content: systemPrompt });
86
+ }
87
+
88
+ payload.messages.push({ role: "user", content });
89
+
90
+ if (formData.get("audioOutput")) {
91
+ payload.audio = {
92
+ voice: String(formData.get("voice") || "alloy"),
93
+ format: "mp3"
94
+ };
95
+ }
96
+
97
+ return payload;
98
+ }
99
+
100
+ async function readResponseBody(response) {
101
+ const text = await response.text();
102
+ if (!text) {
103
+ return {};
104
+ }
105
+
106
+ try {
107
+ return JSON.parse(text);
108
+ } catch (_error) {
109
+ return {
110
+ error: {
111
+ message: text
112
+ }
113
+ };
114
+ }
115
+ }
public/chatclient/index.html ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>oapix chat client</title>
7
+ <link rel="stylesheet" href="/chatclient/styles.css">
8
+ <script type="module" src="/chatclient/app.js"></script>
9
+ </head>
10
+ <body>
11
+ <main class="chat-shell">
12
+ <section class="side-panel">
13
+ <a class="back-link" href="/">Back</a>
14
+ <p class="eyebrow">Demo Client</p>
15
+ <h1>Test the proxy from your browser.</h1>
16
+ <p class="panel-copy">
17
+ Send text plus one optional image or audio attachment. The client supports URL, file, and
18
+ raw base64 inputs, then renders assistant text, audio, images, and raw JSON.
19
+ </p>
20
+
21
+ <form id="chat-form" class="composer-card">
22
+ <label>
23
+ <span>Endpoint</span>
24
+ <input id="endpoint" name="endpoint" type="url">
25
+ </label>
26
+
27
+ <div class="inline-grid">
28
+ <label>
29
+ <span>Model</span>
30
+ <input id="model" name="model" type="text" value="gpt-4.1-mini" required>
31
+ </label>
32
+
33
+ <label>
34
+ <span>Attachment</span>
35
+ <select id="attachment-type" name="attachmentType">
36
+ <option value="none">None</option>
37
+ <option value="image-url">Image URL</option>
38
+ <option value="image-file">Image File</option>
39
+ <option value="image-base64">Image Base64</option>
40
+ <option value="audio-url">Audio URL</option>
41
+ <option value="audio-file">Audio File</option>
42
+ <option value="audio-base64">Audio Base64</option>
43
+ </select>
44
+ </label>
45
+ </div>
46
+
47
+ <label>
48
+ <span>System Prompt</span>
49
+ <textarea id="system-prompt" name="systemPrompt" rows="3" placeholder="Optional system message"></textarea>
50
+ </label>
51
+
52
+ <label>
53
+ <span>User Message</span>
54
+ <textarea id="user-text" name="userText" rows="5" placeholder="Write the user message here"></textarea>
55
+ </label>
56
+
57
+ <section id="attachment-panel" class="attachment-panel">
58
+ <label class="attachment-field" data-kind="image-url audio-url">
59
+ <span>Attachment URL</span>
60
+ <input id="attachment-url" name="attachmentUrl" type="url" placeholder="https://example.com/file">
61
+ </label>
62
+
63
+ <label class="attachment-field" data-kind="image-file audio-file">
64
+ <span>Attachment File</span>
65
+ <input id="attachment-file" name="attachmentFile" type="file">
66
+ </label>
67
+
68
+ <label class="attachment-field" data-kind="image-base64 audio-base64">
69
+ <span>Attachment Base64</span>
70
+ <textarea id="attachment-base64" name="attachmentBase64" rows="5" placeholder="Paste raw base64 or a data URL"></textarea>
71
+ </label>
72
+ </section>
73
+
74
+ <div class="inline-grid">
75
+ <label class="toggle-card">
76
+ <span>Audio Output</span>
77
+ <input id="audio-output" name="audioOutput" type="checkbox" checked>
78
+ </label>
79
+
80
+ <label>
81
+ <span>Voice</span>
82
+ <select id="voice" name="voice">
83
+ <option value="alloy">alloy</option>
84
+ <option value="ash">ash</option>
85
+ <option value="ballad">ballad</option>
86
+ <option value="coral">coral</option>
87
+ <option value="sage">sage</option>
88
+ <option value="verse">verse</option>
89
+ </select>
90
+ </label>
91
+ </div>
92
+
93
+ <button id="submit-button" class="submit-button" type="submit">Send Request</button>
94
+ <p id="status-line" class="status-line">Ready.</p>
95
+ </form>
96
+ </section>
97
+
98
+ <section class="output-panel">
99
+ <article id="result-card" class="result-card">
100
+ <div class="result-head">
101
+ <div>
102
+ <p class="eyebrow">Assistant Output</p>
103
+ <h2>Response</h2>
104
+ </div>
105
+ </div>
106
+
107
+ <div id="message-list" class="message-list">
108
+ <p class="empty-state">Run a request to see assistant output here.</p>
109
+ </div>
110
+ </article>
111
+
112
+ <article class="json-card">
113
+ <div class="result-head">
114
+ <div>
115
+ <p class="eyebrow">Debug</p>
116
+ <h2>Raw JSON</h2>
117
+ </div>
118
+ </div>
119
+ <pre id="raw-json" class="json-output">{}</pre>
120
+ </article>
121
+ </section>
122
+ </main>
123
+
124
+ <button id="error-toast" class="error-toast" type="button" hidden></button>
125
+ </body>
126
+ </html>
public/chatclient/media.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export async function buildAttachmentPart(attachmentType, formData) {
2
+ if (attachmentType === "image-url") {
3
+ const url = String(formData.get("attachmentUrl") || "").trim();
4
+ requireValue(url, "Enter an image URL.");
5
+ return { type: "image_url", image_url: { url } };
6
+ }
7
+
8
+ if (attachmentType === "image-base64") {
9
+ const url = String(formData.get("attachmentBase64") || "").trim();
10
+ requireValue(url, "Paste image base64 or a data URL.");
11
+ return { type: "image_url", image_url: { url } };
12
+ }
13
+
14
+ if (attachmentType === "audio-url") {
15
+ const url = String(formData.get("attachmentUrl") || "").trim();
16
+ requireValue(url, "Enter an audio URL.");
17
+ return { type: "input_audio", input_audio: { url } };
18
+ }
19
+
20
+ if (attachmentType === "audio-base64") {
21
+ const value = String(formData.get("attachmentBase64") || "").trim();
22
+ requireValue(value, "Paste audio base64 or a data URL.");
23
+ const { data, format } = parseAudioBase64(value);
24
+ return { type: "input_audio", input_audio: { data, format } };
25
+ }
26
+
27
+ const file = formData.get("attachmentFile");
28
+ if (!(file instanceof File) || file.size === 0) {
29
+ throw new Error("Select a file for the chosen attachment type.");
30
+ }
31
+
32
+ if (attachmentType === "image-file") {
33
+ return { type: "image_url", image_url: { url: await readFileAsDataUrl(file) } };
34
+ }
35
+
36
+ if (attachmentType === "audio-file") {
37
+ return {
38
+ type: "input_audio",
39
+ input_audio: {
40
+ data: await readFileAsBase64(file),
41
+ format: inferAudioFormat(file)
42
+ }
43
+ };
44
+ }
45
+
46
+ throw new Error(`Unsupported attachment type: ${attachmentType}`);
47
+ }
48
+
49
+ function parseAudioBase64(value) {
50
+ const match = value.match(/^data:audio\/(mpeg|mp3|wav);base64,(.+)$/i);
51
+ if (match) {
52
+ return {
53
+ format: match[1].toLowerCase() === "wav" ? "wav" : "mp3",
54
+ data: match[2]
55
+ };
56
+ }
57
+
58
+ return { format: "mp3", data: value };
59
+ }
60
+
61
+ function inferAudioFormat(file) {
62
+ const name = file.name.toLowerCase();
63
+ if (file.type === "audio/wav" || name.endsWith(".wav")) {
64
+ return "wav";
65
+ }
66
+
67
+ return "mp3";
68
+ }
69
+
70
+ function readFileAsDataUrl(file) {
71
+ return new Promise((resolve, reject) => {
72
+ const reader = new FileReader();
73
+ reader.onload = () => resolve(String(reader.result));
74
+ reader.onerror = () => reject(new Error("Failed to read the selected file."));
75
+ reader.readAsDataURL(file);
76
+ });
77
+ }
78
+
79
+ async function readFileAsBase64(file) {
80
+ const dataUrl = await readFileAsDataUrl(file);
81
+ return dataUrl.split(",")[1] || "";
82
+ }
83
+
84
+ function requireValue(value, message) {
85
+ if (!value) {
86
+ throw new Error(message);
87
+ }
88
+ }
public/chatclient/render.js ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function renderResponse(messageList, requestPayload, responseBody) {
2
+ const assistant = responseBody?.choices?.[0]?.message ?? {};
3
+ const text = extractAssistantText(assistant);
4
+ const imageUrl = extractAssistantImage(assistant);
5
+ const audioUrl = assistant?.audio?.url || null;
6
+
7
+ messageList.innerHTML = "";
8
+ messageList.appendChild(renderCard(
9
+ "Request",
10
+ requestPayload.messages.at(-1)?.content?.[0]?.text || "Attachment-only request."
11
+ ));
12
+ messageList.appendChild(renderCard("Assistant", text || "No assistant text returned."));
13
+
14
+ if (imageUrl) {
15
+ const imageCard = renderCard("Image", imageUrl);
16
+ imageCard.appendChild(Object.assign(document.createElement("img"), {
17
+ src: imageUrl,
18
+ alt: "assistant output"
19
+ }));
20
+ messageList.appendChild(imageCard);
21
+ }
22
+
23
+ if (audioUrl) {
24
+ const audioCard = renderCard("Audio", audioUrl);
25
+ const audio = document.createElement("audio");
26
+ audio.controls = true;
27
+ audio.src = audioUrl;
28
+ audioCard.appendChild(audio);
29
+ messageList.appendChild(audioCard);
30
+ }
31
+ }
32
+
33
+ export function setStatus(statusLine, message, isOk = false) {
34
+ statusLine.textContent = message;
35
+ statusLine.classList.toggle("status-ok", isOk);
36
+ }
37
+
38
+ export function showError(errorToast, message) {
39
+ const code = `ERR-${Date.now().toString(36).toUpperCase()}`;
40
+ errorToast.hidden = false;
41
+ errorToast.textContent = `${message} Click to copy ${code}.`;
42
+ errorToast.onclick = async () => {
43
+ try {
44
+ await navigator.clipboard.writeText(code);
45
+ errorToast.textContent = `Copied ${code}.`;
46
+ } catch (_error) {
47
+ errorToast.textContent = `Copy failed. Error code: ${code}`;
48
+ }
49
+ };
50
+
51
+ window.clearTimeout(showError.timer);
52
+ showError.timer = window.setTimeout(() => {
53
+ errorToast.hidden = true;
54
+ }, 10000);
55
+ }
56
+
57
+ showError.timer = 0;
58
+
59
+ function extractAssistantText(message) {
60
+ if (typeof message.content === "string") {
61
+ return message.content;
62
+ }
63
+
64
+ if (!Array.isArray(message.content)) {
65
+ return "";
66
+ }
67
+
68
+ return message.content
69
+ .map((part) => part.text || part.output_text || "")
70
+ .filter(Boolean)
71
+ .join("\n\n");
72
+ }
73
+
74
+ function extractAssistantImage(message) {
75
+ if (!Array.isArray(message.content)) {
76
+ return null;
77
+ }
78
+
79
+ for (const part of message.content) {
80
+ const value = part?.image_url?.proxy_url || part?.image_url?.url;
81
+ if (value) {
82
+ return value;
83
+ }
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ function renderCard(title, body) {
90
+ const card = document.createElement("article");
91
+ card.className = "message-card";
92
+ card.innerHTML = `<strong>${escapeHtml(title)}</strong><p>${escapeHtml(body)}</p>`;
93
+ return card;
94
+ }
95
+
96
+ function escapeHtml(value) {
97
+ return String(value)
98
+ .replaceAll("&", "&amp;")
99
+ .replaceAll("<", "&lt;")
100
+ .replaceAll(">", "&gt;");
101
+ }
public/chatclient/styles.css ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light;
3
+ --bg: #f3ebdf;
4
+ --surface: rgba(255, 250, 244, 0.82);
5
+ --surface-strong: rgba(255, 250, 244, 0.94);
6
+ --ink: #1e1712;
7
+ --muted: #69584c;
8
+ --line: rgba(61, 45, 31, 0.12);
9
+ --accent: #c2410c;
10
+ --accent-deep: #9a3412;
11
+ --success: #166534;
12
+ --shadow: 0 24px 50px rgba(61, 45, 31, 0.15);
13
+ }
14
+
15
+ * {
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ body {
20
+ margin: 0;
21
+ min-height: 100vh;
22
+ font-family: "Space Grotesk", "Segoe UI", sans-serif;
23
+ color: var(--ink);
24
+ background:
25
+ radial-gradient(circle at top left, rgba(194, 65, 12, 0.14), transparent 32%),
26
+ radial-gradient(circle at bottom right, rgba(22, 101, 52, 0.13), transparent 30%),
27
+ linear-gradient(155deg, #f7f1e8, #eadfce 60%, #e3d5c1);
28
+ }
29
+
30
+ .chat-shell {
31
+ display: grid;
32
+ grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
33
+ gap: 20px;
34
+ width: min(1320px, calc(100% - 28px));
35
+ margin: 0 auto;
36
+ padding: 18px 0 28px;
37
+ }
38
+
39
+ .side-panel,
40
+ .result-card,
41
+ .json-card {
42
+ background: var(--surface);
43
+ border: 1px solid var(--line);
44
+ border-radius: 28px;
45
+ box-shadow: var(--shadow);
46
+ backdrop-filter: blur(12px);
47
+ }
48
+
49
+ .side-panel {
50
+ padding: 24px;
51
+ }
52
+
53
+ .output-panel {
54
+ display: grid;
55
+ grid-template-rows: minmax(0, 1fr) 320px;
56
+ gap: 20px;
57
+ min-height: calc(100vh - 46px);
58
+ }
59
+
60
+ .result-card,
61
+ .json-card {
62
+ padding: 24px;
63
+ overflow: hidden;
64
+ }
65
+
66
+ .back-link {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ min-height: 40px;
70
+ padding: 0 14px;
71
+ border-radius: 999px;
72
+ color: var(--ink);
73
+ text-decoration: none;
74
+ border: 1px solid var(--line);
75
+ background: rgba(255, 255, 255, 0.7);
76
+ }
77
+
78
+ .eyebrow {
79
+ margin: 18px 0 8px;
80
+ font-size: 0.78rem;
81
+ text-transform: uppercase;
82
+ letter-spacing: 0.22em;
83
+ color: var(--accent-deep);
84
+ }
85
+
86
+ h1,
87
+ h2 {
88
+ margin: 0;
89
+ font-family: "Avenir Next", "Space Grotesk", sans-serif;
90
+ }
91
+
92
+ h1 {
93
+ font-size: clamp(2rem, 4vw, 3.4rem);
94
+ line-height: 0.95;
95
+ max-width: 10ch;
96
+ }
97
+
98
+ .panel-copy,
99
+ .status-line,
100
+ .message-card p,
101
+ .empty-state {
102
+ color: var(--muted);
103
+ line-height: 1.55;
104
+ }
105
+
106
+ .composer-card {
107
+ display: grid;
108
+ gap: 14px;
109
+ margin-top: 24px;
110
+ }
111
+
112
+ label,
113
+ .toggle-card {
114
+ display: grid;
115
+ gap: 8px;
116
+ }
117
+
118
+ label span,
119
+ .toggle-card span {
120
+ font-size: 0.85rem;
121
+ text-transform: uppercase;
122
+ letter-spacing: 0.08em;
123
+ color: var(--muted);
124
+ }
125
+
126
+ input,
127
+ textarea,
128
+ select,
129
+ .submit-button {
130
+ width: 100%;
131
+ border: 1px solid var(--line);
132
+ border-radius: 18px;
133
+ font: inherit;
134
+ }
135
+
136
+ input,
137
+ textarea,
138
+ select {
139
+ padding: 14px 16px;
140
+ color: var(--ink);
141
+ background: var(--surface-strong);
142
+ }
143
+
144
+ textarea {
145
+ resize: vertical;
146
+ }
147
+
148
+ .inline-grid {
149
+ display: grid;
150
+ grid-template-columns: repeat(2, minmax(0, 1fr));
151
+ gap: 12px;
152
+ }
153
+
154
+ .attachment-panel {
155
+ display: grid;
156
+ gap: 12px;
157
+ padding: 14px;
158
+ border-radius: 22px;
159
+ background: rgba(255, 255, 255, 0.45);
160
+ border: 1px solid var(--line);
161
+ }
162
+
163
+ .attachment-field[hidden] {
164
+ display: none;
165
+ }
166
+
167
+ .toggle-card {
168
+ align-items: center;
169
+ grid-template-columns: 1fr auto;
170
+ min-height: 58px;
171
+ padding: 0 16px;
172
+ border: 1px solid var(--line);
173
+ border-radius: 18px;
174
+ background: var(--surface-strong);
175
+ }
176
+
177
+ .toggle-card input {
178
+ width: 24px;
179
+ height: 24px;
180
+ }
181
+
182
+ .submit-button {
183
+ min-height: 52px;
184
+ border: 0;
185
+ color: #fff;
186
+ background: linear-gradient(135deg, var(--accent), var(--accent-deep));
187
+ cursor: pointer;
188
+ transition: transform 160ms ease, opacity 160ms ease;
189
+ }
190
+
191
+ .submit-button:disabled {
192
+ opacity: 0.65;
193
+ cursor: progress;
194
+ }
195
+
196
+ .submit-button:hover:not(:disabled) {
197
+ transform: translateY(-1px);
198
+ }
199
+
200
+ .status-line {
201
+ margin: 0;
202
+ }
203
+
204
+ .result-head {
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: space-between;
208
+ margin-bottom: 18px;
209
+ }
210
+
211
+ .message-list {
212
+ display: grid;
213
+ gap: 14px;
214
+ max-height: 100%;
215
+ overflow: auto;
216
+ padding-right: 6px;
217
+ }
218
+
219
+ .message-card {
220
+ padding: 18px;
221
+ border-radius: 22px;
222
+ border: 1px solid var(--line);
223
+ background: rgba(255, 255, 255, 0.58);
224
+ }
225
+
226
+ .message-card strong {
227
+ display: inline-block;
228
+ margin-bottom: 8px;
229
+ }
230
+
231
+ .message-card img {
232
+ width: 100%;
233
+ max-height: 320px;
234
+ object-fit: cover;
235
+ border-radius: 16px;
236
+ margin-top: 12px;
237
+ }
238
+
239
+ .message-card audio {
240
+ width: 100%;
241
+ margin-top: 12px;
242
+ }
243
+
244
+ .json-output {
245
+ height: calc(100% - 60px);
246
+ overflow: auto;
247
+ margin: 0;
248
+ padding: 18px;
249
+ border-radius: 20px;
250
+ background: #171411;
251
+ color: #f9f4ed;
252
+ font-family: "IBM Plex Mono", Consolas, monospace;
253
+ font-size: 0.92rem;
254
+ }
255
+
256
+ .error-toast {
257
+ position: fixed;
258
+ right: 16px;
259
+ bottom: 16px;
260
+ max-width: min(420px, calc(100% - 32px));
261
+ padding: 14px 16px;
262
+ border: 0;
263
+ border-radius: 16px;
264
+ color: #fff;
265
+ background: rgba(127, 29, 29, 0.96);
266
+ box-shadow: var(--shadow);
267
+ text-align: left;
268
+ cursor: pointer;
269
+ }
270
+
271
+ .status-ok {
272
+ color: var(--success);
273
+ }
274
+
275
+ @media (max-width: 980px) {
276
+ .chat-shell {
277
+ grid-template-columns: 1fr;
278
+ }
279
+
280
+ .output-panel {
281
+ min-height: auto;
282
+ grid-template-rows: auto 280px;
283
+ }
284
+ }
285
+
286
+ @media (max-width: 640px) {
287
+ .chat-shell {
288
+ width: min(100% - 14px, 100%);
289
+ padding-top: 8px;
290
+ gap: 14px;
291
+ }
292
+
293
+ .side-panel,
294
+ .result-card,
295
+ .json-card {
296
+ padding: 18px;
297
+ border-radius: 22px;
298
+ }
299
+
300
+ .inline-grid {
301
+ grid-template-columns: 1fr;
302
+ }
303
+ }
public/index.html ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>oapix</title>
7
+ <link rel="stylesheet" href="/styles.css">
8
+ <script type="module" src="/app.js"></script>
9
+ </head>
10
+ <body>
11
+ <main class="landing-shell">
12
+ <section class="hero-card">
13
+ <p class="eyebrow">OpenAI-Compatible Proxy</p>
14
+ <h1>Send text, images, and audio through one proxy endpoint.</h1>
15
+ <p class="hero-copy">
16
+ `oapix` forwards chat completions to your configured upstream, accepts image and audio inputs,
17
+ converts audio URLs to mp3, and exposes media output as temporary URLs.
18
+ </p>
19
+ <div class="hero-actions">
20
+ <a class="primary-link" href="/chatclient/">Open Chat Client</a>
21
+ <a class="secondary-link" href="/v1/health">Health Check</a>
22
+ </div>
23
+ </section>
24
+
25
+ <section class="info-grid">
26
+ <article class="info-card">
27
+ <h2>Endpoint</h2>
28
+ <code>POST /v1/chat/completions</code>
29
+ <p>Use the proxy exactly like a chat completions API, then add multimodal content parts as needed.</p>
30
+ </article>
31
+
32
+ <article class="info-card">
33
+ <h2>Image Input</h2>
34
+ <p>Provide `image_url.url` as an `https` URL, a data URL, or raw base64.</p>
35
+ </article>
36
+
37
+ <article class="info-card">
38
+ <h2>Audio Input</h2>
39
+ <p>Provide `input_audio.data` plus `format`, or use `input_audio.url` and let the proxy convert it.</p>
40
+ </article>
41
+ </section>
42
+
43
+ <section class="code-card">
44
+ <div class="section-head">
45
+ <p class="eyebrow">Quick Start</p>
46
+ <h2>Example request</h2>
47
+ </div>
48
+ <pre><code>{
49
+ "model": "gpt-4.1-mini",
50
+ "messages": [
51
+ {
52
+ "role": "user",
53
+ "content": [
54
+ { "type": "text", "text": "Describe this image." },
55
+ {
56
+ "type": "image_url",
57
+ "image_url": { "url": "https://example.com/photo.jpg" }
58
+ }
59
+ ]
60
+ }
61
+ ],
62
+ "audio": {
63
+ "voice": "alloy",
64
+ "format": "mp3"
65
+ }
66
+ }</code></pre>
67
+ </section>
68
+ </main>
69
+ </body>
70
+ </html>
public/styles.css ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light;
3
+ --bg: #f4efe6;
4
+ --paper: rgba(255, 252, 247, 0.82);
5
+ --ink: #1f1a16;
6
+ --muted: #66584a;
7
+ --line: rgba(72, 52, 35, 0.15);
8
+ --accent: #0f766e;
9
+ --accent-strong: #155e75;
10
+ --shadow: 0 24px 60px rgba(48, 31, 12, 0.16);
11
+ }
12
+
13
+ * {
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ body {
18
+ margin: 0;
19
+ min-height: 100vh;
20
+ font-family: "Space Grotesk", "Segoe UI", sans-serif;
21
+ color: var(--ink);
22
+ background:
23
+ radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 34%),
24
+ radial-gradient(circle at right, rgba(20, 83, 45, 0.12), transparent 28%),
25
+ linear-gradient(160deg, #f8f3eb, #efe7d8 60%, #e7ddcb);
26
+ }
27
+
28
+ .landing-shell {
29
+ width: min(1080px, calc(100% - 32px));
30
+ margin: 0 auto;
31
+ padding: 48px 0 64px;
32
+ }
33
+
34
+ .hero-card,
35
+ .info-card,
36
+ .code-card {
37
+ background: var(--paper);
38
+ border: 1px solid var(--line);
39
+ border-radius: 28px;
40
+ box-shadow: var(--shadow);
41
+ backdrop-filter: blur(12px);
42
+ }
43
+
44
+ .hero-card {
45
+ padding: 40px;
46
+ }
47
+
48
+ .hero-card h1,
49
+ .section-head h2,
50
+ .info-card h2 {
51
+ margin: 0 0 16px;
52
+ font-family: "Avenir Next", "Space Grotesk", sans-serif;
53
+ }
54
+
55
+ .hero-card h1 {
56
+ max-width: 12ch;
57
+ font-size: clamp(2.4rem, 5vw, 4.6rem);
58
+ line-height: 0.95;
59
+ }
60
+
61
+ .hero-copy,
62
+ .info-card p {
63
+ color: var(--muted);
64
+ line-height: 1.6;
65
+ }
66
+
67
+ .eyebrow {
68
+ margin: 0 0 10px;
69
+ font-size: 0.78rem;
70
+ letter-spacing: 0.22em;
71
+ text-transform: uppercase;
72
+ color: var(--accent-strong);
73
+ }
74
+
75
+ .hero-actions {
76
+ display: flex;
77
+ flex-wrap: wrap;
78
+ gap: 12px;
79
+ margin-top: 28px;
80
+ }
81
+
82
+ .primary-link,
83
+ .secondary-link {
84
+ display: inline-flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ min-height: 46px;
88
+ padding: 0 18px;
89
+ border-radius: 999px;
90
+ text-decoration: none;
91
+ }
92
+
93
+ .primary-link {
94
+ color: #fff;
95
+ background: linear-gradient(135deg, var(--accent), var(--accent-strong));
96
+ }
97
+
98
+ .secondary-link {
99
+ color: var(--ink);
100
+ border: 1px solid var(--line);
101
+ background: rgba(255, 255, 255, 0.65);
102
+ }
103
+
104
+ .info-grid {
105
+ display: grid;
106
+ grid-template-columns: repeat(3, minmax(0, 1fr));
107
+ gap: 18px;
108
+ margin: 22px 0;
109
+ }
110
+
111
+ .info-card,
112
+ .code-card {
113
+ padding: 24px;
114
+ }
115
+
116
+ code,
117
+ pre {
118
+ font-family: "IBM Plex Mono", Consolas, monospace;
119
+ }
120
+
121
+ pre {
122
+ margin: 0;
123
+ overflow: auto;
124
+ padding: 20px;
125
+ border-radius: 20px;
126
+ background: #171411;
127
+ color: #f9f4ed;
128
+ }
129
+
130
+ @media (max-width: 840px) {
131
+ .landing-shell {
132
+ width: min(100% - 20px, 680px);
133
+ padding-top: 20px;
134
+ }
135
+
136
+ .hero-card,
137
+ .info-card,
138
+ .code-card {
139
+ border-radius: 22px;
140
+ }
141
+
142
+ .hero-card {
143
+ padding: 28px 22px;
144
+ }
145
+
146
+ .info-grid {
147
+ grid-template-columns: 1fr;
148
+ }
149
+ }
public/sw-register.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export async function registerServiceWorker() {
2
+ if (!("serviceWorker" in navigator)) {
3
+ return;
4
+ }
5
+
6
+ try {
7
+ const registration = await navigator.serviceWorker.register("/sw.js");
8
+ window.setTimeout(() => {
9
+ const worker = registration.active ?? registration.waiting ?? registration.installing;
10
+ worker?.postMessage({ type: "refresh-static-cache" });
11
+ }, 10000);
12
+ } catch (error) {
13
+ console.error("service worker registration failed", error);
14
+ }
15
+ }
public/sw.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const CACHE_NAME = "oapix-static-v2";
2
+ const STATIC_ASSETS = [
3
+ "/",
4
+ "/styles.css",
5
+ "/app.js",
6
+ "/sw-register.js",
7
+ "/chatclient/",
8
+ "/chatclient/media.js",
9
+ "/chatclient/render.js",
10
+ "/chatclient/styles.css",
11
+ "/chatclient/app.js"
12
+ ];
13
+
14
+ self.addEventListener("install", (event) => {
15
+ event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)));
16
+ self.skipWaiting();
17
+ });
18
+
19
+ self.addEventListener("activate", (event) => {
20
+ event.waitUntil(
21
+ caches.keys().then((keys) => Promise.all(
22
+ keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
23
+ ))
24
+ );
25
+ self.clients.claim();
26
+ });
27
+
28
+ self.addEventListener("message", (event) => {
29
+ if (event.data?.type !== "refresh-static-cache") {
30
+ return;
31
+ }
32
+
33
+ event.waitUntil(caches.open(CACHE_NAME).then((cache) => Promise.all(
34
+ STATIC_ASSETS.map(async (asset) => {
35
+ const response = await fetch(asset, { cache: "no-store" });
36
+ if (response.ok) {
37
+ await cache.put(asset, response.clone());
38
+ }
39
+ })
40
+ )));
41
+ });
42
+
43
+ self.addEventListener("fetch", (event) => {
44
+ const request = event.request;
45
+ const url = new URL(request.url);
46
+ if (request.method !== "GET" || url.origin !== self.location.origin) {
47
+ return;
48
+ }
49
+
50
+ if (!["document", "script", "style"].includes(request.destination)) {
51
+ return;
52
+ }
53
+
54
+ event.respondWith((async () => {
55
+ const cache = await caches.open(CACHE_NAME);
56
+ const cached = await cache.match(request);
57
+ const networkPromise = fetch(request).then(async (response) => {
58
+ if (response.ok) {
59
+ await cache.put(request, response.clone());
60
+ }
61
+ return response;
62
+ }).catch(() => cached);
63
+
64
+ return cached ?? networkPromise;
65
+ })());
66
+ });
scripts/build.mjs CHANGED
@@ -21,5 +21,6 @@ await build({
21
  await fs.copyFile("package.json", path.join(outputDir, "package.json"));
22
  await fs.copyFile("package-lock.json", path.join(outputDir, "package-lock.json")).catch(() => {});
23
  await fs.copyFile(".env.example", path.join(outputDir, ".env.example"));
 
24
 
25
  console.log(outputDir);
 
21
  await fs.copyFile("package.json", path.join(outputDir, "package.json"));
22
  await fs.copyFile("package-lock.json", path.join(outputDir, "package-lock.json")).catch(() => {});
23
  await fs.copyFile(".env.example", path.join(outputDir, ".env.example"));
24
+ await fs.cp("public", path.join(outputDir, "public"), { recursive: true });
25
 
26
  console.log(outputDir);
src/app.js CHANGED
@@ -1,9 +1,11 @@
 
1
  import express from "express";
2
  import { createApiRouter } from "./routes/apiRouter.js";
3
  import { HttpError } from "./utils/httpError.js";
4
 
5
  export function createApp({
6
  jsonLimit,
 
7
  chatController,
8
  mediaController
9
  }) {
@@ -11,6 +13,20 @@ export function createApp({
11
 
12
  app.disable("x-powered-by");
13
  app.use(express.json({ limit: jsonLimit }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  app.use("/v1", createApiRouter({ chatController, mediaController }));
16
 
 
1
+ import path from "node:path";
2
  import express from "express";
3
  import { createApiRouter } from "./routes/apiRouter.js";
4
  import { HttpError } from "./utils/httpError.js";
5
 
6
  export function createApp({
7
  jsonLimit,
8
+ publicDir,
9
  chatController,
10
  mediaController
11
  }) {
 
13
 
14
  app.disable("x-powered-by");
15
  app.use(express.json({ limit: jsonLimit }));
16
+ app.get("/", (_req, res) => {
17
+ res.sendFile(path.join(publicDir, "index.html"));
18
+ });
19
+ app.get(["/chatclient", "/chatclient/"], (_req, res) => {
20
+ res.sendFile(path.join(publicDir, "chatclient", "index.html"));
21
+ });
22
+ app.get(["/chat", "/chat/"], (_req, res) => {
23
+ res.redirect(302, "/chatclient/");
24
+ });
25
+ app.use(express.static(publicDir, {
26
+ extensions: ["html"],
27
+ index: false,
28
+ redirect: false
29
+ }));
30
 
31
  app.use("/v1", createApiRouter({ chatController, mediaController }));
32
 
src/server.js CHANGED
@@ -1,3 +1,4 @@
 
1
  import { loadConfig, validateConfig } from "./config.js";
2
  import { createApp } from "./app.js";
3
  import { InMemoryMediaStore } from "./services/mediaStore.js";
@@ -10,6 +11,7 @@ import { createMediaController } from "./controllers/mediaController.js";
10
 
11
  const config = loadConfig();
12
  validateConfig(config);
 
13
 
14
  const mediaStore = new InMemoryMediaStore({ ttlSeconds: config.mediaTtlSeconds });
15
  const audioConversionService = createAudioConversionService({
@@ -36,6 +38,7 @@ const mediaController = createMediaController({ mediaStore });
36
 
37
  const app = createApp({
38
  jsonLimit: config.jsonLimit,
 
39
  chatController,
40
  mediaController
41
  });
 
1
+ import { fileURLToPath } from "node:url";
2
  import { loadConfig, validateConfig } from "./config.js";
3
  import { createApp } from "./app.js";
4
  import { InMemoryMediaStore } from "./services/mediaStore.js";
 
11
 
12
  const config = loadConfig();
13
  validateConfig(config);
14
+ const publicDir = fileURLToPath(new URL("../public", import.meta.url));
15
 
16
  const mediaStore = new InMemoryMediaStore({ ttlSeconds: config.mediaTtlSeconds });
17
  const audioConversionService = createAudioConversionService({
 
38
 
39
  const app = createApp({
40
  jsonLimit: config.jsonLimit,
41
+ publicDir,
42
  chatController,
43
  mediaController
44
  });