diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index 4cc3965dd..b422faeeb 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; +import { spawn } from "child_process"; import { getAllowedFileRoots, isExistingFilePathAllowed, @@ -123,6 +124,68 @@ function parseUploadFileNames(value: unknown): string[] | null { return value; } +type WritableTarget = { ok: string; size: number }; + +/** + * Authorize writing to an existing file: the lexical path must sit inside the + * allowed roots and, after resolving symlinks, still resolve inside them so a + * link inside an allowed root cannot redirect the write outside it. + */ +async function getWritableFile(segments: string[]): Promise { + const filePath = filePathFromSegments(segments); + const allowedRoots = await getAllowedFileRoots(); + if (!isFilePathAllowed(filePath, allowedRoots)) { + return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; + } + + let stat: fs.Stats; + try { + stat = fs.statSync(filePath); + } catch { + return { response: NextResponse.json({ error: "File not found" }, { status: 404 }) }; + } + if (!stat.isFile()) { + return { response: NextResponse.json({ error: "Not a file" }, { status: 400 }) }; + } + + const realPath = fs.realpathSync(filePath); + const realRoots = new Set(); + for (const root of allowedRoots) { + try { + realRoots.add(fs.realpathSync(root)); + } catch { + // Ignore stale session roots that no longer exist. + } + } + if (!isFilePathAllowed(realPath, realRoots)) { + return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) }; + } + + return { ok: realPath, size: stat.size }; +} + +/** Launch the platform's file manager on a directory, resolving once launched. */ +function openInSystemFileManager(directory: string): Promise { + return new Promise((resolve) => { + let command: string; + let args: string[]; + if (process.platform === "win32") { + command = "explorer"; + args = [directory]; + } else if (process.platform === "darwin") { + command = "open"; + args = [directory]; + } else { + command = "xdg-open"; + args = [directory]; + } + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.on("error", () => resolve(`Failed to launch file manager (${command})`)); + child.unref(); + resolve(null); + }); +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } @@ -133,10 +196,42 @@ export async function POST( try { const { path: segments } = await params; + const type = request.nextUrl.searchParams.get("type") ?? "upload"; + + if (type === "save") { + const writable = await getWritableFile(segments); + if ("response" in writable) return writable.response; + if (writable.size > TEXT_PREVIEW_MAX_BYTES) { + return NextResponse.json({ error: "File too large to edit (>256KB)" }, { status: 413 }); + } + const body = await request.json().catch(() => null) as { content?: unknown } | null; + if (typeof body?.content !== "string") { + return NextResponse.json({ error: "content must be a string" }, { status: 400 }); + } + try { + fs.writeFileSync(writable.ok, body.content, "utf-8"); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } + return NextResponse.json({ ok: true, size: Buffer.byteLength(body.content, "utf-8") }); + } + + if (type === "open-directory") { + const uploadDirectory = await getUploadDirectory(segments); + if ("response" in uploadDirectory) return uploadDirectory.response; + const launchError = await openInSystemFileManager(uploadDirectory.directory); + if (launchError) { + return NextResponse.json({ error: launchError }, { status: 500 }); + } + return NextResponse.json({ ok: true }); + } + const uploadDirectory = await getUploadDirectory(segments); if ("response" in uploadDirectory) return uploadDirectory.response; const { directory } = uploadDirectory; - const type = request.nextUrl.searchParams.get("type") ?? "upload"; if (type === "upload-check") { const body = await request.json().catch(() => null) as { fileNames?: unknown } | null; diff --git a/app/api/models/route.ts b/app/api/models/route.ts index 49066504a..35a15344a 100644 --- a/app/api/models/route.ts +++ b/app/api/models/route.ts @@ -27,7 +27,7 @@ function compareModelEntries( async function loadModels(cwd: string): Promise { const nameMap = new Map(); - let modelList: { id: string; name: string; provider: string }[] = []; + let modelList: { id: string; name: string; provider: string; acceptsImages: boolean }[] = []; let defaultModel: { provider: string; modelId: string } | null = null; const thinkingLevels: Record = {}; const thinkingLevelMaps: Record> = {}; @@ -55,6 +55,7 @@ async function loadModels(cwd: string): Promise { id: m.id, name: m.name, provider: m.provider, + acceptsImages: m.input?.includes("image") ?? false, })).sort(compareModelEntries); for (const m of visible) { const key = `${m.provider}:${m.id}`; diff --git a/app/api/terminal/[id]/events/route.ts b/app/api/terminal/[id]/events/route.ts new file mode 100644 index 000000000..7603e8f46 --- /dev/null +++ b/app/api/terminal/[id]/events/route.ts @@ -0,0 +1,97 @@ +import { isApiRequestAllowed } from "@/lib/request-security"; +import { getTerminalSession } from "@/lib/terminal-manager"; + +export const dynamic = "force-dynamic"; + +const HEARTBEAT_INTERVAL_MS = 30_000; + +// GET /api/terminal/[id]/events - SSE stream of terminal output +// Frames: { type: "connected", session } → { type: "data", data }* → { type: "exit", exitCode } +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + if (!isApiRequestAllowed(req)) { + return new Response("Untrusted API request", { status: 403 }); + } + if (req.signal.aborted) return new Response(null, { status: 204 }); + + const { id } = await params; + const session = getTerminalSession(id); + if (!session || !session.isRunning) { + return new Response("Terminal not found", { status: 404 }); + } + + // Captured by `start` so the stream's `cancel()` can tear down subscriptions. + let cancelStream: (closeController: boolean) => void = () => {}; + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + let closed = false; + let heartbeat: ReturnType | null = null; + let unsubscribeData: (() => void) | null = null; + let unsubscribeExit: (() => void) | null = null; + let abortHandler: (() => void) | null = null; + + const cleanup = (closeController: boolean) => { + if (closed) return; + closed = true; + if (heartbeat !== null) clearInterval(heartbeat); + unsubscribeData?.(); + unsubscribeData = null; + unsubscribeExit?.(); + unsubscribeExit = null; + if (abortHandler) req.signal.removeEventListener("abort", abortHandler); + if (closeController) { + try { controller.close(); } catch { /* stream already closed */ } + } + }; + cancelStream = cleanup; + + const enqueue = (data: unknown) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + } catch { + cleanup(false); + } + }; + + abortHandler = () => cleanup(true); + if (req.signal.aborted) { + cleanup(true); + return; + } + req.signal.addEventListener("abort", abortHandler, { once: true }); + + unsubscribeData = session.subscribeData((data) => enqueue({ type: "data", data })); + unsubscribeExit = session.subscribeExit((exitCode) => { + enqueue({ type: "exit", exitCode }); + // Give the browser a chance to read the exit frame before closing. + setTimeout(() => cleanup(true), 50); + }); + + enqueue({ type: "connected", session: session.toPublicInfo() }); + heartbeat = setInterval(() => { + if (!closed) { + try { controller.enqueue(encoder.encode(":\n\n")); } catch { cleanup(false); } + } + }, HEARTBEAT_INTERVAL_MS); + }, + cancel() { + // The request was closed by the client; only stop forwarding events. + // The shell itself keeps running so the terminal can reconnect later. + cancelStream(false); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} \ No newline at end of file diff --git a/app/api/terminal/[id]/route.ts b/app/api/terminal/[id]/route.ts new file mode 100644 index 000000000..42e70f6ce --- /dev/null +++ b/app/api/terminal/[id]/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { isApiRequestAllowed } from "@/lib/request-security"; +import { closeTerminalSession, getTerminalSession } from "@/lib/terminal-manager"; + +export const dynamic = "force-dynamic"; + +const MAX_INPUT_BYTES = 256 * 1024; + +// POST /api/terminal/[id] - write input or resize a running terminal +// body: { type: "input", data: string } | { type: "resize", cols, rows } +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + if (!isApiRequestAllowed(req)) { + return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); + } + + const { id } = await params; + const session = getTerminalSession(id); + if (!session || !session.isRunning) { + return NextResponse.json({ error: "Terminal not found" }, { status: 404 }); + } + + try { + const body = (await req.json().catch(() => null)) as + | { type?: unknown; data?: unknown; cols?: unknown; rows?: unknown } + | null; + if (!body) { + return NextResponse.json({ error: "Missing request body" }, { status: 400 }); + } + const type = body.type; + + if (type === "input") { + if (typeof body.data !== "string") { + return NextResponse.json({ error: "data must be a string" }, { status: 400 }); + } + if (body.data.length > MAX_INPUT_BYTES) { + return NextResponse.json({ error: "Input too large" }, { status: 413 }); + } + session.write(body.data); + return NextResponse.json({ success: true }); + } + + if (type === "resize") { + const clamp = (value: unknown, fallback: number, min: number, max: number) => { + const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.floor(n))); + }; + const cols = clamp(body.cols, 1, 1, 500); + const rows = clamp(body.rows, 1, 1, 200); + session.resize(cols, rows); + return NextResponse.json({ success: true }); + } + + return NextResponse.json({ error: "Unsupported action" }, { status: 400 }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +// DELETE /api/terminal/[id] - kill the shell process and discard the session +export async function DELETE( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + if (!isApiRequestAllowed(req)) { + return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); + } + + const { id } = await params; + if (!closeTerminalSession(id)) { + return NextResponse.json({ error: "Terminal not found" }, { status: 404 }); + } + return NextResponse.json({ success: true }); +} \ No newline at end of file diff --git a/app/api/terminal/route.ts b/app/api/terminal/route.ts new file mode 100644 index 000000000..453a24d82 --- /dev/null +++ b/app/api/terminal/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { resolve } from "path"; +import { statSync } from "fs"; +import { isApiRequestAllowed } from "@/lib/request-security"; +import { getAllowedFileRoots, isFilePathAllowed } from "@/lib/file-access"; +import { + createTerminalSession, + listTerminalSessions, + DEFAULT_TERMINAL_COLUMNS, + DEFAULT_TERMINAL_ROWS, +} from "@/lib/terminal-manager"; + +export const dynamic = "force-dynamic"; + +function clampDimension(value: unknown, fallback: number, min: number, max: number): number { + const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.floor(n))); +} + +// GET /api/terminal - list running terminal sessions +export async function GET(req: Request) { + if (!isApiRequestAllowed(req)) { + return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); + } + return NextResponse.json({ + sessions: listTerminalSessions().map((session) => session.toPublicInfo()), + }); +} + +// POST /api/terminal - create a shell process in an allowed workspace +// body: { cwd: string, cols?: number, rows?: number } +export async function POST(req: Request) { + if (!isApiRequestAllowed(req)) { + return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); + } + + try { + const body = (await req.json().catch(() => null)) as + | { cwd?: unknown; cols?: unknown; rows?: unknown } + | null; + const cwd = typeof body?.cwd === "string" ? body.cwd.trim() : ""; + if (!cwd) { + return NextResponse.json({ error: "cwd is required" }, { status: 400 }); + } + + const normalizedCwd = resolve(cwd); + const roots = await getAllowedFileRoots(); + if (!isFilePathAllowed(normalizedCwd, roots)) { + return NextResponse.json( + { error: "cwd is not an allowed workspace" }, + { status: 403 }, + ); + } + try { + if (!statSync(normalizedCwd).isDirectory()) { + return NextResponse.json({ error: "cwd is not a directory" }, { status: 400 }); + } + } catch { + return NextResponse.json( + { error: `Directory does not exist: ${normalizedCwd}` }, + { status: 400 }, + ); + } + + const cols = clampDimension(body?.cols, DEFAULT_TERMINAL_COLUMNS, 1, 500); + const rows = clampDimension(body?.rows, DEFAULT_TERMINAL_ROWS, 1, 200); + const session = createTerminalSession(normalizedCwd, cols, rows); + + return NextResponse.json( + { success: true, session: session.toPublicInfo() }, + { status: 201 }, + ); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} \ No newline at end of file diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 4f1822a70..0aaee6fd4 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -13,6 +13,7 @@ import { SkillsConfig } from "./SkillsConfig"; import { PluginsConfig } from "./PluginsConfig"; import { ProjectTrustDialog } from "./ProjectTrustDialog"; import { BranchNavigator } from "./BranchNavigator"; +import { TerminalTabs } from "./TerminalTabs"; import { useTheme } from "@/hooks/useTheme"; import { useI18n } from "@/hooks/useI18n"; import { useIsMobile } from "@/hooks/useIsMobile"; @@ -387,6 +388,10 @@ export function AppShell() { const initialSessionId = initialNavigation.sessionId; const [activeCwd, setActiveCwd] = useState(null); + const [terminalTabs, setTerminalTabs] = useState<{ key: string; cwd: string }[]>([]); + const [activeTerminalKey, setActiveTerminalKey] = useState(null); + const terminalSeqRef = useRef(1); + const terminalOpen = terminalTabs.length > 0; const activeProjectKeyRef = useRef(null); // True once the initial ?session= URL param has been resolved (or confirmed absent) const [initialSessionRestored, setInitialSessionRestored] = useState(() => !initialSessionId); @@ -842,6 +847,32 @@ export function AppShell() { }, [newSessionDraftKey]); const showChat = selectedSession !== null || effectiveNewSessionCwd !== null; const projectTrustCwd = selectedSession?.cwd ?? effectiveNewSessionCwd; + const terminalCwd = selectedSession?.cwd ?? effectiveNewSessionCwd ?? activeCwd; + + const openTerminalTab = useCallback(() => { + if (!terminalCwd) return; + const key = `term-${terminalSeqRef.current++}`; + setTerminalTabs((prev) => [...prev, { key, cwd: terminalCwd }]); + setActiveTerminalKey(key); + }, [terminalCwd]); + + const closeTerminalTab = useCallback((key: string) => { + const next = terminalTabs.filter((tab) => tab.key !== key); + setTerminalTabs(next); + if (activeTerminalKey === key) { + setActiveTerminalKey(next.length > 0 ? next[next.length - 1].key : null); + } + }, [activeTerminalKey, terminalTabs]); + + const toggleTerminal = useCallback(() => { + if (terminalOpen) { + // Closing the dock kills all terminal processes (panels unmount). + setTerminalTabs([]); + setActiveTerminalKey(null); + } else { + openTerminalTab(); + } + }, [openTerminalTab, terminalOpen]); // While restoring initial session from URL, don't show the placeholder const showPlaceholder = initialSessionRestored && !showChat; @@ -1078,6 +1109,39 @@ export function AppShell() { ); + const renderTerminalToggle = () => { + const enabled = Boolean(terminalCwd); + return ( + + ); + }; + const renderProjectTrustWarning = (mobileBanner: boolean) => { if (!showChat || !projectTrust?.requiresTrust || projectTrust.trusted) return null; return ( @@ -1798,6 +1862,7 @@ export function AppShell() { {renderProjectTrustWarning(false)} {renderChatToolbarActions(false)} {renderSessionStatsButton(false)} + {renderTerminalToggle()} )} {!isMobile && renderMainFileToggle(false)} @@ -2144,6 +2209,15 @@ export function AppShell() { ) ) : null} + {terminalTabs.length > 0 && ( + + )}
; - modelList?: { id: string; name: string; provider: string }[]; + modelList?: { id: string; name: string; provider: string; acceptsImages?: boolean }[]; modelError?: string | null; /** Diagnostics from resolving `enabledModels`, e.g. a pattern that matched nothing. */ modelScopeWarnings?: string[]; @@ -1172,14 +1172,36 @@ export const ChatInput = forwardRef(function ChatInput({ ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }, []); + // Whether the selected model can accept images. Defaults to enabled when the + // model is unknown (e.g. no selection yet) or it does not declare a capability. + const currentModelAcceptsImages = (() => { + if (!model) return true; + if (modelList && modelList.length > 0) { + const entry = modelList.find( + (m) => m.provider === model.provider && m.id === model.modelId, + ); + if (entry && typeof entry.acceptsImages === "boolean") return entry.acceptsImages; + } + return true; + })(); + + // Whether the selected model supports thinking/reasoning. `availableThinkingLevels` + // becomes exactly ["off"] when the model has reasoning disabled in models.json + // (the "推理 / 思考" toggle), mirroring getSupportedThinkingLevels(). + const currentModelSupportsReasoning = (() => { + if (!availableThinkingLevels || availableThinkingLevels.length !== 1) return true; + return availableThinkingLevels[0] !== "off"; + })(); + const handlePaste = useCallback((e: React.ClipboardEvent) => { + if (!currentModelAcceptsImages) return; const items = Array.from(e.clipboardData?.items ?? []); const imageItems = items.filter((item) => item.type.startsWith("image/")); if (!imageItems.length) return; e.preventDefault(); const files = imageItems.map((item) => item.getAsFile()).filter((f): f is File => f !== null); processImageFiles(files); - }, [processImageFiles]); + }, [processImageFiles, currentModelAcceptsImages]); useEffect(() => { if (slashQuery === null) { @@ -1318,6 +1340,8 @@ export const ChatInput = forwardRef(function ChatInput({ ? `${compactResult.reason && compactResult.reason !== "manual" ? `${compactResult.reason[0].toUpperCase()}${compactResult.reason.slice(1)} ` : t("chat.compacted")} ${formatTokenCount(compactResult.tokensBefore)} -> ${formatTokenCount(compactResult.estimatedTokensAfter)} tokens (${t("chat.tokensSaved", { saved: formatTokenCount(compactSavedTokens) })})` : null; const thinkingDisplayLabel = (() => { + // 模型不支持推理(设置里关闭「推理 / 思考」)时实际行为始终为关闭,直接显示 off + if (!currentModelSupportsReasoning) return "off"; const lvl = thinkingLevel ?? "auto"; if (lvl === "auto" || !thinkingLevelMap) return lvl; return thinkingLevelMap[lvl] ?? lvl; @@ -2031,26 +2055,33 @@ export const ChatInput = forwardRef(function ChatInput({ {/* LEFT: attach + model selector (idle) or steer/followup toggle (streaming) */}
)} - {effectiveDisplayMode === "source" && ( + {isEditing ? ( + <> + + + + ) : effectiveDisplayMode === "source" ? ( <> + - )} + ) : null}
{!isDeletedDiff && }
+ {saveError && ( +
+ {saveError} +
+ )} + {/* Content area */}
- {effectiveDisplayMode === "diff" && hasGitDiff ? ( + {isEditing ? ( +