tr]:last:border-b-0",
48 | className
49 | )}
50 | {...props}
51 | />
52 | )
53 | }
54 |
55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
56 | return (
57 |
65 | )
66 | }
67 |
68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) {
69 | return (
70 | [role=checkbox]]:translate-y-[2px]",
74 | className
75 | )}
76 | {...props}
77 | />
78 | )
79 | }
80 |
81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) {
82 | return (
83 | [role=checkbox]]:translate-y-[2px]",
87 | className
88 | )}
89 | {...props}
90 | />
91 | )
92 | }
93 |
94 | function TableCaption({
95 | className,
96 | ...props
97 | }: React.ComponentProps<"caption">) {
98 | return (
99 |
104 | )
105 | }
106 |
107 | export {
108 | Table,
109 | TableHeader,
110 | TableBody,
111 | TableFooter,
112 | TableHead,
113 | TableRow,
114 | TableCell,
115 | TableCaption,
116 | }
117 |
--------------------------------------------------------------------------------
/docs/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Tabs({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
18 | )
19 | }
20 |
21 | function TabsList({
22 | className,
23 | ...props
24 | }: React.ComponentProps) {
25 | return (
26 |
34 | )
35 | }
36 |
37 | function TabsTrigger({
38 | className,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
50 | )
51 | }
52 |
53 | function TabsContent({
54 | className,
55 | ...props
56 | }: React.ComponentProps) {
57 | return (
58 |
63 | )
64 | }
65 |
66 | export { Tabs, TabsList, TabsTrigger, TabsContent }
67 |
--------------------------------------------------------------------------------
/docs/src/content/2025-03-25.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | date: 2025-03-25
3 | title: Placeholder
4 | description: Placeholder
5 | ogImage:
6 | author: AprilNEA
7 | ---
8 |
9 | 文档测试
--------------------------------------------------------------------------------
/docs/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/docs/src/lib/utils.tsx:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/docs/src/mdx-components.tsx:
--------------------------------------------------------------------------------
1 | import { useMDXComponents as getThemeComponents } from "nextra-theme-docs"; // nextra-theme-blog or your custom theme
2 | import type { MDXComponents } from "nextra/mdx-components";
3 |
4 | // Get the default MDX components
5 | const themeComponents = getThemeComponents();
6 |
7 | // Merge components
8 | export function useMDXComponents(components?: MDXComponents): MDXComponents {
9 | return {
10 | ...themeComponents,
11 | ...components,
12 | };
13 | }
14 |
--------------------------------------------------------------------------------
/docs/src/overrides.css:
--------------------------------------------------------------------------------
1 | /* make nav more blurry */
2 | .nextra-nav-container-blur {
3 | background: transparent !important;
4 | }
5 |
6 | /* Hide guides and faq from main menu */
7 | .nextra-navbar > nav > div > a[href="/showcase"] {
8 | display: none !important;
9 | }
10 |
11 | .nextra-navbar > nav > div > a[href="/faq"] {
12 | display: none !important;
13 | }
14 |
15 | .nextra-navbar > nav > div > a[href="/self-hosting"] {
16 | display: none !important;
17 | }
18 |
19 | .nextra-scrollbar {
20 | scrollbar-color: #73737366 transparent;
21 | }
22 |
--------------------------------------------------------------------------------
/docs/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | darkMode: "class",
5 | content: ["./src/**/*.{js,jsx,ts,tsx,mdx}"],
6 | theme: {
7 | container: {
8 | center: true,
9 | padding: "2rem",
10 | screens: {
11 | "2xl": "1400px",
12 | },
13 | },
14 | extend: {
15 | animation: {
16 | "accordion-down": "accordion-down 0.2s ease-out",
17 | "accordion-up": "accordion-up 0.2s ease-out",
18 | "border-beam": "border-beam calc(var(--duration)*1s) infinite linear",
19 | meteor: "meteor 5s linear infinite",
20 | spin: "spin calc(var(--speed) * 2) infinite linear",
21 | slide: "slide var(--speed) ease-in-out infinite alternate",
22 | gradient: "gradient 6s linear infinite",
23 | marquee: "marquee var(--duration) linear infinite",
24 | grid: "grid 20s linear infinite",
25 | "marquee-vertical": "marquee-vertical var(--duration) linear infinite",
26 | "shimmer-slide":
27 | "shimmer-slide var(--speed) ease-in-out infinite alternate",
28 | "spin-around": "spin-around calc(var(--speed) * 2) infinite linear",
29 | },
30 | keyframes: {
31 | "accordion-down": {
32 | from: { height: 0 },
33 | to: { height: "var(--radix-accordion-content-height)" },
34 | },
35 | "accordion-up": {
36 | from: { height: "var(--radix-accordion-content-height)" },
37 | to: { height: 0 },
38 | },
39 | "border-beam": {
40 | "100%": {
41 | "offset-distance": "100%",
42 | },
43 | },
44 | grid: {
45 | "0%": { transform: "translateY(-50%)" },
46 | "100%": { transform: "translateY(0)" },
47 | },
48 | meteor: {
49 | "0%": { transform: "rotate(215deg) translateX(0)", opacity: 1 },
50 | "70%": { opacity: 1 },
51 | "100%": {
52 | transform: "rotate(215deg) translateX(-500px)",
53 | opacity: 0,
54 | },
55 | },
56 | marquee: {
57 | from: { transform: "translateX(0)" },
58 | to: { transform: "translateX(calc(-100% - var(--gap)))" },
59 | },
60 | "marquee-vertical": {
61 | from: { transform: "translateY(0)" },
62 | to: { transform: "translateY(calc(-100% - var(--gap)))" },
63 | },
64 | gradient: {
65 | to: { "background-position": "200% center" },
66 | },
67 | spin: {
68 | "0%": {
69 | rotate: "0deg",
70 | },
71 | "15%, 35%": {
72 | rotate: "90deg",
73 | },
74 | "65%, 85%": {
75 | rotate: "270deg",
76 | },
77 | "100%": {
78 | rotate: "360deg",
79 | },
80 | },
81 | "spin-around": {
82 | "0%": {
83 | transform: "translateZ(0) rotate(0)",
84 | },
85 | "15%, 35%": {
86 | transform: "translateZ(0) rotate(90deg)",
87 | },
88 | "65%, 85%": {
89 | transform: "translateZ(0) rotate(270deg)",
90 | },
91 | "100%": {
92 | transform: "translateZ(0) rotate(360deg)",
93 | },
94 | },
95 | slide: {
96 | to: {
97 | transform: "translate(calc(100cqw - 100%), 0)",
98 | },
99 | },
100 |
101 | "shimmer-slide": {
102 | to: {
103 | transform: "translate(calc(100cqw - 100%), 0)",
104 | },
105 | },
106 | },
107 | perspective: {
108 | "1000": "1000px",
109 | },
110 | },
111 | },
112 | } satisfies Config;
113 |
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@achat/tsconfig/nextjs.json",
3 | "include": [
4 | "**/*.ts",
5 | "**/*.tsx",
6 | "next-env.d.ts",
7 | ".next/types/**/*.ts"
8 | ],
9 | "exclude": [
10 | "node_modules"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "achat",
3 | "version": "4.0.0",
4 | "description": "🌊 AChat - An open-source/self-hosted/local-first AI platform, designed for enterprises and teams, perfectly combining powerful local processing capabilities with seamless remote synchronization.",
5 | "keywords": [
6 | "ai",
7 | "chat",
8 | "agent",
9 | "chatbot",
10 | "local-first",
11 | "self-hosted",
12 | "open-source",
13 | "enterprise-solutions"
14 | ],
15 | "license": "MIT",
16 | "author": "AprilNEA (https://sku.moe)",
17 | "repository": {
18 | "type": "git",
19 | "url": "git+https://github.com/AprilNEA/AChat"
20 | },
21 | "scripts": {
22 | "build": "turbo run build",
23 | "build:frontend": "turbo run build --filter frontend",
24 | "dev": "turbo run dev --parallel",
25 | "start": "turbo run start",
26 | "lint": "turbo run lint",
27 | "test": "vitest run"
28 | },
29 | "dependencies": {
30 | "dotenv": "^16.4.5",
31 | "dotenv-cli": "^8.0.0",
32 | "drizzle-orm": "^0.41.0",
33 | "drizzle-zod": "^0.7.1",
34 | "handlebars": "^4.7.8",
35 | "hono": "4.7.6",
36 | "pg": "^8.14.1",
37 | "react": "19.1.2",
38 | "react-dom": "19.1.2",
39 | "uuid": "^11.1.0",
40 | "zod": "^3.23.8"
41 | },
42 | "devDependencies": {
43 | "@biomejs/biome": "1.9.4",
44 | "@total-typescript/ts-reset": "^0.6.1",
45 | "@types/node": "^20.16.13",
46 | "@types/pg": "^8.11.11",
47 | "@types/react": "19.0.8",
48 | "@types/react-dom": "19.0.3",
49 | "@types/uuid": "^10.0.0",
50 | "bunchee": "^6.0.3",
51 | "drizzle-kit": "^0.28.1",
52 | "esbuild": "^0.24.0",
53 | "npm-run-all": "^4.1.5",
54 | "rimraf": "^6.0.1",
55 | "tsx": "^4.19.2",
56 | "turbo": "^2.3.3",
57 | "typescript": "^5.7.3",
58 | "vitest": "^3.1.2"
59 | },
60 | "pnpm": {
61 | "overrides": {
62 | "react": "19.1.0",
63 | "react-dom": "19.1.0",
64 | "@types/react": "19.1.2",
65 | "@types/react-dom": "19.1.2"
66 | },
67 | "peerDependencyRules": {
68 | "allowAny": [
69 | "react",
70 | "react-dom",
71 | "next"
72 | ]
73 | },
74 | "patchedDependencies": {
75 | "hono": "patches/hono.patch"
76 | }
77 | },
78 | "packageManager": "pnpm@10.10.0"
79 | }
--------------------------------------------------------------------------------
/packages/backend/database/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 |
3 | import { defineConfig } from "drizzle-kit";
4 |
5 | const dbUrl = process.env.DATABASE_URL;
6 |
7 | if (!dbUrl) {
8 | throw "DATABASE_URL is not set";
9 | }
10 |
11 | export default defineConfig({
12 | dialect: "postgresql",
13 | schema: "./src/schema/**/*.ts",
14 | out: "./migrations/",
15 | dbCredentials: {
16 | url: dbUrl,
17 | },
18 | verbose: process.env.NODE_ENV === "development",
19 | breakpoints: false,
20 | strict: true,
21 | });
22 |
--------------------------------------------------------------------------------
/packages/backend/database/drizzle/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AprilNEA/AChat/bd7e585c4d5d4773cbf80aae87dcfcec94a7d505/packages/backend/database/drizzle/.gitkeep
--------------------------------------------------------------------------------
/packages/backend/database/migrate.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 |
3 | import { migrate } from "drizzle-orm/node-postgres/migrator";
4 | import { drizzle } from "drizzle-orm/node-postgres";
5 |
6 | if (!process.env.DATABASE_URL) {
7 | throw new Error("DATABASE_URL is not set");
8 | }
9 | const db = drizzle(process.env.DATABASE_URL);
10 |
11 | export async function runMigrate() {
12 | console.log("⏳ Running migrations...");
13 |
14 | const start = Date.now();
15 |
16 | await migrate(db, { migrationsFolder: "drizzle" });
17 |
18 | const end = Date.now();
19 |
20 | console.log(`✅ Migrations completed in ${end - start}ms`);
21 | }
22 |
23 | runMigrate()
24 | .then(() => {
25 | process.exit(0);
26 | })
27 | .catch((err) => {
28 | console.error("❌ Migration failed");
29 | console.error(err);
30 | process.exit(1);
31 | });
32 |
--------------------------------------------------------------------------------
/packages/backend/database/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@achat/database",
3 | "type": "module",
4 | "main": "./src/index.ts",
5 | "exports": {
6 | "./schema": "./src/schema.ts",
7 | "./utils": "./src/utils.ts"
8 | },
9 | "files": [
10 | "dist"
11 | ],
12 | "scripts": {
13 | "ts-map": "tsc --rootDir ./src --emitDeclarationOnly",
14 | "dev": "bunchee -w --success \"npm run ts-map\"",
15 | "build": "bunchee && npm run asset",
16 | "push": "drizzle-kit push",
17 | "migrate": "tsx migrate.ts",
18 | "generate": "drizzle-kit generate"
19 | },
20 | "dependencies": {
21 | "drizzle-orm": "*",
22 | "drizzle-zod": "*",
23 | "uuid": "*",
24 | "zod": "*"
25 | },
26 | "devDependencies": {
27 | "@achat/tsconfig": "workspace:*",
28 | "@types/node": "*",
29 | "@types/pg": "*",
30 | "@types/uuid": "*",
31 | "dotenv": "^16.4.7",
32 | "drizzle-kit": "*",
33 | "drizzle-seed": "^0.3.1",
34 | "esbuild": "*",
35 | "pg": "*",
36 | "tsx": "*",
37 | "typescript": "*"
38 | }
39 | }
--------------------------------------------------------------------------------
/packages/backend/database/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./schema";
2 | export * from "./utils";
3 |
--------------------------------------------------------------------------------
/packages/backend/database/src/schema.ts:
--------------------------------------------------------------------------------
1 | export * from "./schema/auth";
2 | export * from "./schema/config";
3 | export * from "./schema/chat";
4 | export * from "./schema/provider";
--------------------------------------------------------------------------------
/packages/backend/database/src/schema/auth.ts:
--------------------------------------------------------------------------------
1 | export * from "./auth/user";
2 | export * from "./auth/organization";
3 | export * from "./auth/sso";
--------------------------------------------------------------------------------
/packages/backend/database/src/schema/auth/organization.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm";
2 | import { pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
3 | import { user } from "./user";
4 |
5 | export const organization = pgTable("organization", {
6 | id: uuid("id").primaryKey(),
7 | name: varchar("name", { length: 255 }).notNull(),
8 | slug: varchar("slug", { length: 255 }).notNull().unique(),
9 | logo: text("logo"),
10 | metadata: text("metadata"),
11 | createdAt: timestamp("created_at").notNull().defaultNow(),
12 | });
13 |
14 | export const member = pgTable("member", {
15 | id: uuid("id").primaryKey(),
16 | userId: uuid("user_id")
17 | .notNull()
18 | .references(() => user.id),
19 | organizationId: uuid("organization_id")
20 | .notNull()
21 | .references(() => organization.id),
22 | teamId: uuid("team_id"),
23 | role: varchar("role", { length: 50 }).notNull(),
24 | createdAt: timestamp("created_at").notNull().defaultNow(),
25 | });
26 |
27 | export const invitation = pgTable("invitation", {
28 | id: uuid("id").primaryKey(),
29 | email: varchar("email", { length: 255 }).notNull(),
30 | inviterId: uuid("inviter_id")
31 | .notNull()
32 | .references(() => user.id),
33 | organizationId: uuid("organization_id")
34 | .notNull()
35 | .references(() => organization.id),
36 | teamId: uuid("team_id"),
37 | role: varchar("role", { length: 50 }).notNull(),
38 | status: varchar("status", { length: 50 }).notNull(),
39 | expiresAt: timestamp("expires_at").notNull(),
40 | createdAt: timestamp("created_at").notNull().defaultNow(),
41 | });
42 |
43 | export const team = pgTable("team", {
44 | id: uuid("id").primaryKey(),
45 | name: varchar("name", { length: 255 }).notNull(),
46 | organizationId: uuid("organization_id")
47 | .notNull()
48 | .references(() => organization.id),
49 | createdAt: timestamp("created_at").notNull().defaultNow(),
50 | updatedAt: timestamp("updated_at"),
51 | });
52 |
53 | // Relations
54 | export const organizationRelations = relations(organization, ({ many }) => ({
55 | members: many(member),
56 | invitations: many(invitation),
57 | teams: many(team),
58 | }));
59 |
60 | export const memberRelations = relations(member, ({ one }) => ({
61 | organization: one(organization, {
62 | fields: [member.organizationId],
63 | references: [organization.id],
64 | }),
65 | team: one(team, {
66 | fields: [member.teamId],
67 | references: [team.id],
68 | }),
69 | }));
70 |
71 | export const invitationRelations = relations(invitation, ({ one }) => ({
72 | organization: one(organization, {
73 | fields: [invitation.organizationId],
74 | references: [organization.id],
75 | }),
76 | team: one(team, {
77 | fields: [invitation.teamId],
78 | references: [team.id],
79 | }),
80 | }));
81 |
82 | export const teamRelations = relations(team, ({ one, many }) => ({
83 | organization: one(organization, {
84 | fields: [team.organizationId],
85 | references: [organization.id],
86 | }),
87 | members: many(member),
88 | invitations: many(invitation),
89 | }));
90 |
--------------------------------------------------------------------------------
/packages/backend/database/src/schema/auth/sso.ts:
--------------------------------------------------------------------------------
1 | import { pgTable, text, uuid } from "drizzle-orm/pg-core";
2 | import { user } from "./user";
3 | import { organization } from "./organization";
4 |
5 | export const ssoProvider = pgTable("sso_provider", {
6 | id: uuid("id").primaryKey(),
7 | issuer: text("issuer").notNull(),
8 | domain: text("domain").notNull(),
9 | oidcConfig: text("oidc_config").notNull(),
10 | userId: text("user_id")
11 | .notNull()
12 | .references(() => user.id),
13 | providerId: uuid("provider_id").notNull(),
14 | organizationId: uuid("organization_id").references(() => organization.id),
15 | });
16 |
--------------------------------------------------------------------------------
/packages/backend/database/src/schema/chat.ts:
--------------------------------------------------------------------------------
1 | export * from "./chat/message";
2 | export * from "./chat/conversation";
3 |
--------------------------------------------------------------------------------
/packages/backend/database/src/schema/chat/conversation.ts:
--------------------------------------------------------------------------------
1 | import {
2 | integer,
3 | pgTable,
4 | serial,
5 | timestamp,
6 | uuid,
7 | varchar,
8 | } from "drizzle-orm/pg-core";
9 | import { organization } from "../auth/organization";
10 | import { user } from "../auth/user";
11 | import { timeColumns } from "../../utils/columns";
12 |
13 | export const conversation = pgTable("conversation", {
14 | id: uuid("id").primaryKey(),
15 | organizationId: uuid("organization_id") .notNull().references(() => organization.id),
16 | userId: uuid("user_id")
17 | .notNull()
18 | .references(() => user.id),
19 |
20 | title: varchar("title", { length: 255 }).notNull(),
21 |
22 | // modelId: integer("model_id").references(() => models.id), // AI model used
23 |
24 | ...timeColumns(),
25 | deletedAt: timestamp("deleted_at", { withTimezone: true }),
26 | });
27 |
--------------------------------------------------------------------------------
/packages/backend/database/src/schema/chat/message.ts:
--------------------------------------------------------------------------------
1 | import {
2 | index,
3 | integer,
4 | jsonb,
5 | pgTable,
6 | primaryKey,
7 | serial,
8 | text,
9 | timestamp,
10 | uuid,
11 | } from "drizzle-orm/pg-core";
12 | import { conversation } from "./conversation";
13 | import { timeColumns } from "../../utils/columns";
14 | import { user } from "../auth/user";
15 | import { organization } from "../auth/organization";
16 |
17 | export const message = pgTable("message", {
18 | id: uuid("id").primaryKey(),
19 | conversationId: uuid("conversation_id")
20 | .references(() => conversation.id)
21 | .notNull(),
22 | organizationId: uuid("organization_id")
23 | .notNull()
24 | .references(() => organization.id),
25 | userId: uuid("user_id")
26 | .notNull()
27 | .references(() => user.id),
28 | content: text("content").notNull(), // Raw text
29 | role: text("role", {
30 | enum: ["user", "system", "assistant", "tool"],
31 | }).notNull(),
32 | // status: varchar("status", { enum: ["pending", "processed", "failed"] }),
33 |
34 | // usage: jsonb("usage"),
35 |
36 | ...timeColumns(),
37 | deletedAt: timestamp("deleted_at", { withTimezone: true }),
38 | });
39 |
40 | export const messageClosure = pgTable(
41 | "message_closure",
42 | {
43 | conversationId: uuid("conversation_id")
44 | .references(() => conversation.id)
45 | .notNull(),
46 | ancestor: uuid("ancestor")
47 | .references(() => message.id, { onDelete: "cascade" })
48 | .notNull(),
49 | descendant: uuid("descendant")
50 | .references(() => message.id, { onDelete: "cascade" })
51 | .notNull(),
52 | depth: integer("depth").notNull(),
53 | },
54 | (t) => [
55 | primaryKey({ columns: [t.ancestor, t.descendant] }),
56 | index().on(t.conversationId),
57 | index().on(t.ancestor),
58 | index().on(t.descendant),
59 | ]
60 | );
61 |
--------------------------------------------------------------------------------
/packages/backend/database/src/schema/config.ts:
--------------------------------------------------------------------------------
1 | import {
2 | integer,
3 | jsonb,
4 | pgTable,
5 | text,
6 | varchar,
7 | } from "drizzle-orm/pg-core";
8 | import { createInsertSchema, createSelectSchema } from "drizzle-zod";
9 | import { timeColumns } from "../utils/columns";
10 |
11 | export const config = pgTable("config", {
12 | id: integer("id").primaryKey().generatedByDefaultAsIdentity({
13 | startWith: 1000,
14 | }),
15 |
16 | key: varchar("key").notNull(),
17 | value: jsonb("value").notNull().default({}),
18 | note: text("note"),
19 |
20 | ...timeColumns(),
21 | });
22 |
23 | export const Config = createSelectSchema(config);
24 | export type Config = typeof config.$inferSelect;
25 | export const NewConfig = createInsertSchema(config);
26 | export type NewConfig = typeof config.$inferInsert;
--------------------------------------------------------------------------------
/packages/backend/database/src/schema/provider.ts:
--------------------------------------------------------------------------------
1 | export * from "./provider/model";
--------------------------------------------------------------------------------
/packages/backend/database/src/utils.ts:
--------------------------------------------------------------------------------
1 | export * from "./utils/fields";
2 |
--------------------------------------------------------------------------------
/packages/backend/database/src/utils/columns.ts:
--------------------------------------------------------------------------------
1 | import { timestamp } from "drizzle-orm/pg-core";
2 |
3 | export const timeColumns = <
4 | T extends "both" | "create-only" | "update-only" = "both",
5 | >(
6 | choose?: T,
7 | ) => {
8 | const createdAt = timestamp("created_at", { withTimezone: true })
9 | .notNull()
10 | .defaultNow();
11 | if (choose === "create-only") {
12 | return {
13 | createdAt: timestamp("created_at", { withTimezone: true })
14 | .notNull()
15 | .defaultNow(),
16 | } as T extends "create-only" ? { createdAt: typeof createdAt } : never;
17 | }
18 |
19 | const updatedAt = timestamp("updated_at", { withTimezone: true }).$onUpdate(
20 | () => new Date(),
21 | );
22 | if (!choose || choose === "both") {
23 | return {
24 | createdAt,
25 | updatedAt,
26 | } as T extends "both"
27 | ? { createdAt: typeof createdAt; updatedAt: typeof updatedAt }
28 | : never;
29 | }
30 | if (choose === "update-only") {
31 | return {
32 | updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
33 | () => new Date(),
34 | ),
35 | } as T extends "update-only" ? { updatedAt: typeof updatedAt } : never;
36 | }
37 |
38 | throw new Error(`Invalid choose: ${choose}`);
39 | };
40 |
--------------------------------------------------------------------------------
/packages/backend/database/src/utils/fields.ts:
--------------------------------------------------------------------------------
1 | import { type PgTable, getTableConfig } from "drizzle-orm/pg-core";
2 |
3 | export const getQueryBuilderFields = (
4 | table: TTable,
5 | ) => {
6 | const { columns } = getTableConfig(table);
7 | return columns.map((column) => ({
8 | name: column.name,
9 | label: column.name,
10 | }));
11 | };
12 |
--------------------------------------------------------------------------------
/packages/backend/database/src/utils/id.ts:
--------------------------------------------------------------------------------
1 | import { sql } from "drizzle-orm";
2 | import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3 | import type { PgSequence } from "drizzle-orm/pg-core";
4 |
5 | export class HiLoIdGenerator {
6 | private hi = 0;
7 | private lo = 0;
8 | private readonly maxLo: number;
9 |
10 | constructor(
11 | private readonly db: NodePgDatabase,
12 | private readonly sequence: PgSequence,
13 | maxLo = 1000
14 | ) {
15 | this.maxLo = maxLo;
16 | this.lo = maxLo; // 初始化时触发获取
17 | }
18 |
19 | private async fetchNextHi(): Promise {
20 | const result = await this.db.execute(
21 | sql`SELECT nextval(${this.sequence.seqName}) as hi`
22 | );
23 | return Number(result.rows[0].hi);
24 | }
25 |
26 | public async nextId(): Promise {
27 | if (this.lo >= this.maxLo) {
28 | this.hi = await this.fetchNextHi();
29 | this.lo = 0;
30 | }
31 | const id = this.hi * this.maxLo + this.lo;
32 | this.lo += 1;
33 | return id;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/backend/database/src/utils/table.ts:
--------------------------------------------------------------------------------
1 | import { pgTableCreator } from "drizzle-orm/pg-core";
2 |
3 | export const shopTable = pgTableCreator((name) => `shop_${name}`);
4 |
5 | export const proxyTable = pgTableCreator((name) => `proxy_${name}`);
6 |
7 | export const userTable = pgTableCreator((name) => `user_${name}`);
8 |
--------------------------------------------------------------------------------
/packages/backend/database/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@achat/tsconfig/node-library.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "outDir": "./dist",
6 | // "baseUrl": "./src",
7 | "tsBuildInfoFile": "./dist/.tsbuildinfo"
8 | },
9 | "include": ["src/**/*.ts", "src/schema/provider"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/backend/server/.env.example:
--------------------------------------------------------------------------------
1 | DATABASE_URL=
--------------------------------------------------------------------------------
/packages/backend/server/.gitignore:
--------------------------------------------------------------------------------
1 | # dev
2 | .yarn/
3 | !.yarn/releases
4 | .vscode/*
5 | !.vscode/launch.json
6 | !.vscode/*.code-snippets
7 | .idea/workspace.xml
8 | .idea/usage.statistics.xml
9 | .idea/shelf
10 |
11 | dist/
12 |
13 | # deps
14 | node_modules/
15 |
16 | # env
17 | .env
18 | .env.production
19 |
20 | # logs
21 | logs/
22 | *.log
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | pnpm-debug.log*
27 | lerna-debug.log*
28 |
29 | # misc
30 | .DS_Store
31 |
--------------------------------------------------------------------------------
/packages/backend/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine AS base
2 |
3 | RUN npm install -g pnpm turbo@^2
4 |
5 | FROM base AS build
6 | WORKDIR /app
7 |
8 | COPY . .
9 |
10 | RUN pnpm install --frozen-lockfile
11 | RUN pnpm run build:api
12 |
13 | EXPOSE 3001
14 |
15 | CMD [ "pnpm", "start:api" ]
16 |
17 |
--------------------------------------------------------------------------------
/packages/backend/server/esbuild.config.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import { sentryEsbuildPlugin } from "@sentry/esbuild-plugin";
3 | import esbuild from "esbuild";
4 |
5 | try {
6 | esbuild
7 | .build({
8 | entryPoints: {
9 | server: "src/index.ts",
10 | },
11 | bundle: true,
12 | platform: "node",
13 | format: "esm",
14 | target: "node18",
15 | outExtension: { ".js": ".mjs" },
16 | minify: true,
17 | sourcemap: true,
18 | outdir: "dist",
19 | tsconfig: "tsconfig.json",
20 | packages: "external",
21 | plugins: [
22 | sentryEsbuildPlugin({
23 | sourcemaps: {
24 | filesToDeleteAfterUpload: ["*.map"],
25 | },
26 | }),
27 | ],
28 | })
29 | .catch(() => {
30 | return process.exit(1);
31 | });
32 | } catch (error) {
33 | console.log(error);
34 | }
35 |
--------------------------------------------------------------------------------
/packages/backend/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@achat/server",
3 | "version": "4.0.0",
4 | "type": "module",
5 | "exports": {
6 | "./hc": {
7 | "import": {
8 | "types": "./dist/hc.d.ts",
9 | "default": "./dist/hc.js"
10 | }
11 | }
12 | },
13 | "files": [
14 | "dist"
15 | ],
16 | "scripts": {
17 | "dev": "tsx watch src/index.ts",
18 | "build": "tsx esbuild.config.ts",
19 | "start": "node dist/server.mjs",
20 | "clean": "rimraf dist",
21 | "test": "vitest"
22 | },
23 | "dependencies": {
24 | "@achat/database": "workspace:^",
25 | "@achat/error": "workspace:^",
26 | "@ai-sdk/anthropic": "^1.2.12",
27 | "@hono/node-server": "^1.13.7",
28 | "@hono/sentry": "^1.2.0",
29 | "@hono/zod-validator": "^0.4.1",
30 | "@linear/sdk": "^33.0.0",
31 | "@scalar/hono-api-reference": "^0.5.163",
32 | "@sentry/esbuild-plugin": "^3.1.2",
33 | "@sentry/node": "^9.22.0",
34 | "@sentry/profiling-node": "^9.22.0",
35 | "ai": "^4.3.16",
36 | "better-auth": "^1.2.5",
37 | "date-fns": "4.0.0-beta.1",
38 | "deepmerge": "^4.3.1",
39 | "devalue": "^5.0.0",
40 | "dotenv": "*",
41 | "drizzle-orm": "0.41.0",
42 | "drizzle-zod": "*",
43 | "handlebars": "^4.7.8",
44 | "hono": "*",
45 | "hono-openapi": "^0.4.4",
46 | "inngest": "^3.34.4",
47 | "minio": "^8.0.1",
48 | "pg": "*",
49 | "react": "*",
50 | "react-querybuilder": "^8.0.0",
51 | "ssh2": "^1.16.0",
52 | "superjson": "^2.2.1",
53 | "uuid": "*",
54 | "winston": "^3.14.2",
55 | "winston-loki": "^6.1.2",
56 | "winston-transport": "^4.9.0",
57 | "zod": "^3.23.8",
58 | "zod-openapi": "^4.1.0"
59 | },
60 | "devDependencies": {
61 | "@biomejs/biome": "*",
62 | "@types/ioredis-mock": "^8.2.5",
63 | "@types/node": "*",
64 | "@types/pg": "*",
65 | "@types/uuid": "*",
66 | "drizzle-kit": "*",
67 | "esbuild": "*",
68 | "ioredis-mock": "^8.9.0",
69 | "openapi-types": "^12.1.3",
70 | "tsx": "*",
71 | "typescript": "*",
72 | "utility-types": "^3.11.0",
73 | "vitest": "*"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/packages/backend/server/src/factory.ts:
--------------------------------------------------------------------------------
1 | import { createFactory } from "hono/factory";
2 | import type { Session } from "./lib/auth";
3 | import type { DataBase } from "./lib/database";
4 | import { authMiddleware } from "./middleware/auth";
5 |
6 | export const appFactory = createFactory<{
7 | Variables: {
8 | user: Session["user"] | null;
9 | session: Session["session"] | null;
10 | db: DataBase;
11 | };
12 | }>({
13 | initApp: (app) => {
14 | app.use(authMiddleware);
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/packages/backend/server/src/hc.ts:
--------------------------------------------------------------------------------
1 | import { hc } from "hono/client";
2 | import type { routes } from ".";
3 |
4 | // import type
5 | // assign the client to a variable to calculate the type when compiling
6 | const client = hc("/api");
7 | export type Client = typeof client;
8 |
9 | export const hcWithType = (...args: Parameters): Client =>
10 | hc(...args);
11 |
--------------------------------------------------------------------------------
/packages/backend/server/src/lib/database/index.ts:
--------------------------------------------------------------------------------
1 | import * as schema from "@achat/database/schema";
2 | import { drizzle } from "drizzle-orm/node-postgres";
3 | import pg from "pg";
4 |
5 | const getDbPool = () => {
6 | return new pg.Pool({
7 | connectionString: process.env.DATABASE_URL,
8 | });
9 | };
10 |
11 | const globalForDB = globalThis as unknown as {
12 | dbPool: ReturnType | undefined;
13 | };
14 |
15 | const dbPool = globalForDB?.dbPool ?? getDbPool();
16 |
17 | const db = drizzle(dbPool, { schema });
18 |
19 | export type DataBase = ReturnType>;
20 |
21 | if (process.env.NODE_ENV !== "production") {
22 | globalForDB.dbPool = dbPool;
23 | }
24 |
25 | export default db;
26 |
--------------------------------------------------------------------------------
/packages/backend/server/src/lib/database/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @see https://gist.github.com/rphlmr/0d1722a794ed5a16da0fdf6652902b15
3 | */
4 |
5 | import { type AnyColumn, not, sql } from "drizzle-orm";
6 | // import { pgTableCreator } from "drizzle-orm/pg-core";
7 |
8 | // import { databasePrefix } from "@/lib/constants";
9 |
10 | /**
11 | * This lets us use the multi-project schema feature of Drizzle ORM. So the same
12 | * database instance can be used for multiple projects.
13 | *
14 | * @see https://orm.drizzle.team/docs/goodies#multi-project-schema
15 | */
16 | // export const pgTable = pgTableCreator((name) => `${databasePrefix}_${name}`);
17 |
18 | export function takeFirstOrNull(data: TData[]) {
19 | return data[0] ?? null;
20 | }
21 |
22 | export function takeFirstOrThrow(data: TData[]) {
23 | const first = takeFirstOrNull(data);
24 |
25 | if (!first) {
26 | throw new Error("Item not found");
27 | }
28 |
29 | return first;
30 | }
31 |
32 | export function isEmpty(column: TColumn) {
33 | return sql`
34 | case
35 | when ${column} is null then true
36 | when ${column} = '' then true
37 | when ${column}::text = '[]' then true
38 | when ${column}::text = '{}' then true
39 | else false
40 | end
41 | `;
42 | }
43 |
44 | export function isNotEmpty(column: TColumn) {
45 | return not(isEmpty(column));
46 | }
47 |
--------------------------------------------------------------------------------
/packages/backend/server/src/lib/plugins/sentry.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from "@sentry/node";
2 | import { nodeProfilingIntegration } from "@sentry/profiling-node";
3 |
4 | export const init = () => {
5 | // Ensure to call this before importing any other modules!
6 | Sentry.init({
7 | dsn: process.env.SENTRY_DSN,
8 | integrations: [
9 | // Add our Profiling integration
10 | nodeProfilingIntegration(),
11 | ],
12 |
13 | // Add Tracing by setting tracesSampleRate
14 | // We recommend adjusting this value in production
15 | tracesSampleRate: 1.0,
16 |
17 | // Set sampling rate for profiling
18 | // This is relative to tracesSampleRate
19 | profilesSampleRate: 1.0,
20 |
21 | _experiments: { enableLogs: true },
22 | });
23 | };
24 |
--------------------------------------------------------------------------------
/packages/backend/server/src/lib/plugins/winston.ts:
--------------------------------------------------------------------------------
1 | import winston, { format } from "winston";
2 | import LokiTransport from "winston-loki";
3 | import Transport from "winston-transport";
4 | import * as Sentry from "@sentry/node";
5 | const SentryWinstonTransport = Sentry.createSentryWinstonTransport(Transport);
6 |
7 | declare module "winston" {
8 | interface Logger {
9 | clearMeta(): void;
10 | initMeta(meta: Record): void;
11 | appendMeta(meta: Record): void;
12 | }
13 | }
14 |
15 | export const getLogger = (appName: string, labels?: Record) => {
16 | const transports: winston.transport | winston.transport[] = [];
17 |
18 | if (process.env.SENTRY_DSN) {
19 | transports.push(
20 | new SentryWinstonTransport({
21 | level: process.env.LOG_LEVEL || "info",
22 | })
23 | );
24 | }
25 | if (process.env.NODE_ENV === "production") {
26 | if (process.env.LOKI_URL) {
27 | transports.push(
28 | new LokiTransport({
29 | host: process.env.LOKI_URL,
30 | labels: { app: appName, ...labels },
31 | json: true,
32 | format: winston.format.json(),
33 | replaceTimestamp: true,
34 | onConnectionError: (err) => console.error("[Logger]", err),
35 | })
36 | );
37 | } else {
38 | console.warn(
39 | "[Logger] LOKI_URL is not set, LokiTransport will not be used"
40 | );
41 | }
42 | }
43 | if (process.env.NODE_ENV === "development") {
44 | transports.push(
45 | new winston.transports.Console({
46 | format: format.combine(
47 | format.colorize(),
48 | format.timestamp(),
49 | format.align(),
50 | format.printf(
51 | (info) =>
52 | `${info.timestamp}\t[${info.level}]\t${info.ip}\t${[
53 | info.type || info.method,
54 | ]}\t${info.path}${info.duration ? `(${info.duration}ms)` : ""}\t${
55 | info.message
56 | }` || JSON.stringify(info)
57 | )
58 | ),
59 | })
60 | );
61 | }
62 |
63 | const logger = winston.createLogger({
64 | level: process.env.LOG_LEVEL || "info",
65 | transports: transports,
66 | });
67 |
68 | logger.clearMeta = () => {
69 | logger.defaultMeta = {};
70 | };
71 | logger.initMeta = (meta: Record) => {
72 | logger.defaultMeta = meta;
73 | };
74 | logger.appendMeta = (meta: Record) => {
75 | logger.defaultMeta = {
76 | ...logger.defaultMeta,
77 | ...meta,
78 | };
79 | };
80 |
81 | return logger;
82 | };
83 |
--------------------------------------------------------------------------------
/packages/backend/server/src/middleware/auth.ts:
--------------------------------------------------------------------------------
1 | import type { Session } from "@server/lib/auth";
2 | import type { MiddlewareHandler } from "hono";
3 |
4 | export const authMiddleware: MiddlewareHandler<{
5 | Variables: {
6 | user: Session["user"] | null;
7 | session: Session["session"] | null;
8 | };
9 | }> = async (c, next) => {
10 | const auth = c.get("auth");
11 | const session = await auth.api.getSession({ headers: c.req.raw.headers });
12 |
13 | if (!session) {
14 | c.set("user", null);
15 | c.set("session", null);
16 | return next();
17 | }
18 |
19 | c.set("user", session.user);
20 | c.set("session", session.session);
21 | return next();
22 | };
23 |
24 | export const authGuard =
25 | (
26 | role: "user" | "admin",
27 | ): MiddlewareHandler<{
28 | Variables: {
29 | user: Session["user"];
30 | session: Session["session"];
31 | };
32 | }> =>
33 | async (c, next) => {
34 | const user = c.var.user;
35 | if (!user) {
36 | return c.json({ error: "Unauthorized" }, 401);
37 | }
38 |
39 | if (role === "admin" && user.role !== "admin") {
40 | return c.json({ error: "Forbidden" }, 403);
41 | }
42 |
43 | return next();
44 | };
45 |
--------------------------------------------------------------------------------
/packages/backend/server/src/middleware/conn-info.ts:
--------------------------------------------------------------------------------
1 | import { getConnInfo } from "@hono/node-server/conninfo";
2 | import type { Context, MiddlewareHandler, Next } from "hono";
3 | import type { ConnInfo } from "hono/conninfo";
4 |
5 | const connInfoMiddleware: MiddlewareHandler<{
6 | Variables: { connInfo: ConnInfo & { ip: string } };
7 | }> = async (c: Context, next: Next) => {
8 | const connInfo = getConnInfo(c);
9 | const ip =
10 | c.req?.header("cf-connecting-ip") ||
11 | // c.req?.header("x-real-ip") || // traefik get bad on this header
12 | c.req
13 | ?.header("x-forwarded-for")
14 | ?.split(",")[0]
15 | .trim() ||
16 | c.req?.header("x-forwarded-for");
17 | connInfo.remote.address?.replace("::ffff:", "") || "127.0.0.1";
18 | c.set("connInfo", {
19 | ip,
20 | remote: connInfo.remote,
21 | });
22 | await next();
23 | };
24 |
25 | export default connInfoMiddleware;
26 |
--------------------------------------------------------------------------------
/packages/backend/server/src/middleware/logger.ts:
--------------------------------------------------------------------------------
1 | import { getLogger } from "@server/lib/plugins/winston";
2 | import type { Context, MiddlewareHandler, Next } from "hono";
3 | import type { Logger } from "winston";
4 |
5 | const logger = getLogger("server");
6 |
7 | const loggerMiddleware: MiddlewareHandler<{
8 | Variables: { logger: Logger };
9 | }> = async (c: Context, next: Next) => {
10 | const start = Date.now();
11 |
12 | c.set("logger", logger);
13 | logger.initMeta({
14 | requestId: c.get("requestId"),
15 | method: c.req.method,
16 | path: c.req.path,
17 | });
18 |
19 | await next();
20 | const duration = Date.now() - start;
21 |
22 | if (c.error) {
23 | // logger.error("Request failed", {
24 | // error: error instanceof Error ? error.defaultMessage : "Unknown error",
25 | // stack: error instanceof Error ? error.stack : "Unknown stack",
26 | // });
27 | } else {
28 | logger.info("Request succeeded", {
29 | status: c.res.status,
30 | duration,
31 | });
32 | }
33 | };
34 |
35 | export default loggerMiddleware;
36 |
--------------------------------------------------------------------------------
/packages/backend/server/src/middleware/provider-registry.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AprilNEA/AChat/bd7e585c4d5d4773cbf80aae87dcfcec94a7d505/packages/backend/server/src/middleware/provider-registry.ts
--------------------------------------------------------------------------------
/packages/backend/server/src/routes/admin/index.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from "hono";
2 |
3 | import model from "./model";
4 |
5 | const app = new Hono()
6 | .route("/", model);
7 |
8 | export default app;
9 |
--------------------------------------------------------------------------------
/packages/backend/server/src/routes/admin/model.ts:
--------------------------------------------------------------------------------
1 | import BizError, { BizCodeEnum } from "@achat/error/biz";
2 | import { appFactory } from "@server/factory";
3 | import { authGuard } from "@server/middleware/auth";
4 | import { describeRoute } from "hono-openapi";
5 |
6 | const app = appFactory
7 | .createApp()
8 | .use(authGuard("admin"))
9 | .post(
10 | "/provider",
11 | describeRoute({
12 | description: "Install a model provider in the organization",
13 | responses: {
14 | 200: {
15 | description: "Successful response",
16 | content: {
17 | "application/json": {},
18 | },
19 | },
20 | },
21 | }),
22 | async (c) => {
23 | const db = c.get("db");
24 | const user = c.get("user");
25 | const session = c.get("session");
26 | if (!session.activeOrganizationId) {
27 | throw new BizError(BizCodeEnum.OrganizationNotFound);
28 | }
29 | }
30 | )
31 | .get(
32 | "/provider",
33 | describeRoute({
34 | description: "Get all model providers",
35 | responses: {
36 | 200: {
37 | description: "Successful response",
38 | content: {
39 | "text/plain": {
40 | schema: {
41 | type: "string",
42 | example: "ok",
43 | },
44 | },
45 | "application/json": {
46 | schema: {
47 | type: "object",
48 | properties: {
49 | status: { type: "string", example: "ok" },
50 | },
51 | },
52 | },
53 | },
54 | },
55 | },
56 | }),
57 | async (c) => {
58 | const db = c.get("db");
59 | const user = c.get("user");
60 | const session = c.get("session");
61 | if (!session.activeOrganizationId) {
62 | throw new BizError(BizCodeEnum.OrganizationNotFound);
63 | }
64 | const providers = await db.query.provider.findMany({
65 | where: (t, { and, eq }) =>
66 | and(
67 | session.activeOrganizationId
68 | ? eq(t.organizationId, session.activeOrganizationId)
69 | : undefined,
70 | eq(t.isValid, true)
71 | ),
72 | });
73 | return c.json(providers);
74 | }
75 | )
76 | .post(
77 | "/provider",
78 | describeRoute({
79 | description: "Update a model provider in the organization",
80 | responses: {
81 | 200: {
82 | description: "Successful response",
83 | content: {
84 | "application/json": {},
85 | },
86 | },
87 | },
88 | }),
89 | async (c) => {
90 | const db = c.get("db");
91 | const user = c.get("user");
92 | const session = c.get("session");
93 | if (!session.activeOrganizationId) {
94 | throw new BizError(BizCodeEnum.OrganizationNotFound);
95 | }
96 | }
97 | );
98 |
99 | export default app;
100 |
--------------------------------------------------------------------------------
/packages/backend/server/src/routes/system/health.ts:
--------------------------------------------------------------------------------
1 | import { describeRoute } from "hono-openapi";
2 | import { accepts } from "hono/accepts";
3 | import { Hono } from "hono";
4 |
5 | const app = new Hono()
6 | .use(
7 | describeRoute({
8 | tags: ["Health"],
9 | })
10 | )
11 | .get(
12 | "/health",
13 | describeRoute({
14 | description:
15 | "Health check endpoint that returns the system status in either plain text or JSON format",
16 |
17 | responses: {
18 | 200: {
19 | description: "Successful response",
20 | content: {
21 | "text/plain": {
22 | schema: {
23 | type: "string",
24 | example: "ok",
25 | },
26 | },
27 | "application/json": {
28 | schema: {
29 | type: "object",
30 | properties: {
31 | status: { type: "string", example: "ok" },
32 | },
33 | },
34 | },
35 | },
36 | },
37 | },
38 | }),
39 | (c) => {
40 | const accept = accepts(c, {
41 | header: "Accept",
42 | supports: ["text/plain", "application/json"],
43 | default: "text/plain",
44 | });
45 | if (accept === "application/json") {
46 | return c.json({
47 | status: "ok",
48 | });
49 | }
50 | return c.text("ok");
51 | }
52 | );
53 |
54 | export default app;
55 |
--------------------------------------------------------------------------------
/packages/backend/server/src/routes/system/index.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from "hono";
2 | import { describeRoute } from "hono-openapi";
3 |
4 | import health from "./health";
5 | import setup from "./setup";
6 |
7 | const app = new Hono().route("/", health).route("/setup", setup);
8 |
9 | export default app;
10 |
--------------------------------------------------------------------------------
/packages/backend/server/src/routes/system/setup.ts:
--------------------------------------------------------------------------------
1 | import { appFactory } from "@server/factory";
2 |
3 | const startTime = Date.now();
4 |
5 | const app = appFactory.createApp().get("/setup", (c) => {
6 | return c.json({
7 | status: "ok",
8 | uptime: Date.now() - startTime,
9 | connInfo: { ip: c.get("connInfo").ip },
10 | });
11 | });
12 |
13 | export default app;
14 |
--------------------------------------------------------------------------------
/packages/backend/server/src/routes/system/webhook.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AprilNEA/AChat/bd7e585c4d5d4773cbf80aae87dcfcec94a7d505/packages/backend/server/src/routes/system/webhook.ts
--------------------------------------------------------------------------------
/packages/backend/server/src/routes/user/index.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from "hono";
2 | import { describeRoute } from "hono-openapi";
3 |
4 | import chat from "./chat";
5 |
6 | const app = new Hono()
7 | .use(
8 | describeRoute({
9 | tags: ["User"],
10 | })
11 | )
12 | .route("/chat", chat);
13 |
14 | export default app;
15 |
--------------------------------------------------------------------------------
/packages/backend/server/test/setup.ts:
--------------------------------------------------------------------------------
1 | import { PGlite } from "@electric-sql/pglite";
2 | import { drizzle } from "drizzle-orm/pglite";
3 | import RedisMock from "ioredis-mock";
4 | import { afterAll, afterEach, beforeEach, vi } from "vitest";
5 | import * as schema from "@achat/database/schema";
6 |
7 | vi.mock("ioredis", () => ({
8 | Redis: RedisMock,
9 | }));
10 |
11 | vi.mock("@achat/database/schema", () => ({
12 | configTable: {
13 | key: "key",
14 | value: "value",
15 | },
16 | }));
17 |
18 | vi.mock("@server/lib/db", async (importOriginal) => {
19 | const client = new PGlite();
20 | const db = drizzle(client, { schema });
21 | return {
22 | ...(await importOriginal()),
23 | db,
24 | client,
25 | };
26 | });
27 |
28 | // Apply migrations before each test
29 | beforeEach(async () => {
30 | // await applyMigrations();
31 | });
32 |
33 | // Clean up the database after each test
34 | afterEach(async () => {
35 | // await db.execute(sql`drop schema if exists public cascade`);
36 | // await db.execute(sql`create schema public`);
37 | // await db.execute(sql`drop schema if exists drizzle cascade`);
38 | });
39 |
40 | // Free up resources after all tests are done
41 | afterAll(async () => {
42 | // client.close();
43 | });
--------------------------------------------------------------------------------
/packages/backend/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "esnext",
5 | "moduleResolution": "bundler",
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "verbatimModuleSyntax": true,
9 | "types": ["node"],
10 | "jsx": "react-jsx",
11 | "jsxImportSource": "react",
12 | "baseUrl": ".",
13 | "paths": {
14 | "@server/*": ["./src/*"]
15 | }
16 | },
17 | "references": [
18 | {
19 | "path": "../database"
20 | },
21 | {
22 | "path": "../../common/error"
23 | }
24 | ],
25 | "include": ["./src/**/*", "./test/**/*"]
26 | }
27 |
--------------------------------------------------------------------------------
/packages/backend/server/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config";
2 |
3 | export default defineConfig({
4 | test: {
5 | // setupFiles: "./test/setup.ts",
6 | globals: true, // 使测试环境支持 `describe`, `it`, `expect` 等全局函数
7 | environment: "node", // 使用 Node 环境进行测试
8 | },
9 | });
10 |
--------------------------------------------------------------------------------
/packages/common/error/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@achat/error",
3 | "version": "4.0.0",
4 | "private": true,
5 | "type": "module",
6 | "exports": {
7 | ".": "./src/index.ts",
8 | "./biz": "./src/biz.ts"
9 | },
10 | "devDependencies": {
11 | "@achat/tsconfig": "workspace:*"
12 | }
13 | }
--------------------------------------------------------------------------------
/packages/common/error/src/biz.ts:
--------------------------------------------------------------------------------
1 | export const BizCodeEnum = {
2 | // General
3 | InvalidRequest: "INVALID_REQUEST",
4 | // Authentication
5 | AuthFailed: "AUTH_FAILED",
6 | InvalidPassword: "INVALID_PASSWORD",
7 | InvalidVerificationCode: "INVALID_VERIFICATION_CODE",
8 | InvalidVerificationToken: "INVALID_VERIFICATION_TOKEN",
9 | VerifyCodeTooFrequently: "VERIFY_CODE_TOO_FREQUENTLY",
10 | VerifyCodeSendFailed: "VERIFY_CODE_SEND_FAILED",
11 | EmailAlreadyUsed: "EMAIL_ALREADY_USED",
12 | RegisterFailed: "REGISTER_FAILED",
13 | // User
14 | UserNotFound: "USER_NOT_FOUND",
15 | OrganizationNotFound: "ORGANIZATION_NOT_FOUND",
16 | } as const;
17 |
18 | export type BizCode = (typeof BizCodeEnum)[keyof typeof BizCodeEnum];
19 |
20 | export default class BizError extends Error {
21 | name = "BizError";
22 |
23 | public readonly code: BizCode;
24 | public readonly statusCode: number;
25 | public readonly message: string;
26 |
27 | constructor(code: BizCode) {
28 | const [statusCode, message] = BizErrorEnum[code];
29 | super(message);
30 | this.code = code;
31 | this.statusCode = statusCode;
32 | this.message = message;
33 | }
34 | }
35 |
36 | export const BizErrorEnum: Record<
37 | BizCode,
38 | [statusCode: number, message: string]
39 | > = {
40 | [BizCodeEnum.InvalidRequest]: [400, "Invalid request"],
41 | [BizCodeEnum.AuthFailed]: [401, "Authentication failed"],
42 | [BizCodeEnum.InvalidPassword]: [401, "Invalid password"],
43 | [BizCodeEnum.InvalidVerificationCode]: [401, "Invalid verification code"],
44 | [BizCodeEnum.InvalidVerificationToken]: [401, "Invalid verification token"],
45 | [BizCodeEnum.VerifyCodeTooFrequently]: [429, "Verify code too frequently"],
46 | [BizCodeEnum.VerifyCodeSendFailed]: [500, "Verify code send failed"],
47 | [BizCodeEnum.EmailAlreadyUsed]: [400, "Email already used"],
48 | [BizCodeEnum.RegisterFailed]: [400, "Register failed"],
49 | [BizCodeEnum.UserNotFound]: [404, "User not found"],
50 | [BizCodeEnum.OrganizationNotFound]: [404, "Organization not found"],
51 | } as const;
52 |
--------------------------------------------------------------------------------
/packages/common/error/src/index.ts:
--------------------------------------------------------------------------------
1 | import type BizError from "./biz";
2 | import type { BizCode } from "./biz";
3 |
4 | export type ErrorType = "BIZ" | "DATABASE" | "NETWORK" | "UNKNOWN";
5 |
6 | export interface UserFriendlyErrorResponse {
7 | status: number;
8 | name: string;
9 | type: ErrorType;
10 | code: BizCode;
11 | message: string;
12 | // data?: any;
13 | // stacktrace?: string;
14 | }
15 |
16 | export class UserFriendlyError
17 | extends Error
18 | implements UserFriendlyErrorResponse
19 | {
20 | readonly status;
21 | readonly code;
22 | readonly type;
23 | override readonly name;
24 | override readonly message;
25 | // readonly data = this.response.data;
26 | // readonly stacktrace = this.response.stacktrace;
27 |
28 | constructor(private readonly response: UserFriendlyErrorResponse) {
29 | super(response.message);
30 | this.status = response.status;
31 | this.code = response.code;
32 | this.type = response.type;
33 | this.message = response.message;
34 | }
35 |
36 | static fromBiz(biz: BizError) {
37 | return new UserFriendlyError({
38 | status: biz.statusCode,
39 | type: "BIZ",
40 | name: biz.name,
41 | code: biz.code,
42 | message: biz.message,
43 | });
44 | }
45 |
46 | // static fromAny(anything: any) {
47 | // if (anything instanceof UserFriendlyError) {
48 | // return anything;
49 | // }
50 |
51 | // switch (typeof anything) {
52 | // case "string":
53 | // return UnknownError(anything);
54 | // case "object": {
55 | // if (anything) {
56 | // if (anything instanceof GraphQLError) {
57 | // return new UserFriendlyError(anything.extensions);
58 | // } else if (anything.type && anything.name && anything.message) {
59 | // return new UserFriendlyError(anything);
60 | // } else if (anything.message) {
61 | // return UnknownError(anything.message);
62 | // }
63 | // }
64 | // }
65 | // }
66 |
67 | // return UnknownError("Unhandled error raised. Please contact us for help.");
68 | // }
69 |
70 | is(name: BizErrorName) {
71 | return this.name === name;
72 | }
73 |
74 | isStatus(status: number) {
75 | return this.status === status;
76 | }
77 |
78 | static isNetworkError(error: UserFriendlyError) {
79 | return error.name === "NETWORK_ERROR";
80 | }
81 |
82 | static notNetworkError(error: UserFriendlyError) {
83 | return !UserFriendlyError.isNetworkError(error);
84 | }
85 |
86 | isNetworkError() {
87 | return UserFriendlyError.isNetworkError(this);
88 | }
89 |
90 | notNetworkError() {
91 | return UserFriendlyError.notNetworkError(this);
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/packages/common/error/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@achat/tsconfig/node-library.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "outDir": "./dist",
6 | // "baseUrl": "./src",
7 | "tsBuildInfoFile": "./dist/.tsbuildinfo"
8 | },
9 | "include": ["./src/**/*"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/common/tsconfig/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Default",
4 | "compilerOptions": {
5 | "alwaysStrict": true,
6 | "composite": false,
7 | "declaration": true,
8 | "declarationMap": true,
9 | "esModuleInterop": true,
10 | "allowSyntheticDefaultImports": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "inlineSources": false,
13 | "isolatedModules": true,
14 | "moduleResolution": "bundler",
15 | "noUnusedLocals": false,
16 | "noUnusedParameters": false,
17 | "preserveWatchOutput": true,
18 | "skipLibCheck": true,
19 | "strict": true,
20 | "strictNullChecks": true,
21 | "target": "esnext"
22 | },
23 | "include": ["**/*.ts", "**/*.tsx", "**/.d.ts"],
24 | "exclude": ["node_modules"]
25 | }
26 |
--------------------------------------------------------------------------------
/packages/common/tsconfig/nextjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "noEmit": true,
13 | "esModuleInterop": true,
14 | "module": "esnext",
15 | "moduleResolution": "bundler",
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "jsx": "preserve",
19 | "incremental": true,
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ],
25 | "paths": {
26 | "@/*": [
27 | "./src/*"
28 | ]
29 | }
30 | },
31 | "include": [
32 | "**/*.ts",
33 | "**/*.tsx",
34 | ".next/types/**/*.ts1",
35 | "next-env.d.ts",
36 | ".next/types/**/*.ts"
37 | ],
38 | "exclude": [
39 | "node_modules"
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/packages/common/tsconfig/node-library.json:
--------------------------------------------------------------------------------
1 | {
2 | "display": "Node Library",
3 | "extends": "./base.json",
4 | "compilerOptions": {
5 | "target": "esnext",
6 | "module": "esnext",
7 | "moduleResolution": "bundler",
8 | "skipLibCheck": true,
9 | "strict": true,
10 | "verbatimModuleSyntax": true,
11 | "outDir": "./dist",
12 | "declaration": true,
13 | "declarationMap": true,
14 | "types": ["node"]
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/common/tsconfig/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@achat/tsconfig",
3 | "private": true,
4 | "scripts": {
5 | "clean": "rimraf node_modules"
6 | },
7 | "files": [
8 | "base.json",
9 | "nextjs.json",
10 | "react-library.json"
11 | ]
12 | }
--------------------------------------------------------------------------------
/packages/common/tsconfig/react-library.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "React Library",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "jsx": "react-jsx",
7 | "lib": ["dom", "dom.iterable", "esnext"],
8 | "target": "esnext",
9 | "module": "esnext",
10 | "noEmit": true,
11 | "declaration": true,
12 | "declarationMap": true,
13 | "resolveJsonModule": true
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/common/tsconfig/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./base.json",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "allowUnreachableCode": true
6 | },
7 | "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "**/*.json"],
8 | "exclude": ["dist", "build", "node_modules"]
9 | }
10 |
--------------------------------------------------------------------------------
/packages/frontend/email/.gitignore:
--------------------------------------------------------------------------------
1 | out
2 | .react-email
--------------------------------------------------------------------------------
/packages/frontend/email/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@achat/email",
3 | "type": "module",
4 | "private": true,
5 | "main": "./dist/index.js",
6 | "module": "./dist/index.js",
7 | "types": "./dist/index.d.ts",
8 | "exports": {
9 | "./template": {
10 | "import": {
11 | "types": "./dist/template.d.ts",
12 | "default": "./dist/template.js"
13 | },
14 | "require": {
15 | "types": "./dist/template.d.cts",
16 | "default": "./dist/template.cjs"
17 | }
18 | }
19 | },
20 | "scripts": {
21 | "dev": "bunchee -w",
22 | "build": "bunchee",
23 | "email:build": "email build",
24 | "email:dev": "email dev --dir ./src/template --port 3002",
25 | "export": "email export --dir ./src/template"
26 | },
27 | "dependencies": {
28 | "@react-email/components": "0.0.25",
29 | "@react-email/render": "1.0.1",
30 | "@types/nodemailer": "^6.4.17",
31 | "nodemailer": "^6.10.1",
32 | "react": "*",
33 | "react-dom": "*",
34 | "react-email": "3.0.1",
35 | "resend": "^4.1.2",
36 | "zod": "*"
37 | },
38 | "devDependencies": {
39 | "@achat/tsconfig": "workspace:*",
40 | "@types/react": "*",
41 | "@types/react-dom": "*"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/_components/button.tsx:
--------------------------------------------------------------------------------
1 | import { Button Section } from "@react-email/components";
2 |
3 | export const RowButton = ({
4 | href,
5 | children,
6 | }: { href: string; children: React.ReactNode }) => {
7 | return (
8 |
9 |
10 | {children}
11 |
12 |
13 | );
14 | };
15 |
16 | const buttonContainer = {
17 | textAlign: "center" as const,
18 | margin: "30px 0",
19 | };
20 |
21 | const button = {
22 | padding: "10px 20px",
23 | backgroundColor: "#3b82f6",
24 | borderRadius: "5px",
25 | color: "#fff",
26 | display: "inline-block",
27 | fontSize: "16px",
28 | fontWeight: "600",
29 | textDecoration: "none",
30 | textAlign: "center" as const,
31 | width: "auto",
32 | };
33 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/_components/layout.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Container,
4 | Head,
5 | Heading,
6 | Hr,
7 | Html,
8 | Img,
9 | Link,
10 | Preview,
11 | Tailwind,
12 | Text,
13 | } from "@react-email/components";
14 |
15 | export interface LayoutProps {
16 | siteName?: string;
17 | siteLogoUrl?: string;
18 | siteEmail?: string;
19 | siteSupportUrl?: string;
20 |
21 | title?: string;
22 | previewText?: string;
23 | }
24 |
25 | export const LayoutPreviewProps = {
26 | siteName: "Achat",
27 | siteLogoUrl: "https://achat.dev/logo.svg",
28 | siteEmail: "support@achat.dev",
29 | siteSupportUrl: "https://achat.dev",
30 | } satisfies LayoutProps;
31 |
32 | const Layout = ({
33 | siteName,
34 | siteLogoUrl,
35 | siteEmail,
36 | siteSupportUrl,
37 |
38 | title,
39 | children,
40 | previewText,
41 | }: LayoutProps & { children: React.ReactNode }) => {
42 | const preview = previewText ?? title;
43 | return (
44 |
45 |
46 | {preview && {`[${siteName}] ${preview}`} }
47 |
48 |
49 |
50 |
51 | {title && {title} }
52 | {children}
53 |
54 |
55 | 此邮件由系统自动发送,请勿直接回复。
56 | {siteSupportUrl && (
57 | <>
58 | 如需更多帮助,请访问我们的{" "}
59 |
60 | 帮助中心
61 |
62 | 。
63 | >
64 | )}
65 |
66 | {siteName && (
67 |
68 | © {new Date().getFullYear()} {siteName}. 保留所有权利。
69 |
70 | )}
71 |
72 |
73 |
74 |
75 | );
76 | };
77 |
78 | const main = {
79 | backgroundColor: "#f6f9fc",
80 | fontFamily:
81 | '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif',
82 | padding: "40px 0",
83 | };
84 |
85 | const container = {
86 | backgroundColor: "#ffffff",
87 | border: "1px solid #eee",
88 | borderRadius: "5px",
89 | boxShadow: "0 5px 10px rgba(20, 50, 70, 0.1)",
90 | margin: "0 auto",
91 | maxWidth: "600px",
92 | padding: "20px",
93 | };
94 |
95 | const hr = {
96 | borderColor: "#e5e7eb",
97 | margin: "30px 0",
98 | };
99 |
100 | const logo = {
101 | margin: "0 auto 20px",
102 | display: "block",
103 | };
104 |
105 | const h1 = {
106 | color: "#1f2937",
107 | fontSize: "24px",
108 | fontWeight: "600",
109 | lineHeight: "1.4",
110 | margin: "30px 0",
111 | padding: "0",
112 | textAlign: "center" as const,
113 | };
114 |
115 | const footer = {
116 | color: "#6b7280",
117 | fontSize: "14px",
118 | lineHeight: "24px",
119 | textAlign: "center" as const,
120 | margin: "12px 0",
121 | };
122 |
123 | const link = {
124 | color: "#3b82f6",
125 | textDecoration: "underline",
126 | };
127 |
128 | export default Layout;
129 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/_components/text.tsx:
--------------------------------------------------------------------------------
1 | import { Text } from "@react-email/components";
2 |
3 | export const BaseText = ({
4 | children,
5 | style,
6 | }: {
7 | children: React.ReactNode;
8 | style?: React.CSSProperties;
9 | }) => {
10 | return {children} ;
11 | };
12 |
13 | const text = {
14 | color: "#4b5563",
15 | fontSize: "16px",
16 | lineHeight: "1.6",
17 | margin: "16px 0",
18 | };
19 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./mailer";
2 | export * from "./template";
3 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/mailer.ts:
--------------------------------------------------------------------------------
1 | import { render } from "@react-email/render";
2 | import React from "react";
3 | import { Resend } from "resend";
4 | import { z } from "zod";
5 | import type { LayoutProps } from "./_components/layout";
6 |
7 | export const EmailConfig = z
8 | .object({
9 | transport: z.enum(["hosting", "resend", "plunk", "smtp"]),
10 | sender: z.string().optional(),
11 | resend: z
12 | .object({
13 | apiKey: z.string(),
14 | })
15 | .optional(),
16 | plunk: z
17 | .object({
18 | apiKey: z.string(),
19 | })
20 | .optional(),
21 | smtp: z
22 | .object({
23 | host: z.string(),
24 | port: z.number(),
25 | })
26 | .optional(),
27 | })
28 | .default({
29 | transport: "hosting",
30 | });
31 |
32 | export type EmailConfig = z.infer;
33 |
34 | export type TransportPayload = {
35 | to: string[];
36 | name?: string;
37 | subject: string;
38 | html?: string;
39 | text?: string;
40 | };
41 | export type Transporter = (data: TransportPayload) => Promise;
42 |
43 | export class Emailer {
44 | private transporter: Transporter;
45 |
46 | constructor(
47 | private config: EmailConfig,
48 | private layout: Omit,
49 | ) {
50 | if (config.transport === "resend") {
51 | if (!config.resend || !config.sender) {
52 | throw new Error("Resend is not enabled");
53 | }
54 | const resend = new Resend(config.resend.apiKey);
55 | this.transporter = ({ to, subject, html, text }) =>
56 | resend.emails.send({
57 | from: config.sender as string,
58 | to: to,
59 | subject: subject,
60 | html,
61 | text: text || subject,
62 | });
63 | } else if (config.transport === "plunk") {
64 | if (!config.plunk) {
65 | throw new Error("Plunk is not enabled");
66 | }
67 | this.transporter = async ({ name, to, subject, html, text }) =>
68 | fetch("https://api.useplunk.com/v1/send", {
69 | method: "POST",
70 | headers: {
71 | "Content-Type": "application/json",
72 | Authorization: `Bearer ${config.plunk?.apiKey}`,
73 | },
74 | body: JSON.stringify({
75 | to: to,
76 | subject: subject,
77 | body: html || text || subject,
78 | subscribed: true,
79 | name: name,
80 | from: config.sender,
81 | }),
82 | }).then((response) => response.json());
83 | } else {
84 | throw new Error("Invalid transport");
85 | }
86 | }
87 |
88 | transport(payload: TransportPayload) {
89 | return this.transporter(payload);
90 | }
91 |
92 | render(element: React.ReactElement) {
93 | if (!element || typeof element !== "object" || !element.props) {
94 | throw new Error(
95 | "Invalid React element provided to renderEmailWithModifiedProps",
96 | );
97 | }
98 | const tenantElement = React.cloneElement(element, {
99 | ...element.props,
100 | ...this.layout,
101 | });
102 | return render(tenantElement);
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/template.ts:
--------------------------------------------------------------------------------
1 | export * from "./template/verification/email-code";
2 |
3 | export * from "./template/transactional/order-cancelled";
4 | export * from "./template/transactional/order-confirmed";
5 | // export * from "./template/transactional/order-receipt";
6 | export * from "./template/transactional/ticket-created";
7 | export * from "./template/transactional/ticket-reply";
8 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/transactional/email-code.tsx:
--------------------------------------------------------------------------------
1 | import { Section, Text } from "@react-email/components";
2 | import Layout, { type LayoutProps } from "../_components/layout";
3 |
4 | interface EmailVerificationCodeProps
5 | extends Omit {
6 | email: string;
7 | otp: string;
8 | }
9 |
10 | export const EmailVerificationCode = ({
11 | email,
12 | otp,
13 | ...props
14 | }: EmailVerificationCodeProps) => {
15 | const previewText = `您的验证码是 ${otp}`;
16 |
17 | return (
18 |
19 |
20 | 尊敬的 {email} ,
21 |
22 |
23 | 您的验证码是
24 |
25 |
26 |
27 | {otp}
28 |
29 |
30 |
31 | );
32 | };
33 |
34 | EmailVerificationCode.PreviewProps = {
35 | email: "alanturing@gmail.com",
36 | otp: "123456",
37 | } as EmailVerificationCodeProps;
38 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/transactional/email-verification-link.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Button,
4 | CodeInline,
5 | Container,
6 | Head,
7 | Hr,
8 | Html,
9 | Link,
10 | Preview,
11 | Section,
12 | Tailwind,
13 | Text,
14 | } from "@react-email/components";
15 | import * as React from "react";
16 |
17 | interface EmailVerificationEmailProps {
18 | username?: string;
19 | inviteLink?: string;
20 | }
21 |
22 | export const EmailVerificationEmail = ({
23 | username,
24 | inviteLink,
25 | }: EmailVerificationEmailProps) => {
26 | const previewText = `验证 ${username} 的电子邮箱地址`;
27 |
28 | return (
29 |
30 |
31 | {previewText}
32 |
33 |
34 |
35 |
36 | 尊敬的 {username} ,
37 |
38 |
39 | 感谢您选择!
40 |
41 | 请点击下方按钮或链接激活您的账户!
42 |
43 |
51 |
52 | 或将此 URL 复制并粘贴到浏览器中:
53 |
54 | {inviteLink}
55 |
56 |
57 |
58 |
59 | 请勿直接回复此邮件。
60 |
61 | 如有疑问,请通过 support@nohara.cloud 联系我们。
62 |
63 | Nohara 团队敬上
64 |
65 |
66 |
67 |
68 |
69 | );
70 | };
71 |
72 | EmailVerificationEmail.PreviewProps = {
73 | username: "alanturing",
74 | inviteLink: "https://vercel.com/teams/invite/foo",
75 | } as EmailVerificationEmailProps;
76 |
77 | export default EmailVerificationEmail;
78 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/transactional/order-confirmed.tsx:
--------------------------------------------------------------------------------
1 | import { Section, Text } from "@react-email/components";
2 | import Layout, {
3 | LayoutPreviewProps,
4 | type LayoutProps,
5 | } from "../_components/layout";
6 | import { type Order, OrderItem } from "../_components/order";
7 | import { BaseText } from "../_components/text";
8 |
9 | interface OrderConfirmedProps extends LayoutProps {
10 | customerName: string;
11 | order: Order;
12 | }
13 |
14 | export const OrderConfirmedEmail = ({
15 | customerName,
16 | order,
17 | ...props
18 | }: OrderConfirmedProps) => {
19 | return (
20 |
21 |
22 | 尊敬的 {customerName} ,
23 |
24 |
25 | 感谢您选择野原云,这是您于
26 | {order.date.toLocaleDateString("zh-CN", {
27 | year: "numeric",
28 | month: "long",
29 | day: "numeric",
30 | })}
31 | 的订单确认信息。
32 |
33 |
34 |
35 |
36 | {"请您尽快完成支付以确保订单生效。未支付的订单将在30分钟内自动取消。"}
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | OrderConfirmedEmail.PreviewProps = {
44 | customerName: "alanturing",
45 | order: {
46 | id: "0194f8aa-cada-7d24-bf88-5299a548586b",
47 | name: "野原云基础版",
48 | amount: 99,
49 | method: "支付宝",
50 | date: new Date("2024-12-25"),
51 | },
52 | ...LayoutPreviewProps,
53 | } as OrderConfirmedProps;
54 |
55 | export default OrderConfirmedEmail;
56 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/transactional/order-receipt.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource react */
2 | import { Section, Text } from "@react-email/components";
3 | import Layout, {
4 | LayoutPreviewProps,
5 | type LayoutProps,
6 | } from "../_components/layout";
7 | import { type Order, OrderItem } from "../_components/order";
8 | import { BaseText } from "../_components/text";
9 |
10 | interface OrderReceiptProps extends LayoutProps {
11 | customerName: string;
12 | order: Order;
13 | }
14 |
15 | export const OrderReceiptEmail = ({
16 | customerName,
17 | order,
18 | ...props
19 | }: OrderReceiptProps) => {
20 | return (
21 |
22 |
23 | 尊敬的 {customerName} ,
24 |
25 |
26 | 感谢您的付款,这是您于
27 | {order.date.toLocaleDateString("zh-CN", {
28 | year: "numeric",
29 | month: "long",
30 | day: "numeric",
31 | })}
32 | 的野原云付款收据。
33 |
34 |
35 |
36 |
37 | {"请您参阅随附的电子发票并保存以备将来参考"}
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | OrderReceiptEmail.PreviewProps = {
45 | customerName: "alanturing",
46 | order: {
47 | id: "0194f8aa-cada-7d24-bf88-5299a548586b",
48 | name: "野原云基础版",
49 | amount: 99,
50 | method: "支付宝",
51 | date: new Date("2024-12-25"),
52 | },
53 | ...LayoutPreviewProps,
54 | } as OrderReceiptProps;
55 |
56 | export default OrderReceiptEmail;
57 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/transactional/ticket-created.tsx:
--------------------------------------------------------------------------------
1 | import { Section, Text } from "@react-email/components";
2 | import { RowButton } from "../_components/button";
3 | import Layout, {
4 | LayoutPreviewProps,
5 | type LayoutProps,
6 | } from "../_components/layout";
7 | import { BaseText } from "../_components/text";
8 |
9 | interface TicketCreatedEmailProps extends LayoutProps {
10 | customerName: string;
11 | ticketNumber: string;
12 | ticketSubject: string;
13 | ticketUrl: string;
14 | }
15 |
16 | export const TicketCreatedEmail = ({
17 | ticketNumber,
18 | customerName,
19 | ticketSubject,
20 | ticketUrl,
21 | ...props
22 | }: TicketCreatedEmailProps) => {
23 | return (
24 |
25 | {customerName},您好!
26 |
27 | 感谢您提交工单。您的工单 {ticketNumber} {" "}
28 | 已成功创建,我们的客服团队将尽快处理您的请求。
29 |
30 |
31 | 工单详情:
32 | 工单编号:{ticketNumber}
33 | 主题:{ticketSubject}
34 | 状态:等待处理
35 |
36 | 查看工单详情
37 | 我们的客服团队会尽快回复您的工单。
38 |
39 | );
40 | };
41 |
42 | const ticketInfoSection = {
43 | backgroundColor: "#f9fafb",
44 | borderRadius: "5px",
45 | padding: "15px",
46 | margin: "20px 0",
47 | };
48 |
49 | const ticketInfoTitle = {
50 | color: "#1f2937",
51 | fontSize: "16px",
52 | fontWeight: "600",
53 | margin: "0 0 10px",
54 | };
55 |
56 | const ticketInfoText = {
57 | color: "#4b5563",
58 | fontSize: "15px",
59 | margin: "5px 0",
60 | lineHeight: "1.4",
61 | };
62 |
63 | TicketCreatedEmail.PreviewProps = {
64 | ticketNumber: "TK-12345",
65 | customerName: "尊敬的客户",
66 | ticketSubject: "产品使用问题",
67 | ticketUrl: "https://nohara.cloud/tickets/TK-12345",
68 | ...LayoutPreviewProps,
69 | } as TicketCreatedEmailProps;
70 |
71 | export default TicketCreatedEmail;
72 |
--------------------------------------------------------------------------------
/packages/frontend/email/src/transactional/ticket-reply.tsx:
--------------------------------------------------------------------------------
1 | import { Section, Text } from "@react-email/components";
2 | import { RowButton } from "../_components/button";
3 | import Layout, {
4 | LayoutPreviewProps,
5 | type LayoutProps,
6 | } from "../_components/layout";
7 | import { BaseText } from "../_components/text";
8 |
9 | interface TicketReplyEmailProps extends LayoutProps {
10 | ticketNumber: string;
11 | customerName: string;
12 | ticketSubject: string;
13 | replyPreview: string;
14 | replierName: string;
15 | ticketUrl: string;
16 | }
17 |
18 | export const TicketReplyEmail = ({
19 | ticketNumber,
20 | customerName,
21 | ticketSubject,
22 | replyPreview,
23 | replierName,
24 | ticketUrl,
25 | }: TicketReplyEmailProps) => {
26 | return (
27 |
28 | {customerName},您好!
29 |
30 | 您的工单 {ticketNumber} 有新的回复。
31 |
32 |
33 | 工单详情:
34 | 工单编号:{ticketNumber}
35 | 主题:{ticketSubject}
36 | 回复人:{replierName}
37 |
38 |
39 | 回复内容预览:
40 | "{replyPreview}"
41 |
42 |
43 | 查看完整回复
44 |
45 | 您可以直接回复此邮件或点击上方按钮查看完整对话并回复。
46 |
47 |
48 | );
49 | };
50 |
51 | const ticketInfoSection = {
52 | backgroundColor: "#f9fafb",
53 | borderRadius: "5px",
54 | padding: "15px",
55 | margin: "20px 0",
56 | };
57 |
58 | const ticketInfoTitle = {
59 | color: "#1f2937",
60 | fontSize: "16px",
61 | fontWeight: "600",
62 | margin: "0 0 10px",
63 | };
64 |
65 | const ticketInfoText = {
66 | color: "#4b5563",
67 | fontSize: "15px",
68 | margin: "5px 0",
69 | lineHeight: "1.4",
70 | };
71 |
72 | const replySection = {
73 | backgroundColor: "#f0f9ff",
74 | borderRadius: "5px",
75 | borderLeft: "4px solid #3b82f6",
76 | padding: "15px",
77 | margin: "20px 0",
78 | };
79 |
80 | const replyTitle = {
81 | color: "#1f2937",
82 | fontSize: "16px",
83 | fontWeight: "600",
84 | margin: "0 0 10px",
85 | };
86 |
87 | const replyContent = {
88 | color: "#4b5563",
89 | fontSize: "15px",
90 | fontStyle: "italic",
91 | lineHeight: "1.6",
92 | margin: "0",
93 | };
94 |
95 | TicketReplyEmail.PreviewProps = {
96 | ticketNumber: "TK-12345",
97 | customerName: "尊敬的客户",
98 | ticketSubject: "产品使用问题",
99 | replyPreview:
100 | "您好,感谢您联系我们的客服团队。关于您提到的问题,我们建议您...",
101 | replierName: "客服专员",
102 | ticketUrl: "https://example.com/tickets/TK-12345",
103 | ...LayoutPreviewProps,
104 | } as TicketReplyEmailProps;
105 |
106 | export default TicketReplyEmail;
107 |
--------------------------------------------------------------------------------
/packages/frontend/email/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@achat/tsconfig/react-library.json",
3 | "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "**/*.json"],
4 | "exclude": ["dist", "build", "node_modules", "out"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/frontend/i18n/lingui.config.ts:
--------------------------------------------------------------------------------
1 | import type { LinguiConfig } from "@lingui/conf"
2 |
3 | const config: LinguiConfig = {
4 | locales: ["en", "pl"],
5 | catalogs: [
6 | {
7 | path: "src/locales/{locale}",
8 | include: ["src"],
9 | },
10 | ],
11 | }
12 |
13 | export default config
--------------------------------------------------------------------------------
/packages/frontend/i18n/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@achat/i18n",
3 | "version": "4.0.0",
4 | "type": "module",
5 | "private": true,
6 | "main": "src/index.ts",
7 | "exports": {
8 | ".": "./src/index.ts"
9 | },
10 | "scripts": {
11 | "build": "r build.ts",
12 | "dev": "r build.ts --dev"
13 | },
14 | "keywords": [],
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/AprilNEA/AChat.git"
18 | },
19 | "devDependencies": {
20 | "@lingui/swc-plugin": "^5.5.2"
21 | },
22 | "dependencies": {
23 | "@lingui/conf": "^5.3.1"
24 | }
25 | }
--------------------------------------------------------------------------------
/packages/frontend/web/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # Sentry Config File
27 | .env.sentry-build-plugin
28 |
--------------------------------------------------------------------------------
/packages/frontend/web/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13 |
14 | ```js
15 | export default tseslint.config({
16 | extends: [
17 | // Remove ...tseslint.configs.recommended and replace with this
18 | ...tseslint.configs.recommendedTypeChecked,
19 | // Alternatively, use this for stricter rules
20 | ...tseslint.configs.strictTypeChecked,
21 | // Optionally, add this for stylistic rules
22 | ...tseslint.configs.stylisticTypeChecked,
23 | ],
24 | languageOptions: {
25 | // other options...
26 | parserOptions: {
27 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
28 | tsconfigRootDir: import.meta.dirname,
29 | },
30 | },
31 | })
32 | ```
33 |
34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
35 |
36 | ```js
37 | // eslint.config.js
38 | import reactX from 'eslint-plugin-react-x'
39 | import reactDom from 'eslint-plugin-react-dom'
40 |
41 | export default tseslint.config({
42 | plugins: {
43 | // Add the react-x and react-dom plugins
44 | 'react-x': reactX,
45 | 'react-dom': reactDom,
46 | },
47 | rules: {
48 | // other rules...
49 | // Enable its recommended typescript rules
50 | ...reactX.configs['recommended-typescript'].rules,
51 | ...reactDom.configs.recommended.rules,
52 | },
53 | })
54 | ```
55 |
--------------------------------------------------------------------------------
/packages/frontend/web/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/index.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/packages/frontend/web/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/packages/frontend/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/packages/frontend/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@achat/web",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@achat/server": "workspace:*",
14 | "@radix-ui/react-dialog": "^1.1.14",
15 | "@radix-ui/react-separator": "^1.1.7",
16 | "@radix-ui/react-slot": "^1.1.2",
17 | "@radix-ui/react-tooltip": "^1.2.7",
18 | "@sentry/react": "^9.22.0",
19 | "@sentry/vite-plugin": "^3.5.0",
20 | "@tailwindcss/vite": "^4.1.7",
21 | "class-variance-authority": "^0.7.1",
22 | "clsx": "^2.1.1",
23 | "lucide-react": "^0.482.0",
24 | "react": "^19.1.0",
25 | "react-dom": "^19.1.0",
26 | "swr": "^2.3.3",
27 | "tailwind-merge": "^3.0.2",
28 | "tailwindcss": "^4",
29 | "wouter": "^3.7.0"
30 | },
31 | "devDependencies": {
32 | "@eslint/js": "^9.25.0",
33 | "@types/node": "^22.15.21",
34 | "@types/react": "^19.1.2",
35 | "@types/react-dom": "^19.1.2",
36 | "@vitejs/plugin-react-swc": "^3.9.0",
37 | "eslint": "^9.25.0",
38 | "eslint-plugin-react-hooks": "^5.2.0",
39 | "eslint-plugin-react-refresh": "^0.4.19",
40 | "globals": "^16.0.0",
41 | "tw-animate-css": "^1.3.0",
42 | "typescript": "~5.8.3",
43 | "typescript-eslint": "^8.30.1",
44 | "vite": "^6.3.5"
45 | }
46 | }
--------------------------------------------------------------------------------
/packages/frontend/web/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Route, Switch } from "wouter";
2 |
3 | const App = () => (
4 | <>
5 | Profile
6 |
7 | About Us
8 |
9 | {/*
10 | Routes below are matched exclusively -
11 | the first matched route gets rendered
12 | */}
13 |
14 | {/* */}
15 |
16 |
17 | {(params) => <>Hello, {params.name}!>}
18 |
19 |
20 | {(params) => <>Hello, {params.name}!>}
21 |
22 |
23 | {/* Default route in a switch */}
24 | 404: No such page!
25 |
26 | >
27 | );
28 |
29 | export default App
30 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<"button"> &
45 | VariantProps & {
46 | asChild?: boolean
47 | }) {
48 | const Comp = asChild ? Slot : "button"
49 |
50 | return (
51 |
56 | )
57 | }
58 |
59 | export { Button, buttonVariants }
60 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/components/ui/chat/chat-input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Textarea } from "@/components/ui/textarea";
3 | import { cn } from "@/lib/utils";
4 |
5 | interface ChatInputProps extends React.TextareaHTMLAttributes{}
6 |
7 | const ChatInput = React.forwardRef(
8 | ({ className, ...props }, ref) => (
9 |
19 | ),
20 | );
21 | ChatInput.displayName = "ChatInput";
22 |
23 | export { ChatInput };
24 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/components/ui/chat/chat-message-list.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ArrowDown } from "lucide-react";
3 | import { Button } from "@/components/ui/button";
4 | import { useAutoScroll } from "@/components/ui/chat/hooks/useAutoScroll";
5 |
6 | interface ChatMessageListProps extends React.HTMLAttributes {
7 | smooth?: boolean;
8 | }
9 |
10 | const ChatMessageList = React.forwardRef(
11 | ({ className, children, smooth = false, ...props }, _ref) => {
12 | const {
13 | scrollRef,
14 | isAtBottom,
15 | autoScrollEnabled,
16 | scrollToBottom,
17 | disableAutoScroll,
18 | } = useAutoScroll({
19 | smooth,
20 | content: children,
21 | });
22 |
23 | return (
24 |
25 |
34 |
35 | {!isAtBottom && (
36 |
{
38 | scrollToBottom();
39 | }}
40 | size="icon"
41 | variant="outline"
42 | className="absolute bottom-2 left-1/2 transform -translate-x-1/2 inline-flex rounded-full shadow-md"
43 | aria-label="Scroll to bottom"
44 | >
45 |
46 |
47 | )}
48 |
49 | );
50 | }
51 | );
52 |
53 | ChatMessageList.displayName = "ChatMessageList";
54 |
55 | export { ChatMessageList };
56 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/components/ui/chat/message-loading.tsx:
--------------------------------------------------------------------------------
1 | // @hidden
2 | export default function MessageLoading() {
3 | return (
4 |
11 |
12 |
21 |
22 |
23 |
31 |
32 |
33 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export { Input }
22 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Separator({
9 | className,
10 | orientation = "horizontal",
11 | decorative = true,
12 | ...props
13 | }: React.ComponentProps) {
14 | return (
15 |
25 | )
26 | }
27 |
28 | export { Separator }
29 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 | return (
5 |
10 | )
11 | }
12 |
13 | export { Skeleton }
14 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function TooltipProvider({
9 | delayDuration = 0,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
18 | )
19 | }
20 |
21 | function Tooltip({
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
26 |
27 |
28 | )
29 | }
30 |
31 | function TooltipTrigger({
32 | ...props
33 | }: React.ComponentProps) {
34 | return
35 | }
36 |
37 | function TooltipContent({
38 | className,
39 | sideOffset = 0,
40 | children,
41 | ...props
42 | }: React.ComponentProps) {
43 | return (
44 |
45 |
54 | {children}
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
62 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/hooks/use-mobile.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | const MOBILE_BREAKPOINT = 768
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener("change", onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => mql.removeEventListener("change", onChange)
16 | }, [])
17 |
18 | return !!isMobile
19 | }
20 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/lib/api-client.ts:
--------------------------------------------------------------------------------
1 | import type { BizCodeEnum } from "@achat/server/error";
2 | import { hcWithType } from "@achat/server/hc";
3 | import type { Schema } from "hono";
4 | import type {
5 | ClientRequest,
6 | InferRequestType,
7 | InferResponseType,
8 | } from "hono/client";
9 | import useSWR from "swr";
10 | import type { SWRConfiguration } from "swr";
11 |
12 | const apiClient = hcWithType("/api", {
13 | init: {
14 | credentials: "include",
15 | },
16 | });
17 |
18 | export default apiClient;
19 |
20 | export class BizClientError extends Error {
21 | name = "BizError";
22 |
23 | public readonly code: (typeof BizCodeEnum)[keyof typeof BizCodeEnum];
24 |
25 | constructor(
26 | code: (typeof BizCodeEnum)[keyof typeof BizCodeEnum],
27 | message: string,
28 | ) {
29 | super(message);
30 | this.code = code;
31 | }
32 | }
33 |
34 | // type RequestFn = (arg: unknown) => Promise;
35 |
36 | export function fetchWrapper(fn: F) {
37 | return async (
38 | args: string | [string | URL, InferRequestType],
39 | ): Promise> => {
40 | const res = typeof args === "string" ? await fn() : await fn(args[1]);
41 | const data = await res.json();
42 | if (!res.ok) {
43 | throw new BizClientError(data.code, data.message);
44 | }
45 | if ("code" in data && "message" in data) {
46 | throw new BizClientError(data.code, data.message);
47 | }
48 | return data as InferResponseType;
49 | };
50 | }
51 |
52 | export function mutationWrapper(fn: F) {
53 | return async (
54 | _url: string | URL,
55 | { arg }: { arg: InferRequestType },
56 | ): Promise> => {
57 | const res = await fn(arg);
58 | const data = await res.json();
59 | if (!res.ok) {
60 | throw new BizClientError(data.code, data.message);
61 | }
62 | if (res.status === 204) {
63 | return null as InferResponseType;
64 | }
65 | if ("code" in data && "message" in data) {
66 | throw new BizClientError(data.code, data.message);
67 | }
68 | return data;
69 | };
70 | }
71 |
72 | type ExtractMethod = T extends `$${infer M}`
73 | ? M extends "url"
74 | ? never
75 | : Uppercase
76 | : never;
77 | type MethodKey = keyof ClientRequest;
78 |
79 | export function useHC(
80 | cr: ClientRequest,
81 | [method, args]: [
82 | ExtractMethod>,
83 | InferRequestType[MethodKey]>,
84 | ] = [
85 | "GET" as ExtractMethod>,
86 | {} as InferRequestType[MethodKey]>,
87 | ],
88 | options?: SWRConfiguration<
89 | InferResponseType[MethodKey]>,
90 | Error
91 | >,
92 | ) {
93 | const prefixedMethod = `$${method.toLowerCase()}` as MethodKey;
94 | return useSWR([cr.$url(), args], fetchWrapper(cr[prefixedMethod]), options);
95 | }
96 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/main.tsx:
--------------------------------------------------------------------------------
1 | import * as Sentry from "@sentry/react";
2 |
3 | import { StrictMode } from "react";
4 | import { createRoot } from "react-dom/client";
5 | import "./index.css";
6 | import App from "./App.tsx";
7 |
8 | Sentry.init({
9 | dsn: process.env.SENTRY_DSN,
10 | sendDefaultPii: true,
11 | });
12 | // biome-ignore lint/style/noNonNullAssertion:
13 | createRoot(document.getElementById("root")!).render(
14 |
15 |
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/pages/auth/index.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AprilNEA/AChat/bd7e585c4d5d4773cbf80aae87dcfcec94a7d505/packages/frontend/web/src/pages/auth/index.ts
--------------------------------------------------------------------------------
/packages/frontend/web/src/pages/chat/index.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AprilNEA/AChat/bd7e585c4d5d4773cbf80aae87dcfcec94a7d505/packages/frontend/web/src/pages/chat/index.ts
--------------------------------------------------------------------------------
/packages/frontend/web/src/pages/console/layout.tsx:
--------------------------------------------------------------------------------
1 | // import { DashboardBreadcrumb } from "@/components/layout/dashboard/breadcrumb";
2 | // import { DashboardSidebar } from "@/components/layout/dashboard/sidebar";
3 | import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
4 |
5 | export default async function ConsoleLayout({
6 | children,
7 | }: {
8 | children: React.ReactNode;
9 | }) {
10 | return (
11 |
12 | {/* */}
13 |
14 | {/* */}
15 | {children}
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/packages/frontend/web/src/pages/setup.tsx:
--------------------------------------------------------------------------------
1 | export default function Setup() {
2 | return Setup
;
3 | }
--------------------------------------------------------------------------------
/packages/frontend/web/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/frontend/web/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "verbatimModuleSyntax": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "erasableSyntaxOnly": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "noUncheckedSideEffectImports": true,
25 |
26 | "baseUrl": ".",
27 | "paths": {
28 | "@/*": [
29 | "./src/*"
30 | ]
31 | }
32 | },
33 | "include": ["src"]
34 | }
35 |
--------------------------------------------------------------------------------
/packages/frontend/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ],
7 | "compilerOptions": {
8 | "baseUrl": ".",
9 | "paths": {
10 | "@/*": ["./src/*"]
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/frontend/web/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "verbatimModuleSyntax": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "erasableSyntaxOnly": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true
23 | },
24 | "include": ["vite.config.ts"]
25 | }
26 |
--------------------------------------------------------------------------------
/packages/frontend/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sentryVitePlugin } from "@sentry/vite-plugin";
2 | import path from "node:path";
3 | import tailwindcss from "@tailwindcss/vite";
4 | import { defineConfig } from "vite";
5 | import react from "@vitejs/plugin-react-swc";
6 |
7 | // https://vite.dev/config/
8 | export default defineConfig({
9 | plugins: [
10 | react(),
11 | tailwindcss(),
12 | sentryVitePlugin({
13 | org: "achat",
14 | project: "web",
15 | telemetry: false,
16 | }),
17 | ],
18 |
19 | resolve: {
20 | alias: {
21 | "@": path.resolve(__dirname, "./src"),
22 | },
23 | },
24 |
25 | build: {
26 | sourcemap: true,
27 | },
28 | });
29 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'docs'
3 | - 'packages/**/*'
--------------------------------------------------------------------------------
/prompts/summarizer.prompt.yml:
--------------------------------------------------------------------------------
1 | name: Text Summarizer
2 | description: Summarizes input text concisely
3 | model: gpt-4o-mini
4 | modelParameters:
5 | temperature: 0.5
6 | messages:
7 | - role: system
8 | content: You are a text summarizer. Your only job is to summarize text given to you.
9 | - role: user
10 | content: |
11 | Summarize the given text, beginning with "Summary -":
12 |
13 | {{input}}
14 |
15 | testData:
16 | - input: |
17 | The quick brown fox jumped over the lazy dog.
18 | The dog was too tired to react.
19 | expected: Summary - A fox jumped over a lazy, unresponsive dog.
20 | evaluators:
21 | - name: Output should start with 'Summary -'
22 | string:
23 | startsWith: 'Summary -'
24 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "globalEnv": [
4 | "DATABASE_URL"
5 | ],
6 | "tasks": {
7 | "build": {
8 | "dependsOn": ["^build"],
9 | "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
10 | },
11 | "check-types": {
12 | "dependsOn": ["^check-types"]
13 | },
14 | "dev": {
15 | "persistent": true,
16 | "cache": false
17 | },
18 | "start": {
19 | "cache": false
20 | },
21 | "clean": {
22 | "cache": false
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------