feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
function playTone(ctx: AudioContext) {
const now = ctx.currentTime;
const freqs = [523.25, 659.25];
freqs.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = "sine";
osc.frequency.value = freq;
const t = now + i * 0.18;
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(0.18, t + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
osc.start(t);
osc.stop(t + 0.45);
});
}
export function useAudio() {
const [enabled, setEnabled] = useState<boolean>(() => {
if (typeof window === "undefined") return true;
const stored = localStorage.getItem("pi-sound-enabled");
return stored === null ? true : stored === "true";
});
const enabledRef = useRef(enabled);
useEffect(() => { enabledRef.current = enabled; }, [enabled]);
// Reuse a single AudioContext so it can be resumed if the browser
// autoplay policy suspends it (contexts created outside user gestures
// start in "suspended" state and produce no sound).
const ctxRef = useRef<AudioContext | null>(null);
const getCtx = useCallback((): AudioContext | null => {
if (ctxRef.current && ctxRef.current.state !== "closed") return ctxRef.current;
try {
ctxRef.current = new AudioContext();
} catch {
return null;
}
return ctxRef.current;
}, []);
const unlockAudio = useCallback((force = false) => {
if (!force && !enabledRef.current) return;
const ctx = getCtx();
if (!ctx || ctx.state !== "suspended") return;
ctx.resume().catch(() => {});
}, [getCtx]);
const toggle = useCallback(() => {
const next = !enabledRef.current;
if (next) unlockAudio(true);
enabledRef.current = next;
localStorage.setItem("pi-sound-enabled", String(next));
setEnabled(next);
}, [unlockAudio]);
const playDone = useCallback(() => {
if (!enabledRef.current) return;
const ctx = getCtx();
if (!ctx) return;
const play = () => {
try {
playTone(ctx);
} catch {
// AudioContext not available
}
};
if (ctx.state === "suspended") {
ctx.resume().then(play).catch(() => {});
return;
}
play();
}, [getCtx]);
return { soundEnabled: enabled, onSoundToggle: toggle, playDoneSound: playDone, unlockAudio, soundEnabledRef: enabledRef };
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useState, useCallback, useRef } from "react";
export function useDragDrop(onDrop: (files: File[]) => void) {
const [isDragOver, setIsDragOver] = useState(false);
const counterRef = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
const hasImages = Array.from(e.dataTransfer.items).some((item) => item.type.startsWith("image/"));
if (!hasImages) return;
e.preventDefault();
counterRef.current += 1;
setIsDragOver(true);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
const hasImages = Array.from(e.dataTransfer.items).some((item) => item.type.startsWith("image/"));
if (!hasImages) return;
e.preventDefault();
}, []);
const handleDragLeave = useCallback(() => {
counterRef.current -= 1;
if (counterRef.current <= 0) {
counterRef.current = 0;
setIsDragOver(false);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
counterRef.current = 0;
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
onDrop(files);
}, [onDrop]);
return { isDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop };
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { getLocalePlugin, getSupportedLocales, resolveBrowserLocale } from "@/lib/i18n/registry";
import { translateMessage } from "@/lib/i18n/format";
import type { Locale, LocalePlugin, TranslationParams } from "@/lib/i18n/types";
const LOCALE_STORAGE_KEY = "pi-locale";
const defaultLocale: Locale = "en";
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string, params?: TranslationParams) => string;
supportedLocales: LocalePlugin[];
}
const I18nContext = createContext<I18nContextValue | null>(null);
function getMessages(): Record<string, Record<string, string>> {
return Object.fromEntries(getSupportedLocales().flatMap((id) => {
const plugin = getLocalePlugin(id);
return plugin ? [[id, plugin.messages]] : [];
}));
}
function readInitialLocale(): Locale {
try {
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored === "en" || stored === "zh-CN") return stored;
} catch {
// 隐私模式或存储不可用时继续使用浏览器语言。
}
return resolveBrowserLocale(window.navigator.languages.length ? window.navigator.languages : [window.navigator.language]);
}
/**
* 提供 Pi Web 的界面语言状态和翻译能力。
* @param props React 子节点
* @returns 包含语言上下文的 React 节点
*/
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(defaultLocale);
const [hydrated, setHydrated] = useState(false);
const supportedLocales = useMemo(
() => getSupportedLocales().map((id) => getLocalePlugin(id)).filter((plugin): plugin is LocalePlugin => Boolean(plugin)),
[],
);
const messages = useMemo(() => getMessages(), []);
useEffect(() => {
const next = readInitialLocale();
setLocaleState(next);
document.documentElement.lang = next;
setHydrated(true);
}, []);
const setLocale = useCallback((next: Locale) => {
if (!getLocalePlugin(next)) return;
setLocaleState(next);
document.documentElement.lang = next;
try {
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
} catch {
// 存储失败不影响当前页面内的语言切换。
}
}, []);
const t = useCallback((key: string, params?: TranslationParams) => translateMessage(locale, key, messages, params), [locale, messages]);
const value = useMemo(() => ({ locale: hydrated ? locale : defaultLocale, setLocale, t, supportedLocales }), [hydrated, locale, setLocale, t, supportedLocales]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
/**
* 获取当前组件树中的国际化能力。
* @returns 当前 locale、翻译函数、语言切换函数和支持的语言列表
* @throws 当组件不在 I18nProvider 内时抛出异常
*/
export function useI18n(): I18nContextValue {
const context = useContext(I18nContext);
if (!context) throw new Error("useI18n must be used inside I18nProvider");
return context;
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useSyncExternalStore } from "react";
// Mobile breakpoint shared with app/globals.css (max-width: 640px).
const MOBILE_QUERY = "(max-width: 640px)";
function subscribe(cb: () => void): () => void {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mql = window.matchMedia(MOBILE_QUERY);
mql.addEventListener("change", cb);
return () => mql.removeEventListener("change", cb);
}
function getSnapshot(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia(MOBILE_QUERY).matches;
}
function getServerSnapshot(): boolean {
return false;
}
/**
* Returns true when the viewport is at or below the mobile breakpoint.
* SSR-safe: renders as desktop (false) on the server and first client paint,
* then syncs to the real viewport after hydration.
*/
export function useIsMobile(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect } from "react";
// ---------------------------------------------------------------------------
// Module-level registry — ChatWindow registers the abort handler here so that
// the global Esc listener in AppShell can call it without prop-drilling.
// ---------------------------------------------------------------------------
let globalAbortHandler: (() => void) | null = null;
/**
* Register (or clear) the abort handler for the global Esc shortcut.
* Call this from ChatWindow whenever agentRunning or handleAbort changes.
*/
export function registerAbortHandler(handler: (() => void) | null): void {
globalAbortHandler = handler;
}
// ---------------------------------------------------------------------------
// Hook: global keyboard shortcuts
// ---------------------------------------------------------------------------
interface UseGlobalKeyboardShortcutsOptions {
/** Called when Ctrl+Alt+N is pressed. Receives current cwd. */
onNewSession?: (cwd: string) => void;
/** The currently selected project directory (sidebar cwd). */
activeCwd?: string | null;
}
/**
* Register global keyboard shortcuts for the application.
*
* Shortcuts handled here:
* Esc – stop the running agent (via module-level abort handler)
* Ctrl+Alt+N – create a new session in the active project directory
*
* Note: Esc inside <textarea> or <input> is deliberately NOT handled here.
* ChatInput manages its own Esc logic (closing slash / @ file menus, stopping
* the agent when no menu is open) because it needs intimate knowledge of menu
* state that is local to that component.
*/
export function useGlobalKeyboardShortcuts(
options: UseGlobalKeyboardShortcutsOptions,
): void {
const { onNewSession, activeCwd } = options;
useEffect(() => {
const handler = (e: KeyboardEvent): void => {
// ---- Esc: stop agent ----
if (e.key === "Escape") {
if (!globalAbortHandler) return;
const tag = (e.target as HTMLElement)?.tagName;
// Let textarea/input handle Esc internally (ChatInput menus / stop).
if (tag === "TEXTAREA" || tag === "INPUT") return;
e.preventDefault();
globalAbortHandler();
return;
}
// ---- Ctrl+Alt+N: new session ----
if (e.key === "n" && e.ctrlKey && e.altKey) {
if (!activeCwd || !onNewSession) return;
e.preventDefault();
onNewSession(activeCwd);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [activeCwd, onNewSession]);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
type Theme = "light" | "dark";
const listeners = new Set<() => void>();
function subscribe(cb: () => void): () => void {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
function getSnapshot(): Theme {
if (typeof document === "undefined") return "light";
return document.documentElement.classList.contains("dark") ? "dark" : "light";
}
function getServerSnapshot(): Theme {
return "light";
}
type ToggleOrigin = { x: number; y: number };
export function useTheme() {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const toggleTheme = useCallback((origin?: ToggleOrigin) => {
const next: Theme = getSnapshot() === "dark" ? "light" : "dark";
const apply = () => {
if (next === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
try {
localStorage.setItem("pi-theme", next);
} catch {
// ignore storage errors (private mode, quota, etc.)
}
listeners.forEach((cb) => cb());
};
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const supportsVT = typeof document.startViewTransition === "function";
if (!supportsVT || reduceMotion) {
apply();
return;
}
const x = origin?.x ?? window.innerWidth / 2;
const y = origin?.y ?? window.innerHeight / 2;
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
const transition = document.startViewTransition(apply);
transition.ready
.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 450,
easing: "cubic-bezier(0.22, 0.61, 0.36, 1)",
pseudoElement: "::view-transition-new(root)",
},
);
})
.catch(() => {
// transition cancelled — ignore
});
}, []);
return { theme, toggleTheme, isDark: theme === "dark" };
}