@rexvane/piweb 0.3.3 → 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.
- package/package.json +1 -1
- package/scripts/build-output.mjs +3 -2
- package/src/app/api/models/route.ts +2 -1
- package/src/app/api/skills/route.ts +7 -2
- package/src/components/AppShell.tsx +49 -6
- package/src/components/ChatInput.tsx +5 -1
- package/src/components/ChatWindow.tsx +13 -3
- package/src/components/ContextMeter.tsx +74 -31
- package/src/components/ProjectPanel.tsx +6 -0
- package/src/components/ProviderSetupModal.module.css +18 -0
- package/src/components/ProviderSetupModal.tsx +20 -8
- package/src/components/SettingsPanel.tsx +72 -16
- package/src/hooks/useGrowth.ts +21 -7
- package/src/hooks/usePiWeb.ts +23 -0
- package/src/i18n.tsx +54 -2
- package/src/lib/agent-manager.ts +104 -1
- package/src/lib/growth-service.ts +93 -49
- package/src/lib/models-service.ts +53 -13
- package/src/lib/process-format.ts +49 -1
- package/src/lib/skills-service.ts +23 -0
- package/src/lib/types.ts +24 -0
- package/src/lib/update-service.ts +1 -1
- package/src/lib/workspace-store.ts +81 -43
|
@@ -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
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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 (
|
|
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)" }}
|
package/src/hooks/useGrowth.ts
CHANGED
|
@@ -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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 {
|
package/src/hooks/usePiWeb.ts
CHANGED
|
@@ -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
|
|
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
|
|
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",
|
package/src/lib/agent-manager.ts
CHANGED
|
@@ -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)
|
|
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;
|