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

imrpove ui for chatclient

Browse files
doc/agent.md CHANGED
@@ -1,23 +1,72 @@
1
- # Agent Task Log
2
 
3
- ## Current Task
4
 
5
- - Start time: 2026-03-01T00:59:31.8639973+08:00
6
- - End time: 2026-03-01T01:12:48.8144187+08:00
7
- - Total time: 00:13:16.9504214
8
- - Agent: codex/gpt5-codex
9
- - Status: completed
10
 
11
- ## Plan Used For This Task
12
 
13
- 1. Create the Node.js project scaffold, env config, and ignore files.
14
- 2. Implement an OpenAI-compatible chat completions proxy at `POST /v1/chat/completions`.
15
- 3. Normalize media input so images accept URL/base64 and audio accepts base64 or URL, with audio URLs downloaded and converted to mp3 base64 before upstream forwarding.
16
- 4. Normalize media output so audio base64 is also exposed as a proxy URL, and image data URLs returned by compatible backends are also exposed as proxy URLs.
17
- 5. Add tests, a temp-folder build script, and the required documentation updates.
18
 
19
- ## Unfinished Task Handoff Format
20
 
21
- - Put the active plan here before implementation starts.
22
- - Record the start timestamp here and in `doc/update.md`.
23
- - Move completed task records into `doc/updates.md` with the newest task first.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ do not edit this file agent.md , it's for human
2
 
 
3
 
4
+ (do not edit this file unless i tell you the part you can edit)
 
 
 
 
5
 
6
+ # task for this project
7
 
 
 
 
 
 
8
 
9
+ # new task
10
 
11
+ improve /chatclient, make it edit like, replace with the rich text editor as a tab of main view, then add some tool icon buttons below the editor, a setting button to show setting ui for endpoint,system prompt, a add button for user to add image and audio file, a send button to send message to ai, after send, get image and audio files as attachment and send it with text, show the output rich text page tab to view ai response, also add a rowoutput to show raw response from ai
12
+
13
+
14
+
15
+ # agent.md
16
+ doc/agent.md, put md doc file in doc/
17
+
18
+
19
+ this is tasks doc for AI agent, instead input prompts direct, put the plan here and ask AI to complete unfinished task (in # new task) so we can record what we have do with AI agent.
20
+
21
+ before start new task, recorder current time stamp so when the task end , we know how much time cost for this task, put the start and end , total time in doc/update.md too, read doc/overview.md to understand the project,ensure doc/common_mistakes.md not happen, follow the # princple ,complete # new tasks, then do # post task and # additional tasks too
22
+
23
+ # post task (do not edit here, only human can edit)
24
+
25
+ ## review
26
+ review what you have done just now, and if you think there are some improvment to improve the app function and cleaness or follow the princinple better, you can improve now
27
+
28
+ ## build
29
+ try to build the project and fix compile error if any, use temp folder as build output place like /tmp/prj_name, show the folder path after finish the task
30
+
31
+ finnally:
32
+ ## update doc files
33
+ update doc/overview.md properly with show project current status , the project structure and all project file tree with simple description
34
+
35
+ when finshed the task, move it to updates.md put newer task above old ones, also add time start/end/total for finished task and which agent and model finished task, if you are codex gpt5 or codex with gpt5-codex , add by codex/gpt5 or codex/gpt5-codex after time stamp, if you are droid/gpt5 or droid/gpt5-codex or claude or cursor/gpt5 or windsurf, put your name like that, do not commit for me unless i ask you to commit
36
+
37
+ write a summary to tell user what you do, and some key info like build output folder path and how to run the app, the way you implement or fix the features
38
+
39
+ # principles
40
+ some coding principles for AI or human to make software quality high((keep this line when modify # principles)
41
+ keep code simple and clean, utilze module and MVC(for gui app), make each module simple and clean(<150-200 lines) have clear responsibility, and each module have clear interface, and each module have clear implementation, and each module have clear test.
42
+
43
+ make sure the UI looks clean, modern , by using minimal style.
44
+
45
+ make sure UI easy to use on mobile, use some modern sytle like tabs, dropdown menu
46
+
47
+ when edit doc file like .md , put these doc files into folder ./doc
48
+
49
+ # common experience
50
+
51
+ for gui app:
52
+
53
+ virsualize every user interaction, implement some simple clean feedback when user click app
54
+ for webapp:
55
+
56
+ always add a service worker to cache all html and js css files, and also update the cache after 10s when app loaded, so user can get fast offline load and update to latest version at same time
57
+
58
+ also add a simple error notifacation at bottom for 10s to let user know what's wrong and copy the the error code when click the notice
59
+
60
+ do not user brower built in prompt like alert() prompt(), write a simple ui to do that
61
+
62
+ do not put too many buttons in one place, classify buttons , put them in a dropdown menu
63
+
64
+ for ios app:
65
+ (comming soon)
66
+
67
+ for android app:
68
+ (comming soon)
69
+
70
+ for server :
71
+
72
+ (comming soon)
doc/audio_completion.md ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Audio and speech
2
+
3
+ The OpenAI API provides a range of audio capabilities. If you know what you want to build, find your use case below to get started. If you're not sure where to start, read this page as an overview.
4
+
5
+ ## Build with audio
6
+
7
+ <div className="w-full max-w-full overflow-hidden">
8
+ </div>
9
+
10
+ ## A tour of audio use cases
11
+
12
+ LLMs can process audio by using sound as input, creating sound as output, or both. OpenAI has several API endpoints that help you build audio applications or voice agents.
13
+
14
+ ### Voice agents
15
+
16
+ Voice agents understand audio to handle tasks and respond back in natural language. There are two main ways to approach voice agents: either with speech-to-speech models and the [Realtime API](https://developers.openai.com/api/docs/guides/realtime), or by chaining together a speech-to-text model, a text language model to process the request, and a text-to-speech model to respond. Speech-to-speech is lower latency and more natural, but chaining together a voice agent is a reliable way to extend a text-based agent into a voice agent. If you are already using the [Agents SDK](https://developers.openai.com/api/docs/guides/agents), you can [extend your existing agents with voice capabilities](https://openai.github.io/openai-agents-python/voice/quickstart/) using the chained approach.
17
+
18
+ ### Streaming audio
19
+
20
+ Process audio in real time to build voice agents and other low-latency applications, including transcription use cases. You can stream audio in and out of a model with the [Realtime API](https://developers.openai.com/api/docs/guides/realtime). Our advanced speech models provide automatic speech recognition for improved accuracy, low-latency interactions, and multilingual support.
21
+
22
+ ### Text to speech
23
+
24
+ For turning text into speech, use the [Audio API](https://developers.openai.com/api/docs/api-reference/audio/) `audio/speech` endpoint. Models compatible with this endpoint are `gpt-4o-mini-tts`, `tts-1`, and `tts-1-hd`. With `gpt-4o-mini-tts`, you can ask the model to speak a certain way or with a certain tone of voice.
25
+
26
+ ### Speech to text
27
+
28
+ For speech to text, use the [Audio API](https://developers.openai.com/api/docs/api-reference/audio/) `audio/transcriptions` endpoint. Models compatible with this endpoint are `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, `whisper-1`, and `gpt-4o-transcribe-diarize`. `gpt-4o-transcribe-diarize` adds speaker labels and timestamps for HTTP requests and is intended for non-latency-sensitive workloads, while the other models focus on transcription only. With streaming, you can continuously pass in audio and get a continuous stream of text back.
29
+
30
+ ## Choosing the right API
31
+
32
+ There are multiple APIs for transcribing or generating audio:
33
+
34
+ | API | Supported modalities | Streaming support |
35
+ | ---------------------------------------------------- | --------------------------------- | ------------------------------------------------ |
36
+ | [Realtime API](https://developers.openai.com/api/docs/api-reference/realtime) | Audio and text inputs and outputs | Audio streaming in, audio and text streaming out |
37
+ | [Chat Completions API](https://developers.openai.com/api/docs/api-reference/chat) | Audio and text inputs and outputs | Audio and text streaming out |
38
+ | [Transcription API](https://developers.openai.com/api/docs/api-reference/audio) | Audio inputs | Text streaming out |
39
+ | [Speech API](https://developers.openai.com/api/docs/api-reference/audio) | Text inputs and audio outputs | Audio streaming out |
40
+
41
+ ### General use APIs vs. specialized APIs
42
+
43
+ The main distinction is general use APIs vs. specialized APIs. With the Realtime and Chat Completions APIs, you can use our latest models' native audio understanding and generation capabilities and combine them with other features like function calling. These APIs can be used for a wide range of use cases, and you can select the model you want to use.
44
+
45
+ On the other hand, the Transcription, Translation and Speech APIs are specialized to work with specific models and only meant for one purpose.
46
+
47
+ ### Talking with a model vs. controlling the script
48
+
49
+ Another way to select the right API is asking yourself how much control you need. To design conversational interactions, where the model thinks and responds in speech, use the Realtime or Chat Completions API, depending if you need low-latency or not.
50
+
51
+ You won't know exactly what the model will say ahead of time, as it will generate audio responses directly, but the conversation will feel natural.
52
+
53
+ For more control and predictability, you can use the Speech-to-text / LLM / Text-to-speech pattern, so you know exactly what the model will say and can control the response. Please note that with this method, there will be added latency.
54
+
55
+ This is what the Audio APIs are for: pair an LLM with the `audio/transcriptions` and `audio/speech` endpoints to take spoken user input, process and generate a text response, and then convert that to speech that the user can hear.
56
+
57
+ ### Recommendations
58
+
59
+ - If you need [real-time interactions](https://developers.openai.com/api/docs/guides/realtime-conversations) or [transcription](https://developers.openai.com/api/docs/guides/realtime-transcription), use the Realtime API.
60
+ - If realtime is not a requirement but you're looking to build a [voice agent](https://developers.openai.com/api/docs/guides/voice-agents) or an audio-based application that requires features such as [function calling](https://developers.openai.com/api/docs/guides/function-calling), use the Chat Completions API.
61
+ - For use cases with one specific purpose, use the Transcription, Translation, or Speech APIs.
62
+
63
+ ## Add audio to your existing application
64
+
65
+ Models such as `gpt-realtime` and `gpt-audio` are natively multimodal, meaning they can understand and generate multiple modalities as input and output.
66
+
67
+ If you already have a text-based LLM application with the [Chat Completions endpoint](https://developers.openai.com/api/docs/api-reference/chat/), you may want to add audio capabilities. For example, if your chat application supports text input, you can add audio input and output—just include `audio` in the `modalities` array and use an audio model, like `gpt-audio`.
68
+
69
+ Audio is not yet supported in the [Responses
70
+ API](https://developers.openai.com/api/docs/api-reference/chat/completions/responses).
71
+
72
+
73
+ <div data-content-switcher-pane data-value="audio-out">
74
+ <div class="hidden">Audio output from model</div>
75
+ Create a human-like audio response to a prompt
76
+
77
+ ```javascript
78
+ import { writeFileSync } from "node:fs";
79
+ import OpenAI from "openai";
80
+
81
+ const openai = new OpenAI();
82
+
83
+ // Generate an audio response to the given prompt
84
+ const response = await openai.chat.completions.create({
85
+ model: "gpt-audio",
86
+ modalities: ["text", "audio"],
87
+ audio: { voice: "alloy", format: "wav" },
88
+ messages: [
89
+ {
90
+ role: "user",
91
+ content: "Is a golden retriever a good family dog?"
92
+ }
93
+ ],
94
+ store: true,
95
+ });
96
+
97
+ // Inspect returned data
98
+ console.log(response.choices[0]);
99
+
100
+ // Write audio data to a file
101
+ writeFileSync(
102
+ "dog.wav",
103
+ Buffer.from(response.choices[0].message.audio.data, 'base64'),
104
+ { encoding: "utf-8" }
105
+ );
106
+ ```
107
+
108
+ ```python
109
+ import base64
110
+ from openai import OpenAI
111
+
112
+ client = OpenAI()
113
+
114
+ completion = client.chat.completions.create(
115
+ model="gpt-audio",
116
+ modalities=["text", "audio"],
117
+ audio={"voice": "alloy", "format": "wav"},
118
+ messages=[
119
+ {
120
+ "role": "user",
121
+ "content": "Is a golden retriever a good family dog?"
122
+ }
123
+ ]
124
+ )
125
+
126
+ print(completion.choices[0])
127
+
128
+ wav_bytes = base64.b64decode(completion.choices[0].message.audio.data)
129
+ with open("dog.wav", "wb") as f:
130
+ f.write(wav_bytes)
131
+ ```
132
+
133
+ ```bash
134
+ curl "https://api.openai.com/v1/chat/completions" \\
135
+ -H "Content-Type: application/json" \\
136
+ -H "Authorization: Bearer $OPENAI_API_KEY" \\
137
+ -d '{
138
+ "model": "gpt-audio",
139
+ "modalities": ["text", "audio"],
140
+ "audio": { "voice": "alloy", "format": "wav" },
141
+ "messages": [
142
+ {
143
+ "role": "user",
144
+ "content": "Is a golden retriever a good family dog?"
145
+ }
146
+ ]
147
+ }'
148
+ ```
149
+
150
+ </div>
151
+ <div data-content-switcher-pane data-value="audio-in" hidden>
152
+ <div class="hidden">Audio input to model</div>
153
+ Use audio inputs for prompting a model
154
+
155
+ ```javascript
156
+ import OpenAI from "openai";
157
+ const openai = new OpenAI();
158
+
159
+ // Fetch an audio file and convert it to a base64 string
160
+ const url = "https://cdn.openai.com/API/docs/audio/alloy.wav";
161
+ const audioResponse = await fetch(url);
162
+ const buffer = await audioResponse.arrayBuffer();
163
+ const base64str = Buffer.from(buffer).toString("base64");
164
+
165
+ const response = await openai.chat.completions.create({
166
+ model: "gpt-audio",
167
+ modalities: ["text", "audio"],
168
+ audio: { voice: "alloy", format: "wav" },
169
+ messages: [
170
+ {
171
+ role: "user",
172
+ content: [
173
+ { type: "text", text: "What is in this recording?" },
174
+ { type: "input_audio", input_audio: { data: base64str, format: "wav" }}
175
+ ]
176
+ }
177
+ ],
178
+ store: true,
179
+ });
180
+
181
+ console.log(response.choices[0]);
182
+ ```
183
+
184
+ ```python
185
+ import base64
186
+ import requests
187
+ from openai import OpenAI
188
+
189
+ client = OpenAI()
190
+
191
+ # Fetch the audio file and convert it to a base64 encoded string
192
+ url = "https://cdn.openai.com/API/docs/audio/alloy.wav"
193
+ response = requests.get(url)
194
+ response.raise_for_status()
195
+ wav_data = response.content
196
+ encoded_string = base64.b64encode(wav_data).decode('utf-8')
197
+
198
+ completion = client.chat.completions.create(
199
+ model="gpt-audio",
200
+ modalities=["text", "audio"],
201
+ audio={"voice": "alloy", "format": "wav"},
202
+ messages=[
203
+ {
204
+ "role": "user",
205
+ "content": [
206
+ {
207
+ "type": "text",
208
+ "text": "What is in this recording?"
209
+ },
210
+ {
211
+ "type": "input_audio",
212
+ "input_audio": {
213
+ "data": encoded_string,
214
+ "format": "wav"
215
+ }
216
+ }
217
+ ]
218
+ },
219
+ ]
220
+ )
221
+
222
+ print(completion.choices[0].message)
223
+ ```
224
+
225
+ ```bash
226
+ curl "https://api.openai.com/v1/chat/completions" \\
227
+ -H "Content-Type: application/json" \\
228
+ -H "Authorization: Bearer $OPENAI_API_KEY" \\
229
+ -d '{
230
+ "model": "gpt-audio",
231
+ "modalities": ["text", "audio"],
232
+ "audio": { "voice": "alloy", "format": "wav" },
233
+ "messages": [
234
+ {
235
+ "role": "user",
236
+ "content": [
237
+ { "type": "text", "text": "What is in this recording?" },
238
+ {
239
+ "type": "input_audio",
240
+ "input_audio": {
241
+ "data": "<base64 bytes here>",
242
+ "format": "wav"
243
+ }
244
+ }
245
+ ]
246
+ }
247
+ ]
248
+ }'
249
+ ```
250
+
251
+ </div>
doc/overview.md CHANGED
@@ -2,10 +2,10 @@
2
 
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
 
10
  ## What The App Does
11
 
@@ -23,59 +23,78 @@
23
  - Configure `OPENAI_API_KEY` and `OPENAI_BASE_URL` in the local `.env` file.
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
 
31
  ```text
32
  oapix/
33
- ├─ .env.example # Example environment variables for local setup
34
- ├─ .gitignore # Node.js and local secret ignore rules
35
- ├─ package-lock.json # Locked dependency versions
36
- ├─ package.json # Scripts, runtime deps, and build deps
37
- ├─ doc/
38
- │ ├─ agent.md # Agent task record and current-task workflow
39
- │ ├─ common_mistakes.md # Project-specific pitfalls to avoid
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/
58
- │ ├─ app.js # Express app factory and error handling
59
- │ ├─ config.js # Env loading and validation
60
- │ ├─ server.js # Dependency wiring and server startup
61
- │ ├─ controllers/
62
- │ │ ├─ chatController.js # Chat proxy request handling
63
- │ │ └─ mediaController.js # Temporary media download handling
64
- │ ├─ routes/
65
- │ │ └─ apiRouter.js # `/v1` routes
66
- │ ├─ services/
67
- │ │ ├─ audioConversionService.js # Audio URL download and mp3 conversion
68
- │ │ ├─ mediaStore.js # In-memory temporary media storage
69
- │ │ ├─ openAiService.js # Upstream OpenAI-compatible fetch client
70
- │ │ ├─ requestNormalizationService.js # Input media normalization
71
- │ │ └─ responseNormalizationService.js# Output media normalization
72
- │ └─ utils/
73
- │ ├─ dataUrl.js # Data URL and base64 helpers
74
- │ ├─ httpError.js # HTTP error shape
75
- │ └─ mediaTypes.js # Audio/image MIME helpers
76
- └─ test/
77
- ├─ requestNormalization.test.js # Request normalization tests
78
- └─ responseNormalization.test.js # Response normalization tests
 
 
 
 
79
  ```
80
 
81
  ## Main Commands
 
2
 
3
  ## Current Status
4
 
5
+ - Project type: Node.js OpenAI-compatible chat completions proxy with a browser chat client.
6
+ - Status: backend proxy is working, and `public/chatclient/` now provides a simplified tabbed rich-text workspace with auto-saved draft input, settings, image/audio attachments by upload or link, assistant output rendering, and raw response inspection.
7
+ - Verified with `npm test`, `npm run build`, and `node --check` on the browser chat client modules.
8
+ - Build output folder: `C:\Users\a\AppData\Local\Temp\oapix-chatclient-preview-build`
9
 
10
  ## What The App Does
11
 
 
23
  - Configure `OPENAI_API_KEY` and `OPENAI_BASE_URL` in the local `.env` file.
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 static assets 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
+ - The chat client stores endpoint, model, system prompt, audio toggle, and voice settings in browser local storage.
29
+ - The chat client also auto-saves compose draft HTML, link-picker state, and link-based attachments in browser local storage.
30
+
31
+ ## Chat Client Status
32
+
33
+ - Compose tab: contenteditable rich text editor with basic formatting tools and `Ctrl+Enter` send.
34
+ - Layout: the large intro/header block and Back button were removed, leaving a compact top tab bar.
35
+ - Tool row: single-line icon toolbar with settings, add-attachment, and a right-aligned send button.
36
+ - Settings panel: endpoint, model, system prompt, audio output, and voice selection.
37
+ - Output tab: formatted assistant text plus returned image and audio media.
38
+ - Raw Output tab: exact JSON returned by the proxy, plus structured error output on failed requests.
39
+ - Attachment flow: image/audio attachments can be added by uploaded file or direct `http(s)` link; image files are sent as data URLs, audio files are sent as base64 with format metadata, and links are forwarded as URLs.
40
+ - Attachment preview: attachments render as compact mini items, and clicking one opens a larger preview overlay for the selected image or audio.
41
+ - Draft flow: editor content and link-based compose state restore automatically when `/chatclient/` is reopened.
42
+ - Send behavior: clicking Send switches to the Output tab immediately, and successful requests keep both the editor prompt text and current attachments in place.
43
 
44
  ## Project Structure
45
 
46
  ```text
47
  oapix/
48
+ |-- .env.example # Example environment variables for local setup
49
+ |-- .gitignore # Node.js and local-secret ignore rules
50
+ |-- package-lock.json # Locked dependency versions
51
+ |-- package.json # Scripts, runtime deps, and build deps
52
+ |-- doc/
53
+ | |-- agent.md # Human-maintained task instructions for agents
54
+ | |-- common_mistakes.md # Project-specific pitfalls to avoid
55
+ | |-- overview.md # Current project status, structure, and file map
56
+ | |-- update.md # Latest finished task summary
57
+ | `-- updates.md # Historical task log, newest first
58
+ |-- public/
59
+ | |-- app.js # Landing-page script and service-worker registration
60
+ | |-- index.html # Landing page with proxy overview and links
61
+ | |-- styles.css # Landing-page styles
62
+ | |-- sw-register.js # Shared service-worker registration helper
63
+ | `-- sw.js # Offline cache and timed refresh logic
64
+ |-- public/chatclient/
65
+ | |-- app.js # Chat client controller, tabs, send flow, draft restore, and local state
66
+ | |-- draft.js # Browser-local draft persistence for compose input and link attachments
67
+ | |-- index.html # Tabbed chat client shell and settings/output panels
68
+ | |-- media.js # Attachment creation from uploads/links, previews, and payload conversion
69
+ | |-- preview.js # Large attachment preview overlay controller
70
+ | |-- render.js # Attachment list rendering, assistant rendering, and status/toast UI
71
+ | |-- richText.js # Safe rich-text rendering helpers for assistant output
72
+ | |-- settings.js # Browser-local chat client settings persistence
73
+ | `-- styles.css # Chat client workspace styles
74
+ |-- scripts/
75
+ | `-- build.mjs # Temp-folder build output script
76
+ |-- src/
77
+ | |-- app.js # Express app factory and error handling
78
+ | |-- config.js # Env loading and validation
79
+ | |-- server.js # Dependency wiring and server startup
80
+ | |-- controllers/
81
+ | | |-- chatController.js # Chat proxy request handling
82
+ | | `-- mediaController.js # Temporary media download handling
83
+ | |-- routes/
84
+ | | `-- apiRouter.js # `/v1` routes
85
+ | |-- services/
86
+ | | |-- audioConversionService.js # Audio URL download and mp3 conversion
87
+ | | |-- mediaStore.js # In-memory temporary media storage
88
+ | | |-- openAiService.js # Upstream OpenAI-compatible fetch client
89
+ | | |-- requestNormalizationService.js # Input media normalization
90
+ | | `-- responseNormalizationService.js # Output media normalization
91
+ | `-- utils/
92
+ | |-- dataUrl.js # Data URL and base64 helpers
93
+ | |-- httpError.js # HTTP error shape
94
+ | `-- mediaTypes.js # Audio/image MIME helpers
95
+ `-- test/
96
+ |-- requestNormalization.test.js # Request normalization tests
97
+ `-- responseNormalization.test.js# Response normalization tests
98
  ```
99
 
100
  ## Main Commands
doc/update.md CHANGED
@@ -1,14 +1,14 @@
1
  # Latest Update
2
 
3
- ## 2026-03-01 OpenAI Chat Proxy
4
 
5
- - Start: 2026-03-01T00:59:31.8639973+08:00
6
- - End: 2026-03-01T01:12:48.8144187+08:00
7
- - Total: 00:13:16.9504214
8
  - By: codex/gpt5-codex
9
  - Status: completed
10
 
11
- - Built a Node.js OpenAI-compatible chat completions proxy with image/audio input normalization.
12
- - Added audio URL download plus mp3 conversion before upstream forwarding.
13
- - Added proxy-hosted media URLs for audio output and compatible image output.
14
- - Verified with tests, a temp-folder build, and a live health-check run.
 
1
  # Latest Update
2
 
3
+ ## 2026-03-01 Chat Client Attachment Preview
4
 
5
+ - Start: 2026-03-01T02:05:56.1994947+08:00
6
+ - End: 2026-03-01T02:07:34.1260341+08:00
7
+ - Total: 00:01:37.8369611
8
  - By: codex/gpt5-codex
9
  - Status: completed
10
 
11
+ - Changed attachment items in `/chatclient/` to compact mini previews.
12
+ - Added a large preview overlay so clicking an image or audio attachment opens a focused view.
13
+ - Added a dedicated preview controller module to keep the chat client logic separated.
14
+ - Verified with `npm test`, `npm run build`, and `node --check` on the updated chat client modules.
doc/updates.md CHANGED
@@ -1,5 +1,122 @@
1
  # Updates
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ## 2026-03-01 OpenAI Chat Proxy
4
 
5
  - Start: 2026-03-01T00:59:31.8639973+08:00
 
1
  # Updates
2
 
3
+ ## 2026-03-01 Chat Client Attachment Preview
4
+
5
+ - Start: 2026-03-01T02:05:56.1994947+08:00
6
+ - End: 2026-03-01T02:07:34.1260341+08:00
7
+ - Total: 00:01:37.8369611
8
+ - By: codex/gpt5-codex
9
+ - Status: completed
10
+
11
+ - Changed attachment items to compact mini previews.
12
+ - Added a large preview overlay for clicked image and audio attachments.
13
+ - Added a separate preview controller module for the overlay behavior.
14
+ - Verified with tests, temp-folder build output, and module syntax checks.
15
+
16
+ ## 2026-03-01 Chat Client Keep Attachments
17
+
18
+ - Start: 2026-03-01T02:04:32.7866048+08:00
19
+ - End: 2026-03-01T02:04:52.4223466+08:00
20
+ - Total: 00:00:19.9566896
21
+ - By: codex/gpt5-codex
22
+ - Status: completed
23
+
24
+ - Changed successful sends to keep image and audio attachments in place.
25
+ - Preserved the existing prompt text retention so follow-up sends can reuse the full compose state.
26
+ - Kept the immediate Output-tab switch on send.
27
+ - Verified with tests and a temp-folder build.
28
+
29
+ ## 2026-03-01 Chat Client Send Tab Switch
30
+
31
+ - Start: 2026-03-01T02:03:03.9353616+08:00
32
+ - End: 2026-03-01T02:03:31.3555095+08:00
33
+ - Total: 00:00:27.2864829
34
+ - By: codex/gpt5-codex
35
+ - Status: completed
36
+
37
+ - Switched `/chatclient/` to the Output tab immediately when Send is clicked.
38
+ - Added a temporary sending placeholder while the response is pending.
39
+ - Kept the Raw Output fallback for request failures.
40
+ - Verified with tests and a temp-folder build.
41
+
42
+ ## 2026-03-01 Chat Client Prompt Preserve
43
+
44
+ - Start: 2026-03-01T02:01:08.8913618+08:00
45
+ - End: 2026-03-01T02:01:53.0436788+08:00
46
+ - Total: 00:00:43.9025630
47
+ - By: codex/gpt5-codex
48
+ - Status: completed
49
+
50
+ - Removed the remaining Back button from the top bar.
51
+ - Preserved the rich text prompt after successful sends.
52
+ - Continued clearing transient attachments after send.
53
+ - Verified with tests and a temp-folder build.
54
+
55
+ ## 2026-03-01 Chat Client Icon Toolbar
56
+
57
+ - Start: 2026-03-01T01:56:14.8837029+08:00
58
+ - End: 2026-03-01T01:56:54.4939585+08:00
59
+ - Total: 00:00:39.5691781
60
+ - By: codex/gpt5-codex
61
+ - Status: completed
62
+
63
+ - Changed the compose action row to icon-only controls.
64
+ - Kept the toolbar on one line and pinned send to the right edge.
65
+ - Removed visible button text while retaining accessibility labels.
66
+ - Verified with tests and a temp-folder build.
67
+
68
+ ## 2026-03-01 Chat Client Header Cleanup
69
+
70
+ - Start: 2026-03-01T01:54:21.8510274+08:00
71
+ - End: 2026-03-01T01:55:12.8472558+08:00
72
+ - Total: 00:00:50.9265085
73
+ - By: codex/gpt5-codex
74
+ - Status: completed
75
+
76
+ - Removed the large intro panel from `/chatclient/`.
77
+ - Moved the main tab bar to the top beside the Back button.
78
+ - Removed the visible workspace heading and ready badge.
79
+ - Verified with tests and a temp-folder build.
80
+
81
+ ## 2026-03-01 Chat Client Draft Autosave And Raw Errors
82
+
83
+ - Start: 2026-03-01T01:51:02.7494126+08:00
84
+ - End: 2026-03-01T01:53:14.2661225+08:00
85
+ - Total: 00:02:11.6374744
86
+ - By: codex/gpt5-codex
87
+ - Status: completed
88
+
89
+ - Added automatic draft save and restore for compose input and link-based attachment state in `/chatclient/`.
90
+ - Switched input persistence to save while typing instead of waiting only for field change/blur.
91
+ - Updated the Raw Output tab to display structured request error data when a send fails.
92
+ - Verified with tests, temp-folder build output, and chat client module syntax checks.
93
+
94
+ ## 2026-03-01 Chat Client Attachment Link Options
95
+
96
+ - Start: 2026-03-01T01:47:46.2556873+08:00
97
+ - End: 2026-03-01T01:50:00.3820302+08:00
98
+ - Total: 00:02:13.8865186
99
+ - By: codex/gpt5-codex
100
+ - Status: completed
101
+
102
+ - Added an add-attachment picker that lets `/chatclient/` use uploaded files or direct file links.
103
+ - Added image/audio link payload support alongside the existing file-upload flow.
104
+ - Updated the attachment list UI to show the attachment source type.
105
+ - Verified with tests, temp-folder build output, and chat client module syntax checks.
106
+
107
+ ## 2026-03-01 Chat Client Rich Text Workspace
108
+
109
+ - Start: 2026-03-01T01:37:28.3180067+08:00
110
+ - End: 2026-03-01T01:46:31.1319622+08:00
111
+ - Total: 00:09:02.6742531
112
+ - By: codex/gpt5-codex
113
+ - Status: completed
114
+
115
+ - Reworked `/chatclient/` into a tabbed editor/output/raw-response workspace.
116
+ - Added rich text editing, settings storage, and image/audio file attachment handling.
117
+ - Added formatted assistant response rendering plus raw JSON inspection.
118
+ - Verified with tests, temp-folder build output, and frontend module syntax checks.
119
+
120
  ## 2026-03-01 OpenAI Chat Proxy
121
 
122
  - Start: 2026-03-01T00:59:31.8639973+08:00
public/chatclient/app.js CHANGED
@@ -1,41 +1,212 @@
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)
@@ -45,58 +216,69 @@ async function handleSubmit(event) {
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) {
@@ -113,3 +295,45 @@ async function readResponseBody(response) {
113
  };
114
  }
115
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { registerServiceWorker } from "/sw-register.js";
2
+ import { buildAttachmentParts, createAttachments, createLinkAttachment, releaseAttachment } from "/chatclient/media.js";
3
+ import { createAttachmentPreviewController } from "/chatclient/preview.js";
4
+ import { loadDraft, saveDraft } from "/chatclient/draft.js";
5
+ import { renderAttachments, renderResponse, setStatus, showError } from "/chatclient/render.js";
6
+ import { loadSettings, saveSettings } from "/chatclient/settings.js";
7
 
8
+ const state = { attachments: [] };
9
+
10
+ const editor = document.querySelector("#editor");
11
  const endpointInput = document.querySelector("#endpoint");
12
+ const modelInput = document.querySelector("#model");
13
+ const systemPromptInput = document.querySelector("#system-prompt");
14
+ const audioOutputInput = document.querySelector("#audio-output");
15
+ const voiceInput = document.querySelector("#voice");
16
+ const attachmentInput = document.querySelector("#attachment-input");
17
+ const attachmentPicker = document.querySelector("#attachment-picker");
18
+ const attachmentLinkType = document.querySelector("#attachment-link-type");
19
+ const attachmentLinkUrl = document.querySelector("#attachment-link-url");
20
+ const attachmentsCard = document.querySelector(".attachments-card");
21
+ const attachmentList = document.querySelector("#attachment-list");
22
+ const attachmentSummary = document.querySelector("#attachment-summary");
23
+ const settingsPanel = document.querySelector("#settings-panel");
24
+ const settingsToggle = document.querySelector("#settings-toggle");
25
  const statusLine = document.querySelector("#status-line");
26
+ const sendButton = document.querySelector("#send-button");
27
+ const responseOutput = document.querySelector("#response-output");
28
  const rawJson = document.querySelector("#raw-json");
 
29
  const errorToast = document.querySelector("#error-toast");
30
+ const previewController = createAttachmentPreviewController();
31
 
32
  registerServiceWorker();
33
+ loadSettings({ endpointInput, modelInput, systemPromptInput, audioOutputInput, voiceInput });
34
+ previewController.close();
35
+ restoreDraft();
36
+ renderAttachments(attachmentsCard, attachmentList, attachmentSummary, state.attachments, removeAttachment, previewController.open);
37
+ bindEvents();
38
+
39
+ function bindEvents() {
40
+ settingsToggle.addEventListener("click", () => toggleSettings());
41
+ audioOutputInput.addEventListener("change", syncAudioFields);
42
+ attachmentInput.addEventListener("change", handleAttachmentSelect);
43
+ document.querySelector("#attachment-trigger").addEventListener("click", () => toggleAttachmentPicker());
44
+ document.querySelector("#attachment-upload-trigger").addEventListener("click", () => attachmentInput.click());
45
+ document.querySelector("#attachment-link-add").addEventListener("click", handleLinkAdd);
46
+ attachmentLinkUrl.addEventListener("keydown", handleLinkKeyDown);
47
+ document.querySelector("#send-button").addEventListener("click", handleSend);
48
+ document.querySelector(".editor-toolbar").addEventListener("click", handleFormatClick);
49
+ editor.addEventListener("keydown", handleEditorKeyDown);
50
+ editor.addEventListener("input", persistDraft);
51
+
52
+ for (const button of document.querySelectorAll(".tab-button")) {
53
+ button.addEventListener("click", () => setActiveTab(button.dataset.tab));
54
+ }
55
+
56
+ for (const button of document.querySelectorAll(".picker-mode-button")) {
57
+ button.addEventListener("click", () => setAttachmentMode(button.dataset.mode));
58
+ }
59
+
60
+ for (const element of [endpointInput, modelInput, systemPromptInput, attachmentLinkType]) {
61
+ element.addEventListener("input", handleInputPersistence);
62
+ }
63
+
64
+ for (const element of [audioOutputInput, voiceInput]) {
65
+ element.addEventListener("change", handleInputPersistence);
66
+ }
67
+
68
+ attachmentLinkUrl.addEventListener("input", persistDraft);
69
+ syncAudioFields();
70
+ }
71
+
72
+ function restoreDraft() {
73
+ const draft = loadDraft();
74
+ editor.innerHTML = draft.editorHtml;
75
+ attachmentLinkType.value = draft.attachmentLinkType;
76
+ attachmentLinkUrl.value = draft.attachmentLinkUrl;
77
+ state.attachments = draft.attachments;
78
+ setAttachmentMode(draft.attachmentMode);
79
+ }
80
+
81
+ function syncAudioFields() {
82
+ voiceInput.disabled = !audioOutputInput.checked;
83
+ }
84
+
85
+ function toggleSettings(forceOpen) {
86
+ const shouldShow = typeof forceOpen === "boolean" ? forceOpen : settingsPanel.hidden;
87
+ settingsPanel.hidden = !shouldShow;
88
+ settingsToggle.classList.toggle("is-active", shouldShow);
89
+ }
90
+
91
+ function toggleAttachmentPicker(forceOpen) {
92
+ const shouldShow = typeof forceOpen === "boolean" ? forceOpen : attachmentPicker.hidden;
93
+ attachmentPicker.hidden = !shouldShow;
94
+ document.querySelector("#attachment-trigger").classList.toggle("is-active", shouldShow);
95
+ }
96
 
97
+ function setAttachmentMode(mode) {
98
+ for (const button of document.querySelectorAll(".picker-mode-button")) {
99
+ button.classList.toggle("is-active", button.dataset.mode === mode);
 
 
100
  }
101
 
102
+ for (const panel of document.querySelectorAll(".picker-panel")) {
103
+ const isActive = panel.id === `attachment-picker-${mode}`;
104
+ panel.hidden = !isActive;
105
+ panel.classList.toggle("is-active", isActive);
106
+ }
107
+
108
+ persistDraft();
109
+ }
110
+
111
+ function setActiveTab(tabName) {
112
+ for (const button of document.querySelectorAll(".tab-button")) {
113
+ const isActive = button.dataset.tab === tabName;
114
+ button.classList.toggle("is-active", isActive);
115
+ button.setAttribute("aria-selected", String(isActive));
116
+ }
117
+
118
+ for (const panel of document.querySelectorAll(".tab-panel")) {
119
+ const isActive = panel.dataset.panel === tabName;
120
+ panel.hidden = !isActive;
121
+ panel.classList.toggle("is-active", isActive);
122
+ }
123
+ }
124
+
125
+ function handleFormatClick(event) {
126
+ const button = event.target.closest(".format-button");
127
+ if (!button) {
128
+ return;
129
+ }
130
+
131
+ editor.focus();
132
+ document.execCommand(button.dataset.command);
133
+ persistDraft();
134
  }
135
 
136
+ function handleEditorKeyDown(event) {
137
+ if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
138
+ event.preventDefault();
139
+ handleSend();
140
+ }
141
+ }
142
+
143
+ function handleAttachmentSelect(event) {
144
+ try {
145
+ const nextItems = createAttachments(event.target.files);
146
+ if (nextItems.length === 0) {
147
+ return;
148
+ }
149
+
150
+ addAttachments(nextItems);
151
+ toggleAttachmentPicker(false);
152
+ } catch (error) {
153
+ showError(errorToast, error.message);
154
+ } finally {
155
+ attachmentInput.value = "";
156
+ }
157
+ }
158
+
159
+ function handleLinkAdd() {
160
+ try {
161
+ addAttachments([createLinkAttachment(attachmentLinkType.value, attachmentLinkUrl.value)]);
162
+ attachmentLinkUrl.value = "";
163
+ toggleAttachmentPicker(false);
164
+ persistDraft();
165
+ } catch (error) {
166
+ showError(errorToast, error.message);
167
+ }
168
+ }
169
+
170
+ function handleLinkKeyDown(event) {
171
+ if (event.key === "Enter") {
172
+ event.preventDefault();
173
+ handleLinkAdd();
174
+ }
175
+ }
176
+
177
+ function removeAttachment(id) {
178
+ const index = state.attachments.findIndex((attachment) => attachment.id === id);
179
+ if (index === -1) {
180
+ return;
181
+ }
182
+
183
+ releaseAttachment(state.attachments[index]);
184
+ state.attachments.splice(index, 1);
185
+ renderAttachments(attachmentsCard, attachmentList, attachmentSummary, state.attachments, removeAttachment, previewController.open);
186
+ setStatus(statusLine, state.attachments.length === 0 ? "Ready." : `${state.attachments.length} attachment(s) ready.`);
187
+ persistDraft();
188
+ }
189
+
190
+ function addAttachments(nextItems) {
191
+ state.attachments.push(...nextItems);
192
+ renderAttachments(attachmentsCard, attachmentList, attachmentSummary, state.attachments, removeAttachment, previewController.open);
193
+ setStatus(statusLine, `${state.attachments.length} attachment(s) ready.`);
194
+ persistDraft();
195
+ }
196
+
197
+ async function handleSend() {
198
+ if (sendButton.disabled) {
199
+ return;
200
+ }
201
+
202
+ sendButton.disabled = true;
203
  setStatus(statusLine, "Sending request...");
204
+ showPendingOutput();
205
+ setActiveTab("output");
206
 
207
  try {
208
+ const payload = await buildPayload();
209
+ const response = await fetch(endpointInput.value.trim(), {
210
  method: "POST",
211
  headers: { "content-type": "application/json" },
212
  body: JSON.stringify(payload)
 
216
  rawJson.textContent = JSON.stringify(data, null, 2);
217
 
218
  if (!response.ok) {
219
+ setActiveTab("raw");
220
  throw new Error(data?.error?.message ?? `HTTP ${response.status}`);
221
  }
222
 
223
+ renderResponse(responseOutput, payload, data);
224
+ setActiveTab("output");
225
  setStatus(statusLine, "Response received.", true);
226
  } catch (error) {
227
+ renderRequestError(error, "request");
 
228
  } finally {
229
+ sendButton.disabled = false;
230
  }
231
  }
232
 
233
+ async function buildPayload() {
234
+ const text = readEditorText();
 
 
235
  const content = [];
236
 
237
+ if (text) {
238
+ content.push({ type: "text", text });
 
 
 
 
239
  }
240
 
241
+ content.push(...await buildAttachmentParts(state.attachments));
242
  if (content.length === 0) {
243
+ throw new Error("Add text or at least one image/audio file before sending.");
244
  }
245
 
246
  const payload = {
247
+ model: modelInput.value.trim(),
248
  messages: []
249
  };
250
 
251
+ if (!payload.model) {
252
+ throw new Error("Enter a model name in settings.");
253
+ }
254
+
255
+ if (systemPromptInput.value.trim()) {
256
+ payload.messages.push({
257
+ role: "system",
258
+ content: systemPromptInput.value.trim()
259
+ });
260
  }
261
 
262
+ payload.messages.push({
263
+ role: "user",
264
+ content
265
+ });
266
 
267
+ if (audioOutputInput.checked) {
268
  payload.audio = {
269
+ voice: voiceInput.value,
270
  format: "mp3"
271
  };
272
  }
273
 
274
+ persistSettings();
275
  return payload;
276
  }
277
 
278
+ function readEditorText() {
279
+ return editor.innerText.replaceAll("\u00A0", " ").trim();
280
+ }
281
+
282
  async function readResponseBody(response) {
283
  const text = await response.text();
284
  if (!text) {
 
295
  };
296
  }
297
  }
298
+
299
+ function handleInputPersistence() {
300
+ persistSettings();
301
+ persistDraft();
302
+ }
303
+
304
+ function persistSettings() {
305
+ saveSettings({
306
+ endpointInput,
307
+ modelInput,
308
+ systemPromptInput,
309
+ audioOutputInput,
310
+ voiceInput
311
+ });
312
+ }
313
+
314
+ function persistDraft() {
315
+ saveDraft({
316
+ editor,
317
+ attachmentMode: document.querySelector(".picker-mode-button.is-active")?.dataset.mode || "upload",
318
+ attachmentLinkType: attachmentLinkType.value,
319
+ attachmentLinkUrl: attachmentLinkUrl.value,
320
+ attachments: state.attachments
321
+ });
322
+ }
323
+
324
+ function renderRequestError(error, stage) {
325
+ rawJson.textContent = JSON.stringify({
326
+ error: {
327
+ stage,
328
+ name: error.name,
329
+ message: error.message
330
+ }
331
+ }, null, 2);
332
+ setActiveTab("raw");
333
+ setStatus(statusLine, "Request failed.");
334
+ showError(errorToast, error.message);
335
+ }
336
+
337
+ function showPendingOutput() {
338
+ responseOutput.innerHTML = '<p class="empty-state">Sending request...</p>';
339
+ }
public/chatclient/draft.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const DRAFT_KEY = "oapix.chatclient.draft";
2
+
3
+ export function loadDraft() {
4
+ try {
5
+ const saved = JSON.parse(window.localStorage.getItem(DRAFT_KEY) || "{}");
6
+ return {
7
+ editorHtml: typeof saved.editorHtml === "string" ? saved.editorHtml : "",
8
+ attachmentMode: saved.attachmentMode === "link" ? "link" : "upload",
9
+ attachmentLinkType: saved.attachmentLinkType === "audio" ? "audio" : "image",
10
+ attachmentLinkUrl: typeof saved.attachmentLinkUrl === "string" ? saved.attachmentLinkUrl : "",
11
+ attachments: sanitizeAttachments(saved.attachments)
12
+ };
13
+ } catch (_error) {
14
+ window.localStorage.removeItem(DRAFT_KEY);
15
+ return emptyDraft();
16
+ }
17
+ }
18
+
19
+ export function saveDraft({ editor, attachmentMode, attachmentLinkType, attachmentLinkUrl, attachments }) {
20
+ window.localStorage.setItem(DRAFT_KEY, JSON.stringify({
21
+ editorHtml: editor.innerHTML,
22
+ attachmentMode,
23
+ attachmentLinkType,
24
+ attachmentLinkUrl,
25
+ attachments: attachments
26
+ .filter((attachment) => attachment.sourceType === "link")
27
+ .map((attachment) => ({
28
+ id: attachment.id,
29
+ kind: attachment.kind,
30
+ name: attachment.name,
31
+ sizeLabel: attachment.sizeLabel,
32
+ previewUrl: attachment.previewUrl,
33
+ sourceType: attachment.sourceType,
34
+ url: attachment.url
35
+ }))
36
+ }));
37
+ }
38
+
39
+ export function clearDraft() {
40
+ window.localStorage.removeItem(DRAFT_KEY);
41
+ }
42
+
43
+ function sanitizeAttachments(value) {
44
+ if (!Array.isArray(value)) {
45
+ return [];
46
+ }
47
+
48
+ return value.filter((attachment) => {
49
+ return attachment
50
+ && attachment.sourceType === "link"
51
+ && (attachment.kind === "image" || attachment.kind === "audio")
52
+ && typeof attachment.url === "string"
53
+ && typeof attachment.name === "string";
54
+ });
55
+ }
56
+
57
+ function emptyDraft() {
58
+ return {
59
+ editorHtml: "",
60
+ attachmentMode: "upload",
61
+ attachmentLinkType: "image",
62
+ attachmentLinkUrl: "",
63
+ attachments: []
64
+ };
65
+ }
public/chatclient/index.html CHANGED
@@ -8,119 +8,221 @@
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>
 
8
  <script type="module" src="/chatclient/app.js"></script>
9
  </head>
10
  <body>
11
+ <main class="client-shell">
12
+ <section class="workspace-card">
13
+ <div class="top-bar">
14
+ <div class="tab-strip" role="tablist" aria-label="Chat client tabs">
15
+ <button class="tab-button is-active" type="button" role="tab" aria-selected="true" data-tab="compose">
16
+ Compose
17
+ </button>
18
+ <button class="tab-button" type="button" role="tab" aria-selected="false" data-tab="output">
19
+ Output
20
+ </button>
21
+ <button class="tab-button" type="button" role="tab" aria-selected="false" data-tab="raw">
22
+ Raw Output
23
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  </div>
25
+ </div>
26
+ <p id="status-line" class="sr-only">Ready.</p>
27
 
28
+ <section class="tab-panel is-active" role="tabpanel" data-panel="compose">
29
+ <article class="editor-card">
30
+ <div class="editor-toolbar" aria-label="Text formatting tools">
31
+ <button class="format-button" type="button" data-command="bold" aria-label="Bold text">
32
+ <strong>B</strong>
33
+ </button>
34
+ <button class="format-button" type="button" data-command="italic" aria-label="Italic text">
35
+ <em>I</em>
36
+ </button>
37
+ <button class="format-button" type="button" data-command="insertUnorderedList" aria-label="Bullet list">
38
+ List
39
+ </button>
40
+ <button class="format-button" type="button" data-command="removeFormat" aria-label="Clear formatting">
41
+ Clear
42
+ </button>
43
+ </div>
44
+
45
+ <div
46
+ id="editor"
47
+ class="rich-editor"
48
+ contenteditable="true"
49
+ spellcheck="true"
50
+ data-placeholder="Write the user message here. Use Ctrl+Enter to send."
51
+ ></div>
52
+ </article>
53
+
54
+ <section class="attachments-card" aria-labelledby="attachments-heading" hidden>
55
+ <div class="section-head">
56
+ <div>
57
+ <p class="eyebrow">Attachments</p>
58
+ <h3 id="attachments-heading">Image and Audio Attachments</h3>
59
+ </div>
60
+ <p id="attachment-summary" class="section-copy">No files added.</p>
61
+ </div>
62
+ <div id="attachment-list" class="attachment-list">
63
+ <p class="empty-state">Add an image or audio file or link to include it with the next request.</p>
64
+ </div>
65
  </section>
66
 
67
+ <div class="tool-row" aria-label="Request tools">
68
+ <button id="settings-toggle" class="tool-button icon-button" type="button" aria-label="Open settings" title="Settings">
69
+ <svg viewBox="0 0 24 24" aria-hidden="true">
70
+ <path d="M12 8.5A3.5 3.5 0 1 0 12 15.5A3.5 3.5 0 1 0 12 8.5z"></path>
71
+ <path d="M19.4 13.5a7.5 7.5 0 0 0 .1-1.5a7.5 7.5 0 0 0-.1-1.5l2-1.6l-2-3.4l-2.4 1a7.9 7.9 0 0 0-2.6-1.5L14 2h-4l-.4 3a7.9 7.9 0 0 0-2.6 1.5l-2.4-1l-2 3.4l2 1.6A7.5 7.5 0 0 0 4.5 12a7.5 7.5 0 0 0 .1 1.5l-2 1.6l2 3.4l2.4-1a7.9 7.9 0 0 0 2.6 1.5l.4 3h4l.4-3a7.9 7.9 0 0 0 2.6-1.5l2.4 1l2-3.4z"></path>
72
+ </svg>
73
+ </button>
74
+
75
+ <button id="attachment-trigger" class="tool-button icon-button" type="button" aria-label="Add attachment" title="Add attachment">
76
+ <svg viewBox="0 0 24 24" aria-hidden="true">
77
+ <path d="M12 5v14"></path>
78
+ <path d="M5 12h14"></path>
79
+ </svg>
80
+ </button>
81
+
82
+ <button id="send-button" class="send-button icon-button" type="button" aria-label="Send message" title="Send">
83
+ <svg viewBox="0 0 24 24" aria-hidden="true">
84
+ <path d="M4 12l15-7l-3 7l3 7z"></path>
85
+ <path d="M10 12h9"></path>
86
+ </svg>
87
+ </button>
88
+
89
+ <input id="attachment-input" type="file" accept="image/*,audio/*" multiple hidden>
90
  </div>
91
 
92
+ <section id="attachment-picker" class="attachment-picker" hidden>
93
+ <div class="section-head">
94
+ <div>
95
+ <p class="eyebrow">Add Attachment</p>
96
+ <h3>Choose Source</h3>
97
+ </div>
98
+ <p class="section-copy">Use uploaded files or direct links for image and audio inputs.</p>
99
+ </div>
100
 
101
+ <div class="picker-mode-row" role="tablist" aria-label="Attachment source options">
102
+ <button id="attachment-mode-upload" class="picker-mode-button is-active" type="button" data-mode="upload">
103
+ Upload Files
104
+ </button>
105
+ <button id="attachment-mode-link" class="picker-mode-button" type="button" data-mode="link">
106
+ File Link
107
+ </button>
108
  </div>
 
109
 
110
+ <div id="attachment-picker-upload" class="picker-panel is-active">
111
+ <p class="picker-copy">Select one or more image/audio files from this device.</p>
112
+ <button id="attachment-upload-trigger" class="picker-action-button" type="button">Choose Files</button>
113
+ </div>
114
 
115
+ <div id="attachment-picker-link" class="picker-panel" hidden>
116
+ <div class="picker-grid">
117
+ <label>
118
+ <span>Type</span>
119
+ <select id="attachment-link-type">
120
+ <option value="image">Image</option>
121
+ <option value="audio">Audio</option>
122
+ </select>
123
+ </label>
124
+
125
+ <label class="picker-link-field">
126
+ <span>File Link</span>
127
+ <input id="attachment-link-url" type="url" placeholder="https://example.com/file">
128
+ </label>
129
+ </div>
130
+
131
+ <button id="attachment-link-add" class="picker-action-button" type="button">Add Link</button>
132
  </div>
133
+ </section>
134
+
135
+ <section id="settings-panel" class="settings-panel" hidden>
136
+ <div class="section-head">
137
+ <div>
138
+ <p class="eyebrow">Settings</p>
139
+ <h3>Request Options</h3>
140
+ </div>
141
+ <p class="section-copy">Endpoint and prompt settings stay local in this browser.</p>
142
+ </div>
143
+
144
+ <div class="settings-grid">
145
+ <label>
146
+ <span>Endpoint</span>
147
+ <input id="endpoint" name="endpoint" type="url">
148
+ </label>
149
+
150
+ <label>
151
+ <span>Model</span>
152
+ <input id="model" name="model" type="text" value="gpt-4.1-mini" required>
153
+ </label>
154
+
155
+ <label class="settings-wide">
156
+ <span>System Prompt</span>
157
+ <textarea id="system-prompt" name="systemPrompt" rows="4" placeholder="Optional system prompt"></textarea>
158
+ </label>
159
+
160
+ <label class="toggle-card">
161
+ <span>Audio Output</span>
162
+ <input id="audio-output" name="audioOutput" type="checkbox" checked>
163
+ </label>
164
+
165
+ <label>
166
+ <span>Voice</span>
167
+ <select id="voice" name="voice">
168
+ <option value="alloy">alloy</option>
169
+ <option value="ash">ash</option>
170
+ <option value="ballad">ballad</option>
171
+ <option value="coral">coral</option>
172
+ <option value="sage">sage</option>
173
+ <option value="verse">verse</option>
174
+ </select>
175
+ </label>
176
+ </div>
177
+ </section>
178
+ </section>
179
+
180
+ <section class="tab-panel" role="tabpanel" data-panel="output" hidden>
181
+ <article class="output-card">
182
+ <div class="section-head">
183
+ <div>
184
+ <p class="eyebrow">Assistant Output</p>
185
+ <h3>Rich Text Page</h3>
186
+ </div>
187
+ <p class="section-copy">Rendered response, returned images, and returned audio live here.</p>
188
+ </div>
189
+ <div id="response-output" class="response-output">
190
+ <p class="empty-state">Send a request to open the assistant response view.</p>
191
+ </div>
192
+ </article>
193
+ </section>
194
+
195
+ <section class="tab-panel" role="tabpanel" data-panel="raw" hidden>
196
+ <article class="output-card">
197
+ <div class="section-head">
198
+ <div>
199
+ <p class="eyebrow">Debug</p>
200
+ <h3>Raw Response</h3>
201
+ </div>
202
+ <p class="section-copy">This tab shows the exact JSON returned by the proxy.</p>
203
+ </div>
204
+ <pre id="raw-json" class="raw-output">{}</pre>
205
+ </article>
206
+ </section>
207
  </section>
208
  </main>
209
 
210
+ <section id="attachment-preview" class="attachment-preview" hidden>
211
+ <div class="attachment-preview__backdrop" data-close-preview></div>
212
+ <article class="attachment-preview__card" role="dialog" aria-modal="true" aria-labelledby="attachment-preview-title">
213
+ <div class="attachment-preview__head">
214
+ <div>
215
+ <p class="eyebrow">Attachment Preview</p>
216
+ <h3 id="attachment-preview-title">Preview</h3>
217
+ </div>
218
+ <button id="attachment-preview-close" class="attachment-preview__close" type="button" aria-label="Close preview">
219
+ x
220
+ </button>
221
+ </div>
222
+ <div id="attachment-preview-body" class="attachment-preview__body"></div>
223
+ </article>
224
+ </section>
225
+
226
  <button id="error-toast" class="error-toast" type="button" hidden></button>
227
  </body>
228
  </html>
public/chatclient/media.js CHANGED
@@ -1,61 +1,89 @@
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) {
@@ -81,8 +109,24 @@ async function readFileAsBase64(file) {
81
  return dataUrl.split(",")[1] || "";
82
  }
83
 
84
- function requireValue(value, message) {
85
- if (!value) {
86
- throw new Error(message);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  }
88
  }
 
1
+ const IMAGE_PREFIX = "image/";
2
+ const AUDIO_PREFIX = "audio/";
 
 
 
 
3
 
4
+ export function createAttachments(fileList) {
5
+ return Array.from(fileList, (file) => createFileAttachment(file));
6
+ }
 
 
7
 
8
+ export function createLinkAttachment(kind, url) {
9
+ const trimmedUrl = String(url || "").trim();
10
+ if (!trimmedUrl) {
11
+ throw new Error("Enter an image or audio link before adding it.");
12
  }
13
 
14
+ if (!/^https?:\/\//i.test(trimmedUrl)) {
15
+ throw new Error("Attachment links must start with http:// or https://.");
 
 
 
16
  }
17
 
18
+ return {
19
+ id: crypto.randomUUID(),
20
+ kind,
21
+ name: inferNameFromUrl(trimmedUrl),
22
+ sizeLabel: "link",
23
+ previewUrl: trimmedUrl,
24
+ sourceType: "link",
25
+ url: trimmedUrl
26
+ };
27
+ }
28
+
29
+ export async function buildAttachmentParts(attachments) {
30
+ return Promise.all(attachments.map((attachment) => buildAttachmentPart(attachment)));
31
+ }
32
+
33
+ export function releaseAttachment(attachment) {
34
+ if (attachment?.sourceType === "file" && attachment.previewUrl) {
35
+ URL.revokeObjectURL(attachment.previewUrl);
36
  }
37
+ }
38
+
39
+ function createFileAttachment(file) {
40
+ const kind = classifyFile(file);
41
+ return {
42
+ id: crypto.randomUUID(),
43
+ file,
44
+ kind,
45
+ name: file.name,
46
+ sizeLabel: formatBytes(file.size),
47
+ previewUrl: URL.createObjectURL(file),
48
+ sourceType: "file"
49
+ };
50
+ }
51
 
52
+ async function buildAttachmentPart(attachment) {
53
+ if (attachment.sourceType === "link") {
54
+ return attachment.kind === "image"
55
+ ? { type: "image_url", image_url: { url: attachment.url } }
56
+ : { type: "input_audio", input_audio: { url: attachment.url } };
57
  }
58
 
59
+ if (attachment.kind === "image") {
60
  return {
61
+ type: "image_url",
62
+ image_url: {
63
+ url: await readFileAsDataUrl(attachment.file)
 
64
  }
65
  };
66
  }
67
 
68
+ return {
69
+ type: "input_audio",
70
+ input_audio: {
71
+ data: await readFileAsBase64(attachment.file),
72
+ format: inferAudioFormat(attachment.file)
73
+ }
74
+ };
75
  }
76
 
77
+ function classifyFile(file) {
78
+ if (file.type.startsWith(IMAGE_PREFIX)) {
79
+ return "image";
 
 
 
 
80
  }
81
 
82
+ if (file.type.startsWith(AUDIO_PREFIX)) {
83
+ return "audio";
84
+ }
85
+
86
+ throw new Error(`Unsupported file type: ${file.type || file.name}`);
87
  }
88
 
89
  function inferAudioFormat(file) {
 
109
  return dataUrl.split(",")[1] || "";
110
  }
111
 
112
+ function formatBytes(value) {
113
+ if (value < 1024) {
114
+ return `${value} B`;
115
+ }
116
+
117
+ if (value < 1024 * 1024) {
118
+ return `${(value / 1024).toFixed(1)} KB`;
119
+ }
120
+
121
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
122
+ }
123
+
124
+ function inferNameFromUrl(url) {
125
+ try {
126
+ const { pathname, hostname } = new URL(url);
127
+ const lastSegment = pathname.split("/").filter(Boolean).at(-1);
128
+ return lastSegment || hostname;
129
+ } catch (_error) {
130
+ return "remote-file";
131
  }
132
  }
public/chatclient/preview.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function createAttachmentPreviewController() {
2
+ const root = document.querySelector("#attachment-preview");
3
+ const body = document.querySelector("#attachment-preview-body");
4
+ const title = document.querySelector("#attachment-preview-title");
5
+ const closeButton = document.querySelector("#attachment-preview-close");
6
+
7
+ closeButton.addEventListener("click", close);
8
+ root.addEventListener("click", (event) => {
9
+ if (event.target instanceof HTMLElement && event.target.hasAttribute("data-close-preview")) {
10
+ close();
11
+ }
12
+ });
13
+
14
+ document.addEventListener("keydown", (event) => {
15
+ if (event.key === "Escape" && !root.hidden) {
16
+ close();
17
+ }
18
+ });
19
+
20
+ return { open, close };
21
+
22
+ function open(attachment) {
23
+ title.textContent = attachment.name;
24
+ body.innerHTML = "";
25
+ body.appendChild(renderPreview(attachment));
26
+ root.hidden = false;
27
+ }
28
+
29
+ function close() {
30
+ root.hidden = true;
31
+ body.innerHTML = "";
32
+ }
33
+ }
34
+
35
+ function renderPreview(attachment) {
36
+ if (attachment.kind === "image") {
37
+ const image = document.createElement("img");
38
+ image.src = attachment.previewUrl;
39
+ image.alt = attachment.name;
40
+ image.className = "attachment-preview__image";
41
+ return image;
42
+ }
43
+
44
+ const wrap = document.createElement("div");
45
+ wrap.className = "attachment-preview__audio-wrap";
46
+
47
+ const audio = document.createElement("audio");
48
+ audio.controls = true;
49
+ audio.src = attachment.previewUrl;
50
+ audio.className = "attachment-preview__audio";
51
+ wrap.appendChild(audio);
52
+ return wrap;
53
+ }
public/chatclient/render.js CHANGED
@@ -1,38 +1,60 @@
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) {
@@ -56,6 +78,71 @@ export function showError(errorToast, message) {
56
 
57
  showError.timer = 0;
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  function extractAssistantText(message) {
60
  if (typeof message.content === "string") {
61
  return message.content;
@@ -71,31 +158,31 @@ function extractAssistantText(message) {
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
  }
 
1
+ import { escapeHtml, renderRichText } from "/chatclient/richText.js";
2
+
3
+ export function renderAttachments(card, container, summary, attachments, onRemove, onPreview) {
4
+ card.hidden = attachments.length === 0;
5
+ summary.textContent = buildAttachmentSummary(attachments);
6
+ container.innerHTML = "";
7
+
8
+ if (attachments.length === 0) {
9
+ container.innerHTML = '<p class="empty-state">Add an image or audio file or link to include it with the next request.</p>';
10
+ return;
11
+ }
12
+
13
+ for (const attachment of attachments) {
14
+ const card = document.createElement("article");
15
+ card.className = "attachment-item";
16
+ card.appendChild(renderAttachmentPreview(attachment, onPreview));
17
+ card.appendChild(renderAttachmentMeta(attachment));
18
+ card.appendChild(renderRemoveButton(attachment.id, onRemove));
19
+ container.appendChild(card);
20
+ }
21
+ }
22
+
23
+ export function renderResponse(container, requestPayload, responseBody) {
24
  const assistant = responseBody?.choices?.[0]?.message ?? {};
25
  const text = extractAssistantText(assistant);
26
+ const images = extractAssistantImages(assistant);
27
  const audioUrl = assistant?.audio?.url || null;
28
 
29
+ container.innerHTML = "";
30
+ container.appendChild(renderInfoCard("Request", describeRequest(requestPayload)));
31
+ container.appendChild(renderRichTextCard("Assistant", text || "No assistant text returned."));
32
+
33
+ for (const imageUrl of images) {
34
+ const imageCard = renderInfoCard("Image", imageUrl);
35
+ const image = document.createElement("img");
36
+ image.src = imageUrl;
37
+ image.alt = "assistant output";
38
+ image.className = "response-image";
39
+ imageCard.appendChild(image);
40
+ container.appendChild(imageCard);
 
 
41
  }
42
 
43
  if (audioUrl) {
44
+ const audioCard = renderInfoCard("Audio", audioUrl);
45
  const audio = document.createElement("audio");
46
  audio.controls = true;
47
  audio.src = audioUrl;
48
+ audio.className = "response-audio";
49
  audioCard.appendChild(audio);
50
+ container.appendChild(audioCard);
51
  }
52
  }
53
 
54
  export function setStatus(statusLine, message, isOk = false) {
55
  statusLine.textContent = message;
56
  statusLine.classList.toggle("status-ok", isOk);
57
+ statusLine.classList.toggle("status-busy", !isOk && /sending/i.test(message));
58
  }
59
 
60
  export function showError(errorToast, message) {
 
78
 
79
  showError.timer = 0;
80
 
81
+ function buildAttachmentSummary(attachments) {
82
+ if (attachments.length === 0) {
83
+ return "No files added.";
84
+ }
85
+
86
+ const imageCount = attachments.filter((item) => item.kind === "image").length;
87
+ const audioCount = attachments.length - imageCount;
88
+ return `${attachments.length} file${attachments.length === 1 ? "" : "s"} ready | ${imageCount} image | ${audioCount} audio`;
89
+ }
90
+
91
+ function renderAttachmentPreview(attachment, onPreview) {
92
+ const button = document.createElement("button");
93
+ button.type = "button";
94
+ button.className = "attachment-preview-trigger";
95
+ button.setAttribute("aria-label", `Open ${attachment.name}`);
96
+ button.addEventListener("click", () => onPreview(attachment));
97
+
98
+ if (attachment.kind === "image") {
99
+ const image = document.createElement("img");
100
+ image.src = attachment.previewUrl;
101
+ image.alt = attachment.name;
102
+ image.className = "attachment-thumb";
103
+ button.appendChild(image);
104
+ return button;
105
+ }
106
+
107
+ button.innerHTML = `
108
+ <svg viewBox="0 0 24 24" aria-hidden="true">
109
+ <path d="M12 18V6"></path>
110
+ <path d="M8 15a4 4 0 1 0 0-6"></path>
111
+ <path d="M16 15a4 4 0 1 0 0-6"></path>
112
+ </svg>
113
+ <span>Open</span>
114
+ `;
115
+ button.classList.add("attachment-audio-tile");
116
+ return button;
117
+ }
118
+
119
+ function renderAttachmentMeta(attachment) {
120
+ const meta = document.createElement("div");
121
+ meta.className = "attachment-meta";
122
+ meta.innerHTML = `
123
+ <strong>${escapeHtml(attachment.name)}</strong>
124
+ <span>${escapeHtml(attachment.kind)} | ${escapeHtml(attachment.sourceType)} | ${escapeHtml(attachment.sizeLabel)}</span>
125
+ `;
126
+ return meta;
127
+ }
128
+
129
+ function renderRemoveButton(attachmentId, onRemove) {
130
+ const button = document.createElement("button");
131
+ button.type = "button";
132
+ button.className = "attachment-remove";
133
+ button.textContent = "Remove";
134
+ button.addEventListener("click", () => onRemove(attachmentId));
135
+ return button;
136
+ }
137
+
138
+ function describeRequest(payload) {
139
+ const userContent = payload.messages.at(-1)?.content ?? [];
140
+ const textPart = userContent.find((part) => part.type === "text");
141
+ const attachmentCount = userContent.filter((part) => part.type !== "text").length;
142
+ const audioOutput = payload.audio ? `Audio output: ${payload.audio.voice}` : "Audio output: off";
143
+ return `${textPart?.text || "Attachment-only request."}\n\nAttachments: ${attachmentCount}\n${audioOutput}`;
144
+ }
145
+
146
  function extractAssistantText(message) {
147
  if (typeof message.content === "string") {
148
  return message.content;
 
158
  .join("\n\n");
159
  }
160
 
161
+ function extractAssistantImages(message) {
162
  if (!Array.isArray(message.content)) {
163
+ return [];
164
  }
165
 
166
+ return message.content
167
+ .map((part) => part?.image_url?.proxy_url || part?.image_url?.url)
168
+ .filter(Boolean);
 
 
 
 
 
169
  }
170
 
171
+ function renderInfoCard(title, body) {
172
  const card = document.createElement("article");
173
+ card.className = "response-block";
174
  card.innerHTML = `<strong>${escapeHtml(title)}</strong><p>${escapeHtml(body)}</p>`;
175
  return card;
176
  }
177
 
178
+ function renderRichTextCard(title, body) {
179
+ const card = document.createElement("article");
180
+ card.className = "response-block";
181
+ card.innerHTML = `<strong>${escapeHtml(title)}</strong>`;
182
+
183
+ const bodyElement = document.createElement("div");
184
+ bodyElement.className = "rich-response";
185
+ renderRichText(bodyElement, body);
186
+ card.appendChild(bodyElement);
187
+ return card;
188
  }
public/chatclient/richText.js ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function renderRichText(container, text) {
2
+ container.innerHTML = "";
3
+ const lines = String(text).replaceAll("\r\n", "\n").split("\n");
4
+ let list = null;
5
+ let codeLines = null;
6
+ let paragraph = [];
7
+
8
+ const flushParagraph = () => {
9
+ if (paragraph.length === 0) {
10
+ return;
11
+ }
12
+
13
+ const element = document.createElement("p");
14
+ element.innerHTML = formatInline(paragraph.join(" "));
15
+ container.appendChild(element);
16
+ paragraph = [];
17
+ };
18
+
19
+ const flushList = () => {
20
+ if (list) {
21
+ container.appendChild(list);
22
+ list = null;
23
+ }
24
+ };
25
+
26
+ const flushCode = () => {
27
+ if (!codeLines) {
28
+ return;
29
+ }
30
+
31
+ const pre = document.createElement("pre");
32
+ pre.textContent = codeLines.join("\n");
33
+ container.appendChild(pre);
34
+ codeLines = null;
35
+ };
36
+
37
+ for (const line of lines) {
38
+ if (line.startsWith("```")) {
39
+ flushParagraph();
40
+ flushList();
41
+ if (codeLines) {
42
+ flushCode();
43
+ } else {
44
+ codeLines = [];
45
+ }
46
+ continue;
47
+ }
48
+
49
+ if (codeLines) {
50
+ codeLines.push(line);
51
+ continue;
52
+ }
53
+
54
+ const listMatch = line.match(/^\s*[-*]\s+(.+)$/);
55
+ if (listMatch) {
56
+ flushParagraph();
57
+ if (!list) {
58
+ list = document.createElement("ul");
59
+ }
60
+ const item = document.createElement("li");
61
+ item.innerHTML = formatInline(listMatch[1]);
62
+ list.appendChild(item);
63
+ continue;
64
+ }
65
+
66
+ if (!line.trim()) {
67
+ flushParagraph();
68
+ flushList();
69
+ continue;
70
+ }
71
+
72
+ flushList();
73
+ paragraph.push(line.trim());
74
+ }
75
+
76
+ flushParagraph();
77
+ flushList();
78
+ flushCode();
79
+ }
80
+
81
+ export function escapeHtml(value) {
82
+ return String(value)
83
+ .replaceAll("&", "&amp;")
84
+ .replaceAll("<", "&lt;")
85
+ .replaceAll(">", "&gt;");
86
+ }
87
+
88
+ function formatInline(value) {
89
+ return escapeHtml(value)
90
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
91
+ .replace(/\*(.+?)\*/g, "<em>$1</em>")
92
+ .replace(/`(.+?)`/g, "<code>$1</code>");
93
+ }
public/chatclient/settings.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const SETTINGS_KEY = "oapix.chatclient.settings";
2
+
3
+ export function loadSettings({
4
+ endpointInput,
5
+ modelInput,
6
+ systemPromptInput,
7
+ audioOutputInput,
8
+ voiceInput
9
+ }) {
10
+ endpointInput.value = `${window.location.origin}/v1/chat/completions`;
11
+
12
+ try {
13
+ const saved = JSON.parse(window.localStorage.getItem(SETTINGS_KEY) || "{}");
14
+ endpointInput.value = saved.endpoint || endpointInput.value;
15
+ modelInput.value = saved.model || modelInput.value;
16
+ systemPromptInput.value = saved.systemPrompt || "";
17
+ audioOutputInput.checked = saved.audioOutput ?? true;
18
+ voiceInput.value = saved.voice || voiceInput.value;
19
+ } catch (_error) {
20
+ window.localStorage.removeItem(SETTINGS_KEY);
21
+ }
22
+ }
23
+
24
+ export function saveSettings({
25
+ endpointInput,
26
+ modelInput,
27
+ systemPromptInput,
28
+ audioOutputInput,
29
+ voiceInput
30
+ }) {
31
+ window.localStorage.setItem(SETTINGS_KEY, JSON.stringify({
32
+ endpoint: endpointInput.value.trim(),
33
+ model: modelInput.value.trim(),
34
+ systemPrompt: systemPromptInput.value,
35
+ audioOutput: audioOutputInput.checked,
36
+ voice: voiceInput.value
37
+ }));
38
+ }
public/chatclient/styles.css CHANGED
@@ -1,255 +1,579 @@
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
 
@@ -265,39 +589,96 @@ textarea {
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
  }
 
1
  :root {
2
  color-scheme: light;
3
+ --bg: #f4efe7;
4
+ --surface: rgba(255, 250, 242, 0.88);
5
+ --surface-strong: #fffaf2;
6
+ --surface-soft: rgba(255, 255, 255, 0.52);
7
+ --ink: #1f1812;
8
+ --muted: #6c5c50;
9
+ --line: rgba(70, 49, 28, 0.14);
10
+ --line-strong: rgba(70, 49, 28, 0.24);
11
+ --accent: #c75a11;
12
+ --accent-deep: #8f3f0a;
13
+ --accent-soft: rgba(199, 90, 17, 0.14);
14
  --success: #166534;
15
+ --shadow: 0 24px 64px rgba(70, 49, 28, 0.14);
16
  }
17
 
18
  * {
19
  box-sizing: border-box;
20
  }
21
 
22
+ [hidden] {
23
+ display: none !important;
24
+ }
25
+
26
  body {
27
  margin: 0;
28
  min-height: 100vh;
 
29
  color: var(--ink);
30
+ font-family: "Space Grotesk", "Segoe UI", sans-serif;
31
  background:
32
+ radial-gradient(circle at top left, rgba(199, 90, 17, 0.15), transparent 28%),
33
+ radial-gradient(circle at 85% 15%, rgba(26, 95, 63, 0.12), transparent 24%),
34
+ linear-gradient(145deg, #f9f4ec, #ece0ce 55%, #e5d4be);
35
  }
36
 
37
+ button,
38
+ input,
39
+ textarea,
40
+ select {
41
+ font: inherit;
42
+ }
43
+
44
+ button {
45
+ cursor: pointer;
46
+ }
47
+
48
+ .client-shell {
49
+ width: min(1080px, calc(100% - 28px));
50
  margin: 0 auto;
51
+ padding: 18px 0 26px;
52
  }
53
 
54
+ .workspace-card,
55
+ .editor-card,
56
+ .attachments-card,
57
+ .attachment-picker,
58
+ .settings-panel,
59
+ .output-card {
60
  border: 1px solid var(--line);
61
  border-radius: 28px;
62
+ background: var(--surface);
63
  box-shadow: var(--shadow);
64
+ backdrop-filter: blur(14px);
65
  }
66
 
67
+ .workspace-card {
68
  padding: 24px;
 
 
 
69
  display: grid;
70
+ gap: 18px;
71
+ min-height: calc(100vh - 44px);
 
 
 
 
 
 
 
72
  }
73
 
74
+ .tab-button,
75
+ .format-button,
76
+ .tool-button,
77
+ .send-button,
78
+ .attachment-remove,
79
+ .attachment-preview-trigger,
80
+ .attachment-preview__close {
 
81
  border: 1px solid var(--line);
82
+ background: var(--surface-strong);
83
+ color: var(--ink);
84
  }
85
 
86
  .eyebrow {
87
+ margin: 0;
88
+ font-size: 0.76rem;
89
+ letter-spacing: 0.24em;
90
  text-transform: uppercase;
 
91
  color: var(--accent-deep);
92
  }
93
 
94
+ h2,
95
+ h3 {
96
  margin: 0;
97
  font-family: "Avenir Next", "Space Grotesk", sans-serif;
98
  }
99
 
100
+ .section-copy,
101
+ .empty-state,
102
+ .response-block p,
103
+ .attachment-meta span {
104
+ margin: 0;
105
+ line-height: 1.55;
106
+ color: var(--muted);
107
  }
108
 
109
+ .top-bar,
110
+ .section-head {
111
+ display: flex;
112
+ justify-content: flex-start;
113
+ gap: 16px;
114
+ align-items: center;
115
  }
116
 
117
+ .tab-strip {
118
+ display: flex;
119
+ gap: 10px;
120
+ flex-wrap: wrap;
121
+ justify-content: flex-start;
122
+ }
123
+
124
+ .tab-button {
125
+ min-height: 44px;
126
+ padding: 0 18px;
127
+ border-radius: 999px;
128
+ transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
129
+ }
130
+
131
+ .tab-button.is-active {
132
+ border-color: transparent;
133
+ color: #fff;
134
+ background: linear-gradient(135deg, var(--accent), var(--accent-deep));
135
+ }
136
+
137
+ .tab-button:hover,
138
+ .format-button:hover,
139
+ .tool-button:hover,
140
+ .send-button:hover,
141
+ .picker-mode-button:hover,
142
+ .picker-action-button:hover,
143
+ .attachment-remove:hover,
144
+ .attachment-preview-trigger:hover,
145
+ .attachment-preview__close:hover {
146
+ transform: translateY(-1px);
147
+ }
148
+
149
+ .tab-panel {
150
+ display: none;
151
+ gap: 18px;
152
+ animation: panel-in 180ms ease;
153
+ }
154
+
155
+ .tab-panel.is-active {
156
  display: grid;
 
 
157
  }
158
 
159
+ .editor-card,
160
+ .attachments-card,
161
+ .attachment-picker,
162
+ .settings-panel,
163
+ .output-card {
164
+ padding: 18px;
165
+ }
166
+
167
+ .editor-card {
168
  display: grid;
169
+ gap: 14px;
170
  }
171
 
172
+ .editor-toolbar,
173
+ .tool-row {
174
+ display: flex;
175
+ gap: 10px;
176
+ flex-wrap: nowrap;
 
177
  }
178
 
179
+ .format-button,
180
+ .tool-button,
181
+ .send-button {
182
+ min-height: 48px;
 
 
183
  border-radius: 18px;
184
+ padding: 0 16px;
185
+ display: inline-flex;
186
+ align-items: center;
187
+ justify-content: center;
188
+ gap: 10px;
189
+ transition: transform 160ms ease, opacity 160ms ease, background 160ms ease;
190
  }
191
 
192
+ .format-button {
193
+ min-width: 70px;
 
 
 
 
194
  }
195
 
196
+ .tool-row {
197
+ align-items: center;
198
+ }
199
+
200
+ .icon-button {
201
+ width: 48px;
202
+ min-width: 48px;
203
+ padding: 0;
204
+ flex: 0 0 48px;
205
+ }
206
+
207
+ .tool-button svg,
208
+ .send-button svg {
209
+ width: 20px;
210
+ height: 20px;
211
+ fill: none;
212
+ stroke: currentColor;
213
+ stroke-width: 1.8;
214
+ stroke-linecap: round;
215
+ stroke-linejoin: round;
216
+ }
217
+
218
+ .tool-button.is-active {
219
+ border-color: var(--line-strong);
220
+ background: var(--accent-soft);
221
+ }
222
+
223
+ .send-button {
224
+ margin-left: auto;
225
+ color: #fff;
226
+ border-color: transparent;
227
+ background: linear-gradient(135deg, var(--accent), var(--accent-deep));
228
+ }
229
+
230
+ .send-button:disabled {
231
+ opacity: 0.7;
232
+ cursor: progress;
233
+ }
234
+
235
+ .tool-row .send-button {
236
+ margin-left: auto;
237
+ }
238
+
239
+ .rich-editor {
240
+ min-height: 320px;
241
+ padding: 22px;
242
+ border-radius: 22px;
243
+ border: 1px solid var(--line);
244
+ background:
245
+ linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(255, 251, 246, 0.95)),
246
+ repeating-linear-gradient(180deg, transparent, transparent 31px, rgba(70, 49, 28, 0.07) 32px);
247
+ outline: none;
248
+ font-size: 1rem;
249
+ line-height: 1.75;
250
  }
251
 
252
+ .rich-editor:empty::before {
253
+ content: attr(data-placeholder);
254
+ color: rgba(108, 92, 80, 0.8);
255
+ }
256
+
257
+ .attachments-card,
258
+ .attachment-picker,
259
+ .settings-panel {
260
+ display: grid;
261
+ gap: 16px;
262
+ }
263
+
264
+ .attachment-list {
265
  display: grid;
 
266
  gap: 12px;
267
  }
268
 
269
+ .picker-mode-row,
270
+ .picker-grid {
271
  display: grid;
272
  gap: 12px;
273
+ }
274
+
275
+ .picker-mode-row {
276
+ grid-template-columns: repeat(2, minmax(0, 1fr));
277
+ }
278
+
279
+ .picker-mode-button,
280
+ .picker-action-button {
281
+ min-height: 46px;
282
+ border-radius: 16px;
283
  border: 1px solid var(--line);
284
+ background: var(--surface-strong);
285
+ color: var(--ink);
286
+ }
287
+
288
+ .picker-mode-button.is-active {
289
+ border-color: var(--line-strong);
290
+ background: var(--accent-soft);
291
  }
292
 
293
+ .picker-panel {
294
  display: none;
295
+ gap: 14px;
296
+ padding: 14px;
297
+ border-radius: 20px;
298
+ border: 1px solid var(--line);
299
+ background: var(--surface-soft);
300
  }
301
 
302
+ .picker-panel.is-active {
303
+ display: grid;
304
+ }
305
+
306
+ .picker-copy {
307
+ margin: 0;
308
+ color: var(--muted);
309
+ }
310
+
311
+ .picker-grid {
312
+ grid-template-columns: 180px minmax(0, 1fr);
313
+ }
314
+
315
+ .picker-link-field {
316
+ grid-column: auto;
317
+ }
318
+
319
+ .attachment-item {
320
+ display: grid;
321
+ grid-template-columns: auto 1fr auto;
322
+ gap: 14px;
323
  align-items: center;
324
+ padding: 12px;
325
+ border-radius: 22px;
 
326
  border: 1px solid var(--line);
327
+ background: var(--surface-soft);
328
+ }
329
+
330
+ .attachment-preview-trigger {
331
+ display: inline-flex;
332
+ align-items: center;
333
+ justify-content: center;
334
+ width: 78px;
335
+ height: 78px;
336
+ padding: 0;
337
  border-radius: 18px;
338
+ overflow: hidden;
339
  }
340
 
341
+ .attachment-thumb {
342
+ width: 78px;
343
+ height: 78px;
344
+ object-fit: cover;
345
+ }
346
+
347
+ .attachment-audio-tile {
348
+ display: grid;
349
+ gap: 6px;
350
+ text-align: center;
351
+ }
352
+
353
+ .attachment-audio-tile svg {
354
  width: 24px;
355
  height: 24px;
356
+ margin: 0 auto;
357
+ fill: none;
358
+ stroke: currentColor;
359
+ stroke-width: 1.8;
360
+ stroke-linecap: round;
361
+ stroke-linejoin: round;
362
  }
363
 
364
+ .attachment-audio-tile span {
365
+ font-size: 0.72rem;
366
+ text-transform: uppercase;
367
+ letter-spacing: 0.08em;
 
 
 
368
  }
369
 
370
+ .attachment-meta {
371
+ display: grid;
372
+ gap: 6px;
373
  }
374
 
375
+ .attachment-remove {
376
+ min-height: 42px;
377
+ padding: 0 16px;
378
+ border-radius: 14px;
379
  }
380
 
381
+ .attachment-preview {
382
+ position: fixed;
383
+ inset: 0;
384
+ z-index: 20;
385
+ display: grid;
386
+ place-items: center;
387
+ padding: 18px;
388
+ }
389
+
390
+ .attachment-preview__backdrop {
391
+ position: absolute;
392
+ inset: 0;
393
+ background: rgba(24, 18, 12, 0.58);
394
+ backdrop-filter: blur(4px);
395
+ }
396
+
397
+ .attachment-preview__card {
398
+ position: relative;
399
+ z-index: 1;
400
+ width: min(720px, 100%);
401
+ max-height: min(86vh, 920px);
402
+ padding: 20px;
403
+ border-radius: 28px;
404
+ border: 1px solid var(--line);
405
+ background: var(--surface);
406
+ box-shadow: var(--shadow);
407
+ display: grid;
408
+ gap: 18px;
409
  }
410
 
411
+ .attachment-preview__head {
412
  display: flex;
 
413
  justify-content: space-between;
414
+ gap: 16px;
415
+ align-items: start;
416
  }
417
 
418
+ .attachment-preview__close {
419
+ width: 42px;
420
+ height: 42px;
421
+ border-radius: 999px;
422
+ padding: 0;
423
+ }
424
+
425
+ .attachment-preview__body {
426
  display: grid;
427
+ place-items: center;
428
+ min-height: 280px;
429
+ }
430
+
431
+ .attachment-preview__image,
432
+ .attachment-preview__audio-wrap {
433
+ width: 100%;
434
+ }
435
+
436
+ .attachment-preview__image {
437
+ max-width: 100%;
438
+ max-height: 68vh;
439
+ object-fit: contain;
440
+ border-radius: 20px;
441
+ }
442
+
443
+ .attachment-preview__audio-wrap {
444
+ padding: 22px;
445
+ border-radius: 20px;
446
+ background: var(--surface-soft);
447
+ border: 1px solid var(--line);
448
+ }
449
+
450
+ .attachment-preview__audio {
451
+ width: 100%;
452
+ }
453
+
454
+ .settings-grid {
455
+ display: grid;
456
+ grid-template-columns: repeat(2, minmax(0, 1fr));
457
  gap: 14px;
 
 
 
458
  }
459
 
460
+ .settings-wide {
461
+ grid-column: 1 / -1;
462
+ }
463
+
464
+ label,
465
+ .toggle-card {
466
+ display: grid;
467
+ gap: 8px;
468
+ }
469
+
470
+ label span,
471
+ .toggle-card span {
472
+ font-size: 0.83rem;
473
+ letter-spacing: 0.08em;
474
+ text-transform: uppercase;
475
+ color: var(--muted);
476
+ }
477
+
478
+ input,
479
+ textarea,
480
+ select {
481
+ width: 100%;
482
+ padding: 14px 16px;
483
+ border-radius: 18px;
484
+ border: 1px solid var(--line);
485
+ color: var(--ink);
486
+ background: var(--surface-strong);
487
+ }
488
+
489
+ textarea {
490
+ resize: vertical;
491
+ }
492
+
493
+ .toggle-card {
494
+ align-items: center;
495
+ grid-template-columns: 1fr auto;
496
+ min-height: 56px;
497
+ padding: 0 16px;
498
+ border: 1px solid var(--line);
499
+ border-radius: 18px;
500
+ background: var(--surface-strong);
501
+ }
502
+
503
+ .toggle-card input {
504
+ width: 22px;
505
+ height: 22px;
506
+ }
507
+
508
+ .response-output {
509
+ display: grid;
510
+ gap: 14px;
511
+ }
512
+
513
+ .response-block {
514
  padding: 18px;
515
  border-radius: 22px;
516
  border: 1px solid var(--line);
517
+ background: var(--surface-soft);
518
  }
519
 
520
+ .response-block strong {
521
  display: inline-block;
522
+ margin-bottom: 12px;
523
  }
524
 
525
+ .response-image,
526
+ .response-audio {
527
  width: 100%;
528
+ margin-top: 14px;
529
+ border-radius: 18px;
530
+ }
531
+
532
+ .response-image {
533
+ max-height: 420px;
534
  object-fit: cover;
 
 
535
  }
536
 
537
+ .rich-response {
538
+ display: grid;
539
+ gap: 14px;
540
+ line-height: 1.65;
541
  }
542
 
543
+ .rich-response p,
544
+ .rich-response ul,
545
+ .rich-response pre {
546
  margin: 0;
547
+ }
548
+
549
+ .rich-response ul {
550
+ padding-left: 20px;
551
+ }
552
+
553
+ .rich-response pre,
554
+ .raw-output {
555
+ overflow: auto;
556
  border-radius: 20px;
557
  background: #171411;
558
  color: #f9f4ed;
559
  font-family: "IBM Plex Mono", Consolas, monospace;
560
+ }
561
+
562
+ .rich-response pre {
563
+ padding: 16px;
564
+ }
565
+
566
+ .rich-response code {
567
+ padding: 0.15em 0.35em;
568
+ border-radius: 8px;
569
+ background: rgba(31, 24, 18, 0.08);
570
+ font-family: "IBM Plex Mono", Consolas, monospace;
571
+ }
572
+
573
+ .raw-output {
574
+ margin: 0;
575
+ padding: 18px;
576
+ min-height: 420px;
577
  font-size: 0.92rem;
578
  }
579
 
 
589
  background: rgba(127, 29, 29, 0.96);
590
  box-shadow: var(--shadow);
591
  text-align: left;
 
592
  }
593
 
594
  .status-ok {
595
  color: var(--success);
596
  }
597
 
598
+ .status-busy {
599
+ color: var(--accent-deep);
600
+ }
601
+
602
+ .sr-only {
603
+ position: absolute;
604
+ width: 1px;
605
+ height: 1px;
606
+ padding: 0;
607
+ margin: -1px;
608
+ overflow: hidden;
609
+ clip: rect(0, 0, 0, 0);
610
+ white-space: nowrap;
611
+ border: 0;
612
+ }
613
+
614
+ @keyframes panel-in {
615
+ from {
616
+ opacity: 0;
617
+ transform: translateY(6px);
618
  }
619
 
620
+ to {
621
+ opacity: 1;
622
+ transform: translateY(0);
623
+ }
624
+ }
625
+
626
+ @media (max-width: 1040px) {
627
+ .workspace-card {
628
  min-height: auto;
 
629
  }
630
  }
631
 
632
+ @media (max-width: 720px) {
633
+ .client-shell {
634
  width: min(100% - 14px, 100%);
635
  padding-top: 8px;
636
  gap: 14px;
637
  }
638
 
639
+ .workspace-card,
640
+ .editor-card,
641
+ .attachments-card,
642
+ .attachment-picker,
643
+ .settings-panel,
644
+ .output-card {
645
+ padding: 16px;
646
  border-radius: 22px;
647
  }
648
 
649
+ .top-bar,
650
+ .section-head,
651
+ .attachment-item {
652
+ grid-template-columns: 1fr;
653
+ }
654
+
655
+ .top-bar,
656
+ .section-head {
657
+ display: grid;
658
+ }
659
+
660
+ .tab-strip {
661
+ justify-content: start;
662
+ }
663
+
664
+ .tool-row {
665
+ gap: 8px;
666
+ }
667
+
668
+ .settings-grid {
669
  grid-template-columns: 1fr;
670
  }
671
+
672
+ .picker-mode-row,
673
+ .picker-grid {
674
+ grid-template-columns: 1fr;
675
+ }
676
+
677
+ .attachment-item {
678
+ grid-template-columns: 1fr;
679
+ }
680
+
681
+ .tool-row .send-button {
682
+ margin-left: auto;
683
+ }
684
  }
public/sw-register.js CHANGED
@@ -4,12 +4,18 @@ export async function registerServiceWorker() {
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
  }
 
4
  }
5
 
6
  try {
7
+ const registrations = await navigator.serviceWorker.getRegistrations();
8
+ await Promise.all(registrations.map((registration) => registration.unregister()));
9
+
10
+ if ("caches" in window) {
11
+ const cacheNames = await window.caches.keys();
12
+ await Promise.all(
13
+ cacheNames
14
+ .filter((name) => name.startsWith("oapix-static"))
15
+ .map((name) => window.caches.delete(name))
16
+ );
17
+ }
18
  } catch (error) {
19
+ console.error("service worker cleanup failed", error);
20
  }
21
  }
public/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = "oapix-static-v2";
2
  const STATIC_ASSETS = [
3
  "/",
4
  "/styles.css",
 
1
+ const CACHE_NAME = "oapix-static-v3";
2
  const STATIC_ASSETS = [
3
  "/",
4
  "/styles.css",