Spaces:
Runtime error
Runtime error
add a chatclient for test
Browse files- doc/common_mistakes.md +1 -0
- doc/overview.md +15 -1
- public/app.js +3 -0
- public/chatclient/app.js +115 -0
- public/chatclient/index.html +126 -0
- public/chatclient/media.js +88 -0
- public/chatclient/render.js +101 -0
- public/chatclient/styles.css +303 -0
- public/index.html +70 -0
- public/styles.css +149 -0
- public/sw-register.js +15 -0
- public/sw.js +66 -0
- scripts/build.mjs +1 -0
- src/app.js +16 -0
- src/server.js +3 -0
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("&", "&")
|
| 99 |
+
.replaceAll("<", "<")
|
| 100 |
+
.replaceAll(">", ">");
|
| 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 |
});
|