diff --git a/README.md b/README.md index d3809b0..c4cda04 100644 --- a/README.md +++ b/README.md @@ -1 +1,72 @@ -# test-code \ No newline at end of file +# 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 +/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`. diff --git a/app.js b/app.js new file mode 100644 index 0000000..e788491 --- /dev/null +++ b/app.js @@ -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(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..c878f9a --- /dev/null +++ b/index.html @@ -0,0 +1,146 @@ + + + + + + Paper Signal Chat + + + + + + + +
+ + +
+
+
+
+

Connection

+

Model Console

+
+ Idle +
+ +
+ + + + + +
+
+ +
+
+
+

Conversation

+

Signal Thread

+
+ +
+ + + +
    + +
    + + + +
    +
    +
    +
    + + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..9ccc40d --- /dev/null +++ b/styles.css @@ -0,0 +1,434 @@ +:root { + --bg: #f4efe2; + --bg-strong: #efe5d3; + --card: rgba(255, 252, 244, 0.86); + --card-border: rgba(46, 42, 34, 0.18); + --ink: #1f1a14; + --muted: #665c50; + --accent: #b64926; + --accent-deep: #7f2f16; + --signal: #17443d; + --shadow: 0 24px 80px rgba(64, 44, 14, 0.15); + --radius-xl: 32px; + --radius-lg: 22px; + --radius-md: 16px; + --grid-line: rgba(31, 26, 20, 0.08); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + color: var(--ink); + font-family: "Manrope", sans-serif; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.34), transparent 34%), + radial-gradient(circle at top left, rgba(182, 73, 38, 0.18), transparent 28%), + radial-gradient(circle at bottom right, rgba(23, 68, 61, 0.18), transparent 24%), + var(--bg); +} + +body::before { + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 32px 32px; + content: ""; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.45), transparent 80%); +} + +.page-shell { + display: grid; + grid-template-columns: 0.9fr 1.3fr; + gap: 24px; + min-height: 100vh; + padding: 28px; +} + +.info-panel, +.app-card { + position: relative; + overflow: hidden; + border: 1px solid var(--card-border); + border-radius: var(--radius-xl); + background: var(--card); + backdrop-filter: blur(12px); + box-shadow: var(--shadow); +} + +.info-panel { + padding: 30px; +} + +.info-panel::after, +.app-card::before { + position: absolute; + border-radius: 999px; + background: rgba(182, 73, 38, 0.08); + content: ""; + filter: blur(1px); +} + +.info-panel::after { + top: -50px; + right: -30px; + width: 220px; + height: 220px; +} + +.app-card::before { + bottom: -90px; + left: -50px; + width: 240px; + height: 240px; +} + +.eyebrow, +.section-label, +.hint, +kbd, +code, +.status-badge, +.message-index { + font-family: "IBM Plex Mono", monospace; +} + +.eyebrow, +.section-label { + margin: 0 0 10px; + color: var(--muted); + font-size: 0.82rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1, +h2 { + margin: 0; + font-family: "Fraunces", serif; + line-height: 0.95; +} + +h1 { + max-width: 9ch; + font-size: clamp(3rem, 6vw, 5.6rem); +} + +h2 { + font-size: clamp(2rem, 3vw, 2.8rem); +} + +.lede { + max-width: 34rem; + margin: 22px 0 32px; + color: var(--muted); + font-size: 1.04rem; + line-height: 1.7; +} + +.info-grid { + display: grid; + gap: 14px; +} + +.info-grid section { + border: 1px solid rgba(31, 26, 20, 0.1); + border-radius: var(--radius-lg); + padding: 16px 18px; + background: rgba(255, 255, 255, 0.42); +} + +.info-grid p:last-child { + margin: 0; + line-height: 1.6; +} + +.app-card { + display: grid; + grid-template-rows: auto 1fr; +} + +.control-panel, +.chat-panel { + padding: 26px; +} + +.control-panel { + border-bottom: 1px solid rgba(31, 26, 20, 0.1); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.6), transparent), + rgba(255, 248, 236, 0.56); +} + +.panel-header, +.chat-header, +.composer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 84px; + padding: 10px 14px; + border-radius: 999px; + background: rgba(23, 68, 61, 0.08); + color: var(--signal); + font-size: 0.8rem; +} + +.status-badge.is-loading { + background: rgba(182, 73, 38, 0.12); + color: var(--accent-deep); +} + +.status-badge.is-error { + background: rgba(182, 73, 38, 0.16); + color: var(--accent-deep); +} + +.config-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 22px; +} + +.field { + display: grid; + gap: 8px; +} + +.field span { + font-size: 0.95rem; + font-weight: 700; +} + +.field-wide { + grid-column: 1 / -1; +} + +input, +textarea, +.send-button, +.ghost-button { + border: 1px solid rgba(31, 26, 20, 0.14); + border-radius: var(--radius-md); + font: inherit; +} + +input, +textarea { + width: 100%; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.84); + color: var(--ink); + outline: none; + transition: + border-color 180ms ease, + box-shadow 180ms ease, + transform 180ms ease; +} + +input:focus, +textarea:focus { + border-color: rgba(182, 73, 38, 0.7); + box-shadow: 0 0 0 4px rgba(182, 73, 38, 0.12); + transform: translateY(-1px); +} + +textarea { + resize: vertical; + min-height: 132px; +} + +.chat-panel { + display: grid; + grid-template-rows: auto auto 1fr auto; + gap: 18px; + min-height: 0; +} + +.error-banner { + padding: 14px 16px; + border-radius: var(--radius-md); + background: rgba(182, 73, 38, 0.13); + color: var(--accent-deep); + font-weight: 600; +} + +.message-list { + display: grid; + gap: 14px; + min-height: 320px; + margin: 0; + padding: 0; + overflow: auto; + list-style: none; +} + +.message { + padding: 16px 18px; + border: 1px solid rgba(31, 26, 20, 0.1); + border-radius: 20px; + background: rgba(255, 255, 255, 0.7); +} + +.message[data-role="assistant"] { + background: + linear-gradient(135deg, rgba(23, 68, 61, 0.08), transparent 55%), + rgba(255, 255, 255, 0.8); +} + +.message[data-role="user"] { + background: + linear-gradient(135deg, rgba(182, 73, 38, 0.09), transparent 55%), + rgba(255, 249, 242, 0.88); +} + +.message[data-role="system"] { + border-style: dashed; +} + +.message-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.message-role { + font-weight: 800; + text-transform: capitalize; +} + +.message-index { + color: var(--muted); + font-size: 0.78rem; +} + +.message-body { + white-space: pre-wrap; + line-height: 1.7; + color: #2f2922; +} + +.composer { + display: grid; + gap: 14px; +} + +.composer-field { + margin: 0; +} + +.hint { + margin: 0; + color: var(--muted); + font-size: 0.8rem; +} + +kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.8em; + padding: 0.18em 0.45em; + border-radius: 999px; + background: rgba(31, 26, 20, 0.08); + font-size: 0.78rem; +} + +.send-button, +.ghost-button { + cursor: pointer; + transition: + transform 180ms ease, + background 180ms ease, + box-shadow 180ms ease; +} + +.send-button { + padding: 14px 20px; + background: var(--ink); + color: #fff8f0; + box-shadow: 0 12px 24px rgba(31, 26, 20, 0.16); +} + +.send-button:hover, +.send-button:focus-visible { + transform: translateY(-2px); + background: #352a1f; +} + +.ghost-button { + padding: 10px 14px; + background: rgba(255, 255, 255, 0.68); + color: var(--ink); +} + +.ghost-button:hover, +.ghost-button:focus-visible { + transform: translateY(-2px); + background: rgba(255, 255, 255, 0.94); +} + +.send-button:disabled, +.ghost-button:disabled { + cursor: wait; + opacity: 0.7; + transform: none; +} + +@media (max-width: 960px) { + .page-shell { + grid-template-columns: 1fr; + } + + .info-panel, + .app-card { + min-height: auto; + } +} + +@media (max-width: 640px) { + .page-shell { + padding: 14px; + } + + .control-panel, + .chat-panel, + .info-panel { + padding: 18px; + } + + .config-grid { + grid-template-columns: 1fr; + } + + .panel-header, + .chat-header, + .composer-footer { + flex-direction: column; + align-items: stretch; + } + + .message-list { + min-height: 260px; + } +}