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
CI / build (push) Waiting to run
Closes #1 Closes #2 Closes #3 Closes #4 Closes #5
This commit is contained in:
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user