create-next-imagicma 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/template-hono/.env.example +6 -14
  4. package/template-hono/.imagicma/AGENTS.md +7 -0
  5. package/template-hono/AGENTS.md +98 -120
  6. package/template-hono/README.md +86 -118
  7. package/template-hono/client/index.html +1 -1
  8. package/template-hono/client/public/imagicma-picker-bridge.js +2 -6
  9. package/template-hono/client/public/imagicma-preview-feedback.js +0 -1
  10. package/template-hono/client/src/components/HelloClient.tsx +1 -1
  11. package/template-hono/client/src/globals.css +417 -1
  12. package/template-hono/client/src/lib/imagicma-preview-bridge.ts +1 -1
  13. package/template-hono/client/src/lib/imagicma-preview-picker.ts +28 -130
  14. package/template-hono/client/src/providers.tsx +1 -1
  15. package/template-hono/gitignore +2 -1
  16. package/template-hono/package.json +9 -9
  17. package/template-hono/pnpm-lock.yaml +646 -1173
  18. package/template-hono/server/app.ts +3 -2
  19. package/template-hono/server/controllers/greeting.controller.ts +22 -0
  20. package/template-hono/server/db/index.ts +129 -0
  21. package/template-hono/server/index.ts +3 -3
  22. package/template-hono/server/middlewares/error-handler.ts +8 -0
  23. package/template-hono/server/models/entities/message.entity.ts +13 -0
  24. package/template-hono/server/models/repositories/message.repository.ts +43 -0
  25. package/template-hono/server/models/services/greeting.service.ts +14 -0
  26. package/template-hono/server/models/types/message.ts +7 -0
  27. package/template-hono/server/routes/greeting.ts +1 -1
  28. package/template-hono/shared/constants/greeting.ts +1 -0
  29. package/template-hono/shared/contracts/routes.ts +14 -0
  30. package/template-hono/shared/routes.ts +1 -68
  31. package/template-hono/shared/schema.ts +5 -8
  32. package/template-hono/shared/types/greeting.ts +3 -0
  33. package/template-hono/tailwind.config.mjs +62 -111
  34. package/template-hono/tsconfig.json +3 -2
  35. package/template-hono/tsconfig.server.json +2 -0
  36. package/template-hono/vite.config.ts +4 -4
  37. package/template-hono/client/src/lib/ai-ui-stream.ts +0 -64
  38. package/template-hono/client/src/theme/default-theme.css +0 -482
  39. package/template-hono/drizzle.config.ts +0 -13
  40. package/template-hono/server/controllers/ai-chat-controller.ts +0 -49
  41. package/template-hono/server/controllers/greeting-controller.ts +0 -25
  42. package/template-hono/server/db/client.ts +0 -25
  43. package/template-hono/server/env.ts +0 -89
  44. package/template-hono/server/lib/ai-provider.ts +0 -74
  45. package/template-hono/server/lib/ai.ts +0 -205
  46. package/template-hono/server/middlewares/auth.ts +0 -69
  47. package/template-hono/server/middlewares/index.ts +0 -12
  48. package/template-hono/server/repositories/message-repository.ts +0 -13
  49. package/template-hono/server/routes/ai-chat.ts +0 -9
  50. package/template-hono/shared/schema/greeting.ts +0 -17
@@ -1,89 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- const ENV_FILES = [".env.local", ".env"];
5
-
6
- let loaded = false;
7
-
8
- function parseLine(line: string) {
9
- const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(line.trim());
10
- if (!match) return null;
11
-
12
- const key = match[1];
13
- let value = match[2] ?? "";
14
-
15
- if (
16
- (value.startsWith('"') && value.endsWith('"')) ||
17
- (value.startsWith("'") && value.endsWith("'"))
18
- ) {
19
- value = value.slice(1, -1);
20
- }
21
-
22
- return { key, value };
23
- }
24
-
25
- export function loadLocalEnv() {
26
- if (loaded) return;
27
-
28
- for (const file of ENV_FILES) {
29
- const filePath = path.resolve(process.cwd(), file);
30
- if (!fs.existsSync(filePath)) continue;
31
-
32
- const content = fs.readFileSync(filePath, "utf8");
33
- for (const rawLine of content.split(/\r?\n/u)) {
34
- const line = rawLine.trim();
35
- if (!line || line.startsWith("#")) continue;
36
-
37
- const parsed = parseLine(line);
38
- if (!parsed) continue;
39
-
40
- if (process.env[parsed.key] === undefined) {
41
- process.env[parsed.key] = parsed.value;
42
- }
43
- }
44
- }
45
-
46
- loaded = true;
47
- }
48
-
49
- export function requireDatabaseUrl() {
50
- loadLocalEnv();
51
-
52
- const databaseUrl = process.env.DATABASE_URL?.trim();
53
- if (!databaseUrl) {
54
- throw new Error(
55
- "DATABASE_URL 缺失:请在 .env.local 中配置可用的 PostgreSQL 连接串。",
56
- );
57
- }
58
-
59
- return databaseUrl;
60
- }
61
-
62
- export const DEFAULT_AI_MODEL = "gpt-4o-mini";
63
-
64
- export function readAiConfig() {
65
- loadLocalEnv();
66
-
67
- const apiKey =
68
- process.env.AI_API_KEY?.trim() ||
69
- process.env.OPENAI_API_KEY?.trim();
70
- if (!apiKey) {
71
- throw new Error(
72
- "AI_API_KEY 缺失:请在 .env.local 中配置可用的模型访问密钥。",
73
- );
74
- }
75
-
76
- const provider = process.env.AI_PROVIDER?.trim() || undefined;
77
- const baseURL =
78
- process.env.AI_BASE_URL?.trim() ||
79
- process.env.OPENAI_BASE_URL?.trim() ||
80
- undefined;
81
- const model = process.env.AI_MODEL?.trim() || DEFAULT_AI_MODEL;
82
-
83
- return {
84
- provider,
85
- apiKey,
86
- baseURL,
87
- model,
88
- };
89
- }
@@ -1,74 +0,0 @@
1
- import { createOpenAI } from "@ai-sdk/openai";
2
- import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
3
- import { readAiConfig } from "../env";
4
-
5
- const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProviderName, string>> = {
6
- minimax: "https://api.minimaxi.com/v1",
7
- kimi: "https://api.moonshot.cn/v1",
8
- };
9
-
10
- export type AiProviderName =
11
- | "openai"
12
- | "openai-compatible"
13
- | "minimax"
14
- | "kimi";
15
-
16
- function normalizeProviderName(value: string | undefined): AiProviderName {
17
- if (!value) return "openai";
18
-
19
- switch (value.trim().toLowerCase()) {
20
- case "openai":
21
- return "openai";
22
- case "openai-compatible":
23
- case "compatible":
24
- return "openai-compatible";
25
- case "minimax":
26
- return "minimax";
27
- case "kimi":
28
- case "moonshot":
29
- return "kimi";
30
- default:
31
- throw new Error(
32
- `AI_PROVIDER 不支持:${value}。当前仅支持 openai、openai-compatible、minimax、kimi。`,
33
- );
34
- }
35
- }
36
-
37
- function resolveBaseUrl(
38
- provider: AiProviderName,
39
- configuredBaseURL: string | undefined,
40
- ) {
41
- if (configuredBaseURL) return configuredBaseURL;
42
- return PROVIDER_DEFAULT_BASE_URL[provider];
43
- }
44
-
45
- function requireCompatibleBaseUrl(
46
- provider: Exclude<AiProviderName, "openai">,
47
- baseURL: string | undefined,
48
- ) {
49
- if (baseURL) return baseURL;
50
- throw new Error(
51
- `${provider} 缺少可用的 AI_BASE_URL。请在 .env.local 中显式配置兼容网关地址。`,
52
- );
53
- }
54
-
55
- export function createChatModel(modelIdOverride?: string) {
56
- const config = readAiConfig();
57
- const provider = normalizeProviderName(config.provider);
58
- const modelId = modelIdOverride || config.model;
59
- const baseURL = resolveBaseUrl(provider, config.baseURL);
60
-
61
- if (provider === "openai") {
62
- return createOpenAI({
63
- apiKey: config.apiKey,
64
- baseURL,
65
- })(modelId);
66
- }
67
-
68
- return createOpenAICompatible({
69
- name: provider,
70
- apiKey: config.apiKey,
71
- baseURL: requireCompatibleBaseUrl(provider, baseURL),
72
- includeUsage: true,
73
- })(modelId);
74
- }
@@ -1,205 +0,0 @@
1
- import {
2
- convertToModelMessages,
3
- streamText,
4
- type UIMessage,
5
- } from "ai";
6
- import type { AIMessage } from "../../shared/routes";
7
- import { createChatModel } from "./ai-provider";
8
-
9
- const MAX_FILE_COUNT = 3;
10
- const MAX_TOTAL_FILE_BYTES = 5 * 1024 * 1024;
11
- const DEFAULT_SYSTEM_PROMPT =
12
- "You are a helpful AI assistant. Give accurate, concise answers and explain limitations clearly.";
13
-
14
- export class AiGatewayError extends Error {
15
- status: number;
16
- code: string;
17
-
18
- constructor(status: number, code: string, message: string) {
19
- super(message);
20
- this.name = "AiGatewayError";
21
- this.status = status;
22
- this.code = code;
23
- }
24
- }
25
-
26
- export function createAiErrorBody(code: string, message: string) {
27
- return {
28
- error: {
29
- code,
30
- message,
31
- },
32
- };
33
- }
34
-
35
- function isAllowedMediaType(mediaType: string) {
36
- return (
37
- mediaType.startsWith("image/") ||
38
- mediaType.startsWith("text/") ||
39
- mediaType === "application/pdf"
40
- );
41
- }
42
-
43
- function getDataUrlInfo(url: string) {
44
- if (!url.startsWith("data:")) return null;
45
-
46
- const commaIndex = url.indexOf(",");
47
- if (commaIndex < 0) return null;
48
-
49
- const metadata = url.slice(5, commaIndex);
50
- const payload = url.slice(commaIndex + 1);
51
- const [mediaType = "text/plain"] = metadata.split(";");
52
- const isBase64 = metadata.includes(";base64");
53
-
54
- if (isBase64) {
55
- const normalized = payload.replace(/\s+/gu, "");
56
- const padding = normalized.endsWith("==")
57
- ? 2
58
- : normalized.endsWith("=")
59
- ? 1
60
- : 0;
61
-
62
- return {
63
- mediaType,
64
- byteLength: Math.max(0, Math.floor((normalized.length * 3) / 4) - padding),
65
- };
66
- }
67
-
68
- try {
69
- return {
70
- mediaType,
71
- byteLength: Buffer.byteLength(decodeURIComponent(payload), "utf8"),
72
- };
73
- } catch {
74
- return null;
75
- }
76
- }
77
-
78
- function assertAttachmentPolicy(messages: AIMessage[]) {
79
- let fileCount = 0;
80
- let totalFileBytes = 0;
81
-
82
- for (const message of messages) {
83
- for (const part of message.parts) {
84
- if (part.type !== "file") continue;
85
-
86
- fileCount += 1;
87
- if (fileCount > MAX_FILE_COUNT) {
88
- throw new AiGatewayError(
89
- 400,
90
- "AI_TOO_MANY_FILES",
91
- `最多只能上传 ${MAX_FILE_COUNT} 个文件。`,
92
- );
93
- }
94
-
95
- if (!isAllowedMediaType(part.mediaType)) {
96
- throw new AiGatewayError(
97
- 400,
98
- "AI_UNSUPPORTED_FILE_TYPE",
99
- `不支持的文件类型:${part.mediaType}。当前仅支持 image/*、text/*、application/pdf。`,
100
- );
101
- }
102
-
103
- const dataUrlInfo = getDataUrlInfo(part.url);
104
- if (!dataUrlInfo) {
105
- throw new AiGatewayError(
106
- 400,
107
- "AI_INVALID_FILE_URL",
108
- "文件必须以内联 data URL 形式提交,便于服务端统一校验。",
109
- );
110
- }
111
-
112
- if (dataUrlInfo.mediaType && dataUrlInfo.mediaType !== part.mediaType) {
113
- throw new AiGatewayError(
114
- 400,
115
- "AI_FILE_MEDIA_TYPE_MISMATCH",
116
- "文件声明的 mediaType 与实际 data URL 不一致。",
117
- );
118
- }
119
-
120
- totalFileBytes += dataUrlInfo.byteLength;
121
- if (totalFileBytes > MAX_TOTAL_FILE_BYTES) {
122
- throw new AiGatewayError(
123
- 400,
124
- "AI_FILE_SIZE_LIMIT",
125
- "附件总大小不能超过 5MB。",
126
- );
127
- }
128
- }
129
- }
130
- }
131
-
132
- function collectErrorMessages(
133
- error: unknown,
134
- seen = new Set<unknown>(),
135
- ): string[] {
136
- if (error == null || seen.has(error)) return [];
137
- seen.add(error);
138
-
139
- if (error instanceof Error) {
140
- const nested: string[] = [
141
- ...collectErrorMessages((error as Error & { cause?: unknown }).cause, seen),
142
- ...collectErrorMessages((error as Error & { errors?: unknown[] }).errors, seen),
143
- ...collectErrorMessages((error as Error & { lastError?: unknown }).lastError, seen),
144
- ];
145
- return [error.message, ...nested].filter(Boolean);
146
- }
147
-
148
- if (Array.isArray(error)) {
149
- return error.flatMap((item) => collectErrorMessages(item, seen));
150
- }
151
-
152
- if (typeof error === "object") {
153
- return [
154
- ...collectErrorMessages((error as { message?: unknown }).message, seen),
155
- ...collectErrorMessages((error as { cause?: unknown }).cause, seen),
156
- ...collectErrorMessages((error as { errors?: unknown[] }).errors, seen),
157
- ...collectErrorMessages((error as { lastError?: unknown }).lastError, seen),
158
- ];
159
- }
160
-
161
- if (typeof error === "string") {
162
- return [error];
163
- }
164
-
165
- return [];
166
- }
167
-
168
- function mapAiStreamError(error: unknown, fallbackMessage: string) {
169
- console.error("[ai] stream request failed", error);
170
-
171
- const combinedMessage = collectErrorMessages(error).join("\n");
172
- if (
173
- combinedMessage.includes("UND_ERR_CONNECT_TIMEOUT") ||
174
- combinedMessage.includes("Connect Timeout Error")
175
- ) {
176
- return "AI 服务连接超时,请稍后重试或检查 AI_BASE_URL 配置。";
177
- }
178
-
179
- if (
180
- combinedMessage.includes("Cannot connect to API") ||
181
- combinedMessage.includes("fetch failed") ||
182
- combinedMessage.includes("ECONNREFUSED") ||
183
- combinedMessage.includes("ENOTFOUND")
184
- ) {
185
- return "AI 服务当前无法连接,请稍后重试或检查 AI_BASE_URL 配置。";
186
- }
187
-
188
- return fallbackMessage;
189
- }
190
-
191
- export function streamAiChat(messages: AIMessage[], signal?: AbortSignal) {
192
- assertAttachmentPolicy(messages);
193
-
194
- const result = streamText({
195
- model: createChatModel(),
196
- system: DEFAULT_SYSTEM_PROMPT,
197
- messages: convertToModelMessages(messages as UIMessage[]),
198
- abortSignal: signal,
199
- });
200
-
201
- return result.toUIMessageStreamResponse({
202
- onError: (error) =>
203
- mapAiStreamError(error, "AI 响应生成失败,请稍后重试。"),
204
- });
205
- }
@@ -1,69 +0,0 @@
1
- import type { Context } from "hono";
2
- import { createMiddleware } from "hono/factory";
3
-
4
- export type AuthUser = {
5
- id: string;
6
- roles: string[];
7
- email?: string | null;
8
- };
9
-
10
- export type AppMiddlewareVariables = {
11
- currentUser: AuthUser | null;
12
- };
13
-
14
- export type AppMiddlewareEnv = {
15
- Variables: AppMiddlewareVariables;
16
- };
17
-
18
- export type AuthenticateRequest = (input: {
19
- token: string;
20
- c: Context;
21
- }) => Promise<AuthUser | null>;
22
-
23
- function readBearerToken(c: Context) {
24
- const authorization = c.req.header("Authorization");
25
- if (!authorization) return null;
26
-
27
- const [scheme, token] = authorization.trim().split(/\s+/, 2);
28
- if (scheme !== "Bearer" || !token) return null;
29
-
30
- return token;
31
- }
32
-
33
- export function optionalAuth(authenticate: AuthenticateRequest) {
34
- return createMiddleware<AppMiddlewareEnv>(async (c, next) => {
35
- const token = readBearerToken(c);
36
- if (!token) {
37
- c.set("currentUser", null);
38
- return next();
39
- }
40
-
41
- const user = await authenticate({ token, c });
42
- c.set("currentUser", user);
43
-
44
- return next();
45
- });
46
- }
47
-
48
- export function requireAuth(authenticate: AuthenticateRequest) {
49
- return createMiddleware<AppMiddlewareEnv>(async (c, next) => {
50
- const token = readBearerToken(c);
51
- if (!token) {
52
- c.set("currentUser", null);
53
- return c.json({ message: "未登录:缺少 Bearer Token。" }, 401);
54
- }
55
-
56
- const user = await authenticate({ token, c });
57
- if (!user) {
58
- c.set("currentUser", null);
59
- return c.json({ message: "登录已失效或认证失败。" }, 401);
60
- }
61
-
62
- c.set("currentUser", user);
63
- return next();
64
- });
65
- }
66
-
67
- export function getCurrentUser(c: Context<AppMiddlewareEnv>) {
68
- return c.get("currentUser");
69
- }
@@ -1,12 +0,0 @@
1
- export {
2
- getCurrentUser,
3
- optionalAuth,
4
- requireAuth,
5
- } from "./auth";
6
-
7
- export type {
8
- AppMiddlewareEnv,
9
- AppMiddlewareVariables,
10
- AuthenticateRequest,
11
- AuthUser,
12
- } from "./auth";
@@ -1,13 +0,0 @@
1
- import { desc } from "drizzle-orm";
2
- import { messages } from "../../shared/schema";
3
- import { db } from "../db/client";
4
-
5
- export async function findLatestMessage() {
6
- const rows = await db
7
- .select({ content: messages.content })
8
- .from(messages)
9
- .orderBy(desc(messages.id))
10
- .limit(1);
11
-
12
- return rows[0]?.content ?? null;
13
- }
@@ -1,9 +0,0 @@
1
- import { Hono } from "hono";
2
- import { api } from "../../shared/routes";
3
- import { postAiChat } from "../controllers/ai-chat-controller";
4
-
5
- const aiChatRoute = new Hono();
6
-
7
- aiChatRoute.post(api.ai.chat.post.path, postAiChat);
8
-
9
- export { aiChatRoute };
@@ -1,17 +0,0 @@
1
- import { boolean, pgTable, serial, text } from "drizzle-orm/pg-core";
2
- import { createInsertSchema } from "drizzle-zod";
3
- import { z } from "zod";
4
-
5
- export const messages = pgTable("messages", {
6
- id: serial("id").primaryKey(),
7
- content: text("content").notNull(),
8
- isRead: boolean("is_read").notNull().default(false),
9
- });
10
-
11
- export const insertMessageSchema = createInsertSchema(messages).pick({
12
- content: true,
13
- isRead: true,
14
- });
15
-
16
- export type Message = typeof messages.$inferSelect;
17
- export type InsertMessage = z.infer<typeof insertMessageSchema>;