应用地址:觅识🌿NPDP
程序主程
程序主程入口(程序),\app\page.tsx
...
export default async function App() {
return (
<>
<Home />
{serverConfig?.isVercel && <Analytics />}
</>
);
}
首页(组件),\app\components\home.tsx
...
function Screen() {
const config = useAppConfig();
const location = useLocation();
const isHome = location.pathname === Path.Home;
const isMobileScreen = useMobileScreen();
return (
<div
className={
styles.container +
` ${
config.tightBorder && !isMobileScreen
? styles["tight-container"]
: styles.container
}`
}
>
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
<div className={styles["window-content"]} id={SlotID.AppBody}>
<Routes>
<Route path={Path.Home} element={<Chat />} />
<Route path={Path.NewChat} element={<NewChat />} />
<Route path={Path.Masks} element={<MaskPage />} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</div>
</div>
);
}
export function Home() {
useSwitchTheme();
if (!useHasHydrated()) {
return <Loading />;
}
return (
<ErrorBoundary>
<Router>
<Screen />
</Router>
</ErrorBoundary>
);
}
设置(组件),\app\components\settings.tsx
...
function EditPromptModal(props: { id: number; onClose: () => void }) {...}
function UserPromptModal(props: { onClose?: () => void }) {...}
function formatVersionDate(t: string) {...}
export function Settings() {
function checkUpdate(force = false) {...}
function checkUsage(force = false) {...}
return (
<ErrorBoundary>
<div className="window-header">...</div>
<div className={styles["settings"]}>...</div>
</ErrorBoundary>
);
}
当前的对话(组件),\app\components\chat.tsx
...
function exportMessages(messages: Message[], topic: string) {...}
export function SessionConfigModel(props: { onClose: () => void }) {...}
function PromptToast(props: {...}
function useSubmitHandler() {...}
export function PromptHints(props: {...}
function useScrollToBottom() {...}
export function ChatActions(props: {...}
export function Chat() {
...
return (
<div className={styles.chat} key={session.id}>
<div className="window-header">...</div>
<div
className={styles["chat-body"]}
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
onMouseDown={() => inputRef.current?.blur()}
onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)}
onTouchStart={() => {
inputRef.current?.blur();
setAutoScroll(false);
}}>...</div>
<div className={styles["chat-input-panel"]}>...</div>
</div>
);
}
新的空白对话(组件),\app\components\new-chat.tsx
...
function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {...}
function MaskItem(props: { mask: Mask; onClick?: () => void }) {...}
function useMaskGroup(masks: Mask[]) {...}
export function NewChat() {
...
return (
<div className={styles["new-chat"]}>
<div className={styles["mask-header"]}>...</div>
<div className={styles["mask-cards"]}>...</div>
<div className={styles["title"]}>...</div>
<div className={styles["sub-title"]}>...</div>
<div className={styles["actions"]}>...</div>
<div className={styles["masks"]}>...</div>
</div>
);
}
新的预设对话(组件),\app\components\mask.tsx
...
export function MaskAvatar(props: { mask: Mask }) {...}
export function MaskConfig(props: {...}
function ContextPromptItem(props: {...}
export function ContextPrompts(props: {...}
export function MaskPage() {
...
return (
<ErrorBoundary>
<div className={styles["mask-page"]}>
<div className="window-header">...</div>
<div className={styles["mask-page-body"]}>...</div>
</div>
{editingMask && (
<div className="modal-mask">
...
</div>
)}
</ErrorBoundary>
);
}
command(app),\app\command.ts
import { useSearchParams } from "react-router-dom";
type Command = (param: string) => void;
interface Commands {
fill?: Command;
submit?: Command;
mask?: Command;
}
export function useCommand(commands: Commands = {}) {
const [searchParams, setSearchParams] = useSearchParams();
if (commands === undefined) return;
let shouldUpdate = false;
searchParams.forEach((param, name) => {
const commandName = name as keyof Commands;
if (typeof commands[commandName] === "function") {
commands[commandName]!(param);
searchParams.delete(name);
shouldUpdate = true;
}
});
if (shouldUpdate) {
setSearchParams(searchParams);
}
}
constant(app),\app\constant.ts
export const OWNER = "AI-Product";
export const REPO = "Forager🌿AI";
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
export const UPDATE_URL = `${REPO_URL}#keep-updated`;
export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
export const LOING_URL = "https://www.5loi.com/about_loi";
export enum Path {
Home = "/",
Chat = "/chat",
Settings = "/settings",
NewChat = "/new-chat",
Masks = "/masks",
}
export enum SlotID {
AppBody = "app-body",
}
export enum FileName {
Masks = "masks.json",
Prompts = "prompts.json",
}
export enum StoreKey {
Chat = "chat-next-web-store",
Access = "access-control",
Config = "app-config",
Mask = "mask-store",
Prompt = "prompt-store",
Update = "chat-update",
}
export const MAX_SIDEBAR_WIDTH = 500;
export const MIN_SIDEBAR_WIDTH = 230;
export const NARROW_SIDEBAR_WIDTH = 100;
export const ACCESS_CODE_PREFIX = "ak-";
export const LAST_INPUT_KEY = "last-input";
global(app),\app\global.ts
declare module "*.jpg";
declare module "*.png";
declare module "*.woff2";
declare module "*.woff";
declare module "*.ttf";
declare module "*.scss" {
const content: Record<string, string>;
export default content;
}
declare module "*.svg";
layout(app),\app\layout.tsx
import { getBuildConfig } from "./config/build";
/* eslint-disable @next/next/no-page-custom-font */
import "./styles/globals.scss";
import "./styles/highlight.scss";
import "./styles/markdown.scss";
const buildConfig = getBuildConfig();
export const metadata = {
title: "Forager🌿AI",
description: "Your LLM Chat Consultant.",
appleWebApp: {
title: "Forager🌿AI",
statusBarStyle: "default",
},
themeColor: "#fafafa",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<meta
name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
/>
<meta
name="theme-color"
content="#151515"
media="(prefers-color-scheme: dark)"
/>
<meta name="version" content={buildConfig.commitId} />
<link rel="manifest" href="/site.webmanifest"></link>
<link rel="preconnect" href="https://fonts.proxy.ustclug.org"></link>
<link
href="https://fonts.proxy.ustclug.org/css2?family=Noto+Sans+SC:wght@300;400;700;900&display=swap"
rel="stylesheet"
></link>
<script src="/serviceWorkerRegister.js" defer></script>
<script src="/chatcrisp.js" defer></script>
</head>
<body>{children}</body>
</html>
);
}
polyfill(app),\app\polyfill.ts
declare global {
interface Array<T> {
at(index: number): T | undefined;
}
}
if (!Array.prototype.at) {
Array.prototype.at = function (index: number) {
// Get the length of the array
const length = this.length;
// Convert negative index to a positive index
if (index < 0) {
index = length + index;
}
// Return undefined if the index is out of range
if (index < 0 || index >= length) {
return undefined;
}
// Use Array.prototype.slice method to get value at the specified index
return Array.prototype.slice.call(this, index, index + 1)[0];
};
}
export {};
requests(app),\app\requests.ts
import type { ChatRequest, ChatResponse } from "./api/openai/typing";
import {
Message,
ModelConfig,
ModelType,
useAccessStore,
useAppConfig,
useChatStore,
} from "./store";
import { showToast } from "./components/ui-lib";
import { ACCESS_CODE_PREFIX } from "./constant";
const TIME_OUT_MS = 60000;
const makeRequestParam = (
messages: Message[],
options?: {
stream?: boolean;
overrideModel?: ModelType;
},
): ChatRequest => {
let sendMessages = messages.map((v) => ({
role: v.role,
content: v.content,
}));
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
};
// override model config
if (options?.overrideModel) {
modelConfig.model = options.overrideModel;
}
return {
messages: sendMessages,
stream: options?.stream,
model: modelConfig.model,
temperature: modelConfig.temperature,
presence_penalty: modelConfig.presence_penalty,
};
};
function getHeaders() {
const accessStore = useAccessStore.getState();
let headers: Record<string, string> = {};
const makeBearer = (token: string) => `Bearer ${token.trim()}`;
const validString = (x: string) => x && x.length > 0;
// use user's api key first
if (validString(accessStore.token)) {
headers.Authorization = makeBearer(accessStore.token);
} else if (
accessStore.enabledAccessControl() &&
validString(accessStore.accessCode)
) {
headers.Authorization = makeBearer(
ACCESS_CODE_PREFIX + accessStore.accessCode,
);
}
return headers;
}
export function requestOpenaiClient(path: string) {
const openaiUrl = useAccessStore.getState().openaiUrl;
return (body: any, method = "POST") =>
fetch(openaiUrl + path, {
method,
body: body && JSON.stringify(body),
headers: getHeaders(),
});
}
export async function requestChat(
messages: Message[],
options?: {
model?: ModelType;
},
) {
const req: ChatRequest = makeRequestParam(messages, {
overrideModel: options?.model,
});
const res = await requestOpenaiClient("v1/chat/completions")(req);
try {
const response = (await res.json()) as ChatResponse;
return response;
} catch (error) {
console.error("[Request Chat] ", error, res.body);
}
}
export async function requestUsage() {
const formatDate = (d: Date) =>
`${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d
.getDate()
.toString()
.padStart(2, "0")}`;
const ONE_DAY = 1 * 24 * 60 * 60 * 1000;
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startDate = formatDate(startOfMonth);
const endDate = formatDate(new Date(Date.now() + ONE_DAY));
const [used, subs] = await Promise.all([
requestOpenaiClient(
`dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`,
)(null, "GET"),
requestOpenaiClient("dashboard/billing/subscription")(null, "GET"),
]);
const response = (await used.json()) as {
total_usage?: number;
error?: {
type: string;
message: string;
};
};
const total = (await subs.json()) as {
hard_limit_usd?: number;
};
if (response.error && response.error.type) {
showToast(response.error.message);
return;
}
if (response.total_usage) {
response.total_usage = Math.round(response.total_usage) / 100;
}
if (total.hard_limit_usd) {
total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100;
}
return {
used: response.total_usage,
subscription: total.hard_limit_usd,
};
}
export async function requestChatStream(
messages: Message[],
options?: {
modelConfig?: ModelConfig;
overrideModel?: ModelType;
onMessage: (message: string, done: boolean) => void;
onError: (error: Error, statusCode?: number) => void;
onController?: (controller: AbortController) => void;
},
) {
const req = makeRequestParam(messages, {
stream: true,
overrideModel: options?.overrideModel,
});
console.log("[Request] ", req);
const controller = new AbortController();
const reqTimeoutId = setTimeout(() => controller.abort(), TIME_OUT_MS);
try {
const openaiUrl = useAccessStore.getState().openaiUrl;
const res = await fetch(openaiUrl + "v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
...getHeaders(),
},
body: JSON.stringify(req),
signal: controller.signal,
});
clearTimeout(reqTimeoutId);
let responseText = "";
const finish = () => {
options?.onMessage(responseText, true);
controller.abort();
};
if (res.ok) {
const reader = res.body?.getReader();
const decoder = new TextDecoder();
options?.onController?.(controller);
while (true) {
const resTimeoutId = setTimeout(() => finish(), TIME_OUT_MS);
const content = await reader?.read();
clearTimeout(resTimeoutId);
if (!content || !content.value) {
break;
}
const text = decoder.decode(content.value, { stream: true });
responseText += text;
const done = content.done;
options?.onMessage(responseText, false);
if (done) {
break;
}
}
finish();
} else if (res.status === 401) {
console.error("Unauthorized");
options?.onError(new Error("Unauthorized"), res.status);
} else {
console.error("Stream Error", res.body);
options?.onError(new Error("Stream Error"), res.status);
}
} catch (err) {
console.error("NetWork Error", err);
options?.onError(err as Error);
}
}
export async function requestWithPrompt(
messages: Message[],
prompt: string,
options?: {
model?: ModelType;
},
) {
messages = messages.concat([
{
role: "user",
content: prompt,
date: new Date().toLocaleString(),
},
]);
const res = await requestChat(messages, options);
return res?.choices?.at(0)?.message?.content ?? "";
}
// To store message streaming controller
export const ControllerPool = {
controllers: {} as Record<string, AbortController>,
addController(
sessionIndex: number,
messageId: number,
controller: AbortController,
) {
const key = this.key(sessionIndex, messageId);
this.controllers[key] = controller;
return key;
},
stop(sessionIndex: number, messageId: number) {
const key = this.key(sessionIndex, messageId);
const controller = this.controllers[key];
controller?.abort();
},
stopAll() {
Object.values(this.controllers).forEach((v) => v.abort());
},
hasPending() {
return Object.values(this.controllers).length > 0;
},
remove(sessionIndex: number, messageId: number) {
const key = this.key(sessionIndex, messageId);
delete this.controllers[key];
},
key(sessionIndex: number, messageIndex: number) {
return `${sessionIndex},${messageIndex}`;
},
};
utils(app),\app\utils.ts
import { useEffect, useState } from "react";
import { showToast } from "./components/ui-lib";
import Locale from "./locales";
export function trimTopic(topic: string) {
return topic.replace(/[,。!?”“"、,.!?]*$/, "");
}
export async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
showToast(Locale.Copy.Success);
} catch (error) {
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
showToast(Locale.Copy.Success);
} catch (error) {
showToast(Locale.Copy.Failed);
}
document.body.removeChild(textArea);
}
}
export function downloadAs(text: string, filename: string) {
const element = document.createElement("a");
element.setAttribute(
"href",
"data:text/plain;charset=utf-8," + encodeURIComponent(text),
);
element.setAttribute("download", filename);
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
export function readFromFile() {
return new Promise<string>((res, rej) => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "application/json";
fileInput.onchange = (event: any) => {
const file = event.target.files[0];
const fileReader = new FileReader();
fileReader.onload = (e: any) => {
res(e.target.result);
};
fileReader.onerror = (e) => rej(e);
fileReader.readAsText(file);
};
fileInput.click();
});
}
export function isIOS() {
const userAgent = navigator.userAgent.toLowerCase();
return /iphone|ipad|ipod/.test(userAgent);
}
export function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const onResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, []);
return size;
}
export const MOBILE_MAX_WIDTH = 600;
export function useMobileScreen() {
const { width } = useWindowSize();
return width <= MOBILE_MAX_WIDTH;
}
export function isMobileScreen() {
if (typeof window === "undefined") {
return false;
}
return window.innerWidth <= MOBILE_MAX_WIDTH;
}
export function isFirefox() {
return (
typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent)
);
}
export function selectOrCopy(el: HTMLElement, content: string) {
const currentSelection = window.getSelection();
if (currentSelection?.type === "Range") {
return false;
}
copyToClipboard(content);
return true;
}
function getDomContentWidth(dom: HTMLElement) {
const style = window.getComputedStyle(dom);
const paddingWidth =
parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const width = dom.clientWidth - paddingWidth;
return width;
}
function getOrCreateMeasureDom(id: string, init?: (dom: HTMLElement) => void) {
let dom = document.getElementById(id);
if (!dom) {
dom = document.createElement("span");
dom.style.position = "absolute";
dom.style.wordBreak = "break-word";
dom.style.fontSize = "14px";
dom.style.transform = "translateY(-200vh)";
dom.style.pointerEvents = "none";
dom.style.opacity = "0";
dom.id = id;
document.body.appendChild(dom);
init?.(dom);
}
return dom!;
}
export function autoGrowTextArea(dom: HTMLTextAreaElement) {
const measureDom = getOrCreateMeasureDom("__measure");
const singleLineDom = getOrCreateMeasureDom("__single_measure", (dom) => {
dom.innerText = "TEXT_FOR_MEASURE";
});
const width = getDomContentWidth(dom);
measureDom.style.width = width + "px";
measureDom.innerText = dom.value !== "" ? dom.value : "1";
const endWithEmptyLine = dom.value.endsWith("\n");
const height = parseFloat(window.getComputedStyle(measureDom).height);
const singleLineHeight = parseFloat(
window.getComputedStyle(singleLineDom).height,
);
const rows =
Math.round(height / singleLineHeight) + (endWithEmptyLine ? 1 : 0);
return rows;
}
export function getCSSVar(varName: string) {
return getComputedStyle(document.body).getPropertyValue(varName).trim();
}
5Loi