@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.
- package/README.md +48 -73
- package/package.json +1 -1
- package/scripts/build-output.mjs +3 -2
- package/scripts/install-build.mjs +54 -6
- package/src/app/api/models/route.ts +2 -1
- package/src/app/api/skills/route.ts +7 -2
- package/src/components/AppShell.tsx +12 -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 +1 -0
- package/src/i18n.tsx +48 -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
|
@@ -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
|
-
<
|
|
445
|
-
{
|
|
446
|
-
|
|
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
|
|
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
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
|
|
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
|
|
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",
|
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;
|