From 3b0439a7c3f5c764ae7c6f34e275c189e0687b29 Mon Sep 17 00:00:00 2001 From: syy <815728149@qq.com> Date: Wed, 29 Jul 2026 10:13:04 +0800 Subject: [PATCH 1/4] feat: add password auth and improve skill UX --- README.md | 4 +- README.zh-CN.md | 4 +- app/api/auth/session/route.ts | 85 +++++++++++++++++ app/login/LoginForm.tsx | 87 ++++++++++++++++++ app/login/login.module.css | 167 ++++++++++++++++++++++++++++++++++ app/login/page.tsx | 13 +++ components/ChatInput.tsx | 15 +-- components/ChatWindow.tsx | 19 ++-- components/MessageView.tsx | 17 +++- hooks/useAgentSession.ts | 8 +- lib/auth.test.mjs | 36 ++++++++ lib/auth.ts | 105 +++++++++++++++++++++ lib/skill-block.test.mjs | 22 +++++ lib/skill-block.ts | 18 ++++ lib/slash-command.test.mjs | 28 ++++++ lib/slash-command.ts | 24 +++++ proxy.ts | 37 +++++++- 17 files changed, 665 insertions(+), 24 deletions(-) create mode 100644 app/api/auth/session/route.ts create mode 100644 app/login/LoginForm.tsx create mode 100644 app/login/login.module.css create mode 100644 app/login/page.tsx create mode 100644 lib/auth.test.mjs create mode 100644 lib/auth.ts create mode 100644 lib/skill-block.test.mjs create mode 100644 lib/skill-block.ts create mode 100644 lib/slash-command.test.mjs create mode 100644 lib/slash-command.ts diff --git a/README.md b/README.md index f9175d776..89b85149f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ PI_WEB_ALLOWED_HOSTS=pi-web.internal pi-web # allow an exact proxy/custom hostn PI_WEB_NO_OPEN=1 pi-web # useful when running as a background service ``` -Pi Web has no application-level authentication and can invoke a high-privilege agent. Do not expose it to the internet; only use non-loopback bindings on a trusted network. +On first access, Pi Web asks you to create an access password. The password hash and session secret are stored in `~/.pi/agent/pi-web-auth.json` with private file permissions. Pi Web can invoke a high-privilege agent, so do not expose it directly to the internet; only use non-loopback bindings on a trusted network or behind a properly secured reverse proxy. API requests accept loopback names, IP literals, the selected bind hostname, and exact comma-separated names in `PI_WEB_ALLOWED_HOSTS`. Configure that variable when a trusted reverse proxy uses a different external hostname. ## HTTP Proxy @@ -110,7 +110,7 @@ Avoid running `next build` / `npm run build` during local development. It writes app/ api/ agent/ # creates/drives AgentSession and exposes SSE events - auth/ # OAuth and API key management + auth/ # password sessions, OAuth, and API key management cwd/browse/ # browsable server directory listing cwd/validate/ # custom working directory validation default-cwd/ # pi default working directory lookup diff --git a/README.zh-CN.md b/README.zh-CN.md index 3243b5643..94f734737 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -39,7 +39,7 @@ PI_WEB_ALLOWED_HOSTS=pi-web.internal pi-web # 允许指定的代理或自定义 PI_WEB_NO_OPEN=1 pi-web # 适用于后台服务或开机自启 ``` -Pi Web 没有应用层身份验证,并且可以调用高权限智能体。请勿将其暴露到互联网;仅在可信网络中使用非 loopback 监听地址。 +首次访问 Pi Web 时需要设置访问密码。密码哈希和会话密钥保存在 `~/.pi/agent/pi-web-auth.json`,文件权限仅限当前用户。Pi Web 可以调用高权限智能体,请勿直接暴露到互联网;仅在可信网络中使用非 loopback 监听地址,或放在安全配置的反向代理之后。 API 请求仅接受 loopback 名称、IP 字面量、当前监听主机名,以及 `PI_WEB_ALLOWED_HOSTS` 中以逗号分隔的精确主机名。可信反向代理使用不同的外部主机名时,请配置该变量。 ## HTTP 代理 @@ -106,7 +106,7 @@ npm run lint app/ api/ agent/ # 创建/驱动 AgentSession,提供 SSE 事件流 - auth/ # OAuth 和 API key 管理 + auth/ # 密码会话、OAuth 和 API key 管理 cwd/browse/ # 服务端目录浏览 cwd/validate/ # 自定义工作目录校验 default-cwd/ # 获取 pi 默认工作目录 diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 000000000..6b7e8770d --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server"; +import { + AUTH_COOKIE_NAME, + authIsConfigured, + authSessionMaxAge, + createSessionToken, + initializeAuth, + verifyPassword, +} from "@/lib/auth"; + +function validPassword(value: unknown): value is string { + return typeof value === "string" && value.length >= 8 && value.length <= 256; +} + +function safeNextPath(value: unknown): string { + if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return "/"; + return value; +} + +function formRedirect(path: string, error?: string): NextResponse { + const url = new URL(path, "http://localhost"); + if (error) url.searchParams.set("error", error); + return new NextResponse(null, { + status: 303, + headers: { Location: `${url.pathname}${url.search}` }, + }); +} + +export async function GET() { + return NextResponse.json({ configured: authIsConfigured() }); +} + +export async function POST(request: Request) { + try { + const isForm = request.headers.get("content-type")?.includes("application/x-www-form-urlencoded") + || request.headers.get("content-type")?.includes("multipart/form-data"); + const body = isForm + ? Object.fromEntries(await request.formData()) as { password?: unknown; initialize?: unknown; next?: unknown } + : await request.json().catch(() => null) as { password?: unknown; initialize?: unknown; next?: unknown } | null; + const initialize = body?.initialize === true || body?.initialize === "true"; + + if (!validPassword(body?.password)) { + if (isForm) return formRedirect("/login", "密码长度必须为 8 到 256 个字符"); + return NextResponse.json({ error: "密码长度必须为 8 到 256 个字符" }, { status: 400 }); + } + + if (initialize) { + if (initializeAuth(body.password) === "exists") { + if (isForm) return formRedirect("/login", "密码已经设置,请直接登录"); + return NextResponse.json({ error: "密码已经设置,请直接登录" }, { status: 409 }); + } + } else if (!verifyPassword(body.password)) { + if (isForm) return formRedirect("/login", "密码错误"); + return NextResponse.json({ error: "密码错误" }, { status: 401 }); + } + + const response = isForm + ? formRedirect(safeNextPath(body?.next)) + : NextResponse.json({ success: true }); + response.cookies.set(AUTH_COOKIE_NAME, createSessionToken(), { + httpOnly: true, + // 独立 PWA 从系统桌面启动时属于顶层外部导航,Lax 可携带登录 Cookie。 + sameSite: "lax", + secure: request.headers.get("x-forwarded-proto") === "https" || new URL(request.url).protocol === "https:", + path: "/", + maxAge: authSessionMaxAge, + }); + return response; + } catch (error) { + console.error("密码登录失败", error); + return NextResponse.json({ error: "认证服务异常" }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + const response = NextResponse.json({ success: true }); + response.cookies.set(AUTH_COOKIE_NAME, "", { + httpOnly: true, + sameSite: "lax", + secure: request.headers.get("x-forwarded-proto") === "https" || new URL(request.url).protocol === "https:", + path: "/", + maxAge: 0, + }); + return response; +} diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx new file mode 100644 index 000000000..91898d0e5 --- /dev/null +++ b/app/login/LoginForm.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import styles from "./login.module.css"; + +function safeNextPath(value: string | null): string { + if (!value || !value.startsWith("/") || value.startsWith("//")) return "/"; + return value; +} + +export function LoginForm({ configured }: { configured: boolean }) { + const searchParams = useSearchParams(); + const [password, setPassword] = useState(""); + const [error, setError] = useState(() => searchParams.get("error") ?? ""); + const [submitting, setSubmitting] = useState(false); + const initializing = !configured; + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (submitting) return; + + setError(""); + setSubmitting(true); + try { + const response = await fetch("/api/auth/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password, initialize: initializing }), + }); + const data = await response.json().catch(() => null) as { error?: string } | null; + if (!response.ok) { + setError(data?.error ?? "登录失败,请重试"); + return; + } + + window.location.replace(safeNextPath(searchParams.get("next"))); + } catch { + setError("无法连接认证服务,请检查网络后重试"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ +
+

Pi Web

+

{initializing ? "设置访问密码以保护此工作区" : "输入密码以继续访问工作区"}

+
+ +
+ + + +
+ + setPassword(event.target.value)} + aria-invalid={Boolean(error)} + aria-describedby={error ? "login-error" : initializing ? "password-hint" : undefined} + /> + {initializing && !error && ( + 至少 8 个字符 + )} + {error && {error}} +
+ + +
+
+
+ ); +} diff --git a/app/login/login.module.css b/app/login/login.module.css new file mode 100644 index 000000000..59d309f88 --- /dev/null +++ b/app/login/login.module.css @@ -0,0 +1,167 @@ +.page { + min-height: 100dvh; + width: 100%; + display: grid; + place-items: center; + padding: max(24px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); + background: + linear-gradient(var(--border), var(--border)) center / 1px 100% no-repeat, + var(--bg); +} + +.panel { + width: min(100%, 360px); + padding: 36px 32px 32px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + box-shadow: 0 16px 48px color-mix(in srgb, var(--text) 8%, transparent); +} + +.brandMark { + width: 44px; + height: 44px; + display: grid; + place-items: center; + margin-bottom: 24px; + border: 1px solid var(--text); + border-radius: 50%; + color: var(--text); + font-family: Georgia, "Times New Roman", serif; + font-size: 26px; + line-height: 1; +} + +.header h1 { + margin: 0; + color: var(--text); + font-family: var(--font-mono); + font-size: 24px; + font-weight: 650; + letter-spacing: 0; + line-height: 1.25; +} + +.header p { + min-height: 42px; + margin: 8px 0 24px; + color: var(--text-muted); + font-size: 14px; + line-height: 1.5; +} + +.form, +.field { + display: flex; + flex-direction: column; +} + +.form { + gap: 20px; +} + +.field { + gap: 8px; +} + +.field label { + color: var(--text); + font-size: 13px; + font-weight: 600; +} + +.field input { + width: 100%; + height: 44px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 6px; + outline: none; + background: var(--bg-panel); + color: var(--text); + font: inherit; + transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; +} + +.field input:hover:not(:disabled) { + border-color: var(--text-dim); +} + +.field input:focus-visible { + border-color: var(--accent); + background: var(--bg); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} + +.field input[aria-invalid="true"] { + border-color: #dc2626; +} + +.field input:disabled { + cursor: wait; + opacity: 0.65; +} + +.hint, +.error { + min-height: 18px; + font-size: 12px; + line-height: 1.5; +} + +.hint { + color: var(--text-dim); +} + +.error { + color: #dc2626; +} + +.form button { + min-height: 44px; + border: 1px solid var(--accent); + border-radius: 6px; + background: var(--accent); + color: #ffffff; + font: inherit; + font-weight: 650; + cursor: pointer; + transition: background 160ms ease, border-color 160ms ease, opacity 160ms ease; +} + +.form button:hover:not(:disabled) { + border-color: var(--accent-hover); + background: var(--accent-hover); +} + +.form button:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 28%, transparent); + outline-offset: 2px; +} + +.form button:disabled { + cursor: wait; + opacity: 0.65; +} + +@media (max-width: 480px) { + .page { + place-items: stretch; + align-content: center; + background: var(--bg); + } + + .panel { + width: 100%; + padding: 0; + border: 0; + box-shadow: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .field input, + .form button { + transition: none; + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 000000000..84551cc2a --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,13 @@ +import { Suspense } from "react"; +import { authIsConfigured } from "@/lib/auth"; +import { LoginForm } from "./LoginForm"; + +export const dynamic = "force-dynamic"; + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index af1f0e417..7a046f5f1 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -3,6 +3,7 @@ import React, { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef, KeyboardEvent } from "react"; import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession"; import { clearDraft, getDraft, setDraft, type ChatDraftImage } from "@/lib/draft-store"; +import { applySlashSelection, findSlashQuery } from "@/lib/slash-command"; import { MAX_ATTACHED_IMAGE_BYTES, MAX_ATTACHED_IMAGES, @@ -502,13 +503,13 @@ export const ChatInput = forwardRef(function ChatInput({ clearInput(); }, [value, attachedImages, isStreaming, onBuiltinCommand, onSend, clearInput, onAudioUnlock]); - const slashQuery = value.startsWith("/") && !/\s/.test(value.slice(1)) - ? value.slice(1).toLowerCase() - : null; + const slash = findSlashQuery(value); + const slashQuery = slash?.query ?? null; const filteredSlashCommands = (() => { if (slashQuery === null) return []; - const commands = [...(isStreaming ? [] : BUILTIN_SLASH_COMMANDS), ...(slashCommands ?? [])]; + const commands = [...(isStreaming ? [] : BUILTIN_SLASH_COMMANDS), ...(slashCommands ?? [])] + .filter((command) => !slash?.inline || command.source === "skill"); return [...commands] .filter((command) => { const name = command.name.toLowerCase(); @@ -710,7 +711,9 @@ export const ChatInput = forwardRef(function ChatInput({ }, []); const applySlashCommand = useCallback((command: SlashCommandPaletteItem) => { - const nextValue = `/${command.name} `; + const activeSlash = findSlashQuery(value); + if (!activeSlash) return; + const nextValue = applySlashSelection(value, activeSlash, command.name); setValue(nextValue); setSlashMenuOpen(false); setSlashActiveIndex(0); @@ -722,7 +725,7 @@ export const ChatInput = forwardRef(function ChatInput({ ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }); - }, []); + }, [value]); const sendQueued = useCallback((mode: "steer" | "followup") => { const msg = value.trim(); diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 49ba96cd2..b342afd73 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -201,7 +201,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate retryInfo, contextUsage, forkingEntryId, isCompacting, compactError, compactResult, displayModel: displayModelValue, sessionStats, slashCommands, slashCommandsLoading, queuedMessages, - notices, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput, + notices, dismissNotice, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput, isAutoModelSelection, agentPhase, isNew, @@ -473,7 +473,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate - + {chatInputElement} @@ -492,7 +492,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate }} >
- +
@@ -771,7 +771,8 @@ function ExtensionWidgets({ widgets }: { widgets: Array<{ key: string; lines: st ); } -function NoticeShelf({ notices, floating = false, align = "left" }: { notices: NoticeItem[]; floating?: boolean; align?: "left" | "right" }) { +function NoticeShelf({ notices, onDismiss, floating = false, align = "left" }: { notices: NoticeItem[]; onDismiss: (id: string) => void; floating?: boolean; align?: "left" | "right" }) { + const { t } = useI18n(); if (notices.length === 0) return null; return (
{notices.map((notice, index) => { @@ -791,8 +793,11 @@ function NoticeShelf({ notices, floating = false, align = "left" }: { notices: N ? "#10b981" : "var(--accent)"; return ( -
onDismiss(notice.id)} + title={t("i18n.close")} className="notice-shelf-item" style={{ display: "flex", @@ -819,6 +824,8 @@ function NoticeShelf({ notices, floating = false, align = "left" }: { notices: N ? "notice-shelf-out 0.18s ease-in forwards" : "notice-shelf-in 0.18s ease-out both", padding: "0 12px", + cursor: "pointer", + textAlign: "left", }} > {notice.message} -
+ ); })}
diff --git a/components/MessageView.tsx b/components/MessageView.tsx index d894a5941..5e4c0bf0d 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -7,6 +7,7 @@ import { useI18n } from "@/hooks/useI18n"; import { parseCompactionSummary } from "@/lib/compaction-summary"; import { isEmptyThinkingBlock } from "@/lib/message-display"; import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch"; +import { parseSkillBlock } from "@/lib/skill-block"; import type { AgentMessage, UserMessage, @@ -159,6 +160,11 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o .filter((b): b is TextContent => b.type === "text") .map((b) => b.text) .join("\n"); + const skillBlock = parseSkillBlock(content); + const displayContent = skillBlock?.userMessage ?? (skillBlock ? "" : content); + const skillCommand = skillBlock + ? `/skill:${skillBlock.name}${skillBlock.userMessage ? ` ${skillBlock.userMessage}` : ""}` + : null; const imageBlocks: ImageContent[] = typeof message.content === "string" @@ -170,7 +176,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o const canNavigate = !!prevAssistantEntryId && !!onNavigate; const copyContent = () => { - copyText(content).then(() => { + copyText(skillCommand ?? content).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); @@ -222,7 +228,12 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o })}
)} - {content && {content}} + {skillBlock && ( +
+ [skill] {skillBlock.name} +
+ )} + {displayContent && {displayContent}} @@ -278,7 +289,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o }}> {canNavigate && (