修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)
一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。
二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。
三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
This commit is contained in:
1 parent
e03465c398
commit
237a09a5b0
161 files changed
+19429
-2
No files matched your search
@@ -0,0 +1,34 @@
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]] $ArgsList
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$pythonScript = Join-Path $scriptDir "generate_image.py"
|
||||
$python = if ($env:DRAW_PYTHON) { $env:DRAW_PYTHON } else { "python" }
|
||||
|
||||
$framePath = ""
|
||||
$remaining = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
for ($i = 0; $i -lt $ArgsList.Count; $i++) {
|
||||
if ($ArgsList[$i] -eq "--frame") {
|
||||
if ($i + 1 -ge $ArgsList.Count) {
|
||||
throw "--frame requires a path"
|
||||
}
|
||||
$framePath = $ArgsList[$i + 1]
|
||||
$i++
|
||||
continue
|
||||
}
|
||||
$remaining.Add($ArgsList[$i])
|
||||
}
|
||||
|
||||
$commandArgs = @($pythonScript)
|
||||
if ($framePath) {
|
||||
$commandArgs += @("--ref", $framePath)
|
||||
}
|
||||
$commandArgs += $remaining.ToArray()
|
||||
|
||||
& $python @commandArgs
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="${DRAW_VENV:-$HOME/.cache/draw/venv}"
|
||||
PROVIDER="${DRAW_PROVIDER:-zenmux}"
|
||||
RAW_ARGS=("$@")
|
||||
for ((i = 0; i < ${#RAW_ARGS[@]}; i++)); do
|
||||
case "${RAW_ARGS[$i]}" in
|
||||
--provider)
|
||||
if [[ $((i + 1)) -ge ${#RAW_ARGS[@]} ]]; then
|
||||
echo "[ERROR] --provider requires zenmux or codex." >&2
|
||||
exit 2
|
||||
fi
|
||||
PROVIDER="${RAW_ARGS[$((i + 1))]}"
|
||||
((i += 1))
|
||||
;;
|
||||
--provider=*)
|
||||
PROVIDER="${RAW_ARGS[$i]#--provider=}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$PROVIDER" in
|
||||
codex)
|
||||
PYTHON_BIN="${DRAW_PYTHON:-python3}"
|
||||
;;
|
||||
zenmux)
|
||||
PYTHON_BIN="${DRAW_PYTHON:-$VENV_DIR/bin/python3}"
|
||||
if [[ ! -x "$PYTHON_BIN" ]]; then
|
||||
mkdir -p "$VENV_DIR"
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
if ! "$PYTHON_BIN" -c "import google.genai, PIL" >/dev/null 2>&1; then
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 "$PYTHON_BIN" -m pip install --quiet --upgrade pip google-genai pillow
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] Invalid provider '$PROVIDER'; expected zenmux or codex." >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# --frame <path>: prepend a frame reference image before all other --ref args.
|
||||
# Usage: ask_draw.sh --frame /path/to/frame.png [other args...]
|
||||
# This injects --ref <frame> at the front so the frame is always ref[0].
|
||||
FRAME_PATH=""
|
||||
REMAINING_ARGS=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--frame)
|
||||
FRAME_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
REMAINING_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "$FRAME_PATH" ]]; then
|
||||
exec "$PYTHON_BIN" "$SCRIPT_DIR/generate_image.py" --ref "$FRAME_PATH" "${REMAINING_ARGS[@]}"
|
||||
else
|
||||
exec "$PYTHON_BIN" "$SCRIPT_DIR/generate_image.py" "${REMAINING_ARGS[@]}"
|
||||
fi
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""按生成前的分段顺序拼接等宽长页,不裁切或缩放原图。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def assemble(manifest: Path, output: Path, *, force: bool = False) -> tuple[int, int]:
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("清单必须是JSON对象")
|
||||
expected, sections = data.get("expected_sections"), data.get("sections")
|
||||
if not isinstance(expected, list) or not expected or any(not isinstance(x, str) or not x.strip() for x in expected):
|
||||
raise ValueError("expected_sections必须是非空ID列表")
|
||||
if len(set(expected)) != len(expected):
|
||||
raise ValueError("expected_sections存在重复ID")
|
||||
if not isinstance(sections, list) or any(not isinstance(x, dict) for x in sections):
|
||||
raise ValueError("sections必须是包含id和image的列表")
|
||||
if [x.get("id") for x in sections] != expected:
|
||||
raise ValueError("实际分段缺失、重复或顺序与expected_sections不符")
|
||||
if output.suffix.lower() != ".png":
|
||||
raise ValueError("完整长页输出必须使用.png扩展名")
|
||||
if output.exists() and not force:
|
||||
raise FileExistsError(f"输出已存在:{output};确认替换后才使用--force")
|
||||
|
||||
images = []
|
||||
seen_paths = set()
|
||||
try:
|
||||
for section in sections:
|
||||
raw = section.get("image")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
raise ValueError(f"分段{section['id']}缺少image路径")
|
||||
path = (manifest.parent / raw).resolve()
|
||||
if path == output.resolve() or path in seen_paths:
|
||||
raise ValueError("不能重复使用同一图片或覆盖输入图片")
|
||||
seen_paths.add(path)
|
||||
with Image.open(path) as im:
|
||||
im.load()
|
||||
if images and im.width != images[0].width:
|
||||
raise ValueError(f"分段{section['id']}宽度{im.width}与首段{images[0].width}不一致;不会自动缩放")
|
||||
images.append(im.convert("RGBA"))
|
||||
size = (images[0].width, sum(im.height for im in images))
|
||||
with Image.new("RGBA", size) as page:
|
||||
offset = 0
|
||||
for im in images:
|
||||
page.paste(im, (0, offset))
|
||||
offset += im.height
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".full-page-", suffix=".png", dir=output.parent)
|
||||
os.close(fd)
|
||||
temporary = Path(temp_name)
|
||||
try:
|
||||
page.save(temporary, format="PNG")
|
||||
if force:
|
||||
os.replace(temporary, output)
|
||||
else:
|
||||
# 同目录硬链接原子拒绝覆盖,避免检查后输出被其他任务创建。
|
||||
os.link(temporary, output)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return size
|
||||
finally:
|
||||
for im in images:
|
||||
im.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
width, height = assemble(args.manifest.resolve(), args.output.resolve(), force=args.force)
|
||||
except (OSError, ValueError, KeyError, Image.DecompressionBombError) as exc:
|
||||
parser.exit(1, f"[错误] {exc}\n")
|
||||
print(f"output_path={args.output.resolve()}")
|
||||
print(f"size={width}x{height}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查图片的实际透明度;不会抠图、修改图片或判断构图质量。"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def inspect_alpha(path):
|
||||
with Image.open(path) as im:
|
||||
native_alpha = "A" in im.getbands() or "transparency" in im.info
|
||||
histogram = im.convert("RGBA").getchannel("A").histogram()
|
||||
total = im.width * im.height
|
||||
transparent = histogram[0]
|
||||
partial = sum(histogram[1:255])
|
||||
opaque = histogram[255]
|
||||
return {
|
||||
"path": str(Path(path).resolve()),
|
||||
"mode": im.mode,
|
||||
"size": [im.width, im.height],
|
||||
"has_alpha_or_transparency_metadata": native_alpha,
|
||||
"transparent_pixels": transparent,
|
||||
"partial_alpha_pixels": partial,
|
||||
"opaque_pixels": opaque,
|
||||
"nonopaque_fraction": (transparent + partial) / total,
|
||||
"has_visible_content": partial + opaque > 0,
|
||||
"transparency_check_passed": native_alpha
|
||||
and transparent + partial > 0
|
||||
and partial + opaque > 0,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("image", type=Path)
|
||||
parser.add_argument("--require-transparency", action="store_true")
|
||||
args = parser.parse_args()
|
||||
result = inspect_alpha(args.image)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
if args.require_transparency and not result["transparency_check_passed"]:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,39 @@
|
||||
/* 由 prepare_capture.py 绑定测量基线。只读取主文档,不导航、不操作页面。 */
|
||||
(plan) => {
|
||||
const elements = plan.targets.map(({name, selector}) => {
|
||||
let matches;
|
||||
try { matches = document.querySelectorAll(selector); }
|
||||
catch { return {name, selector, status: 'invalid_selector', count: 0}; }
|
||||
if (matches.length !== 1) return {name, selector, count: matches.length, status: matches.length ? 'ambiguous' : 'missing'};
|
||||
const el = matches[0];
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
let hidden = ['hidden', 'collapse'].includes(style.visibility);
|
||||
for (let parent = el; parent; parent = parent.parentElement) {
|
||||
const s = getComputedStyle(parent);
|
||||
if (s.display === 'none' || Number(s.opacity) === 0) hidden = true;
|
||||
}
|
||||
const hasBox = el.getClientRects().length > 0 && rect.width > 0 && rect.height > 0;
|
||||
const result = {
|
||||
name, selector, count: 1,
|
||||
status: hidden ? 'hidden' : hasBox ? 'measured' : 'unmeasurable',
|
||||
box: [rect.left + scrollX, rect.top + scrollY, rect.width, rect.height],
|
||||
text: (el.textContent || '').trim().slice(0, 300),
|
||||
computed: {display: style.display, visibility: style.visibility, opacity: style.opacity, fontFamily: style.fontFamily, fontSize: style.fontSize, lineHeight: style.lineHeight, fontWeight: style.fontWeight, color: style.color, backgroundColor: style.backgroundColor},
|
||||
viewport_intersects: rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth
|
||||
};
|
||||
if (el instanceof HTMLImageElement) result.asset = {src: el.currentSrc, natural_width: el.naturalWidth, natural_height: el.naturalHeight, version: el.dataset.version || null};
|
||||
return result;
|
||||
});
|
||||
return {
|
||||
measurement_sha256: plan.measurement_sha256,
|
||||
url: location.href, title: document.title,
|
||||
viewport: {width: innerWidth, height: innerHeight}, dpr: devicePixelRatio,
|
||||
scroll: {x: scrollX, y: scrollY}, document_height: document.documentElement.scrollHeight,
|
||||
fonts_ready: document.fonts.status === 'loaded',
|
||||
images_ready: [...document.images].every(im => im.complete && im.naturalWidth > 0),
|
||||
document_overflow_x: document.documentElement.scrollWidth > innerWidth,
|
||||
captured_at: new Date().toISOString(), elements,
|
||||
assets: Object.fromEntries(elements.filter(el => el.asset).map(el => [el.name, el.asset]))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""按原始坐标比较截图,不缩放候选图,不把误差分数当成视觉验收。"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
from PIL import Image, ImageChops, ImageEnhance, ImageStat
|
||||
|
||||
|
||||
def safe_name(value):
|
||||
if not re.fullmatch(r'[\w.-]+', value) or '..' in value:
|
||||
raise ValueError('名称只能包含文字、数字、短横线、下划线或单个点')
|
||||
return value
|
||||
|
||||
|
||||
def rms(diff):
|
||||
stat = ImageStat.Stat(diff)
|
||||
return math.sqrt(sum(value * value for value in stat.rms) / len(stat.rms))
|
||||
|
||||
|
||||
def make_heatmap(diff):
|
||||
gray = ImageEnhance.Contrast(diff.convert('L')).enhance(2.2)
|
||||
gray = ImageEnhance.Brightness(gray).enhance(1.5)
|
||||
heat = Image.new('RGB', diff.size, (255, 255, 255))
|
||||
heat.paste(Image.new('RGB', diff.size, (255, 55, 55)), mask=gray)
|
||||
return heat
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--reference', type=Path, required=True)
|
||||
parser.add_argument('--candidate', type=Path, required=True)
|
||||
parser.add_argument('--out-dir', type=Path, required=True)
|
||||
parser.add_argument('--prefix', default='comparison')
|
||||
parser.add_argument('--clip', action='append', default=[], help='名称:x,y,w,h;可重复')
|
||||
parser.add_argument('--allow-height-difference', action='store_true', help='长页诊断:保留高度差,只比较共同坐标,不表示页面完整')
|
||||
args = parser.parse_args()
|
||||
reference = Image.open(args.reference).convert('RGB')
|
||||
candidate = Image.open(args.candidate).convert('RGB')
|
||||
if reference.width != candidate.width:
|
||||
parser.error('截图宽度不一致;请先校准视口、DPR及截图缩放方式。不会自动缩放。')
|
||||
if reference.height != candidate.height and not args.allow_height_difference:
|
||||
parser.error('截图高度不一致;先修正截图方式。长页诊断可显式使用 --allow-height-difference。')
|
||||
clips = []
|
||||
names = {'full', 'overlap', 'reference-tail', 'candidate-tail'}
|
||||
try:
|
||||
safe_name(args.prefix)
|
||||
for raw in args.clip:
|
||||
name, numbers = raw.split(':', 1)
|
||||
safe_name(name)
|
||||
x, y, w, h = map(int, numbers.split(','))
|
||||
if name in names or min(x, y) < 0 or min(w, h) <= 0 or x+w > reference.width or y+h > reference.height:
|
||||
raise ValueError('分区重名、尺寸无效或超出参考图')
|
||||
if y+h > candidate.height and not args.allow_height_difference:
|
||||
raise ValueError('分区超出候选图')
|
||||
names.add(name)
|
||||
clips.append((name, (x, y, x+w, y+h)))
|
||||
except ValueError as error:
|
||||
parser.error(str(error))
|
||||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def compare_region(name, ref, cand):
|
||||
diff = ImageChops.difference(ref, cand)
|
||||
paths = {suffix: args.out_dir / f'{args.prefix}-{name}-{suffix}.png' for suffix in ('candidate', 'diff', 'heatmap')}
|
||||
cand.save(paths['candidate'])
|
||||
diff.save(paths['diff'])
|
||||
make_heatmap(diff).save(paths['heatmap'])
|
||||
return {'name': name, 'size': ref.size, 'rms_diff': round(rms(diff), 3),
|
||||
'mean_abs_diff': round(sum(ImageStat.Stat(diff).mean)/3, 3),
|
||||
**{key: str(value) for key, value in paths.items()}}
|
||||
|
||||
height = min(reference.height, candidate.height)
|
||||
equal = reference.size == candidate.size
|
||||
box = (0, 0, reference.width, height)
|
||||
regions = [compare_region('full' if equal else 'overlap', reference.crop(box), candidate.crop(box))]
|
||||
tails = {}
|
||||
for label, image in [('reference', reference), ('candidate', candidate)]:
|
||||
if image.height > height:
|
||||
path = args.out_dir / f'{args.prefix}-{label}-tail.png'
|
||||
image.crop((0, height, image.width, image.height)).save(path)
|
||||
tails[label] = str(path)
|
||||
for name, box in clips:
|
||||
if box[3] > candidate.height:
|
||||
regions.append({'name': name, 'status': '候选截图缺少完整分区,未计分', 'box': box})
|
||||
else:
|
||||
regions.append(compare_region(name, reference.crop(box), candidate.crop(box)))
|
||||
metrics = {'reference': str(args.reference), 'candidate': str(args.candidate),
|
||||
'reference_size': reference.size, 'candidate_size_original': candidate.size,
|
||||
'candidate_size_compared': [candidate.width, height], 'resized': False,
|
||||
'geometry_match': equal, 'height_delta': candidate.height-reference.height,
|
||||
'unmatched_tails': tails, 'regions': regions,
|
||||
'note': '误差不等于还原度;字体、摄影和位置分别判断。尺寸一致也不代表视觉通过。'}
|
||||
(args.out_dir / f'{args.prefix}-metrics.json').write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps(metrics, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,162 @@
|
||||
# 可复用凭据输入页
|
||||
|
||||
供本机桌面 Skill 配置 API Key、访问令牌等单行凭据。一个 Key 使用紧凑单输入框,多个 Key 在同页纵向排列,共用一个保存按钮。页面默认黑白灰,密钥只存系统凭据库。
|
||||
|
||||
## 适用范围
|
||||
|
||||
适合没有现成安全配置入口、需要用户首次填写或更换 API Key、访问令牌、Client Secret 的本机桌面 Skill。已有宿主凭据能力优先复用。
|
||||
|
||||
| 其他场景 | 使用方式 |
|
||||
| --- | --- |
|
||||
| OAuth、验证码或账号登录 | 服务官方授权流程 |
|
||||
| 模型名、地址、目录等普通设置 | 普通配置文件或设置页 |
|
||||
| 私钥文件、证书、多行密钥 | 专用凭据或文件授权机制 |
|
||||
| CI、容器、远程服务器 | 已有 Secret 管理与可信运行时注入,不暴露本机输入页到网络 |
|
||||
|
||||
本组件减少密钥进入 Agent 对话和工具输出的机会,不是对同一用户下任意代码执行或浏览器控制的强隔离。
|
||||
|
||||
## 安装与单项配置
|
||||
|
||||
要求 Node.js 22.18+,以及下文对应系统凭据服务。首次下载依赖后,本地输入页可离线运行:
|
||||
|
||||
```bash
|
||||
npm ci --ignore-scripts
|
||||
npm run configure -- --label "服务 API Key" --title "输入密钥" --placeholder "粘贴你的密钥"
|
||||
npm start
|
||||
```
|
||||
|
||||
用户打开返回的本机链接,亲自填写并保存。默认字段文件为 manifests/default.json;configure 只修改非敏感声明,不接收密钥。支持 --dry-run 和 --save-label。
|
||||
|
||||
字段声明示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"id": "sample-skill",
|
||||
"label": "服务 API Key",
|
||||
"credential": "sample-skill/service/default",
|
||||
"ui": { "title": "输入密钥", "placeholder": "粘贴你的密钥", "saveLabel": "保存" }
|
||||
}
|
||||
```
|
||||
|
||||
id、label、credential 必填,ui 可省略。每项只接受非空单行密钥,最长 2500 个字符;系统后端的容量限制仍可能更低,失败时不会改存明文文件。密钥值不属于声明字段。
|
||||
|
||||
## 同页填写多个 Key
|
||||
|
||||
先创建另一项独立声明:
|
||||
|
||||
```bash
|
||||
npm run configure -- --manifest manifests/image.json --id sample-skill --label "图片服务 API Key" --credential sample-skill/image/default --placeholder "粘贴图片服务密钥"
|
||||
```
|
||||
|
||||
临时组合:
|
||||
|
||||
```bash
|
||||
npm start -- --manifest manifests/default.json --manifest manifests/image.json
|
||||
```
|
||||
|
||||
可复用页面配置:
|
||||
|
||||
```bash
|
||||
npm run configure-page -- --page manifests/setup.page.json --manifest manifests/default.json --manifest manifests/image.json --title "连接服务" --label "两项服务" --save-label "保存"
|
||||
npm start -- --page manifests/setup.page.json
|
||||
```
|
||||
|
||||
页面 JSON 只保存引用与文案:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"manifests": ["default.json", "image.json"],
|
||||
"ui": { "title": "连接服务", "label": "两项服务", "saveLabel": "保存" }
|
||||
}
|
||||
```
|
||||
|
||||
每页 1 至 16 项,顺序由 manifests 决定。文件内的路径相对于页面配置文件,CLI 参数路径相对于运行目录。配置命令自动转换为相对路径;支持三端搬移。
|
||||
|
||||
只改页面文案:
|
||||
|
||||
```bash
|
||||
npm run configure-page -- --page manifests/setup.page.json --title "配置服务"
|
||||
```
|
||||
|
||||
不传 --manifest 保留列表,传入时替换整组。支持 --dry-run。字段标签和占位文字继续由 configure 修改;同页标题与按钮来自页面 ui。修改配置后重新启动。--page 和启动时的 --manifest 互斥。
|
||||
|
||||
已有字段声明的 id 与 credential 不可直接改写;新账号另建声明,不同账号使用不同引用。移除页面字段只改变表单,不删除系统凭据。
|
||||
|
||||
## 保存与恢复
|
||||
|
||||
尚未配置项必填;已有项显示“已配置”,留空保留,输入新值才替换。原值不会回填页面。替换按钮固定显示“替换并保存”,自定义文案不能覆盖这一提示。
|
||||
|
||||
整组预检通过后顺序保存。系统凭据库没有跨项事务:中途失败保留成功项、停止后续写入,并逐项标明结果,不自动回滚。用户先核对状态,再补填未完成项;后端报错可能存在结果不确定的情况,不能据此断言未写入。
|
||||
|
||||
提交结束或离开页面会清空输入。会话 30 分钟有效,全部成功后保留结果 90 秒。取消和过期不撤销此前保存。网络超时后先查状态,不重复提交。
|
||||
|
||||
Agent 只处理入口与脱敏状态,不自动操作含真实密钥的页面。/agent/status 使用启动令牌认证;只有 saved 表示全部成功,partial 表示部分失败。不要分享本机会话链接或让用户将密钥贴进聊天。
|
||||
|
||||
## 存储与平台
|
||||
|
||||
| 平台 | 后端 | 前提与管理入口 |
|
||||
| --- | --- | --- |
|
||||
| macOS | Keychain 钥匙串 | 当前用户钥匙串可访问;“钥匙串访问”中管理,系统授权由用户确认 |
|
||||
| Windows | Credential Manager 凭据管理器 | 当前用户会话可用;“凭据管理器 → Windows 凭据”中的对应通用凭据 |
|
||||
| Linux | secret-tool / Secret Service | libsecret 工具、用户 D-Bus 会话及已解锁的 Secret Service 实现,例如 GNOME Keyring;管理入口依桌面环境而定 |
|
||||
|
||||
macOS / Windows 通过 @napi-rs/keyring 原生绑定。Linux 显式调用 secret-tool,保存值经标准输入传递,不使用可能降级到临时 keyutils 的默认绑定。
|
||||
|
||||
服务标识为 org.oiloil.skill-credentials,账号属性为声明的 credential。不同后端界面显示名称可能不同。存储属于当前系统用户,本组件不负责跨设备同步、备份或迁移旧文件。
|
||||
|
||||
凭据库不可用或被锁定时停止,明确提示检查服务;不自动安装、解锁或降级到 JSON、浏览器存储等明文介质。服务器、无桌面 Linux 或其他凭据实现必须另行确认实际能力。
|
||||
|
||||
当前 macOS 已验证;Windows / Linux 已实现适配但尚未实机验证。
|
||||
|
||||
## 业务使用
|
||||
|
||||
把真实可信程序与环境变量名代入:
|
||||
|
||||
```bash
|
||||
node src/run.ts --manifest manifests/default.json --env SERVICE_API_KEY -- your-program your-arguments
|
||||
```
|
||||
|
||||
一次任务读取多个 Key:
|
||||
|
||||
```bash
|
||||
node src/run.ts --manifest manifests/default.json --env FIRST_API_KEY --manifest manifests/image.json --env SECOND_API_KEY -- your-program your-arguments
|
||||
```
|
||||
|
||||
全部读取成功后才启动程序,重复变量或缺少凭据时停止。页面文件只定义填写方式,业务端仍逐项绑定,避免注入不需要的密钥。密钥不进入命令参数或父进程环境。
|
||||
|
||||
包装器不是沙箱:可信子进程取得原值后仍可能打印或外传。需要强隔离时使用独立可信执行服务或宿主权限边界,不依赖密码框或系统凭据库作绝对保证。
|
||||
|
||||
检查单项状态:
|
||||
|
||||
```bash
|
||||
npm run status -- --manifest manifests/image.json
|
||||
```
|
||||
|
||||
仅返回配置状态与后端;未配置退出码 2,后端失败退出码 1。可信 Node.js 程序也可内部使用 nativeBackend 的 get / set / delete;保存与删除须用户授权,不向 Agent 暴露任意读取或删除接口。
|
||||
|
||||
## 开发验收
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run build
|
||||
npm test
|
||||
npm run test:native
|
||||
```
|
||||
|
||||
常规测试使用假后端与受控 DOM,不操作真实浏览器。原生测试经真实同页 HTTP 入口写入随机假凭据,检查多变量业务读取与隔离,最后清理测试项。修改前端后必须提交一致的 public/app.js,最终用户无需编译。
|
||||
|
||||
维护时同时验证单 Key、同页多 Key、留空保留、部分失败重试、未知字段拒绝、会话隔离、状态脱敏及系统后端。不能用另写的静态演示替代正式页面测试。
|
||||
|
||||
## 固定业务入口
|
||||
|
||||
`manifests/profiles.json` 只保存业务名、声明文件和环境变量,不保存密钥。目标 Skill 接入时提供实际配置名;单项或多项复用相同页面与运行器:
|
||||
|
||||
```bash
|
||||
node src/profile.ts status default
|
||||
node src/profile.ts setup default
|
||||
node src/profile.ts run default -- your-program your-arguments
|
||||
```
|
||||
|
||||
status 退出码 0 表示所需凭据可读取,2 表示缺失,1 表示后端或配置错误。setup 由用户亲自填写;run 优先复用运行时环境变量,缺失时仅读取对应声明,不把其他服务凭据注入任务。指定其他服务时同时修改业务参数,不能只换凭据。
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"files": {
|
||||
"README.md": "c66161233e7b45ed3027d43a1dacbb3f5eca68c8db52cac20d3781f4a868f786",
|
||||
"tsconfig.web.json": "7ced73bff9909988639b0369bbd4bfbb756d8570f75ac711a02b29a6e42d88e7",
|
||||
"package-lock.json": "0b457e658ad62b3d699d4aa3b0a504ae03e9f879a9d0a8d8d4e15d0db1a3845c",
|
||||
"package.json": "82c1b1b85ee7f5ce2be69d2f389167de6a7f3bfb47ef210a83c4dd4ec51d720d",
|
||||
"tsconfig.json": "005d701b301f56cf02563ba2936134899a60685d1b41536c13726457eab351a5",
|
||||
"web/app.ts": "c279800cdbdd67b145802ae0716588e605f05c43ad7468b43c1b26392bde461d",
|
||||
"tests/profile.test.ts": "48dcbbb90f0b39c4adaec1615c51edf33a76fff57549907da5e197ffde425da5",
|
||||
"tests/configure.test.ts": "3f360ed0102c944b7a502d783eea3c63016fdcaa3c230a1a6ec0865eb513cbd6",
|
||||
"tests/run.test.ts": "157f946c795d5f5b0a4c049201818bf0f646305050324d14f0913e751df1ca45",
|
||||
"tests/page.test.ts": "85772cc4b949443ff80d090bc1fc2fbc77f30ec4d8c26e40aa6530dd4270417a",
|
||||
"tests/native-smoke.ts": "d0a7ed2dd1ce7135a2a63bb8cfb779c24ccba19290cbabefaa280598866e0990",
|
||||
"tests/ui.test.ts": "613a90efc3607315a058a65a161e8d701453e4cba66a4cef89651a7ecd6c490a",
|
||||
"tests/credentials.test.ts": "2e33bbdfac5ffe04919526e755680e259e0a4aeef4cf411c5b39b1ccbeac0b13",
|
||||
"tests/multi-key.test.ts": "1879363e3718e8c538f01dcb180f78cbaa9b4a672e759068ec10b1188c1be654",
|
||||
"public/index.html": "859d06fa573f9f5409bc7d575e752a06dd08ce77fc67f464eea248f3b8f274be",
|
||||
"public/style.css": "7a993fc7fbd5edd29dfbff919c3370da3dc44fd6abc022709d55b892e3c0a893",
|
||||
"public/app.js": "7e08218bf04a2614bf801bc5c1c33f239bcdd2975804aa5869e60fdb6c4aaa3d",
|
||||
"public/favicon.svg": "574d5efbf305158c498636fbc81ecd27756b7cb7ff77ad73b4051fcf07d8fe42",
|
||||
"src/cli.ts": "a29c42750d81061f4721ce44ac3684d17d66fec791db155452234116c8988f34",
|
||||
"src/page.ts": "fbad1e2a9b1b2157444a715f55257f9992fea9b8dd8a9d3af15ef9545775f7f8",
|
||||
"src/profile.ts": "215ba42fa05cda235d1c9d09d42afbfc8c43a1d53fa4d7b7b4253948d6a3cdaf",
|
||||
"src/config.ts": "6d84f58c7859b49d291429819a4cd8b73051720ec420b34fcfb86538ed1cb687",
|
||||
"src/server.ts": "b84d14af6d805e1872875bd1aae94823b80d429139af9dc61ed937281795e2bf",
|
||||
"src/run.ts": "fe6b0ed145b6737ed472c929bb0e7f6c77721a0e4dfa9b6d785c3f09a2c5c028"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"id": "draw-ui",
|
||||
"label": "图片生成服务 API Key",
|
||||
"credential": "draw-ui/zenmux/default",
|
||||
"ui": {
|
||||
"title": "配置服务",
|
||||
"placeholder": "粘贴该服务的 API Key",
|
||||
"saveLabel": "保存"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"id": "draw-ui",
|
||||
"label": "图片 API Key",
|
||||
"credential": "draw-ui/openai/default",
|
||||
"ui": {
|
||||
"title": "配置服务",
|
||||
"placeholder": "粘贴该服务的 API Key",
|
||||
"saveLabel": "保存"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"default": [
|
||||
{
|
||||
"manifest": "default.json",
|
||||
"env": "ZENMUX_API_KEY"
|
||||
}
|
||||
],
|
||||
"openai": [
|
||||
{
|
||||
"manifest": "openai.json",
|
||||
"env": "OPENAI_API_KEY"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
{
|
||||
"name": "skill-credentials",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "skill-credentials",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/keyring": "2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.20.1",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-2.0.0.tgz",
|
||||
"integrity": "sha512-TnrIt0nO9U2Ue9E9vJQjso1hqhIYiGrn2Ew1HBsMSqQe4+ceOqPJJwRVsV0/Wy7pqp04/doOLASyIFM3gGwZJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/keyring-darwin-arm64": "2.0.0",
|
||||
"@napi-rs/keyring-darwin-x64": "2.0.0",
|
||||
"@napi-rs/keyring-freebsd-x64": "2.0.0",
|
||||
"@napi-rs/keyring-linux-arm-gnueabihf": "2.0.0",
|
||||
"@napi-rs/keyring-linux-arm64-gnu": "2.0.0",
|
||||
"@napi-rs/keyring-linux-arm64-musl": "2.0.0",
|
||||
"@napi-rs/keyring-linux-riscv64-gnu": "2.0.0",
|
||||
"@napi-rs/keyring-linux-x64-gnu": "2.0.0",
|
||||
"@napi-rs/keyring-linux-x64-musl": "2.0.0",
|
||||
"@napi-rs/keyring-win32-arm64-msvc": "2.0.0",
|
||||
"@napi-rs/keyring-win32-ia32-msvc": "2.0.0",
|
||||
"@napi-rs/keyring-win32-x64-msvc": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-darwin-arm64": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-2.0.0.tgz",
|
||||
"integrity": "sha512-yvIfviiXpsDSsPdyzWWd7STZt7v774biPfMBpWkiK7rwauwWbOmVjUzgiJ11rbhJbWLqXE3CuQMyLgvcbm3jIA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-darwin-x64": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-2.0.0.tgz",
|
||||
"integrity": "sha512-XJUONH0c5cg7M9/1Vj3WeIi8TtWYdZlo8Jqho09ga8OWm9cFNxUv7+4QZx5UK/3JSp2qiyhTlresyKdcpwpnRw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-freebsd-x64": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-2.0.0.tgz",
|
||||
"integrity": "sha512-u/M114J9Lp3RqtIZihIqhvreNQ5f8wgLFo9tJFy+bIIO/xEHkMR0LENrfUF8hSY6FTNyS2kSbbkN6yOItBFY5g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-arm-gnueabihf": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-2.0.0.tgz",
|
||||
"integrity": "sha512-CHMv/KTuELo/MsrGUha52KrFGPBuBkeAonnMFUd3nNcxBTSmeJbCAZTuVSXzpessdpb4tT9xIMQsv2ZdWrl8iw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-arm64-gnu": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-2.0.0.tgz",
|
||||
"integrity": "sha512-BeUUPGSnW026yDGT4pKuNXDnwxw0xslwiSK6cuOIsDNLi3UO93rfF/7moqKznrBldgsZr8pl9LkMdRk8bnbjEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-arm64-musl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-2.0.0.tgz",
|
||||
"integrity": "sha512-zj7wZ23Vs7SL4odnGDbWnhZhiyrnEgBe4+8dzKHDi1mTBX1d00FYlGOubkjvx3AeK7mm6G2FyFDDFqIxyR82gA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-riscv64-gnu": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-2.0.0.tgz",
|
||||
"integrity": "sha512-xk/1SOhuk2yQvXiN+pBhR4njSfquLm1SUKUIJcPIeYV1bHFXbUsYrXdfy4NtynSW0lhs41zWjLXSdE8TLeTt2g==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-x64-gnu": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-2.0.0.tgz",
|
||||
"integrity": "sha512-12Dq6t2TOrQTibcJcfV5bnHbTvMwEz6zSDqQLHMO2x388gGFQBAeEvw7Hmt+R1QavxspTa2ptgI7axomu9TH+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-x64-musl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-2.0.0.tgz",
|
||||
"integrity": "sha512-7NJZvFUiL1FPCrSIQ1L4IUzN6l/2zoxT3IN6j3rHTuDdIEunJaycrUovDMvPlBNP1Vh3fSYz6Pp7tFjdt/1/oQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-win32-arm64-msvc": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-2.0.0.tgz",
|
||||
"integrity": "sha512-JTktZGXKow0HF/rhaZiQYB8DUS/iSX7S7FvJDoGxPT8mGavKE3w/vadtfKSBNDL4uGef3wu2Ll0XxSfDKBsm2Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-win32-ia32-msvc": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-2.0.0.tgz",
|
||||
"integrity": "sha512-AzvIFTqn1hJzCPu0foeYWn+kXruAIrVq3Z2IfJ24WPbIXUdD7+rcj49fqco7b14Z6C1aUfsSTyvrAgDfUWhKvA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-win32-x64-msvc": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-2.0.0.tgz",
|
||||
"integrity": "sha512-POpEUTV6U+pb69cpuOgtLV4xhcyEWDWq+/9zdiZmNxTL1AenE0MllWrbepS+Hng4M7EarQ5TB3kX69ASqGwfIw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "skill-credentials",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.web.json",
|
||||
"check": "tsc --noEmit -p tsconfig.json",
|
||||
"start": "node src/server.ts",
|
||||
"configure": "node src/cli.ts configure",
|
||||
"configure-page": "node src/cli.ts configure-page",
|
||||
"status": "node src/cli.ts status",
|
||||
"test": "node --test tests/*.test.ts",
|
||||
"test:native": "node tests/native-smoke.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.20.1",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/keyring": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use strict";
|
||||
const get = (id) => document.getElementById(id);
|
||||
const form = get('credential-form');
|
||||
const button = get('save');
|
||||
let inputs = [];
|
||||
let meta;
|
||||
let busy = false;
|
||||
const saveLabel = () => inputs.some((input, index) => input.value.trim() && meta.fields[index].configured)
|
||||
? '替换并保存' : (meta.page.saveLabel ?? (meta.fields.length === 1 ? meta.fields[0].ui?.saveLabel : undefined) ?? '保存');
|
||||
async function request(url, options = {}) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { ...options, credentials: 'same-origin', cache: 'no-store',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Local-Request': '1', ...options.headers }, signal: AbortSignal.timeout(25_000) });
|
||||
}
|
||||
catch {
|
||||
throw new Error('未收到服务回复,请刷新确认保存结果,不要重复提交。');
|
||||
}
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(result.error ?? '操作未完成,请重试。');
|
||||
return result;
|
||||
}
|
||||
function message(text) { const node = get('message'); node.textContent = text; node.hidden = false; }
|
||||
function clearInputs() { inputs.forEach(input => { input.value = ''; }); }
|
||||
function done() {
|
||||
clearInputs();
|
||||
form.hidden = true;
|
||||
get('heading').textContent = '已保存';
|
||||
get('hint').hidden = true;
|
||||
message('可以关闭此页,回到对话继续。');
|
||||
}
|
||||
function update() {
|
||||
button.disabled = busy || !meta || !['waiting', 'partial'].includes(meta.outcome)
|
||||
|| !inputs.some(input => input.value.trim())
|
||||
|| inputs.some((input, index) => !meta.fields[index].configured && !input.value.trim());
|
||||
if (meta && !busy)
|
||||
button.textContent = saveLabel();
|
||||
}
|
||||
function render() {
|
||||
clearInputs();
|
||||
inputs = [];
|
||||
const multi = meta.fields.length > 1;
|
||||
form.classList.toggle('multi', multi);
|
||||
const container = get('fields');
|
||||
container.replaceChildren();
|
||||
meta.fields.forEach((field, index) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'field';
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = 'secret-' + index;
|
||||
label.textContent = field.label;
|
||||
if (!multi)
|
||||
label.className = 'visually-hidden';
|
||||
if (field.configured) {
|
||||
const state = document.createElement('span');
|
||||
state.className = 'field-state';
|
||||
state.textContent = '已配置';
|
||||
label.append(state);
|
||||
}
|
||||
const input = document.createElement('input');
|
||||
input.id = label.htmlFor;
|
||||
input.type = 'password';
|
||||
input.autocomplete = 'new-password';
|
||||
input.autocapitalize = 'off';
|
||||
input.spellcheck = false;
|
||||
input.maxLength = 2500;
|
||||
input.required = !field.configured;
|
||||
input.setAttribute('aria-describedby', 'hint message');
|
||||
input.placeholder = field.configured ? '留空保留,输入则替换' : (field.ui?.placeholder ?? '粘贴 API Key');
|
||||
input.disabled = !['waiting', 'partial'].includes(meta.outcome);
|
||||
wrapper.append(label, input);
|
||||
container.append(wrapper);
|
||||
inputs.push(input);
|
||||
});
|
||||
get('context').textContent = meta.page.label ?? (multi ? meta.fields.length + ' 项凭据' : meta.fields[0].label);
|
||||
const title = meta.page.title ?? (multi ? '输入密钥' : meta.fields[0].ui?.title) ?? '输入密钥';
|
||||
get('heading').textContent = title;
|
||||
document.title = title;
|
||||
const storage = [...new Set(meta.fields.map(field => field.storage))].join('、');
|
||||
get('hint').textContent = '仅保存到' + storage + (meta.fields.some(field => field.configured) ? ' · 已配置项留空保留' : '');
|
||||
update();
|
||||
if (meta.outcome === 'saved')
|
||||
done();
|
||||
else if (meta.outcome === 'partial')
|
||||
message('上次仅部分保存。请核对已配置项,补填未完成项后重试。');
|
||||
else if (meta.outcome !== 'waiting')
|
||||
message('本次配置已结束,请重新打开入口。');
|
||||
}
|
||||
form.addEventListener('input', () => { update(); get('message').hidden = true; });
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (busy || button.disabled)
|
||||
return;
|
||||
busy = true;
|
||||
update();
|
||||
inputs.forEach(input => { input.disabled = true; });
|
||||
button.textContent = '正在保存…';
|
||||
try {
|
||||
const entries = inputs.flatMap((input, index) => input.value.trim() ? [{ credential: meta.fields[index].credential,
|
||||
value: input.value, revision: meta.fields[index].revision, replaceExisting: meta.fields[index].configured }] : []);
|
||||
const result = await request('/api/save', { method: 'POST', body: JSON.stringify({ entries }) });
|
||||
if (result.status === 'saved') {
|
||||
meta.outcome = 'saved';
|
||||
done();
|
||||
}
|
||||
else {
|
||||
clearInputs();
|
||||
meta = await request('/api/meta');
|
||||
render();
|
||||
const statuses = { saved: '已保存', failed: '未确认成功', not_attempted: '未尝试' };
|
||||
message((result.results ?? []).map(item => (meta.fields.find(field => field.credential === item.credential)?.label ?? '凭据') + ':' + (statuses[item.status] ?? '请核对')).join(';')
|
||||
+ '。请核对状态后重新填写未完成项。');
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
clearInputs();
|
||||
try {
|
||||
meta = await request('/api/meta');
|
||||
render();
|
||||
}
|
||||
catch {
|
||||
meta.outcome = 'unknown';
|
||||
}
|
||||
if (meta.outcome !== 'saved')
|
||||
message(error instanceof Error ? error.message : '保存未完成,请刷新确认结果。');
|
||||
}
|
||||
finally {
|
||||
clearInputs();
|
||||
busy = false;
|
||||
inputs.forEach(input => { input.disabled = !['waiting', 'partial'].includes(meta.outcome); });
|
||||
update();
|
||||
}
|
||||
});
|
||||
window.addEventListener('pagehide', clearInputs);
|
||||
async function initialize() {
|
||||
const token = location.hash.slice(1);
|
||||
if (token) {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
await request('/api/session', { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: '{}' });
|
||||
}
|
||||
meta = await request('/api/meta');
|
||||
render();
|
||||
}
|
||||
initialize().catch(error => { get('context').textContent = '暂时无法连接'; message(error instanceof Error ? error.message : '请重新打开配置入口。'); });
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="12" fill="#292929"/><path d="M12 19v-4a8 8 0 0 1 16 0v4" fill="none" stroke="#eeeeee" stroke-width="3"/><rect x="9" y="18" width="22" height="16" rx="5" fill="#eeeeee"/><circle cx="20" cy="25" r="2" fill="#292929"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>输入密钥</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<script type="module" src="/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1 id="heading">输入密钥</h1>
|
||||
<p id="context" class="context">正在连接…</p>
|
||||
<form id="credential-form" autocomplete="off">
|
||||
<div id="fields">
|
||||
<label for="secret" class="visually-hidden">密钥</label>
|
||||
<input id="secret" name="secret" type="password" placeholder="粘贴 API Key" autocomplete="new-password" autocapitalize="off" autocorrect="off" spellcheck="false" maxlength="2500" required disabled aria-describedby="hint message" />
|
||||
</div>
|
||||
<button id="save" type="submit" disabled>保存</button>
|
||||
</form>
|
||||
<p id="hint" class="hint">仅保存到本机系统凭据库</p>
|
||||
<p id="message" class="message" role="status" aria-live="polite" hidden></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,143 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #282828;
|
||||
background: #fafafa;
|
||||
font-synthesis: none;
|
||||
color-scheme: light;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100svh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin-top: -5vh;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 580;
|
||||
letter-spacing: -.45px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.context {
|
||||
margin: 0 0 19px;
|
||||
font-size: 11.5px;
|
||||
color: #777777;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
#fields, .field { display: contents; }
|
||||
form.multi { grid-template-columns: 1fr; gap: 18px; }
|
||||
.multi #fields { display: grid; gap: 17px; }
|
||||
.multi .field { display: grid; gap: 7px; }
|
||||
.multi label { font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.field-state { color: #818181; font-size: 10.5px; margin-left: 7px; }
|
||||
.multi button { justify-self: end; height: 36px; }
|
||||
body:has(form.multi) { align-items: safe center; }
|
||||
body:has(form.multi) main { margin-top: 0; }
|
||||
input, button {
|
||||
font: inherit;
|
||||
border-radius: 8px;
|
||||
height: 42px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #dddddd;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
color: #303030;
|
||||
outline: none;
|
||||
box-shadow: 0 1px 2px #00000003;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input::placeholder {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
}
|
||||
input:hover:not(:disabled) { border-color: #c5c5c5; }
|
||||
input:focus {
|
||||
border-color: #929292;
|
||||
box-shadow: 0 0 0 3px #eeeeee, 0 1px 2px #00000003;
|
||||
}
|
||||
input:disabled { background: #f4f4f4; }
|
||||
button {
|
||||
min-width: 62px;
|
||||
padding: 0 15px;
|
||||
background: #303030;
|
||||
border: 1px solid #292929;
|
||||
color: #fafafa;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 #ffffff0d, 0 1px 2px #0000000c;
|
||||
transition: background .15s, border-color .15s, transform .12s;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
background: #454545;
|
||||
border-color: #3c3c3c;
|
||||
}
|
||||
button:active:not(:disabled) { transform: translateY(1px); }
|
||||
button:disabled {
|
||||
background: #ececec;
|
||||
border-color: #e3e3e3;
|
||||
color: #949494;
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 3px solid #c9c9c9;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.hint {
|
||||
margin: 12px 0 0;
|
||||
font-size: 10.5px;
|
||||
color: #818181;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.message {
|
||||
margin: 12px 0 0;
|
||||
font-size: 11px;
|
||||
color: #5c5c5c;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.message.success { color: #5c5c5c; }
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
main { margin-top: -8vh; }
|
||||
input { font-size: 16px; }
|
||||
input, button { height: 44px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { loadManifest, nativeBackend, PublicError, validateManifest, type Manifest } from './config.ts';
|
||||
import { loadPage, validateFields, validatePageUI } from './page.ts';
|
||||
|
||||
async function writeConfiguration(file: string, configuration: object) {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
const temporary = file + '.' + randomBytes(8).toString('hex') + '.tmp';
|
||||
try {
|
||||
const handle = await open(temporary, 'wx', 0o600);
|
||||
try { await handle.writeFile(JSON.stringify(configuration, null, 2) + '\n', 'utf8'); await handle.sync(); }
|
||||
finally { await handle.close(); }
|
||||
await rename(temporary, file);
|
||||
} finally { await unlink(temporary).catch(() => {}); }
|
||||
}
|
||||
export async function configurePage(file: string, manifests: string[], options: Record<string, string>, dryRun = false) {
|
||||
if (Object.keys(options).some(key => !['title', 'label', 'save-label'].includes(key))) throw new PublicError('存在不支持的页面配置项。');
|
||||
let existing: { manifests: string[]; ui?: Record<string, string> } | undefined;
|
||||
try {
|
||||
const stat = await lstat(file);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new PublicError('页面配置必须是独立的普通文件。');
|
||||
await loadPage(file);
|
||||
existing = JSON.parse(await readFile(file, 'utf8'));
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
||||
// 命令参数按当前目录解析;写入页面文件后统一保存为相对该文件的路径。
|
||||
const paths = manifests.length ? manifests.map(item => path.relative(path.dirname(file), path.resolve(item)).split(path.sep).join('/')) : existing?.manifests ?? [];
|
||||
validateFields(await Promise.all(paths.map(item => loadManifest(path.resolve(path.dirname(file), item)))));
|
||||
const ui = { ...existing?.ui };
|
||||
for (const [flag, key] of [['title', 'title'], ['label', 'label'], ['save-label', 'saveLabel']])
|
||||
if (options[flag] !== undefined) ui[key] = options[flag];
|
||||
const configuration = { version: 1, manifests: paths, ui: validatePageUI(ui) };
|
||||
if (!dryRun) await writeConfiguration(file, configuration);
|
||||
return { status: dryRun ? 'preview' : 'configured', page: file, configuration };
|
||||
}
|
||||
|
||||
export async function configureManifest(file: string, options: Record<string, string>, dryRun = false) {
|
||||
const allowed = ['id', 'label', 'credential', 'title', 'placeholder', 'save-label'];
|
||||
if (Object.keys(options).some(k => !allowed.includes(k))) throw new PublicError('存在不支持的配置项。');
|
||||
let existing: Manifest | undefined;
|
||||
try {
|
||||
const stat = await lstat(file);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new PublicError('声明必须是独立的普通文件。');
|
||||
existing = await loadManifest(file);
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
||||
if (existing && ((options.id && options.id !== existing.id) || (options.credential && options.credential !== existing.credential)))
|
||||
throw new PublicError('已有声明的身份和凭据引用不可改写。请为新凭据指定新的 --manifest 文件。');
|
||||
const draft: Record<string, unknown> = { version: 1, ...existing };
|
||||
for (const key of ['id', 'label', 'credential']) if (options[key] !== undefined) draft[key] = options[key];
|
||||
const ui = { ...existing?.ui };
|
||||
for (const [flag, key] of [['title', 'title'], ['placeholder', 'placeholder'], ['save-label', 'saveLabel']] as const)
|
||||
if (options[flag] !== undefined) ui[key] = options[flag];
|
||||
if (Object.keys(ui).length) draft.ui = ui;
|
||||
const manifest = validateManifest(draft);
|
||||
if (!dryRun) await writeConfiguration(file, manifest);
|
||||
return { status: dryRun ? 'preview' : 'configured', manifest: file, configuration: manifest };
|
||||
}
|
||||
async function main() {
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
if (!command || command === '--help') {
|
||||
process.stdout.write('configure [--manifest 文件] --id 标识 --label 用途 --credential 引用 [--title 标题] [--placeholder 占位文字] [--save-label 按钮文字] [--dry-run]\nconfigure-page --page 文件 [--manifest 声明(可重复,替换整组)] [--title 标题] [--label 用途] [--save-label 按钮文字] [--dry-run]\nstatus [--manifest 文件]\n');
|
||||
return;
|
||||
}
|
||||
const flags: Record<string, string> = {};
|
||||
const manifests: string[] = [];
|
||||
let dryRun = false;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--dry-run' && ['configure', 'configure-page'].includes(command)) { dryRun = true; continue; }
|
||||
if (command === 'configure-page' && args[i] === '--manifest' && args[i + 1] && !args[i + 1].startsWith('--')) { manifests.push(args[++i]); continue; }
|
||||
const name = args[i].replace(/^--/, '');
|
||||
if (!args[i].startsWith('--') || !args[i + 1] || args[i + 1].startsWith('--') || Object.hasOwn(flags, name))
|
||||
throw new PublicError('命令参数不正确。');
|
||||
flags[name] = args[++i];
|
||||
}
|
||||
if (command === 'configure-page') {
|
||||
if (!flags.page) throw new PublicError('请指定 --page 配置文件。');
|
||||
const file = path.resolve(flags.page); delete flags.page;
|
||||
process.stdout.write(JSON.stringify(await configurePage(file, manifests, flags, dryRun)) + '\n'); return;
|
||||
}
|
||||
const file = path.resolve(flags.manifest ?? fileURLToPath(new URL('../manifests/default.json', import.meta.url)));
|
||||
delete flags.manifest;
|
||||
if (command === 'configure') process.stdout.write(JSON.stringify(await configureManifest(file, flags, dryRun)) + '\n');
|
||||
else if (command === 'status' && !Object.keys(flags).length) {
|
||||
const m = await loadManifest(file);
|
||||
const backend = await nativeBackend(m.credential);
|
||||
const configured = (await backend.get()) !== undefined;
|
||||
process.stdout.write(JSON.stringify({ id: m.id, credential: m.credential, configured, storage: backend.name }) + '\n');
|
||||
if (!configured) process.exitCode = 2;
|
||||
} else throw new PublicError('命令不支持,请使用 --help。');
|
||||
}
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
|
||||
main().catch(error => {
|
||||
process.stderr.write((error instanceof PublicError ? error.message : '配置操作未完成,请检查文件位置与权限。') + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createHmac, randomBytes } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
|
||||
export class PublicError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status = 400) { super(message); this.status = status; }
|
||||
}
|
||||
export type Manifest = {
|
||||
version: 1; id: string; label: string; credential: string;
|
||||
ui?: { title?: string; placeholder?: string; saveLabel?: string };
|
||||
};
|
||||
export const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
export async function loadManifest(file: string): Promise<Manifest> {
|
||||
let m: unknown;
|
||||
try { m = JSON.parse(await readFile(file, 'utf8')); } catch { throw new PublicError('无法读取配置声明。'); }
|
||||
return validateManifest(m);
|
||||
}
|
||||
export function validateManifest(m: unknown): Manifest {
|
||||
if (!object(m) || m.version !== 1 || typeof m.id !== 'string' || !/^[a-z0-9-]{1,80}$/.test(m.id)
|
||||
|| typeof m.label !== 'string' || !m.label.trim() || m.label.length > 120
|
||||
|| typeof m.credential !== 'string' || !/^[a-z0-9][a-z0-9/_.-]{0,150}$/.test(m.credential)
|
||||
|| Object.keys(m).some(k => !['version', 'id', 'label', 'credential', 'ui'].includes(k))) throw new PublicError('配置声明不合法。');
|
||||
if (m.ui !== undefined && (!object(m.ui) || Object.entries(m.ui).some(([k, v]) =>
|
||||
!['title', 'placeholder', 'saveLabel'].includes(k) || typeof v !== 'string' || !v.trim() || v.length > 80)))
|
||||
throw new PublicError('页面配置不合法。');
|
||||
return m as Manifest;
|
||||
}
|
||||
export interface CredentialBackend {
|
||||
name: string;
|
||||
get(): Promise<string | undefined>;
|
||||
set(value: string): Promise<void>;
|
||||
delete(): Promise<void>;
|
||||
}
|
||||
export const service = 'org.oiloil.skill-credentials';
|
||||
const backendError = () => new PublicError('无法访问系统凭据库,请解锁或检查系统凭据服务后重试。', 503);
|
||||
|
||||
// Linux 显式使用 Secret Service,不走可能回退到临时 keyutils 的默认绑定。
|
||||
function linuxBackend(credential: string): CredentialBackend {
|
||||
const attributes = ['service', service, 'account', credential];
|
||||
const run = (args: string[]): Promise<string | undefined> => new Promise((resolve, reject) => {
|
||||
execFile('secret-tool', args, { timeout: 15_000, maxBuffer: 32_768, encoding: 'utf8' }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
if (args[0] === 'lookup' && error.code === 1 && !stderr.trim()) resolve(undefined);
|
||||
else reject(backendError());
|
||||
} else resolve(stdout.replace(/\r?\n$/, ''));
|
||||
});
|
||||
});
|
||||
return { name: 'Linux Secret Service', get: () => run(['lookup', ...attributes]),
|
||||
set: value => new Promise((resolve, reject) => {
|
||||
const child = spawn('secret-tool', ['store', '--label=Skill 凭据', ...attributes], { stdio: ['pipe', 'ignore', 'ignore'], timeout: 15_000, shell: false });
|
||||
child.once('error', () => reject(backendError()));
|
||||
child.stdin.on('error', () => reject(backendError()));
|
||||
child.once('close', code => code === 0 ? resolve() : reject(backendError()));
|
||||
child.stdin.end(value + '\n');
|
||||
}),
|
||||
delete: async () => { await run(['clear', ...attributes]); }
|
||||
};
|
||||
}
|
||||
export async function nativeBackend(credential: string): Promise<CredentialBackend> {
|
||||
if (process.platform === 'linux') return linuxBackend(credential);
|
||||
if (!['darwin', 'win32'].includes(process.platform)) throw new PublicError('当前系统暂不支持凭据保存。', 503);
|
||||
try {
|
||||
const { AsyncEntry } = await import('@napi-rs/keyring');
|
||||
const entry = new AsyncEntry(service, credential);
|
||||
return { name: process.platform === 'darwin' ? 'macOS 钥匙串' : 'Windows 凭据管理器',
|
||||
// 原生绑定的空结果在实际运行中可能为 null,统一为接口约定的 undefined。
|
||||
get: async () => { try { return (await entry.getPassword()) ?? undefined; } catch { throw backendError(); } },
|
||||
set: async value => { try { await entry.setPassword(value); } catch { throw backendError(); } },
|
||||
delete: async () => { try { await entry.deleteCredential(); } catch { throw backendError(); } }
|
||||
};
|
||||
} catch { throw backendError(); }
|
||||
}
|
||||
export function createStore(manifest: Manifest, backend: CredentialBackend) {
|
||||
const salt = randomBytes(32);
|
||||
let busy = false;
|
||||
async function status() {
|
||||
let value: string | undefined;
|
||||
try { value = await backend.get(); } catch { throw backendError(); }
|
||||
const revision = createHmac('sha256', salt).update(value === undefined ? 'missing:' : 'exists:').update(value ?? '').digest('hex');
|
||||
return { revision, configured: value !== undefined, storage: backend.name };
|
||||
}
|
||||
async function validate(input: unknown) {
|
||||
if (!object(input) || typeof input.value !== 'string' || !input.value.trim() || input.value.length > 2500
|
||||
|| /[\r\n\0]/.test(input.value) || typeof input.revision !== 'string'
|
||||
|| Object.keys(input).some(k => !['value', 'revision', 'replaceExisting'].includes(k))) throw new PublicError('请填写有效的单行密钥。');
|
||||
const current = await status();
|
||||
if (current.revision !== input.revision) throw new PublicError('凭据已发生变化,请刷新后重试。', 409);
|
||||
if (current.configured && input.replaceExisting !== true) throw new PublicError('已有凭据,请确认替换后保存。', 409);
|
||||
return { value: input.value.trim() };
|
||||
}
|
||||
async function save(input: unknown) {
|
||||
if (busy) throw new PublicError('正在保存,请稍后。', 409);
|
||||
busy = true;
|
||||
try {
|
||||
const checked = await validate(input);
|
||||
await backend.set(checked.value);
|
||||
return { status: 'saved', skill: manifest.id, credential: manifest.credential, configured: true };
|
||||
} catch (error) { if (error instanceof PublicError) throw error; throw backendError(); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
return { status, save, validate };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { loadManifest, object, PublicError, validateManifest, type Manifest } from './config.ts';
|
||||
|
||||
export type PageUI = { title?: string; label?: string; saveLabel?: string };
|
||||
export function validatePageUI(ui: unknown): PageUI {
|
||||
if (!object(ui) || Object.entries(ui).some(([key, value]) =>
|
||||
!['title', 'label', 'saveLabel'].includes(key) || typeof value !== 'string' || !value.trim() || value.length > 120))
|
||||
throw new PublicError('页面文案配置不合法。');
|
||||
return ui as PageUI;
|
||||
}
|
||||
export function validateFields(fields: Manifest[]): Manifest[] {
|
||||
if (!Array.isArray(fields) || fields.length < 1 || fields.length > 16) throw new PublicError('每页需要 1 至 16 项凭据声明。');
|
||||
fields.forEach(validateManifest);
|
||||
if (new Set(fields.map(field => field.credential)).size !== fields.length) throw new PublicError('同一页不能重复引用相同凭据。');
|
||||
return fields;
|
||||
}
|
||||
export async function loadPage(file: string) {
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(await readFile(file, 'utf8')); } catch { throw new PublicError('无法读取页面配置。'); }
|
||||
if (!object(value) || value.version !== 1 || Object.keys(value).some(key => !['version', 'manifests', 'ui'].includes(key))
|
||||
|| !Array.isArray(value.manifests) || !value.manifests.length || value.manifests.length > 16
|
||||
|| value.manifests.some(item => typeof item !== 'string' || !item.trim())) throw new PublicError('页面配置不合法。');
|
||||
const ui = validatePageUI(value.ui ?? {});
|
||||
const manifests = validateFields(await Promise.all(value.manifests.map(item => loadManifest(path.resolve(path.dirname(file), item)))));
|
||||
return { manifests, ui };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { loadManifest, nativeBackend, object, PublicError } from './config.ts';
|
||||
import { validateFields } from './page.ts';
|
||||
import { parseBindings, prepareCommand } from './run.ts';
|
||||
import { startServer } from './server.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
type Binding = { manifest: string; env: string };
|
||||
export async function loadProfile(name: string, directory = root): Promise<Binding[]> {
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(await readFile(path.join(directory, 'manifests/profiles.json'), 'utf8')); }
|
||||
catch { throw new PublicError('未找到业务凭据配置。'); }
|
||||
if (!/^[a-z0-9-]+$/.test(name) || !object(value) || value.version !== 1 || !object(value.profiles)
|
||||
|| !Object.hasOwn(value.profiles, name)) throw new PublicError('请选择已声明的业务配置。');
|
||||
const items = value.profiles[name];
|
||||
if (!Array.isArray(items) || !items.length || items.length > 16) throw new PublicError('业务凭据配置不合法。');
|
||||
const bindings: Binding[] = items.map(item => {
|
||||
if (!object(item) || Object.keys(item).some(k => !['manifest', 'env'].includes(k))
|
||||
|| typeof item.manifest !== 'string' || !/^[a-z0-9-]+\.json$/.test(item.manifest)
|
||||
|| typeof item.env !== 'string') throw new PublicError('业务凭据绑定不合法。');
|
||||
return { manifest: path.join(directory, 'manifests', item.manifest), env: item.env };
|
||||
});
|
||||
parseBindings([...bindingArgs(bindings), '--', 'check']);
|
||||
validateFields(await Promise.all(bindings.map(b => loadManifest(b.manifest))));
|
||||
return bindings;
|
||||
}
|
||||
function bindingArgs(bindings: Binding[]) {
|
||||
return bindings.flatMap(b => ['--manifest', b.manifest, '--env', b.env]);
|
||||
}
|
||||
export async function profileStatus(bindings: Binding[], environment = process.env,
|
||||
read = async (ref: string) => (await nativeBackend(ref)).get()) {
|
||||
const fields = [];
|
||||
for (const b of bindings) {
|
||||
const m = await loadManifest(b.manifest);
|
||||
const fromEnv = Boolean(environment[b.env]?.trim());
|
||||
const configured = fromEnv || Boolean(await read(m.credential));
|
||||
fields.push({ credential: m.credential, configured, source: fromEnv ? 'environment' : 'system-store' });
|
||||
}
|
||||
return { configured: fields.every(f => f.configured), fields };
|
||||
}
|
||||
export async function prepareProfile(bindings: Binding[], command: string[], environment = process.env,
|
||||
read = async (ref: string) => (await nativeBackend(ref)).get()) {
|
||||
if (!command.length) throw new PublicError('请指定真实业务程序。');
|
||||
// 环境注入优先;本次只读取所选业务需要的凭据。
|
||||
const missing = bindings.filter(b => !environment[b.env]?.trim());
|
||||
if (!missing.length) return { command: command[0], args: command.slice(1), env: { ...environment } };
|
||||
return prepareCommand([...bindingArgs(missing), '--', ...command], read, environment);
|
||||
}
|
||||
async function main() {
|
||||
const [action, name, ...args] = process.argv.slice(2);
|
||||
if (!['status', 'setup', 'run'].includes(action) || !name || (action !== 'run' && args.length)
|
||||
|| (action === 'run' && (args[0] !== '--' || args.length < 2)))
|
||||
throw new PublicError('用法:node src/profile.ts status|setup 配置名;node src/profile.ts run 配置名 -- 程序 参数');
|
||||
const bindings = await loadProfile(name);
|
||||
if (action === 'status') {
|
||||
const status = await profileStatus(bindings);
|
||||
process.stdout.write(JSON.stringify(status) + '\n');
|
||||
if (!status.configured) process.exitCode = 2;
|
||||
} else if (action === 'setup') {
|
||||
const manifests = await Promise.all(bindings.map(b => loadManifest(b.manifest)));
|
||||
const app = await startServer({ manifests, onComplete: result => process.stdout.write(JSON.stringify(result) + '\n') });
|
||||
process.stdout.write(`本机配置页面(由用户亲自填写,30 分钟内有效):\n${app.url}\n`);
|
||||
process.once('SIGINT', app.close); process.once('SIGTERM', app.close);
|
||||
} else {
|
||||
const plan = await prepareProfile(bindings, args.slice(1));
|
||||
const child = spawn(plan.command, plan.args, { env: plan.env, stdio: 'inherit', shell: false });
|
||||
for (const b of bindings) delete plan.env[b.env];
|
||||
child.once('error', () => { process.stderr.write('业务程序启动失败。\n'); process.exitCode = 1; });
|
||||
child.once('exit', code => { process.exitCode = code ?? 1; });
|
||||
}
|
||||
}
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
|
||||
main().catch(error => {
|
||||
process.stderr.write((error instanceof PublicError ? error.message : '配置未完成,请检查依赖与系统凭据服务。') + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { loadManifest, nativeBackend, PublicError } from './config.ts';
|
||||
|
||||
type Binding = { file: string; variable: string };
|
||||
const defaultManifest = fileURLToPath(new URL('../manifests/default.json', import.meta.url));
|
||||
const reserved = new Set(['PATH', 'HOME', 'SHELL', 'NODE_OPTIONS', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES', 'PYTHONPATH', 'PYTHONSTARTUP', 'COMSPEC', 'SYSTEMROOT']);
|
||||
export function parseBindings(args: string[]) {
|
||||
const split = args.indexOf('--');
|
||||
if (split < 0 || !args[split + 1]) throw new PublicError('请指定要启动的程序。');
|
||||
const bindings: Binding[] = [];
|
||||
const variables = new Set<string>();
|
||||
let pending: string | undefined;
|
||||
for (let i = 0; i < split; i += 2) {
|
||||
const value = args[i + 1];
|
||||
if (i + 1 >= split || !value || value.startsWith('--')) throw new PublicError('凭据绑定参数不完整。');
|
||||
if (args[i] === '--manifest' && pending === undefined) pending = value;
|
||||
else if (args[i] === '--env') {
|
||||
if (!/^[A-Z][A-Z0-9_]*$/.test(value) || reserved.has(value) || variables.has(value))
|
||||
throw new PublicError('环境变量名重复或不适合注入凭据。');
|
||||
bindings.push({ file: pending ?? defaultManifest, variable: value });
|
||||
variables.add(value); pending = undefined;
|
||||
} else throw new PublicError('凭据绑定参数不正确。');
|
||||
}
|
||||
if (pending !== undefined || !bindings.length || bindings.length > 16) throw new PublicError('请提供一至十六组完整的凭据绑定。');
|
||||
return { bindings, command: args[split + 1], args: args.slice(split + 2) };
|
||||
}
|
||||
export async function prepareCommand(
|
||||
args: string[],
|
||||
readCredential: (ref: string) => Promise<string | undefined> = async ref => (await nativeBackend(ref)).get(),
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
const plan = parseBindings(args);
|
||||
const manifests = await Promise.all(plan.bindings.map(binding => loadManifest(binding.file)));
|
||||
const env = { ...baseEnv };
|
||||
try {
|
||||
for (let i = 0; i < plan.bindings.length; i++) {
|
||||
const value = await readCredential(manifests[i].credential);
|
||||
if (!value) throw new PublicError('有凭据尚未配置,任务未启动。');
|
||||
env[plan.bindings[i].variable] = value;
|
||||
}
|
||||
return { ...plan, env };
|
||||
} catch (error) {
|
||||
for (const binding of plan.bindings) delete env[binding.variable];
|
||||
if (error instanceof PublicError) throw error;
|
||||
throw new PublicError('凭据读取未完成,任务未启动。');
|
||||
}
|
||||
}
|
||||
async function main() {
|
||||
const prepared = await prepareCommand(process.argv.slice(2));
|
||||
const child = spawn(prepared.command, prepared.args, { env: prepared.env, stdio: 'inherit', shell: false });
|
||||
for (const binding of prepared.bindings) delete prepared.env[binding.variable];
|
||||
child.once('error', () => { process.stderr.write('无法启动目标程序。\n'); process.exitCode = 1; });
|
||||
child.once('exit', code => { process.exitCode = code ?? 1; });
|
||||
}
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
|
||||
main().catch(error => {
|
||||
process.stderr.write((error instanceof PublicError ? error.message : '无法读取凭据或启动参数不正确。') + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { createStore, loadManifest, nativeBackend, object, PublicError, type Manifest, type CredentialBackend } from './config.ts';
|
||||
import { loadPage, validateFields, validatePageUI, type PageUI } from './page.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
function equal(a: string, b: string) { const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); }
|
||||
export async function startServer(options: { manifest?: Manifest; manifests?: Manifest[]; ui?: PageUI; backend?: CredentialBackend; backends?: CredentialBackend[]; port?: number; ttlMs?: number; onComplete?: (result: object) => void }) {
|
||||
if (options.manifest && options.manifests) throw new PublicError('不能同时指定单项与多项声明。');
|
||||
const fields = validateFields(options.manifests ?? (options.manifest ? [options.manifest] : []));
|
||||
const ui = validatePageUI(options.ui ?? {});
|
||||
if ((options.backends && options.backends.length !== fields.length) || (options.backend && (fields.length !== 1 || options.backends)))
|
||||
throw new PublicError('凭据后端与字段数量不匹配。');
|
||||
const stores = await Promise.all(fields.map(async (field, index) => createStore(field,
|
||||
options.backends?.[index] ?? options.backend ?? await nativeBackend(field.credential))));
|
||||
const store = stores[0];
|
||||
const identity = fields.length === 1 ? { skill: fields[0].id, credential: fields[0].credential }
|
||||
: { credentials: fields.map(field => ({ skill: field.id, credential: field.credential })) };
|
||||
let lastResult: object | undefined;
|
||||
const bootstrap = randomBytes(32).toString('hex');
|
||||
const session = randomBytes(32).toString('hex');
|
||||
// 同一主机上的不同端口共享 cookie 命名空间,因此每个会话使用独立名称。
|
||||
const cookieName = 'credentials_session_' + randomBytes(12).toString('hex');
|
||||
let origin = '';
|
||||
let outcome = 'waiting';
|
||||
let saving = false;
|
||||
let completedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const assets: Record<string, [string, string]> = {
|
||||
'/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
|
||||
'/style.css': ['style.css', 'text/css; charset=utf-8'], '/favicon.svg': ['favicon.svg', 'image/svg+xml']
|
||||
};
|
||||
function json(res: ServerResponse, code: number, body: object) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); }
|
||||
async function body(req: IncomingMessage) {
|
||||
if (req.headers['content-type'] !== 'application/json') throw new PublicError('请求格式不支持。', 415);
|
||||
const parts: Buffer[] = []; let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > 256 * 1024) throw new PublicError('请求内容过大。', 413);
|
||||
parts.push(chunk);
|
||||
}
|
||||
try { return JSON.parse(Buffer.concat(parts).toString('utf8')) as unknown; }
|
||||
catch { throw new PublicError('请求格式不正确。'); }
|
||||
}
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
|
||||
try {
|
||||
if (req.headers.host !== new URL(origin).host) throw new PublicError('请求来源不正确。', 403);
|
||||
const url = new URL(req.url ?? '/', origin);
|
||||
if (url.search) throw new PublicError('不接受 URL 查询参数。');
|
||||
if (req.headers.origin && req.headers.origin !== origin) throw new PublicError('不接受跨站请求。', 403);
|
||||
if (req.method === 'GET' && Object.hasOwn(assets, url.pathname)) {
|
||||
const [file, type] = assets[url.pathname];
|
||||
const content = await readFile(path.join(root, 'public', file));
|
||||
res.writeHead(200, { 'Content-Type': type }); res.end(content); return;
|
||||
}
|
||||
if (url.pathname === '/agent/status' && req.method === 'GET') {
|
||||
if (!equal(req.headers.authorization ?? '', `Bearer ${bootstrap}`)) throw new PublicError('无权访问。', 401);
|
||||
json(res, 200, { ...identity, status: outcome, result: lastResult }); return;
|
||||
}
|
||||
if (req.method === 'POST' && (req.headers.origin !== origin || req.headers['x-local-request'] !== '1'))
|
||||
throw new PublicError('请从本地配置页面提交。', 403);
|
||||
if (url.pathname === '/api/session' && req.method === 'POST') {
|
||||
if (!equal(req.headers.authorization ?? '', `Bearer ${bootstrap}`)) throw new PublicError('入口已失效,请重新打开工具提供的链接。', 401);
|
||||
res.setHeader('Set-Cookie', `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.ceil((options.ttlMs ?? 30 * 60_000) / 1000)}`);
|
||||
json(res, 200, { status: 'ready' }); return;
|
||||
}
|
||||
const cookies = (req.headers.cookie ?? '').split(';').map(c => c.trim());
|
||||
if (!cookies.some(c => equal(c, `${cookieName}=${session}`))) throw new PublicError('请重新打开工具提供的配置链接。', 401);
|
||||
if (url.pathname === '/api/meta' && req.method === 'GET') {
|
||||
const metadata = await Promise.all(fields.map(async (field, index) => ({ ...field, ...await stores[index].status() })));
|
||||
json(res, 200, { ...metadata[0], fields: metadata, page: ui, outcome }); return;
|
||||
}
|
||||
if (url.pathname === '/api/save' && req.method === 'POST') {
|
||||
if (!['waiting', 'partial'].includes(outcome) || saving) throw new PublicError('配置正在保存或已结束,请稍后确认状态。', 409);
|
||||
saving = true;
|
||||
try {
|
||||
const input = await body(req);
|
||||
let result;
|
||||
if (fields.length === 1 && object(input) && !Object.hasOwn(input, 'entries')) result = await store.save(input);
|
||||
else {
|
||||
if (!object(input) || Object.keys(input).some(key => key !== 'entries') || !Array.isArray(input.entries)
|
||||
|| !input.entries.length || input.entries.length > fields.length) throw new PublicError('提交字段不合法。');
|
||||
const seen = new Set<string>();
|
||||
const entries = input.entries.map(entry => {
|
||||
if (!object(entry) || typeof entry.credential !== 'string' || seen.has(entry.credential)) throw new PublicError('提交凭据重复或不合法。');
|
||||
seen.add(entry.credential);
|
||||
const index = fields.findIndex(field => field.credential === entry.credential);
|
||||
if (index === -1) throw new PublicError('提交了页面之外的凭据。');
|
||||
const { credential, ...payload } = entry;
|
||||
return { index, payload };
|
||||
});
|
||||
// 所有格式、替换授权和版本先验证;系统存储不提供跨项事务。
|
||||
await Promise.all([
|
||||
...entries.map(entry => stores[entry.index].validate(entry.payload)),
|
||||
...fields.map(async (field, index) => {
|
||||
if (!seen.has(field.credential) && !(await stores[index].status()).configured)
|
||||
throw new PublicError('请填写所有尚未配置的密钥。');
|
||||
}),
|
||||
]);
|
||||
const results: { credential: string; status: string }[] = [];
|
||||
let failed = false;
|
||||
for (const entry of entries) {
|
||||
let status = 'not_attempted';
|
||||
if (!failed) {
|
||||
try { await stores[entry.index].save(entry.payload); status = 'saved'; }
|
||||
catch { status = 'failed'; failed = true; }
|
||||
}
|
||||
results.push({ credential: fields[entry.index].credential, status });
|
||||
}
|
||||
result = { ...identity, status: failed ? 'partial' : 'saved', results };
|
||||
}
|
||||
outcome = result.status;
|
||||
lastResult = result;
|
||||
json(res, 200, result);
|
||||
options.onComplete?.(result);
|
||||
if (outcome === 'saved') { completedTimer = setTimeout(close, 90_000); completedTimer.unref(); }
|
||||
return;
|
||||
} finally { saving = false; }
|
||||
}
|
||||
if (url.pathname === '/api/cancel' && req.method === 'POST') {
|
||||
if (!['waiting', 'partial'].includes(outcome) || saving) throw new PublicError('配置正在保存或已结束。', 409);
|
||||
outcome = 'cancelled'; json(res, 200, { status: outcome });
|
||||
options.onComplete?.({ ...identity, status: outcome });
|
||||
completedTimer = setTimeout(close, 500); completedTimer.unref(); return;
|
||||
}
|
||||
throw new PublicError('接口不存在。', 404);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) json(res, error instanceof PublicError ? error.status : 500,
|
||||
{ error: error instanceof PublicError ? error.message : '操作未完成,请检查系统凭据服务。' });
|
||||
else res.end();
|
||||
}
|
||||
});
|
||||
server.requestTimeout = 15_000;
|
||||
server.headersTimeout = 10_000;
|
||||
server.maxHeadersCount = 30;
|
||||
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, '127.0.0.1', () => resolve()); });
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('无法启动本机服务。');
|
||||
origin = `http://127.0.0.1:${addr.port}`;
|
||||
const expiry = setTimeout(() => {
|
||||
if (['waiting', 'partial'].includes(outcome)) options.onComplete?.({ ...identity, status: 'expired', result: lastResult });
|
||||
close();
|
||||
}, options.ttlMs ?? 30 * 60_000);
|
||||
expiry.unref();
|
||||
function close() { clearTimeout(expiry); clearTimeout(completedTimer); server.close(); server.closeAllConnections(); }
|
||||
return { origin, url: `${origin}/#${bootstrap}`, bootstrap, close };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const manifestFiles: string[] = [];
|
||||
let pageFile: string | undefined;
|
||||
let port = 0;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--manifest' && args[i + 1]) manifestFiles.push(path.resolve(args[++i]));
|
||||
else if (args[i] === '--page' && args[i + 1] && !pageFile) pageFile = path.resolve(args[++i]);
|
||||
else if (args[i] === '--port' && /^\d+$/.test(args[i + 1] ?? '')) port = Number(args[++i]);
|
||||
else throw new PublicError('用法:npm start -- [--manifest 声明路径(可重复) | --page 页面配置] [--port 端口]');
|
||||
}
|
||||
if (port > 65535) throw new PublicError('端口不合法。');
|
||||
if (pageFile && manifestFiles.length) throw new PublicError('--page 与 --manifest 不能同时使用。');
|
||||
const page = pageFile ? await loadPage(pageFile) : {
|
||||
manifests: await Promise.all((manifestFiles.length ? manifestFiles : [path.join(root, 'manifests', 'default.json')]).map(loadManifest))
|
||||
};
|
||||
const app = await startServer({ ...page, port,
|
||||
onComplete: result => process.stdout.write(JSON.stringify(result) + '\n') });
|
||||
process.stdout.write(`本机配置页面(30 分钟内有效):\n${app.url}\n`);
|
||||
process.once('SIGINT', app.close); process.once('SIGTERM', app.close);
|
||||
}
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
||||
main().catch(() => { process.stderr.write('启动失败,请检查 Node.js 版本、声明文件和端口。\n'); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const script = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
|
||||
const run = (args: string[]) => new Promise<{ code: number; output: string }>(resolve => {
|
||||
execFile(process.execPath, [script, 'configure', ...args], { encoding: 'utf8' },
|
||||
(error, stdout, stderr) => resolve({ code: error ? 1 : 0, output: stdout + stderr }));
|
||||
});
|
||||
test('真实 CLI:创建、局部修改、幂等与只读预览', async t => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'credential-configure-')); t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const file = path.join(dir, 'service.credential.json');
|
||||
const create = ['--manifest', file, '--id', 'sample-skill', '--label', '服务凭据', '--credential', 'sample/service/default'];
|
||||
assert.equal((await run(create)).code, 0);
|
||||
const first = await readFile(file, 'utf8');
|
||||
assert.equal((await run(create)).code, 0); assert.equal(await readFile(file, 'utf8'), first);
|
||||
assert.equal((await run(['--manifest', file, '--title', '连接服务', '--placeholder', '输入访问凭据'])).code, 0);
|
||||
const updated = JSON.parse(await readFile(file, 'utf8'));
|
||||
assert.equal(updated.credential, 'sample/service/default'); assert.equal(updated.ui.title, '连接服务');
|
||||
const before = await readFile(file, 'utf8');
|
||||
assert.equal((await run(['--manifest', file, '--label', '预览', '--dry-run'])).code, 0);
|
||||
assert.equal(await readFile(file, 'utf8'), before);
|
||||
});
|
||||
test('真实 CLI:拒绝更换已有身份、未知字段和不完整声明', async t => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'credential-configure-')); t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const file = path.join(dir, 'service.credential.json');
|
||||
await run(['--manifest', file, '--id', 'sample-skill', '--label', '服务凭据', '--credential', 'sample/service/default']);
|
||||
const before = await readFile(file, 'utf8');
|
||||
assert.equal((await run(['--manifest', file, '--credential', 'other/account'])).code, 1);
|
||||
assert.equal((await run(['--manifest', file, '--api-key', 'FAKE_VALUE_FOR_TEST_ONLY'])).code, 1);
|
||||
assert.equal(await readFile(file, 'utf8'), before);
|
||||
assert.equal((await run(['--manifest', path.join(dir, 'incomplete.json'), '--label', '缺字段'])).code, 1);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createStore, loadManifest, type CredentialBackend } from '../src/config.ts';
|
||||
import { startServer } from '../src/server.ts';
|
||||
|
||||
const manifest = await loadManifest(fileURLToPath(new URL('../manifests/default.json', import.meta.url)));
|
||||
const fake = 'TEST_ONLY_NOT_A_REAL_SECRET_12345';
|
||||
test('通用页面默认使用中性色,不引入未经配置的品牌色', async () => {
|
||||
const css = await readFile(new URL('../public/style.css', import.meta.url), 'utf8');
|
||||
for (const match of css.matchAll(/#([0-9a-f]{6})(?:[0-9a-f]{2})?\b/gi)) {
|
||||
const color = match[1];
|
||||
assert.equal(color.slice(0, 2), color.slice(2, 4));
|
||||
assert.equal(color.slice(2, 4), color.slice(4, 6));
|
||||
}
|
||||
});
|
||||
function memory(initial?: string) {
|
||||
let value = initial;
|
||||
let writes = 0;
|
||||
const backend: CredentialBackend = { name: '测试凭据库', get: async () => value,
|
||||
set: async v => { value = v; writes++; }, delete: async () => { value = undefined; } };
|
||||
return { backend, value: () => value, writes: () => writes };
|
||||
}
|
||||
test('首次保存只写入凭据后端;状态与结果不包含密钥', async () => {
|
||||
const m = memory();
|
||||
const store = createStore(manifest, m.backend);
|
||||
const before = await store.status();
|
||||
assert.equal(before.configured, false);
|
||||
const saved = await store.save({ revision: before.revision, value: fake });
|
||||
assert.equal(m.value(), fake);
|
||||
const after = await store.status();
|
||||
assert.equal(after.configured, true);
|
||||
assert.equal(JSON.stringify({ saved, after }).includes(fake), false);
|
||||
assert.equal('target' in after, false);
|
||||
});
|
||||
test('已有值需要确认替换,旧版本请求不能覆盖新值', async () => {
|
||||
const m = memory('TEST_OLD');
|
||||
const store = createStore(manifest, m.backend);
|
||||
const before = await store.status();
|
||||
await assert.rejects(store.save({ revision: before.revision, value: fake }), /确认替换/);
|
||||
assert.equal(m.writes(), 0);
|
||||
await store.save({ revision: before.revision, value: fake, replaceExisting: true });
|
||||
await assert.rejects(store.save({ revision: before.revision, value: 'TEST_STALE', replaceExisting: true }), /发生变化/);
|
||||
assert.equal(m.value(), fake);
|
||||
});
|
||||
test('凭据服务不可用时明确失败,不回退文件,不泄漏底层错误', async () => {
|
||||
const bad: CredentialBackend = { name: '不可用', get: async () => { throw Error(fake); },
|
||||
set: async () => { throw Error(fake); }, delete: async () => {} };
|
||||
await assert.rejects(createStore(manifest, bad).status(), e => e instanceof Error && !e.message.includes(fake) && /系统凭据/.test(e.message));
|
||||
const m = memory(); m.backend.set = async () => { throw Error(fake); };
|
||||
const store = createStore(manifest, m.backend);
|
||||
await assert.rejects(store.save({ revision: (await store.status()).revision, value: fake }), e => e instanceof Error && !e.message.includes(fake));
|
||||
});
|
||||
test('拒绝路径、旧 JSON 请求、空值、多行及超长密钥', async () => {
|
||||
const m = memory(); const store = createStore(manifest, m.backend);
|
||||
const revision = (await store.status()).revision;
|
||||
for (const input of [{ revision, value: fake, target: '/tmp/unwanted.json' },
|
||||
{ revision, values: { api_key: fake } }, { revision, value: '' },
|
||||
{ revision, value: 'one\ntwo' }, { revision, value: 'x'.repeat(2501) }])
|
||||
await assert.rejects(store.save(input));
|
||||
assert.equal(m.writes(), 0);
|
||||
});
|
||||
test('HTTP 认证、Host 和跨站保护、脱敏及默认单字段占位框', async t => {
|
||||
const m = memory(); const emitted: object[] = [];
|
||||
const app = await startServer({ manifest, backend: m.backend, onComplete: v => emitted.push(v) });
|
||||
t.after(app.close);
|
||||
assert.equal((await fetch(app.origin + '/api/meta')).status, 401);
|
||||
const wrongHost = await new Promise<number | undefined>((resolve, reject) => {
|
||||
const req = httpRequest(app.origin, { headers: { Host: 'attacker.example' } }, res => { res.resume(); resolve(res.statusCode); });
|
||||
req.on('error', reject); req.end();
|
||||
});
|
||||
assert.equal(wrongHost, 403);
|
||||
const html = await fetch(app.origin);
|
||||
assert.match(html.headers.get('Content-Security-Policy')!, /frame-ancestors 'none'/);
|
||||
assert.equal((await html.text()).match(/<input\b/g)?.length, 1);
|
||||
const headers: Record<string, string> = { Origin: app.origin, 'X-Local-Request': '1', 'Content-Type': 'application/json', Authorization: 'Bearer ' + app.bootstrap };
|
||||
const auth = await fetch(app.origin + '/api/session', { method: 'POST', headers, body: '{}' });
|
||||
assert.equal(auth.status, 200); headers.Cookie = auth.headers.get('set-cookie')!.split(';')[0];
|
||||
const meta = await (await fetch(app.origin + '/api/meta', { headers })).json();
|
||||
assert.equal(meta.label, manifest.label);
|
||||
assert.deepEqual(meta.ui, manifest.ui);
|
||||
const payload = { revision: meta.revision, value: fake };
|
||||
const post = (extras: Record<string, string>) => fetch(app.origin + '/api/save', { method: 'POST', headers: { ...headers, ...extras }, body: JSON.stringify(payload) });
|
||||
assert.equal((await post({ Origin: 'https://attacker.example' })).status, 403);
|
||||
assert.equal((await post({ 'X-Local-Request': '' })).status, 403);
|
||||
const result = await post({}); assert.equal(result.status, 200);
|
||||
const visible = [await result.text(), JSON.stringify(emitted)];
|
||||
visible.push(await (await fetch(app.origin + '/api/meta', { headers })).text());
|
||||
const agent = await (await fetch(app.origin + '/agent/status', { headers })).json();
|
||||
assert.equal(agent.status, 'saved'); visible.push(JSON.stringify(agent));
|
||||
assert.equal(visible.some(v => v.includes(fake)), false);
|
||||
assert.equal((await post({})).status, 409); assert.equal(m.writes(), 1);
|
||||
});
|
||||
test('取消不写凭据,取消后不能再保存', async t => {
|
||||
const m = memory(); const app = await startServer({ manifest, backend: m.backend }); t.after(app.close);
|
||||
const headers: Record<string, string> = { Origin: app.origin, 'X-Local-Request': '1', 'Content-Type': 'application/json', Authorization: 'Bearer ' + app.bootstrap };
|
||||
const auth = await fetch(app.origin + '/api/session', { method: 'POST', headers, body: '{}' });
|
||||
headers.Cookie = auth.headers.get('set-cookie')!.split(';')[0];
|
||||
assert.equal((await fetch(app.origin + '/api/cancel', { method: 'POST', headers, body: '{}' })).status, 200);
|
||||
assert.equal((await fetch(app.origin + '/api/save', { method: 'POST', headers, body: '{}' })).status, 409);
|
||||
assert.equal(m.writes(), 0);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { createStore, type CredentialBackend, type Manifest } from '../src/config.ts';
|
||||
import { startServer } from '../src/server.ts';
|
||||
|
||||
const manifest = (name: string): Manifest => ({ version: 1, id: 'sample-skill', label: name, credential: 'sample-skill/' + name });
|
||||
function memory() {
|
||||
const values = new Map<string, string>();
|
||||
const backend = (ref: string): CredentialBackend => ({
|
||||
name: '测试凭据库', get: async () => values.get(ref),
|
||||
set: async value => { values.set(ref, value); }, delete: async () => { values.delete(ref); }
|
||||
});
|
||||
return { values, backend };
|
||||
}
|
||||
test('同一个 Skill 的多个 key 独立保存、替换和删除', async () => {
|
||||
const m = memory(), a = manifest('first'), b = manifest('second');
|
||||
const sa = createStore(a, m.backend(a.credential)), sb = createStore(b, m.backend(b.credential));
|
||||
await sa.save({ value: 'FAKE_FIRST_VALUE', revision: (await sa.status()).revision });
|
||||
await sb.save({ value: 'FAKE_SECOND_VALUE', revision: (await sb.status()).revision });
|
||||
await sa.save({ value: 'FAKE_REPLACED_VALUE', revision: (await sa.status()).revision, replaceExisting: true });
|
||||
assert.equal(m.values.get(b.credential), 'FAKE_SECOND_VALUE');
|
||||
await m.backend(a.credential).delete();
|
||||
assert.equal((await sa.status()).configured, false); assert.equal((await sb.status()).configured, true);
|
||||
});
|
||||
test('多个端口共享浏览器 cookie 容器时,会话仍互不覆盖', async t => {
|
||||
const m = memory(), a = manifest('first'), b = manifest('second');
|
||||
const one = await startServer({ manifest: a, backend: m.backend(a.credential) });
|
||||
const two = await startServer({ manifest: b, backend: m.backend(b.credential) });
|
||||
t.after(one.close); t.after(two.close);
|
||||
const jar = new Map<string, string>();
|
||||
for (const app of [one, two]) {
|
||||
const auth = await fetch(app.origin + '/api/session', { method: 'POST',
|
||||
headers: { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap, 'X-Local-Request': '1' } });
|
||||
assert.equal(auth.status, 200);
|
||||
const cookie = auth.headers.get('set-cookie')!.split(';')[0], split = cookie.indexOf('=');
|
||||
jar.set(cookie.slice(0, split), cookie.slice(split + 1));
|
||||
}
|
||||
assert.equal(jar.size, 2);
|
||||
const cookie = [...jar].map(([k, v]) => k + '=' + v).join('; ');
|
||||
for (const [app, ref, value] of [[one, a.credential, 'FAKE_FIRST_VALUE'], [two, b.credential, 'FAKE_SECOND_VALUE']] as const) {
|
||||
const headers = { Cookie: cookie, Origin: app.origin, 'X-Local-Request': '1', 'Content-Type': 'application/json' };
|
||||
const response = await fetch(app.origin + '/api/meta', { headers }); assert.equal(response.status, 200);
|
||||
const meta = await response.json();
|
||||
const saved = await fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({ revision: meta.revision, value }) });
|
||||
assert.equal(saved.status, 200);
|
||||
const status = await (await fetch(app.origin + '/agent/status', { headers: { Authorization: 'Bearer ' + app.bootstrap } })).json();
|
||||
assert.equal(status.credential, ref); assert.equal(status.status, 'saved');
|
||||
}
|
||||
assert.equal(m.values.size, 2);
|
||||
assert.equal((await fetch(one.origin + '/agent/status', { headers: { Authorization: 'Bearer ' + two.bootstrap } })).status, 401);
|
||||
});
|
||||
test('同一凭据的旧会话在其他会话保存后不能覆盖', async () => {
|
||||
const m = memory(), a = manifest('shared');
|
||||
const first = createStore(a, m.backend(a.credential)), second = createStore(a, m.backend(a.credential));
|
||||
const initial = await second.status();
|
||||
await first.save({ revision: (await first.status()).revision, value: 'FAKE_LATEST_VALUE' });
|
||||
await assert.rejects(second.save({ revision: initial.revision, value: 'FAKE_STALE_VALUE', replaceExisting: true }), /发生变化/);
|
||||
assert.equal(m.values.get(a.credential), 'FAKE_LATEST_VALUE');
|
||||
});
|
||||
|
||||
async function session(app: Awaited<ReturnType<typeof startServer>>) {
|
||||
const headers: Record<string, string> = { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap,
|
||||
'X-Local-Request': '1', 'Content-Type': 'application/json' };
|
||||
const auth = await fetch(app.origin + '/api/session', { method: 'POST', headers });
|
||||
headers.Cookie = auth.headers.get('set-cookie')!.split(';')[0];
|
||||
return {
|
||||
meta: async () => (await fetch(app.origin + '/api/meta', { headers })).json(),
|
||||
save: (entries: object[]) => fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({ entries }) }),
|
||||
agent: async () => (await fetch(app.origin + '/agent/status', { headers })).json(),
|
||||
};
|
||||
}
|
||||
test('同页多 key 真实 HTTP 保存、文案与状态脱敏', async t => {
|
||||
const m = memory(), fields = [manifest('one'), manifest('two')], emitted: object[] = [];
|
||||
const app = await startServer({ manifests: fields, backends: fields.map(field => m.backend(field.credential)),
|
||||
ui: { title: '连接服务', label: '双服务配置', saveLabel: '确认保存' }, onComplete: value => emitted.push(value) });
|
||||
t.after(app.close); const client = await session(app); const meta = await client.meta();
|
||||
assert.equal(meta.fields.length, 2); assert.equal(meta.page.title, '连接服务');
|
||||
const response = await client.save(meta.fields.map((field: any, index: number) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_BATCH_' + index })));
|
||||
assert.equal(response.status, 200); const result = await response.json(); assert.equal(result.status, 'saved');
|
||||
assert.equal(m.values.get(fields[0].credential), 'FAKE_BATCH_0'); assert.equal(m.values.get(fields[1].credential), 'FAKE_BATCH_1');
|
||||
assert.equal(JSON.stringify([result, await client.meta(), await client.agent(), emitted]).includes('FAKE_BATCH_'), false);
|
||||
});
|
||||
test('整组预检拒绝无效项、重复项、越界引用、未确认替换和缺失项,零写入', async t => {
|
||||
const m = memory(), fields = [manifest('one'), manifest('two')];
|
||||
m.values.set(fields[0].credential, 'FAKE_OLD');
|
||||
const app = await startServer({ manifests: fields, backends: fields.map(field => m.backend(field.credential)) });
|
||||
t.after(app.close); const client = await session(app); const meta = await client.meta();
|
||||
const entries = meta.fields.map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_NEW', replaceExisting: true }));
|
||||
for (const invalid of [
|
||||
[entries[0]], [entries[0], { ...entries[1], value: 'multi\nline' }],
|
||||
[entries[0], entries[0]], [entries[0], { ...entries[1], credential: 'not/in/page' }],
|
||||
[{ ...entries[0], replaceExisting: false }, entries[1]],
|
||||
[entries[0], { ...entries[1], revision: 'stale' }],
|
||||
[entries[0], { ...entries[1], target: '/tmp/plaintext.json' }],
|
||||
]) { assert.ok((await client.save(invalid)).status >= 400); assert.deepEqual([...m.values], [[fields[0].credential, 'FAKE_OLD']]); }
|
||||
});
|
||||
test('已有项留空不覆盖,中途失败保留已保存项并可仅重试失败项', async t => {
|
||||
const m = memory(), fields = [manifest('one'), manifest('two'), manifest('three'), manifest('four')];
|
||||
m.values.set(fields[0].credential, 'FAKE_KEEP');
|
||||
const backends = fields.map(field => m.backend(field.credential));
|
||||
let fail = true; const original = backends[2].set;
|
||||
backends[2].set = async value => { if (fail) throw Error(value); await original(value); };
|
||||
const app = await startServer({ manifests: fields, backends }); t.after(app.close);
|
||||
const client = await session(app); let meta = await client.meta();
|
||||
const response = await client.save(meta.fields.slice(1).map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_BATCH' })));
|
||||
const result = await response.json(); assert.equal(result.status, 'partial');
|
||||
assert.deepEqual(result.results.map((item: any) => item.status), ['saved', 'failed', 'not_attempted']);
|
||||
assert.equal(m.values.has(fields[3].credential), false);
|
||||
assert.equal((await client.agent()).status, 'partial'); assert.equal(JSON.stringify(result).includes('FAKE_BATCH'), false);
|
||||
assert.equal(m.values.get(fields[0].credential), 'FAKE_KEEP'); assert.equal(m.values.get(fields[1].credential), 'FAKE_BATCH');
|
||||
fail = false; meta = await client.meta();
|
||||
const retry = await client.save(meta.fields.slice(2).map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_RETRY' })));
|
||||
assert.equal((await retry.json()).status, 'saved'); assert.equal(m.values.get(fields[1].credential), 'FAKE_BATCH');
|
||||
});
|
||||
test('16 项预检并行读取,写入仍顺序执行且每项写前复验', async t => {
|
||||
const fields = Array.from({ length: 16 }, (_, index) => manifest('key-' + index));
|
||||
let active = 0, peak = 0, reads = 0, writes = 0;
|
||||
const backends = fields.map((): CredentialBackend => ({ name: '测试凭据库',
|
||||
get: async () => {
|
||||
reads++; active++; peak = Math.max(peak, active);
|
||||
await new Promise(resolve => setTimeout(resolve, 2)); active--; return undefined;
|
||||
},
|
||||
set: async () => { assert.equal(active, 0); writes++; }, delete: async () => {},
|
||||
}));
|
||||
const app = await startServer({ manifests: fields, backends }); t.after(app.close);
|
||||
const client = await session(app); const meta = await client.meta();
|
||||
peak = 0; reads = 0;
|
||||
const response = await client.save(meta.fields.map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_ONLY' })));
|
||||
assert.equal((await response.json()).status, 'saved');
|
||||
assert.equal(peak, 16); assert.equal(reads, 32); assert.equal(writes, 16);
|
||||
});
|
||||
test('页面拒绝重复引用、超限字段和空配置', async () => {
|
||||
const m = memory(), field = manifest('one');
|
||||
for (const fields of [[], [field, field], Array.from({ length: 17 }, (_, index) => manifest('key-' + index))])
|
||||
await assert.rejects(startServer({ manifests: fields, backends: fields.map(item => m.backend(item.credential)) }));
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { nativeBackend, type CredentialBackend } from '../src/config.ts';
|
||||
import { startServer } from '../src/server.ts';
|
||||
|
||||
const namespace = 'test/' + randomUUID();
|
||||
const refs = [namespace + '/first', namespace + '/second'];
|
||||
const entries: { backend: CredentialBackend; owned: boolean }[] = [];
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'credential-native-'));
|
||||
let phase = '初始化';
|
||||
let cleanupFailed = false;
|
||||
let app: Awaited<ReturnType<typeof startServer>> | undefined;
|
||||
try {
|
||||
for (const ref of refs) {
|
||||
const backend = await nativeBackend(ref);
|
||||
if (await backend.get() !== undefined) throw Error();
|
||||
entries.push({ backend, owned: false });
|
||||
}
|
||||
const values = ['TEST_ONLY_A_' + randomUUID(), 'TEST_ONLY_B_' + randomUUID()];
|
||||
phase = '同页 HTTP 多 key 保存与回读';
|
||||
app = await startServer({ manifests: refs.map((credential, index) => ({ version: 1, id: 'sample-skill', label: '测试服务' + index, credential })) });
|
||||
const headers: Record<string, string> = { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap, 'X-Local-Request': '1', 'Content-Type': 'application/json' };
|
||||
const session = await fetch(app.origin + '/api/session', { method: 'POST', headers });
|
||||
headers.Cookie = session.headers.get('set-cookie')!.split(';')[0];
|
||||
const metadata = await (await fetch(app.origin + '/api/meta', { headers })).json();
|
||||
entries.forEach(entry => { entry.owned = true; });
|
||||
const saved = await fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({
|
||||
entries: metadata.fields.map((field: { credential: string; revision: string }, index: number) => ({ credential: field.credential, revision: field.revision, value: values[index] }))
|
||||
}) });
|
||||
if (!saved.ok || (await saved.json()).status !== 'saved') throw Error();
|
||||
for (let i = 0; i < entries.length; i++) if (await entries[i].backend.get() !== values[i]) throw Error();
|
||||
phase = '替换隔离';
|
||||
values[0] = 'TEST_ONLY_A_REPLACED_' + randomUUID();
|
||||
await entries[0].backend.set(values[0]);
|
||||
if (await entries[1].backend.get() !== values[1]) throw Error();
|
||||
phase = '多 key 业务读取';
|
||||
const files = [path.join(directory, 'first.json'), path.join(directory, 'second.json')];
|
||||
for (let i = 0; i < files.length; i++)
|
||||
await writeFile(files[i], JSON.stringify({ version: 1, id: 'sample-skill', label: '测试服务', credential: refs[i] }));
|
||||
const script = fileURLToPath(new URL('../src/run.ts', import.meta.url));
|
||||
await new Promise<void>((resolve, reject) => execFile(process.execPath, [script,
|
||||
'--manifest', files[0], '--env', 'FIRST_API_KEY', '--manifest', files[1], '--env', 'SECOND_API_KEY', '--',
|
||||
process.execPath, '-e', 'process.exit(process.env.FIRST_API_KEY?.startsWith("TEST_ONLY_A_REPLACED_") && process.env.SECOND_API_KEY?.startsWith("TEST_ONLY_B_") ? 0 : 1)'],
|
||||
{ timeout: 20_000 }, error => error ? reject(Error()) : resolve()));
|
||||
phase = '删除隔离';
|
||||
await entries[0].backend.delete();
|
||||
if (await entries[0].backend.get() !== undefined || await entries[1].backend.get() !== values[1]) throw Error();
|
||||
process.stdout.write('系统凭据库多 key 保存、替换隔离、业务读取和删除隔离验证通过(假凭据)。\n');
|
||||
} catch {
|
||||
process.stderr.write('系统凭据库验证未通过,阶段:' + phase + '。\n'); process.exitCode = 1;
|
||||
} finally {
|
||||
app?.close();
|
||||
for (const entry of entries) {
|
||||
if (!entry.owned) continue;
|
||||
try {
|
||||
if (await entry.backend.get() !== undefined) await entry.backend.delete();
|
||||
if (await entry.backend.get() !== undefined) cleanupFailed = true;
|
||||
} catch { cleanupFailed = true; }
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
if (cleanupFailed) {
|
||||
process.stderr.write('测试凭据清理失败,测试引用前缀:' + namespace + '\n'); process.exitCode = 1;
|
||||
} else process.stdout.write('测试凭据和临时声明已清理。\n');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadPage } from '../src/page.ts';
|
||||
|
||||
test('页面配置真实 CLI:相对路径、独立字段文案、更新与只读预览', async t => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'credential-page-')); t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const files = ['one', 'two'].map(name => path.join(dir, name + '.json'));
|
||||
for (const [index, file] of files.entries()) await writeFile(file, JSON.stringify({ version: 1, id: 'sample-skill', label: '服务' + index,
|
||||
credential: 'sample/' + index, ui: { placeholder: '请输入测试凭据' + index } }));
|
||||
const page = path.join(dir, 'page.json');
|
||||
const script = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
|
||||
const run = (args: string[]) => new Promise<number>(resolve => execFile(process.execPath,
|
||||
[script, 'configure-page', '--page', page, ...args], error => resolve(error ? 1 : 0)));
|
||||
const args = ['--manifest', files[0], '--manifest', files[1], '--title', '两项凭据'];
|
||||
assert.equal(await run(args), 0);
|
||||
assert.deepEqual(JSON.parse(await readFile(page, 'utf8')).manifests, ['one.json', 'two.json']);
|
||||
const loaded = await loadPage(page); assert.equal(loaded.manifests.length, 2); assert.equal(loaded.ui.title, '两项凭据');
|
||||
assert.equal(loaded.manifests[1].ui?.placeholder, '请输入测试凭据1');
|
||||
assert.equal(await run(['--label', '业务配置']), 0);
|
||||
const before = await readFile(page, 'utf8');
|
||||
assert.equal(await run(['--title', '只读预览', '--dry-run']), 0); assert.equal(await readFile(page, 'utf8'), before);
|
||||
assert.notEqual(await run(['--manifest', files[0], '--manifest', files[0]]), 0);
|
||||
assert.notEqual(await run(['--api-key', 'FAKE_ONLY']), 0);
|
||||
assert.equal(await readFile(page, 'utf8'), before);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadProfile, prepareProfile, profileStatus } from '../src/profile.ts';
|
||||
import { loadManifest, type CredentialBackend } from '../src/config.ts';
|
||||
import { startServer } from '../src/server.ts';
|
||||
|
||||
test('真实分发配置:正式页面保存后可被对应业务环境读取,状态与参数不含值', async () => {
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const config = JSON.parse(await readFile(path.join(root, 'manifests/profiles.json'), 'utf8'));
|
||||
for (const name of Object.keys(config.profiles)) {
|
||||
const bindings = await loadProfile(name);
|
||||
const manifests = await Promise.all(bindings.map(b => loadManifest(b.manifest)));
|
||||
const values = new Map<string, string>();
|
||||
const backendFactory = async (ref: string): Promise<CredentialBackend> => ({
|
||||
name: 'fake', get: async () => values.get(ref),
|
||||
set: async value => { values.set(ref, value); }, delete: async () => { values.delete(ref); },
|
||||
});
|
||||
const app = await startServer({ manifests, backends: await Promise.all(manifests.map(m => backendFactory(m.credential))) });
|
||||
try {
|
||||
const headers: Record<string, string> = { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap, 'X-Local-Request': '1', 'Content-Type': 'application/json' };
|
||||
const session = await fetch(app.origin + '/api/session', { method: 'POST', headers });
|
||||
headers.Cookie = session.headers.get('set-cookie')!.split(';')[0];
|
||||
assert.equal((await fetch(app.origin)).status, 200);
|
||||
const meta = await (await fetch(app.origin + '/api/meta', { headers })).json();
|
||||
const saved = await fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({ entries: meta.fields.map((f: { credential: string; revision: string }, i: number) => ({ credential: f.credential, revision: f.revision, value: 'TEST_ONLY_PROFILE_' + i })) }) });
|
||||
assert.equal((await saved.json()).status, 'saved');
|
||||
const status = await profileStatus(bindings, {}, async ref => values.get(ref));
|
||||
assert.equal(status.configured, true);
|
||||
assert.equal(JSON.stringify(status).includes('TEST_ONLY'), false);
|
||||
const plan = await prepareProfile(bindings, ['business-program', '--input', 'a b'], {}, async ref => values.get(ref));
|
||||
for (const b of bindings) assert.match(plan.env[b.env]!, /^TEST_ONLY_PROFILE_/);
|
||||
assert.equal(JSON.stringify(plan.args).includes('TEST_ONLY'), false);
|
||||
assert.deepEqual(plan.args, ['--input', 'a b']);
|
||||
} finally { app.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('已有环境凭据无需读取系统库;缺失或后端失败时不启动业务', async () => {
|
||||
const bindings = await loadProfile('default');
|
||||
const env = Object.fromEntries(bindings.map(b => [b.env, 'TEST_ONLY_ENV']));
|
||||
const noRead = async () => { throw Error('TEST_ONLY_FAILURE'); };
|
||||
assert.equal((await profileStatus(bindings, env, noRead)).configured, true);
|
||||
assert.equal((await prepareProfile(bindings, ['business'], env, noRead)).env[bindings[0].env], 'TEST_ONLY_ENV');
|
||||
await assert.rejects(prepareProfile(bindings, ['business'], {}, async () => undefined), /未配置/);
|
||||
await assert.rejects(prepareProfile(bindings, ['business'], {}, noRead), error => error instanceof Error && !error.message.includes('TEST_ONLY'));
|
||||
});
|
||||
|
||||
test('配置拒绝跨目录声明、危险变量与未知业务', async t => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'credential-profile-')); t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
await mkdir(path.join(dir, 'manifests'));
|
||||
for (const binding of [{ manifest: '../outside.json', env: 'API_KEY' }, { manifest: 'default.json', env: 'NODE_OPTIONS' }]) {
|
||||
await writeFile(path.join(dir, 'manifests/profiles.json'), JSON.stringify({ version: 1, profiles: { default: [binding] } }));
|
||||
await assert.rejects(loadProfile('default', dir));
|
||||
}
|
||||
await assert.rejects(loadProfile('unknown', dir));
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { parseBindings, prepareCommand } from '../src/run.ts';
|
||||
|
||||
async function fixture() {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'credential-run-'));
|
||||
const files = [path.join(dir, 'first.json'), path.join(dir, 'second.json')];
|
||||
await Promise.all(files.map((file, i) => writeFile(file, JSON.stringify({ version: 1, id: 'sample-skill', label: '服务', credential: 'sample/key-' + i }))));
|
||||
return { dir, files, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
||||
}
|
||||
test('多个 key 同时注入一个真实子进程,命令参数不包含值', async t => {
|
||||
const f = await fixture(); t.after(f.cleanup);
|
||||
const plan = await prepareCommand(['--manifest', f.files[0], '--env', 'FIRST_API_KEY', '--manifest', f.files[1], '--env', 'SECOND_API_KEY', '--',
|
||||
process.execPath, '-e', 'process.exit(process.env.FIRST_API_KEY && process.env.SECOND_API_KEY && process.env.FIRST_API_KEY !== process.env.SECOND_API_KEY ? 0 : 1)'],
|
||||
async ref => ref.endsWith('0') ? 'FAKE_FIRST_VALUE' : 'FAKE_SECOND_VALUE');
|
||||
assert.equal(plan.env.FIRST_API_KEY, 'FAKE_FIRST_VALUE'); assert.equal(plan.env.SECOND_API_KEY, 'FAKE_SECOND_VALUE');
|
||||
assert.equal(JSON.stringify(plan.args).includes('FAKE_'), false);
|
||||
await new Promise<void>((resolve, reject) => execFile(plan.command, plan.args, { env: plan.env }, error => error ? reject(error) : resolve()));
|
||||
});
|
||||
test('任一 key 缺失或后端失败,不返回可启动的命令,也不修改父环境', async t => {
|
||||
const f = await fixture(); t.after(f.cleanup);
|
||||
const args = ['--manifest', f.files[0], '--env', 'FIRST_API_KEY', '--manifest', f.files[1], '--env', 'SECOND_API_KEY', '--', 'unused'];
|
||||
const base = { KEEP: 'unchanged' };
|
||||
await assert.rejects(prepareCommand(args, async ref => ref.endsWith('0') ? 'FAKE_FIRST_VALUE' : undefined, base), /未配置/);
|
||||
await assert.rejects(prepareCommand(args, async () => { throw Error('FAKE_VALUE_MUST_NOT_LEAK'); }, base),
|
||||
error => error instanceof Error && !error.message.includes('FAKE_'));
|
||||
assert.deepEqual(base, { KEEP: 'unchanged' });
|
||||
});
|
||||
test('拒绝重复变量和不完整绑定,兼容单 key 的原入口', () => {
|
||||
assert.throws(() => parseBindings(['--env', 'DUP_KEY', '--env', 'DUP_KEY', '--', 'unused']), /重复/);
|
||||
assert.throws(() => parseBindings(['--manifest', 'a.json', '--manifest', 'b.json', '--env', 'A_KEY', '--', 'unused']));
|
||||
assert.throws(() => parseBindings(['--env', 'HOME', '--', 'unused']));
|
||||
assert.equal(parseBindings(['--env', 'SERVICE_API_KEY', '--', 'program', 'arg']).bindings.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
// 运行实际构建产物的 DOM 契约测试,不启动浏览器,不接触真实凭据。
|
||||
class Element {
|
||||
children: Element[] = [];
|
||||
value = ''; textContent = ''; hidden = false; disabled = false; type = ''; required = false; placeholder = '';
|
||||
className = ''; id = ''; htmlFor = '';
|
||||
listeners: Record<string, (event: object) => unknown> = {};
|
||||
classes = new Set<string>();
|
||||
classList = { toggle: (key: string, enabled: boolean) => enabled ? this.classes.add(key) : this.classes.delete(key) };
|
||||
append(...children: Element[]) { this.children.push(...children); }
|
||||
replaceChildren() { this.children = []; }
|
||||
setAttribute() {}
|
||||
addEventListener(event: string, callback: (event: object) => unknown) { this.listeners[event] = callback; }
|
||||
}
|
||||
async function harness(configured = false, fail = false) {
|
||||
const nodes = Object.fromEntries(['credential-form', 'fields', 'save', 'heading', 'context', 'hint', 'message'].map(key => [key, new Element()]));
|
||||
const fields = [0, 1].map(index => ({ id: 'sample', label: index ? '<img src=x>' : '语音服务', credential: 'sample/' + index,
|
||||
configured: index === 0 && configured, revision: 'revision-' + index, storage: '测试凭据库', ui: { placeholder: '测试输入' } }));
|
||||
const metadata = { fields, page: { title: '连接服务', label: '两个服务', saveLabel: '确认保存' }, outcome: 'waiting' };
|
||||
const submitted: any[] = [];
|
||||
const lifecycle: Record<string, () => void> = {};
|
||||
const context = { document: { getElementById: (id: string) => nodes[id], createElement: () => new Element(), title: '' },
|
||||
location: { hash: '', pathname: '/' }, history: { replaceState() {} }, AbortSignal,
|
||||
window: { addEventListener: (key: string, callback: () => void) => { lifecycle[key] = callback; } },
|
||||
fetch: async (url: string, options: any) => {
|
||||
if (url === '/api/meta') return { ok: true, json: async () => structuredClone(metadata) };
|
||||
assert.equal(url, '/api/save'); submitted.push(JSON.parse(options.body));
|
||||
if (fail) {
|
||||
metadata.fields[0].configured = true; metadata.outcome = 'partial';
|
||||
return { ok: true, json: async () => ({ status: 'partial', results: [{ credential: 'sample/0', status: 'saved' }, { credential: 'sample/1', status: 'failed' }] }) };
|
||||
}
|
||||
metadata.outcome = 'saved'; return { ok: true, json: async () => ({ status: 'saved' }) };
|
||||
} };
|
||||
runInNewContext(await readFile(new URL('../public/app.js', import.meta.url), 'utf8'), context);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const inputs = () => nodes.fields.children.map(field => field.children[1]);
|
||||
const input = () => nodes['credential-form'].listeners.input({});
|
||||
const submit = () => nodes['credential-form'].listeners.submit({ preventDefault() {} });
|
||||
return { nodes, inputs, input, submit, submitted, lifecycle };
|
||||
}
|
||||
test('真实前端产物:配置生成两个密码框、纯文本标签、必填与成功清空', async () => {
|
||||
const h = await harness();
|
||||
assert.equal(h.inputs().length, 2); assert.ok(h.inputs().every(input => input.type === 'password' && input.required));
|
||||
assert.equal(h.nodes.fields.children[1].children[0].textContent, '<img src=x>');
|
||||
assert.equal(h.nodes.heading.textContent, '连接服务');
|
||||
h.inputs()[0].value = 'FAKE_ONE'; h.input(); assert.equal(h.nodes.save.disabled, true);
|
||||
h.inputs()[1].value = 'FAKE_TWO'; h.input(); assert.equal(h.nodes.save.disabled, false);
|
||||
await h.submit(); assert.equal(h.submitted[0].entries.length, 2);
|
||||
assert.ok(h.inputs().every(input => !input.value)); assert.equal(h.nodes['credential-form'].hidden, true);
|
||||
});
|
||||
test('真实前端产物:已有项留空保留,替换按钮明确,离开清空', async () => {
|
||||
const h = await harness(true);
|
||||
assert.equal(h.inputs()[0].required, false); assert.match(h.inputs()[0].placeholder, /留空保留/);
|
||||
h.inputs()[0].value = 'FAKE_REPLACE'; h.input(); assert.equal(h.nodes.save.textContent, '替换并保存');
|
||||
h.inputs()[0].value = ''; h.inputs()[1].value = 'FAKE_SECOND'; h.input();
|
||||
await h.submit(); assert.equal(h.submitted[0].entries.length, 1); assert.equal(h.submitted[0].entries[0].credential, 'sample/1');
|
||||
h.inputs()[0].value = 'FAKE_LEAVE'; h.lifecycle.pagehide(); assert.equal(h.inputs()[0].value, '');
|
||||
});
|
||||
test('真实前端产物:部分失败不显示全部保存,刷新状态并允许补填', async () => {
|
||||
const h = await harness(false, true);
|
||||
h.inputs().forEach(input => { input.value = 'FAKE_ONLY'; }); h.input(); await h.submit();
|
||||
assert.equal(h.nodes['credential-form'].hidden, false); assert.match(h.nodes.message.textContent, /未确认成功/);
|
||||
assert.ok(h.inputs().every(input => !input.value)); assert.equal(h.inputs()[0].required, false);
|
||||
assert.equal(h.inputs()[1].required, true); assert.equal(h.nodes.save.disabled, true);
|
||||
h.inputs()[1].value = 'FAKE_RETRY'; h.input(); assert.equal(h.nodes.save.disabled, false);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"],
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "web/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"rootDir": "web",
|
||||
"outDir": "public",
|
||||
"types": []
|
||||
},
|
||||
"include": ["web/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
type Field = { id: string; label: string; credential: string; revision: string; configured: boolean; storage: string; ui?: { title?: string; placeholder?: string; saveLabel?: string } };
|
||||
type Metadata = { fields: Field[]; page: { title?: string; label?: string; saveLabel?: string }; outcome: string };
|
||||
type SaveResult = { status: string; results?: { credential: string; status: string }[] };
|
||||
const get = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const form = get<HTMLFormElement>('credential-form');
|
||||
const button = get<HTMLButtonElement>('save');
|
||||
let inputs: HTMLInputElement[] = [];
|
||||
let meta: Metadata;
|
||||
let busy = false;
|
||||
const saveLabel = () => inputs.some((input, index) => input.value.trim() && meta.fields[index].configured)
|
||||
? '替换并保存' : (meta.page.saveLabel ?? (meta.fields.length === 1 ? meta.fields[0].ui?.saveLabel : undefined) ?? '保存');
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { ...options, credentials: 'same-origin', cache: 'no-store',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Local-Request': '1', ...options.headers }, signal: AbortSignal.timeout(25_000) });
|
||||
} catch { throw new Error('未收到服务回复,请刷新确认保存结果,不要重复提交。'); }
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error ?? '操作未完成,请重试。');
|
||||
return result as T;
|
||||
}
|
||||
function message(text: string) { const node = get('message'); node.textContent = text; node.hidden = false; }
|
||||
function clearInputs() { inputs.forEach(input => { input.value = ''; }); }
|
||||
function done() {
|
||||
clearInputs(); form.hidden = true; get('heading').textContent = '已保存';
|
||||
get('hint').hidden = true; message('可以关闭此页,回到对话继续。');
|
||||
}
|
||||
function update() {
|
||||
button.disabled = busy || !meta || !['waiting', 'partial'].includes(meta.outcome)
|
||||
|| !inputs.some(input => input.value.trim())
|
||||
|| inputs.some((input, index) => !meta.fields[index].configured && !input.value.trim());
|
||||
if (meta && !busy) button.textContent = saveLabel();
|
||||
}
|
||||
function render() {
|
||||
clearInputs(); inputs = [];
|
||||
const multi = meta.fields.length > 1;
|
||||
form.classList.toggle('multi', multi);
|
||||
const container = get('fields'); container.replaceChildren();
|
||||
meta.fields.forEach((field, index) => {
|
||||
const wrapper = document.createElement('div'); wrapper.className = 'field';
|
||||
const label = document.createElement('label'); label.htmlFor = 'secret-' + index; label.textContent = field.label;
|
||||
if (!multi) label.className = 'visually-hidden';
|
||||
if (field.configured) {
|
||||
const state = document.createElement('span'); state.className = 'field-state'; state.textContent = '已配置'; label.append(state);
|
||||
}
|
||||
const input = document.createElement('input'); input.id = label.htmlFor; input.type = 'password';
|
||||
input.autocomplete = 'new-password'; input.autocapitalize = 'off'; input.spellcheck = false; input.maxLength = 2500;
|
||||
input.required = !field.configured; input.setAttribute('aria-describedby', 'hint message');
|
||||
input.placeholder = field.configured ? '留空保留,输入则替换' : (field.ui?.placeholder ?? '粘贴 API Key');
|
||||
input.disabled = !['waiting', 'partial'].includes(meta.outcome);
|
||||
wrapper.append(label, input); container.append(wrapper); inputs.push(input);
|
||||
});
|
||||
get('context').textContent = meta.page.label ?? (multi ? meta.fields.length + ' 项凭据' : meta.fields[0].label);
|
||||
const title = meta.page.title ?? (multi ? '输入密钥' : meta.fields[0].ui?.title) ?? '输入密钥';
|
||||
get('heading').textContent = title; document.title = title;
|
||||
const storage = [...new Set(meta.fields.map(field => field.storage))].join('、');
|
||||
get('hint').textContent = '仅保存到' + storage + (meta.fields.some(field => field.configured) ? ' · 已配置项留空保留' : '');
|
||||
update();
|
||||
if (meta.outcome === 'saved') done();
|
||||
else if (meta.outcome === 'partial') message('上次仅部分保存。请核对已配置项,补填未完成项后重试。');
|
||||
else if (meta.outcome !== 'waiting') message('本次配置已结束,请重新打开入口。');
|
||||
}
|
||||
form.addEventListener('input', () => { update(); get('message').hidden = true; });
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault(); if (busy || button.disabled) return;
|
||||
busy = true; update(); inputs.forEach(input => { input.disabled = true; }); button.textContent = '正在保存…';
|
||||
try {
|
||||
const entries = inputs.flatMap((input, index) => input.value.trim() ? [{ credential: meta.fields[index].credential,
|
||||
value: input.value, revision: meta.fields[index].revision, replaceExisting: meta.fields[index].configured }] : []);
|
||||
const result = await request<SaveResult>('/api/save', { method: 'POST', body: JSON.stringify({ entries }) });
|
||||
if (result.status === 'saved') { meta.outcome = 'saved'; done(); }
|
||||
else {
|
||||
clearInputs(); meta = await request<Metadata>('/api/meta'); render();
|
||||
const statuses: Record<string, string> = { saved: '已保存', failed: '未确认成功', not_attempted: '未尝试' };
|
||||
message((result.results ?? []).map(item => (meta.fields.find(field => field.credential === item.credential)?.label ?? '凭据') + ':' + (statuses[item.status] ?? '请核对')).join(';')
|
||||
+ '。请核对状态后重新填写未完成项。');
|
||||
}
|
||||
} catch (error) {
|
||||
clearInputs();
|
||||
try { meta = await request<Metadata>('/api/meta'); render(); }
|
||||
catch { meta.outcome = 'unknown'; }
|
||||
if (meta.outcome !== 'saved') message(error instanceof Error ? error.message : '保存未完成,请刷新确认结果。');
|
||||
} finally {
|
||||
clearInputs(); busy = false; inputs.forEach(input => { input.disabled = !['waiting', 'partial'].includes(meta.outcome); }); update();
|
||||
}
|
||||
});
|
||||
window.addEventListener('pagehide', clearInputs);
|
||||
async function initialize() {
|
||||
const token = location.hash.slice(1);
|
||||
if (token) {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
await request('/api/session', { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: '{}' });
|
||||
}
|
||||
meta = await request<Metadata>('/api/meta'); render();
|
||||
}
|
||||
initialize().catch(error => { get('context').textContent = '暂时无法连接'; message(error instanceof Error ? error.message : '请重新打开配置入口。'); });
|
||||
@@ -0,0 +1,546 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
DEFAULT_MODEL = os.getenv("DRAW_MODEL", "openai/gpt-image-2")
|
||||
DEFAULT_PROVIDER = os.getenv("DRAW_PROVIDER", "zenmux")
|
||||
DEFAULT_BASE_URL = os.getenv("ZENMUX_VERTEX_BASE_URL", "https://zenmux.ai/api/vertex-ai")
|
||||
DEFAULT_OUTPUT_ROOT = Path.home() / ".local" / "share" / "draw" / "outputs"
|
||||
DEFAULT_MIME = "image/png"
|
||||
DEFAULT_CODEX_MODEL = os.getenv("DRAW_CODEX_MODEL", "gpt-5.6")
|
||||
|
||||
|
||||
|
||||
|
||||
def _read_env_value(path: Path, key: str) -> str:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
name, value = stripped.split("=", 1)
|
||||
if name.strip() != key:
|
||||
continue
|
||||
value = value.strip().strip('"').strip("'")
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_api_key() -> str:
|
||||
env = os.getenv("ZENMUX_API_KEY", "").strip()
|
||||
if env:
|
||||
return env
|
||||
cwd = Path.cwd().resolve()
|
||||
for directory in [cwd, *cwd.parents]:
|
||||
found = _read_env_value(directory / ".env.local", "ZENMUX_API_KEY")
|
||||
if found:
|
||||
return found
|
||||
config_path = Path.home() / ".config" / "see" / "api_key"
|
||||
try:
|
||||
return config_path.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
|
||||
def sanitize_name(value: str, fallback: str = "image") -> str:
|
||||
value = value.strip()
|
||||
value = re.sub(r"[\\/:*?\"<>|]+", "-", value)
|
||||
value = re.sub(r"\s+", "-", value)
|
||||
value = re.sub(r"-+", "-", value).strip("-_.")
|
||||
if not value:
|
||||
return fallback
|
||||
return value[:80]
|
||||
|
||||
|
||||
def build_output_path(*, output_arg: str, image_type: str, topic: str, explicit_name: str, ext: str) -> Path:
|
||||
if output_arg:
|
||||
out = Path(output_arg).expanduser().resolve()
|
||||
if out.suffix:
|
||||
return out
|
||||
return out.with_suffix(ext)
|
||||
now = datetime.now()
|
||||
day_dir = DEFAULT_OUTPUT_ROOT / now.strftime("%Y-%m-%d")
|
||||
day_dir.mkdir(parents=True, exist_ok=True)
|
||||
base_name = sanitize_name(explicit_name or topic, fallback=image_type)
|
||||
return day_dir / f"{now.strftime('%Y%m%d-%H%M%S')}__{image_type}__{base_name}{ext}"
|
||||
|
||||
|
||||
def metadata_path_for(image_path: Path) -> Path:
|
||||
return image_path.with_suffix(image_path.suffix + ".json")
|
||||
|
||||
|
||||
def _output_paths_for_check(output_path: Path) -> list[Path]:
|
||||
"""Return every path that a writer may choose before its MIME type is known."""
|
||||
output_path = output_path.resolve()
|
||||
if output_path.suffix:
|
||||
return [output_path]
|
||||
|
||||
# The current writers default to PNG, while render_response may infer another
|
||||
# image extension from the model response. Include existing siblings so an
|
||||
# unknown future MIME type cannot silently replace one of them.
|
||||
paths = [output_path.with_suffix(".png")]
|
||||
pattern = f"{output_path.name}.*"
|
||||
paths.extend(path for path in output_path.parent.glob(pattern) if path.is_file())
|
||||
return list(dict.fromkeys(paths))
|
||||
|
||||
|
||||
def ensure_output_available(output_path: Path) -> None:
|
||||
"""Reject an output or its metadata before any remote generation starts."""
|
||||
conflicts: list[Path] = []
|
||||
for candidate in _output_paths_for_check(output_path):
|
||||
if candidate.exists():
|
||||
conflicts.append(candidate)
|
||||
metadata_path = metadata_path_for(candidate)
|
||||
if metadata_path.exists():
|
||||
conflicts.append(metadata_path)
|
||||
if conflicts:
|
||||
paths = ", ".join(str(path) for path in dict.fromkeys(conflicts))
|
||||
raise FileExistsError(f"输出或 metadata 已存在,拒绝覆盖:{paths}")
|
||||
|
||||
|
||||
def _write_new_bytes(path: Path, data: bytes) -> None:
|
||||
"""Create a file without ever replacing an existing output."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("xb") as handle:
|
||||
handle.write(data)
|
||||
|
||||
|
||||
def guess_extension(mime_type: str | None) -> str:
|
||||
if not mime_type:
|
||||
return ".png"
|
||||
guessed = mimetypes.guess_extension(mime_type)
|
||||
if guessed == ".jpe":
|
||||
return ".jpg"
|
||||
return guessed or ".png"
|
||||
|
||||
|
||||
# Type only controls aspect ratio, prompt is fully controlled by caller
|
||||
ASPECT_RATIOS = {
|
||||
"ultrawide": "21:9",
|
||||
"wide": "16:9",
|
||||
"square": "1:1",
|
||||
"portrait": "3:4",
|
||||
"classic": "4:3",
|
||||
}
|
||||
|
||||
CODEX_SIZE_PRESETS = {
|
||||
"ultrawide": "1536x640",
|
||||
"wide": "1536x864",
|
||||
"classic": "1024x768",
|
||||
"square": "1024x1024",
|
||||
"portrait": "768x1024",
|
||||
}
|
||||
|
||||
MODE_PROMPTS = {
|
||||
"normal": "",
|
||||
"replicate": (
|
||||
"Use the reference image as the primary visual source. Recreate the UI screen as closely as possible. "
|
||||
"Preserve layout, spacing, typography hierarchy, colors, shadows, border radius, icon style, density, "
|
||||
"and the relative position of every major element. Do not redesign unless the prompt explicitly asks for changes. "
|
||||
"If text is unreadable, preserve its visual length, alignment, and hierarchy. Output only the clean UI mockup, "
|
||||
"with no browser chrome, watermark, annotations, or surrounding device frame."
|
||||
),
|
||||
"frame-lock": (
|
||||
"Use the first reference image as a locked application frame. Preserve the sidebar, top navigation, brand area, "
|
||||
"and persistent chrome as closely as possible. Redesign or generate only the content area requested by the prompt. "
|
||||
"Keep the result as a clean full-screen UI mockup with no browser chrome or watermark."
|
||||
),
|
||||
"asset-redraw": (
|
||||
"Use the reference image to recreate only the requested visual asset as a clean standalone asset. Remove surrounding "
|
||||
"UI, labels, browser chrome, mockup frames, and unrelated elements unless explicitly requested. Preserve the source "
|
||||
"asset's proportions, material, color, and brand feel with high clarity and generous padding."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def effective_prompt(prompt: str, mode: str) -> str:
|
||||
mode_prompt = MODE_PROMPTS.get(mode, "")
|
||||
if not mode_prompt:
|
||||
return prompt
|
||||
return f"{mode_prompt}\n\nUser request:\n{prompt}"
|
||||
|
||||
|
||||
def download_file(url: str, dest: Path, timeout: int = 120) -> None:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
dest.write_bytes(response.read())
|
||||
|
||||
|
||||
def resolve_ref(raw: str, tmp_dir: Path) -> Path:
|
||||
parsed = urllib.parse.urlparse(raw)
|
||||
if parsed.scheme in {"http", "https"}:
|
||||
suffix = Path(parsed.path).suffix or ".png"
|
||||
dest = tmp_dir / f"ref-{len(list(tmp_dir.iterdir())) + 1}{suffix}"
|
||||
download_file(raw, dest)
|
||||
return dest
|
||||
path = Path(raw).expanduser().resolve()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Reference not found: {raw}")
|
||||
return path
|
||||
|
||||
|
||||
def load_genai():
|
||||
try:
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
except ModuleNotFoundError as exc:
|
||||
raise SystemExit(
|
||||
"[ERROR] Missing dependency `google-genai`. Re-run via scripts/ask_draw.sh so it can auto-install it."
|
||||
) from exc
|
||||
return genai, types
|
||||
|
||||
|
||||
def build_contents(*, prompt: str, types, refs: Iterable[Path]):
|
||||
parts = [types.Part.from_text(text=prompt)]
|
||||
for ref in refs:
|
||||
mime = mimetypes.guess_type(ref.name)[0] or DEFAULT_MIME
|
||||
parts.append(types.Part.from_bytes(data=ref.read_bytes(), mime_type=mime))
|
||||
return parts
|
||||
|
||||
|
||||
def extract_parts(response) -> list:
|
||||
if getattr(response, "parts", None):
|
||||
return list(response.parts)
|
||||
candidates = getattr(response, "candidates", None) or []
|
||||
parts = []
|
||||
for candidate in candidates:
|
||||
content = getattr(candidate, "content", None)
|
||||
if content and getattr(content, "parts", None):
|
||||
parts.extend(content.parts)
|
||||
return parts
|
||||
|
||||
|
||||
def render_response(*, response, output_path: Path) -> tuple[str, str]:
|
||||
text_parts: list[str] = []
|
||||
image_written = False
|
||||
image_mime = DEFAULT_MIME
|
||||
pending_bytes: bytes | None = None
|
||||
|
||||
for part in extract_parts(response):
|
||||
text = getattr(part, "text", None)
|
||||
if text:
|
||||
text_parts.append(text.strip())
|
||||
inline_data = getattr(part, "inline_data", None)
|
||||
if inline_data:
|
||||
data = inline_data.data
|
||||
if isinstance(data, str):
|
||||
pending_bytes = base64.b64decode(data)
|
||||
else:
|
||||
pending_bytes = data
|
||||
image_mime = getattr(inline_data, "mime_type", None) or DEFAULT_MIME
|
||||
|
||||
if pending_bytes:
|
||||
final_path = output_path
|
||||
if not output_path.suffix:
|
||||
final_path = output_path.with_suffix(guess_extension(image_mime))
|
||||
_write_new_bytes(final_path, pending_bytes)
|
||||
image_written = True
|
||||
else:
|
||||
final_path = output_path
|
||||
|
||||
if not image_written:
|
||||
raise RuntimeError("Model returned no image data.")
|
||||
|
||||
return final_path.as_posix(), "\n".join([t for t in text_parts if t]).strip()
|
||||
|
||||
|
||||
def resolve_codex_api_key() -> str:
|
||||
key = os.getenv("OPENAI_IMAGE_API_KEY") or os.getenv("OPENAI_API_KEY")
|
||||
return key.strip() if key else ""
|
||||
|
||||
|
||||
def resolve_codex_base_url() -> str:
|
||||
base_url = (os.getenv("OPENAI_IMAGE_BASE_URL") or "https://api.openai.com/v1").rstrip("/")
|
||||
parsed = urllib.parse.urlparse(base_url)
|
||||
if parsed.scheme and parsed.netloc and parsed.path in ("", "/"):
|
||||
return f"{base_url}/v1"
|
||||
return base_url
|
||||
|
||||
|
||||
def resolve_codex_model(override: str = "") -> str:
|
||||
return override.strip() or DEFAULT_CODEX_MODEL
|
||||
|
||||
|
||||
def join_endpoint(base_url: str, endpoint: str) -> str:
|
||||
base = base_url.rstrip("/")
|
||||
endpoint = endpoint.lstrip("/")
|
||||
if base.endswith("/v1") and endpoint.startswith("v1/"):
|
||||
endpoint = endpoint[3:]
|
||||
return f"{base}/{endpoint}"
|
||||
|
||||
|
||||
def ref_to_input_image(path: Path) -> dict:
|
||||
mime = mimetypes.guess_type(path.name)[0] or DEFAULT_MIME
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return {"type": "input_image", "image_url": f"data:{mime};base64,{encoded}"}
|
||||
|
||||
|
||||
def looks_like_base64_image(value: str) -> bool:
|
||||
if len(value) < 200:
|
||||
return False
|
||||
compact = value.strip()
|
||||
if compact.startswith("data:image/"):
|
||||
compact = compact.split(",", 1)[-1]
|
||||
try:
|
||||
head = base64.b64decode(compact[:256] + "==", validate=False)
|
||||
except Exception:
|
||||
return False
|
||||
return head.startswith(b"\x89PNG") or head.startswith(b"\xff\xd8\xff") or head.startswith(b"RIFF")
|
||||
|
||||
|
||||
def find_image_result_recursive(value: object) -> str | None:
|
||||
if isinstance(value, dict):
|
||||
value_type = value.get("type")
|
||||
for key in ("result", "b64_json", "image_base64"):
|
||||
item = value.get(key)
|
||||
if isinstance(item, str) and (value_type == "image_generation_call" or looks_like_base64_image(item)):
|
||||
return item.split(",", 1)[-1] if item.startswith("data:image/") else item
|
||||
for item in value.values():
|
||||
found = find_image_result_recursive(item)
|
||||
if found:
|
||||
return found
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
found = find_image_result_recursive(item)
|
||||
if found:
|
||||
return found
|
||||
elif isinstance(value, str) and looks_like_base64_image(value):
|
||||
return value.split(",", 1)[-1] if value.startswith("data:image/") else value
|
||||
return None
|
||||
|
||||
|
||||
def request_codex_image(
|
||||
*, prompt: str, refs: list[Path], image_type: str, model: str, output_path: Path
|
||||
) -> Path:
|
||||
api_key = resolve_codex_api_key()
|
||||
if not api_key:
|
||||
raise RuntimeError("No OPENAI_IMAGE_API_KEY or OPENAI_API_KEY found.")
|
||||
|
||||
base_url = resolve_codex_base_url()
|
||||
endpoint = join_endpoint(base_url, "responses")
|
||||
content: list[dict] = [{"type": "input_text", "text": prompt}]
|
||||
content.extend(ref_to_input_image(ref) for ref in refs)
|
||||
payload = {
|
||||
"model": model,
|
||||
"instructions": "Use the image_generation tool to create exactly the requested image. Do not add extra text.",
|
||||
"stream": False,
|
||||
"store": False,
|
||||
"input": [{"role": "user", "content": content}],
|
||||
"tools": [{"type": "image_generation", "size": CODEX_SIZE_PRESETS.get(image_type, "1024x1024")}],
|
||||
"tool_choice": "required",
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
endpoint,
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=600) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
details = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"HTTP {exc.code} from Codex image API:\n{details}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"Could not reach Codex image API: {exc.reason}") from exc
|
||||
|
||||
try:
|
||||
response_payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Codex image API returned non-JSON response:\n{raw[:1500]}") from exc
|
||||
|
||||
image_b64 = find_image_result_recursive(response_payload)
|
||||
if not image_b64:
|
||||
raise RuntimeError("No image result found in Codex Responses API output.")
|
||||
|
||||
final_path = output_path if output_path.suffix else output_path.with_suffix(".png")
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
image_bytes = base64.b64decode(image_b64, validate=True)
|
||||
except (binascii.Error, ValueError, TypeError) as exc:
|
||||
raise RuntimeError("Codex image API returned invalid base64 image data.") from exc
|
||||
_write_new_bytes(final_path, image_bytes)
|
||||
return final_path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Generate UI images via ZenMux or a Codex OpenAI-compatible provider.")
|
||||
parser.add_argument("--type", choices=sorted(ASPECT_RATIOS.keys()), default="wide", help="Aspect ratio preset.")
|
||||
parser.add_argument("--prompt", required=True, help="Full prompt for image generation.")
|
||||
parser.add_argument("--ref", action="append", default=[], help="Reference image path or URL (repeatable).")
|
||||
parser.add_argument("--name", default="", help="Optional short output name.")
|
||||
parser.add_argument("-o", "--output", default="", help="Output image path.")
|
||||
parser.add_argument("--provider", choices=["zenmux", "codex"], default=DEFAULT_PROVIDER, help="Image backend.")
|
||||
parser.add_argument("--mode", choices=sorted(MODE_PROMPTS.keys()), default="normal", help="UI prompt wrapper.")
|
||||
parser.add_argument("--model", default="", help="Model override (defaults per provider).")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help=argparse.SUPPRESS)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _uses_generate_images_api(model: str) -> bool:
|
||||
"""Models that require the generate_images / edit_image API instead of generate_content."""
|
||||
return model.startswith("openai/")
|
||||
|
||||
|
||||
def _run_generate_images(*, client, model: str, prompt: str, refs: list[Path], types) -> tuple[bytes, str]:
|
||||
"""Call generate_images (or edit_image when refs are provided) and return (image_bytes, response_text)."""
|
||||
if refs:
|
||||
# Use edit_image with reference images
|
||||
# First ref becomes the base image
|
||||
base_image_path = refs[0]
|
||||
base_mime = mimetypes.guess_type(base_image_path.name)[0] or DEFAULT_MIME
|
||||
base_image = types.Image(image_bytes=base_image_path.read_bytes(), mime_type=base_mime)
|
||||
reference_images = [
|
||||
types.RawReferenceImage(reference_id=1, reference_image=base_image)
|
||||
]
|
||||
# Additional refs as extra references
|
||||
for i, ref_path in enumerate(refs[1:], start=2):
|
||||
ref_mime = mimetypes.guess_type(ref_path.name)[0] or DEFAULT_MIME
|
||||
ref_img = types.Image(image_bytes=ref_path.read_bytes(), mime_type=ref_mime)
|
||||
reference_images.append(
|
||||
types.RawReferenceImage(reference_id=i, reference_image=ref_img)
|
||||
)
|
||||
response = client.models.edit_image(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
reference_images=reference_images,
|
||||
)
|
||||
else:
|
||||
response = client.models.generate_images(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
generated = getattr(response, "generated_images", None)
|
||||
if not generated:
|
||||
raise RuntimeError("Model returned no generated images.")
|
||||
|
||||
image_obj = generated[0].image
|
||||
image_bytes = getattr(image_obj, "image_bytes", None)
|
||||
if image_bytes is None:
|
||||
# Some versions expose .data as base64
|
||||
raw = getattr(image_obj, "data", None)
|
||||
if isinstance(raw, str):
|
||||
image_bytes = base64.b64decode(raw)
|
||||
elif isinstance(raw, bytes):
|
||||
image_bytes = raw
|
||||
if not image_bytes:
|
||||
raise RuntimeError("Could not extract image bytes from generate_images response.")
|
||||
|
||||
return image_bytes, ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
aspect_ratio = ASPECT_RATIOS[args.type]
|
||||
prompt = effective_prompt(args.prompt, args.mode)
|
||||
model = resolve_codex_model(args.model) if args.provider == "codex" else (args.model or DEFAULT_MODEL)
|
||||
|
||||
output_path = build_output_path(
|
||||
output_arg=args.output,
|
||||
image_type=args.type,
|
||||
topic=args.name or "image",
|
||||
explicit_name=args.name,
|
||||
ext=".png",
|
||||
)
|
||||
ensure_output_available(output_path)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="draw-refs-") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
refs = [resolve_ref(raw, tmp_dir) for raw in args.ref]
|
||||
|
||||
if args.provider == "codex":
|
||||
final_path = request_codex_image(
|
||||
prompt=prompt,
|
||||
refs=refs,
|
||||
image_type=args.type,
|
||||
model=model,
|
||||
output_path=output_path,
|
||||
)
|
||||
response_text = ""
|
||||
else:
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
print(
|
||||
"[ERROR] No ZENMUX_API_KEY found. Set it as env var, in .env.local, or in ~/.config/see/api_key",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
genai, types = load_genai()
|
||||
# OpenAI image models via ZenMux can take longer; bump timeout to 5 minutes.
|
||||
timeout = 300 if _uses_generate_images_api(model) else 120
|
||||
client = genai.Client(
|
||||
api_key=api_key,
|
||||
vertexai=True,
|
||||
http_options=types.HttpOptions(api_version="v1", base_url=args.base_url, timeout=timeout * 1000),
|
||||
)
|
||||
|
||||
if _uses_generate_images_api(model):
|
||||
image_bytes, response_text = _run_generate_images(
|
||||
client=client, model=model, prompt=prompt, refs=refs, types=types,
|
||||
)
|
||||
final_path = output_path if output_path.suffix else output_path.with_suffix(".png")
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_write_new_bytes(final_path, image_bytes)
|
||||
else:
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=build_contents(prompt=prompt, types=types, refs=refs),
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["TEXT", "IMAGE"],
|
||||
image_config=types.ImageConfig(aspect_ratio=aspect_ratio),
|
||||
),
|
||||
)
|
||||
final_path_str, response_text = render_response(response=response, output_path=output_path)
|
||||
final_path = Path(final_path_str)
|
||||
|
||||
meta_path = metadata_path_for(final_path)
|
||||
metadata = {
|
||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"type": args.type,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"prompt": prompt,
|
||||
"raw_prompt": args.prompt,
|
||||
"refs": [str(path) for path in refs],
|
||||
"provider": args.provider,
|
||||
"mode": args.mode,
|
||||
"model": model,
|
||||
"base_url": args.base_url if args.provider == "zenmux" else resolve_codex_base_url(),
|
||||
"output_path": str(final_path),
|
||||
"response_text": response_text,
|
||||
}
|
||||
meta_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with meta_path.open("x", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(metadata, ensure_ascii=False, indent=2))
|
||||
|
||||
print(f"output_path={final_path}")
|
||||
print(f"metadata_path={meta_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测量已标注的参考图区域,输出原始几何、局部颜色、重复网格和阈值扫描;不猜测原始 CSS。"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def positive(value, name):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
|
||||
raise ValueError(f'{name} 必须是有限正数')
|
||||
return value
|
||||
|
||||
|
||||
def integer(value, name):
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f'{name} 必须是整数')
|
||||
return value
|
||||
|
||||
|
||||
def box_checked(box, size):
|
||||
if not isinstance(box, list) or len(box) != 4:
|
||||
raise ValueError('box 必须为 [x,y,width,height]')
|
||||
x, y, w, h = [integer(v, 'box 坐标') for v in box]
|
||||
if min(x, y) < 0 or min(w, h) <= 0 or x+w > size[0] or y+h > size[1]:
|
||||
raise ValueError('标注越界或面积无效;不自动裁短')
|
||||
return x, y, w, h
|
||||
|
||||
|
||||
def distribution(values):
|
||||
values = sorted(values)
|
||||
def quantile(p):
|
||||
pos = (len(values)-1)*p
|
||||
lo, hi = math.floor(pos), math.ceil(pos)
|
||||
return round(values[lo] + (values[hi]-values[lo])*(pos-lo), 3)
|
||||
return {'median': quantile(.5), 'p10': quantile(.1), 'p90': quantile(.9)}
|
||||
|
||||
|
||||
def measure(manifest_path, out):
|
||||
data = json.loads(manifest_path.read_text(encoding='utf-8'))
|
||||
source = (manifest_path.parent / data['reference']).resolve()
|
||||
with Image.open(source) as original:
|
||||
im = original.convert('RGBA')
|
||||
css_width = data.get('css_viewport_width')
|
||||
factor = positive(css_width, 'css_viewport_width')/im.width if css_width is not None else None
|
||||
regions = []
|
||||
names = set()
|
||||
def take_name(item):
|
||||
name = item['name']
|
||||
if not isinstance(name, str) or not name.strip() or name in names:
|
||||
raise ValueError('标注名称必须非空且唯一')
|
||||
names.add(name)
|
||||
return name
|
||||
for item in data.get('regions', []):
|
||||
name = take_name(item)
|
||||
kind = item.get('kind', 'box')
|
||||
if kind not in ('box', 'color'):
|
||||
raise ValueError('kind 只支持 box 或 color')
|
||||
x,y,w,h = box_checked(item['box'], im.size)
|
||||
box_type = item.get('box_type', 'sample' if kind == 'color' else 'element')
|
||||
if box_type not in ('element', 'ink', 'sample') or (kind == 'color' and box_type != 'sample'):
|
||||
raise ValueError('box_type 必须为 element、ink 或 sample;颜色区只能为 sample')
|
||||
region = {'name': name, 'kind':kind, 'box_type':box_type, 'box':[x,y,w,h],
|
||||
'normalized_box':[round(x/im.width,6),round(y/im.height,6),round(w/im.width,6),round(h/im.height,6)],
|
||||
'boundary_evidence':'Agent 选择的标注范围,非自动识别的真实元素边界'}
|
||||
if factor is not None:
|
||||
region['css_box'] = [round(v*factor,3) for v in [x,y,w,h]]
|
||||
if 'selector' in item: region['selector'] = item['selector']
|
||||
region['dom_comparable'] = box_type == 'element'
|
||||
if 'group' in item:
|
||||
if box_type != 'element': raise ValueError('重复网格只接受元素框,不能混用字形框或采样框')
|
||||
if not isinstance(item['group'],str) or not item['group'].strip(): raise ValueError('group 必须是非空字符串')
|
||||
region['group'] = item['group']
|
||||
if kind == 'color':
|
||||
inset=integer(item.get('inset',0),'inset')
|
||||
if inset < 0 or inset*2 >= min(w,h): raise ValueError('inset 使采样区域无效')
|
||||
crop = im.crop((x+inset,y+inset,x+w-inset,y+h-inset))
|
||||
# 只统计完全不透明的像素,不将透明底错误统计为黑色。
|
||||
pixels = [p for p in crop.get_flattened_data() if p[3] == 255] if hasattr(crop,'get_flattened_data') else [p for p in crop.getdata() if p[3] == 255]
|
||||
if not pixels: raise ValueError(f'{name} 没有不透明的颜色样本')
|
||||
channels = [distribution([p[c] for p in pixels]) for c in range(3)]
|
||||
rgb = [round(c['median']) for c in channels]
|
||||
region['color'] = {'median_hex':'#' + ''.join(f'{v:02X}' for v in rgb),'rgb_channels':channels,
|
||||
'opaque_samples':len(pixels),'excluded_nonopaque':crop.width*crop.height-len(pixels),
|
||||
'sample_box':[x+inset,y+inset,w-inset*2,h-inset*2],
|
||||
'max_channel_p90_minus_p10':round(max(c['p90']-c['p10'] for c in channels),3),
|
||||
'meaning':'局部像素统计,不证明原始色值、纯色或渐变模型'}
|
||||
regions.append(region)
|
||||
groups=[]
|
||||
for name in sorted({r['group'] for r in regions if 'group' in r}):
|
||||
members=sorted([r for r in regions if r.get('group')==name],key=lambda r:r['box'][0])
|
||||
if len(members)<2: raise ValueError('重复网格至少需要两个标注')
|
||||
gaps=[b['box'][0]-(a['box'][0]+a['box'][2]) for a,b in zip(members,members[1:])]
|
||||
groups.append({'name':name,'members':[r['name'] for r in members],'x_gaps':gaps,
|
||||
'median_width':median([r['box'][2] for r in members]),'median_height':median([r['box'][3] for r in members]),
|
||||
'median_gap':median(gaps),'y_spread':max(r['box'][1] for r in members)-min(r['box'][1] for r in members),
|
||||
'width_spread':max(r['box'][2] for r in members)-min(r['box'][2] for r in members),
|
||||
'meaning':'横向重复结构的描述统计,不自动把不等宽设计改成等宽'})
|
||||
scans=[]
|
||||
for spec in data.get('vertical_scans',[]):
|
||||
name=take_name(spec)
|
||||
x,y,_,h=box_checked([spec['x'],spec['y'],1,spec['height']],im.size)
|
||||
floor=integer(spec['min_channel'],'min_channel'); spread=integer(spec['max_channel_spread'],'max_channel_spread')
|
||||
run_min=integer(spec.get('min_run',1),'min_run')
|
||||
if not 0<=floor<=255 or not 0<=spread<=255 or run_min<1: raise ValueError('扫描阈值无效')
|
||||
runs=[];start=None
|
||||
for yy in range(y,y+h+1):
|
||||
p=im.getpixel((x,yy)) if yy<y+h else None
|
||||
match=p is not None and p[3]==255 and min(p[:3])>=floor and max(p[:3])-min(p[:3])<=spread
|
||||
if match and start is None: start=yy
|
||||
if not match and start is not None:
|
||||
if yy-start>=run_min:runs.append({'y_start':start,'y_end_exclusive':yy,'height':yy-start})
|
||||
start=None
|
||||
scans.append({'name':name,'configuration':spec,'runs':runs,'meaning':'满足给定颜色阈值的连续像素,不自动认定为区块边界'})
|
||||
if not regions and not scans: raise ValueError('至少提供一个区域或扫描')
|
||||
result={'source':str(source),'source_sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'source_size':list(im.size),
|
||||
'css_mapping':{'assumed_viewport_width':css_width,'css_per_image_pixel':factor,'meaning':'实现约定,不证明参考图原始 DPR 或 CSS 视口'},
|
||||
'regions':regions,'groups':groups,'vertical_scans':scans}
|
||||
if 'expected' in data:
|
||||
result['expected'] = data['expected']
|
||||
result['expected_assets'] = data.get('expected_assets', {})
|
||||
result['input_manifest_sha256'] = hashlib.sha256(manifest_path.read_bytes()).hexdigest()
|
||||
# 所有输入检查完成后才创建输出,且拒绝复用已有轮次。
|
||||
out.mkdir(parents=True,exist_ok=False)
|
||||
(out/'measurements.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
# PNG 编码原图用于自包含的 SVG 标注层;不缩放、去背或重建素材。
|
||||
import io
|
||||
buffer=io.BytesIO();im.save(buffer,format='PNG')
|
||||
encoded=base64.b64encode(buffer.getvalue()).decode('ascii')
|
||||
svg=[f'<svg xmlns="http://www.w3.org/2000/svg" width="{im.width}" height="{im.height}" viewBox="0 0 {im.width} {im.height}">',f'<image width="{im.width}" height="{im.height}" href="data:image/png;base64,{encoded}"/>']
|
||||
for region in regions:
|
||||
x,y,w,h=region['box'];label=html.escape(region['name']);color='#36edcc' if region['kind']=='box' else '#f6cb46'
|
||||
svg.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="none" stroke="{color}" stroke-width="2"/><text x="{x+3}" y="{y+15}" fill="{color}" stroke="#000" stroke-width="3" paint-order="stroke" font-size="13" font-family="sans-serif">{label}</text>')
|
||||
svg.append('</svg>')
|
||||
(out/'annotations.svg').write_text('\n'.join(svg),encoding='utf-8')
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--out-dir',type=Path,required=True)
|
||||
args=parser.parse_args()
|
||||
try:
|
||||
result=measure(args.manifest,args.out_dir)
|
||||
print(json.dumps({'status':'标注区域测量完成,设计关系仍需判断','regions':len(result['regions']),'groups':len(result['groups']),'output':str(args.out_dir)},ensure_ascii=False))
|
||||
except (OSError,ValueError,KeyError,TypeError) as error:
|
||||
parser.exit(2,f'测量失败:{error}\n')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""从同一测量基线生成浏览器采集表达式与验收配置,不启动浏览器。"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def read_baseline(path, expected_digest=None):
|
||||
actual_digest = digest(path)
|
||||
if expected_digest is not None and actual_digest != expected_digest:
|
||||
raise ValueError('测量基线已改动;为新基线创建新轮次,不能沿用旧采集')
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
source = Path(data['source'])
|
||||
if not source.is_absolute(): source = path.parent / source
|
||||
if digest(source) != data['source_sha256']:
|
||||
raise ValueError('参考图已改动,与测量基线不符')
|
||||
with Image.open(source) as image:
|
||||
if list(image.size) != data['source_size']: raise ValueError('参考图尺寸与测量基线不符')
|
||||
return data, source, actual_digest
|
||||
|
||||
|
||||
def validate_expected(data):
|
||||
expected = data.get('expected')
|
||||
if not isinstance(expected, dict): raise ValueError('先在区域清单填写 expected 并重新测量,不能后补截图基线')
|
||||
if not isinstance(expected.get('title'), str) or not expected['title'].strip(): raise ValueError('缺少页面标题')
|
||||
url = urlparse(expected.get('url', ''))
|
||||
if url.scheme not in ('http', 'https') or not url.hostname: raise ValueError('页面 URL 必须为 http(s) 地址')
|
||||
for value in [expected['viewport']['width'], expected['viewport']['height'], expected['dpr']]:
|
||||
if isinstance(value, bool) or not isinstance(value, (int,float)) or not math.isfinite(value) or value <= 0:
|
||||
raise ValueError('视口与 DPR 必须为有限正数')
|
||||
if type(expected.get('full_page')) is not bool or expected.get('screenshot_scale') not in ('css','device'):
|
||||
raise ValueError('明确 full_page 布尔值与 screenshot_scale: css/device')
|
||||
if data['css_mapping']['assumed_viewport_width'] != expected['viewport']['width']:
|
||||
raise ValueError('测量的 CSS 映射与截图视口不一致')
|
||||
factor = expected['viewport']['width']/data['source_size'][0]
|
||||
if not math.isclose(data['css_mapping']['css_per_image_pixel'], factor):
|
||||
raise ValueError('测量基线的比例无效')
|
||||
scale = expected['dpr'] if expected['screenshot_scale'] == 'device' else 1
|
||||
if abs(expected['viewport']['width']*scale - data['source_size'][0]) >= 1:
|
||||
raise ValueError('截图输出宽度与参考图不等;调整截图像素模式或另建基线,不自动缩放')
|
||||
if not expected['full_page'] and abs(expected['viewport']['height']*scale - data['source_size'][1]) >= 1:
|
||||
raise ValueError('视口截图高度与参考图不等;长图应明确 full_page')
|
||||
return expected
|
||||
|
||||
|
||||
def targets_for(data):
|
||||
names = set()
|
||||
targets = []
|
||||
for region in data['regions']:
|
||||
name = region['name']
|
||||
if name in names: raise ValueError('测量区域重名')
|
||||
names.add(name)
|
||||
if 'selector' not in region: continue
|
||||
if not isinstance(region['selector'], str) or not region['selector'].strip(): raise ValueError('selector 必须为非空 CSS 选择器')
|
||||
targets.append({'name': name, 'selector': region['selector']})
|
||||
if not targets: raise ValueError('至少为一个标注提供 selector,才能生成采集计划')
|
||||
return targets
|
||||
|
||||
|
||||
def prepare(path, out):
|
||||
path, out = path.resolve(), out.resolve()
|
||||
data, source, baseline_digest = read_baseline(path)
|
||||
expected = validate_expected(data)
|
||||
targets = targets_for(data)
|
||||
plan = {'measurement_sha256':baseline_digest, 'targets':targets}
|
||||
collector = Path(__file__).with_name('collect_browser.js').read_text(encoding='utf-8')
|
||||
expression = f'({collector})({json.dumps(plan,ensure_ascii=False)})'
|
||||
run = {'reference':os.path.relpath(source, out), 'candidate':'candidate.png', 'capture_metadata':'capture.json',
|
||||
'expected':expected, 'measurement_baseline':os.path.relpath(path.resolve(),out),
|
||||
'measurement_sha256':baseline_digest, 'allow_height_difference':expected['full_page'],
|
||||
'expected_assets':data.get('expected_assets',{}),
|
||||
'clips':[f"region-{i:03d}:"+','.join(str(v) for v in r['box']) for i,r in enumerate(data['regions'])]}
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
for filename, obj in [('run.json',run),('capture-plan.json',plan)]:
|
||||
(out/filename).write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
(out/'collect.js').write_text(expression,encoding='utf-8')
|
||||
(out/'round-notes.md').write_text('# 本轮校准记录\n\n填写本轮目标区域、依据、调整的变量,以及报告产生后确认的收益与退步;区分测量事实与视觉判断。\n',encoding='utf-8')
|
||||
return run
|
||||
|
||||
|
||||
def compare_geometry(run, capture, base, previous=None):
|
||||
path = (base / run['measurement_baseline']).resolve()
|
||||
data, source, baseline_digest = read_baseline(path, run['measurement_sha256'])
|
||||
expected = validate_expected(data)
|
||||
if run['expected'] != expected or (base/run['reference']).resolve() != source.resolve():
|
||||
raise ValueError('验收配置与测量基线的参考图或截图条件不一致')
|
||||
if run.get('expected_assets',{}) != data.get('expected_assets',{}):
|
||||
raise ValueError('关键素材约定与测量基线不一致')
|
||||
if capture.get('measurement_sha256') != baseline_digest:
|
||||
raise ValueError('采集脚本来自其他测量基线;重新准备并采集')
|
||||
targets = targets_for(data)
|
||||
rows = capture.get('elements')
|
||||
if not isinstance(rows,list): raise ValueError('缺少生成采集脚本产生的 elements')
|
||||
actual = {}
|
||||
for row in rows:
|
||||
if row['name'] in actual: raise ValueError('采集结果包含重复区域')
|
||||
actual[row['name']] = row
|
||||
if set(actual) != {r['name'] for r in targets}: raise ValueError('采集区域与测量清单不一致')
|
||||
regions = []
|
||||
for r in data['regions']:
|
||||
if 'selector' not in r:
|
||||
regions.append({'name':r['name'],'status':'reference_only','reason':'仅测量参考图,没有 DOM 选择器'})
|
||||
continue
|
||||
row = actual[r['name']]
|
||||
if row['selector'] != r['selector']: raise ValueError('采集选择器与测量清单不一致')
|
||||
status = row['status']
|
||||
if status not in ('measured','missing','ambiguous','hidden','unmeasurable','invalid_selector'):
|
||||
raise ValueError('未知采集状态')
|
||||
item = {'name':r['name'],'selector':r['selector'],'status':status,'box_type':r['box_type']}
|
||||
if status == 'measured':
|
||||
if row.get('count') != 1: raise ValueError('可测量元素必须唯一匹配')
|
||||
box = row.get('box')
|
||||
if not isinstance(box,list) or len(box)!=4 or any(isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) for v in box) or min(box[2:])<=0:
|
||||
raise ValueError('采集元素框无效')
|
||||
item['actual_css_box'] = box
|
||||
item['computed'] = row.get('computed',{})
|
||||
if r['box_type'] != 'element':
|
||||
item['status'] = 'visual_only'
|
||||
item['reason'] = '字形框或采样区不与 DOM 元素外框比较'
|
||||
else:
|
||||
factor=data['css_mapping']['css_per_image_pixel']
|
||||
ref=[v*factor for v in r['box']]
|
||||
item['reference_css_box']=[round(v,3) for v in ref]
|
||||
item['delta_css']=[round(a-b,3) for a,b in zip(box,ref)]
|
||||
item['max_abs_delta_css']=round(max(abs(a-b) for a,b in zip(box,ref)),3)
|
||||
regions.append(item)
|
||||
incomplete = [r['name'] for r in regions if r['status'] not in ('measured','visual_only','reference_only')]
|
||||
result = {'measurement_sha256':baseline_digest,'complete':not incomplete,'unavailable_regions':incomplete,
|
||||
'regions':regions,'note':'偏差是 CSS 像素,不是还原率;布局可测量不证明未遮挡、字体正确或交互可用。'}
|
||||
if previous is not None:
|
||||
previous_report=json.loads(previous.read_text(encoding='utf-8'))
|
||||
before=previous_report.get('geometry',{})
|
||||
if before.get('measurement_sha256') != baseline_digest:
|
||||
raise ValueError('前轮报告不是同一测量基线,不能声称前后改善')
|
||||
for key in ('viewport','dpr','full_page','screenshot_scale','title','url'):
|
||||
if previous_report['capture'].get(key) != capture.get(key):
|
||||
raise ValueError(f'前后采集条件不同:{key}')
|
||||
old={r['name']:r for r in before['regions']}
|
||||
changes=[]
|
||||
for item in regions:
|
||||
prev=old.get(item['name'],{})
|
||||
a,b=prev.get('max_abs_delta_css'),item.get('max_abs_delta_css')
|
||||
changes.append({'name':item['name'],'before':a,'after':b,'delta':round(b-a,3) if a is not None and b is not None else None,
|
||||
'change':'unavailable' if a is None or b is None else 'reduced' if b<a else 'increased' if b>a else 'unchanged'})
|
||||
result['changes']=changes
|
||||
return result
|
||||
|
||||
|
||||
def write_geometry_markdown(out, geometry):
|
||||
def cell(value): return str(value).replace('|','\\|').replace('\n',' ')
|
||||
lines=['# 元素几何偏差','',geometry['note'],'', '| 区域 | 状态 | Δx | Δy | Δ宽 | Δ高 |','| --- | --- | --- | --- | --- | --- |']
|
||||
for r in geometry['regions']:
|
||||
lines.append('| '+' | '.join(cell(v) for v in [r['name'],r['status'],*r.get('delta_css',['—']*4)])+' |')
|
||||
if 'changes' in geometry:
|
||||
lines+=['','## 相对前轮的最大绝对偏差','','| 区域 | 前轮 | 本轮 | 变化 |','| --- | --- | --- | --- |']
|
||||
for r in geometry['changes']: lines.append('| '+' | '.join(cell(v) for v in [r['name'],r['before'],r['after'],r['change']])+' |')
|
||||
(out/'geometry.md').write_text('\n'.join(lines)+'\n',encoding='utf-8')
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--measurements',type=Path,required=True)
|
||||
p.add_argument('--out-dir',type=Path,required=True)
|
||||
args=p.parse_args()
|
||||
try:
|
||||
prepare(args.measurements,args.out_dir)
|
||||
print(json.dumps({'status':'采集计划已生成;在已授权浏览器执行 collect.js 并保存真实截图','output':str(args.out_dir)},ensure_ascii=False))
|
||||
except (OSError,ValueError,KeyError,TypeError) as error:
|
||||
p.exit(2,f'准备失败:{error}\n')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def foreground_mask(img: Image.Image, *, threshold: int) -> Image.Image:
|
||||
rgb = img.convert("RGB")
|
||||
return Image.eval(rgb, lambda px: 0 if px >= threshold else 255).convert("L")
|
||||
|
||||
|
||||
def trim(img: Image.Image, *, threshold: int, padding: int) -> Image.Image:
|
||||
mask = foreground_mask(img, threshold=threshold)
|
||||
bbox = mask.getbbox()
|
||||
if bbox is None:
|
||||
return img
|
||||
|
||||
left, top, right, bottom = bbox
|
||||
left = max(0, left - padding)
|
||||
top = max(0, top - padding)
|
||||
right = min(img.width, right + padding)
|
||||
bottom = min(img.height, bottom + padding)
|
||||
return img.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def trim_alpha(img: Image.Image, *, padding: int) -> Image.Image:
|
||||
rgba = img.convert("RGBA")
|
||||
alpha = rgba.getchannel("A")
|
||||
bbox = alpha.getbbox()
|
||||
if bbox is None:
|
||||
return rgba
|
||||
|
||||
left, top, right, bottom = bbox
|
||||
left = max(0, left - padding)
|
||||
top = max(0, top - padding)
|
||||
right = min(rgba.width, right + padding)
|
||||
bottom = min(rgba.height, bottom + padding)
|
||||
return rgba.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def white_to_alpha(img: Image.Image, *, threshold: int, feather: int) -> Image.Image:
|
||||
rgba = img.convert("RGBA")
|
||||
pixels = rgba.load()
|
||||
|
||||
for y in range(rgba.height):
|
||||
for x in range(rgba.width):
|
||||
red, green, blue, alpha = pixels[x, y]
|
||||
brightness = max(red, green, blue)
|
||||
if red >= threshold and green >= threshold and blue >= threshold:
|
||||
pixels[x, y] = (255, 255, 255, 0)
|
||||
continue
|
||||
if feather > 0 and brightness >= threshold - feather:
|
||||
new_alpha = int(alpha * (threshold - brightness + feather) / feather)
|
||||
pixels[x, y] = (red, green, blue, max(0, min(alpha, new_alpha)))
|
||||
|
||||
return rgba
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
raw = value.strip().lstrip("#")
|
||||
if len(raw) != 6:
|
||||
raise ValueError("key color must be a 6-digit hex color, for example #00ff00")
|
||||
return int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)
|
||||
|
||||
|
||||
def key_color_to_alpha(
|
||||
img: Image.Image,
|
||||
*,
|
||||
key_color: tuple[int, int, int],
|
||||
threshold: int,
|
||||
feather: int,
|
||||
despill: bool,
|
||||
edge_contract: int,
|
||||
) -> Image.Image:
|
||||
rgba = img.convert("RGBA")
|
||||
pixels = rgba.load()
|
||||
kr, kg, kb = key_color
|
||||
|
||||
for y in range(rgba.height):
|
||||
for x in range(rgba.width):
|
||||
red, green, blue, alpha = pixels[x, y]
|
||||
distance = ((red - kr) ** 2 + (green - kg) ** 2 + (blue - kb) ** 2) ** 0.5
|
||||
if distance <= threshold:
|
||||
pixels[x, y] = (red, green, blue, 0)
|
||||
continue
|
||||
if feather > 0 and distance <= threshold + feather:
|
||||
new_alpha = int(alpha * (distance - threshold) / feather)
|
||||
pixels[x, y] = (red, green, blue, max(0, min(alpha, new_alpha)))
|
||||
|
||||
if despill:
|
||||
pixels = rgba.load()
|
||||
for y in range(rgba.height):
|
||||
for x in range(rgba.width):
|
||||
red, green, blue, alpha = pixels[x, y]
|
||||
if alpha == 0:
|
||||
continue
|
||||
# Remove green spill on semi-transparent and edge pixels.
|
||||
green_excess = green - max(red, blue)
|
||||
if green_excess <= 0:
|
||||
continue
|
||||
edge_factor = 1.0 - (alpha / 255.0)
|
||||
reduction = int(green_excess * (0.72 + 0.28 * edge_factor))
|
||||
green = max(max(red, blue), green - reduction)
|
||||
pixels[x, y] = (red, green, blue, alpha)
|
||||
|
||||
if edge_contract > 0:
|
||||
rgba = contract_alpha_edge(rgba, pixels=edge_contract)
|
||||
|
||||
return rgba
|
||||
|
||||
|
||||
def contract_alpha_edge(img: Image.Image, *, pixels: int) -> Image.Image:
|
||||
rgba = img.convert("RGBA")
|
||||
alpha = rgba.getchannel("A")
|
||||
try:
|
||||
from PIL import ImageFilter
|
||||
for _ in range(pixels):
|
||||
alpha = alpha.filter(ImageFilter.MinFilter(3))
|
||||
rgba.putalpha(alpha)
|
||||
except Exception:
|
||||
pass
|
||||
return rgba
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Prepare AI-generated UI assets by trimming white padding and optionally making white background transparent."
|
||||
)
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--no-trim", action="store_true", help="Keep original canvas size.")
|
||||
parser.add_argument("--alpha", action="store_true", help="Convert near-white background to transparent alpha.")
|
||||
parser.add_argument("--key-color", help="Convert a chroma key color such as #00ff00 to transparent alpha.")
|
||||
parser.add_argument("--threshold", type=int, default=248, help="Near-white threshold, 0-255.")
|
||||
parser.add_argument("--key-threshold", type=int, default=28, help="Chroma key distance threshold.")
|
||||
parser.add_argument("--feather", type=int, default=12, help="Soft alpha range below threshold.")
|
||||
parser.add_argument("--despill", action="store_true", help="Reduce green spill on chroma-keyed edges.")
|
||||
parser.add_argument("--edge-contract", type=int, default=0, help="Shrink alpha edge by this many pixels after keying.")
|
||||
parser.add_argument("--padding", type=int, default=24, help="Padding kept around trimmed foreground.")
|
||||
args = parser.parse_args()
|
||||
|
||||
img = Image.open(args.input).convert("RGBA")
|
||||
if args.key_color:
|
||||
img = key_color_to_alpha(
|
||||
img,
|
||||
key_color=parse_hex_color(args.key_color),
|
||||
threshold=args.key_threshold,
|
||||
feather=args.feather,
|
||||
despill=args.despill,
|
||||
edge_contract=args.edge_contract,
|
||||
)
|
||||
if not args.no_trim:
|
||||
img = trim_alpha(img, padding=args.padding)
|
||||
elif not args.no_trim:
|
||||
img = trim(img, threshold=args.threshold, padding=args.padding)
|
||||
if args.alpha:
|
||||
img = white_to_alpha(img, threshold=args.threshold, feather=args.feather)
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(args.output)
|
||||
print(f"wrote {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""验证已授权浏览器产生的截图与元数据,再运行像素对比;本脚本不启动浏览器。"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urldefrag, urlparse
|
||||
from urllib.request import urlopen
|
||||
from html.parser import HTMLParser
|
||||
from PIL import Image
|
||||
from prepare_capture import compare_geometry, write_geometry_markdown
|
||||
|
||||
|
||||
def verify(manifest_path, out_dir, previous=None):
|
||||
if out_dir.exists():
|
||||
raise ValueError('输出目录已存在;使用新的轮次目录,拒绝覆盖')
|
||||
data = json.loads(manifest_path.read_text(encoding='utf-8'))
|
||||
base = manifest_path.parent
|
||||
expected = data['expected']
|
||||
capture = json.loads((base / data['capture_metadata']).read_text(encoding='utf-8'))
|
||||
candidate = base / data['candidate']
|
||||
reference = base / data['reference']
|
||||
failures = []
|
||||
def check(condition, message):
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
for key in ('url', 'title', 'viewport', 'dpr'):
|
||||
if key not in capture or key not in expected:
|
||||
raise ValueError(f'缺少必填字段:{key}')
|
||||
check(bool(expected['title']) and capture['title'] == expected['title'], '页面标题不匹配')
|
||||
check(bool(expected['url']) and urldefrag(capture['url'])[0] == urldefrag(expected['url'])[0], '页面URL不匹配')
|
||||
check(capture['viewport'] == expected['viewport'], '实际视口不匹配')
|
||||
check(capture['dpr'] == expected['dpr'] and capture['dpr'] > 0, '实际DPR不匹配')
|
||||
check(capture.get('fonts_ready') is True, '字体尚未加载完成')
|
||||
check(capture.get('images_ready') is True, '图片尚未加载完成')
|
||||
check(capture.get('full_page') == expected['full_page'], '截图范围与约定不一致')
|
||||
scale_mode = capture['screenshot_scale']
|
||||
check(scale_mode == expected['screenshot_scale'] and scale_mode in ('css', 'device'), '截图像素缩放方式不匹配')
|
||||
scale = capture['dpr'] if scale_mode == 'device' else 1
|
||||
target_height = capture['document_height'] if capture['full_page'] else capture['viewport']['height']
|
||||
with Image.open(candidate) as image:
|
||||
check(abs(image.width - capture['viewport']['width']*scale) < 1, '截图宽度与元数据不符')
|
||||
check(abs(image.height - target_height*scale) < 1, '截图高度与元数据不符')
|
||||
for name, asset in data.get('expected_assets', {}).items():
|
||||
actual = capture.get('assets', {}).get(name, {})
|
||||
check(bool(asset) and all(actual.get(key) == value for key, value in asset.items()), f'资源版本或尺寸不匹配:{name}')
|
||||
if failures:
|
||||
raise ValueError(';'.join(failures))
|
||||
geometry = None
|
||||
if 'measurement_baseline' in data:
|
||||
geometry = compare_geometry(data, capture, base, previous)
|
||||
elif previous is not None:
|
||||
raise ValueError('前后比较需要由测量基线生成的验收配置')
|
||||
command = [sys.executable, str(Path(__file__).with_name('compare_mockup.py')),
|
||||
'--reference', str(reference), '--candidate', str(candidate), '--out-dir', str(out_dir), '--prefix', 'verify']
|
||||
if data.get('allow_height_difference'):
|
||||
command.append('--allow-height-difference')
|
||||
for clip in data.get('clips', []):
|
||||
command.extend(['--clip', clip])
|
||||
out_dir.mkdir(parents=True, exist_ok=False)
|
||||
result = subprocess.run(command, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError(result.stderr.strip())
|
||||
actual_sections = {section['name']: section for section in capture.get('sections', [])}
|
||||
positions = []
|
||||
for name, target_y in data.get('section_positions', {}).items():
|
||||
actual = actual_sections.get(name)
|
||||
positions.append({'name': name, 'expected_y': target_y, 'actual_y': actual['y'] if actual else None,
|
||||
'delta_y': round(actual['y']-target_y, 3) if actual else None})
|
||||
report = {'status': '截图身份与渲染前提通过;视觉仍需复核', 'manifest': str(manifest_path.resolve()),
|
||||
'capture': capture, 'section_positions': positions,
|
||||
'comparison': str(out_dir / 'verify-metrics.json')}
|
||||
if geometry is not None:
|
||||
report['geometry'] = geometry
|
||||
write_geometry_markdown(out_dir, geometry)
|
||||
if not geometry['complete']:
|
||||
report['status'] = '截图前提通过,但元素采集不完整,需处理报告中的缺项'
|
||||
(out_dir / 'verification.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
if geometry is not None and not geometry['complete']:
|
||||
raise ValueError('元素采集不完整;诊断报告已保存')
|
||||
|
||||
|
||||
def preflight(manifest_path):
|
||||
expected = json.loads(manifest_path.read_text(encoding='utf-8'))['expected']
|
||||
url = expected['url']
|
||||
if urlparse(url).hostname not in ('127.0.0.1', 'localhost', '::1'):
|
||||
raise ValueError('启动前检查仅用于本地页面')
|
||||
class TitleParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.inside = False
|
||||
self.parts = []
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'title': self.inside = True
|
||||
def handle_endtag(self, tag):
|
||||
if tag == 'title': self.inside = False
|
||||
def handle_data(self, text):
|
||||
if self.inside: self.parts.append(text)
|
||||
with urlopen(url, timeout=8) as response:
|
||||
if urldefrag(response.geturl())[0] != urldefrag(url)[0]:
|
||||
raise ValueError('页面重定向到其他URL')
|
||||
html = response.read(2_000_000).decode('utf-8')
|
||||
parser = TitleParser()
|
||||
parser.feed(html)
|
||||
actual = ''.join(parser.parts).strip()
|
||||
if not expected['title'] or actual != expected['title']:
|
||||
raise ValueError('本地HTML标题不匹配,禁止继续浏览器验证')
|
||||
print(json.dumps({'status': '本地服务与页面身份检查通过', 'url': url, 'title': actual}, ensure_ascii=False))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest', type=Path, required=True)
|
||||
parser.add_argument('--out-dir', type=Path)
|
||||
parser.add_argument('--previous', type=Path, help='同一测量基线下前轮 verification.json,用于报告改善与退步')
|
||||
parser.add_argument('--preflight', action='store_true', help='仅核对本地HTML服务与标题;成功后才能导航')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.preflight:
|
||||
preflight(args.manifest)
|
||||
elif args.out_dir is None:
|
||||
parser.error('截图验收需要 --out-dir')
|
||||
else:
|
||||
verify(args.manifest, args.out_dir, args.previous)
|
||||
except (ValueError, KeyError, OSError, TypeError) as error:
|
||||
parser.exit(2, f'验证失败:{error}\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# 兼容脚本名称;截图由当前已授权的宿主浏览器生成,不再隐式启动另一套浏览器。
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/verify_capture.py" "$@"
|
||||
Reference in new issue
Block a user