mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
Reference in New Issue
Block a user