@yuer678/create-kb 0.3.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.
- package/LICENSE +21 -0
- package/dist/index.js +196 -0
- package/package.json +48 -0
- package/templates/ai/.env.example +7 -0
- package/templates/ai/README.md +39 -0
- package/templates/ai/package.json +42 -0
- package/templates/ai/public/index.html +117 -0
- package/templates/ai/src/app.ts +22 -0
- package/templates/ai/src/config.ts +13 -0
- package/templates/ai/src/index.ts +6 -0
- package/templates/ai/src/lib/llm.ts +24 -0
- package/templates/ai/src/routes/chat.ts +72 -0
- package/templates/ai/test/chat.spec.ts +27 -0
- package/templates/ai/tsconfig.json +13 -0
- package/templates/ai/vitest.config.ts +8 -0
- package/templates/api/README.md +50 -0
- package/templates/api/package.json +42 -0
- package/templates/api/src/app.ts +84 -0
- package/templates/api/src/data/options.ts +17 -0
- package/templates/api/src/data/regions.ts +71 -0
- package/templates/api/src/data/users.ts +76 -0
- package/templates/api/src/index.ts +9 -0
- package/templates/api/src/middleware/error.ts +28 -0
- package/templates/api/src/middleware/validate.ts +22 -0
- package/templates/api/src/query.ts +106 -0
- package/templates/api/src/routes/health.ts +21 -0
- package/templates/api/src/routes/options.ts +19 -0
- package/templates/api/src/routes/regions.ts +31 -0
- package/templates/api/src/routes/users.ts +57 -0
- package/templates/api/src/server.ts +31 -0
- package/templates/api/src/types.ts +8 -0
- package/templates/api/test/app.spec.ts +216 -0
- package/templates/api/tsconfig.json +13 -0
- package/templates/api/vitest.config.ts +8 -0
- package/templates/base/README.md +25 -0
- package/templates/base/index.html +12 -0
- package/templates/base/package.json +32 -0
- package/templates/base/src/App.vue +46 -0
- package/templates/base/src/main.ts +6 -0
- package/templates/base/tsconfig.json +18 -0
- package/templates/base/vite.config.ts +6 -0
- package/templates/electron/README.md +32 -0
- package/templates/electron/package.json +54 -0
- package/templates/electron/src/main/index.ts +36 -0
- package/templates/electron/src/preload/index.ts +10 -0
- package/templates/electron/src/renderer/index.html +22 -0
- package/templates/electron/src/renderer/renderer.ts +14 -0
- package/templates/electron/tsconfig.json +13 -0
- package/templates/fullstack/Dockerfile +16 -0
- package/templates/fullstack/README.md +37 -0
- package/templates/fullstack/docker-compose.yml +26 -0
- package/templates/fullstack/package.json +30 -0
- package/templates/fullstack/pnpm-workspace.yaml +3 -0
- package/templates/fullstack/server/package.json +41 -0
- package/templates/fullstack/server/src/app.ts +84 -0
- package/templates/fullstack/server/src/data/options.ts +17 -0
- package/templates/fullstack/server/src/data/regions.ts +71 -0
- package/templates/fullstack/server/src/data/users.ts +76 -0
- package/templates/fullstack/server/src/index.ts +9 -0
- package/templates/fullstack/server/src/middleware/error.ts +28 -0
- package/templates/fullstack/server/src/middleware/validate.ts +22 -0
- package/templates/fullstack/server/src/query.ts +106 -0
- package/templates/fullstack/server/src/routes/health.ts +21 -0
- package/templates/fullstack/server/src/routes/options.ts +19 -0
- package/templates/fullstack/server/src/routes/regions.ts +31 -0
- package/templates/fullstack/server/src/routes/users.ts +57 -0
- package/templates/fullstack/server/src/server.ts +31 -0
- package/templates/fullstack/server/src/types.ts +8 -0
- package/templates/fullstack/server/test/app.spec.ts +216 -0
- package/templates/fullstack/server/tsconfig.json +13 -0
- package/templates/fullstack/server/vitest.config.ts +8 -0
- package/templates/fullstack/web/index.html +12 -0
- package/templates/fullstack/web/package.json +30 -0
- package/templates/fullstack/web/src/App.vue +108 -0
- package/templates/fullstack/web/src/main.ts +4 -0
- package/templates/fullstack/web/tsconfig.json +13 -0
- package/templates/fullstack/web/vite.config.ts +16 -0
- package/templates/react/README.md +8 -0
- package/templates/react/index.html +12 -0
- package/templates/react/package.json +34 -0
- package/templates/react/src/App.css +32 -0
- package/templates/react/src/App.tsx +16 -0
- package/templates/react/src/main.tsx +9 -0
- package/templates/react/tsconfig.json +20 -0
- package/templates/react/vite.config.ts +9 -0
- package/templates/starter/README.md +28 -0
- package/templates/starter/index.html +12 -0
- package/templates/starter/package.json +32 -0
- package/templates/starter/src/App.vue +125 -0
- package/templates/starter/src/main.ts +6 -0
- package/templates/starter/tsconfig.json +18 -0
- package/templates/starter/vite.config.ts +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 anyuer678
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/prompts.ts
|
|
7
|
+
import prompts from "prompts";
|
|
8
|
+
async function promptMissing(options) {
|
|
9
|
+
if (options.projectName && options.template && options.packageManager) {
|
|
10
|
+
return {
|
|
11
|
+
projectName: options.projectName,
|
|
12
|
+
template: options.template,
|
|
13
|
+
packageManager: options.packageManager,
|
|
14
|
+
git: options.git ?? true
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const answers = await prompts(
|
|
18
|
+
[
|
|
19
|
+
{
|
|
20
|
+
type: "text",
|
|
21
|
+
name: "projectName",
|
|
22
|
+
message: "\u9879\u76EE\u540D\u79F0",
|
|
23
|
+
initial: "kb-app",
|
|
24
|
+
validate: validateProjectName,
|
|
25
|
+
skip: () => Boolean(options.projectName)
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
type: "select",
|
|
29
|
+
name: "template",
|
|
30
|
+
message: "\u9009\u62E9\u6A21\u677F",
|
|
31
|
+
choices: [
|
|
32
|
+
{ title: "base - \u6700\u5C0F\u53EF\u7528", value: "base" },
|
|
33
|
+
{ title: "starter - \u5B8C\u6574\u793A\u4F8B", value: "starter" },
|
|
34
|
+
{ title: "api - Express \u540E\u7AEF\u670D\u52A1", value: "api" },
|
|
35
|
+
{ title: "fullstack - \u524D\u7AEF+API+Docker", value: "fullstack" },
|
|
36
|
+
{ title: "electron - \u684C\u9762\u5E94\u7528", value: "electron" },
|
|
37
|
+
{ title: "react - React 19 \u524D\u7AEF", value: "react" },
|
|
38
|
+
{ title: "ai - LLM \u5DE5\u4F5C\u53F0", value: "ai" }
|
|
39
|
+
],
|
|
40
|
+
skip: () => Boolean(options.template)
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: "select",
|
|
44
|
+
name: "packageManager",
|
|
45
|
+
message: "\u5305\u7BA1\u7406\u5668",
|
|
46
|
+
choices: [
|
|
47
|
+
{ title: "pnpm", value: "pnpm" },
|
|
48
|
+
{ title: "npm", value: "npm" },
|
|
49
|
+
{ title: "yarn", value: "yarn" }
|
|
50
|
+
],
|
|
51
|
+
skip: () => Boolean(options.packageManager)
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
{ onCancel: () => process.exit(1) }
|
|
55
|
+
);
|
|
56
|
+
return {
|
|
57
|
+
projectName: options.projectName ?? answers.projectName,
|
|
58
|
+
template: options.template ?? answers.template,
|
|
59
|
+
packageManager: options.packageManager ?? answers.packageManager,
|
|
60
|
+
git: options.git ?? true
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function validateProjectName(name) {
|
|
64
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
65
|
+
return "\u9879\u76EE\u540D\u53EA\u80FD\u5305\u542B\u5C0F\u5199\u5B57\u6BCD\u3001\u6570\u5B57\u548C\u8FDE\u5B57\u7B26\uFF08-\uFF09\uFF0C\u4E14\u4E0D\u80FD\u4EE5\u8FDE\u5B57\u7B26\u5F00\u5934";
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
function toPackageName(name) {
|
|
70
|
+
return name;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/create.ts
|
|
74
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
75
|
+
import { join, dirname } from "path";
|
|
76
|
+
import { fileURLToPath } from "url";
|
|
77
|
+
import { execSync } from "child_process";
|
|
78
|
+
import { createRequire } from "module";
|
|
79
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
80
|
+
function templatesRoot() {
|
|
81
|
+
return join(__dirname, "../templates");
|
|
82
|
+
}
|
|
83
|
+
function kbVersion() {
|
|
84
|
+
const require2 = createRequire(import.meta.url);
|
|
85
|
+
try {
|
|
86
|
+
const pkg = require2("kb-ui-vue/package.json");
|
|
87
|
+
return pkg.version;
|
|
88
|
+
} catch {
|
|
89
|
+
console.warn('\u26A0 \u672A\u8BFB\u53D6\u5230 kb-ui-vue \u7248\u672C\uFF0C\u6A21\u677F\u5C06\u4F7F\u7528 "latest" \u4F5C\u4E3A\u4F9D\u8D56\u7248\u672C');
|
|
90
|
+
return "latest";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
var PLACEHOLDER_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
94
|
+
".json",
|
|
95
|
+
".ts",
|
|
96
|
+
".vue",
|
|
97
|
+
".html",
|
|
98
|
+
".md",
|
|
99
|
+
".js",
|
|
100
|
+
".mjs",
|
|
101
|
+
".css",
|
|
102
|
+
".gitignore",
|
|
103
|
+
".yml",
|
|
104
|
+
".yaml"
|
|
105
|
+
]);
|
|
106
|
+
function shouldReplace(fileName) {
|
|
107
|
+
if (fileName === ".gitignore") return true;
|
|
108
|
+
const ext = fileName.slice(fileName.lastIndexOf("."));
|
|
109
|
+
return PLACEHOLDER_EXTENSIONS.has(ext);
|
|
110
|
+
}
|
|
111
|
+
function replacePlaceholders(filePath, options, version) {
|
|
112
|
+
const content = readFileSync(filePath, "utf-8");
|
|
113
|
+
const replaced = content.replaceAll("{{projectName}}", options.projectName).replaceAll("{{packageName}}", toPackageName(options.projectName)).replaceAll("{{kbVersion}}", version);
|
|
114
|
+
if (replaced !== content) {
|
|
115
|
+
writeFileSync(filePath, replaced, "utf-8");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function walkAndReplace(dir, options, version) {
|
|
119
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
120
|
+
const full = join(dir, entry.name);
|
|
121
|
+
if (entry.isDirectory()) {
|
|
122
|
+
walkAndReplace(full, options, version);
|
|
123
|
+
} else if (shouldReplace(entry.name)) {
|
|
124
|
+
replacePlaceholders(full, options, version);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function createProject(options) {
|
|
129
|
+
const targetDir = join(process.cwd(), options.projectName);
|
|
130
|
+
const templateDir = join(templatesRoot(), options.template);
|
|
131
|
+
if (!existsSync(templateDir)) {
|
|
132
|
+
console.error(`\u2716 \u6A21\u677F\u4E0D\u5B58\u5728\uFF1A${options.template}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
if (existsSync(targetDir)) {
|
|
136
|
+
const entries = readdirSync(targetDir);
|
|
137
|
+
if (entries.length > 0) {
|
|
138
|
+
console.error(`\u2716 \u76EE\u6807\u76EE\u5F55 ${targetDir} \u5DF2\u5B58\u5728\u4E14\u975E\u7A7A\uFF0C\u8BF7\u9009\u62E9\u5176\u4ED6\u9879\u76EE\u540D\u6216\u5220\u9664\u8BE5\u76EE\u5F55`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
mkdirSync(targetDir, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
console.log(`
|
|
145
|
+
\u521B\u5EFA\u9879\u76EE ${options.projectName}\uFF08\u6A21\u677F\uFF1A${options.template}\uFF09...`);
|
|
146
|
+
cpSync(templateDir, targetDir, { recursive: true });
|
|
147
|
+
const version = kbVersion();
|
|
148
|
+
walkAndReplace(targetDir, options, version);
|
|
149
|
+
if (options.git) {
|
|
150
|
+
try {
|
|
151
|
+
execSync("git init", { cwd: targetDir, stdio: "ignore" });
|
|
152
|
+
} catch {
|
|
153
|
+
console.warn("\u26A0 git init \u5931\u8D25\uFF08\u672A\u5B89\u88C5 git\uFF1F\uFF09\uFF0C\u5DF2\u8DF3\u8FC7\uFF0C\u4E0D\u5F71\u54CD\u9879\u76EE\u521B\u5EFA");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
console.log(`
|
|
157
|
+
\u2714 \u9879\u76EE\u521B\u5EFA\u5B8C\u6210\uFF1A${targetDir}
|
|
158
|
+
`);
|
|
159
|
+
console.log("\u4E0B\u4E00\u6B65\uFF1A");
|
|
160
|
+
console.log(` cd ${options.projectName}`);
|
|
161
|
+
console.log(` ${options.packageManager} install`);
|
|
162
|
+
console.log(` ${options.packageManager} run dev
|
|
163
|
+
`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/index.ts
|
|
167
|
+
var program = new Command();
|
|
168
|
+
program.name("create-kb").description("\u521B\u5EFA\u4E00\u4E2A\u9884\u88C5 @kb/ui \u7684 Vue 3 + Vite + TypeScript \u9879\u76EE").version("0.1.0").argument("[project-name]", "\u9879\u76EE\u540D\u79F0").option("--template <template>", "\u6A21\u677F\uFF1Abase\uFF08\u6700\u5C0F\uFF09| starter\uFF08\u5B8C\u6574\u793A\u4F8B\uFF09", "base").option("--no-git", "\u4E0D\u521D\u59CB\u5316 git \u4ED3\u5E93").option("-p, --package-manager <pm>", "\u5305\u7BA1\u7406\u5668\uFF1Apnpm | npm | yarn", "pnpm").action(async (projectName) => {
|
|
169
|
+
const cliOptions = program.opts();
|
|
170
|
+
if (projectName !== void 0) {
|
|
171
|
+
const result = validateProjectName(projectName);
|
|
172
|
+
if (result !== true) {
|
|
173
|
+
console.error(`\u2716 ${result}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const validTemplates = ["base", "starter", "api", "fullstack", "electron", "react", "ai"];
|
|
178
|
+
const template = validTemplates.includes(cliOptions.template) ? cliOptions.template : "base";
|
|
179
|
+
if (cliOptions.template !== template) {
|
|
180
|
+
console.warn(`\u26A0 \u672A\u77E5\u6A21\u677F ${cliOptions.template}\uFF0C\u4F7F\u7528\u9ED8\u8BA4\u6A21\u677F base`);
|
|
181
|
+
}
|
|
182
|
+
const pm = cliOptions.packageManager;
|
|
183
|
+
const pmValid = pm === "pnpm" || pm === "npm" || pm === "yarn";
|
|
184
|
+
if (!pmValid) {
|
|
185
|
+
console.error("\u2716 \u5305\u7BA1\u7406\u5668\u5FC5\u987B\u662F pnpm\u3001npm \u6216 yarn");
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
const options = await promptMissing({
|
|
189
|
+
projectName,
|
|
190
|
+
template,
|
|
191
|
+
packageManager: pm,
|
|
192
|
+
git: cliOptions.git
|
|
193
|
+
});
|
|
194
|
+
await createProject(options);
|
|
195
|
+
});
|
|
196
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yuer678/create-kb",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "anyuer678",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/anyuer678/kb-ui.git",
|
|
9
|
+
"directory": "packages/create-kb"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://anyuer678.github.io/kb-ui/",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/anyuer678/kb-ui/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"create-kb": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"templates"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"commander": "^15.0.0",
|
|
28
|
+
"prompts": "^2.4.2",
|
|
29
|
+
"kb-ui-vue": "0.4.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^26.6.1",
|
|
33
|
+
"@types/prompts": "^2.4.9",
|
|
34
|
+
"tsup": "^8.0.0",
|
|
35
|
+
"typescript": "^5.9.0"
|
|
36
|
+
},
|
|
37
|
+
"overrides": {
|
|
38
|
+
"js-yaml": "^4.1.1",
|
|
39
|
+
"fast-uri": "^3.1.0",
|
|
40
|
+
"vite": "^6.3.5"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup",
|
|
44
|
+
"dev": "tsup --watch",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"lint": "eslint src"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# {{projectName}} · AI 工作台模板
|
|
2
|
+
|
|
3
|
+
OpenAI 兼容协议的 LLM 接入骨架:非流式 + SSE 流式对话、多模型切换、温度调节、内置聊天前端。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cp .env.example .env # 填入 OPENAI_API_KEY(可选换 baseURL)
|
|
9
|
+
pnpm install
|
|
10
|
+
pnpm dev # http://localhost:3000
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
支持任意 OpenAI 兼容服务:OpenAI / DeepSeek / Moonshot / Ollama(本地)——改 `.env` 的 `OPENAI_BASE_URL` 即可。
|
|
14
|
+
|
|
15
|
+
## 接口
|
|
16
|
+
|
|
17
|
+
| 方法 | 路径 | 说明 |
|
|
18
|
+
|------|------|------|
|
|
19
|
+
| GET | / | 内置聊天页面 |
|
|
20
|
+
| POST | /chat | 非流式对话 |
|
|
21
|
+
| POST | /chat/stream | SSE 流式对话 |
|
|
22
|
+
|
|
23
|
+
## 结构
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
src/
|
|
27
|
+
├── app.ts # Express 应用(可测试)
|
|
28
|
+
├── config.ts # 环境配置 + 启动校验
|
|
29
|
+
├── lib/llm.ts # OpenAI 客户端单例 + 模型列表
|
|
30
|
+
└── routes/chat.ts # 对话路由(zod 校验 + 流式)
|
|
31
|
+
public/ # 聊天前端(原生 JS)
|
|
32
|
+
test/ # 校验层单测
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 扩展
|
|
36
|
+
|
|
37
|
+
- 加角色:messages 支持 system 指令,前端或服务端预设
|
|
38
|
+
- 加 Skill:在 chat 路由前加命令解析(如 /sql),命中则拼入 system
|
|
39
|
+
- 会话持久化:把 history 存入数据库/文件
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{packageName}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "tsx watch src/index.ts",
|
|
11
|
+
"start": "tsx src/index.ts",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"express": "^5.1.0",
|
|
17
|
+
"openai": "^6.0.0",
|
|
18
|
+
"zod": "^4.0.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/express": "^5.0.0",
|
|
22
|
+
"@types/node": "^24.0.0",
|
|
23
|
+
"supertest": "^7.1.0",
|
|
24
|
+
"@types/supertest": "^6.0.0",
|
|
25
|
+
"tsx": "^4.20.0",
|
|
26
|
+
"typescript": "^5.9.0",
|
|
27
|
+
"vitest": "^4.1.0"
|
|
28
|
+
},
|
|
29
|
+
"overrides": {
|
|
30
|
+
"js-yaml": "^4.3.2",
|
|
31
|
+
"fast-uri": "^3.1.0",
|
|
32
|
+
"vite": "^6.3.5",
|
|
33
|
+
"electron": "^39.8.10"
|
|
34
|
+
},
|
|
35
|
+
"pnpm": {
|
|
36
|
+
"overrides": {
|
|
37
|
+
"js-yaml": "^4.1.1",
|
|
38
|
+
"fast-uri": "^3.1.0",
|
|
39
|
+
"vite": "^6.3.5"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>{{projectName}} · AI 工作台</title>
|
|
6
|
+
<style>
|
|
7
|
+
* { box-sizing: border-box; }
|
|
8
|
+
body { margin: 0; font-family: system-ui, sans-serif; background: #f6f7f9; height: 100vh; display: flex; }
|
|
9
|
+
.sidebar { width: 200px; background: #1e293b; color: #fff; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
|
10
|
+
.sidebar h2 { font-size: 15px; margin: 0 0 8px; }
|
|
11
|
+
.sidebar label { font-size: 12px; color: #94a3b8; }
|
|
12
|
+
.sidebar select { width: 100%; padding: 6px; border-radius: 6px; border: none; background: #334155; color: #fff; }
|
|
13
|
+
.chat { flex: 1; display: flex; flex-direction: column; }
|
|
14
|
+
.messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
15
|
+
.msg { max-width: 70%; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.6; white-space: pre-wrap; }
|
|
16
|
+
.msg.user { align-self: flex-end; background: #3b82f6; color: #fff; }
|
|
17
|
+
.msg.assistant { align-self: flex-start; background: #fff; border: 1px solid #e2e8f0; }
|
|
18
|
+
.inputbar { display: flex; gap: 8px; padding: 12px 16px; background: #fff; border-top: 1px solid #e2e8f0; }
|
|
19
|
+
.inputbar textarea { flex: 1; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; resize: none; font-family: inherit; }
|
|
20
|
+
.inputbar button { padding: 10px 20px; border: none; border-radius: 8px; background: #3b82f6; color: #fff; cursor: pointer; }
|
|
21
|
+
.inputbar button:disabled { opacity: 0.5; }
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<aside class="sidebar">
|
|
26
|
+
<h2>{{projectName}}</h2>
|
|
27
|
+
<label>模型</label>
|
|
28
|
+
<select id="model">
|
|
29
|
+
<option value="gpt-4o-mini">GPT-4o mini</option>
|
|
30
|
+
<option value="gpt-4o">GPT-4o</option>
|
|
31
|
+
<option value="deepseek-chat">DeepSeek Chat</option>
|
|
32
|
+
<option value="deepseek-reasoner">DeepSeek Reasoner</option>
|
|
33
|
+
<option value="llama3.1">本地 Ollama</option>
|
|
34
|
+
</select>
|
|
35
|
+
<label>温度</label>
|
|
36
|
+
<input id="temp" type="range" min="0" max="2" step="0.1" value="0.7" />
|
|
37
|
+
</aside>
|
|
38
|
+
<main class="chat">
|
|
39
|
+
<div class="messages" id="messages">
|
|
40
|
+
<div class="msg assistant">你好!我是 AI 工作台演示,输入问题开始对话(流式输出)。</div>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="inputbar">
|
|
43
|
+
<textarea id="input" rows="1" placeholder="输入消息…(Enter 发送,Shift+Enter 换行)"></textarea>
|
|
44
|
+
<button id="send">发送</button>
|
|
45
|
+
</div>
|
|
46
|
+
</main>
|
|
47
|
+
<script>
|
|
48
|
+
const messagesEl = document.getElementById('messages')
|
|
49
|
+
const inputEl = document.getElementById('input')
|
|
50
|
+
const sendEl = document.getElementById('send')
|
|
51
|
+
const modelEl = document.getElementById('model')
|
|
52
|
+
const tempEl = document.getElementById('temp')
|
|
53
|
+
|
|
54
|
+
const history = []
|
|
55
|
+
function addMessage(role, content) {
|
|
56
|
+
const el = document.createElement('div')
|
|
57
|
+
el.className = `msg ${role}`
|
|
58
|
+
el.textContent = content
|
|
59
|
+
messagesEl.appendChild(el)
|
|
60
|
+
messagesEl.scrollTop = messagesEl.scrollHeight
|
|
61
|
+
return el
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function send() {
|
|
65
|
+
const text = inputEl.value.trim()
|
|
66
|
+
if (!text) return
|
|
67
|
+
inputEl.value = ''
|
|
68
|
+
history.push({ role: 'user', content: text })
|
|
69
|
+
addMessage('user', text)
|
|
70
|
+
const assistant = addMessage('assistant', '')
|
|
71
|
+
sendEl.disabled = true
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch('/chat/stream', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'content-type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ messages: history, model: modelEl.value, temperature: Number(tempEl.value) }),
|
|
78
|
+
})
|
|
79
|
+
const reader = res.body.getReader()
|
|
80
|
+
const decoder = new TextDecoder()
|
|
81
|
+
let buffer = ''
|
|
82
|
+
let full = ''
|
|
83
|
+
while (true) {
|
|
84
|
+
const { done, value } = await reader.read()
|
|
85
|
+
if (done) break
|
|
86
|
+
buffer += decoder.decode(value, { stream: true })
|
|
87
|
+
const lines = buffer.split('\n\n')
|
|
88
|
+
buffer = lines.pop() || ''
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
if (!line.startsWith('data: ')) continue
|
|
91
|
+
const payload = line.slice(6)
|
|
92
|
+
if (payload === '[DONE]') continue
|
|
93
|
+
const data = JSON.parse(payload)
|
|
94
|
+
if (data.error) { assistant.textContent = `⚠ ${data.error}`; continue }
|
|
95
|
+
full += data.delta
|
|
96
|
+
assistant.textContent = full
|
|
97
|
+
messagesEl.scrollTop = messagesEl.scrollHeight
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
history.push({ role: 'assistant', content: full })
|
|
101
|
+
} catch {
|
|
102
|
+
assistant.textContent = '⚠ 请求失败,请确认后端已启动且 .env 配置正确'
|
|
103
|
+
} finally {
|
|
104
|
+
sendEl.disabled = false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
sendEl.addEventListener('click', send)
|
|
109
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
110
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
111
|
+
e.preventDefault()
|
|
112
|
+
send()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
</script>
|
|
116
|
+
</body>
|
|
117
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { chatRouter } from './routes/chat'
|
|
5
|
+
import { checkConfig } from './config'
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
|
|
9
|
+
export function createApp() {
|
|
10
|
+
const app = express()
|
|
11
|
+
|
|
12
|
+
app.use(express.json())
|
|
13
|
+
|
|
14
|
+
app.get('/', (_req, res) => {
|
|
15
|
+
res.sendFile(join(__dirname, '../public/index.html'))
|
|
16
|
+
})
|
|
17
|
+
app.use('/chat', chatRouter)
|
|
18
|
+
|
|
19
|
+
return app
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
checkConfig()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** 环境配置(启动时校验,缺失给出明确提示) */
|
|
2
|
+
export const config = {
|
|
3
|
+
apiKey: process.env.OPENAI_API_KEY ?? '',
|
|
4
|
+
baseURL: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',
|
|
5
|
+
defaultModel: process.env.DEFAULT_MODEL ?? 'gpt-4o-mini',
|
|
6
|
+
port: Number(process.env.PORT ?? 3000),
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function checkConfig(): void {
|
|
10
|
+
if (!config.apiKey) {
|
|
11
|
+
console.warn('⚠ 未设置 OPENAI_API_KEY(复制 .env.example 为 .env 并填写)')
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import OpenAI from 'openai'
|
|
2
|
+
import { config } from '../config'
|
|
3
|
+
|
|
4
|
+
let client: OpenAI | null = null
|
|
5
|
+
|
|
6
|
+
/** 单例 OpenAI 客户端(兼容协议:DeepSeek/Moonshot/Ollama 等) */
|
|
7
|
+
export function getClient(): OpenAI {
|
|
8
|
+
if (!client) {
|
|
9
|
+
client = new OpenAI({
|
|
10
|
+
apiKey: config.apiKey || 'sk-placeholder',
|
|
11
|
+
baseURL: config.baseURL,
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
return client
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 可选模型列表(按服务能力增减) */
|
|
18
|
+
export const modelOptions = [
|
|
19
|
+
{ label: 'GPT-4o mini', value: 'gpt-4o-mini' },
|
|
20
|
+
{ label: 'GPT-4o', value: 'gpt-4o' },
|
|
21
|
+
{ label: 'DeepSeek Chat', value: 'deepseek-chat' },
|
|
22
|
+
{ label: 'DeepSeek Reasoner', value: 'deepseek-reasoner' },
|
|
23
|
+
{ label: '本地 Ollama', value: 'llama3.1' },
|
|
24
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { getClient } from '../lib/llm'
|
|
4
|
+
import { config } from '../config'
|
|
5
|
+
|
|
6
|
+
export const chatRouter = Router()
|
|
7
|
+
|
|
8
|
+
const messageSchema = z.object({
|
|
9
|
+
role: z.enum(['system', 'user', 'assistant']),
|
|
10
|
+
content: z.string(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const chatSchema = z.object({
|
|
14
|
+
messages: z.array(messageSchema).min(1),
|
|
15
|
+
model: z.string().optional(),
|
|
16
|
+
temperature: z.number().min(0).max(2).optional(),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/** 非流式对话:POST /chat */
|
|
20
|
+
chatRouter.post('/', async (req, res) => {
|
|
21
|
+
const body = chatSchema.safeParse(req.body)
|
|
22
|
+
if (!body.success) {
|
|
23
|
+
res.status(400).json({ code: 400, message: '参数校验失败', errors: body.error.flatten() })
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
const { messages, model, temperature } = body.data
|
|
27
|
+
try {
|
|
28
|
+
const completion = await getClient().chat.completions.create({
|
|
29
|
+
model: model ?? config.defaultModel,
|
|
30
|
+
messages,
|
|
31
|
+
temperature: temperature ?? 0.7,
|
|
32
|
+
})
|
|
33
|
+
res.json({ content: completion.choices[0]?.message?.content ?? '', model: completion.model })
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error('[chat error]', error)
|
|
36
|
+
res.status(502).json({ code: 502, message: '调用模型服务失败,请检查 .env 配置' })
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/** 流式对话:POST /chat/stream(SSE) */
|
|
41
|
+
chatRouter.post('/stream', async (req, res) => {
|
|
42
|
+
const body = chatSchema.safeParse(req.body)
|
|
43
|
+
if (!body.success) {
|
|
44
|
+
res.status(400).json({ code: 400, message: '参数校验失败' })
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
const { messages, model, temperature } = body.data
|
|
48
|
+
|
|
49
|
+
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
|
50
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
51
|
+
res.setHeader('Connection', 'keep-alive')
|
|
52
|
+
res.flushHeaders()
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const stream = await getClient().chat.completions.create({
|
|
56
|
+
model: model ?? config.defaultModel,
|
|
57
|
+
messages,
|
|
58
|
+
temperature: temperature ?? 0.7,
|
|
59
|
+
stream: true,
|
|
60
|
+
})
|
|
61
|
+
for await (const chunk of stream) {
|
|
62
|
+
const delta = chunk.choices[0]?.delta?.content
|
|
63
|
+
if (delta) res.write(`data: ${JSON.stringify({ delta })}\n\n`)
|
|
64
|
+
}
|
|
65
|
+
res.write('data: [DONE]\n\n')
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('[stream error]', error)
|
|
68
|
+
res.write(`data: ${JSON.stringify({ error: '调用模型服务失败' })}\n\n`)
|
|
69
|
+
} finally {
|
|
70
|
+
res.end()
|
|
71
|
+
}
|
|
72
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { chatSchema } from '../src/routes/chat'
|
|
3
|
+
|
|
4
|
+
// 校验层单测(不触发真实模型调用)
|
|
5
|
+
describe('chatSchema', () => {
|
|
6
|
+
it('合法消息通过', () => {
|
|
7
|
+
const result = chatSchema.safeParse({
|
|
8
|
+
messages: [{ role: 'user', content: '你好' }],
|
|
9
|
+
})
|
|
10
|
+
expect(result.success).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('空消息拒绝', () => {
|
|
14
|
+
const result = chatSchema.safeParse({ messages: [] })
|
|
15
|
+
expect(result.success).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('非法角色拒绝', () => {
|
|
19
|
+
const result = chatSchema.safeParse({ messages: [{ role: 'robot', content: 'hi' }] })
|
|
20
|
+
expect(result.success).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('temperature 越界拒绝', () => {
|
|
24
|
+
const result = chatSchema.safeParse({ messages: [{ role: 'user', content: 'x' }], temperature: 5 })
|
|
25
|
+
expect(result.success).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|