@firstpick/pi-package-webui 0.3.3 → 0.3.5
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/bin/pi-webui.mjs +118 -0
- package/package.json +1 -1
- package/public/app.js +608 -3
- package/public/index.html +45 -2
- package/public/styles.css +307 -0
- package/tests/mobile-static.test.mjs +50 -1
package/bin/pi-webui.mjs
CHANGED
|
@@ -33,6 +33,7 @@ const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
|
33
33
|
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
|
34
34
|
const OPENAI_CODEX_USAGE_ENDPOINT = process.env.PI_WEBUI_CODEX_USAGE_URL || "https://chatgpt.com/backend-api/wham/usage";
|
|
35
35
|
const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
36
|
+
const SKILL_FILE_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
|
|
36
37
|
const PROMPT_BODY_LIMIT_BYTES = 24 * 1024 * 1024;
|
|
37
38
|
const UPLOAD_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
|
|
38
39
|
const ATTACHMENT_UPLOAD_MAX_FILES = 12;
|
|
@@ -4242,6 +4243,109 @@ async function getMergedSkillConfigData(tab) {
|
|
|
4242
4243
|
return { skills: mergeRuntimeAndResolvedSkills(runtime.skills || [], resolved.skills || []) };
|
|
4243
4244
|
}
|
|
4244
4245
|
|
|
4246
|
+
function normalizeSkillRequestName(value) {
|
|
4247
|
+
return String(value || "").trim().replace(/^skill:/i, "").toLowerCase();
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
function skillFileRequestParts(source = {}) {
|
|
4251
|
+
return {
|
|
4252
|
+
name: normalizeSkillRequestName(source.name || source.skillName),
|
|
4253
|
+
filePath: String(source.path || source.filePath || "").trim(),
|
|
4254
|
+
};
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4257
|
+
function sameResolvedPath(left, right) {
|
|
4258
|
+
if (!left || !right) return false;
|
|
4259
|
+
return path.resolve(left) === path.resolve(right);
|
|
4260
|
+
}
|
|
4261
|
+
|
|
4262
|
+
function skillFilePathInside(root, target) {
|
|
4263
|
+
if (!root || !target) return false;
|
|
4264
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
4265
|
+
return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
4266
|
+
}
|
|
4267
|
+
|
|
4268
|
+
function skillNameFromSkillFilePath(filePath) {
|
|
4269
|
+
const normalized = String(filePath || "").replace(/\\/g, "/");
|
|
4270
|
+
const match = normalized.match(/\/skills\/([^/]+)\/SKILL\.md$/i);
|
|
4271
|
+
return normalizeSkillRequestName(match?.[1] || "");
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
async function resolveExplicitSkillFilePath(tab, filePath, requestedName = "") {
|
|
4275
|
+
const resolvedPath = path.resolve(filePath || "");
|
|
4276
|
+
const pathSkillName = skillNameFromSkillFilePath(resolvedPath);
|
|
4277
|
+
if (!pathSkillName) throw makeHttpError(400, "Skill path must point to /skills/<name>/SKILL.md");
|
|
4278
|
+
if (requestedName && requestedName !== pathSkillName) throw makeHttpError(400, "Skill name does not match the requested SKILL.md path");
|
|
4279
|
+
const allowedRoots = [agentDir, path.join(tab?.cwd || options.cwd, ".pi")];
|
|
4280
|
+
if (!allowedRoots.some((root) => skillFilePathInside(root, resolvedPath))) {
|
|
4281
|
+
throw makeHttpError(403, "Skill path is outside allowed Pi skill locations");
|
|
4282
|
+
}
|
|
4283
|
+
const info = await stat(resolvedPath).catch(() => null);
|
|
4284
|
+
if (!info?.isFile()) throw makeHttpError(404, `Skill file not found: ${resolvedPath}`);
|
|
4285
|
+
return {
|
|
4286
|
+
name: pathSkillName,
|
|
4287
|
+
description: "",
|
|
4288
|
+
filePath: resolvedPath,
|
|
4289
|
+
enabled: true,
|
|
4290
|
+
fileStats: info,
|
|
4291
|
+
};
|
|
4292
|
+
}
|
|
4293
|
+
|
|
4294
|
+
async function resolveEditableSkillFile(tab, request = {}) {
|
|
4295
|
+
const { name, filePath } = skillFileRequestParts(request);
|
|
4296
|
+
if (!name && !filePath) throw makeHttpError(400, "Skill name or path is required");
|
|
4297
|
+
const { skills } = await resolveSkillResources(tab);
|
|
4298
|
+
const skill = skills.find((item) => (
|
|
4299
|
+
filePath ? sameResolvedPath(item.filePath, filePath) : name && normalizeSkillRequestName(item.name) === name
|
|
4300
|
+
));
|
|
4301
|
+
if (skill?.filePath) {
|
|
4302
|
+
if (path.basename(skill.filePath) !== "SKILL.md") throw makeHttpError(400, "Only SKILL.md files can be edited from skill tags");
|
|
4303
|
+
const info = await stat(skill.filePath).catch(() => null);
|
|
4304
|
+
if (!info?.isFile()) throw makeHttpError(404, `Skill file not found: ${skill.filePath}`);
|
|
4305
|
+
return { ...skill, filePath: path.resolve(skill.filePath), fileStats: info };
|
|
4306
|
+
}
|
|
4307
|
+
if (filePath) return resolveExplicitSkillFilePath(tab, filePath, name);
|
|
4308
|
+
throw makeHttpError(404, "Skill is not configured in this Pi tab");
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
async function getSkillFileData(tab, request = {}) {
|
|
4312
|
+
const skill = await resolveEditableSkillFile(tab, request);
|
|
4313
|
+
const content = await readFile(skill.filePath, "utf8");
|
|
4314
|
+
return {
|
|
4315
|
+
name: parseSkillFrontmatter(content, skill.filePath).name || skill.name,
|
|
4316
|
+
description: skill.description || "",
|
|
4317
|
+
path: skill.filePath,
|
|
4318
|
+
content,
|
|
4319
|
+
mtimeMs: skill.fileStats.mtimeMs,
|
|
4320
|
+
size: skill.fileStats.size,
|
|
4321
|
+
enabled: skill.enabled === true,
|
|
4322
|
+
};
|
|
4323
|
+
}
|
|
4324
|
+
|
|
4325
|
+
async function saveSkillFileData(tab, body = {}) {
|
|
4326
|
+
if (typeof body.content !== "string") throw makeHttpError(400, "Skill content must be a string");
|
|
4327
|
+
if (body.content.includes("\0")) throw makeHttpError(400, "Skill content cannot contain null bytes");
|
|
4328
|
+
if (Buffer.byteLength(body.content, "utf8") > SKILL_FILE_BODY_LIMIT_BYTES) throw makeHttpError(413, `Skill file is too large (limit ${formatBytes(SKILL_FILE_BODY_LIMIT_BYTES)})`);
|
|
4329
|
+
const skill = await resolveEditableSkillFile(tab, body);
|
|
4330
|
+
const expectedMtimeMs = Number(body.mtimeMs);
|
|
4331
|
+
if (Number.isFinite(expectedMtimeMs) && Math.abs(skill.fileStats.mtimeMs - expectedMtimeMs) > 5) {
|
|
4332
|
+
throw makeHttpError(409, "Skill file changed on disk after it was opened. Reopen it before saving.");
|
|
4333
|
+
}
|
|
4334
|
+
const tmpFile = `${skill.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
4335
|
+
await writeFile(tmpFile, body.content, { encoding: "utf8", mode: skill.fileStats.mode & 0o777 });
|
|
4336
|
+
await rename(tmpFile, skill.filePath);
|
|
4337
|
+
const nextStats = await stat(skill.filePath);
|
|
4338
|
+
const metadata = parseSkillFrontmatter(body.content, skill.filePath);
|
|
4339
|
+
return {
|
|
4340
|
+
name: metadata.name || skill.name,
|
|
4341
|
+
description: metadata.description || skill.description || "",
|
|
4342
|
+
path: skill.filePath,
|
|
4343
|
+
mtimeMs: nextStats.mtimeMs,
|
|
4344
|
+
size: nextStats.size,
|
|
4345
|
+
enabled: skill.enabled === true,
|
|
4346
|
+
};
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4245
4349
|
function getResourcePatternForSkill(tab, skill) {
|
|
4246
4350
|
const info = skill.sourceInfo || {};
|
|
4247
4351
|
const baseDir = info.baseDir || (info.scope === "project" ? path.join(tab?.cwd || options.cwd, ".pi") : agentDir);
|
|
@@ -5685,6 +5789,20 @@ const server = createServer(async (req, res) => {
|
|
|
5685
5789
|
return;
|
|
5686
5790
|
}
|
|
5687
5791
|
|
|
5792
|
+
if (url.pathname === "/api/skill-file" && req.method === "GET") {
|
|
5793
|
+
const tab = getRequestedTab(req, url);
|
|
5794
|
+
sendJson(res, 200, { ok: true, data: await getSkillFileData(tab, { name: url.searchParams.get("name"), path: url.searchParams.get("path") }) });
|
|
5795
|
+
return;
|
|
5796
|
+
}
|
|
5797
|
+
|
|
5798
|
+
if (url.pathname === "/api/skill-file" && req.method === "POST") {
|
|
5799
|
+
if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Saving skill files is only allowed from localhost");
|
|
5800
|
+
const body = await readJsonBody(req, { limitBytes: SKILL_FILE_BODY_LIMIT_BYTES });
|
|
5801
|
+
const tab = getRequestedTab(req, url, body);
|
|
5802
|
+
sendJson(res, 200, { ok: true, data: await saveSkillFileData(tab, body) });
|
|
5803
|
+
return;
|
|
5804
|
+
}
|
|
5805
|
+
|
|
5688
5806
|
if (url.pathname === "/api/settings" && req.method === "GET") {
|
|
5689
5807
|
const tab = getRequestedTab(req, url);
|
|
5690
5808
|
sendJson(res, 200, { ok: true, data: nativeSettingsPayload(settingsManagerForTab(tab)) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
|