1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-09-09 01:09:54 +00:00

10 Commits

Author SHA1 Message Date
Laura
225cf59b4e improvements 2025-08-28 16:37:48 +02:00
Laura
f14faa11f2 show tool call cost 2025-08-28 15:00:02 +02:00
Laura
98c6976dfa include exa cost 2025-08-28 14:46:28 +02:00
Laura
b331920634 cleanup 2025-08-28 14:36:52 +02:00
ca5693b08a fix 2025-08-28 01:24:42 +02:00
Laura
26ad8698b7 show retry btn 2025-08-27 17:24:57 +02:00
Laura
5dbb0b0815 small fix 2025-08-27 17:24:08 +02:00
Laura
5479286595 fix 2025-08-27 13:04:55 +02:00
36cc50e90b notes 2025-08-26 03:04:54 +02:00
7d48984703 notes 2025-08-26 02:29:21 +02:00
18 changed files with 326 additions and 100 deletions

View File

@@ -32,6 +32,7 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode
## TODO
- improved custom prompts
- settings
- auto-retry on edit
- ctrl+enter vs enter for sending

35
chat.go
View File

@@ -14,11 +14,12 @@ import (
)
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Args string `json:"args"`
Result string `json:"result,omitempty"`
Done bool `json:"done,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Args string `json:"args"`
Result string `json:"result,omitempty"`
Done bool `json:"done,omitempty"`
Cost float64 `json:"cost,omitempty"`
}
type TextFile struct {
@@ -38,14 +39,24 @@ type Reasoning struct {
Tokens int `json:"tokens"`
}
type Tools struct {
JSON bool `json:"json"`
Search bool `json:"search"`
}
type Metadata struct {
Timezone string `json:"timezone"`
Platform string `json:"platform"`
}
type Request struct {
Prompt string `json:"prompt"`
Model string `json:"model"`
Temperature float64 `json:"temperature"`
Iterations int64 `json:"iterations"`
JSON bool `json:"json"`
Search bool `json:"search"`
Tools Tools `json:"tools"`
Reasoning Reasoning `json:"reasoning"`
Metadata Metadata `json:"metadata"`
Messages []Message `json:"messages"`
}
@@ -118,13 +129,13 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
}
}
if model.JSON && r.JSON {
if model.JSON && r.Tools.JSON {
request.ResponseFormat = &openrouter.ChatCompletionResponseFormat{
Type: openrouter.ChatCompletionResponseFormatTypeJSONObject,
}
}
prompt, err := BuildPrompt(r.Prompt, model)
prompt, err := BuildPrompt(r.Prompt, r.Metadata, model)
if err != nil {
return nil, err
}
@@ -133,7 +144,7 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
request.Messages = append(request.Messages, openrouter.SystemMessage(prompt))
}
if model.Tools && r.Search && env.Tokens.Exa != "" {
if model.Tools && r.Tools.Search && env.Tokens.Exa != "" {
request.Tools = GetSearchTools()
request.ToolChoice = "auto"
@@ -409,6 +420,10 @@ func SplitImagePairs(text string) []openrouter.ChatMessagePart {
)
push := func(str, end int) {
if str > end {
return
}
rest := text[str:end]
if rest == "" {

View File

@@ -5,6 +5,7 @@ import "strings"
var cleaner = strings.NewReplacer(
"", "-",
"—", "-",
"", "-",
"“", "\"",
"”", "\"",

5
exa.go
View File

@@ -18,9 +18,14 @@ type ExaResult struct {
Summary string `json:"summary"`
}
type ExaCost struct {
Total float64 `json:"total"`
}
type ExaResults struct {
RequestID string `json:"requestId"`
Results []ExaResult `json:"results"`
Cost ExaCost `json:"costDollars"`
}
func (e *ExaResult) String() string {

View File

@@ -15,9 +15,10 @@ import (
)
type PromptData struct {
Name string
Slug string
Date string
Name string
Slug string
Date string
Platform string
}
type Prompt struct {
@@ -87,7 +88,7 @@ func LoadPrompts() ([]Prompt, error) {
prompt := Prompt{
Key: strings.Replace(filepath.Base(path), ".txt", "", 1),
Name: strings.TrimSpace(string(body[:index])),
Text: strings.TrimSpace(string(body[:index+3])),
Text: strings.TrimSpace(string(body[index+3:])),
}
prompts = append(prompts, prompt)
@@ -110,7 +111,7 @@ func LoadPrompts() ([]Prompt, error) {
return prompts, nil
}
func BuildPrompt(name string, model *Model) (string, error) {
func BuildPrompt(name string, metadata Metadata, model *Model) (string, error) {
if name == "" {
return "", nil
}
@@ -120,12 +121,26 @@ func BuildPrompt(name string, model *Model) (string, error) {
return "", fmt.Errorf("unknown prompt: %q", name)
}
tz := time.UTC
if metadata.Timezone != "" {
parsed, err := time.LoadLocation(metadata.Timezone)
if err == nil {
tz = parsed
}
}
if metadata.Platform == "" {
metadata.Platform = "Unknown"
}
var buf bytes.Buffer
err := tmpl.Execute(&buf, PromptData{
Name: model.Name,
Slug: model.ID,
Date: time.Now().Format(time.RFC1123),
Name: model.Name,
Slug: model.ID,
Date: time.Now().In(tz).Format(time.RFC1123),
Platform: metadata.Platform,
})
if err != nil {

View File

@@ -1,6 +1,6 @@
Data Analyst
---
You are {{ .Name }} ({{ .Slug }}), an expert data analyst who transforms raw data into clear, actionable insights. Today is {{ .Date }}.
You are {{ .Name }} ({{ .Slug }}), an expert data analyst who transforms raw data into clear, actionable insights. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`.
## Role & Expertise
- **Primary Role**: Data analyst with expertise in statistical analysis, pattern recognition, and business intelligence

View File

@@ -1,6 +1,6 @@
Prompt Engineer
---
You are {{ .Name }} ({{ .Slug }}), an expert prompt engineering specialist who designs, optimizes, and troubleshoots prompts for maximum AI effectiveness. Today is {{ .Date }}.
You are {{ .Name }} ({{ .Slug }}), an expert prompt engineering specialist who designs, optimizes, and troubleshoots prompts for maximum AI effectiveness. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`.
## Role & Expertise
- **Primary Role**: Senior prompt engineer with deep knowledge of LLM behavior, cognitive architectures, and optimization techniques

View File

@@ -1,6 +1,6 @@
Assistant
---
You are {{ .Name }} ({{ .Slug }}), a versatile AI assistant designed to help users accomplish diverse tasks efficiently and accurately. Today is {{ .Date }}.
You are {{ .Name }} ({{ .Slug }}), a versatile AI assistant designed to help users accomplish diverse tasks efficiently and accurately. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`.
## Core Identity & Approach
- **Role**: General-purpose AI assistant with broad knowledge and problem-solving capabilities

View File

@@ -1,6 +1,6 @@
Physics Explainer
---
You are {{ .Name }} ({{ .Slug }}), a physics educator who makes complex concepts accessible without sacrificing accuracy. Today is {{ .Date }}.
You are {{ .Name }} ({{ .Slug }}), a physics educator who makes complex concepts accessible without sacrificing accuracy. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`.
## Role & Expertise
- **Primary Role**: Physics educator with deep conceptual understanding and exceptional communication skills

View File

@@ -1,6 +1,6 @@
Research Assistant
---
You are {{ .Name }} ({{ .Slug }}), a methodical AI research specialist who conducts systematic information gathering and synthesis to provide comprehensive, evidence-based answers. Today is {{ .Date }}.
You are {{ .Name }} ({{ .Slug }}), a methodical AI research specialist who conducts systematic information gathering and synthesis to provide comprehensive, evidence-based answers. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`.
## Role & Expertise
- **Primary Role**: Research methodologist skilled in systematic information gathering, source evaluation, and evidence synthesis

View File

@@ -1,6 +1,6 @@
Code Reviewer
---
You are {{ .Name }} ({{ .Slug }}), an expert code security and quality analyst specializing in production-ready code assessment. Today is {{ .Date }}.
You are {{ .Name }} ({{ .Slug }}), an expert code security and quality analyst specializing in production-ready code assessment. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`.
## Role & Expertise
- **Primary Role**: Senior code reviewer with deep expertise in security vulnerabilities, performance optimization, and maintainable code practices

View File

@@ -1,6 +1,6 @@
Shell Scripter
---
You are {{ .Name }} ({{ .Slug }}), an expert automation engineer specializing in robust shell scripting and system automation. Today is {{ .Date }}.
You are {{ .Name }} ({{ .Slug }}), an expert automation engineer specializing in robust shell scripting and system automation. Today is {{ .Date }} (in the user's timezone). The users platform is `{{ .Platform }}`.
## Role & Expertise
- **Primary Role**: Senior DevOps engineer and automation specialist with deep expertise in Bash, PowerShell, and cross-platform scripting

View File

@@ -116,6 +116,8 @@ func HandleSearchWebTool(ctx context.Context, tool *ToolCall) error {
return nil
}
tool.Cost = results.Cost.Total
if len(results.Results) == 0 {
tool.Result = "error: no search results"
@@ -146,6 +148,8 @@ func HandleFetchContentsTool(ctx context.Context, tool *ToolCall) error {
return nil
}
tool.Cost = results.Cost.Total
if len(results.Results) == 0 {
tool.Result = "error: no search results"

View File

@@ -131,6 +131,7 @@ body:not(.loading) #loading {
position: absolute;
top: 15px;
right: 20px;
z-index: 45;
}
.notification {
@@ -347,34 +348,24 @@ body:not(.loading) #loading {
max-width: 800px;
}
.message .reasoning,
.message .tool,
.message .text {
display: block;
background: transparent;
padding: 10px 12px;
width: 100%;
}
.message .reasoning {
padding-top: 14px;
}
.message:not(.editing) textarea.text,
.message.editing div.text {
display: none;
}
.message .reasoning,
.message .tool,
.message div.text {
background: #24273a;
}
.message textarea.text {
display: block;
background: #181926;
min-width: 480px;
min-height: 100px;
max-width: 100%;
padding: 10px 12px;
width: calc(700px - 24px);
border-radius: 2px;
}
.message.assistant textarea.text {
width: calc(800px - 24px);
}
.message .text .error {
@@ -394,21 +385,7 @@ body:not(.loading) #loading {
background: #1e2030;
border-radius: 6px;
padding: 10px 12px;
}
.message .reasoning-wrapper {
--height: auto;
height: calc(var(--height) + 20px);
overflow: hidden;
transition: 150ms;
}
.message:not(.expanded) .reasoning-wrapper {
height: 0;
}
.message.expanded .reasoning-text {
margin-top: 10px;
margin-top: 16px;
}
.message.has-reasoning:not(.has-text):not(.errored) div.text,
@@ -420,13 +397,30 @@ body:not(.loading) #loading {
}
.message .body {
position: relative;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
overflow: hidden;
padding: 14px 12px;
display: flex;
flex-direction: column;
gap: 12px;
background: #24273a;
}
.message.has-reasoning .text {
padding-top: 4px;
.message.collapsed .body {
height: 32px;
}
.message.collapsed .body::before {
position: absolute;
content: "collapsed...";
font-style: italic;
color: #939ab7;
font-size: 12px;
top: 50%;
left: 12px;
transform: translateY(-50%);
}
.tool .call,
@@ -446,7 +440,7 @@ body:not(.loading) #loading {
background-image: url(icons/reasoning.svg);
position: absolute;
top: -2px;
left: -2px;
left: 0px;
width: 20px;
height: 20px;
}
@@ -459,23 +453,25 @@ body:not(.loading) #loading {
transition: 150ms;
}
.message.expanded .reasoning .toggle::after {
transform: rotate(180deg);
}
.message.has-tool .text {
padding-bottom: 4px;
}
.message .reasoning,
.message .tool {
--height: 0px;
overflow: hidden;
transition: 150ms;
height: calc(90px + var(--height));
height: calc(40px + 16px + var(--height));
position: relative;
}
.message .reasoning {
height: calc(18px + 16px + var(--height));
}
.message .reasoning:not(.expanded) {
height: 18px;
}
.message .tool:not(.expanded) {
height: 62px;
height: 40px;
}
.tool .call {
@@ -505,8 +501,12 @@ body:not(.loading) #loading {
right: -22px;
}
.reasoning.expanded .toggle::after {
transform: scaleY(-100%);
}
.tool.expanded .call .name::after {
transform: translateY(-50%) rotate(180deg);
transform: translateY(-50%) scaleY(-100%);
}
.tool .call::before {
@@ -518,8 +518,19 @@ body:not(.loading) #loading {
width: max-content;
}
.message .tool .result {
margin-top: 16px;
.tool .cost {
position: absolute;
top: 2px;
right: 2px;
font-size: 12px;
font-style: italic;
color: #a5adcb;
transition: 150ms opacity;
opacity: 0;
}
.tool:hover .cost {
opacity: 1;
}
.message .options {
@@ -538,9 +549,31 @@ body:not(.loading) #loading {
pointer-events: all;
}
.message .collapse {
position: relative;
margin-right: 14px;
}
.message .collapse::before {
content: "";
transition: 150ms;
position: absolute;
top: 0;
left: 0;
}
.message.collapsed .collapse::before {
transform: scaleY(-100%);
}
.message .collapse::after {
position: absolute;
top: 4px;
right: -14px;
}
.message.errored .options .copy,
.message.errored .options .edit,
.message.errored .options .retry,
.message.waiting .options,
.message.reasoning .options,
.message.tooling .options,
@@ -780,6 +813,7 @@ select {
gap: 4px;
}
.message .options .collapse::after,
#chat .option+.option::before {
content: "";
display: block;
@@ -803,6 +837,8 @@ body.loading #version,
.message .role::before,
.message .tag-json,
.message .tag-search,
.message .collapse,
.message .collapse::before,
.message .copy,
.message .edit,
.message .retry,
@@ -850,6 +886,10 @@ input.invalid {
border: 1px solid #ed8796;
}
.message .collapse::before {
background-image: url(icons/collapse.svg);
}
.pre-copy,
.message .copy {
background-image: url(icons/copy.svg);

View File

@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>

After

Width:  |  Height:  |  Size: 567 B

View File

@@ -72,6 +72,10 @@
text-decoration-color: rgba(183, 189, 248, 0.6);
}
.markdown p {
white-space: pre-wrap;
}
.markdown img {
max-width: 100%;
border-radius: 6px;

View File

@@ -34,6 +34,16 @@
$password = document.getElementById("password"),
$login = document.getElementById("login");
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
let platform = "";
detectPlatform().then(result => {
platform = result;
console.info(`Detected platform: ${platform}`);
});
const messages = [],
models = {},
modelList = [],
@@ -143,7 +153,6 @@
#error = false;
#editing = false;
#expanded = false;
#state = false;
#_diff;
@@ -159,7 +168,7 @@
#_tool;
#_statistics;
constructor(role, reasoning, text, files = []) {
constructor(role, reasoning, text, files = [], collapsed = false) {
this.#id = uid();
this.#role = role;
this.#reasoning = reasoning || "";
@@ -167,7 +176,7 @@
this.#_diff = document.createElement("div");
this.#build();
this.#build(collapsed);
this.#render();
for (const file of files) {
@@ -181,9 +190,9 @@
}
}
#build() {
#build(collapsed) {
// main message div
this.#_message = make("div", "message", this.#role);
this.#_message = make("div", "message", this.#role, collapsed ? "collapsed" : "");
// message role (wrapper)
const _wrapper = make("div", "role", this.#role);
@@ -224,26 +233,19 @@
_reasoning.appendChild(_toggle);
_toggle.addEventListener("click", () => {
this.#expanded = !this.#expanded;
_reasoning.classList.toggle("expanded");
this.#_message.classList.toggle("expanded", this.#expanded);
if (this.#expanded) {
if (_reasoning.classList.contains("expanded")) {
this.#updateReasoningHeight();
}
updateScrollButton();
});
// message reasoning (height wrapper)
const _height = make("div", "reasoning-wrapper");
_reasoning.appendChild(_height);
// message reasoning (content)
this.#_reasoning = make("div", "reasoning-text", "markdown");
_height.appendChild(this.#_reasoning);
_reasoning.appendChild(this.#_reasoning);
// message content
this.#_text = make("div", "text", "markdown");
@@ -291,6 +293,13 @@
_call.appendChild(_callArguments);
// tool call cost
const _callCost = make("div", "cost");
_callCost.title = "Cost of this tool call";
this.#_tool.appendChild(_callCost);
// tool call result
const _callResult = make("div", "result", "markdown");
@@ -301,6 +310,19 @@
this.#_message.appendChild(_opts);
// collapse option
const _optCollapse = make("button", "collapse");
_optCollapse.title = "Collapse/Expand message";
_opts.appendChild(_optCollapse);
_optCollapse.addEventListener("click", () => {
this.#_message.classList.toggle("collapsed");
this.#save();
});
// copy option
const _optCopy = make("button", "copy");
@@ -471,10 +493,11 @@
if (!only || only === "tool") {
if (this.#tool) {
const { name, args, result } = this.#tool;
const { name, args, result, cost } = this.#tool;
const _name = this.#_tool.querySelector(".name"),
_arguments = this.#_tool.querySelector(".arguments"),
_cost = this.#_tool.querySelector(".cost"),
_result = this.#_tool.querySelector(".result");
_name.title = `Show ${name} call result`;
@@ -483,6 +506,8 @@
_arguments.title = args;
_arguments.textContent = args;
_cost.textContent = cost ? `${formatMoney(cost)}` : "";
_result.innerHTML = render(result || "*processing*");
this.#_tool.setAttribute("data-tool", name);
@@ -615,6 +640,10 @@
data.statistics = this.#statistics;
}
if (this.#_message.classList.contains("collapsed") && full) {
data.collapsed = true;
}
if (!data.files?.length && !data.reasoning && !data.text && !data.tool) {
return false;
}
@@ -651,7 +680,7 @@
console.error(err);
if (!retrying && err.message.includes("not found")) {
setTimeout(this.loadGenerationData.bind(this), 750, generationID, true);
setTimeout(this.loadGenerationData.bind(this), 1500, generationID, true);
}
}
}
@@ -772,12 +801,12 @@
activeMessage = this;
this.#_edit.value = this.#text;
this.#_edit.style.height = `${this.#_text.offsetHeight}px`;
this.#_edit.style.width = `${this.#_text.offsetWidth}px`;
this.#_edit.style.height = "";
this.setState("editing");
this.#_edit.style.height = `${Math.max(100, this.#_edit.scrollHeight)}px`;
this.#_edit.focus();
} else {
activeMessage = null;
@@ -905,6 +934,8 @@
chatController.abort();
if (cancel) {
$chat.classList.remove("completing");
return;
}
}
@@ -949,12 +980,18 @@
model: $model.value,
temperature: temperature,
iterations: iterations,
tools: {
json: jsonMode,
search: searchTool,
},
reasoning: {
effort: effort,
tokens: tokens || 0,
},
json: jsonMode,
search: searchTool,
metadata: {
timezone: timezone,
platform: platform,
},
messages: messages.map(message => message.getData()).filter(Boolean),
};
@@ -968,7 +1005,7 @@
message.setState(false);
if (!aborted) {
setTimeout(message.loadGenerationData.bind(message), 750, generationID);
setTimeout(message.loadGenerationData.bind(message), 1000, generationID);
}
message = null;
@@ -1003,6 +1040,8 @@
},
chunk => {
if (chunk === "aborted") {
chatController = null;
finish(true);
return;
@@ -1038,6 +1077,8 @@
message.setTool(chunk.text);
if (chunk.text.done) {
totalCost += chunk.text.cost || 0;
finish();
}
@@ -1247,7 +1288,7 @@
}
loadValue("messages", []).forEach(message => {
const obj = new Message(message.role, message.reasoning, message.text, message.files || []);
const obj = new Message(message.role, message.reasoning, message.text, message.files || [], message.collapsed);
if (message.error) {
obj.showError(message.error);

View File

@@ -45,6 +45,8 @@ function uid() {
}
function make(tag, ...classes) {
classes = classes.filter(Boolean);
const el = document.createElement(tag);
if (classes.length) {
@@ -222,3 +224,94 @@ function selectFile(accept, multiple, handler, onError = false) {
input.click();
});
}
async function detectPlatform() {
let os, arch;
let platform = navigator.platform || "";
if (navigator.userAgentData?.getHighEntropyValues) {
try {
const data = await navigator.userAgentData.getHighEntropyValues(["platform", "architecture"]);
platform = data.platform;
arch = data.architecture;
} catch {}
}
const ua = navigator.userAgent || "";
// Windows
if (/Windows NT 10\.0/.test(ua)) os = "Windows 10/11";
else if (/Windows NT 6\.3/.test(ua)) os = "Windows 8.1";
else if (/Windows NT 6\.2/.test(ua)) os = "Windows 8";
else if (/Windows NT 6\.1/.test(ua)) os = "Windows 7";
else if (/Windows NT 6\.0/.test(ua)) os = "Windows Vista";
else if (/Windows NT 5\.1/.test(ua)) os = "Windows XP";
else if (/Windows NT 5\.0/.test(ua)) os = "Windows 2000";
else if (/Windows NT 4\.0/.test(ua)) os = "Windows NT 4.0";
else if (/Win(98|95|16)/.test(ua)) os = "Windows (legacy)";
else if (/Windows/.test(ua)) os = "Windows (unknown version)";
// Mac OS
else if (/Mac OS X/.test(ua)) {
os = "macOS";
const match = ua.match(/Mac OS X ([0-9_]+)/);
if (match) {
os += ` ${match[1].replace(/_/g, ".")}`;
} else {
os += " (unknown version)";
}
}
// Chrome OS
else if (/CrOS/.test(ua)) {
os = "Chrome OS";
const match = ua.match(/CrOS [^ ]+ ([0-9.]+)/);
if (match) {
os += ` ${match[1]}`;
}
}
// Linux (special)
else if (/FreeBSD/.test(ua)) os = "FreeBSD";
else if (/OpenBSD/.test(ua)) os = "OpenBSD";
else if (/NetBSD/.test(ua)) os = "NetBSD";
else if (/SunOS/.test(ua)) os = "Solaris";
// Linux (generic)
else if (/Linux/.test(ua)) {
if (/Ubuntu/i.test(ua)) os = "Ubuntu";
else if (/Debian/i.test(ua)) os = "Debian";
else if (/Fedora/i.test(ua)) os = "Fedora";
else if (/CentOS/i.test(ua)) os = "CentOS";
else if (/Red Hat/i.test(ua)) os = "Red Hat";
else if (/SUSE/i.test(ua)) os = "SUSE";
else if (/Gentoo/i.test(ua)) os = "Gentoo";
else if (/Arch/i.test(ua)) os = "Arch Linux";
else os = "Linux";
}
// Mobile
else if (/Android/.test(ua)) os = "Android";
else if (/iPhone|iPad|iPod/.test(ua)) os = "iOS";
// We still have no OS?
if (!os && platform) {
if (platform.includes("Win")) os = "Windows";
else if (/Mac/.test(platform)) os = "macOS";
else if (/Linux/.test(platform)) os = "Linux";
else os = platform;
}
// Detect architecture
if (!arch) {
if (/WOW64|Win64|x64|amd64/i.test(ua)) arch = "x64";
else if (/arm64|aarch64/i.test(ua)) arch = "arm64";
else if (/i[0-9]86|x86/i.test(ua)) arch = "x86";
else if (/ppc/i.test(ua)) arch = "ppc";
else if (/sparc/i.test(ua)) arch = "sparc";
else if (platform && /arm/i.test(platform)) arch = "arm";
}
return `${os || "Unknown OS"}${arch ? `, ${arch}` : ""}`;
}