This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
README.md
|
||||||
|
*.log
|
||||||
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_KEY=your_dashscope_api_key_here
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Environment variables (never commit secrets)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|||||||
@@ -10,11 +10,9 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Use a simple Node static server instead of nginx
|
|
||||||
RUN npm install -g serve
|
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Serve the built SPA on port 80, with history fallback so /admin 等前端路由都可直接访问
|
# Serve SPA + proxy streaming API on port 80
|
||||||
CMD ["serve", "-s", "dist", "-l", "80"]
|
ENV PORT=80
|
||||||
|
CMD ["node", "server/index.mjs"]
|
||||||
|
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -44,6 +44,11 @@ Use AI (DashScope Qwen) to generate authentic WeChat Moments copy instantly.
|
|||||||
VITE_API_KEY=your_dashscope_api_key_here
|
VITE_API_KEY=your_dashscope_api_key_here
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For Docker / production deployment, prefer using server-side env:
|
||||||
|
```env
|
||||||
|
DASHSCOPE_API_KEY=your_dashscope_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
4. **Start Development Server**
|
4. **Start Development Server**
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -65,13 +70,13 @@ Use AI (DashScope Qwen) to generate authentic WeChat Moments copy instantly.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t wx-pyq .
|
docker build -t wx-pyq .
|
||||||
docker run -p 80:80 wx-pyq
|
docker run -p 80:80 -e DASHSCOPE_API_KEY=your_dashscope_api_key_here wx-pyq
|
||||||
```
|
```
|
||||||
|
|
||||||
## Note / 注意事项
|
## Note / 注意事项
|
||||||
|
|
||||||
- This is a frontend-only demo. The API Key is exposed in the browser network requests. For production, please use a backend proxy.
|
- For dev, it can call DashScope directly (API Key is exposed in the browser). For production/Docker, the app uses a same-origin server proxy (`/api/chat/completions`) so streaming is more reliable and the key is not bundled into frontend assets.
|
||||||
- 本项目为纯前端演示。API Key 会暴露在浏览器请求中。生产环境请务必使用后端代理。
|
- 开发环境可直连 DashScope(API Key 会暴露在浏览器请求中)。生产环境/Docker 默认走同源后端代理(`/api/chat/completions`),流式更稳定且不会把 Key 打进前端静态资源。
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"start": "node server/index.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
|
|||||||
232
server/index.mjs
Normal file
232
server/index.mjs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 80);
|
||||||
|
const DIST_DIR = path.resolve(process.cwd(), 'dist');
|
||||||
|
|
||||||
|
const DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||||
|
|
||||||
|
function getContentType(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case '.html':
|
||||||
|
return 'text/html; charset=utf-8';
|
||||||
|
case '.js':
|
||||||
|
return 'text/javascript; charset=utf-8';
|
||||||
|
case '.css':
|
||||||
|
return 'text/css; charset=utf-8';
|
||||||
|
case '.svg':
|
||||||
|
return 'image/svg+xml';
|
||||||
|
case '.png':
|
||||||
|
return 'image/png';
|
||||||
|
case '.jpg':
|
||||||
|
case '.jpeg':
|
||||||
|
return 'image/jpeg';
|
||||||
|
case '.ico':
|
||||||
|
return 'image/x-icon';
|
||||||
|
case '.json':
|
||||||
|
return 'application/json; charset=utf-8';
|
||||||
|
case '.map':
|
||||||
|
return 'application/json; charset=utf-8';
|
||||||
|
case '.woff2':
|
||||||
|
return 'font/woff2';
|
||||||
|
case '.txt':
|
||||||
|
return 'text/plain; charset=utf-8';
|
||||||
|
default:
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, statusCode, body) {
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
res.statusCode = statusCode;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.end(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(req) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyApiCorsHeaders(req, res) {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (!origin) return;
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
res.setHeader('Vary', 'Origin');
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChatCompletions(req, res) {
|
||||||
|
applyApiCorsHeaders(req, res);
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.statusCode = 204;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
sendJson(res, 405, { error: 'Method Not Allowed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey =
|
||||||
|
process.env.DASHSCOPE_API_KEY ||
|
||||||
|
process.env.VITE_API_KEY ||
|
||||||
|
process.env.DASHSCOPE_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
sendJson(res, 500, { error: 'Missing DASHSCOPE_API_KEY in server environment' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await readJsonBody(req);
|
||||||
|
} catch (e) {
|
||||||
|
sendJson(res, 400, { error: 'Invalid JSON request body' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamUrl = `${DASHSCOPE_BASE_URL}/chat/completions`;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
let upstream;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(upstreamUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
sendJson(res, 502, { error: 'Upstream request failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStream = Boolean(body && body.stream);
|
||||||
|
|
||||||
|
res.statusCode = upstream.status;
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Type',
|
||||||
|
upstream.headers.get('content-type') ||
|
||||||
|
(isStream ? 'text/event-stream; charset=utf-8' : 'application/json; charset=utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Help prevent buffering in common reverse proxies.
|
||||||
|
res.setHeader('Cache-Control', isStream ? 'no-cache, no-transform' : 'no-store');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
|
// For streaming: flush headers ASAP so the browser can start rendering.
|
||||||
|
if (isStream) {
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
}
|
||||||
|
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
||||||
|
|
||||||
|
if (!upstream.body) {
|
||||||
|
const text = await upstream.text().catch(() => '');
|
||||||
|
res.end(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reader = upstream.body.getReader();
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (value) res.write(Buffer.from(value));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If client disconnected, ignore; otherwise just end response.
|
||||||
|
} finally {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveStatic(req, res, url) {
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.end('Method Not Allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = decodeURIComponent(url.pathname);
|
||||||
|
|
||||||
|
// Prevent path traversal.
|
||||||
|
const safePath = path.normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, '');
|
||||||
|
const fsPath = path.join(DIST_DIR, safePath);
|
||||||
|
|
||||||
|
let resolved = fsPath;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pathname.endsWith('/')) {
|
||||||
|
resolved = path.join(fsPath, 'index.html');
|
||||||
|
} else if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
||||||
|
resolved = path.join(resolved, 'index.html');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
||||||
|
// SPA history fallback.
|
||||||
|
resolved = path.join(DIST_DIR, 'index.html');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
resolved = path.join(DIST_DIR, 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAsset = pathname.startsWith('/assets/');
|
||||||
|
const contentType = getContentType(resolved);
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader(
|
||||||
|
'Cache-Control',
|
||||||
|
isAsset ? 'public, max-age=31536000, immutable' : 'no-cache'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (req.method === 'HEAD') {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.createReadStream(resolved).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||||
|
|
||||||
|
if (url.pathname === '/api/chat/completions') {
|
||||||
|
await handleChatCompletions(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
serveStatic(req, res, url);
|
||||||
|
} catch (e) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('Internal Server Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Server listening on :${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -4,17 +4,18 @@ import type { InternalAxiosRequestConfig } from 'axios';
|
|||||||
// DashScope API Configuration
|
// DashScope API Configuration
|
||||||
const API_KEY = import.meta.env.VITE_API_KEY;
|
const API_KEY = import.meta.env.VITE_API_KEY;
|
||||||
const DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
const DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||||
|
const USE_SERVER_PROXY = import.meta.env.PROD;
|
||||||
|
|
||||||
if (!API_KEY) {
|
if (!USE_SERVER_PROXY && !API_KEY) {
|
||||||
console.warn('DashScope API Key is missing in .env file!');
|
console.warn('DashScope API Key is missing in .env file!');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Axios instance for DashScope
|
// Create Axios instance for DashScope
|
||||||
export const dashScopeApi = axios.create({
|
export const dashScopeApi = axios.create({
|
||||||
baseURL: DASHSCOPE_BASE_URL,
|
baseURL: USE_SERVER_PROXY ? '' : DASHSCOPE_BASE_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${API_KEY}`,
|
...(USE_SERVER_PROXY ? {} : { 'Authorization': `Bearer ${API_KEY}` }),
|
||||||
},
|
},
|
||||||
timeout: 60000, // 60s timeout for AI generation
|
timeout: 60000, // 60s timeout for AI generation
|
||||||
});
|
});
|
||||||
@@ -27,7 +28,7 @@ const INITIAL_RETRY_DELAY = 1000;
|
|||||||
dashScopeApi.interceptors.request.use(
|
dashScopeApi.interceptors.request.use(
|
||||||
(config: InternalAxiosRequestConfig) => {
|
(config: InternalAxiosRequestConfig) => {
|
||||||
// Ensure API Key is present
|
// Ensure API Key is present
|
||||||
if (!config.headers.Authorization && API_KEY) {
|
if (!USE_SERVER_PROXY && !config.headers.Authorization && API_KEY) {
|
||||||
config.headers.Authorization = `Bearer ${API_KEY}`;
|
config.headers.Authorization = `Bearer ${API_KEY}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
@@ -75,7 +76,8 @@ dashScopeApi.interceptors.response.use(
|
|||||||
*/
|
*/
|
||||||
export const generateCopy = async (model: string, messages: { role: string; content: string }[]) => {
|
export const generateCopy = async (model: string, messages: { role: string; content: string }[]) => {
|
||||||
try {
|
try {
|
||||||
const response = await dashScopeApi.post('/chat/completions', {
|
const endpoint = USE_SERVER_PROXY ? '/api/chat/completions' : '/chat/completions';
|
||||||
|
const response = await dashScopeApi.post(endpoint, {
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
// stream: true, // TODO: Implement streaming if needed
|
// stream: true, // TODO: Implement streaming if needed
|
||||||
@@ -103,12 +105,19 @@ export const generateCopyStream = async (
|
|||||||
onFinish: () => void
|
onFinish: () => void
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${DASHSCOPE_BASE_URL}/chat/completions`, {
|
const url = USE_SERVER_PROXY ? '/api/chat/completions' : `${DASHSCOPE_BASE_URL}/chat/completions`;
|
||||||
method: 'POST',
|
const headers: Record<string, string> = {
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${API_KEY}`,
|
'Accept': 'text/event-stream',
|
||||||
},
|
};
|
||||||
|
if (!USE_SERVER_PROXY) {
|
||||||
|
headers['Authorization'] = `Bearer ${API_KEY}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
@@ -146,7 +155,8 @@ export const generateCopyStream = async (
|
|||||||
|
|
||||||
if (trimmedLine.startsWith('data:')) {
|
if (trimmedLine.startsWith('data:')) {
|
||||||
try {
|
try {
|
||||||
const jsonStr = trimmedLine.replace('data: ', '');
|
const jsonStr = trimmedLine.slice('data:'.length).trimStart();
|
||||||
|
if (jsonStr === '[DONE]') continue;
|
||||||
const json = JSON.parse(jsonStr);
|
const json = JSON.parse(jsonStr);
|
||||||
const content = json.choices?.[0]?.delta?.content || '';
|
const content = json.choices?.[0]?.delta?.content || '';
|
||||||
if (content) {
|
if (content) {
|
||||||
|
|||||||
Reference in New Issue
Block a user