fix: resolve Gitea issues #1–#5 (mobile layout, suggestion refresh, import/export icons, context rail default/icon, manual save)
CI / build (push) Waiting to run

Closes #1
Closes #2
Closes #3
Closes #4
Closes #5
This commit is contained in:
Schwitzz
2026-11-02 14:00:48 -06:00
parent 020563ed85
commit b01073eac0
11 changed files with 799 additions and 152 deletions
+6
View File
@@ -198,10 +198,16 @@ export function registerAuthRoutes(app: FastifyInstance): void {
const count = (
app.db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
).n;
const ua = String(req.headers["user-agent"] ?? "");
const chMobile = String(req.headers["sec-ch-ua-mobile"] ?? "");
const isMobileClient =
chMobile === "?1" ||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
return {
initialized: count > 0,
authenticated: Boolean(req.user),
user: req.user ?? null,
isMobileClient,
};
});
+121
View File
@@ -212,6 +212,18 @@ export async function buildApp() {
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
const ua = String(req.headers["user-agent"] ?? "");
const chMobile = String(req.headers["sec-ch-ua-mobile"] ?? "");
const isMobile =
chMobile === "?1" ||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
reply.header("X-PubScript-Client", isMobile ? "mobile" : "desktop");
reply.setCookie("ps_client_device", isMobile ? "mobile" : "desktop", {
path: "/",
httpOnly: false,
sameSite: "lax",
});
const cfg = loadPersistedServerSysConfig();
const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host;
if (!isRequestHostAllowed(forwardedHost, cfg.allowedHosts)) {
@@ -336,6 +348,11 @@ export async function buildApp() {
app.get("/api/system/server-config", async (req) => {
requireAuth(req);
const persisted = loadPersistedServerSysConfig();
const isMobileClient =
String(req.headers["sec-ch-ua-mobile"] ?? "") === "?1" ||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
String(req.headers["user-agent"] ?? ""),
);
return {
ok: true,
enabled: persisted.enabled,
@@ -350,9 +367,113 @@ export async function buildApp() {
projectDirectory: persisted.projectDirectory,
autostartPath: autostartDesktopPath,
systemdServicePath,
isMobileClient,
};
});
// Host-side AI proxy so remote/mobile browsers and local sessions can talk to
// Ollama / llama.cpp / LM Studio / OpenAI-compatible / Anthropic / Google
// endpoints running on or reachable from the Host Machine without CORS or
// remote-localhost errors.
app.post("/api/ai/chat", async (req, reply) => {
requireAuth(req);
const body = (req.body ?? {}) as {
kind?: string;
baseUrl?: string;
apiKey?: string;
model?: string;
messages?: Array<{ role: string; content: string }>;
temperature?: number;
maxTokens?: number;
};
const baseUrl = (body.baseUrl ?? "http://localhost:11434/v1").replace(/\/+$/, "");
const model = body.model || "llama3.2";
const messages = body.messages ?? [];
const temperature = body.temperature ?? 0.7;
const maxTokens = body.maxTokens ?? 2048;
try {
if (body.kind === "anthropic") {
const systemMsg = messages
.filter((m) => m.role === "system")
.map((m) => m.content)
.join("\n\n");
const chatMsgs = messages
.filter((m) => m.role !== "system")
.map((m) => ({
role: m.role === "assistant" ? "assistant" : "user",
content: m.content,
}));
const res = await fetch(`${baseUrl}/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
...(body.apiKey ? { "x-api-key": body.apiKey } : {}),
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model,
max_tokens: maxTokens,
temperature,
...(systemMsg ? { system: systemMsg } : {}),
messages: chatMsgs,
}),
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
return reply.code(res.status).send({ error: `Provider HTTP ${res.status}: ${errText}` });
}
const json = (await res.json()) as {
content?: Array<{ type?: string; text?: string; thinking?: string }>;
};
const text = (json.content ?? [])
.filter((c) => c.type === "text" || (!c.type && c.text))
.map((c) => c.text ?? "")
.join("");
const reasoning = (json.content ?? [])
.filter((c) => c.type === "thinking")
.map((c) => c.thinking ?? "")
.join("");
return { ok: true, text, ...(reasoning ? { reasoning } : {}) };
}
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (body.apiKey) {
headers.authorization = `Bearer ${body.apiKey}`;
}
const res = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model,
messages,
stream: false,
temperature,
max_tokens: maxTokens,
}),
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
return reply.code(res.status).send({ error: `Provider HTTP ${res.status}: ${errText}` });
}
const json = (await res.json()) as {
choices?: Array<{
message?: { content?: string; reasoning_content?: string; reasoning?: string };
}>;
};
const msg = json.choices?.[0]?.message;
const text = msg?.content ?? "";
const reasoning = msg?.reasoning_content ?? msg?.reasoning;
return { ok: true, text, ...(reasoning ? { reasoning } : {}) };
} catch (err) {
return reply.code(502).send({
error: err instanceof Error ? err.message : "Failed to reach AI provider from host",
});
}
});
app.post("/api/system/server-config", async (req, reply) => {
requireAdmin(req);
const body = (req.body ?? {}) as Partial<PersistedServerSysConfig>;
+9
View File
@@ -117,8 +117,17 @@ describe("admin endpoints", () => {
initialized: false,
authenticated: false,
user: null,
isMobileClient: false,
});
const mobileStatus = await app.inject({
method: "GET",
url: "/api/auth/status",
headers: { "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Mobile/15E148" },
});
expect(mobileStatus.headers["x-pubscript-client"]).toBe("mobile");
expect((mobileStatus.json() as { isMobileClient: boolean }).isMobileClient).toBe(true);
const setupRes = await app.inject({
method: "POST",
url: "/api/auth/setup",