@rexvane/piweb 0.3.2 → 0.3.4

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.
@@ -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,28 @@ export function usePiWeb() {
496
496
  }
497
497
  }, [refreshWorkspaces]);
498
498
 
499
+ /** 手动输入路径添加工作区(原生选择器不可用 / 无桌面环境时的兜底) */
500
+ const addWorkspaceByPath = useCallback(async (dir: string): Promise<string | null> => {
501
+ const trimmed = dir.trim().replace(/^["']|["']$/g, "");
502
+ if (!trimmed) return null;
503
+ try {
504
+ const r = await fetch("/api/workspaces", {
505
+ method: "POST",
506
+ headers: { "Content-Type": "application/json" },
507
+ body: JSON.stringify({ action: "add", path: trimmed }),
508
+ });
509
+ const j = await r.json();
510
+ if (!r.ok || !j.success) throw new Error(j.error || `request failed (${r.status})`);
511
+ setAddedWorkspaces(j.data?.workspaces ?? []);
512
+ await refreshWorkspaces();
513
+ return trimmed;
514
+ } catch (error) {
515
+ const message = error instanceof Error ? error.message : "failed to add workspace";
516
+ setState((current) => ({ ...current, error: message }));
517
+ return null;
518
+ }
519
+ }, [refreshWorkspaces]);
520
+
499
521
  const removeWorkspace = useCallback(
500
522
  async (dir: string) => {
501
523
  try {
@@ -884,6 +906,7 @@ export function usePiWeb() {
884
906
  setGroupBy,
885
907
  setOrderBy,
886
908
  addWorkspaceByPicker,
909
+ addWorkspaceByPath,
887
910
  removeWorkspace,
888
911
  refreshModels,
889
912
  sendCommand,
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} 行",
@@ -143,6 +145,9 @@ const zh = {
143
145
  workspaces: "工作区",
144
146
  searchSessions: "搜索会话...",
145
147
  addWorkspace: "添加工作区",
148
+ workspacePathPlaceholder: "输入路径添加…",
149
+ workspacePathInvalid: "路径无效或目录不存在",
150
+ workspacePathAdd: "添加",
146
151
  groupBy: "分组方式",
147
152
  sortBy: "排序方式",
148
153
  byWorkspace: "按工作区",
@@ -258,6 +263,19 @@ const zh = {
258
263
  contextSystem: "系统提示词",
259
264
  contextMessages: "对话消息",
260
265
  contextTools: "工具",
266
+ contextSystemPrompt: "System Prompt",
267
+ contextSystemTools: "System Tools",
268
+ contextCustomTools: "Custom Tools",
269
+ contextMemory: "Memory(AGENTS.md)",
270
+ contextSkills: "Skills",
271
+ contextUserMessages: "User Messages",
272
+ contextAgentText: "Agent Text",
273
+ contextAgentThinking: "Agent Thinking",
274
+ contextAgentToolCall: "Agent Tool Call",
275
+ contextToolOutput: "Tool Output",
276
+ contextCompacted: "Compacted Data",
277
+ contextAutoCompactBuffer: "Auto-Compact Buffer",
278
+ contextFreeSpace: "Free Space",
261
279
  // 统计条
262
280
  turns: "轮次",
263
281
  toolCalls: "工具调用",
@@ -345,7 +363,7 @@ const zh = {
345
363
  providerIdImmutable: "Provider ID 创建后不可修改;如需更名,请删除后重建。",
346
364
  modelConfigReopen: "模型配置已保存。新建会话或重启 PiWeb 后,当前运行时才能使用新配置。",
347
365
  customModelIdentityNotice: "自定义模型 ID 只是发往该 API 地址的路由名;PiWeb 不会自动下载、接入或识别本机 Ollama 模型。",
348
- customApiKeyHint: "可留空改用 auth.json;Ollama 等无鉴权本地服务仍需填写任意占位值。",
366
+ customApiKeyHint: "可留空改用 auth.json;指向本机/私网的无鉴权服务(Ollama 等)保存时自动填占位值。",
349
367
  modelsTitle: "模型",
350
368
  modelsDesc: "填入各提供方的 API 密钥即可使用其模型。",
351
369
  addProvider: "添加提供方",
@@ -374,6 +392,8 @@ const zh = {
374
392
  displayNameOptional: "显示名称(可选)",
375
393
  modelCatalog: "模型目录",
376
394
  discoverModels: "获取可用模型",
395
+ allowPrivateDiscovery: "允许本机/私网",
396
+ allowPrivateHint: "模型地址指向本机或私有网络(如 Ollama、LM Studio、one-api 本地网关)时勾选,本次探测放行;发消息不受此限制",
377
397
  discoveringModels: "获取中...",
378
398
  usingDefaultModels: "正在使用 Pi SDK 默认模型 · {count} 个",
379
399
  customModelsRequired: "至少添加一个可发送的模型 ID",
@@ -385,6 +405,8 @@ const zh = {
385
405
  saving: "保存中...",
386
406
  setKey: "保存 Key",
387
407
  removeKey: "移除 Key",
408
+ removeOAuth: "移除 OAuth",
409
+ confirmRemoveOAuth: "确定移除“{name}”的 OAuth 登录吗?移除后需重新登录才能使用。",
388
410
  confirmRemoveProvider: "确定删除提供方“{name}”及其配置吗?",
389
411
  confirmRemoveKey: "确定移除“{name}”的已保存密钥吗?",
390
412
  customProviders: "自定义 Provider(models.json)",
@@ -412,6 +434,9 @@ const zh = {
412
434
  updatedTo: "已更新到 {v},重启 PiWeb 后生效",
413
435
  updateFailed: "更新失败",
414
436
  skillsDesc: "关闭后技能不会出现在系统提示中,仅可通过 /skill:名称 手动调用",
437
+ confirmDeleteSkill: "确定删除技能“{name}”吗?将删除其所在技能目录,不可恢复。",
438
+ deleteSkillHint: "删除技能目录",
439
+ skillDeletePackageHint: "包技能请用禁用管理",
415
440
  scopeGlobal: "全局",
416
441
  scopeProject: "项目",
417
442
  scopePackage: "包",
@@ -472,6 +497,7 @@ const zh = {
472
497
  growthNoChangesRound: "本轮无文件改动",
473
498
  growthNoSession: "打开或新建一个会话后,这里会显示项目的生长过程。",
474
499
  growthUnavailable: "机器上找不到 git,项目生长功能不可用",
500
+ growthUnavailableFallback: "生长快照不可用(找不到可用的 git),已显示当前目录结构",
475
501
  growthStepLabel: "第 {n} 步",
476
502
  growthRoundLabel: "第 {n} 轮",
477
503
  growthStepsCount: "{n} 步",
@@ -571,6 +597,7 @@ const en: Dict = {
571
597
  toolExit: "exit {code}",
572
598
  toolWritten: "written",
573
599
  diffSummary: "+{add} −{del} lines",
600
+ deletedFile: "deleted {path}",
574
601
  backToBottom: "Back to bottom",
575
602
  modeSteer: "steer",
576
603
  modeQueue: "queue",
@@ -640,6 +667,7 @@ const en: Dict = {
640
667
  modelErrorContext: "Context exceeds the model window; try /compact and retry",
641
668
  modelErrorTimeout: "Request timed out",
642
669
  modelErrorServer: "Provider server error or overloaded",
670
+ modelErrorModeration: "Request blocked by the provider's content filter (sensitive words); rephrase or start a new session",
643
671
  modelErrorNetwork: "Network connection failed",
644
672
  turnAborted: "Aborted",
645
673
  moreLines: "{n} more lines",
@@ -650,6 +678,9 @@ const en: Dict = {
650
678
  workspaces: "Workspaces",
651
679
  searchSessions: "Search sessions...",
652
680
  addWorkspace: "Add workspace",
681
+ workspacePathPlaceholder: "Type a path to add…",
682
+ workspacePathInvalid: "Invalid path or directory does not exist",
683
+ workspacePathAdd: "Add",
653
684
  groupBy: "Group by",
654
685
  sortBy: "Sort by",
655
686
  byWorkspace: "Workspace",
@@ -763,6 +794,19 @@ const en: Dict = {
763
794
  contextSystem: "System prompt",
764
795
  contextMessages: "Messages",
765
796
  contextTools: "Tools",
797
+ contextSystemPrompt: "System Prompt",
798
+ contextSystemTools: "System Tools",
799
+ contextCustomTools: "Custom Tools",
800
+ contextMemory: "Memory (AGENTS.md)",
801
+ contextSkills: "Skills",
802
+ contextUserMessages: "User Messages",
803
+ contextAgentText: "Agent Text",
804
+ contextAgentThinking: "Agent Thinking",
805
+ contextAgentToolCall: "Agent Tool Call",
806
+ contextToolOutput: "Tool Output",
807
+ contextCompacted: "Compacted Data",
808
+ contextAutoCompactBuffer: "Auto-Compact Buffer",
809
+ contextFreeSpace: "Free Space",
766
810
  turns: "turns",
767
811
  toolCalls: "tool calls",
768
812
  cost: "cost",
@@ -846,7 +890,7 @@ const en: Dict = {
846
890
  providerIdImmutable: "A Provider ID cannot be changed after creation. Delete and recreate it to rename it.",
847
891
  modelConfigReopen: "Model configuration saved. Start a new session or restart PiWeb before using it in the current runtime.",
848
892
  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.",
893
+ customApiKeyHint: "Leave blank to use auth.json. Keyless local/private services (Ollama etc.) get a placeholder key automatically on save.",
850
894
  modelsTitle: "Models",
851
895
  modelsDesc: "Enter an API key for each provider to use its models.",
852
896
  addProvider: "Add provider",
@@ -875,6 +919,8 @@ const en: Dict = {
875
919
  displayNameOptional: "Display name (optional)",
876
920
  modelCatalog: "Model catalog",
877
921
  discoverModels: "Fetch available models",
922
+ allowPrivateDiscovery: "Allow local/private",
923
+ 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
924
  discoveringModels: "Fetching...",
879
925
  usingDefaultModels: "Using Pi SDK default models · {count}",
880
926
  customModelsRequired: "Add at least one model ID that can be sent",
@@ -886,6 +932,8 @@ const en: Dict = {
886
932
  saving: "Saving...",
887
933
  setKey: "Save key",
888
934
  removeKey: "Remove key",
935
+ removeOAuth: "Remove OAuth",
936
+ confirmRemoveOAuth: "Remove the OAuth login for \"{name}\"? You will need to log in again to use it.",
889
937
  confirmRemoveProvider: "Delete provider \"{name}\" and its configuration?",
890
938
  confirmRemoveKey: "Remove the saved key for \"{name}\"?",
891
939
  customProviders: "Custom providers (models.json)",
@@ -913,6 +961,9 @@ const en: Dict = {
913
961
  updatedTo: "Updated to {v}; restart PiWeb to apply",
914
962
  updateFailed: "Update failed",
915
963
  skillsDesc: "Disabled skills are hidden from the system prompt and only callable via /skill:name",
964
+ confirmDeleteSkill: "Delete skill \"{name}\"? Its skill directory will be removed and cannot be recovered.",
965
+ deleteSkillHint: "Delete the skill directory",
966
+ skillDeletePackageHint: "package skill: use disable",
916
967
  scopeGlobal: "Global",
917
968
  scopeProject: "Project",
918
969
  scopePackage: "Package",
@@ -972,6 +1023,7 @@ const en: Dict = {
972
1023
  growthNoChangesRound: "No file changes in this round",
973
1024
  growthNoSession: "Open or start a session to watch the project grow here.",
974
1025
  growthUnavailable: "git was not found on this machine; project growth is unavailable",
1026
+ growthUnavailableFallback: "Growth snapshots are unavailable (no usable git); showing the current directory structure",
975
1027
  growthStepLabel: "Step {n}",
976
1028
  growthRoundLabel: "Round {n}",
977
1029
  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;