diff --git a/README.md b/README.md index 4a70ee7..47edb78 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,14 @@ admin admin123 super_admin 系统管理员测试 manager manager123 dept_manager 部门经理权限测试 employee employee123 employee 普通员工权限测试 + + +需求ai工作平台,场景 +1、用户和ai沟通工作,有现成流程给用户选择使用; +2、上司可以查看下属工作情况,ai会综合评估下属工作情况; +3、上司可以通过ai工作平台指派任务给下属; +4、流程由后台通过组件无代码搭建; +5、ai工作平台通过mcp服务获取系统数据,或者更新数据,mcp服务需要原系统支持, +6、知识库用于提升模型认知,提升专业程度; +7、预留企业微信接入逻辑 + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..7ee4c75 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,65 @@ +services: + postgres: + image: postgres:16-alpine + container_name: ent-postgres + restart: always + environment: + POSTGRES_USER: enterprise + POSTGRES_PASSWORD: enterprise123 + POSTGRES_DB: enterprise_ai + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init-db:/docker-entrypoint-initdb.d + ports: + - "5431:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U enterprise"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: ent-redis + restart: always + command: redis-server --appendonly yes --requirepass redis123 + volumes: + - redis_data:/data + ports: + - "6378:6379" + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: ent-backend + restart: always + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + environment: + DATABASE_URL: postgresql+asyncpg://enterprise:enterprise123@postgres:5432/enterprise_ai + REDIS_URL: redis://:redis123@redis:6379/0 + JWT_SECRET: dev-secret-key-change-in-production-32chars + LLM_API_KEY: ${LLM_API_KEY:-sk-placeholder} + LLM_API_BASE: ${LLM_API_BASE:-https://api.openai.com/v1} + LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini} + ports: + - "8100:8000" + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + container_name: ent-frontend + restart: always + depends_on: + - backend + ports: + - "80:80" + +volumes: + postgres_data: + redis_data: diff --git a/docker-compose.yml b/docker-compose.yml index 0b1a5b7..ae8201e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,12 @@ +# 开发环境配置文件 +# 使用方式: docker compose -f docker-compose.yml up --build -d +# +# 特点: +# - 前端使用 Vite 开发服务器,支持热更新 (HMR) +# - 代码修改后自动刷新浏览器 +# - Nginx 代理 WebSocket 连接用于 HMR +# - 挂载本地代码目录到容器 + services: postgres: image: postgres:16-alpine @@ -54,13 +63,18 @@ services: frontend: build: context: ./frontend - dockerfile: Dockerfile + dockerfile: Dockerfile.dev container_name: ent-frontend restart: always depends_on: - backend ports: - "3000:80" + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - CHOKIDAR_USEPOLLING=true nginx: image: nginx:alpine @@ -70,10 +84,10 @@ services: - frontend - backend volumes: - - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro ports: - "80:80" volumes: postgres_data: - redis_data: \ No newline at end of file + redis_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 67f7046..e340a9a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,12 +1,10 @@ -FROM node:20-alpine AS build +FROM node:20-alpine WORKDIR /app + COPY package*.json ./ RUN npm install -COPY . . -RUN npm run build -FROM nginx:alpine -COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 \ No newline at end of file +EXPOSE 80 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "80"] diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..e340a9a --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,10 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +EXPOSE 80 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "80"] diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..2223cc5 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,16 @@ +FROM node:20-alpine AS builder + +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY ../nginx/nginx.prod.conf /etc/nginx/nginx.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue index cc17a67..c4b85e0 100644 --- a/frontend/src/components/layout/MainLayout.vue +++ b/frontend/src/components/layout/MainLayout.vue @@ -59,6 +59,11 @@ 通知中心 + + + 流程管理 + + 个人中心 @@ -108,7 +113,7 @@ import { ref, computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useUserStore } from '@/stores/user' -import { Fold, User, ArrowDown, Tools, Search } from '@element-plus/icons-vue' +import { Fold, User, ArrowDown, Tools, Search, Promotion } from '@element-plus/icons-vue' import PortalSwitcher from '@/components/common/PortalSwitcher.vue' const route = useRoute() @@ -122,6 +127,7 @@ const activeMenu = computed(() => { if (path.startsWith('/user/task')) return '/user/task/list' if (path.startsWith('/user/agent')) return '/user/agent/list' if (path.startsWith('/user/document')) return '/user/document/manager' + if (path.startsWith('/user/flow')) return '/user/flow/list' if (path.startsWith('/user/wecom')) return '/user/wecom/config' if (path.startsWith('/user/notification')) return '/user/notification/center' if (path.startsWith('/user/settings')) return '/user/settings' diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index d912021..4211874 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -94,6 +94,24 @@ const router = createRouter({ component: () => import('@/views/notification/NotificationCenter.vue'), meta: { title: '通知中心' }, }, + { + path: 'flow/list', + name: 'UserFlowList', + component: () => import('@/views/flow/FlowList.vue'), + meta: { title: '流程管理', perms: ['flow:read'] }, + }, + { + path: 'flow/editor', + name: 'UserFlowEditor', + component: () => import('@/views/flow/FlowEditor.vue'), + meta: { title: '流编辑器', perms: ['flow:create'] }, + }, + { + path: 'flow/editor/:id', + name: 'UserFlowEditorEdit', + component: () => import('@/views/flow/FlowEditor.vue'), + meta: { title: '编辑流', perms: ['flow:update'] }, + }, { path: 'profile', name: 'Profile', @@ -211,29 +229,31 @@ const router = createRouter({ router.beforeEach(async (to, _from) => { const userStore = useUserStore() - if (!userStore.token) { - if (to.name === 'Login') { return true } - return { name: 'Login', query: { redirect: to.fullPath } } - } - - if (!userStore.user) { - try { - await userStore.fetchUser() - } catch { - return { name: 'Login', query: { redirect: to.fullPath } } + if (userStore.token) { + if (to.name === 'Login' || to.path === '/') { + return { name: 'Dashboard' } } - if (!userStore.isLoggedIn) { - return { name: 'Login', query: { redirect: to.fullPath } } + if (!userStore.user) { + try { + await userStore.fetchUser() + } catch { + userStore.logout() + return { name: 'Login', query: { redirect: to.fullPath } } + } + if (!userStore.isLoggedIn) { + return { name: 'Login', query: { redirect: to.fullPath } } + } } - } - - if (to.meta.perms && Array.isArray(to.meta.perms) && to.meta.perms.length > 0) { - if (!userStore.hasPermission(to.meta.perms[0])) { - return '/user/dashboard' + if (to.meta.perms && Array.isArray(to.meta.perms) && to.meta.perms.length > 0) { + if (!userStore.hasPermission(to.meta.perms[0])) { + return { name: 'Dashboard' } + } } + return true } - return true + if (to.name === 'Login') { return true } + return { name: 'Login', query: { redirect: to.fullPath } } }) export default router \ No newline at end of file diff --git a/frontend/src/views/flow/FlowCanvas.vue b/frontend/src/views/flow/FlowCanvas.vue index 40020bf..274dbd9 100644 --- a/frontend/src/views/flow/FlowCanvas.vue +++ b/frontend/src/views/flow/FlowCanvas.vue @@ -6,9 +6,11 @@ @edges-change="onEdgesChange" @connect="handleConnect" @node-click="$emit('node-click', $event)" - @pane-click="$emit('pane-click')" + @pane-click="onPaneClickLocal" @drop="$emit('drop', $event)" @dragover.prevent + @edge-click="onEdgeClick" + @contextmenu="onContextMenu" > @@ -32,7 +34,7 @@ diff --git a/frontend/src/views/flow/FlowEditor.vue b/frontend/src/views/flow/FlowEditor.vue index 9118c78..84db651 100644 --- a/frontend/src/views/flow/FlowEditor.vue +++ b/frontend/src/views/flow/FlowEditor.vue @@ -35,7 +35,9 @@

拖拽节点到画布

从绿色圆点拖线(true)

从红色圆点拖线(false)

-

右键点击边可删除

+

选中连线后按 Delete 删除

+

右键点击连线可删除

+

点击空白处取消选中

滚轮缩放画布

@@ -47,8 +49,8 @@ - - - - - - - - - - - - - - - + @@ -178,6 +92,14 @@ import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import { Promotion, ChatDotRound, Tools, Connection, Bell, DataAnalysis, Search } from '@element-plus/icons-vue' import FlowCanvas from './FlowCanvas.vue' +import TriggerConfig from './node-configs/TriggerConfig.vue' +import LlmConfig from './node-configs/LlmConfig.vue' +import ToolConfig from './node-configs/ToolConfig.vue' +import McpConfig from './node-configs/McpConfig.vue' +import WecomNotifyConfig from './node-configs/WecomNotifyConfig.vue' +import ConditionConfig from './node-configs/ConditionConfig.vue' +import RagConfig from './node-configs/RagConfig.vue' +import OutputConfig from './node-configs/OutputConfig.vue' const route = useRoute() const router = useRouter() @@ -212,6 +134,21 @@ const nodeTypes = [ { type: 'output', label: '输出节点', icon: Promotion, typeDesc: '结果输出' }, ] +const configComponentMap: Record = { + trigger: TriggerConfig, + llm: LlmConfig, + tool: ToolConfig, + mcp: McpConfig, + wecom_notify: WecomNotifyConfig, + condition: ConditionConfig, + rag: RagConfig, + output: OutputConfig, +} + +function getConfigComponent(type: string) { + return configComponentMap[type] || null +} + const colorMap: Record = { trigger: '#722ed1', llm: '#409EFF', @@ -255,17 +192,31 @@ function onDrop(event: DragEvent) { type: nodeData.type, typeDesc: nodeData.typeDesc, color: colorMap[nodeData.type] || '#409EFF', - config: nodeData.type === 'llm' ? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7, agent_id: '' } : {}, + config: getDefaultConfig(nodeData.type), }, draggable: true, connectable: true, }]) } +function getDefaultConfig(type: string) { + const defaults: Record = { + trigger: { event_type: 'text_message', callback_url: '' }, + llm: { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7, agent_id: '', max_tokens: 2000, context_length: 5, memory_mode: 'short_term', stream: true, tool_call: false }, + tool: { tool_type: '', tool_name: '', param_mapping: '{}', timeout: 30, retry_count: 0, error_handling: 'throw' }, + mcp: { mcp_server: '', tool_name: '', input_params: '{}', timeout: 30, response_parser: 'json', error_handling: 'throw' }, + wecom_notify: { message_template: '', target: '', message_type: 'text', async_send: false, error_handling: 'throw' }, + condition: { condition_type: 'expression', condition: '', true_label: '是', false_label: '否', short_circuit: true, default_branch: 'false' }, + rag: { knowledge_base: '', top_k: 5, search_mode: 'hybrid', similarity_threshold: 0.7, result_sort: 'similarity', include_metadata: true }, + output: { format: 'text', output_template: '', indent: 2, encoding: 'utf-8', truncate: false, max_length: 2000 }, + } + return defaults[type] || {} +} + function onNodeClick({ node }: any) { selectedNodeId.value = node.id const d = node.data || {} - selectedNodeData.value = { label: d.label || '', typeDesc: d.typeDesc || '', ...(d.config || {}) } + selectedNodeData.value = { label: d.label || '', typeDesc: d.typeDesc || '', config: { ...(d.config || {}) } } } function onPaneClick() { selectedNodeId.value = '' } @@ -279,38 +230,18 @@ function onConfigLabelChange() { } } -function onAgentSelect(val: string) { - if (!val) return - const agent = agentList.value.find(a => a.id === val) - if (agent) { - selectedNodeData.value.system_prompt = agent.system_prompt || '' - selectedNodeData.value.model = agent.model || 'gpt-4o-mini' - selectedNodeData.value.temperature = agent.temperature ?? 0.7 - } - onConfigChange() -} - function onConfigChange() { const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value) if (idx === -1) return const found = nodes.value[idx] - const nodeType = found.data.type - const cfg: any = {} - if (nodeType === 'trigger') cfg.event_type = selectedNodeData.value.event_type - else if (nodeType === 'llm') { cfg.system_prompt = selectedNodeData.value.system_prompt; cfg.model = selectedNodeData.value.model; cfg.temperature = selectedNodeData.value.temperature; cfg.agent_id = selectedNodeData.value.agent_id } - else if (nodeType === 'tool') cfg.tool_name = selectedNodeData.value.tool_name - else if (nodeType === 'mcp') { cfg.mcp_server = selectedNodeData.value.mcp_server; cfg.tool_name = selectedNodeData.value.tool_name } - else if (nodeType === 'wecom_notify') { cfg.message_template = selectedNodeData.value.message_template; cfg.target = selectedNodeData.value.target } - else if (nodeType === 'condition') cfg.condition = selectedNodeData.value.condition - else if (nodeType === 'rag') { cfg.knowledge_base = selectedNodeData.value.knowledge_base; cfg.top_k = selectedNodeData.value.top_k } - else if (nodeType === 'output') cfg.format = selectedNodeData.value.format const updated = { ...found } - updated.data = { ...found.data, config: cfg } + updated.data = { ...found.data, config: { ...selectedNodeData.value.config } } nodes.value[idx] = updated } function removeNode(id: string) { - canvasRef.value?.removeNodes?.([id]) + nodes.value = nodes.value.filter((n: any) => n.id !== id) + edges.value = edges.value.filter((e: any) => e.source !== id && e.target !== id) if (selectedNodeId.value === id) selectedNodeId.value = '' } @@ -344,7 +275,7 @@ async function loadFlow() { const source = e.source || e.from const target = e.target || e.to const cond = e.condition || e.sourceHandle - loadedEdges.push({ id: e.id || `edge_${source}_${target}`, source, target, sourceHandle: cond || 'source', type: 'smoothstep', animated: true, style: cond === 'false' ? { stroke: '#F56C6C', strokeWidth: 2 } : { stroke: '#409EFF', strokeWidth: 2 } }) + loadedEdges.push({ id: e.id || `edge_${source}_${target}`, source, target, sourceHandle: cond || 'source', type: 'smoothstep', animated: true, markerEnd: true, style: cond === 'false' ? { stroke: '#E53935', strokeWidth: 3 } : { stroke: '#1976D2', strokeWidth: 3 } }) } nodes.value = loadedNodes edges.value = loadedEdges @@ -367,8 +298,9 @@ async function saveFlow() { saving.value = true try { const { flowApi } = await import('@/api') - const serializedNodes = nodes.value.map((n: any) => ({ id: n.id, type: n.data?.type || n.type, label: n.data?.label || n.id, config: n.data?.config || {}, position: n.position })) - const serializedEdges = edges.value.map((e: any) => ({ source: e.source, target: e.target, sourceHandle: e.sourceHandle || 'source', condition: e.sourceHandle === 'false' ? 'false' : (e.sourceHandle === 'true' ? 'true' : undefined) })) + const snapshot = canvasRef.value?.getSnapshot() || { nodes: [], edges: [] } + const serializedNodes = snapshot.nodes.map((n: any) => ({ id: n.id, type: n.data?.type || n.type, label: n.data?.label || n.id, config: n.data?.config || {}, position: n.position })) + const serializedEdges = snapshot.edges.map((e: any) => ({ source: e.source, target: e.target, sourceHandle: e.sourceHandle || 'source', condition: e.sourceHandle === 'false' ? 'false' : (e.sourceHandle === 'true' ? 'true' : undefined) })) const payload = { name: flowName.value, description: flowDesc.value, nodes: serializedNodes, edges: serializedEdges, trigger: {} } if (isEdit.value) { await flowApi.updateFlow(flowId.value, payload); ElMessage.success('保存成功') } else { const res: any = await flowApi.createFlow(payload); const data = res?.data || res || {}; if (data.id) { router.replace(`/admin/flow/editor/${data.id}`); ElMessage.success('创建成功') } } diff --git a/frontend/src/views/flow/node-configs/ConditionConfig.vue b/frontend/src/views/flow/node-configs/ConditionConfig.vue new file mode 100644 index 0000000..47a53bf --- /dev/null +++ b/frontend/src/views/flow/node-configs/ConditionConfig.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/views/flow/node-configs/LlmConfig.vue b/frontend/src/views/flow/node-configs/LlmConfig.vue new file mode 100644 index 0000000..2e76b0c --- /dev/null +++ b/frontend/src/views/flow/node-configs/LlmConfig.vue @@ -0,0 +1,92 @@ + + + diff --git a/frontend/src/views/flow/node-configs/McpConfig.vue b/frontend/src/views/flow/node-configs/McpConfig.vue new file mode 100644 index 0000000..e1f3668 --- /dev/null +++ b/frontend/src/views/flow/node-configs/McpConfig.vue @@ -0,0 +1,63 @@ + + + diff --git a/frontend/src/views/flow/node-configs/OutputConfig.vue b/frontend/src/views/flow/node-configs/OutputConfig.vue new file mode 100644 index 0000000..2f505e1 --- /dev/null +++ b/frontend/src/views/flow/node-configs/OutputConfig.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/views/flow/node-configs/RagConfig.vue b/frontend/src/views/flow/node-configs/RagConfig.vue new file mode 100644 index 0000000..d4ba4a9 --- /dev/null +++ b/frontend/src/views/flow/node-configs/RagConfig.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/views/flow/node-configs/ToolConfig.vue b/frontend/src/views/flow/node-configs/ToolConfig.vue new file mode 100644 index 0000000..4ffc76b --- /dev/null +++ b/frontend/src/views/flow/node-configs/ToolConfig.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend/src/views/flow/node-configs/TriggerConfig.vue b/frontend/src/views/flow/node-configs/TriggerConfig.vue new file mode 100644 index 0000000..8bbed60 --- /dev/null +++ b/frontend/src/views/flow/node-configs/TriggerConfig.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/views/flow/node-configs/WecomNotifyConfig.vue b/frontend/src/views/flow/node-configs/WecomNotifyConfig.vue new file mode 100644 index 0000000..1eb0488 --- /dev/null +++ b/frontend/src/views/flow/node-configs/WecomNotifyConfig.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9d00db7..a3a43aa 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,10 +10,11 @@ export default defineConfig({ }, }, server: { - port: 3000, + host: '0.0.0.0', + port: 5173, proxy: { '/api': { - target: 'http://localhost:8000', + target: 'http://backend:8000', changeOrigin: true, }, }, diff --git a/hg-agents.zip b/hg-agents.zip deleted file mode 100644 index a01520c..0000000 Binary files a/hg-agents.zip and /dev/null differ diff --git a/nginx/nginx.dev.conf b/nginx/nginx.dev.conf new file mode 100644 index 0000000..fda2f4f --- /dev/null +++ b/nginx/nginx.dev.conf @@ -0,0 +1,52 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + upstream backend_api { + server backend:8000; + } + + upstream frontend_app { + server frontend:80; + } + + server { + listen 80; + + location / { + proxy_pass http://frontend_app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # WebSocket support for Vite HMR + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + location /api/ { + proxy_pass http://backend_api/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400s; + } + + location /wecom/ { + proxy_pass http://backend_api/wecom/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +} diff --git a/nginx/nginx.prod.conf b/nginx/nginx.prod.conf new file mode 100644 index 0000000..a846cc2 --- /dev/null +++ b/nginx/nginx.prod.conf @@ -0,0 +1,48 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + upstream backend_api { + server backend:8000; + } + + server { + listen 80; + + root /usr/share/nginx/html; + index index.html; + + # SPA fallback - try file first, then fallback to index.html + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend_api/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400s; + } + + location /wecom/ { + proxy_pass http://backend_api/wecom/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } +}