@rexvane/piweb 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -145,13 +145,13 @@ export function ProviderSetupModal({
145
145
  };
146
146
 
147
147
  /** 供 ModelCatalog 的候选对话框调用:用表单当前值(含未保存的 key)探测远端目录 */
148
- const discoverAvailableModels = async (): Promise<ModelDraft[]> => {
148
+ const discoverAvailableModels = async (allowPrivate: boolean): Promise<ModelDraft[]> => {
149
149
  if (!baseUrl.trim()) throw new Error(t.apiAddress);
150
150
  const response = await fetch("/api/models", {
151
151
  method: "POST",
152
152
  headers: { "Content-Type": "application/json" },
153
153
  // builtin 模式带 providerId:表单 key 为空时后端回退到已存密钥 / OAuth 令牌
154
- body: JSON.stringify({ action: "discoverModels", baseUrl: baseUrl.trim(), api: mode === "builtin" ? selected?.apis[0] : api, apiKey: apiKey.trim(), providerId: mode === "builtin" ? selected?.id : undefined }),
154
+ body: JSON.stringify({ action: "discoverModels", baseUrl: baseUrl.trim(), api: mode === "builtin" ? selected?.apis[0] : api, apiKey: apiKey.trim(), providerId: mode === "builtin" ? selected?.id : undefined, allowPrivate }),
155
155
  });
156
156
  const result = await response.json();
157
157
  if (!response.ok || !result.success) throw new Error(result.error || t.toastError);
@@ -332,14 +332,16 @@ export function ModelCatalog({
332
332
  setModels: React.Dispatch<React.SetStateAction<ModelDraft[]>>;
333
333
  updateModel: (index: number, patch: Partial<ModelDraft>) => void;
334
334
  inheritedCount?: number;
335
- /** 用表单当前值探测远端目录;抛错时错误显示在目录内 */
336
- discover?: () => Promise<ModelDraft[]>;
335
+ /** 用表单当前值探测远端目录(allowPrivate:用户勾选了允许访问本机/私网地址);抛错时错误显示在目录内 */
336
+ discover?: (allowPrivate: boolean) => Promise<ModelDraft[]>;
337
337
  discoverDisabled?: boolean;
338
338
  }) {
339
339
  const { t } = useI18n();
340
340
  const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
341
341
  const [discovering, setDiscovering] = useState(false);
342
342
  const [discoverError, setDiscoverError] = useState("");
343
+ // 本地网关(Ollama 等)在私网/回环地址上,探测需要用户显式授权
344
+ const [allowPrivate, setAllowPrivate] = useState(false);
343
345
  // 候选对话框:null = 关闭
344
346
  const [candidates, setCandidates] = useState<ModelDraft[] | null>(null);
345
347
  const [picked, setPicked] = useState<ReadonlySet<string>>(new Set());
@@ -384,7 +386,7 @@ export function ModelCatalog({
384
386
  setDiscovering(true);
385
387
  setDiscoverError("");
386
388
  try {
387
- const found = await discover();
389
+ const found = await discover(allowPrivate);
388
390
  if (!found.length) {
389
391
  setDiscoverError(t.discoverEmpty);
390
392
  return;
@@ -441,9 +443,19 @@ export function ModelCatalog({
441
443
  </div>
442
444
  </div>
443
445
  {discover ? (
444
- <button className={styles.discoverButton} disabled={discoverDisabled || discovering} onClick={() => void startDiscover()}>
445
- {discovering ? t.discoveringModels : t.discoverModels}
446
- </button>
446
+ <div className={styles.discoverControls}>
447
+ <label className={styles.allowPrivate} title={t.allowPrivateHint}>
448
+ <input
449
+ type="checkbox"
450
+ checked={allowPrivate}
451
+ onChange={(event) => setAllowPrivate(event.target.checked)}
452
+ />
453
+ {t.allowPrivateDiscovery}
454
+ </label>
455
+ <button className={styles.discoverButton} disabled={discoverDisabled || discovering} onClick={() => void startDiscover()}>
456
+ {discovering ? t.discoveringModels : t.discoverModels}
457
+ </button>
458
+ </div>
447
459
  ) : null}
448
460
  </div>
449
461
  {discoverError ? <div className={styles.catalogError}>{discoverError}</div> : null}
@@ -5,6 +5,7 @@
5
5
  * 五节:General / Models / 安全 / 技能 / 插件。
6
6
  */
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import { createPortal } from "react-dom";
8
9
  import { type EnterBehavior, getEnterBehavior, setEnterBehavior as saveEnterBehavior } from "@/lib/enter-behavior";
9
10
  import {
10
11
  IconAgentPresetOutline16,
@@ -605,11 +606,16 @@ function SelectOption({
605
606
  disabled?: boolean;
606
607
  }) {
607
608
  const [open, setOpen] = useState(false);
608
- const ref = useRef<HTMLDivElement>(null);
609
+ const [rect, setRect] = useState<{ left: number; top: number; bottom: number } | null>(null);
610
+ const anchorRef = useRef<HTMLButtonElement>(null);
611
+ const popRef = useRef<HTMLDivElement>(null);
609
612
  useEffect(() => {
610
613
  if (!open) return;
614
+ // 弹层挂在 body(fixed 定位):设置对话框 overflow-hidden 会裁掉 absolute 弹层
615
+ const anchor = anchorRef.current?.getBoundingClientRect();
616
+ if (anchor) setRect({ left: anchor.left, top: anchor.bottom + 6, bottom: anchor.top - 6 });
611
617
  const h = (e: MouseEvent) => {
612
- if (!ref.current?.contains(e.target as Node)) setOpen(false);
618
+ if (!anchorRef.current?.contains(e.target as Node) && !popRef.current?.contains(e.target as Node)) setOpen(false);
613
619
  };
614
620
  document.addEventListener("mousedown", h);
615
621
  return () => document.removeEventListener("mousedown", h);
@@ -619,19 +625,19 @@ function SelectOption({
619
625
  }, [disabled]);
620
626
  const current = options.find((o) => o.value === value);
621
627
  return (
622
- <div ref={ref} className="relative">
623
- <button className="select-chip" data-open={open} disabled={disabled} onClick={() => setOpen((o) => !o)}>
628
+ <div className="relative">
629
+ <button ref={anchorRef} className="select-chip" data-open={open} disabled={disabled} onClick={() => setOpen((o) => !o)}>
624
630
  {current?.label ?? value}
625
631
  <span className="chevron">
626
632
  <IconChevronDown14 size={14} />
627
633
  </span>
628
634
  </button>
629
- {open && !disabled && (
635
+ {open && !disabled && rect && createPortal(
630
636
  <div
631
- // left-0:根容器在表单里占满整行宽,right-0 会把弹层甩到行右缘(远离 chip);
632
- // chip 恒在容器左缘,左对齐两种用法(Row 的 flex-none / Field 的整行)都正确
633
- className="popover absolute left-0 top-10 z-50 p-1.5 shadow-xl"
634
- style={{ minWidth: width ?? 140, width: "max-content", maxHeight: 280, overflowY: "auto" }}
637
+ ref={popRef}
638
+ className="popover fixed z-[130] p-1.5 shadow-xl"
639
+ // 下方空间不足时向上翻转;左右都对齐 chip 左缘
640
+ style={{ left: rect.left, top: Math.min(rect.top, window.innerHeight - 40), minWidth: width ?? 140, width: "max-content", maxHeight: 280, overflowY: "auto" }}
635
641
  >
636
642
  {options.map((o) => {
637
643
  const selected = o.value === value;
@@ -664,7 +670,8 @@ function SelectOption({
664
670
  </button>
665
671
  );
666
672
  })}
667
- </div>
673
+ </div>,
674
+ document.body,
668
675
  )}
669
676
  </div>
670
677
  );
@@ -1140,6 +1147,17 @@ function ModelsSection() {
1140
1147
  <button className="btn-outline" style={{ height: 28, padding: "0 12px", fontSize: 12 }} onClick={() => startEdit(p.id)}>
1141
1148
  {t.edit}
1142
1149
  </button>
1150
+ {p.storedAuthType === "oauth" && (
1151
+ <button
1152
+ style={{ height: 28, padding: "0 12px", fontSize: 12, color: "var(--dsw-danger)" }}
1153
+ onClick={async () => {
1154
+ if (!window.confirm(t.confirmRemoveOAuth.replace("{name}", providerName(p.id)))) return;
1155
+ await call({ action: "removeKey", providerId: p.id });
1156
+ }}
1157
+ >
1158
+ {t.removeOAuth}
1159
+ </button>
1160
+ )}
1143
1161
  {p.authTypes?.includes("oauth") && (
1144
1162
  <button
1145
1163
  className="btn-outline"
@@ -1201,12 +1219,12 @@ function ModelsSection() {
1201
1219
  models={cModels}
1202
1220
  setModels={setCModels}
1203
1221
  updateModel={(index, patch) => setCModels((current) => current.map((model, at) => (at === index ? { ...model, ...patch } : model)))}
1204
- discover={async () => {
1222
+ discover={async (allowPrivate) => {
1205
1223
  const response = await fetch("/api/models", {
1206
1224
  method: "POST",
1207
1225
  headers: { "Content-Type": "application/json" },
1208
1226
  // providerId:key 留空(保持不变)时后端回退到已存密钥
1209
- body: JSON.stringify({ action: "discoverModels", providerId: p.id, baseUrl: cBaseUrl.trim(), api: cApi, apiKey: cKey.trim() }),
1227
+ body: JSON.stringify({ action: "discoverModels", providerId: p.id, baseUrl: cBaseUrl.trim(), api: cApi, apiKey: cKey.trim(), allowPrivate }),
1210
1228
  });
1211
1229
  const result = await response.json();
1212
1230
  if (!result.success) throw new Error(result.error || t.toastError);
@@ -1257,12 +1275,12 @@ function ModelsSection() {
1257
1275
  setModels={setBModels}
1258
1276
  updateModel={(index, patch) => setBModels((current) => current.map((model, at) => (at === index ? { ...model, ...patch } : model)))}
1259
1277
  inheritedCount={p.modelCount}
1260
- discover={async () => {
1278
+ discover={async (allowPrivate) => {
1261
1279
  const response = await fetch("/api/models", {
1262
1280
  method: "POST",
1263
1281
  headers: { "Content-Type": "application/json" },
1264
1282
  // providerId:key 留空(保持不变)时后端回退到已存密钥
1265
- body: JSON.stringify({ action: "discoverModels", providerId: p.id, baseUrl: (bBaseUrl.trim() || p.baseUrl || ""), api: p.apis[0], apiKey: keyDraft.trim() }),
1283
+ body: JSON.stringify({ action: "discoverModels", providerId: p.id, baseUrl: (bBaseUrl.trim() || p.baseUrl || ""), api: p.apis[0], apiKey: keyDraft.trim(), allowPrivate }),
1266
1284
  });
1267
1285
  const result = await response.json();
1268
1286
  if (!result.success) throw new Error(result.error || t.toastError);
@@ -1279,12 +1297,16 @@ function ModelsSection() {
1279
1297
  style={{ fontSize: 12.5, color: "var(--dsw-danger)" }}
1280
1298
  disabled={editBusy}
1281
1299
  onClick={async () => {
1282
- if (!window.confirm(t.confirmRemoveKey.replace("{name}", p.name))) return;
1300
+ if (p.storedAuthType === "oauth") {
1301
+ if (!window.confirm(t.confirmRemoveOAuth.replace("{name}", p.name))) return;
1302
+ } else {
1303
+ if (!window.confirm(t.confirmRemoveKey.replace("{name}", p.name))) return;
1304
+ }
1283
1305
  const j = await call({ action: "removeKey", providerId: p.id });
1284
1306
  if (j.success) setEditing(null);
1285
1307
  }}
1286
1308
  >
1287
- {t.removeKey}
1309
+ {p.storedAuthType === "oauth" ? t.removeOAuth : t.removeKey}
1288
1310
  </button>
1289
1311
  )}
1290
1312
  <button className="btn-outline" style={{ height: 32 }} disabled={editBusy} onClick={() => setEditing(null)}>{t.cancel}</button>
@@ -1626,11 +1648,30 @@ function SkillsSection({ cwd, onOpenFileContent }: { cwd: string; onOpenFileCont
1626
1648
  if (j.success) onOpenFileContent?.(s.filePath, j.data.content);
1627
1649
  };
1628
1650
 
1651
+ const [deleteError, setDeleteError] = useState<string | null>(null);
1652
+ const remove = async (s: SkillRow) => {
1653
+ if (!window.confirm(t.confirmDeleteSkill.replace("{name}", s.name))) return;
1654
+ setDeleteError(null);
1655
+ const r = await fetch("/api/skills", {
1656
+ method: "POST",
1657
+ headers: { "Content-Type": "application/json" },
1658
+ body: JSON.stringify({ action: "delete", filePath: s.filePath, cwd }),
1659
+ });
1660
+ const j = await r.json();
1661
+ if (j.success) void load();
1662
+ else setDeleteError(j.error || t.toastError);
1663
+ };
1664
+
1629
1665
  const scopeLabel = (s: SkillRow["scope"]) => (s === "global" ? t.scopeGlobal : s === "project" ? t.scopeProject : t.scopePackage);
1630
1666
 
1631
1667
  return (
1632
1668
  <div className="flex flex-col gap-4">
1633
1669
  <div style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>{t.skillsDesc}</div>
1670
+ {deleteError && (
1671
+ <div role="alert" className="rounded-lg px-3 py-2" style={{ fontSize: 12.5, background: "var(--dsw-hover)", color: "var(--dsw-danger)" }}>
1672
+ {deleteError}
1673
+ </div>
1674
+ )}
1634
1675
  {skills.length === 0 && (
1635
1676
  <div style={{ fontSize: 13, color: "var(--dsw-label-caption)" }}>—</div>
1636
1677
  )}
@@ -1659,6 +1700,21 @@ function SkillsSection({ cwd, onOpenFileContent }: { cwd: string; onOpenFileCont
1659
1700
  >
1660
1701
  {t.viewSkillFile}
1661
1702
  </button>
1703
+ {s.scope !== "package" && (
1704
+ <button
1705
+ className="rounded-lg px-2.5 py-1"
1706
+ style={{ fontSize: 11.5, color: "var(--dsw-danger)" }}
1707
+ title={t.deleteSkillHint}
1708
+ onClick={() => void remove(s)}
1709
+ >
1710
+ {t.delete}
1711
+ </button>
1712
+ )}
1713
+ {s.scope === "package" && (
1714
+ <span style={{ fontSize: 10.5, color: "var(--dsw-label-caption)" }} title={t.skillDeletePackageHint}>
1715
+ {t.skillDeletePackageHint}
1716
+ </span>
1717
+ )}
1662
1718
  <button
1663
1719
  className="relative h-5 w-9 flex-none rounded-full transition-colors"
1664
1720
  style={{ background: s.disabled ? "var(--dsw-border-l3)" : "var(--dsw-accent)" }}
@@ -265,19 +265,33 @@ export function useGrowth({
265
265
  try {
266
266
  const params = new URLSearchParams({ cwd });
267
267
  if (sessionPath) params.set("session", sessionPath);
268
- const [growthResult, diskResult] = await Promise.allSettled([
269
- sessionPath ? getJson<{ available: boolean; steps: GrowthStep[] }>(`/api/growth?${params}`, controller.signal) : Promise.resolve({ available: true, steps: [] }),
270
- listDiskEntries(cwd, "", controller.signal),
271
- ]);
268
+ // 两个请求解耦:磁盘目录先到先画,growth 慢(git 探测超时等)不拖住目录树
269
+ const growthRequest = sessionPath
270
+ ? getJson<{ available: boolean; steps: GrowthStep[] }>(`/api/growth?${params}`, controller.signal).then(
271
+ (value) => ({ status: "fulfilled" as const, value }),
272
+ (reason: unknown) => ({ status: "rejected" as const, reason }),
273
+ )
274
+ : Promise.resolve({ status: "fulfilled" as const, value: { available: true, steps: [] } });
275
+ const diskRequest = listDiskEntries(cwd, "", controller.signal).then(
276
+ (value) => ({ status: "fulfilled" as const, value }),
277
+ (reason: unknown) => ({ status: "rejected" as const, reason }),
278
+ );
279
+ void diskRequest.then((diskResult) => {
280
+ if (!isCurrent(controller)) return;
281
+ if (diskResult.status === "fulfilled") {
282
+ updateLazy(controller, (previous) => new Map(previous).set("", diskNodes("", diskResult.value)));
283
+ }
284
+ });
285
+ const growthResult = await growthRequest;
272
286
  if (!isCurrent(controller)) return;
273
287
  if (growthResult.status === "fulfilled") {
274
288
  setFetched({ key, steps: growthResult.value.steps ?? [], available: growthResult.value.available !== false, error: null });
275
289
  } else {
276
290
  setFetched({ key, steps: [], available: true, error: growthResult.reason instanceof Error ? growthResult.reason.message : "failed to load" });
277
291
  }
278
- if (diskResult.status === "fulfilled") {
279
- updateLazy(controller, (previous) => new Map(previous).set("", diskNodes("", diskResult.value)));
280
- } else if (growthResult.status === "fulfilled") {
292
+ const diskResult = await diskRequest;
293
+ if (!isCurrent(controller)) return;
294
+ if (diskResult.status === "rejected" && growthResult.status === "fulfilled") {
281
295
  setFetched((previous) => ({ ...previous, error: diskResult.reason instanceof Error ? diskResult.reason.message : "failed to list workspace" }));
282
296
  }
283
297
  } finally {
@@ -496,6 +496,7 @@ export function usePiWeb() {
496
496
  }
497
497
  }, [refreshWorkspaces]);
498
498
 
499
+
499
500
  const removeWorkspace = useCallback(
500
501
  async (dir: string) => {
501
502
  try {
package/src/i18n.tsx CHANGED
@@ -64,6 +64,7 @@ const zh = {
64
64
  toolExit: "退出码 {code}",
65
65
  toolWritten: "已写入",
66
66
  diffSummary: "+{add} −{del} 行",
67
+ deletedFile: "已删除 {path}",
67
68
  backToBottom: "回到底部",
68
69
  modeSteer: "插话",
69
70
  modeQueue: "排队",
@@ -133,6 +134,7 @@ const zh = {
133
134
  modelErrorContext: "上下文超出模型窗口,可用 /compact 压缩后重试",
134
135
  modelErrorTimeout: "请求超时",
135
136
  modelErrorServer: "供应商服务端出错或过载",
137
+ modelErrorModeration: "请求内容被供应商审核拦截(敏感词),换措辞或新开会话试试",
136
138
  modelErrorNetwork: "网络连接失败",
137
139
  turnAborted: "已中止",
138
140
  moreLines: "还有 {n} 行",
@@ -258,6 +260,19 @@ const zh = {
258
260
  contextSystem: "系统提示词",
259
261
  contextMessages: "对话消息",
260
262
  contextTools: "工具",
263
+ contextSystemPrompt: "System Prompt",
264
+ contextSystemTools: "System Tools",
265
+ contextCustomTools: "Custom Tools",
266
+ contextMemory: "Memory(AGENTS.md)",
267
+ contextSkills: "Skills",
268
+ contextUserMessages: "User Messages",
269
+ contextAgentText: "Agent Text",
270
+ contextAgentThinking: "Agent Thinking",
271
+ contextAgentToolCall: "Agent Tool Call",
272
+ contextToolOutput: "Tool Output",
273
+ contextCompacted: "Compacted Data",
274
+ contextAutoCompactBuffer: "Auto-Compact Buffer",
275
+ contextFreeSpace: "Free Space",
261
276
  // 统计条
262
277
  turns: "轮次",
263
278
  toolCalls: "工具调用",
@@ -345,7 +360,7 @@ const zh = {
345
360
  providerIdImmutable: "Provider ID 创建后不可修改;如需更名,请删除后重建。",
346
361
  modelConfigReopen: "模型配置已保存。新建会话或重启 PiWeb 后,当前运行时才能使用新配置。",
347
362
  customModelIdentityNotice: "自定义模型 ID 只是发往该 API 地址的路由名;PiWeb 不会自动下载、接入或识别本机 Ollama 模型。",
348
- customApiKeyHint: "可留空改用 auth.json;Ollama 等无鉴权本地服务仍需填写任意占位值。",
363
+ customApiKeyHint: "可留空改用 auth.json;指向本机/私网的无鉴权服务(Ollama 等)保存时自动填占位值。",
349
364
  modelsTitle: "模型",
350
365
  modelsDesc: "填入各提供方的 API 密钥即可使用其模型。",
351
366
  addProvider: "添加提供方",
@@ -374,6 +389,8 @@ const zh = {
374
389
  displayNameOptional: "显示名称(可选)",
375
390
  modelCatalog: "模型目录",
376
391
  discoverModels: "获取可用模型",
392
+ allowPrivateDiscovery: "允许本机/私网",
393
+ allowPrivateHint: "模型地址指向本机或私有网络(如 Ollama、LM Studio、one-api 本地网关)时勾选,本次探测放行;发消息不受此限制",
377
394
  discoveringModels: "获取中...",
378
395
  usingDefaultModels: "正在使用 Pi SDK 默认模型 · {count} 个",
379
396
  customModelsRequired: "至少添加一个可发送的模型 ID",
@@ -385,6 +402,8 @@ const zh = {
385
402
  saving: "保存中...",
386
403
  setKey: "保存 Key",
387
404
  removeKey: "移除 Key",
405
+ removeOAuth: "移除 OAuth",
406
+ confirmRemoveOAuth: "确定移除“{name}”的 OAuth 登录吗?移除后需重新登录才能使用。",
388
407
  confirmRemoveProvider: "确定删除提供方“{name}”及其配置吗?",
389
408
  confirmRemoveKey: "确定移除“{name}”的已保存密钥吗?",
390
409
  customProviders: "自定义 Provider(models.json)",
@@ -412,6 +431,9 @@ const zh = {
412
431
  updatedTo: "已更新到 {v},重启 PiWeb 后生效",
413
432
  updateFailed: "更新失败",
414
433
  skillsDesc: "关闭后技能不会出现在系统提示中,仅可通过 /skill:名称 手动调用",
434
+ confirmDeleteSkill: "确定删除技能“{name}”吗?将删除其所在技能目录,不可恢复。",
435
+ deleteSkillHint: "删除技能目录",
436
+ skillDeletePackageHint: "包技能请用禁用管理",
415
437
  scopeGlobal: "全局",
416
438
  scopeProject: "项目",
417
439
  scopePackage: "包",
@@ -472,6 +494,7 @@ const zh = {
472
494
  growthNoChangesRound: "本轮无文件改动",
473
495
  growthNoSession: "打开或新建一个会话后,这里会显示项目的生长过程。",
474
496
  growthUnavailable: "机器上找不到 git,项目生长功能不可用",
497
+ growthUnavailableFallback: "生长快照不可用(找不到可用的 git),已显示当前目录结构",
475
498
  growthStepLabel: "第 {n} 步",
476
499
  growthRoundLabel: "第 {n} 轮",
477
500
  growthStepsCount: "{n} 步",
@@ -571,6 +594,7 @@ const en: Dict = {
571
594
  toolExit: "exit {code}",
572
595
  toolWritten: "written",
573
596
  diffSummary: "+{add} −{del} lines",
597
+ deletedFile: "deleted {path}",
574
598
  backToBottom: "Back to bottom",
575
599
  modeSteer: "steer",
576
600
  modeQueue: "queue",
@@ -640,6 +664,7 @@ const en: Dict = {
640
664
  modelErrorContext: "Context exceeds the model window; try /compact and retry",
641
665
  modelErrorTimeout: "Request timed out",
642
666
  modelErrorServer: "Provider server error or overloaded",
667
+ modelErrorModeration: "Request blocked by the provider's content filter (sensitive words); rephrase or start a new session",
643
668
  modelErrorNetwork: "Network connection failed",
644
669
  turnAborted: "Aborted",
645
670
  moreLines: "{n} more lines",
@@ -763,6 +788,19 @@ const en: Dict = {
763
788
  contextSystem: "System prompt",
764
789
  contextMessages: "Messages",
765
790
  contextTools: "Tools",
791
+ contextSystemPrompt: "System Prompt",
792
+ contextSystemTools: "System Tools",
793
+ contextCustomTools: "Custom Tools",
794
+ contextMemory: "Memory (AGENTS.md)",
795
+ contextSkills: "Skills",
796
+ contextUserMessages: "User Messages",
797
+ contextAgentText: "Agent Text",
798
+ contextAgentThinking: "Agent Thinking",
799
+ contextAgentToolCall: "Agent Tool Call",
800
+ contextToolOutput: "Tool Output",
801
+ contextCompacted: "Compacted Data",
802
+ contextAutoCompactBuffer: "Auto-Compact Buffer",
803
+ contextFreeSpace: "Free Space",
766
804
  turns: "turns",
767
805
  toolCalls: "tool calls",
768
806
  cost: "cost",
@@ -846,7 +884,7 @@ const en: Dict = {
846
884
  providerIdImmutable: "A Provider ID cannot be changed after creation. Delete and recreate it to rename it.",
847
885
  modelConfigReopen: "Model configuration saved. Start a new session or restart PiWeb before using it in the current runtime.",
848
886
  customModelIdentityNotice: "A custom model ID is only a route name sent to that API endpoint. PiWeb does not automatically download, attach, or identify local Ollama models.",
849
- customApiKeyHint: "Leave blank to use auth.json. Keyless local services such as Ollama still need any placeholder value.",
887
+ customApiKeyHint: "Leave blank to use auth.json. Keyless local/private services (Ollama etc.) get a placeholder key automatically on save.",
850
888
  modelsTitle: "Models",
851
889
  modelsDesc: "Enter an API key for each provider to use its models.",
852
890
  addProvider: "Add provider",
@@ -875,6 +913,8 @@ const en: Dict = {
875
913
  displayNameOptional: "Display name (optional)",
876
914
  modelCatalog: "Model catalog",
877
915
  discoverModels: "Fetch available models",
916
+ allowPrivateDiscovery: "Allow local/private",
917
+ allowPrivateHint: "Check this when the model address points to loopback or a private network (Ollama, LM Studio, one-api gateways) to allow this probe; chat requests are not affected",
878
918
  discoveringModels: "Fetching...",
879
919
  usingDefaultModels: "Using Pi SDK default models · {count}",
880
920
  customModelsRequired: "Add at least one model ID that can be sent",
@@ -886,6 +926,8 @@ const en: Dict = {
886
926
  saving: "Saving...",
887
927
  setKey: "Save key",
888
928
  removeKey: "Remove key",
929
+ removeOAuth: "Remove OAuth",
930
+ confirmRemoveOAuth: "Remove the OAuth login for \"{name}\"? You will need to log in again to use it.",
889
931
  confirmRemoveProvider: "Delete provider \"{name}\" and its configuration?",
890
932
  confirmRemoveKey: "Remove the saved key for \"{name}\"?",
891
933
  customProviders: "Custom providers (models.json)",
@@ -913,6 +955,9 @@ const en: Dict = {
913
955
  updatedTo: "Updated to {v}; restart PiWeb to apply",
914
956
  updateFailed: "Update failed",
915
957
  skillsDesc: "Disabled skills are hidden from the system prompt and only callable via /skill:name",
958
+ confirmDeleteSkill: "Delete skill \"{name}\"? Its skill directory will be removed and cannot be recovered.",
959
+ deleteSkillHint: "Delete the skill directory",
960
+ skillDeletePackageHint: "package skill: use disable",
916
961
  scopeGlobal: "Global",
917
962
  scopeProject: "Project",
918
963
  scopePackage: "Package",
@@ -972,6 +1017,7 @@ const en: Dict = {
972
1017
  growthNoChangesRound: "No file changes in this round",
973
1018
  growthNoSession: "Open or start a session to watch the project grow here.",
974
1019
  growthUnavailable: "git was not found on this machine; project growth is unavailable",
1020
+ growthUnavailableFallback: "Growth snapshots are unavailable (no usable git); showing the current directory structure",
975
1021
  growthStepLabel: "Step {n}",
976
1022
  growthRoundLabel: "Round {n}",
977
1023
  growthStepsCount: "{n} steps",
@@ -30,9 +30,12 @@ import { userTurnsFromEntries } from "./growth-turns";
30
30
  import { TrajLedger, buildTrajectoryFromEntries, toTrajTokens } from "./trajectory";
31
31
  import { sanitizeToolOutput } from "./text-sanitize";
32
32
  import { BoundaryError } from "./path-security";
33
+ import { estimateTokensOf } from "./process-format";
34
+ import { getPiSettings } from "./pi-settings";
33
35
  import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
34
36
  import type {
35
37
  AgentCommand,
38
+ ContextBreakdown,
36
39
  ContextResource,
37
40
  GrowthStep,
38
41
  TrajEntry,
@@ -604,6 +607,91 @@ function loadContextResources(cwd: string, loader: AgentSession["resourceLoader"
604
607
  ];
605
608
  }
606
609
 
610
+ /**
611
+ * 上下文占用的服务端估算分项。systemPrompt 是整段拼接(正文 + memory + skills + 工具列表),
612
+ * 所以 Memory / Skills 从资源加载器与 systemPrompt 的资源段差分得出:
613
+ * SDK 的拼接顺序固定为 正文 → context files → skills → 工具列表,按内容定位切块。
614
+ */
615
+ async function buildContextBreakdown(
616
+ cwd: string,
617
+ session: AgentSession | null,
618
+ resources: ContextResource[],
619
+ ): Promise<ContextBreakdown> {
620
+ const breakdown: ContextBreakdown = {
621
+ systemPrompt: 0, systemTools: 0, customTools: 0, memory: 0, skills: 0, compacted: 0, autoCompactBuffer: 0,
622
+ };
623
+ if (!session) return breakdown;
624
+ try {
625
+ const prompt = session.systemPrompt ?? "";
626
+ // Memory:AGENTS.md 等注入文件(contextResources 的 project 部分)
627
+ const memoryText = resources.filter((r) => r.source === "project").map((r) => r.content).join("\n\n");
628
+ breakdown.memory = estimateTokensOf(memoryText);
629
+ // Skills:从资源加载器取技能清单,按 system-prompt.js 的 formatSkillsForPrompt 段落定位
630
+ try {
631
+ const { skills } = session.resourceLoader.getSkills();
632
+ if (skills.length && prompt) {
633
+ const header = prompt.indexOf("## Skills");
634
+ if (header >= 0) {
635
+ // skills 段之后是工具列表段(## Available Tools 等);找不到就取到结尾
636
+ const next = prompt.slice(header + 1).search(/^## /m);
637
+ const section = next >= 0 ? prompt.slice(header, header + 1 + next) : prompt.slice(header);
638
+ breakdown.skills = estimateTokensOf(section);
639
+ }
640
+ }
641
+ } catch {
642
+ /* loader 不可用则跳过 */
643
+ }
644
+ // System Prompt 正文 = 全文减去资源段与 skills 段的近似差分,下限 0
645
+ const estimatedSections = breakdown.memory + breakdown.skills;
646
+ breakdown.systemPrompt = Math.max(0, estimateTokensOf(prompt) - estimatedSections);
647
+ // 工具:builtin 与扩展注册的分开统计(schema + description + guidelines 序列化估算)
648
+ try {
649
+ const tools = session.getAllTools();
650
+ for (const tool of tools) {
651
+ const size = estimateTokensOf(JSON.stringify({
652
+ name: tool.name,
653
+ description: tool.description,
654
+ parameters: tool.parameters,
655
+ promptGuidelines: tool.promptGuidelines,
656
+ }));
657
+ if ((tool as { sourceInfo?: { source?: string } }).sourceInfo?.source === "builtin") breakdown.systemTools += size;
658
+ else breakdown.customTools += size;
659
+ }
660
+ } catch {
661
+ /* getAllTools 不可用则跳过 */
662
+ }
663
+ // 最新一次压缩的摘要
664
+ try {
665
+ const entries = m_smEntries(session);
666
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
667
+ const entry = entries[i] as { type?: string; summary?: string };
668
+ if (entry?.type === "compaction" && typeof entry.summary === "string") {
669
+ breakdown.compacted = estimateTokensOf(entry.summary);
670
+ break;
671
+ }
672
+ }
673
+ } catch {
674
+ /* entries 不可用则跳过 */
675
+ }
676
+ // auto-compact 预留 = compaction.reserveTokens(启用时)
677
+ try {
678
+ const settings = await getPiSettings();
679
+ if (settings.compaction.enabled) breakdown.autoCompactBuffer = settings.compaction.reserveTokens;
680
+ } catch {
681
+ /* 设置读取失败则保持 0 */
682
+ }
683
+ } catch {
684
+ /* systemPrompt 不可用则保持 0 */
685
+ }
686
+ return breakdown;
687
+ }
688
+
689
+ /** SessionManager 的 getEntries 经由 session 暴露的形态(各版本字段名有差异,这里宽松取) */
690
+ function m_smEntries(session: AgentSession): unknown[] {
691
+ const s = session as unknown as { sm?: { getEntries?: () => unknown[] }; sessionManager?: { getEntries?: () => unknown[] } };
692
+ return s.sm?.getEntries?.() ?? s.sessionManager?.getEntries?.() ?? [];
693
+ }
694
+
607
695
  function environmentTrajectory(m: Managed, session: AgentSession | null): TrajEntry[] {
608
696
  const ts = m.ledger.entries[0]?.ts ?? Date.now();
609
697
  const entries: TrajEntry[] = [];
@@ -1051,6 +1139,13 @@ export async function buildSnapshot(m: Managed): Promise<WebSnapshot> {
1051
1139
  } catch {
1052
1140
  contextResources = [];
1053
1141
  }
1142
+ // 服务端可得的上下文分项(估算;失败不影响快照)
1143
+ let contextBreakdown: ContextBreakdown | undefined;
1144
+ try {
1145
+ contextBreakdown = await buildContextBreakdown(m.cwd, m.session, contextResources);
1146
+ } catch {
1147
+ contextBreakdown = undefined;
1148
+ }
1054
1149
 
1055
1150
  // 斜杠命令数据源:技能 / 提示模板 / 扩展命令,加上项目信任与资源诊断
1056
1151
  const resources = collectResources(m);
@@ -1065,6 +1160,7 @@ export async function buildSnapshot(m: Managed): Promise<WebSnapshot> {
1065
1160
  name,
1066
1161
  contextFiles: contextResources.map((resource) => resource.path),
1067
1162
  contextResources,
1163
+ contextBreakdown,
1068
1164
  ...resources,
1069
1165
  messages,
1070
1166
  userTurns: userTurnsFromEntries(m.sm.getEntries() as any[]),
@@ -1143,7 +1239,14 @@ export async function execute(m: Managed, cmd: AgentCommand): Promise<CommandRes
1143
1239
  if (m.disposed) throw new Error("session is disposed");
1144
1240
  // 只有用户显式选择 steer/followUp 才排队;旧的普通提交不能悄悄成为 steering。
1145
1241
  if ((session.isStreaming || m.runActive) && !cmd.behavior) return { ok: false, error: "session is busy; choose steer or followUp" };
1146
- if (!session.isStreaming) await growthOf(m).prepare();
1242
+ if (!session.isStreaming) {
1243
+ // 基线快照不能无限阻塞首条 prompt(git 不可用时每次探测至多 30s 超时):
1244
+ // 5s 内没完成就放行 prompt,快照后台补拍(工具结束后照常记步,外部修改兜底)。
1245
+ await Promise.race([
1246
+ growthOf(m).prepare().catch(() => undefined),
1247
+ new Promise<void>((resolve) => setTimeout(resolve, 5000)),
1248
+ ]);
1249
+ }
1147
1250
  if (m.disposed) throw new Error("session is disposed");
1148
1251
  return await new Promise<CommandResult>((resolve) => {
1149
1252
  let accepted = false;