ima2-gen 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +49 -2
- package/README.md +192 -152
- package/bin/commands/edit.js +1 -1
- package/bin/commands/gen.js +1 -1
- package/bin/ima2.js +15 -7
- package/bin/lib/star-prompt.js +97 -0
- package/config.js +167 -0
- package/lib/assetLifecycle.js +4 -3
- package/lib/db.js +11 -6
- package/lib/errorClassify.js +62 -0
- package/lib/historyList.js +66 -0
- package/lib/inflight.js +70 -6
- package/lib/logger.js +116 -0
- package/lib/nodeStore.js +3 -2
- package/lib/oauthLauncher.js +31 -0
- package/lib/oauthNormalize.js +30 -0
- package/lib/oauthProxy.js +271 -0
- package/lib/refs.js +35 -0
- package/lib/sessionStore.js +49 -0
- package/lib/styleSheet.js +128 -0
- package/package.json +4 -2
- package/routes/edit.js +171 -0
- package/routes/generate.js +254 -0
- package/routes/health.js +89 -0
- package/routes/history.js +102 -0
- package/routes/index.js +16 -0
- package/routes/nodes.js +272 -0
- package/routes/sessions.js +273 -0
- package/server.js +120 -1050
- package/ui/dist/assets/index-D1MUxZaB.js +16 -0
- package/ui/dist/assets/index-D1MUxZaB.js.map +1 -0
- package/ui/dist/assets/index-Jcs5Q1sj.css +1 -0
- package/ui/dist/index.html +18 -2
- package/ui/dist/assets/index-BlTTpUh8.js +0 -16
- package/ui/dist/assets/index-BlTTpUh8.js.map +0 -1
- package/ui/dist/assets/index-fsgUenJk.css +0 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { config } from "../../config.js";
|
|
7
|
+
|
|
8
|
+
const REPO = "lidge-jun/ima2-gen";
|
|
9
|
+
|
|
10
|
+
export function starPromptStatePath() {
|
|
11
|
+
return join(config.storage.configDir, "state", "star-prompt.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function hasBeenPrompted() {
|
|
15
|
+
const path = starPromptStatePath();
|
|
16
|
+
if (!existsSync(path)) return false;
|
|
17
|
+
try {
|
|
18
|
+
const content = await readFile(path, "utf8");
|
|
19
|
+
const state = JSON.parse(content);
|
|
20
|
+
return typeof state.prompted_at === "string";
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function markPrompted() {
|
|
27
|
+
const path = starPromptStatePath();
|
|
28
|
+
await mkdir(dirname(path), { recursive: true });
|
|
29
|
+
await writeFile(path, JSON.stringify({ prompted_at: new Date().toISOString() }, null, 2));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isGhInstalled() {
|
|
33
|
+
const result = spawnSync("gh", ["--version"], {
|
|
34
|
+
encoding: "utf8",
|
|
35
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
36
|
+
timeout: 3000,
|
|
37
|
+
windowsHide: true,
|
|
38
|
+
});
|
|
39
|
+
return !result.error && result.status === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function starRepo(spawnSyncFn = spawnSync) {
|
|
43
|
+
const result = spawnSyncFn("gh", ["api", "-X", "PUT", `/user/starred/${REPO}`], {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
46
|
+
timeout: 10000,
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (result.error) return { ok: false, error: result.error.message };
|
|
51
|
+
if (result.status !== 0) {
|
|
52
|
+
const stderr = (result.stderr || "").trim();
|
|
53
|
+
const stdout = (result.stdout || "").trim();
|
|
54
|
+
return { ok: false, error: stderr || stdout || `gh exited ${result.status}` };
|
|
55
|
+
}
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function askYesNo(question) {
|
|
60
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
61
|
+
try {
|
|
62
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
63
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
64
|
+
} finally {
|
|
65
|
+
rl.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function maybePromptGithubStar(deps = {}) {
|
|
70
|
+
const stdinIsTTY = deps.stdinIsTTY ?? process.stdin.isTTY;
|
|
71
|
+
const stdoutIsTTY = deps.stdoutIsTTY ?? process.stdout.isTTY;
|
|
72
|
+
if (!stdinIsTTY || !stdoutIsTTY) return;
|
|
73
|
+
|
|
74
|
+
const hasBeenPromptedImpl = deps.hasBeenPromptedFn ?? hasBeenPrompted;
|
|
75
|
+
if (await hasBeenPromptedImpl()) return;
|
|
76
|
+
|
|
77
|
+
const isGhInstalledImpl = deps.isGhInstalledFn ?? isGhInstalled;
|
|
78
|
+
if (!isGhInstalledImpl()) return;
|
|
79
|
+
|
|
80
|
+
const markPromptedImpl = deps.markPromptedFn ?? markPrompted;
|
|
81
|
+
await markPromptedImpl();
|
|
82
|
+
|
|
83
|
+
const askYesNoImpl = deps.askYesNoFn ?? askYesNo;
|
|
84
|
+
const approved = await askYesNoImpl("[ima2] Enjoying ima2-gen? Star it on GitHub? [Y/n] ");
|
|
85
|
+
if (!approved) return;
|
|
86
|
+
|
|
87
|
+
const starRepoImpl = deps.starRepoFn ?? starRepo;
|
|
88
|
+
const star = starRepoImpl();
|
|
89
|
+
if (star.ok) {
|
|
90
|
+
const log = deps.logFn ?? console.log;
|
|
91
|
+
log("[ima2] Thanks for the star!");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const warn = deps.warnFn ?? console.warn;
|
|
96
|
+
warn(`[ima2] Could not star repository automatically: ${star.error}`);
|
|
97
|
+
}
|
package/config.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// config.js — centralized runtime configuration (0.09.12).
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for ports, limits, paths, and tunables. All server,
|
|
4
|
+
// lib, and script code should import `config` (or named legacy constants) from
|
|
5
|
+
// here rather than reading `process.env` directly.
|
|
6
|
+
//
|
|
7
|
+
// Priority: env var > ${IMA2_CONFIG_DIR}/config.json > built-in default.
|
|
8
|
+
// `config.json` is loaded once at module import. Mutating the file at runtime
|
|
9
|
+
// requires a server restart (same as env vars).
|
|
10
|
+
//
|
|
11
|
+
// Keep this module dependency-free aside from node:* built-ins to avoid
|
|
12
|
+
// circular imports with lib/*.
|
|
13
|
+
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
const env = process.env;
|
|
20
|
+
const packageRoot = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const configDir = env.IMA2_CONFIG_DIR || join(homedir(), ".ima2");
|
|
22
|
+
|
|
23
|
+
// ── Optional config.json layer ─────────────────────────────────────────
|
|
24
|
+
// Users can drop `${configDir}/config.json` to override defaults without
|
|
25
|
+
// setting env vars. Shape: same as the `config` object below (partial).
|
|
26
|
+
function loadConfigJson() {
|
|
27
|
+
const candidates = [
|
|
28
|
+
join(configDir, "config.json"),
|
|
29
|
+
join(packageRoot, ".ima2", "config.json"),
|
|
30
|
+
];
|
|
31
|
+
for (const p of candidates) {
|
|
32
|
+
if (!existsSync(p)) continue;
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(p, "utf-8");
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
// ignore malformed config.json; env+defaults still apply
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
const fileCfg = loadConfigJson();
|
|
43
|
+
|
|
44
|
+
function firstDefined(...vals) {
|
|
45
|
+
return vals.find((v) => v !== undefined && v !== "");
|
|
46
|
+
}
|
|
47
|
+
function pickInt(envVal, fileVal, fallback) {
|
|
48
|
+
const candidate = firstDefined(envVal, fileVal);
|
|
49
|
+
if (candidate === undefined) return fallback;
|
|
50
|
+
const n = Number(candidate);
|
|
51
|
+
return Number.isFinite(n) ? n : fallback;
|
|
52
|
+
}
|
|
53
|
+
function pickStr(envVal, fileVal, fallback) {
|
|
54
|
+
return firstDefined(envVal, fileVal) ?? fallback;
|
|
55
|
+
}
|
|
56
|
+
function pickBool(envVal, fileVal, fallback) {
|
|
57
|
+
const v = firstDefined(envVal, fileVal);
|
|
58
|
+
if (v === undefined) return fallback;
|
|
59
|
+
if (typeof v === "boolean") return v;
|
|
60
|
+
const s = String(v).toLowerCase();
|
|
61
|
+
return s === "1" || s === "true" || s === "yes";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const config = {
|
|
65
|
+
server: {
|
|
66
|
+
// Accept both IMA2_PORT and legacy PORT.
|
|
67
|
+
port: pickInt(firstDefined(env.IMA2_PORT, env.PORT), fileCfg.server?.port, 3333),
|
|
68
|
+
host: pickStr(env.IMA2_HOST, fileCfg.server?.host, "127.0.0.1"),
|
|
69
|
+
bodyLimit: pickStr(env.IMA2_BODY_LIMIT, fileCfg.server?.bodyLimit, "50mb"),
|
|
70
|
+
},
|
|
71
|
+
limits: {
|
|
72
|
+
maxRefB64Bytes: pickInt(env.IMA2_MAX_REF_B64_BYTES, fileCfg.limits?.maxRefB64Bytes, 7 * 1024 * 1024),
|
|
73
|
+
maxRefCount: pickInt(env.IMA2_MAX_REF_COUNT, fileCfg.limits?.maxRefCount, 5),
|
|
74
|
+
maxParallel: pickInt(env.IMA2_MAX_PARALLEL, fileCfg.limits?.maxParallel, 8),
|
|
75
|
+
graphMaxNodes: pickInt(env.IMA2_GRAPH_MAX_NODES, fileCfg.limits?.graphMaxNodes, 500),
|
|
76
|
+
graphMaxEdges: pickInt(env.IMA2_GRAPH_MAX_EDGES, fileCfg.limits?.graphMaxEdges, 1000),
|
|
77
|
+
},
|
|
78
|
+
history: {
|
|
79
|
+
defaultPageSize: pickInt(
|
|
80
|
+
env.IMA2_HISTORY_PAGE_SIZE,
|
|
81
|
+
fileCfg.history?.defaultPageSize ?? fileCfg.limits?.historyDefaultPageSize,
|
|
82
|
+
50,
|
|
83
|
+
),
|
|
84
|
+
maxPageCap: pickInt(
|
|
85
|
+
env.IMA2_HISTORY_MAX_PAGE,
|
|
86
|
+
fileCfg.history?.maxPageCap ?? fileCfg.limits?.historyMaxPageCap,
|
|
87
|
+
500,
|
|
88
|
+
),
|
|
89
|
+
},
|
|
90
|
+
oauth: {
|
|
91
|
+
// Accept both IMA2_OAUTH_PROXY_PORT and legacy OAUTH_PORT.
|
|
92
|
+
proxyPort: pickInt(firstDefined(env.IMA2_OAUTH_PROXY_PORT, env.OAUTH_PORT), fileCfg.oauth?.proxyPort, 10531),
|
|
93
|
+
// IMA2_NO_OAUTH_PROXY=1 disables auto-start; default is auto-start enabled.
|
|
94
|
+
autoStart: !pickBool(env.IMA2_NO_OAUTH_PROXY, fileCfg.oauth?.disableAutoStart, false),
|
|
95
|
+
statusTimeoutMs: pickInt(env.IMA2_OAUTH_STATUS_TIMEOUT_MS, fileCfg.oauth?.statusTimeoutMs, 3000),
|
|
96
|
+
restartDelayMs: pickInt(env.IMA2_OAUTH_RESTART_DELAY_MS, fileCfg.oauth?.restartDelayMs, 5000),
|
|
97
|
+
researchSuffix: pickStr(
|
|
98
|
+
env.IMA2_RESEARCH_SUFFIX,
|
|
99
|
+
fileCfg.oauth?.researchSuffix,
|
|
100
|
+
"\n\nIf the subject matter requires factual accuracy (faces, products, places, recent events), search the web for accurate visual references first, then generate.",
|
|
101
|
+
),
|
|
102
|
+
validModeration: new Set(
|
|
103
|
+
Array.isArray(fileCfg.oauth?.validModeration) && fileCfg.oauth.validModeration.length
|
|
104
|
+
? fileCfg.oauth.validModeration
|
|
105
|
+
: ["auto", "low"],
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
storage: {
|
|
109
|
+
configDir,
|
|
110
|
+
packageRoot,
|
|
111
|
+
generatedDir: pickStr(env.IMA2_GENERATED_DIR, fileCfg.storage?.generatedDir, join(packageRoot, "generated")),
|
|
112
|
+
trashDir: pickStr(env.IMA2_TRASH_DIR, fileCfg.storage?.trashDir, join(packageRoot, "generated", ".trash")),
|
|
113
|
+
generatedDirName: pickStr(env.IMA2_GENERATED_DIRNAME, fileCfg.storage?.generatedDirName, "generated"),
|
|
114
|
+
trashDirName: pickStr(env.IMA2_TRASH_DIRNAME, fileCfg.storage?.trashDirName, ".trash"),
|
|
115
|
+
dbPath: pickStr(env.IMA2_DB_PATH, fileCfg.storage?.dbPath, join(configDir, "sessions.db")),
|
|
116
|
+
configFile: join(configDir, "config.json"),
|
|
117
|
+
advertiseFile: pickStr(env.IMA2_ADVERTISE_FILE, fileCfg.storage?.advertiseFile, join(configDir, "server.json")),
|
|
118
|
+
staticMaxAge: pickStr(env.IMA2_STATIC_MAX_AGE, fileCfg.storage?.staticMaxAge, "1y"),
|
|
119
|
+
},
|
|
120
|
+
ids: {
|
|
121
|
+
generatedHexBytes: pickInt(env.IMA2_GENERATED_HEX_BYTES, fileCfg.ids?.generatedHexBytes, 4),
|
|
122
|
+
nodeHexBytes: pickInt(env.IMA2_NODE_HEX_BYTES, fileCfg.ids?.nodeHexBytes, 5),
|
|
123
|
+
},
|
|
124
|
+
inflight: {
|
|
125
|
+
ttlMs: pickInt(env.IMA2_INFLIGHT_TTL_MS, fileCfg.inflight?.ttlMs, 10 * 60 * 1000),
|
|
126
|
+
reapMs: pickInt(env.IMA2_INFLIGHT_REAP_MS, fileCfg.inflight?.reapMs, 60 * 1000),
|
|
127
|
+
terminalTtlMs: pickInt(env.IMA2_INFLIGHT_TERMINAL_TTL_MS, fileCfg.inflight?.terminalTtlMs, 30 * 1000),
|
|
128
|
+
},
|
|
129
|
+
trash: {
|
|
130
|
+
ttlMs: pickInt(env.IMA2_TRASH_TTL_MS, fileCfg.trash?.ttlMs, 10_000),
|
|
131
|
+
},
|
|
132
|
+
styleSheet: {
|
|
133
|
+
maxPrefix: pickInt(env.IMA2_STYLE_SHEET_MAX_PREFIX, fileCfg.styleSheet?.maxPrefix, 4000),
|
|
134
|
+
model: pickStr(env.IMA2_STYLE_MODEL, fileCfg.styleSheet?.model, "gpt-5.4-mini"),
|
|
135
|
+
},
|
|
136
|
+
log: {
|
|
137
|
+
level: pickStr(env.IMA2_LOG_LEVEL, fileCfg.log?.level, "info"),
|
|
138
|
+
pretty: env.NODE_ENV !== "production",
|
|
139
|
+
},
|
|
140
|
+
dev: {
|
|
141
|
+
viteDevMode: pickBool(env.VITE_IMA2_DEV, fileCfg.dev?.viteDevMode, false),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export default config;
|
|
146
|
+
|
|
147
|
+
// ── Backward-compatible flat re-exports (used by lib/inflight.js & earlier
|
|
148
|
+
// call sites). Prefer `import { config } from "./config.js"` going forward.
|
|
149
|
+
export const PORT = config.server.port;
|
|
150
|
+
export const OAUTH_PORT = config.oauth.proxyPort;
|
|
151
|
+
export const OAUTH_URL = `http://127.0.0.1:${config.oauth.proxyPort}`;
|
|
152
|
+
export const CONFIG_DIR = config.storage.configDir;
|
|
153
|
+
export const CONFIG_FILE = config.storage.configFile;
|
|
154
|
+
export const ADVERTISE_FILE = config.storage.advertiseFile;
|
|
155
|
+
export const DB_FILE = config.storage.dbPath;
|
|
156
|
+
export const GENERATED_DIR = config.storage.generatedDir;
|
|
157
|
+
export const BODY_LIMIT = config.server.bodyLimit;
|
|
158
|
+
export const MAX_REF_B64_BYTES = config.limits.maxRefB64Bytes;
|
|
159
|
+
export const MAX_REFS = config.limits.maxRefCount;
|
|
160
|
+
export const MAX_N = config.limits.maxParallel;
|
|
161
|
+
export const INFLIGHT_TTL_MS = config.inflight.ttlMs;
|
|
162
|
+
export const INFLIGHT_REAP_MS = config.inflight.reapMs;
|
|
163
|
+
export const INFLIGHT_TERMINAL_TTL_MS = config.inflight.terminalTtlMs;
|
|
164
|
+
export const STYLE_SHEET_MAX_PREFIX = config.styleSheet.maxPrefix;
|
|
165
|
+
export const LOG_LEVEL = config.log.level;
|
|
166
|
+
export const NO_OAUTH_PROXY = !config.oauth.autoStart;
|
|
167
|
+
export const DEV_MODE = config.dev.viteDevMode;
|
package/lib/assetLifecycle.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { getDb } from "./db.js";
|
|
2
2
|
import { rename, unlink, mkdir, access } from "fs/promises";
|
|
3
3
|
import { join, resolve, sep } from "path";
|
|
4
|
+
import { config } from "../config.js";
|
|
4
5
|
|
|
5
|
-
const DIR =
|
|
6
|
-
const TRASH =
|
|
7
|
-
const TRASH_TTL_MS =
|
|
6
|
+
const DIR = config.storage.generatedDirName;
|
|
7
|
+
const TRASH = config.storage.trashDirName;
|
|
8
|
+
const TRASH_TTL_MS = config.trash.ttlMs;
|
|
8
9
|
|
|
9
10
|
function resolveInGenerated(rootDir, relPath) {
|
|
10
11
|
if (typeof relPath !== "string" || relPath.length === 0) {
|
package/lib/db.js
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
2
|
import { mkdirSync, existsSync } from "fs";
|
|
3
|
-
import { dirname
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
const DEFAULT_DB_DIR = join(homedir(), ".ima2");
|
|
7
|
-
const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, "sessions.db");
|
|
3
|
+
import { dirname } from "path";
|
|
4
|
+
import { config } from "../config.js";
|
|
8
5
|
|
|
9
6
|
let db = null;
|
|
10
7
|
|
|
11
8
|
export function getDbPath() {
|
|
12
|
-
return
|
|
9
|
+
return config.storage.dbPath;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
12
|
export function getDb() {
|
|
@@ -73,6 +70,14 @@ function migrate(database) {
|
|
|
73
70
|
"ALTER TABLE sessions ADD COLUMN graph_version INTEGER NOT NULL DEFAULT 0",
|
|
74
71
|
);
|
|
75
72
|
}
|
|
73
|
+
if (!sessionColumns.includes("style_sheet")) {
|
|
74
|
+
database.exec("ALTER TABLE sessions ADD COLUMN style_sheet TEXT");
|
|
75
|
+
}
|
|
76
|
+
if (!sessionColumns.includes("style_sheet_enabled")) {
|
|
77
|
+
database.exec(
|
|
78
|
+
"ALTER TABLE sessions ADD COLUMN style_sheet_enabled INTEGER NOT NULL DEFAULT 0",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
76
81
|
|
|
77
82
|
const row = database.prepare("SELECT value FROM _meta WHERE key = 'schema_version'").get();
|
|
78
83
|
if (!row) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// 0.09.8 — upstream error classifier.
|
|
2
|
+
// Pattern-match upstream OpenAI / OAuth / network errors into stable ImaErrorCode
|
|
3
|
+
// values so the UI can surface localized, actionable messages with CTAs.
|
|
4
|
+
|
|
5
|
+
/** @typedef {"REF_TOO_LARGE"|"REF_NOT_BASE64"|"REF_EMPTY"|"REF_TOO_MANY"|"MODERATION_REFUSED"|"UPSTREAM_5XX"|"AUTH_CHATGPT_EXPIRED"|"AUTH_API_KEY_INVALID"|"NETWORK_FAILED"|"OAUTH_UNAVAILABLE"|"INVALID_MODERATION"|"APIKEY_DISABLED"|"SAFETY_REFUSAL"|"DB_ERROR"|"UNKNOWN"} ImaErrorCode */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Classify an upstream error message into an ImaErrorCode.
|
|
9
|
+
* Order matters: auth session expiry must beat generic "token" matches,
|
|
10
|
+
* and moderation must beat generic 5xx.
|
|
11
|
+
* @param {string | undefined | null} msg
|
|
12
|
+
* @returns {ImaErrorCode}
|
|
13
|
+
*/
|
|
14
|
+
export function classifyUpstreamError(msg) {
|
|
15
|
+
const s = String(msg || "").toLowerCase();
|
|
16
|
+
if (!s) return "UNKNOWN";
|
|
17
|
+
|
|
18
|
+
if (s.includes("content generation refused") || s.includes("moderation_blocked") || s.includes("moderation refused")) {
|
|
19
|
+
return "MODERATION_REFUSED";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ChatGPT sign-in session expiry must precede the generic api-key checks
|
|
23
|
+
// so it is not misclassified when messages contain both "token" and "api".
|
|
24
|
+
if (
|
|
25
|
+
s.includes("token is expired") ||
|
|
26
|
+
s.includes("sign in again") ||
|
|
27
|
+
(s.includes("access token") && s.includes("expired")) ||
|
|
28
|
+
(s.includes("token") && s.includes("expired") && !s.includes("api key"))
|
|
29
|
+
) {
|
|
30
|
+
return "AUTH_CHATGPT_EXPIRED";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
s.includes("incorrect api key") ||
|
|
35
|
+
s.includes("invalid authentication") ||
|
|
36
|
+
s.includes("exceeded your current quota") ||
|
|
37
|
+
s.includes("incorrect organization")
|
|
38
|
+
) {
|
|
39
|
+
return "AUTH_API_KEY_INVALID";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
s.includes("failed to fetch") ||
|
|
44
|
+
s.includes("econnrefused") ||
|
|
45
|
+
s.includes("econnreset") ||
|
|
46
|
+
s.includes("enotfound") ||
|
|
47
|
+
s.includes("etimedout") ||
|
|
48
|
+
s.includes("network error")
|
|
49
|
+
) {
|
|
50
|
+
return "NETWORK_FAILED";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (s.includes("oauth") && (s.includes("not running") || s.includes("unavailable"))) {
|
|
54
|
+
return "OAUTH_UNAVAILABLE";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (s.includes("an error occurred while processing") || /\b5\d\d\b/.test(s)) {
|
|
58
|
+
return "UPSTREAM_5XX";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return "UNKNOWN";
|
|
62
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, stat } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
|
|
5
|
+
async function listImageFiles(baseDir) {
|
|
6
|
+
const out = [];
|
|
7
|
+
|
|
8
|
+
async function walk(dir, depth) {
|
|
9
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
if (entry.name === config.storage.trashDirName) continue;
|
|
12
|
+
const full = join(dir, entry.name);
|
|
13
|
+
if (entry.isDirectory() && depth > 0) {
|
|
14
|
+
await walk(full, depth - 1);
|
|
15
|
+
} else if (entry.isFile() && /\.(png|jpe?g|webp)$/i.test(entry.name)) {
|
|
16
|
+
out.push({ full, rel: full.slice(baseDir.length + 1), name: entry.name });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
await walk(baseDir, 2);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function listHistoryRows(baseDir = config.storage.generatedDir) {
|
|
26
|
+
await mkdir(baseDir, { recursive: true });
|
|
27
|
+
const imgs = await listImageFiles(baseDir);
|
|
28
|
+
const rows = await Promise.all(imgs.map(async ({ full, rel, name }) => {
|
|
29
|
+
const st = await stat(full).catch(() => null);
|
|
30
|
+
let meta = null;
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readFile(full + ".json", "utf-8");
|
|
33
|
+
meta = JSON.parse(raw);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (e.code !== "ENOENT") console.warn("[history] sidecar parse fail:", rel, e.message);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
filename: rel,
|
|
39
|
+
url: `/generated/${rel.split("/").map(encodeURIComponent).join("/")}`,
|
|
40
|
+
createdAt: meta?.createdAt || st?.mtimeMs || 0,
|
|
41
|
+
prompt: meta?.prompt || null,
|
|
42
|
+
userPrompt: meta?.userPrompt || meta?.prompt || null,
|
|
43
|
+
revisedPrompt: meta?.revisedPrompt || null,
|
|
44
|
+
promptMode: meta?.promptMode || null,
|
|
45
|
+
quality: meta?.quality || null,
|
|
46
|
+
size: meta?.size || null,
|
|
47
|
+
format: meta?.format || name.split(".").pop(),
|
|
48
|
+
provider: meta?.provider || "oauth",
|
|
49
|
+
usage: meta?.usage || null,
|
|
50
|
+
webSearchCalls: meta?.webSearchCalls || 0,
|
|
51
|
+
sessionId: meta?.sessionId || null,
|
|
52
|
+
nodeId: meta?.nodeId || null,
|
|
53
|
+
parentNodeId: meta?.parentNodeId || null,
|
|
54
|
+
clientNodeId: meta?.clientNodeId || null,
|
|
55
|
+
kind: meta?.kind || null,
|
|
56
|
+
refsCount: Number.isFinite(meta?.refsCount) ? meta.refsCount : 0,
|
|
57
|
+
};
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
rows.sort((a, b) => {
|
|
61
|
+
if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt;
|
|
62
|
+
return b.filename < a.filename ? -1 : b.filename > a.filename ? 1 : 0;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
package/lib/inflight.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { logEvent } from "./logger.js";
|
|
3
|
+
|
|
1
4
|
// In-memory inflight job registry.
|
|
2
5
|
// Tracks generation requests that are currently running on the server so clients
|
|
3
6
|
// can reconcile optimistic UI state after a reload or across tabs.
|
|
@@ -6,20 +9,30 @@
|
|
|
6
9
|
// are lost (which is correct — the fetch they came from is already gone).
|
|
7
10
|
|
|
8
11
|
const jobs = new Map(); // requestId -> { requestId, kind, prompt, meta, startedAt, phase, phaseAt }
|
|
12
|
+
const terminalJobs = new Map(); // requestId -> terminal snapshot, active-only API stays default
|
|
9
13
|
|
|
10
14
|
// Phases: "queued" → "streaming" (upstream connection open, waiting for image)
|
|
11
15
|
// → "decoding" (b64 received, writing to disk)
|
|
12
|
-
// finishJob removes the entry entirely.
|
|
13
16
|
export function startJob({ requestId, kind, prompt, meta = {} }) {
|
|
14
17
|
if (!requestId) return;
|
|
18
|
+
const startedAt = Date.now();
|
|
15
19
|
jobs.set(requestId, {
|
|
16
20
|
requestId,
|
|
17
21
|
kind,
|
|
18
22
|
prompt: typeof prompt === "string" ? prompt.slice(0, 500) : "",
|
|
19
23
|
meta,
|
|
20
|
-
startedAt
|
|
24
|
+
startedAt,
|
|
21
25
|
phase: "queued",
|
|
22
|
-
phaseAt:
|
|
26
|
+
phaseAt: startedAt,
|
|
27
|
+
});
|
|
28
|
+
terminalJobs.delete(requestId);
|
|
29
|
+
logEvent("inflight", "start", {
|
|
30
|
+
requestId,
|
|
31
|
+
kind,
|
|
32
|
+
sessionId: meta?.sessionId || null,
|
|
33
|
+
parentNodeId: meta?.parentNodeId || null,
|
|
34
|
+
clientNodeId: meta?.clientNodeId || null,
|
|
35
|
+
promptChars: typeof prompt === "string" ? prompt.length : 0,
|
|
23
36
|
});
|
|
24
37
|
}
|
|
25
38
|
|
|
@@ -29,18 +42,56 @@ export function setJobPhase(requestId, phase) {
|
|
|
29
42
|
if (!j) return;
|
|
30
43
|
j.phase = phase;
|
|
31
44
|
j.phaseAt = Date.now();
|
|
45
|
+
logEvent("inflight", "phase", { requestId, kind: j.kind, phase });
|
|
32
46
|
}
|
|
33
47
|
|
|
34
|
-
export function finishJob(requestId,
|
|
48
|
+
export function finishJob(requestId, options = {}) {
|
|
35
49
|
if (!requestId) return;
|
|
50
|
+
const j = jobs.get(requestId);
|
|
51
|
+
if (j) {
|
|
52
|
+
const finishedAt = Date.now();
|
|
53
|
+
const status = options.canceled ? "canceled" : options.status || "completed";
|
|
54
|
+
terminalJobs.set(requestId, {
|
|
55
|
+
requestId,
|
|
56
|
+
kind: j.kind,
|
|
57
|
+
status,
|
|
58
|
+
startedAt: j.startedAt,
|
|
59
|
+
finishedAt,
|
|
60
|
+
durationMs: finishedAt - j.startedAt,
|
|
61
|
+
phase: j.phase,
|
|
62
|
+
phaseAt: j.phaseAt,
|
|
63
|
+
httpStatus: options.httpStatus,
|
|
64
|
+
errorCode: options.errorCode,
|
|
65
|
+
meta: {
|
|
66
|
+
...j.meta,
|
|
67
|
+
...(options.meta || {}),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
logEvent("inflight", "finish", {
|
|
71
|
+
requestId,
|
|
72
|
+
kind: j.kind,
|
|
73
|
+
status,
|
|
74
|
+
durationMs: finishedAt - j.startedAt,
|
|
75
|
+
httpStatus: options.httpStatus,
|
|
76
|
+
errorCode: options.errorCode,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
36
79
|
jobs.delete(requestId);
|
|
80
|
+
reapTerminalJobs();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function reapTerminalJobs() {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
for (const [id, j] of terminalJobs) {
|
|
86
|
+
if (now - j.finishedAt > config.inflight.terminalTtlMs) terminalJobs.delete(id);
|
|
87
|
+
}
|
|
37
88
|
}
|
|
38
89
|
|
|
39
90
|
export function listJobs(filters = {}) {
|
|
40
|
-
// Stale reaping: >
|
|
91
|
+
// Stale reaping: > TTL is almost certainly a crashed fetch.
|
|
41
92
|
const now = Date.now();
|
|
42
93
|
for (const [id, j] of jobs) {
|
|
43
|
-
if (now - j.startedAt >
|
|
94
|
+
if (now - j.startedAt > config.inflight.ttlMs) jobs.delete(id);
|
|
44
95
|
}
|
|
45
96
|
const { kind, sessionId } = filters;
|
|
46
97
|
return Array.from(jobs.values())
|
|
@@ -52,6 +103,19 @@ export function listJobs(filters = {}) {
|
|
|
52
103
|
.sort((a, b) => a.startedAt - b.startedAt);
|
|
53
104
|
}
|
|
54
105
|
|
|
106
|
+
export function listTerminalJobs(filters = {}) {
|
|
107
|
+
reapTerminalJobs();
|
|
108
|
+
const { kind, sessionId } = filters;
|
|
109
|
+
return Array.from(terminalJobs.values())
|
|
110
|
+
.filter((j) => {
|
|
111
|
+
if (kind && j.kind !== kind) return false;
|
|
112
|
+
if (sessionId && j.meta?.sessionId !== sessionId) return false;
|
|
113
|
+
return true;
|
|
114
|
+
})
|
|
115
|
+
.sort((a, b) => b.finishedAt - a.finishedAt);
|
|
116
|
+
}
|
|
117
|
+
|
|
55
118
|
export function _resetForTests() {
|
|
56
119
|
jobs.clear();
|
|
120
|
+
terminalJobs.clear();
|
|
57
121
|
}
|
package/lib/logger.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const REDACTED = "[redacted]";
|
|
2
|
+
const MAX_VALUE_LEN = 240;
|
|
3
|
+
|
|
4
|
+
const SECRET_KEYS = new Set([
|
|
5
|
+
"authorization",
|
|
6
|
+
"cookie",
|
|
7
|
+
"headers",
|
|
8
|
+
"apiKey",
|
|
9
|
+
"token",
|
|
10
|
+
"password",
|
|
11
|
+
"secret",
|
|
12
|
+
"body",
|
|
13
|
+
"prompt",
|
|
14
|
+
"effectivePrompt",
|
|
15
|
+
"userPrompt",
|
|
16
|
+
"revisedPrompt",
|
|
17
|
+
"textPrompt",
|
|
18
|
+
"styleSheet",
|
|
19
|
+
"style_sheet",
|
|
20
|
+
"image",
|
|
21
|
+
"imageB64",
|
|
22
|
+
"image_url",
|
|
23
|
+
"references",
|
|
24
|
+
"rawResponse",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const ALLOWED_PROMPT_METRICS = new Set(["promptChars", "promptMode"]);
|
|
28
|
+
|
|
29
|
+
function shouldRedactKey(key) {
|
|
30
|
+
if (ALLOWED_PROMPT_METRICS.has(key)) return false;
|
|
31
|
+
if (SECRET_KEYS.has(key)) return true;
|
|
32
|
+
const lower = key.toLowerCase();
|
|
33
|
+
return (
|
|
34
|
+
lower.includes("token") ||
|
|
35
|
+
lower.includes("authorization") ||
|
|
36
|
+
lower.includes("cookie") ||
|
|
37
|
+
lower.includes("apikey") ||
|
|
38
|
+
lower.includes("api_key") ||
|
|
39
|
+
lower.includes("secret") ||
|
|
40
|
+
lower.includes("b64") ||
|
|
41
|
+
lower.includes("base64") ||
|
|
42
|
+
lower.includes("dataurl")
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sanitizeValue(value) {
|
|
47
|
+
if (value == null) return value;
|
|
48
|
+
if (value instanceof Error) return sanitizeError(value);
|
|
49
|
+
if (Array.isArray(value)) return `[array:${value.length}]`;
|
|
50
|
+
if (Buffer.isBuffer(value)) return `[buffer:${value.length}]`;
|
|
51
|
+
if (typeof value === "object") return "[object]";
|
|
52
|
+
if (typeof value === "string") {
|
|
53
|
+
const oneLine = value
|
|
54
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
|
|
55
|
+
.replace(/data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi, "data:image/[redacted]")
|
|
56
|
+
.replace(/\s+/g, " ")
|
|
57
|
+
.trim();
|
|
58
|
+
return oneLine.length > MAX_VALUE_LEN ? `${oneLine.slice(0, MAX_VALUE_LEN)}...` : oneLine;
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function sanitizeError(err) {
|
|
64
|
+
if (!err) return { message: "Unknown error" };
|
|
65
|
+
return {
|
|
66
|
+
name: err.name || "Error",
|
|
67
|
+
code: err.code || undefined,
|
|
68
|
+
status: err.status || undefined,
|
|
69
|
+
message: sanitizeValue(err.message || "Unknown error"),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sanitizeFields(fields = {}) {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
76
|
+
out[key] = shouldRedactKey(key) ? REDACTED : sanitizeValue(value);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatValue(value) {
|
|
82
|
+
if (value === undefined) return undefined;
|
|
83
|
+
if (value === null) return "null";
|
|
84
|
+
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
|
85
|
+
return JSON.stringify(String(value));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatLog(scope, event, fields = {}) {
|
|
89
|
+
const safeFields = sanitizeFields(fields);
|
|
90
|
+
const parts = Object.entries(safeFields)
|
|
91
|
+
.map(([key, value]) => {
|
|
92
|
+
const formatted = formatValue(value);
|
|
93
|
+
return formatted === undefined ? null : `${key}=${formatted}`;
|
|
94
|
+
})
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
return `[${scope}.${event}]${parts.length ? ` ${parts.join(" ")}` : ""}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function logEvent(scope, event, fields = {}) {
|
|
100
|
+
console.log(formatLog(scope, event, fields));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function logWarn(scope, event, fields = {}) {
|
|
104
|
+
console.warn(formatLog(scope, event, fields));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function logError(scope, event, err, fields = {}) {
|
|
108
|
+
const safe = sanitizeError(err);
|
|
109
|
+
console.error(formatLog(scope, event, {
|
|
110
|
+
...fields,
|
|
111
|
+
errorName: safe.name,
|
|
112
|
+
errorCode: safe.code,
|
|
113
|
+
errorStatus: safe.status,
|
|
114
|
+
errorMessage: safe.message,
|
|
115
|
+
}));
|
|
116
|
+
}
|