diff --git a/.env.development b/.env.development index b8ccd8b..ae6beaf 100644 --- a/.env.development +++ b/.env.development @@ -4,3 +4,13 @@ VITE_APP_NAME=后台管理(开发环境) #VITE_API_URL=https://cms-api.s209.websoft.top/api + +# AI 网关(开发环境建议走同源反代,避免浏览器 CORS) +VITE_AI_API_URL=/ai-proxy + +# Ollama 原生接口(开发环境建议走同源反代,避免浏览器 CORS) +VITE_OLLAMA_API_URL=/ollama-proxy + +# 如果 AI 网关启用了鉴权(401 Not authenticated),填入你的 Key(仅供本机 dev server 使用) +# 不要加 VITE_ 前缀,避免被打包进前端 +# AI_API_KEY=your_ai_api_key diff --git a/.env.example b/.env.example index eb6f73c..78102ce 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,19 @@ VITE_API_URL=https://your-api.com/api VITE_SERVER_API_URL=https://your-server.com/api VITE_DOMAIN=https://your-domain.com VITE_FILE_SERVER=https://your-file-server.com +# AI 网关(OpenAI兼容) +# - 开发环境推荐走同源反代:VITE_AI_API_URL=/ai-proxy(配合 vite.config.ts) +# - 生产环境可直连(需 AI 服务允许 CORS),或在 Nginx 里配置 /ai-proxy 反代 +VITE_AI_API_URL=https://ai-api.websoft.top/api/v1 + +# Ollama 原生接口(默认端口 11434) +# - 开发环境推荐走同源反代:VITE_OLLAMA_API_URL=/ollama-proxy(配合 vite.config.ts) +# - 生产环境不要直接用 http(会混合内容被拦截),建议 Nginx 反代成同源 https +VITE_OLLAMA_API_URL=http://47.119.165.234:11434 + +# 仅用于本地开发反代注入(vite.config.ts 会读取并注入到 /ai-proxy 请求头) +# 不要加 VITE_ 前缀,避免被打包到前端产物里 +AI_API_KEY=your_ai_api_key # 租户配置 VITE_TENANT_ID=your_tenant_id diff --git a/docs/AI_PROXY_NGINX.md b/docs/AI_PROXY_NGINX.md new file mode 100644 index 0000000..4cc4e22 --- /dev/null +++ b/docs/AI_PROXY_NGINX.md @@ -0,0 +1,76 @@ +# AI /ai-proxy Nginx 反代示例 + +前端页面 `src/views/ai/index.vue` 默认在开发环境使用 `AI_API_URL=/ai-proxy`,通过同源反代解决浏览器 CORS。 + +## 1) Vite 开发环境 + +项目已在 `vite.config.ts` 配置(默认目标可通过 `AI_PROXY_TARGET` 调整): + +- `/ai-proxy/*` -> `https://ai-api.websoft.top/api/v1/*` + +配合 `.env.development`: + +```bash +VITE_AI_API_URL=/ai-proxy +``` + +## 2) 生产环境(Nginx 反代) + +如果你的生产站点是 Nginx 托管静态文件,建议也加一条同源反代: + +```nginx +location /ai-proxy/ { + proxy_pass https://ai-api.websoft.top/api/v1/; + proxy_http_version 1.1; + proxy_set_header Host ai-api.websoft.top; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE/流式输出建议关闭缓存与缓冲 + proxy_buffering off; + proxy_cache off; + + # 如果你的 AI 网关开启了鉴权(401 Not authenticated),可以在反代层固定注入: + # proxy_set_header Authorization "Bearer YOUR_AI_API_KEY"; +} +``` + +然后把生产环境的 `VITE_AI_API_URL` 配置为: + +```bash +VITE_AI_API_URL=/ai-proxy +``` + +## 2.1) Ollama 原生接口(Nginx 反代) + +如果你要直接用原生 Ollama(`http://:11434`),生产环境同样建议走同源反代(避免 CORS + https 混合内容): + +```nginx +location /ollama-proxy/ { + proxy_pass http://47.119.165.234:11434/; + proxy_http_version 1.1; + proxy_set_header Host 47.119.165.234; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_cache off; +} +``` + +然后把 `VITE_OLLAMA_API_URL` 配置为: + +```bash +VITE_OLLAMA_API_URL=/ollama-proxy +``` + +## 3) 关于 API Key + +不建议把 Key 放在浏览器里。 + +推荐做法: + +- Key 放在你自己的后端(或 Nginx)里统一注入 / 鉴权; +- 前端只请求同源 `/ai-proxy/*`。 diff --git a/src/api/ai/ollama.ts b/src/api/ai/ollama.ts new file mode 100644 index 0000000..03aaf41 --- /dev/null +++ b/src/api/ai/ollama.ts @@ -0,0 +1,146 @@ +import { OLLAMA_API_URL } from '@/config/setting'; + +export type OllamaRole = 'system' | 'user' | 'assistant' | 'tool'; + +export interface OllamaChatMessage { + role: OllamaRole; + content: string; +} + +export interface OllamaModelTag { + name: string; + modified_at?: string; + size?: number; + digest?: string; + details?: any; +} + +export interface OllamaTagsResponse { + models: OllamaModelTag[]; +} + +export interface OllamaChatRequest { + model: string; + messages: OllamaChatMessage[]; + stream?: boolean; + options?: { + temperature?: number; + top_p?: number; + num_predict?: number; + }; +} + +export interface OllamaChatResponseChunk { + model?: string; + created_at?: string; + message?: { role: OllamaRole; content: string }; + done?: boolean; + error?: string; +} + +function normalizeBaseURL(baseURL: string) { + return baseURL.replace(/\/+$/, ''); +} + +export async function listOllamaModels(opts?: { baseURL?: string }) { + const baseURL = normalizeBaseURL(opts?.baseURL ?? OLLAMA_API_URL); + const res = await fetch(`${baseURL}/api/tags`, { method: 'GET' }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `listOllamaModels failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}` + ); + } + return (await res.json()) as OllamaTagsResponse; +} + +export async function ollamaChat( + body: OllamaChatRequest, + opts?: { baseURL?: string; signal?: AbortSignal } +) { + const baseURL = normalizeBaseURL(opts?.baseURL ?? OLLAMA_API_URL); + const res = await fetch(`${baseURL}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...body, stream: false }), + signal: opts?.signal + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `ollamaChat failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}` + ); + } + return (await res.json()) as OllamaChatResponseChunk; +} + +/** + * Ollama native streaming is newline-delimited JSON objects, not SSE. + */ +export async function ollamaChatStream( + body: Omit, + opts: { + baseURL?: string; + signal?: AbortSignal; + onDelta: (text: string) => void; + onDone?: () => void; + } +) { + const baseURL = normalizeBaseURL(opts.baseURL ?? OLLAMA_API_URL); + const res = await fetch(`${baseURL}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...body, stream: true }), + signal: opts.signal + }); + + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ''); + throw new Error( + `ollamaChatStream failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}` + ); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + let idx = buffer.indexOf('\n'); + while (idx !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + idx = buffer.indexOf('\n'); + + if (!line) continue; + + let json: OllamaChatResponseChunk; + try { + json = JSON.parse(line); + } catch { + continue; + } + + if (json.error) { + throw new Error(json.error); + } + + const delta = json.message?.content; + if (delta) { + opts.onDelta(delta); + } + + if (json.done) { + opts.onDone?.(); + return; + } + } + } + + opts.onDone?.(); +} + diff --git a/src/api/ai/openai.ts b/src/api/ai/openai.ts new file mode 100644 index 0000000..4ada4fa --- /dev/null +++ b/src/api/ai/openai.ts @@ -0,0 +1,173 @@ +import { AI_API_URL } from '@/config/setting'; + +export type OpenAIRole = 'system' | 'user' | 'assistant' | 'tool'; + +export interface OpenAIChatMessage { + role: OpenAIRole; + content: string; + name?: string; +} + +export interface OpenAIModel { + id: string; + object: string; + created?: number; + owned_by?: string; + name?: string; +} + +export interface OpenAIListModelsResponse { + data: OpenAIModel[]; +} + +export interface OpenAIChatCompletionRequest { + model: string; + messages: OpenAIChatMessage[]; + temperature?: number; + top_p?: number; + max_tokens?: number; + stream?: boolean; +} + +export interface OpenAIChatCompletionChoice { + index: number; + message?: { role: OpenAIRole; content: string }; + delta?: { role?: OpenAIRole; content?: string }; + finish_reason?: string | null; +} + +export interface OpenAIChatCompletionResponse { + id?: string; + object?: string; + created?: number; + model?: string; + choices: OpenAIChatCompletionChoice[]; +} + +function getHeaders(apiKey?: string) { + const headers: Record = { + 'Content-Type': 'application/json' + }; + if (apiKey) { + const trimmed = apiKey.trim(); + // Accept either raw token or "Bearer xxx". + headers.Authorization = /^bearer\s+/i.test(trimmed) + ? trimmed + : `Bearer ${trimmed}`; + } + return headers; +} + +function normalizeBaseURL(baseURL: string) { + return baseURL.replace(/\/+$/, ''); +} + +export async function listModels(opts?: { apiKey?: string; baseURL?: string }) { + const baseURL = normalizeBaseURL(opts?.baseURL ?? AI_API_URL); + const res = await fetch(`${baseURL}/models`, { + method: 'GET', + headers: getHeaders(opts?.apiKey) + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `listModels failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}` + ); + } + return (await res.json()) as OpenAIListModelsResponse; +} + +export async function chatCompletions( + body: OpenAIChatCompletionRequest, + opts?: { apiKey?: string; baseURL?: string; signal?: AbortSignal } +) { + const baseURL = normalizeBaseURL(opts?.baseURL ?? AI_API_URL); + const res = await fetch(`${baseURL}/chat/completions`, { + method: 'POST', + headers: getHeaders(opts?.apiKey), + body: JSON.stringify(body), + signal: opts?.signal + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `chatCompletions failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}` + ); + } + return (await res.json()) as OpenAIChatCompletionResponse; +} + +/** + * Stream OpenAI-compatible SSE (`stream: true`) and emit incremental tokens. + * Most gateways (Open-WebUI / LiteLLM / Ollama OpenAI proxy) follow: + * data: { choices: [{ delta: { content: "..." } }] } + * data: [DONE] + */ +export async function chatCompletionsStream( + body: Omit, + opts: { + apiKey?: string; + baseURL?: string; + signal?: AbortSignal; + onDelta: (text: string) => void; + onDone?: () => void; + } +) { + const baseURL = normalizeBaseURL(opts.baseURL ?? AI_API_URL); + const res = await fetch(`${baseURL}/chat/completions`, { + method: 'POST', + headers: getHeaders(opts.apiKey), + body: JSON.stringify({ ...body, stream: true }), + signal: opts.signal + }); + + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ''); + throw new Error( + `chatCompletionsStream failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}` + ); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // SSE events are separated by blank lines. + let idx = buffer.indexOf('\n\n'); + while (idx !== -1) { + const rawEvent = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + idx = buffer.indexOf('\n\n'); + + const lines = rawEvent + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data) continue; + if (data === '[DONE]') { + opts.onDone?.(); + return; + } + try { + const json = JSON.parse(data) as OpenAIChatCompletionResponse; + const delta = json.choices?.[0]?.delta?.content; + if (delta) { + opts.onDelta(delta); + } + } catch { + // Ignore malformed chunks + } + } + } + } + + opts.onDone?.(); +} diff --git a/src/config/setting.ts b/src/config/setting.ts index 0b11d7c..849db15 100644 --- a/src/config/setting.ts +++ b/src/config/setting.ts @@ -16,6 +16,18 @@ export const MODULES_API_URL = export const FILE_SERVER = import.meta.env.VITE_FILE_SERVER || 'https://your-file-server.com'; +// OpenAI-compatible gateway (Ollama/Open-WebUI/LiteLLM etc.) +export const AI_API_URL = + import.meta.env.VITE_AI_API_URL || + // Prefer same-origin reverse proxy during local development to avoid CORS. + (import.meta.env.DEV ? '/ai-proxy' : 'https://ai-api.websoft.top/api/v1'); + +// Ollama native API endpoint (usually http://host:11434). +// Note: browsers cannot call http from an https site (mixed-content); prefer same-origin proxy. +export const OLLAMA_API_URL = + import.meta.env.VITE_OLLAMA_API_URL || + (import.meta.env.DEV ? '/ollama-proxy' : 'http://47.119.165.234:11434'); + /** * 以下配置一般不需要修改 */ diff --git a/src/router/routes.ts b/src/router/routes.ts index 1890cc6..08fec94 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -60,6 +60,12 @@ export const routes = [ component: () => import('@/views/led/index.vue'), meta: { title: '医生出诊信息表' } }, + // AI 测试页面(无需菜单即可访问,登录后直接打开 /ai-test) + { + path: '/ai-test', + component: () => import('@/views/ai/index.vue'), + meta: { title: 'AI 测试' } + }, // { // path: '/forget', // component: () => import('@/views/passport/forget/index.vue'), diff --git a/src/views/ai/index.vue b/src/views/ai/index.vue new file mode 100644 index 0000000..9836114 --- /dev/null +++ b/src/views/ai/index.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/vite.config.ts b/vite.config.ts index 2401645..a4d60a1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import vue from '@vitejs/plugin-vue'; import ViteCompression from 'vite-plugin-compression'; import ViteComponents from 'unplugin-vue-components/vite'; @@ -44,7 +44,9 @@ function getSmartPort() { } } -export default defineConfig(({ command }) => { +export default defineConfig(({ command, mode }) => { + // Load env for Vite config usage (including non-VITE_ keys like AI_API_KEY). + const env = loadEnv(mode, process.cwd(), ''); const isBuild = command === 'build'; // 智能端口配置(仅在开发模式下) @@ -68,7 +70,7 @@ export default defineConfig(({ command }) => { // 代理配置 proxy: { '/api': { - target: process.env.VITE_API_URL || 'https://server.websoft.top', + target: env.VITE_API_URL || process.env.VITE_API_URL || 'https://server.websoft.top', changeOrigin: true, secure: false, configure: (proxy, _options) => { @@ -82,6 +84,39 @@ export default defineConfig(({ command }) => { console.log('Received Response from the Target:', proxyRes.statusCode, req.url); }); }, + }, + // OpenAI-compatible gateway reverse proxy (dev only). + // Example: + // GET /ai-proxy/models -> https://ai.websoft.top/api/v1/models + // POST /ai-proxy/chat/completions -> https://ai.websoft.top/api/v1/chat/completions + '/ai-proxy': { + target: env.AI_PROXY_TARGET || 'https://ai-api.websoft.top', + changeOrigin: true, + secure: false, + rewrite: (path) => + path.replace(/^\/ai-proxy/, env.AI_PROXY_REWRITE_PREFIX || '/api/v1'), + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + // Inject auth for local dev to avoid putting API keys in the browser. + const key = env.AI_API_KEY || process.env.AI_API_KEY; + if (key && !proxyReq.getHeader('Authorization')) { + const trimmed = String(key).trim(); + const value = /^bearer\\s+/i.test(trimmed) + ? trimmed + : `Bearer ${trimmed}`; + proxyReq.setHeader('Authorization', value); + } + }); + } + }, + // Ollama native API reverse proxy (dev only). + // GET /ollama-proxy/api/tags -> http://47.119.165.234:11434/api/tags + // POST /ollama-proxy/api/chat -> http://47.119.165.234:11434/api/chat + '/ollama-proxy': { + target: 'http://47.119.165.234:11434', + changeOrigin: true, + secure: false, + rewrite: (path) => path.replace(/^\/ollama-proxy/, '') } }, // 端口冲突时的处理