Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,72 @@
# test-code
# Paper Signal Chat

Paper Signal Chat is a static browser client for OpenAI-compatible chat completion APIs. It was added in response to [issue #1](https://github.com/KKK985429/test-code/issues/1) so the repository has a concrete purpose, a usable interface, and clear run instructions.

## What It Includes

- A polished single-page chat interface
- Local message history for the current browser session
- Configurable API base URL, model, and API key fields
- `localStorage` persistence for connection settings
- Loading and error states for API requests
- A plain `fetch` request path to `/chat/completions`

## Project Structure

- `index.html`: app layout and font loading
- `styles.css`: visual system, layout, and responsive styling
- `app.js`: local state, persistence, and chat request logic

## Run Locally

Because this is a static app, any simple file server works.

### Option 1

```bash
python3 -m http.server 8080
```

Then open `http://localhost:8080`.

### Option 2

Use any static hosting service such as GitHub Pages, Netlify, or Vercel static hosting.

## Configure The API

Before sending messages, fill in:

- `API Base URL`: for example `https://api.openai.com/v1`
- `Model`: for example `gpt-4o-mini`
- `API Key`: your own key

The app stores these values in the browser only. No secrets are committed to the repository.

## Request Shape

The app sends a POST request to:

```text
<API Base URL>/chat/completions
```

Payload shape:

```json
{
"model": "your-model",
"messages": [
{ "role": "system", "content": "..." },
{ "role": "user", "content": "..." }
]
}
```

It expects an OpenAI-compatible JSON response and reads the first choice message content.

## Notes

- The app does not stream responses yet.
- The API key is intentionally handled only in the browser.
- If your provider requires a different path or custom headers, adjust `app.js`.
220 changes: 220 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
const STORAGE_KEY = "paper-signal-chat-settings";

const defaults = {
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini",
apiKey: "",
};

const state = {
messages: [
{
role: "system",
content:
"Ready. Add your own API base URL, model, and key, then start a conversation.",
},
],
loading: false,
error: "",
};

const elements = {
baseUrlInput: document.querySelector("#baseUrlInput"),
modelInput: document.querySelector("#modelInput"),
apiKeyInput: document.querySelector("#apiKeyInput"),
chatForm: document.querySelector("#chatForm"),
messageInput: document.querySelector("#messageInput"),
messageList: document.querySelector("#messageList"),
errorBanner: document.querySelector("#errorBanner"),
sendButton: document.querySelector("#sendButton"),
clearButton: document.querySelector("#clearButton"),
statusBadge: document.querySelector("#statusBadge"),
messageTemplate: document.querySelector("#messageTemplate"),
};

function loadSettings() {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...defaults };
return { ...defaults, ...JSON.parse(raw) };
} catch {
return { ...defaults };
}
}

function saveSettings() {
const payload = {
baseUrl: elements.baseUrlInput.value.trim(),
model: elements.modelInput.value.trim(),
apiKey: elements.apiKeyInput.value.trim(),
};
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}

function setError(message) {
state.error = message;
elements.errorBanner.hidden = !message;
elements.errorBanner.textContent = message;
renderStatus();
}

function renderStatus() {
elements.statusBadge.classList.remove("is-loading", "is-error");

if (state.loading) {
elements.statusBadge.textContent = "Sending";
elements.statusBadge.classList.add("is-loading");
return;
}

if (state.error) {
elements.statusBadge.textContent = "Error";
elements.statusBadge.classList.add("is-error");
return;
}

elements.statusBadge.textContent = "Idle";
}

function renderMessages() {
elements.messageList.textContent = "";

state.messages.forEach((message, index) => {
const fragment = elements.messageTemplate.content.cloneNode(true);
const item = fragment.querySelector(".message");
const role = fragment.querySelector(".message-role");
const ordinal = fragment.querySelector(".message-index");
const body = fragment.querySelector(".message-body");

item.dataset.role = message.role;
role.textContent = message.role;
ordinal.textContent = `#${String(index + 1).padStart(2, "0")}`;
body.textContent = message.content;

elements.messageList.appendChild(fragment);
});

elements.messageList.scrollTop = elements.messageList.scrollHeight;
}

function appendMessage(role, content) {
state.messages.push({ role, content });
renderMessages();
}

async function sendMessage(content) {
const settings = {
baseUrl: elements.baseUrlInput.value.trim().replace(/\/+$/, ""),
model: elements.modelInput.value.trim(),
apiKey: elements.apiKeyInput.value.trim(),
};

if (!settings.baseUrl || !settings.model || !settings.apiKey) {
setError("Fill in API Base URL, Model, and API Key before sending.");
return;
}

setError("");
state.loading = true;
elements.sendButton.disabled = true;
elements.clearButton.disabled = true;
renderStatus();

appendMessage("user", content);

try {
const response = await fetch(`${settings.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${settings.apiKey}`,
},
body: JSON.stringify({
model: settings.model,
messages: state.messages
.filter((message) => message.role !== "system" || message.content)
.map(({ role, content: text }) => ({ role, content: text })),
}),
});

const data = await response.json().catch(() => ({}));

if (!response.ok) {
const message =
data?.error?.message ||
data?.message ||
`Request failed with status ${response.status}.`;
throw new Error(message);
}

const assistantMessage =
data?.choices?.[0]?.message?.content ||
data?.choices?.[0]?.text ||
"";

if (!assistantMessage) {
throw new Error("The API returned no assistant message.");
}

appendMessage("assistant", assistantMessage);
} catch (error) {
const message =
error instanceof Error ? error.message : "Request failed unexpectedly.";
setError(message);
} finally {
state.loading = false;
elements.sendButton.disabled = false;
elements.clearButton.disabled = false;
renderStatus();
}
}

function resetConversation() {
state.messages = [
{
role: "system",
content:
"Conversation cleared. Your connection settings are still saved locally.",
},
];
setError("");
renderMessages();
}

function hydrateSettings() {
const settings = loadSettings();
elements.baseUrlInput.value = settings.baseUrl;
elements.modelInput.value = settings.model;
elements.apiKeyInput.value = settings.apiKey;
}

function bindEvents() {
[elements.baseUrlInput, elements.modelInput, elements.apiKeyInput].forEach(
(input) => {
input.addEventListener("input", saveSettings);
}
);

elements.chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
const content = elements.messageInput.value.trim();
if (!content || state.loading) return;

elements.messageInput.value = "";
await sendMessage(content);
});

elements.messageInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
elements.chatForm.requestSubmit();
}
});

elements.clearButton.addEventListener("click", resetConversation);
}

hydrateSettings();
bindEvents();
renderMessages();
renderStatus();
Loading