Spaces:
Runtime error
Runtime error
imrpove ui for chatclient
Browse files- doc/agent.md +66 -17
- doc/audio_completion.md +251 -0
- doc/overview.md +70 -51
- doc/update.md +8 -8
- doc/updates.md +117 -0
- public/chatclient/app.js +267 -43
- public/chatclient/draft.js +65 -0
- public/chatclient/index.html +201 -99
- public/chatclient/media.js +86 -42
- public/chatclient/preview.js +53 -0
- public/chatclient/render.js +122 -35
- public/chatclient/richText.js +93 -0
- public/chatclient/settings.js +38 -0
- public/chatclient/styles.css +531 -150
- public/sw-register.js +12 -6
- public/sw.js +1 -1
doc/agent.md
CHANGED
|
@@ -1,23 +1,72 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
## Current Task
|
| 4 |
|
| 5 |
-
|
| 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 |
-
#
|
| 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 |
-
#
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 7 |
-
- Verified with `npm test`, `npm run build`, and
|
| 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
|
| 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 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 4 |
|
| 5 |
-
- Start: 2026-03-
|
| 6 |
-
- End: 2026-03-
|
| 7 |
-
- Total: 00:
|
| 8 |
- By: codex/gpt5-codex
|
| 9 |
- Status: completed
|
| 10 |
|
| 11 |
-
-
|
| 12 |
-
- Added
|
| 13 |
-
- Added
|
| 14 |
-
- Verified with
|
|
|
|
| 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 {
|
| 3 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
const
|
|
|
|
|
|
|
| 6 |
const endpointInput = document.querySelector("#endpoint");
|
| 7 |
-
const
|
| 8 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const statusLine = document.querySelector("#status-line");
|
| 10 |
-
const
|
|
|
|
| 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
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
function
|
| 22 |
-
const
|
| 23 |
-
|
| 24 |
-
const kinds = field.dataset.kind.split(" ");
|
| 25 |
-
field.hidden = !kinds.includes(value);
|
| 26 |
}
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
-
|
| 32 |
-
event.
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
setStatus(statusLine, "Sending request...");
|
|
|
|
|
|
|
| 35 |
|
| 36 |
try {
|
| 37 |
-
const payload = await buildPayload(
|
| 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(
|
|
|
|
| 52 |
setStatus(statusLine, "Response received.", true);
|
| 53 |
} catch (error) {
|
| 54 |
-
|
| 55 |
-
showError(errorToast, error.message);
|
| 56 |
} finally {
|
| 57 |
-
|
| 58 |
}
|
| 59 |
}
|
| 60 |
|
| 61 |
-
async function buildPayload(
|
| 62 |
-
const
|
| 63 |
-
const userText = String(formData.get("userText") || "").trim();
|
| 64 |
-
const systemPrompt = String(formData.get("systemPrompt") || "").trim();
|
| 65 |
const content = [];
|
| 66 |
|
| 67 |
-
if (
|
| 68 |
-
content.push({ type: "text", text
|
| 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
|
| 77 |
}
|
| 78 |
|
| 79 |
const payload = {
|
| 80 |
-
model:
|
| 81 |
messages: []
|
| 82 |
};
|
| 83 |
|
| 84 |
-
if (
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
-
payload.messages.push({
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
if (
|
| 91 |
payload.audio = {
|
| 92 |
-
voice:
|
| 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="
|
| 12 |
-
<section class="
|
| 13 |
-
<
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
<
|
| 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 |
-
|
| 48 |
-
|
| 49 |
-
<
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
<
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</section>
|
| 73 |
|
| 74 |
-
<div class="
|
| 75 |
-
<
|
| 76 |
-
<
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
<
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
<
|
| 85 |
-
<
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
</div>
|
| 92 |
|
| 93 |
-
<
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
<
|
| 103 |
-
|
|
|
|
| 104 |
</div>
|
| 105 |
-
</div>
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
</div>
|
| 118 |
-
</
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
return { type: "image_url", image_url: { url } };
|
| 12 |
-
}
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
}
|
| 19 |
|
| 20 |
-
if (
|
| 21 |
-
|
| 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 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
-
if (
|
| 37 |
return {
|
| 38 |
-
type: "
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
format: inferAudioFormat(file)
|
| 42 |
}
|
| 43 |
};
|
| 44 |
}
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
-
function
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
return {
|
| 53 |
-
format: match[1].toLowerCase() === "wav" ? "wav" : "mp3",
|
| 54 |
-
data: match[2]
|
| 55 |
-
};
|
| 56 |
}
|
| 57 |
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
function inferAudioFormat(file) {
|
|
@@ -81,8 +109,24 @@ async function readFileAsBase64(file) {
|
|
| 81 |
return dataUrl.split(",")[1] || "";
|
| 82 |
}
|
| 83 |
|
| 84 |
-
function
|
| 85 |
-
if (
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
const assistant = responseBody?.choices?.[0]?.message ?? {};
|
| 3 |
const text = extractAssistantText(assistant);
|
| 4 |
-
const
|
| 5 |
const audioUrl = assistant?.audio?.url || null;
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
)
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
}));
|
| 20 |
-
messageList.appendChild(imageCard);
|
| 21 |
}
|
| 22 |
|
| 23 |
if (audioUrl) {
|
| 24 |
-
const audioCard =
|
| 25 |
const audio = document.createElement("audio");
|
| 26 |
audio.controls = true;
|
| 27 |
audio.src = audioUrl;
|
|
|
|
| 28 |
audioCard.appendChild(audio);
|
| 29 |
-
|
| 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
|
| 75 |
if (!Array.isArray(message.content)) {
|
| 76 |
-
return
|
| 77 |
}
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
return value;
|
| 83 |
-
}
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
return null;
|
| 87 |
}
|
| 88 |
|
| 89 |
-
function
|
| 90 |
const card = document.createElement("article");
|
| 91 |
-
card.className = "
|
| 92 |
card.innerHTML = `<strong>${escapeHtml(title)}</strong><p>${escapeHtml(body)}</p>`;
|
| 93 |
return card;
|
| 94 |
}
|
| 95 |
|
| 96 |
-
function
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("&", "&")
|
| 84 |
+
.replaceAll("<", "<")
|
| 85 |
+
.replaceAll(">", ">");
|
| 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: #
|
| 4 |
-
--surface: rgba(255, 250,
|
| 5 |
-
--surface-strong:
|
| 6 |
-
--
|
| 7 |
-
--
|
| 8 |
-
--
|
| 9 |
-
--
|
| 10 |
-
--
|
|
|
|
|
|
|
|
|
|
| 11 |
--success: #166534;
|
| 12 |
-
--shadow: 0 24px
|
| 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(
|
| 26 |
-
radial-gradient(circle at
|
| 27 |
-
linear-gradient(
|
| 28 |
}
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
margin: 0 auto;
|
| 36 |
-
padding: 18px 0
|
| 37 |
}
|
| 38 |
|
| 39 |
-
.
|
| 40 |
-
.
|
| 41 |
-
.
|
| 42 |
-
|
|
|
|
|
|
|
| 43 |
border: 1px solid var(--line);
|
| 44 |
border-radius: 28px;
|
|
|
|
| 45 |
box-shadow: var(--shadow);
|
| 46 |
-
backdrop-filter: blur(
|
| 47 |
}
|
| 48 |
|
| 49 |
-
.
|
| 50 |
padding: 24px;
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
.output-panel {
|
| 54 |
display: grid;
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
min-height: calc(100vh - 46px);
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
.result-card,
|
| 61 |
-
.json-card {
|
| 62 |
-
padding: 24px;
|
| 63 |
-
overflow: hidden;
|
| 64 |
}
|
| 65 |
|
| 66 |
-
.
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
text-decoration: none;
|
| 74 |
border: 1px solid var(--line);
|
| 75 |
-
background:
|
|
|
|
| 76 |
}
|
| 77 |
|
| 78 |
.eyebrow {
|
| 79 |
-
margin:
|
| 80 |
-
font-size: 0.
|
|
|
|
| 81 |
text-transform: uppercase;
|
| 82 |
-
letter-spacing: 0.22em;
|
| 83 |
color: var(--accent-deep);
|
| 84 |
}
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
margin: 0;
|
| 89 |
font-family: "Avenir Next", "Space Grotesk", sans-serif;
|
| 90 |
}
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
| 96 |
}
|
| 97 |
|
| 98 |
-
.
|
| 99 |
-
.
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
}
|
| 105 |
|
| 106 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
display: grid;
|
| 108 |
-
gap: 14px;
|
| 109 |
-
margin-top: 24px;
|
| 110 |
}
|
| 111 |
|
| 112 |
-
|
| 113 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
display: grid;
|
| 115 |
-
gap:
|
| 116 |
}
|
| 117 |
|
| 118 |
-
|
| 119 |
-
.
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
color: var(--muted);
|
| 124 |
}
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
width: 100%;
|
| 131 |
-
border: 1px solid var(--line);
|
| 132 |
border-radius: 18px;
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
select {
|
| 139 |
-
padding: 14px 16px;
|
| 140 |
-
color: var(--ink);
|
| 141 |
-
background: var(--surface-strong);
|
| 142 |
}
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
|
| 148 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
display: grid;
|
| 150 |
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 151 |
gap: 12px;
|
| 152 |
}
|
| 153 |
|
| 154 |
-
.
|
|
|
|
| 155 |
display: grid;
|
| 156 |
gap: 12px;
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
border: 1px solid var(--line);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
}
|
| 162 |
|
| 163 |
-
.
|
| 164 |
display: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
}
|
| 166 |
|
| 167 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
align-items: center;
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
padding: 0 16px;
|
| 172 |
border: 1px solid var(--line);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
border-radius: 18px;
|
| 174 |
-
|
| 175 |
}
|
| 176 |
|
| 177 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
width: 24px;
|
| 179 |
height: 24px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
}
|
| 181 |
|
| 182 |
-
.
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
|
| 187 |
-
cursor: pointer;
|
| 188 |
-
transition: transform 160ms ease, opacity 160ms ease;
|
| 189 |
}
|
| 190 |
|
| 191 |
-
.
|
| 192 |
-
|
| 193 |
-
|
| 194 |
}
|
| 195 |
|
| 196 |
-
.
|
| 197 |
-
|
|
|
|
|
|
|
| 198 |
}
|
| 199 |
|
| 200 |
-
.
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
}
|
| 203 |
|
| 204 |
-
.
|
| 205 |
display: flex;
|
| 206 |
-
align-items: center;
|
| 207 |
justify-content: space-between;
|
| 208 |
-
|
|
|
|
| 209 |
}
|
| 210 |
|
| 211 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
display: grid;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
gap: 14px;
|
| 214 |
-
max-height: 100%;
|
| 215 |
-
overflow: auto;
|
| 216 |
-
padding-right: 6px;
|
| 217 |
}
|
| 218 |
|
| 219 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
padding: 18px;
|
| 221 |
border-radius: 22px;
|
| 222 |
border: 1px solid var(--line);
|
| 223 |
-
background:
|
| 224 |
}
|
| 225 |
|
| 226 |
-
.
|
| 227 |
display: inline-block;
|
| 228 |
-
margin-bottom:
|
| 229 |
}
|
| 230 |
|
| 231 |
-
.
|
|
|
|
| 232 |
width: 100%;
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
object-fit: cover;
|
| 235 |
-
border-radius: 16px;
|
| 236 |
-
margin-top: 12px;
|
| 237 |
}
|
| 238 |
|
| 239 |
-
.
|
| 240 |
-
|
| 241 |
-
|
|
|
|
| 242 |
}
|
| 243 |
|
| 244 |
-
.
|
| 245 |
-
|
| 246 |
-
|
| 247 |
margin: 0;
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
}
|
| 279 |
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
min-height: auto;
|
| 282 |
-
grid-template-rows: auto 280px;
|
| 283 |
}
|
| 284 |
}
|
| 285 |
|
| 286 |
-
@media (max-width:
|
| 287 |
-
.
|
| 288 |
width: min(100% - 14px, 100%);
|
| 289 |
padding-top: 8px;
|
| 290 |
gap: 14px;
|
| 291 |
}
|
| 292 |
|
| 293 |
-
.
|
| 294 |
-
.
|
| 295 |
-
.
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
| 297 |
border-radius: 22px;
|
| 298 |
}
|
| 299 |
|
| 300 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
} catch (error) {
|
| 13 |
-
console.error("service worker
|
| 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-
|
| 2 |
const STATIC_ASSETS = [
|
| 3 |
"/",
|
| 4 |
"/styles.css",
|
|
|
|
| 1 |
+
const CACHE_NAME = "oapix-static-v3";
|
| 2 |
const STATIC_ASSETS = [
|
| 3 |
"/",
|
| 4 |
"/styles.css",
|