feat: webUI 文本文件编辑/保存 + 相关改动

- FileViewer 新增文本编辑模式(textarea + 保存/取消 + Ctrl+S)
- 后端 /api/files POST 新增 type=write 原子写入
- i18n 补充编辑相关文案(en/zh-CN)
- searxng-search: 加强 web_search 自动搜索条件
- worktree/next.config: 路径比较与 allowedDevOrigins 优化
This commit is contained in:
luckyyzh
2026-08-01 02:58:01 +08:00
parent 551c59413e
commit a70798e874
8 changed files with 429 additions and 12 deletions
+92 -1
View File
@@ -17,6 +17,9 @@ import {
getDocumentMime, getDocumentMime,
getFileExt, getFileExt,
getImageMime, getImageMime,
isAudioPath,
isDocumentPreviewPath,
isImagePath,
} from "@/lib/file-types"; } from "@/lib/file-types";
import { resolveDirentIsDirectory } from "@/lib/file-dirent"; import { resolveDirentIsDirectory } from "@/lib/file-dirent";
import { isFilePathReferencedBySession } from "@/lib/session-file-references"; import { isFilePathReferencedBySession } from "@/lib/session-file-references";
@@ -39,6 +42,7 @@ const IGNORED_SUFFIXES = [".pyc"];
const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const; const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const;
type FileRequestType = typeof FILE_REQUEST_TYPES[number]; type FileRequestType = typeof FILE_REQUEST_TYPES[number];
const FILE_REQUEST_TYPE_SET = new Set<string>(FILE_REQUEST_TYPES); const FILE_REQUEST_TYPE_SET = new Set<string>(FILE_REQUEST_TYPES);
const MAX_WRITE_BODY_BYTES = TEXT_PREVIEW_MAX_BYTES;
const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024; const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024;
const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024; const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024;
// Multipart boundaries and headers are not file bytes, but must be bounded too. // Multipart boundaries and headers are not file bytes, but must be bounded too.
@@ -122,6 +126,46 @@ function parseUploadFileNames(value: unknown): string[] | null {
return value; return value;
} }
async function getWritableFile(
segments: string[],
sessionId: string | null,
): Promise<{ filePath: string } | { response: NextResponse }> {
const filePath = filePathFromSegments(segments);
const allowedRoots = await getAllowedFileRoots();
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
const allowedBySessionReference =
!allowedByRoot &&
await isFilePathReferencedBySession(filePath, sessionId);
if (!allowedByRoot && !allowedBySessionReference) {
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
}
// The target must already exist as a regular file that resolves inside an
// allowed root. Resolving before the write prevents a symlink (possibly
// swapped in by a race) from redirecting the payload outside the roots, and
// the non-existing-path check above excludes files the session or current
// roots no longer own.
if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) {
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
}
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return { response: NextResponse.json({ error: "Not found" }, { status: 404 }) };
}
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
}
if (!stat.isFile() || stat.isSymbolicLink()) {
return { response: NextResponse.json({ error: "Not a writable file" }, { status: 403 }) };
}
return { filePath };
}
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> } { params }: { params: Promise<{ path: string[] }> }
@@ -132,10 +176,57 @@ export async function POST(
try { try {
const { path: segments } = await params; const { path: segments } = await params;
const type = request.nextUrl.searchParams.get("type") ?? "upload";
const sessionId = request.nextUrl.searchParams.get("sessionId");
if (type === "write") {
const writable = await getWritableFile(segments, sessionId);
if ("response" in writable) return writable.response;
const { filePath } = writable;
const readonlyFile = isImagePath(filePath) || isAudioPath(filePath) || isDocumentPreviewPath(filePath);
if (readonlyFile) {
return NextResponse.json({ error: "This file type cannot be edited as text" }, { status: 400 });
}
const body = await request.json().catch(() => null) as { content?: unknown } | null;
if (!body || typeof body.content !== "string") {
return NextResponse.json({ error: "content must be a string" }, { status: 400 });
}
const content = body.content;
const contentBytes = Buffer.byteLength(content, "utf-8");
if (contentBytes > MAX_WRITE_BODY_BYTES) {
return NextResponse.json(
{ error: "File too large to save (>256KB)" },
{ status: 413 },
);
}
// Write to a temp file in the same directory then rename, so a crash or
// an error mid-write never leaves a truncated file behind.
const directory = path.dirname(filePath);
const tmpPath = path.join(
directory,
`.pi-write-${path.basename(filePath)}-${process.pid}-${Date.now()}.tmp`,
);
try {
fs.writeFileSync(tmpPath, content, "utf-8");
fs.renameSync(tmpPath, filePath);
} catch (writeError) {
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
return NextResponse.json(
{ error: writeError instanceof Error ? writeError.message : String(writeError) },
{ status: 500 },
);
}
const stat = fs.statSync(filePath);
return NextResponse.json({ size: stat.size });
}
const uploadDirectory = await getUploadDirectory(segments); const uploadDirectory = await getUploadDirectory(segments);
if ("response" in uploadDirectory) return uploadDirectory.response; if ("response" in uploadDirectory) return uploadDirectory.response;
const { directory } = uploadDirectory; const { directory } = uploadDirectory;
const type = request.nextUrl.searchParams.get("type") ?? "upload";
if (type === "upload-check") { if (type === "upload-check") {
const body = await request.json().catch(() => null) as { fileNames?: unknown } | null; const body = await request.json().catch(() => null) as { fileNames?: unknown } | null;
+1
View File
@@ -1478,6 +1478,7 @@ export function AppShell() {
gitRefreshKey={explorerRefreshKey} gitRefreshKey={explorerRefreshKey}
initialDisplayMode={activeFileTab.initialDisplayMode} initialDisplayMode={activeFileTab.initialDisplayMode}
onMentionLines={rightPanelOpen ? handleFileLineMention : undefined} onMentionLines={rightPanelOpen ? handleFileLineMention : undefined}
onEditorSaved={() => setExplorerRefreshKey((k) => k + 1)}
onOpenFile={(filePath) => handleOpenFile( onOpenFile={(filePath) => handleOpenFile(
filePath, filePath,
getFileName(filePath), getFileName(filePath),
+292 -6
View File
@@ -31,6 +31,7 @@ interface Props {
sourceSessionId?: string | null; sourceSessionId?: string | null;
onOpenFile?: (filePath: string) => void; onOpenFile?: (filePath: string) => void;
onMentionLines?: (relativePath: string, startLine: number, endLine: number) => void; onMentionLines?: (relativePath: string, startLine: number, endLine: number) => void;
onEditorSaved?: (filePath: string) => void;
gitRefreshKey?: number; gitRefreshKey?: number;
initialDisplayMode?: DisplayMode; initialDisplayMode?: DisplayMode;
} }
@@ -207,6 +208,16 @@ function getFileApiUrl(
return `/api/files/${encoded}?${searchParams.toString()}`; return `/api/files/${encoded}?${searchParams.toString()}`;
} }
function getFileWriteApiUrl(
filePath: string,
sourceSessionId?: string | null,
): string {
const encoded = encodeFilePathForApi(filePath);
const searchParams = new URLSearchParams({ type: "write" });
if (sourceSessionId) searchParams.set("sessionId", sourceSessionId);
return `/api/files/${encoded}?${searchParams.toString()}`;
}
function DownloadLink({ filePath, sourceSessionId }: { filePath: string; sourceSessionId?: string | null }) { function DownloadLink({ filePath, sourceSessionId }: { filePath: string; sourceSessionId?: string | null }) {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
@@ -783,7 +794,7 @@ function DocumentViewer({ filePath, cwd, sourceSessionId }: Props) {
); );
} }
export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey, initialDisplayMode }: Props) { export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: Props) {
if (isImagePath(filePath)) { if (isImagePath(filePath)) {
return <ImageViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />; return <ImageViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
} }
@@ -793,10 +804,10 @@ export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMenti
if (isDocumentPreviewPath(filePath)) { if (isDocumentPreviewPath(filePath)) {
return <DocumentViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />; return <DocumentViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
} }
return <TextFileViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} onOpenFile={onOpenFile} onMentionLines={onMentionLines} gitRefreshKey={gitRefreshKey} initialDisplayMode={initialDisplayMode} />; return <TextFileViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} onOpenFile={onOpenFile} onMentionLines={onMentionLines} onEditorSaved={onEditorSaved} gitRefreshKey={gitRefreshKey} initialDisplayMode={initialDisplayMode} />;
} }
function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey, initialDisplayMode }: Props) { function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: Props) {
const { isDark } = useTheme(); const { isDark } = useTheme();
const { t } = useI18n(); const { t } = useI18n();
const [data, setData] = useState<FileData | null>(null); const [data, setData] = useState<FileData | null>(null);
@@ -810,7 +821,13 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
const esRef = useRef<EventSource | null>(null); const esRef = useRef<EventSource | null>(null);
const gitDiffRequestRef = useRef(0); const gitDiffRequestRef = useRef(0);
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<HTMLTextAreaElement | null>(null);
const [selectedLineRange, setSelectedLineRange] = useState<SelectedLineRange | null>(null); const [selectedLineRange, setSelectedLineRange] = useState<SelectedLineRange | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState<string>("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [confirmDiscard, setConfirmDiscard] = useState(false);
const fetchContent = useCallback((filePath: string) => { const fetchContent = useCallback((filePath: string) => {
return fetch(getFileApiUrl(filePath, "read", sourceSessionId)) return fetch(getFileApiUrl(filePath, "read", sourceSessionId))
@@ -969,6 +986,88 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
mentionLineRange(selectedLineRange); mentionLineRange(selectedLineRange);
}, [mentionLineRange, selectedLineRange]); }, [mentionLineRange, selectedLineRange]);
const isDeleted = isDeletedDiff;
// Every text file routed to this viewer (i.e. non-image/audio/document) can
// be edited; the backend independently rejects binary/read-only types.
const editable = !isDeleted && !!data;
const dirty = isEditing && draft !== (data?.content ?? "");
const stopEditing = useCallback(() => {
setConfirmDiscard(false);
setIsEditing(false);
setSaveError(null);
setDraft("");
}, []);
const exitEditingGuard = useCallback((handler: () => void) => {
if (dirty) {
setConfirmDiscard(true);
return;
}
stopEditing();
handler();
}, [dirty, stopEditing]);
const startEditing = useCallback(() => {
if (!data) return;
setDraft(data.content);
setSaveError(null);
setConfirmDiscard(false);
setIsEditing(true);
requestAnimationFrame(() => editorRef.current?.focus());
}, [data]);
const saveFile = useCallback(async () => {
if (saving || !dirty) {
if (!dirty && isEditing) stopEditing();
return;
}
setSaving(true);
setSaveError(null);
try {
const response = await fetch(
getFileWriteApiUrl(filePath, sourceSessionId),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: draft }),
},
);
const result = await response.json().catch(() => ({})) as { size?: number; error?: string };
if (!response.ok) {
setSaveError(result.error ?? `HTTP ${response.status}`);
return;
}
setData((prev) => (prev ? { ...prev, content: draft, size: result.size ?? prev.size } : prev));
stopEditing();
void fetchGitDiff(filePath);
onEditorSaved?.(filePath);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}, [dirty, draft, filePath, fetchGitDiff, isEditing, onEditorSaved, saving, sourceSessionId, stopEditing]);
// Reset edit state whenever the file path or content source changes.
useEffect(() => {
stopEditing();
setSaveError(null);
setSaving(false);
}, [filePath, sourceSessionId, stopEditing]);
// Ctrl/Cmd+S saves while editing.
useEffect(() => {
if (!isEditing) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.repeat || event.key.toLowerCase() !== "s" || (!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey) return;
event.preventDefault();
void saveFile();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isEditing, saveFile]);
useEffect(() => { useEffect(() => {
if (!onMentionLines || displayMode !== "source") return; if (!onMentionLines || displayMode !== "source") return;
@@ -1069,7 +1168,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
<button <button
key={mode} key={mode}
type="button" type="button"
onClick={() => setDisplayMode(mode)} onClick={() => exitEditingGuard(() => setDisplayMode(mode))}
title={mode === "diff" ? t("i18n.compareHead") : undefined} title={mode === "diff" ? t("i18n.compareHead") : undefined}
aria-pressed={active} aria-pressed={active}
className="file-viewer-mode-button" className="file-viewer-mode-button"
@@ -1086,7 +1185,65 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
)} )}
<div className="file-viewer-actions"> <div className="file-viewer-actions">
{effectiveDisplayMode === "source" && ( {effectiveDisplayMode === "source" && !isEditing && editable && (
<button
type="button"
onClick={startEditing}
title={t("i18n.editFile")}
aria-label={t("i18n.editFile")}
className="file-viewer-icon-button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
</button>
)}
{isEditing && (
<>
<button
type="button"
onClick={() => void saveFile()}
disabled={saving || !dirty}
title={t("i18n.saveShortcut")}
aria-label={t("i18n.saveFile")}
className="file-viewer-icon-button"
style={{
color: dirty ? "#22c55e" : "var(--text-muted)",
opacity: saving || !dirty ? 0.6 : 1,
}}
>
{saving ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ animation: "spin 0.8s linear infinite" }} aria-hidden="true">
<path d="M21 12a9 9 0 1 1-5.7-8.4" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" />
<path d="M17 21v-8H7v8" />
<path d="M7 3v5h8" />
</svg>
)}
</button>
<button
type="button"
onClick={() => {
if (dirty) setConfirmDiscard(true);
else stopEditing();
}}
title={t("i18n.stopEditing")}
aria-label={t("i18n.stopEditing")}
className="file-viewer-icon-button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</>
)}
{effectiveDisplayMode === "source" && !isEditing && (
<> <>
<button <button
type="button" type="button"
@@ -1128,7 +1285,88 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
{/* Content area */} {/* Content area */}
<div ref={contentRef} className="file-viewer-content" style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}> <div ref={contentRef} className="file-viewer-content" style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
{effectiveDisplayMode === "diff" && hasGitDiff ? ( {isEditing ? (
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
{saveError && (
<div
role="alert"
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
fontSize: 11,
color: "#f87171",
background: "color-mix(in srgb, #ef4444 8%, var(--bg))",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t("i18n.saveError", { error: saveError })}
</span>
<button
type="button"
onClick={() => setSaveError(null)}
title={t("i18n.close")}
style={{ marginLeft: "auto", background: "none", border: "none", color: "var(--text-dim)", cursor: "pointer", padding: 2, display: "flex" }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<path d="m6 6 12 12" />
<path d="M18 6 6 18" />
</svg>
</button>
</div>
)}
<textarea
ref={editorRef}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
aria-label={t("i18n.editFile")}
style={{
flex: 1,
width: "100%",
minHeight: 0,
resize: "none",
border: "none",
outline: "none",
background: "var(--bg)",
color: "var(--text)",
padding: "14px 16px",
fontFamily: "var(--font-mono)",
fontSize: 13,
lineHeight: 1.6,
whiteSpace: "pre",
overflow: "auto",
tabSize: 2,
}}
onKeyDown={(e) => {
// Tab inserts spaces instead of moving focus.
if (e.key === "Tab") {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const indent = " ";
const next = draft.slice(0, start) + indent + draft.slice(end);
setDraft(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + indent.length;
});
}
}}
/>
</div>
) : effectiveDisplayMode === "diff" && hasGitDiff ? (
<DiffView patch={gitDiff.patch!} /> <DiffView patch={gitDiff.patch!} />
) : isHtml && effectiveDisplayMode === "preview" ? ( ) : isHtml && effectiveDisplayMode === "preview" ? (
<iframe <iframe
@@ -1237,6 +1475,54 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
</SyntaxHighlighter> </SyntaxHighlighter>
)} )}
</div> </div>
{confirmDiscard && (
<div
role="alertdialog"
aria-modal="true"
aria-label={t("i18n.discardChanges")}
style={{
position: "absolute",
inset: 0,
zIndex: 20,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.32)",
}}
>
<div
style={{
width: "min(360px, calc(100% - 48px))",
background: "var(--bg-panel)",
border: "1px solid var(--border)",
borderRadius: 10,
padding: "16px 16px 12px",
boxShadow: "0 12px 32px rgba(0,0,0,0.28)",
}}
>
<div style={{ fontSize: 13, lineHeight: 1.45, color: "var(--text)" }}>
{t("i18n.closeWithoutSaving")}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 6, marginTop: 14 }}>
<button
type="button"
onClick={() => setConfirmDiscard(false)}
style={{ height: 26, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 5, background: "var(--bg)", color: "var(--text)", cursor: "pointer", fontSize: 11 }}
>
{t("i18n.cancel")}
</button>
<button
type="button"
onClick={stopEditing}
style={{ height: 26, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 5, background: "transparent", color: "var(--text-muted)", cursor: "pointer", fontSize: 11 }}
>
{t("i18n.discardChanges")}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+10
View File
@@ -304,6 +304,16 @@ export const enLocale: LocalePlugin = {
"i18n.mentionSelectedLines": "Mention selected lines", "i18n.mentionSelectedLines": "Mention selected lines",
"i18n.disableWrap": "Disable word wrap", "i18n.disableWrap": "Disable word wrap",
"i18n.enableWrap": "Enable word wrap", "i18n.enableWrap": "Enable word wrap",
"i18n.editFile": "Edit file",
"i18n.stopEditing": "Stop editing",
"i18n.saveFile": "Save file",
"i18n.saveShortcut": "Save (Ctrl/Cmd+S)",
"i18n.discardChanges": "Discard unsaved changes",
"i18n.saveError": "Could not save file: {error}",
"i18n.closeWithoutSaving": "You have unsaved changes. Discard them and exit editing?",
"i18n.editing": "Editing",
"i18n.unsaved": "unsaved changes",
"i18n.readOnly": "Read-only",
"i18n.htmlPreview": "HTML preview", "i18n.htmlPreview": "HTML preview",
"i18n.previewFile": "Preview {file}", "i18n.previewFile": "Preview {file}",
"i18n.invalidMermaid": "Invalid Mermaid diagram", "i18n.invalidMermaid": "Invalid Mermaid diagram",
+10
View File
@@ -304,6 +304,16 @@ export const zhCNLocale: LocalePlugin = {
"i18n.mentionSelectedLines": "提及所选行", "i18n.mentionSelectedLines": "提及所选行",
"i18n.disableWrap": "禁用自动换行", "i18n.disableWrap": "禁用自动换行",
"i18n.enableWrap": "启用自动换行", "i18n.enableWrap": "启用自动换行",
"i18n.editFile": "编辑文件",
"i18n.stopEditing": "停止编辑",
"i18n.saveFile": "保存文件",
"i18n.saveShortcut": "保存(Ctrl/Cmd+S)",
"i18n.discardChanges": "放弃未保存的更改",
"i18n.saveError": "无法保存文件:{error}",
"i18n.closeWithoutSaving": "您有未保存的更改。放弃并退出编辑?",
"i18n.editing": "编辑中",
"i18n.unsaved": "未保存的更改",
"i18n.readOnly": "只读",
"i18n.htmlPreview": "HTML 预览", "i18n.htmlPreview": "HTML 预览",
"i18n.previewFile": "预览 {file}", "i18n.previewFile": "预览 {file}",
"i18n.invalidMermaid": "Mermaid 图表无效", "i18n.invalidMermaid": "Mermaid 图表无效",
+11 -2
View File
@@ -2,7 +2,7 @@ import { execFile } from "child_process";
import { existsSync, mkdirSync, realpathSync } from "fs"; import { existsSync, mkdirSync, realpathSync } from "fs";
import { basename, dirname, join, resolve } from "path"; import { basename, dirname, join, resolve } from "path";
import { promisify } from "util"; import { promisify } from "util";
import { allowFileRoot } from "./allowed-roots"; import { allowFileRoot, normalizeSlashes } from "./allowed-roots";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -48,6 +48,15 @@ export function invalidateProjectCache(): void {
globalThis.__piProjectCache?.clear(); globalThis.__piProjectCache?.clear();
} }
/** Path equality that tolerates Windows quirks: git prints forward slashes
* while realpathSync uses backslashes, and drive letters may differ in case. */
function samePath(a: string, b: string): boolean {
if (process.platform === "win32") {
return normalizeSlashes(a).toLowerCase() === normalizeSlashes(b).toLowerCase();
}
return a === b;
}
async function git(cwd: string, args: string[]): Promise<string> { async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
timeout: 10_000, timeout: 10_000,
@@ -99,7 +108,7 @@ export async function resolveProject(cwd: string): Promise<ProjectInfo> {
// cwd is a subdirectory of a repo keeps its own project identity — // cwd is a subdirectory of a repo keeps its own project identity —
// grouping subdirs under the repo root would change where new sessions // grouping subdirs under the repo root would change where new sessions
// are created for existing users. // are created for existing users.
const isTopLevel = toplevel === realCwd; const isTopLevel = samePath(toplevel, realCwd);
const isWorktreeTopLevel = gitDir !== commonDir && isTopLevel; const isWorktreeTopLevel = gitDir !== commonDir && isTopLevel;
info = { info = {
projectRoot: isWorktreeTopLevel ? dirname(commonDir) : cwd, projectRoot: isWorktreeTopLevel ? dirname(commonDir) : cwd,
+6 -1
View File
@@ -9,6 +9,11 @@ try {
piVersion = (JSON.parse(readFileSync(piPkgPath, "utf8")) as { version: string }).version; piVersion = (JSON.parse(readFileSync(piPkgPath, "utf8")) as { version: string }).version;
} catch { /* package not found, use default */ } } catch { /* package not found, use default */ }
const allowedDevOrigins = [
"192.168.*.*",
...(process.env.PI_WEB_ALLOWED_HOSTS?.split(",") ?? []),
].map((origin) => origin.trim()).filter(Boolean);
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
devIndicators: false, devIndicators: false,
serverExternalPackages: [ serverExternalPackages: [
@@ -18,7 +23,7 @@ const nextConfig: NextConfig = {
"@earendil-works/pi-ai", "@earendil-works/pi-ai",
"@earendil-works/pi-tui", "@earendil-works/pi-tui",
], ],
allowedDevOrigins: ['192.168.*.*'], allowedDevOrigins,
async headers() { async headers() {
return [ return [
{ {
+7 -2
View File
@@ -91,7 +91,8 @@ function formatResults(data: SearxngResponse, maxResults: number): string {
const formatted = results.map((result, index) => { const formatted = results.map((result, index) => {
const lines = [`${index + 1}. ${compactText(result.title ?? "(no title)", 500)}`]; const lines = [`${index + 1}. ${compactText(result.title ?? "(no title)", 500)}`];
if (result.url) lines.push(` URL: ${result.url}`); if (result.url) lines.push(` URL: ${result.url}`);
const engines = result.engines?.length ? result.engines : result.engine ? [result.engine] : []; let engines = result.engines && result.engines.length > 0 ? result.engines : [];
if (engines.length === 0 && result.engine) engines = [result.engine];
if (engines.length > 0) lines.push(` Engines: ${engines.join(", ")}`); if (engines.length > 0) lines.push(` Engines: ${engines.join(", ")}`);
if (result.publishedDate) lines.push(` Published: ${result.publishedDate}`); if (result.publishedDate) lines.push(` Published: ${result.publishedDate}`);
if (result.content) lines.push(` ${compactText(result.content, MAX_SNIPPET_LENGTH)}`); if (result.content) lines.push(` ${compactText(result.content, MAX_SNIPPET_LENGTH)}`);
@@ -125,10 +126,14 @@ export default function searxngSearchExtension(pi: ExtensionAPI) {
name: "web_search", name: "web_search",
label: "Web Search", label: "Web Search",
description: description:
"Search the current web through the application's configured SearXNG service. Returns result titles, URLs, engines, dates, and snippets. Use it for recent or time-sensitive information, current software documentation and versions, unfamiliar errors, explicit lookup requests, and verifiable facts you are uncertain about.", "Search the current web through the application's configured SearXNG service. Returns result titles, URLs, engines, dates, and snippets. Use it for recent or time-sensitive information, current software documentation and versions, unfamiliar errors, explicit lookup requests, and verifiable facts you are uncertain about. By default verify anything that may have changed within the last year.",
promptSnippet: "Search the current web through the configured SearXNG service", promptSnippet: "Search the current web through the configured SearXNG service",
promptGuidelines: [ promptGuidelines: [
"Use web_search when the user asks to search or verify online, or when information may have changed recently.", "Use web_search when the user asks to search or verify online, or when information may have changed recently.",
"Search by default for ANY question that involves software/library versions, APIs, error messages, tutorials, news, events, prices, or facts that may have changed within the last ~year — verify these with a live search rather than answering from training-data memory.",
"Search when the user cites a source, reference, claim, or asks you to check something online or if something is true/current.",
"When genuinely uncertain whether information is current or correct, search instead of guessing.",
"You MAY skip searching only when fully confident the answer cannot have changed (pure math, static language syntax, facts local to this codebase) or the user clearly wants a purely local/codebase answer.",
"Prefer focused queries. If results are weak, refine the query or select suitable engines and try again.", "Prefer focused queries. If results are weak, refine the query or select suitable engines and try again.",
"Do not search for information already available in the opened codebase, conversation, or provided files.", "Do not search for information already available in the opened codebase, conversation, or provided files.",
"When presenting search findings, retain the result URLs so the user can inspect the sources.", "When presenting search findings, retain the result URLs so the user can inspect the sources.",