# Directory Structure
```
├── bin
│ ├── libs
│ │ ├── mcpcli.py
│ │ ├── mcpez.py
│ │ └── mcphandler.py
│ └── mcpproxy.py
├── docker-compose.yaml
├── dockerfile
├── imgs
│ ├── image-1.png
│ ├── image-2.png
│ ├── image-3.png
│ ├── image-4.png
│ └── image.png
├── mcpeasy-entrypoint.sh
├── mcpez
│ └── main.py
├── mcpez_ngx.conf
├── README.md
└── webui
├── chat.html
├── edit.html
├── index.html
├── js
│ ├── MCPEditor.js
│ └── serviceCtl.js
└── libs
├── ai
│ └── ai.js
└── mcpcli.js
```
# Files
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
```yaml
1 | version: '3.8'
2 | services:
3 | mcpez:
4 | image: mcpez
5 | build:
6 | context: .
7 | dockerfile: dockerfile
8 | ports:
9 | - "8777:80"
```
--------------------------------------------------------------------------------
/dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 |
2 | FROM alpine:latest
3 |
4 | RUN apk update
5 | RUN apk add bash openresty openrc python3 py3-pip sqlite pipx nodejs npm
6 | RUN pipx install uv
7 | RUN pipx ensurepath
8 | ENV PATH="/root/.local/bin:$PATH"
9 | RUN mkdir -p /data/app
10 | WORKDIR /data/app
11 | RUN uv venv
12 | RUN uv pip install tornado fastapi uvicorn sqlmodel httpx httpx-sse
13 | RUN mkdir -p /var/run/mcpez
14 | COPY ./mcpeasy-entrypoint.sh /mcpeasy-entrypoint.sh
15 | COPY mcpez_ngx.conf /etc/nginx/mcpez.conf
16 | COPY ./bin /data/app/bin
17 | COPY ./mcpez /data/app/mcpez
18 | COPY ./webui /data/app/webui
19 | RUN chmod +x /mcpeasy-entrypoint.sh
20 | ENTRYPOINT ["/mcpeasy-entrypoint.sh"]
```
--------------------------------------------------------------------------------
/mcpez_ngx.conf:
--------------------------------------------------------------------------------
```
1 | worker_processes 1;
2 | daemon on;
3 |
4 | events {
5 | worker_connections 102400;
6 | }
7 |
8 | http {
9 | include mime.types;
10 | default_type application/octet-stream;
11 |
12 | sendfile on;
13 | keepalive_timeout 65;
14 |
15 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
16 | '$status $body_bytes_sent "$http_referer" '
17 | '"$http_user_agent" "$http_x_forwarded_for"';
18 |
19 | server {
20 | listen 80;
21 |
22 | access_log /var/log/nginx/access.log main;
23 | error_log /var/log/nginx/error.log error;
24 |
25 | location / {
26 | # 界面界面
27 | root /data/app/webui;
28 | index index.html;
29 | }
30 |
31 | location /api {
32 | # API 代理设置
33 | rewrite ^/api/(.*)$ /$1 break;
34 | proxy_pass http://127.0.0.1:8000;
35 | proxy_set_header Host $host;
36 | proxy_set_header X-Real-IP $remote_addr;
37 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
38 | proxy_set_header X-Forwarded-Proto $scheme;
39 | proxy_connect_timeout 5s;
40 | }
41 |
42 |
43 | location /mcp/ {
44 | # MCP 代理设置,真的mcp都会在这里被挂载
45 | set $mcp_upstream "unix:/tmp";
46 | set $original_uri $request_uri;
47 |
48 | keepalive_timeout 180;
49 |
50 | rewrite_by_lua_block {
51 |
52 | local captures, err = ngx.re.match(ngx.var.request_uri, "^/mcp/([a-zA-Z0-9\\-_]+)(/.*)?","jio")
53 | if not captures then
54 | ngx.status = ngx.HTTP_BAD_REQUEST
55 | ngx.log(ngx.ERR, "Invalid request format: ", ngx.var.request_uri)
56 | ngx.say("Invalid request format")
57 | return
58 | end
59 |
60 | local key = captures[1]
61 | local path_suffix = captures[2] or "/"
62 |
63 |
64 | ngx.var.mcp_upstream = "unix:/var/run/mcpez/"..key..".sock"
65 | }
66 |
67 | # 代理设置
68 | proxy_pass http://$mcp_upstream;
69 | proxy_set_header Host $host;
70 | proxy_set_header X-Real-IP $remote_addr;
71 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
72 | proxy_set_header X-Forwarded-Proto $scheme;
73 | proxy_connect_timeout 5s;
74 | proxy_buffering off;
75 | }
76 |
77 | }
78 | }
```
--------------------------------------------------------------------------------
/bin/libs/mcpez.py:
--------------------------------------------------------------------------------
```python
1 | from libs.mcpcli import MCPCli
2 | from libs.mcphandler import make_mcp_handlers
3 | import copy
4 | import os
5 |
6 | class MCPEazy:
7 |
8 | def __init__(self, name, description=''):
9 | self.servers = {}
10 | self.name = name
11 | self.description = description
12 | self.functions = {}
13 | self.ctxStore = None
14 | self.pathroute = None
15 | self.apps = None
16 |
17 | async def add_mcp_server(self, name, config):
18 | subsrv = MCPCli(config)
19 | await subsrv.init()
20 | self.servers[name] = subsrv
21 | return self.servers[name]
22 |
23 | async def add_to_server(self, app, pathroute=''):
24 | self.apps = app
25 | self.pathroute = pathroute
26 | make_mcp_handlers(app, self, pathroute=pathroute, name=self.name)
27 |
28 | async def stop(self):
29 | names = list(self.servers.keys())
30 | for name in names:
31 | srv = self.servers.pop(name, None)
32 | srv.close()
33 | for route in self.apps.default_router.rules[0].target.rules:
34 | if route.target_kwargs.get('name') == self.name:
35 | self.apps.default_router.rules[0].target.rules.remove(route)
36 |
37 | def get_tools(self, openai=None):
38 | tools = []
39 | self.functions = {}
40 | for srv in self.servers.values():
41 | for tool in srv.tools:
42 | namehash = os.urandom(5).hex()
43 | self.functions[namehash] = {'name':tool['name'], 'srv':srv}
44 | _tool = copy.deepcopy(tool)
45 | _tool['name'] = namehash
46 | tools.append(_tool)
47 | return tools
48 |
49 | async def list_tools(self, req, session):
50 | await session.write_jsonrpc(req['id'], {'tools':self.get_tools()})
51 |
52 | async def call_tools(self, req, session):
53 | name = req['params'].get('name')
54 | if name not in self.functions:
55 | return await session.write_jsonrpc(req['id'], {'error': {'code': -32601, 'message': f"Method {name} not found"}})
56 | _srv = self.functions[name]
57 | try:
58 | req['params']['name'] = _srv['name']
59 | result = await _srv['srv'].request('tools/call', req['params'])
60 | return await session.write_jsonrpc(req['id'], {'result': result.get('result')})
61 | except Exception as e:
62 | return await session.write_jsonrpc(req['id'], {'error': {'code': -32603, 'message': str(e)}})
```
--------------------------------------------------------------------------------
/bin/mcpproxy.py:
--------------------------------------------------------------------------------
```python
1 | import asyncio
2 | import json
3 | import tornado.web
4 | import argparse
5 | import sys, os
6 | import logging
7 |
8 | from tornado.httpserver import HTTPServer
9 | from tornado.netutil import bind_unix_socket
10 |
11 | from libs.mcpez import MCPEazy
12 | from sqlmodel import Field, SQLModel, Session, create_engine
13 |
14 | logging.basicConfig(level=logging.DEBUG)
15 |
16 |
17 | optparser = argparse.ArgumentParser(description='MCP代理服务,支持从文件或数据库加载配置')
18 | optparser.add_argument('-c', '--id', type=int, default=0, help='app id')
19 | optparser.add_argument('-s', '--socketdir', default='/var/run/mcpez', help='服务器端口号,也可以是一个uds路径,')
20 | optparser.add_argument('-n', '--name', default='mcpproxy',help='代理服务名称')
21 | optargs = optparser.parse_args()
22 |
23 | class AppDB(SQLModel, table=True):
24 | id: int = Field(default=None, primary_key=True)
25 | name: str = Field(index=True)
26 | description: str = Field(default='')
27 | item_type: str = Field(index=True)
28 | config: str
29 | functions: str = None
30 | create_at: int = None
31 | modify_at: int = None
32 |
33 |
34 | class MCPProxy(MCPEazy):
35 | def __init__(self, name="MCPProxy"):
36 | super().__init__(name)
37 |
38 | async def load_config_from_db(self, app_id):
39 | engine = create_engine('sqlite:///./mcpez.db')
40 | with Session(engine) as session:
41 | try:
42 | app = session.get(AppDB, app_id)
43 | if not app: raise Exception('Config is not Found')
44 | if app.item_type != 'app': raise Exception('item is not a app')
45 | self.name = app.name
46 | self.description = app.description
47 | return json.loads(app.config)
48 | except Exception as e:
49 | logging.error(e)
50 | sys.exit(1)
51 |
52 | async def load_config(self):
53 | try:
54 | config = await self.load_config_from_db(optargs.id)
55 | active_servers = []
56 | for name, server_config in config.get('mcpServers', {}).items():
57 | try:
58 | await self.add_mcp_server(name, server_config)
59 | active_servers.append(name)
60 | except Exception as e:
61 | logging.error(e)
62 |
63 | if not active_servers: raise Exception('no active servers found')
64 | return active_servers
65 | except Exception as e:
66 | logging.error(f"配置加载失败: {str(e)}")
67 | return []
68 |
69 | @staticmethod
70 | async def start():
71 | proxy = MCPProxy(name=optargs.name)
72 | await proxy.load_config()
73 | app = tornado.web.Application(debug=True)
74 | await proxy.add_to_server(app, pathroute=f"/mcp/{optargs.id}")
75 | server = HTTPServer(app)
76 | socket_file = f"{optargs.socketdir}/{optargs.id}.sock"
77 | server.add_socket(bind_unix_socket(socket_file))
78 | os.system(f"chmod 777 {socket_file}")
79 | logging.info(f"HTTP Server runing at unix:{socket_file}:/mcp/{optargs.id}/sse")
80 | server.start()
81 |
82 |
83 | async def main():
84 | await MCPProxy.start()
85 | await asyncio.Event().wait()
86 |
87 |
88 | if __name__ == "__main__":
89 | asyncio.run(main())
```
--------------------------------------------------------------------------------
/webui/libs/mcpcli.js:
--------------------------------------------------------------------------------
```javascript
1 | (function(global) {
2 | class MCPCli {
3 | constructor(server, {fetcher=fetch, timeout=60000, format='openai'}={}) {
4 | this.server = server||'https://mcp-main.toai.chat/sse';
5 | this.endpoint = null;
6 | this.jsonrpc_id = 0;
7 | this.responses = {}
8 | this._tools = null
9 | this._format = format
10 | this.tools = []
11 | this.timeout = timeout
12 | this.fetcher = fetcher
13 | this.stream = null
14 | }
15 |
16 | eventLoop(options) {
17 | const { resolve, reject, timeout } = options;
18 | const Stream = new EventSource(this.server);
19 | this.stream = Stream
20 |
21 | setTimeout(()=>{
22 | if(!this.endpoint) { reject('timeout');Stream.close()}
23 | }, timeout||this.timeout||60000)
24 |
25 | Stream.addEventListener('message', (event) => {
26 | const data = JSON.parse(event.data);
27 | this.responses[data.id] && this.responses[data.id](data)
28 | });
29 |
30 | Stream.addEventListener('endpoint', (event) => {
31 | this.endpoint = event.data.startsWith('/') ? new URL(event.data, new URL(this.server)).toString() : event.data;
32 | resolve(this.endpoint)
33 | });
34 | }
35 |
36 | async connect(timeout) {
37 | await (new Promise((resolve, reject) => { this.eventLoop({resolve, reject, timeout});}))
38 | await this.send('initialize', {'protocolVersion':'2024-11-05', 'capabilities': {}, 'clientInfo': {'name': 'JSMCPCli', 'version': '0.1.0'}})
39 | this.send('notifications/initialized', {}, {idpp:false})
40 | this._tools = await this.send('tools/list')
41 | this.tools = this.transform(this._format)
42 | return this
43 | }
44 |
45 | close() {
46 | this.stream.close()
47 | }
48 |
49 | send(method, params={}, args={}) {
50 | const { idpp=true, timeout=this.timeout } = args;
51 | return new Promise((resolve, reject) => {
52 | idpp ? this.jsonrpc_id++ : null
53 | const bodyObject = { jsonrpc: '2.0', id: this.jsonrpc_id, method: method, params: params }
54 | setTimeout(()=>{
55 | if (!this.responses[this.jsonrpc_id]) return
56 | delete this.responses[this.jsonrpc_id]
57 | reject('timeout')
58 | }, timeout);
59 | this.responses[this.jsonrpc_id] = (data) => {
60 | if (!this.responses[this.jsonrpc_id]) return
61 | delete this.responses[this.jsonrpc_id]
62 | resolve(data.result)
63 | }
64 | fetch(this.endpoint, {method: 'POST', body: JSON.stringify(bodyObject),headers: { 'Content-Type': 'application/json' }})
65 | })
66 | }
67 |
68 | transform (format) {
69 | if (!this._tools) return []
70 | if (format === 'claude') return this._tools.tools
71 | if (format === 'openai') {
72 | return this._tools.tools.map(tool => {
73 | return {
74 | type: 'function',
75 | function: {
76 | name: tool.name,
77 | description: tool.description,
78 | parameters: {
79 | type: 'object',
80 | properties: tool.inputSchema.properties,
81 | required: tool.inputSchema.required,
82 | additionalProperties: false
83 | },
84 | strict: true
85 | },
86 | }
87 | })
88 | }
89 | }
90 |
91 | execute(name, args) {
92 | return this.send('tools/call', {name:name, arguments:args})
93 | }
94 |
95 | }
96 |
97 | global.MCPCli = MCPCli;
98 | if (typeof module !== 'undefined' && module.exports) {
99 | module.exports = { MCPCli };
100 | } else if (typeof define === 'function' && define.amd) {
101 | define(function() { return { MCPCli }; });
102 | }
103 |
104 | })(typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : this);
105 |
106 | //(new MCPCli()).connect().then(cli=>cli.execute('get_stock_price', {symbol:'AAPL'})).then(console.log).catch(console.error)
107 |
```
--------------------------------------------------------------------------------
/bin/libs/mcphandler.py:
--------------------------------------------------------------------------------
```python
1 | from tornado.web import RequestHandler
2 | import json
3 | import os
4 | import logging
5 | import time
6 |
7 |
8 | class SSEServer(RequestHandler):
9 |
10 | def initialize(self, *args, **kwargs):
11 | self._auto_finish = False
12 | self.ctxStore = kwargs.get('ctxStore')
13 | self.pathroute = kwargs.get('pathroute', '')
14 |
15 | def set_default_headers(self):
16 | self.set_header('Content-Type', 'text/event-stream')
17 | self.set_header('Cache-Control', 'no-cache')
18 | self.set_header('Connection', 'keep-alive')
19 | self.set_header('Access-Control-Allow-Origin', '*')
20 | self.set_header('Access-Control-Allow-Headers', 'Content-Type')
21 | self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
22 |
23 | def options(self): self.set_status(204)
24 |
25 | async def get(self):
26 | self.ctxid = os.urandom(16).hex()
27 | self.ctxStore[self.ctxid] = self
28 | await self.write_sse(self.pathroute+'/messages/?session_id=' + self.ctxid, 'endpoint')
29 |
30 | async def write_sse(self, data, event='message'):
31 | self.write('event: ' + event + '\r\ndata: ' + data + '\r\n\r\n')
32 | await self.flush()
33 |
34 | async def write_jsonrpc(self, req_id, result):
35 | response = {'jsonrpc': '2.0', 'id': req_id, 'result': result}
36 | await self.write_sse( json.dumps(response) )
37 |
38 | def on_connection_close(self):
39 | if not hasattr(self, 'ctxid'): return
40 | self.ctxStore.pop(self.ctxid, None)
41 |
42 |
43 |
44 |
45 | class RPCServer(RequestHandler):
46 |
47 | def initialize(self, *args, **kwargs):
48 | self.executor = kwargs.get('executor')
49 | self.ctxStore = kwargs.get('ctxStore')
50 |
51 | def set_default_headers(self):
52 | self.set_header('Content-Type', 'text/event-stream')
53 | self.set_header('Cache-Control', 'no-cache')
54 | self.set_header('Connection', 'keep-alive')
55 | self.set_header('Access-Control-Allow-Origin', '*')
56 | self.set_header('Access-Control-Allow-Headers', 'Content-Type')
57 | self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
58 |
59 | def options(self): self.set_status(204)
60 |
61 | async def post(self):
62 | ctxid = self.get_argument('session_id')
63 | session = self.ctxStore.get(ctxid)
64 | req = json.loads(self.request.body)
65 | req_method = req.get('method')
66 | func = self.for_name(req_method)
67 | if func: await func(req, session)
68 | self.set_status(202)
69 | self.finish('Accepted')
70 |
71 | def for_name(self, method):
72 | return getattr(self, 'with_' + method.replace('/', '_'), None)
73 |
74 | async def with_initialize(self, req, session):
75 | req_id = req.get('id')
76 | result = {"protocolVersion":"2024-11-05","capabilities":{"experimental":{},"prompts":{"listChanged":False},"resources":{"subscribe":False,"listChanged":False},"tools":{"listChanged":False}},"serverInfo":{"name":"mcpsrv","version":"1.3.0"}}
77 | await session.write_jsonrpc(req_id, result)
78 |
79 | async def with_tools_list(self, req, session):
80 | try:
81 | self.executor and await self.executor.list_tools(req, session)
82 | except Exception as e:
83 | await session.write_jsonrpc(req['id'], {'tools': []})
84 |
85 | async def with_ping(self, req, session):
86 | req_id = req.get('id')
87 | result = {}
88 | await session.write_jsonrpc(req_id, result)
89 |
90 | async def with_tools_call(self, req, session):
91 | try:
92 | self.executor and await self.executor.call_tools(req, session)
93 | except Exception as e:
94 | await session.write_jsonrpc(req['id'], {'result': 'error'})
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 | class ServerStatus(RequestHandler):
103 | def initialize(self, *args, **kwargs):
104 | self.ctxStore = kwargs.get('ctxStore')
105 | self.init_time = kwargs.get('init_time')
106 | self.name = kwargs.get('name')
107 | self.executor = kwargs.get('executor')
108 |
109 |
110 | def set_default_headers(self):
111 | self.set_header('Content-Type', 'application/json')
112 | self.set_header('Cache-Control', 'no-cache')
113 | self.set_header('Connection', 'keep-alive')
114 | self.set_header('Access-Control-Allow-Origin', '*')
115 | self.set_header('Access-Control-Allow-Headers', 'Content-Type')
116 | self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
117 |
118 | def options(self): self.set_status(204)
119 |
120 | async def get(self):
121 | self.finish(dict(
122 | name = self.name,
123 | description = self.executor.description,
124 | init_time = self.init_time,
125 | status = 'ok',
126 | connection_cnt = len(self.ctxStore),
127 | tools = self.executor.get_tools()
128 | ))
129 |
130 |
131 |
132 |
133 | def make_mcp_handlers(application, executor, pathroute='',name=''):
134 | ctxStore = {}
135 | executor.ctxStore = ctxStore
136 | executor.pathroute = pathroute
137 | application.add_handlers('.*', [
138 | (pathroute + '/sse', SSEServer, {'ctxStore': ctxStore, 'pathroute': pathroute, 'name':name}),
139 | (pathroute + '/messages/', RPCServer, {'executor': executor, 'ctxStore': ctxStore, 'name':name}),
140 | (pathroute + '/server_status', ServerStatus, {'ctxStore': ctxStore, 'executor': executor, 'name': name, 'init_time': int(time.time())}),
141 | ])
```
--------------------------------------------------------------------------------
/bin/libs/mcpcli.py:
--------------------------------------------------------------------------------
```python
1 | import asyncio
2 | import json
3 | import logging
4 |
5 |
6 | class MCPCli:
7 |
8 | def __init__(self, config, **kwargs):
9 | self.rpcid = 0
10 | self.rpc_responses = {}
11 | self.config = config
12 | self.name = kwargs.get('name')
13 | self.timeout = kwargs.get('timeout', 60)
14 | self.config['type'] = 'sse' if config.get('baseUrl') else 'stdio'
15 | self.write = None
16 | self.tools = None
17 | self.process = None
18 | logging.info('created Mcpcli instance')
19 |
20 |
21 | async def request(self, method, params, with_response=True, with_timeout=None):
22 | assert self.write, "Connection not established"
23 |
24 | if with_response:
25 | self.rpcid += 1
26 | json_rpc_data = {'method': method, 'params': params, 'jsonrpc': '2.0', 'id': self.rpcid}
27 | fut = asyncio.Future()
28 | self.rpc_responses[self.rpcid] = fut
29 | logging.debug(f"Sending request: {json_rpc_data}")
30 | await self.write(json.dumps(json_rpc_data).encode() + b'\n')
31 | try:
32 | return await asyncio.wait_for(fut, timeout=with_timeout or self.timeout)
33 | except asyncio.TimeoutError:
34 | raise TimeoutError(f"Timeout waiting for response to {method}({params})")
35 | else:
36 | json_rpc_data = {'method': method, 'params': params, 'jsonrpc': '2.0'}
37 | await self.write(json.dumps(json_rpc_data).encode() + b'\n')
38 |
39 |
40 | async def init(self):
41 | if self.config['type'] == 'stdio':
42 | await self.start_stdio_mcp()
43 | elif self.config['type'] == 'sse':
44 | await self.start_sse()
45 |
46 |
47 | def close(self):
48 | if self.config['type'] == 'stdio':
49 | self.process.terminate()
50 | elif self.config['type'] == 'sse':
51 | asyncio.ensure_future(self.process.aclose())
52 |
53 |
54 | async def start_sse(self):
55 | import urllib.parse
56 | from httpx_sse import EventSource
57 | import httpx
58 | client = httpx.AsyncClient(verify=False, timeout=httpx.Timeout(None, connect=10.0))
59 | session_addr = None
60 | is_connected = asyncio.Future()
61 |
62 | async def start_loop():
63 | try:
64 | async with client.stream('GET', self.config['baseUrl']) as response:
65 | self.process = client
66 | event_source = EventSource(response)
67 | async for event in event_source.aiter_sse():
68 | if client.is_closed: break
69 | if event.event == 'endpoint':
70 | session_addr = event.data if event.data.startswith('http') else urllib.parse.urljoin(self.config['baseUrl'], event.data)
71 | is_connected.set_result(session_addr)
72 | elif event.event == 'message':
73 | try:
74 | data = json.loads(event.data)
75 | if data.get('id') and data.get('result'):
76 | future = self.rpc_responses.get(data['id'])
77 | if future: future.set_result(data)
78 | except json.JSONDecodeError:
79 | logging.warning(f"Failed to decode JSON: {repr(event.data)}")
80 | except Exception as e:
81 | await client.aclose()
82 |
83 | def writer(data):
84 | return client.post(session_addr, data=data)
85 |
86 |
87 | asyncio.ensure_future(start_loop())
88 | session_addr = await is_connected
89 | self.write = writer
90 | await self.request('initialize', {'protocolVersion':'2024-11-05', 'capabilities': {}, 'clientInfo': {'name': 'EzMCPCli', 'version': '0.1.2'}}, with_response=False)
91 | await self.request('notifications/initialized', {}, with_response=False)
92 | r = await self.request('tools/list', {}, with_timeout=10)
93 | self.tools = r.get('result', {}).get('tools', [])
94 |
95 |
96 |
97 | async def start_stdio_mcp(self):
98 | proc = await asyncio.create_subprocess_exec(
99 | self.config['command'],
100 | *self.config['args'],
101 | env=self.config.get('env', None),
102 | stdin=asyncio.subprocess.PIPE,
103 | stdout=asyncio.subprocess.PIPE,
104 | stderr=asyncio.subprocess.PIPE
105 | )
106 | self.process = proc
107 |
108 | async def writer(data):
109 | proc.stdin.write(data)
110 |
111 | self.write = writer
112 |
113 | async def start_loop():
114 | while True:
115 | line = await proc.stdout.readline()
116 | if not line:
117 | break
118 | try:
119 | data = json.loads(line.decode())
120 | if 'id' in data and data['id'] in self.rpc_responses:
121 | self.rpc_responses[data['id']].set_result(data)
122 | del self.rpc_responses[data['id']]
123 | else:
124 | logging.debug(f"Received notification: {data}")
125 | except json.JSONDecodeError as e:
126 | logging.warning(f"Failed to decode JSON: {repr(line)}")
127 |
128 | asyncio.ensure_future(start_loop())
129 |
130 | await self.request('initialize', {'protocolVersion':'2024-11-05', 'capabilities': {}, 'clientInfo': {'name': 'EzMCPCli', 'version': '0.1.2'}}, with_response=False)
131 | await self.request('notifications/initialized', {}, with_response=False)
132 | r = await self.request('tools/list', {}, with_timeout=30)
133 | self.tools = r.get('result', {}).get('tools', [])
134 |
135 |
```
--------------------------------------------------------------------------------
/webui/libs/ai/ai.js:
--------------------------------------------------------------------------------
```javascript
1 | // Version: 1.1.0
2 | (function(global) {
3 | class AI {
4 | constructor(options = {}) {
5 | this.api_key = options.api_key || options.apiKey || '';
6 | this.baseURL = options.baseURL || options.endpoint || 'https://porky.toai.chat/to/openrouter';
7 | this.completionsURI = options.completionsURI === undefined ? '/chat/completions' : '';
8 | this.model = options.model || 'deepseek/deepseek-r1-distill-qwen-32b:free';
9 | this.messages = options.messages || [];
10 | this.system_prompt = options.system_prompt || options.sysprompt || '';
11 | this.abortController = null;
12 | this.toolFn = options.tool_fn || options.mcpserver || null;
13 | this.opts = { temperature: 0.7, max_tokens: 128000, top_p: 1, stream: true, ...options.opts };
14 | this.completions = { create: this.create.bind(this) };
15 | this.filters = {};
16 |
17 | this.add_response_filter('reasoning', data=>!!data.choices?.[0]?.delta?.reasoning);
18 |
19 | return this;
20 | }
21 |
22 | add_response_filter(queue_name, filterFn) {
23 | if (!this.filters[queue_name]) this.filters[queue_name] = [];
24 | this.filters[queue_name].push(filterFn);
25 | return this;
26 | }
27 |
28 | cancel() {
29 | if (this.abortController) {
30 | this.abortController.abort();
31 | this.abortController = null;
32 | }
33 | }
34 |
35 | async create(prompt, args) {
36 | const { options={}, images=[], audio=[], files=[] } = args || {};
37 |
38 | const messages = [...(options.messages || this.messages || [])];
39 | if (this.system_prompt) messages.unshift({ role: 'system', content: this.system_prompt });
40 | if (prompt || images?.length || audio?.length || files?.length)
41 | messages.push(this.prepareUserMessage(prompt, { images, audio, files }));
42 |
43 | const reqOptions = {
44 | model: options.model || this.model,
45 | messages,
46 | temperature: options.temperature || this.opts.temperature,
47 | max_tokens: options.max_tokens || this.opts.max_tokens,
48 | top_p: options.top_p || this.opts.top_p,
49 | stream: options.stream !== undefined ? options.stream : this.opts.stream,
50 | ...(this.toolFn?.tools && { tools: this.toolFn.tools, tool_choice: options.tool_choice || "auto" })
51 | };
52 |
53 | this.abortController = new AbortController();
54 |
55 | try {
56 | const response = await fetch(`${this.baseURL}${this.completionsURI}`, {
57 | method: 'POST',
58 | headers: {
59 | 'Content-Type': 'application/json',
60 | ...(this.api_key ? { 'Authorization': `Bearer ${this.api_key}` } : {})
61 | },
62 | body: JSON.stringify(reqOptions),
63 | signal: this.abortController.signal
64 | });
65 |
66 | if (!response.ok) {
67 | const error = await response.json().catch(() => ({}));
68 | throw new Error(`API错误: ${response.status} ${error.error?.message || ''}`);
69 | }
70 |
71 | if (!reqOptions.stream) {
72 | const result = await response.json();
73 | return new ResponseIterator({
74 | type: 'final',
75 | message: result.choices?.[0]?.message || { role: 'assistant', content: '' },
76 | ai: this, final: true,
77 | filters: this.filters
78 | });
79 | }
80 |
81 | const iter = new ResponseIterator({ ai: this, reqOptions, filters: this.filters });
82 | this.processStream(response, iter);
83 | return iter;
84 | } catch (error) { throw error; }
85 | }
86 |
87 | async processStream(response, iter) {
88 | const reader = response.body.getReader();
89 | const decoder = new TextDecoder();
90 | const msg = { role: 'assistant', content: '' }; // 移除 reasoning_content 字段
91 | const toolCalls = [];
92 | let buffer = '';
93 |
94 | try {
95 | while (true) {
96 | const { done, value } = await reader.read();
97 | if (done) break;
98 |
99 | buffer += decoder.decode(value, { stream: true });
100 | const lines = buffer.split('\n');
101 | buffer = lines.pop() || '';
102 |
103 | for (const line of lines) {
104 | const event = this.parseSSELine(line);
105 | if (!event) continue;
106 |
107 | if (event.isDone) {
108 | await this.finalizeResponse(msg, toolCalls, iter);
109 | return;
110 | }
111 |
112 | await this.processEvent(event, msg, toolCalls, iter);
113 | }
114 | }
115 |
116 | // 处理最后可能的缓冲数据
117 | if (buffer.trim()) {
118 | const event = this.parseSSELine(buffer);
119 | if (event && !event.isDone) {
120 | await this.processEvent(event, msg, toolCalls, iter);
121 | }
122 | }
123 |
124 | await this.finalizeResponse(msg, toolCalls, iter);
125 | } catch (error) {
126 | iter.error(error);
127 | } finally {
128 | reader.releaseLock();
129 | this.abortController = null;
130 | }
131 | }
132 |
133 | parseSSELine(line) {
134 | line = line.trim();
135 | if (!line) return null;
136 |
137 | // 检测[DONE]标记
138 | if (line === 'data: [DONE]') {
139 | return { isDone: true };
140 | }
141 |
142 | // 解析数据
143 | let eventType = '';
144 | let data = '';
145 |
146 | if (line.startsWith('event:')) {
147 | eventType = line.slice(6).trim();
148 | } else if (line.startsWith('data:')) {
149 | data = line.slice(5).trim();
150 | try {
151 | return { type: eventType || 'content', data: JSON.parse(data), isDone: false };
152 | } catch (e) {
153 | return { type: eventType || 'content', data, isDone: false, raw: true };
154 | }
155 | } else {
156 | try {
157 | return { type: 'content', data: JSON.parse(line), isDone: false };
158 | } catch (e) {
159 | return { type: 'content', data: line, isDone: false, raw: true };
160 | }
161 | }
162 |
163 | return null;
164 | }
165 |
166 | async processEvent(event, msg, toolCalls, iter) {
167 | const { type, data, raw } = event;
168 |
169 | // 应用过滤器并推送到队列
170 | Object.keys(this.filters).forEach((filter_name)=>{
171 | const filter = this.filters[filter_name];
172 | const shouldpush = filter.some(f=>f(data))
173 | if (shouldpush) {
174 | iter.push(filter_name, { type: filter_name, message: {...msg}, delta: data });
175 | }
176 | })
177 |
178 | // 处理对象数据
179 | if (!raw && typeof data === 'object') {
180 | // 处理工具调用
181 | if (data.choices && data.choices[0]?.delta?.tool_calls) {
182 | this.updateToolCalls(data.choices[0].delta.tool_calls, toolCalls);
183 | iter.push('tool_calls', { type: 'tool_calls', toolCalls: [...toolCalls], message: {...msg}, delta: data });
184 |
185 | if (data.choices[0].finish_reason === 'tool_calls') {
186 | msg.tool_calls = [...toolCalls];
187 | iter.push('tool_request', { type: 'tool_request', toolCalls: [...toolCalls], message: {...msg} });
188 | return await this.handleToolCalls(msg, toolCalls, iter);
189 | }
190 | return;
191 | }
192 |
193 | // 处理内容更新
194 | const content = data.choices?.[0]?.delta?.content || '';
195 | if (content) {
196 | msg.content += content;
197 | iter.push('content', { type: 'content', content, message: {...msg}, delta: content });
198 | return;
199 | }
200 | } else {
201 | // 处理字符串和其他类型的数据
202 | switch (type) {
203 | case 'tool_calls':
204 | if (Array.isArray(data)) {
205 | this.updateToolCalls(data, toolCalls);
206 | iter.push('tool_calls', { type, toolCalls: [...toolCalls], message: {...msg}, delta: data });
207 |
208 | if (toolCalls.every(t => t.function?.arguments)) {
209 | msg.tool_calls = [...toolCalls];
210 | iter.push('tool_request', { type: 'tool_request', toolCalls: [...toolCalls], message: {...msg} });
211 | return await this.handleToolCalls(msg, toolCalls, iter);
212 | }
213 | }
214 | break;
215 | case 'content':
216 | if (data) {
217 | msg.content += data;
218 | iter.push('content', { type, content: data, message: {...msg}, delta: data });
219 | }
220 | break;
221 | }
222 | }
223 | }
224 |
225 | async finalizeResponse(msg, toolCalls, iter) {
226 | if (toolCalls.length > 0 && toolCalls.every(t => t.function?.name)) {
227 | msg.tool_calls = [...toolCalls];
228 | iter.push('tool_request', { type: 'tool_request', toolCalls: [...toolCalls], message: {...msg} });
229 | return await this.handleToolCalls(msg, toolCalls, iter);
230 | } else {
231 | iter.push('final', { type: 'final', message: msg });
232 | iter.complete();
233 | }
234 | }
235 |
236 | async handleToolCalls(msg, toolCalls, iter) {
237 | if (!this.toolFn) {
238 | iter.push('final', { type: 'final', message: msg });
239 | iter.complete();
240 | return;
241 | }
242 |
243 | const results = await Promise.all(toolCalls.map(async tool => {
244 | try {
245 | const args = JSON.parse(tool.function.arguments);
246 | const result = await this.toolFn.execute(tool.function.name, args);
247 | return {
248 | tool_call_id: tool.id,
249 | role: "tool",
250 | content: typeof result === 'string' ? result : JSON.stringify(result)
251 | };
252 | } catch (err) {
253 | return {
254 | tool_call_id: tool.id,
255 | role: "tool",
256 | content: JSON.stringify({ error: err.message })
257 | };
258 | }
259 | }));
260 |
261 | iter.push('tool_response', { type: 'tool_response', toolResults: results, toolCalls });
262 |
263 | try {
264 | // 继续对话,将工具结果添加到消息历史中
265 | const nextResponse = await this.create("", {
266 | options: {
267 | ...iter.reqOptions,
268 | messages: [
269 | ...iter.reqOptions.messages,
270 | {...msg},
271 | ...results.map(r => ({
272 | role: "tool",
273 | tool_call_id: r.tool_call_id,
274 | content: r.content
275 | }))
276 | ],
277 | tool_choice: "auto"
278 | }
279 | });
280 | iter.chainWith(nextResponse);
281 | } catch (error) { iter.error(error); }
282 | }
283 |
284 | prepareUserMessage(message, { images, audio, files } = {}) {
285 | if ((!images?.length) && (!audio?.length) && (!files?.length))
286 | return { role: "user", content: message };
287 |
288 | const content = [];
289 | if (message?.trim()) content.push({ type: "text", text: message });
290 |
291 | if (images?.length) images.forEach(img => content.push({
292 | type: "image_url", image_url: { url: img.url || img, detail: img.detail || "auto" }
293 | }));
294 |
295 | if (audio?.length) audio.forEach(clip =>
296 | content.push({ type: "audio", audio: { url: clip.url || clip } }));
297 |
298 | if (files?.length) files.forEach(file =>
299 | content.push({ type: "file", file: { url: file.url || file } }));
300 |
301 | return { role: "user", content };
302 | }
303 |
304 | updateToolCalls(deltas, toolCalls) {
305 | if (!Array.isArray(deltas)) return;
306 |
307 | deltas.forEach(d => {
308 | const idx = d.index;
309 | if (!toolCalls[idx]) toolCalls[idx] = {
310 | id: d.id || '', type: 'function', function: { name: '', arguments: '' }
311 | };
312 |
313 | if (d.id) toolCalls[idx].id = d.id;
314 | if (d.function?.name) toolCalls[idx].function.name = d.function.name;
315 | if (d.function?.arguments) toolCalls[idx].function.arguments += d.function.arguments;
316 | });
317 | }
318 | }
319 |
320 | class ResponseIterator {
321 | constructor({ ai, reqOptions, type, message, final = false, filters = {} }) {
322 | this.ai = ai;
323 | this.reqOptions = reqOptions;
324 | this.queues = new Map();
325 | this.waiters = new Map();
326 | this.chained = null;
327 | this.completed = false;
328 | this.filters = filters;
329 |
330 | // 创建所有队列 - 移除 reasoning_content
331 | ['content', 'tool_calls', 'tool_request',
332 | 'tool_response', 'streaming', 'final', ...Object.keys(filters)].forEach(t => {
333 | this.queues.set(t, []);
334 | this.waiters.set(t, []);
335 | });
336 |
337 | if (final && type && message) {
338 | this.push(type, { type, message });
339 | this.complete();
340 | }
341 | }
342 |
343 | push(type, data) {
344 | if (this.completed) return;
345 | // 仅处理基本队列
346 | const queue = this.queues.get(type) || [];
347 | const waiters = this.waiters.get(type) || [];
348 | queue.push(data);
349 | if (waiters.length) waiters.shift()();
350 | }
351 |
352 | complete() {
353 | this.completed = true;
354 | for (const waiters of this.waiters.values()) waiters.forEach(r => r());
355 | }
356 |
357 | chainWith(r) { this.chained = r; this.complete(); }
358 | error(e) { this.errorObj = e; this.complete(); }
359 |
360 | on(type) {
361 | if (!this.queues.has(type)) throw new Error(`未知事件类型: ${type}`);
362 | const self = this;
363 |
364 | return {
365 | async *[Symbol.asyncIterator]() {
366 | try {
367 | if (self.errorObj) throw self.errorObj;
368 | let queue = self.queues.get(type), index = 0;
369 |
370 | while (true) {
371 | if (index >= queue.length) {
372 | if (self.completed && !self.chained) break;
373 | if (self.chained) {
374 | for await (const item of self.chained.on(type)) yield item;
375 | break;
376 | }
377 |
378 | await new Promise(r => self.waiters.get(type).push(r));
379 | if (self.errorObj) throw self.errorObj;
380 | queue = self.queues.get(type);
381 | } else yield queue[index++];
382 | }
383 | } catch (e) { throw e; }
384 | }
385 | };
386 | }
387 |
388 | async *[Symbol.asyncIterator]() { yield* this.on('streaming'); }
389 | }
390 |
391 | global.AI = AI;
392 | if (typeof module !== 'undefined' && module.exports) {
393 | module.exports = { AI };
394 | } else if (typeof define === 'function' && define.amd) {
395 | define(function() { return { AI }; });
396 | }
397 | })(typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : this);
```
--------------------------------------------------------------------------------
/webui/index.html:
--------------------------------------------------------------------------------
```html
1 | <!DOCTYPE html>
2 | <html lang="zh-CN">
3 | <head>
4 | <meta charset="UTF-8">
5 | <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 | <title>MCP服务状态管理</title>
7 | <!-- 引入TailwindCSS -->
8 | <script src="https://cdn.tailwindcss.com"></script>
9 | <script>
10 | tailwind.config = {
11 | theme: {
12 | extend: {
13 | colors: {
14 | primary: {
15 | 50: '#e3f2fd',
16 | 100: '#bbdefb',
17 | 200: '#90caf9',
18 | 300: '#64b5f6',
19 | 400: '#42a5f5',
20 | 500: '#2196f3',
21 | 600: '#1e88e5',
22 | 700: '#1976d2',
23 | 800: '#1565c0',
24 | 900: '#0d47a1'
25 | },
26 | secondary: '#64748b'
27 | }
28 | }
29 | }
30 | }
31 | </script>
32 | <!-- 引入UIkit -->
33 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/uikit.min.css" />
34 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit.min.js"></script>
35 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit-icons.min.js"></script>
36 | <style>
37 | .uk-card {
38 | border-radius: 1rem;
39 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
40 | }
41 | .uk-button {
42 | border-radius: 0.5rem;
43 | transition: all 0.2s ease;
44 | }
45 | .uk-button-primary {
46 | background: linear-gradient(145deg, #1e88e5, #1976d2);
47 | box-shadow: 0 4px 6px -1px rgba(30, 136, 229, 0.2);
48 | }
49 | .uk-button-primary:hover {
50 | transform: translateY(-1px);
51 | box-shadow: 0 10px 15px -3px rgba(30, 136, 229, 0.3);
52 | }
53 | .uk-input, .uk-select, .uk-textarea {
54 | border-radius: 0.5rem;
55 | transition: all 0.2s ease;
56 | }
57 | .uk-input:focus, .uk-select:focus, .uk-textarea:focus {
58 | box-shadow: 0 0 0 3px rgba(30, 136, 229, 0.2);
59 | }
60 | .uk-form-label {
61 | font-weight: 500;
62 | margin-bottom: 0.5rem;
63 | color: #334155;
64 | }
65 | .service-table tr {
66 | transition: all 0.2s ease;
67 | }
68 | .service-table tr:hover {
69 | background-color: rgba(30, 136, 229, 0.05);
70 | }
71 | .service-table tr.active {
72 | background-color: rgba(30, 136, 229, 0.1);
73 | border-left: 4px solid #1e88e5;
74 | }
75 | .badge {
76 | padding: 0.25rem 0.75rem;
77 | border-radius: 9999px;
78 | font-size: 0.75rem;
79 | font-weight: 500;
80 | }
81 | /* 定制滚动条 */
82 | ::-webkit-scrollbar {
83 | width: 6px;
84 | height: 6px;
85 | }
86 | ::-webkit-scrollbar-track {
87 | background: #f1f1f1;
88 | border-radius: 3px;
89 | }
90 | ::-webkit-scrollbar-thumb {
91 | background: #c1c1c1;
92 | border-radius: 3px;
93 | }
94 | ::-webkit-scrollbar-thumb:hover {
95 | background: #a8a8a8;
96 | }
97 | @media (max-width: 959px) {
98 | .mobile-full-width {
99 | width: 100% !important;
100 | }
101 | }
102 | </style>
103 | </head>
104 | <body class="bg-slate-100 min-h-screen">
105 | <!-- 导航栏 -->
106 | <nav class="bg-gradient-to-r from-primary-700 to-primary-500 shadow-lg">
107 | <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
108 | <div class="flex justify-between h-16">
109 | <div class="flex items-center text-white">
110 | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
111 | <path stroke-linecap="round" stroke-linejoin="round" d="m20.893 13.393-1.135-1.135a2.252 2.252 0 0 1-.421-.585l-1.08-2.16a.414.414 0 0 0-.663-.107.827.827 0 0 1-.812.21l-1.273-.363a.89.89 0 0 0-.738 1.595l.587.39c.59.395.674 1.23.172 1.732l-.2.2c-.212.212-.33.498-.33.796v.41c0 .409-.11.809-.32 1.158l-1.315 2.191a2.11 2.11 0 0 1-1.81 1.025 1.055 1.055 0 0 1-1.055-1.055v-1.172c0-.92-.56-1.747-1.414-2.089l-.655-.261a2.25 2.25 0 0 1-1.383-2.46l.007-.042a2.25 2.25 0 0 1 .29-.787l.09-.15a2.25 2.25 0 0 1 2.37-1.048l1.178.236a1.125 1.125 0 0 0 1.302-.795l.208-.73a1.125 1.125 0 0 0-.578-1.315l-.665-.332-.091.091a2.25 2.25 0 0 1-1.591.659h-.18c-.249 0-.487.1-.662.274a.931.931 0 0 1-1.458-1.137l1.411-2.353a2.25 2.25 0 0 0 .286-.76m11.928 9.869A9 9 0 0 0 8.965 3.525m11.928 9.868A9 9 0 1 1 8.965 3.525" />
112 | </svg>
113 | <span class="ml-2 text-xl font-bold">MCP服务状态管理</span>
114 | </div>
115 | <div class="flex items-center gap-6">
116 | <div class="relative">
117 | <input type="text" id="searchApp" class="border pl-10 h-10 rounded-xl border-gray-200 bg-white bg-opacity-20 text-white placeholder-gray-300 focus:border-white focus:ring focus:ring-white focus:ring-opacity-20 transition-all" placeholder="搜索应用...">
118 | <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
119 | <svg class="w-5 h-5 text-gray-300" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
120 | <path d="M21 21L15 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
121 | </svg>
122 | </div>
123 | </div>
124 | <a class="hover:text-white hover:no-underline bg-white bg-opacity-20 hover:bg-opacity-30 text-white rounded-lg px-4 py-2 flex items-center transition-all" href="chat.html" target="_blank">
125 | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 mr-2">
126 | <path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9.75 16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" />
127 | </svg>
128 | PlayGround
129 | </a>
130 | </div>
131 | </div>
132 | </div>
133 | </nav>
134 |
135 | <!-- 主界面 -->
136 | <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
137 | <div class="bg-white rounded-2xl shadow-xl p-6 overflow-hidden">
138 | <div class="flex justify-between items-center mb-6">
139 | <h2 class="text-2xl font-bold text-gray-800">应用服务列表</h2>
140 | <div><a href="edit.html" target="_blank" class="uk-button-small border rounded-lg py-2 hover:text-gray-900 hover:no-underline"> 新建服务 </a> </div>
141 | </div>
142 |
143 | <!-- 加载指示器 -->
144 | <div id="statusLoading" class="flex justify-center items-center py-8 hidden">
145 | <div uk-spinner></div>
146 | <span class="ml-3">加载服务状态...</span>
147 | </div>
148 |
149 | <div class="overflow-x-auto max-h-[600px] overflow-y-auto">
150 | <table class="w-full text-left service-table">
151 | <thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider sticky top-0 z-10">
152 | <tr>
153 | <th class="px-4 py-3 rounded-tl-lg">应用ID</th>
154 | <th class="px-4 py-3">名称</th>
155 | <th class="px-4 py-3">描述</th>
156 | <th class="px-4 py-3">状态</th>
157 | <th class="px-4 py-3">详情</th>
158 | <th class="px-4 py-3 rounded-tr-lg text-right">操作</th>
159 | </tr>
160 | </thead>
161 | <tbody id="serviceTableBody" class="divide-y divide-gray-100">
162 | <!-- 应用列表项会动态添加到这里 -->
163 | </tbody>
164 | </table>
165 | </div>
166 |
167 | <div id="noServicesMessage" class="text-center py-10 text-gray-500">
168 | 还没有可运行的MCP服务,请开始 <a href="edit.html" target="_blank">[新建服务]</a>
169 | </div>
170 |
171 | </div>
172 | </div>
173 |
174 | <!-- 应用详情模态框 -->
175 | <div id="app-details-modal" uk-modal>
176 | <div class="uk-modal-dialog uk-modal-body rounded-xl w-[80%]">
177 | <button class="uk-modal-close-default" type="button" uk-close></button>
178 | <h2 class="text-2xl font-bold mb-4" id="appModalTitle">应用详情</h2>
179 |
180 | <div class="space-y-4">
181 | <div>
182 | <h3 class="text-sm font-medium text-gray-500">应用描述</h3>
183 | <p id="appModalDescription" class="mt-1 text-gray-900">加载中...</p>
184 | </div>
185 |
186 | <div>
187 | <h3 class="text-sm font-medium text-gray-500">服务配置</h3>
188 | <div class="mt-2 bg-gray-50 p-4 rounded-lg">
189 | <pre id="appModalConfig" class="whitespace-pre-wrap overflow-x-auto text-xs">加载中...</pre>
190 | </div>
191 | </div>
192 | </div>
193 |
194 | <div class="flex justify-end space-x-4 mt-6">
195 | <button type="button" id="startServiceBtn" class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
196 | <svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
197 | <path d="M8 5v14l11-7-11-7z" fill="currentColor"/>
198 | </svg>
199 | 启动服务
200 | </button>
201 | <button type="button" class="uk-modal-close inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium rounded-lg transition-colors">
202 | <svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
203 | <path d="M6 18L18 6M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
204 | </svg>
205 | 关闭
206 | </button>
207 | </div>
208 | </div>
209 | </div>
210 |
211 | <!-- 服务详情模态框 -->
212 | <div id="service-details-modal" uk-modal>
213 | <div class="uk-modal-dialog uk-modal-body rounded-xl w-[80%]">
214 | <button class="uk-modal-close-default" type="button" uk-close></button>
215 | <h2 class="text-2xl font-bold mb-4">服务状态详情</h2>
216 |
217 | <div id="serviceStatusLoading" class="flex justify-center items-center py-8">
218 | <div uk-spinner></div>
219 | <span class="ml-3">加载服务状态...</span>
220 | </div>
221 |
222 | <div id="serviceStatusContent" class="space-y-4 hidden">
223 | <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
224 | <div>
225 | <h3 class="text-sm font-medium text-gray-500">服务ID</h3>
226 | <p id="serviceModalId" class="mt-1 text-gray-900">-</p>
227 | </div>
228 |
229 | <div>
230 | <h3 class="text-sm font-medium text-gray-500">当前状态</h3>
231 | <div id="serviceModalStatus" class="mt-1">-</div>
232 | </div>
233 |
234 | <div>
235 | <h3 class="text-sm font-medium text-gray-500">服务地址</h3>
236 | <p id="serviceModalAddress" class="mt-1 text-gray-900 break-all">-</p>
237 | </div>
238 | </div>
239 |
240 | <div>
241 | <h3 class="text-sm font-medium text-gray-500">详细信息</h3>
242 | <div class="mt-2 bg-gray-50 p-4 rounded-lg">
243 | <pre id="serviceModalDetails" class="whitespace-pre-wrap overflow-x-auto text-xs">无可用信息</pre>
244 | </div>
245 | </div>
246 | </div>
247 |
248 | <div id="serviceStatusError" class="bg-red-50 text-red-600 p-4 rounded-lg mb-4 hidden">
249 | 无法获取服务状态信息
250 | </div>
251 |
252 | <div class="flex justify-end space-x-4 mt-6">
253 | <button type="button" id="stopServiceDetailsBtn" class="inline-flex items-center px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
254 | <svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
255 | <path d="M6 6h12v12H6z" fill="currentColor"/>
256 | </svg>
257 | 停止服务
258 | </button>
259 | <button type="button" id="refreshServiceDetailsBtn" class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors">
260 | <svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
261 | <path d="M4 4V9H4.58152M19.9381 11C19.446 7.05369 16.0796 4 12 4C8.64262 4 5.76829 6.06817 4.58152 9M4.58152 9H9M20 20V15H19.4185M19.4185 15C18.2317 17.9318 15.3574 20 12 20C7.92038 20 4.55399 16.9463 4.06189 13M19.4185 15H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
262 | </svg>
263 | 刷新
264 | </button>
265 | <button type="button" class="uk-modal-close inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium rounded-lg transition-colors">
266 | 关闭
267 | </button>
268 | </div>
269 | </div>
270 | </div>
271 |
272 | <!-- 引入控制器脚本 -->
273 | <script src="js/serviceCtl.js"></script>
274 | <script>
275 | document.addEventListener('DOMContentLoaded', function() {
276 | // 初始化服务控制器
277 | const controller = new ServiceController();
278 | controller.init();
279 | });
280 | </script>
281 | </body>
282 | </html>
283 |
```
--------------------------------------------------------------------------------
/webui/edit.html:
--------------------------------------------------------------------------------
```html
1 | <!DOCTYPE html>
2 | <html lang="zh-CN">
3 | <head>
4 | <meta charset="UTF-8">
5 | <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 | <title>MCP应用编辑器</title>
7 | <!-- TailwindCSS -->
8 | <script src="https://cdn.tailwindcss.com"></script>
9 | <script>
10 | tailwind.config = {
11 | theme: {
12 | extend: {
13 | colors: {
14 | primary: {
15 | 50: '#e3f2fd',
16 | 100: '#bbdefb',
17 | 200: '#90caf9',
18 | 300: '#64b5f6',
19 | 400: '#42a5f5',
20 | 500: '#2196f3',
21 | 600: '#1e88e5',
22 | 700: '#1976d2',
23 | 800: '#1565c0',
24 | 900: '#0d47a1'
25 | },
26 | secondary: '#64748b'
27 | }
28 | }
29 | }
30 | }
31 | </script>
32 | <!-- UIkit CSS -->
33 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/uikit.min.css" />
34 | <!-- UIkit JS -->
35 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit.min.js"></script>
36 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit-icons.min.js"></script>
37 | <style>
38 | .uk-card {
39 | border-radius: 1rem;
40 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
41 | }
42 | .uk-button {
43 | border-radius: 0.5rem;
44 | transition: all 0.2s ease;
45 | }
46 | .uk-button-primary {
47 | background: linear-gradient(145deg, #1e88e5, #1976d2);
48 | box-shadow: 0 4px 6px -1px rgba(30, 136, 229, 0.2);
49 | }
50 | .uk-button-primary:hover {
51 | transform: translateY(-1px);
52 | box-shadow: 0 10px 15px -3px rgba(30, 136, 229, 0.3);
53 | }
54 | .uk-input, .uk-select, .uk-textarea {
55 | border-radius: 0.5rem;
56 | transition: all 0.2s ease;
57 | }
58 | .uk-input:focus, .uk-select:focus, .uk-textarea:focus {
59 | box-shadow: 0 0 0 3px rgba(30, 136, 229, 0.2);
60 | }
61 | .uk-form-label {
62 | font-weight: 500;
63 | margin-bottom: 0.5rem;
64 | color: #334155;
65 | }
66 | </style>
67 | </head>
68 | <body class="bg-slate-100 min-h-screen">
69 | <!-- 导航栏 -->
70 | <nav class="bg-gradient-to-r from-primary-700 to-primary-500 shadow-lg">
71 | <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
72 | <div class="flex justify-between h-16">
73 | <div class="flex items-center text-white">
74 |
75 | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
76 | <path stroke-linecap="round" stroke-linejoin="round" d="m20.893 13.393-1.135-1.135a2.252 2.252 0 0 1-.421-.585l-1.08-2.16a.414.414 0 0 0-.663-.107.827.827 0 0 1-.812.21l-1.273-.363a.89.89 0 0 0-.738 1.595l.587.39c.59.395.674 1.23.172 1.732l-.2.2c-.212.212-.33.498-.33.796v.41c0 .409-.11.809-.32 1.158l-1.315 2.191a2.11 2.11 0 0 1-1.81 1.025 1.055 1.055 0 0 1-1.055-1.055v-1.172c0-.92-.56-1.747-1.414-2.089l-.655-.261a2.25 2.25 0 0 1-1.383-2.46l.007-.042a2.25 2.25 0 0 1 .29-.787l.09-.15a2.25 2.25 0 0 1 2.37-1.048l1.178.236a1.125 1.125 0 0 0 1.302-.795l.208-.73a1.125 1.125 0 0 0-.578-1.315l-.665-.332-.091.091a2.25 2.25 0 0 1-1.591.659h-.18c-.249 0-.487.1-.662.274a.931.931 0 0 1-1.458-1.137l1.411-2.353a2.25 2.25 0 0 0 .286-.76m11.928 9.869A9 9 0 0 0 8.965 3.525m11.928 9.868A9 9 0 1 1 8.965 3.525" />
77 | </svg>
78 | <span class="ml-2 text-xl font-bold">MCP应用编辑器</span>
79 | </div>
80 | <div class="flex items-center gap-6">
81 | <button class="bg-white bg-opacity-20 hover:bg-opacity-30 text-white rounded-lg px-4 py-2 flex items-center transition-all" id="exportConfigBtn">
82 | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 mr-2">
83 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 0 1-2.25 2.25M16.5 7.5V18a2.25 2.25 0 0 0 2.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 0 0 2.25 2.25h13.5M6 7.5h3v3H6v-3Z" />
84 | </svg>
85 | 导出JSON
86 | </button>
87 |
88 | </div>
89 | </div>
90 | </div>
91 | </nav>
92 |
93 | <!-- 主内容区域 -->
94 | <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
95 | <div class="grid grid-cols-12 gap-6">
96 | <!-- 侧边栏 -->
97 | <div class="col-span-12 md:col-span-3 space-y-6">
98 | <!-- 基本信息卡片 -->
99 | <div class="bg-white rounded-xl shadow-sm p-6">
100 | <h2 class="text-lg font-bold text-gray-800 mb-4">应用信息</h2>
101 | <div class="space-y-4">
102 | <div>
103 | <label class="block text-sm font-medium text-gray-700 mb-1">应用名称</label>
104 | <input type="text" id="appName" class="border w-full h-10 px-3 rounded-lg border-gray-200 focus:border-primary-500 focus:ring focus:ring-primary-500 focus:ring-opacity-20" placeholder="输入应用名称">
105 | </div>
106 | <div>
107 | <label class="block text-sm font-medium text-gray-700 mb-1">应用描述</label>
108 | <textarea id="appDescription" rows="3" class="border w-full px-3 py-2 rounded-lg border-gray-200 focus:border-primary-500 focus:ring focus:ring-primary-500 focus:ring-opacity-20" placeholder="描述此应用的用途"></textarea>
109 | </div>
110 | </div>
111 | </div>
112 |
113 | <!-- 导入/导出卡片 -->
114 | <div class="bg-white rounded-xl shadow-sm p-6">
115 | <h2 class="text-lg font-bold text-gray-800 mb-4">配置管理</h2>
116 | <div class="space-y-3 mb-3">
117 | <button class="w-full flex items-center justify-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors" id="saveConfigBtn">
118 | <svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 mr-2">
119 | <path stroke-linecap="round" stroke-linejoin="round" d="M9 3.75H6.912a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H15M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859M12 3v8.25m0 0-3-3m3 3 3-3" />
120 | </svg>
121 | 保存配置
122 | </button>
123 | </div>
124 | <div class="space-y-3">
125 | <button class="w-full flex items-center justify-center px-4 py-2 bg-violet-600 bg-opacity-80 hover:bg-violet-700 text-white text-sm font-medium rounded-lg transition-colors" id="importJsonBtn">
126 | <svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
127 | <path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
128 | </svg>
129 | 导入JSON
130 | </button>
131 | <button class="w-full flex items-center justify-center px-4 py-2 text-primary-700 bg-primary-50 hover:bg-primary-100 text-sm font-medium rounded-lg transition-colors" id="useTemplateBtn">
132 | <svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
133 | <path d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
134 | </svg>
135 | 使用模板
136 | </button>
137 | <button class="w-full flex items-center justify-center px-4 py-2 text-red-700 bg-red-50 hover:bg-red-100 text-sm font-medium rounded-lg transition-colors" id="deleteAppBtn">
138 | <svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
139 | <path d="M19 7L18.1327 19.1425C18.0579 20.1891 17.187 21 16.1378 21H7.86224C6.81296 21 5.94208 20.1891 5.86732 19.1425L5 7M10 11V17M14 11V17M15 7V4C15 3.44772 14.5523 3 14 3H10C9.44772 3 9 3.44772 9 4V7M4 7H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
140 | </svg>
141 | 删除应用
142 | </button>
143 | </div>
144 | </div>
145 | </div>
146 |
147 | <!-- 主要内容区域 -->
148 | <div class="col-span-12 md:col-span-9 space-y-6">
149 | <!-- 服务器列表 -->
150 | <div class="bg-white rounded-xl shadow-sm p-6">
151 | <div class="flex justify-between items-center mb-6">
152 | <h2 class="text-lg font-bold text-gray-800">服务器配置</h2>
153 | <button class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors" id="addServerBtn">
154 | <svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
155 | <path d="M12 6v12M18 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
156 | </svg>
157 | MCP
158 | </button>
159 | </div>
160 |
161 | <div class="overflow-x-auto">
162 | <table class="w-full text-left">
163 | <thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
164 | <tr>
165 | <th class="px-4 py-3 rounded-tl-lg">名称</th>
166 | <th class="px-4 py-3">类型</th>
167 | <th class="px-4 py-3">命令</th>
168 | <th class="px-4 py-3 rounded-tr-lg text-right">操作</th>
169 | </tr>
170 | </thead>
171 | <tbody id="serverTableBody" class="divide-y divide-gray-100">
172 | <!-- 服务器列表项会动态添加到这里 -->
173 | </tbody>
174 | </table>
175 | </div>
176 |
177 | <div id="emptyServerMessage" class="text-center py-8 text-gray-500 hidden">
178 | 还没有添加任何服务器配置
179 | </div>
180 | </div>
181 |
182 | <!-- JSON预览区域 -->
183 | <div class="bg-white rounded-xl shadow-sm p-6">
184 | <h2 class="text-lg font-bold text-gray-800 mb-4">JSON预览</h2>
185 | <div class="relative">
186 | <pre id="jsonPreview" class="bg-gray-50 p-4 rounded-lg text-sm font-mono overflow-x-auto"></pre>
187 | <button id="copyJsonBtn" class="absolute top-2 right-2 p-2 text-gray-500 hover:text-gray-700 bg-white rounded-lg shadow-sm">
188 | <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
189 | <path d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
190 | </svg>
191 | </button>
192 | </div>
193 | </div>
194 | </div>
195 | </div>
196 | </div>
197 |
198 | <!-- 服务器配置模态框 -->
199 | <div id="server-modal" uk-modal>
200 | <div class="uk-modal-dialog uk-modal-body rounded-xl">
201 | <button class="uk-modal-close-default" type="button" uk-close></button>
202 | <h2 class="text-2xl font-bold mb-6" id="serverModalTitle">添加服务器</h2>
203 |
204 | <form id="serverForm" class="space-y-6">
205 | <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
206 | <div>
207 | <label class="block text-sm font-medium text-gray-700 mb-1">服务器名称</label>
208 | <input type="text" id="serverName" required class="border w-full h-10 px-3 rounded-lg border-gray-200 focus:border-primary-500 focus:ring focus:ring-primary-500 focus:ring-opacity-20" placeholder="输入服务器名称">
209 | </div>
210 |
211 | <div>
212 | <label class="block text-sm font-medium text-gray-700 mb-1">服务器类型</label>
213 | <select id="serverType" class="border w-full h-10 px-3 rounded-lg border-gray-200 focus:border-primary-500 focus:ring focus:ring-primary-500 focus:ring-opacity-20">
214 | <option value="sse">SSE (远程服务)</option>
215 | <option value="stdio">STDIO (本地进程)</option>
216 | </select>
217 | </div>
218 | </div>
219 |
220 | <div>
221 | <label class="block text-sm font-medium text-gray-700 mb-1">服务器描述</label>
222 | <textarea id="serverDescription" class="border w-full min-h-[48px] p-2 rounded-lg focus:border-sky-200"></textarea>
223 | </div>
224 |
225 | <!-- SSE配置 -->
226 | <div id="sseConfig" class="space-y-4">
227 | <div>
228 | <label class="block text-sm font-medium text-gray-700 mb-1">Base URL</label>
229 | <input type="text" id="baseUrl" class="border w-full h-10 px-3 rounded-lg border-gray-200 focus:border-primary-500 focus:ring focus:ring-primary-500 focus:ring-opacity-20" placeholder="http://example.com/api">
230 | </div>
231 |
232 | <div>
233 | <label class="block text-sm font-medium text-gray-700 mb-3">Headers</label>
234 | <div id="headersContainer" class="space-y-2">
235 | <!-- Headers会动态添加到这里 -->
236 | </div>
237 | <button type="button" id="addHeaderBtn" class="mt-2 inline-flex items-center px-3 py-2 text-sm font-medium text-primary-600 bg-primary-50 rounded-lg hover:bg-primary-100 transition-colors">
238 | <svg class="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="none">
239 | <path d="M12 6v12M18 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
240 | </svg>
241 | 添加Header
242 | </button>
243 | </div>
244 | </div>
245 |
246 | <!-- STDIO配置 -->
247 | <div id="stdioConfig" class="space-y-4 hidden">
248 | <div>
249 | <label class="block text-sm font-medium text-gray-700 mb-1">Command</label>
250 | <input type="text" id="command" class="border w-full h-10 px-3 rounded-lg border-gray-200 focus:border-primary-500 focus:ring focus:ring-primary-500 focus:ring-opacity-20" placeholder="执行的命令">
251 | </div>
252 |
253 | <div>
254 | <label class="block text-sm font-medium text-gray-700 mb-3">参数列表</label>
255 | <div id="argsContainer" class="space-y-2">
256 | <!-- 参数会动态添加到这里 -->
257 | </div>
258 | <button type="button" id="addArgBtn" class="mt-2 inline-flex items-center px-3 py-2 text-sm font-medium text-primary-600 bg-primary-50 rounded-lg hover:bg-primary-100 transition-colors">
259 | <svg class="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="none">
260 | <path d="M12 6v12M18 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
261 | </svg>
262 | 添加参数
263 | </button>
264 | </div>
265 |
266 | <div>
267 | <label class="block text-sm font-medium text-gray-700 mb-3">环境变量</label>
268 | <div id="envContainer" class="space-y-2">
269 | <!-- 环境变量会动态添加到这里 -->
270 | </div>
271 | <button type="button" id="addEnvBtn" class="mt-2 inline-flex items-center px-3 py-2 text-sm font-medium text-primary-600 bg-primary-50 rounded-lg hover:bg-primary-100 transition-colors">
272 | <svg class="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="none">
273 | <path d="M12 6v12M18 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
274 | </svg>
275 | 添加环境变量
276 | </button>
277 | </div>
278 | </div>
279 |
280 | <div class="flex justify-end space-x-4 pt-4">
281 | <button type="submit" class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg shadow hover:shadow-lg transition-all">
282 | <svg class="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="none">
283 | <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
284 | </svg>
285 | 保存配置
286 | </button>
287 | <button type="button" class="uk-modal-close inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium rounded-lg transition-colors">
288 | 取消
289 | </button>
290 | </div>
291 | </form>
292 | </div>
293 | </div>
294 |
295 | <!-- 模板选择模态框 -->
296 | <div id="template-modal" uk-modal>
297 | <div class="uk-modal-dialog uk-modal-body rounded-xl w-2/5">
298 | <button class="uk-modal-close-default" type="button" uk-close></button>
299 | <h2 class="text-2xl font-bold mb-4 ">选择模板</h2>
300 |
301 | <div id="templateLoading" class="flex justify-center items-center py-8">
302 | <div uk-spinner></div>
303 | <span class="ml-3">加载模板列表...</span>
304 | </div>
305 |
306 | <div id="templateTableContainer" class="hidden">
307 | <div class="flex justify-between items-center mb-4">
308 | <div class="flex items-center">
309 | <input type="checkbox" id="selectAllTools" class="mr-2 h-4 w-4 rounded text-primary-600">
310 | <label for="selectAllTools" class="text-sm font-medium text-gray-700">全选</label>
311 | </div>
312 | <button id="addSelectedToolsBtn" class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors">
313 | <svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none">
314 | <path d="M12 6v12M18 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
315 | </svg>
316 | 添加选中的模板
317 | </button>
318 | </div>
319 |
320 | <div class="overflow-x-auto">
321 | <table class="w-full text-left">
322 | <thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
323 | <tr>
324 | <th class="px-4 py-3 rounded-tl-lg w-10"></th>
325 | <th class="px-4 py-3">名称</th>
326 | <th class="px-4 py-3">类型</th>
327 | <th class="px-4 py-3 rounded-tr-lg">描述</th>
328 | </tr>
329 | </thead>
330 | <tbody id="templateTableBody" class="divide-y divide-gray-100">
331 | <!-- 模板列表项会动态添加到这里 -->
332 | </tbody>
333 | </table>
334 | </div>
335 | </div>
336 |
337 | <div id="noTemplatesMessage" class="text-center py-8 text-gray-500 hidden">
338 | <div id="json-modal" uk-modal>
339 | <div class="uk-modal-dialog uk-modal-body rounded-xl">
340 | <button class="uk-modal-close-default" type="button" uk-close></button>
341 | <h2 class="text-2xl font-bold mb-4">导入JSON配置</h2>
342 |
343 | <div class="mb-4">
344 | <textarea id="jsonInput" class="w-full h-96 px-4 py-3 font-mono text-sm border rounded-xl focus:border-primary-500 focus:ring focus:ring-primary-500 focus:ring-opacity-20" placeholder="在此粘贴JSON配置..."></textarea>
345 | </div>
346 |
347 | <div class="flex justify-end">
348 | <button type="button" id="importBtn" class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors">
349 | <svg class="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="none">
350 | <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
351 | </svg>
352 | 导入配置
353 | </button>
354 | </div>
355 | </div>
356 | </div>
357 |
358 | <script src="js/MCPEditor.js"></script>
359 | <script>
360 | document.addEventListener('DOMContentLoaded', function() {
361 | const editor = new MCPEditor();
362 | editor.init();
363 | });
364 | </script>
365 | </body>
366 | </html>
367 |
```
--------------------------------------------------------------------------------
/webui/chat.html:
--------------------------------------------------------------------------------
```html
1 | <!DOCTYPE html>
2 | <html lang="zh-CN" class="scroll-smooth overflow-hidden">
3 | <head>
4 | <meta charset="UTF-8">
5 | <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6 | <meta name="mobile-web-app-capable" content="yes">
7 | <title>Playground/toai.chat</title>
8 |
9 | <!-- 引入UIKit和TailwindCSS -->
10 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/uikit.min.css" />
11 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit.min.js"></script>
12 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit-icons.min.js"></script>
13 |
14 | <!-- 引入marked和highlight.js -->
15 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.min.js"></script>
16 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/index.umd.min.js"></script>
17 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css/github-markdown.css" id="markdown-light">
18 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/github-markdown-dark.css" id="markdown-dark" disabled>
19 |
20 | <script src="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/highlight.min.js"></script>
21 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/styles/a11y-light.min.css" id="code-light">
22 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/styles/a11y-dark.min.css" id="code-dark" disabled>
23 |
24 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.css" id="mermaid-light">
25 | <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
26 |
27 | <script src="https://cdn.tailwindcss.com"></script>
28 |
29 | <script src="ai/ai.js"></script>
30 | <script src="mcp/mcpcli.js"></script>
31 |
32 | <script>
33 | tailwind.config = {
34 | darkMode: 'class',
35 | theme: {
36 | extend: {
37 | colors: {
38 | 'ai-green': '#10a37f',
39 | 'ai-green-dark': '#0e8a6e',
40 | 'chat-bg': '#f7f7f8',
41 | 'chat-dark': '#444654',
42 | 'text-light': '#222222',
43 | 'text-dark': '#ececf1',
44 | 'user-bg': '#eeeeee'
45 | },
46 | boxShadow: {
47 | 'send-btn': '0 2px 5px rgba(0,0,0,0.1)'
48 | }
49 | }
50 | }
51 | }
52 | </script>
53 | <style>
54 | .markdown-body { background-color: transparent; h-* { color: #eee; } }
55 | ::-webkit-scrollbar { width: 0px; }
56 | </style>
57 | </head>
58 | <body class="dark:bg-gray-900 dark:text-gray-100">
59 | <!-- 添加UIKit Offcanvas组件作为左侧抽屉 -->
60 | <div id="offcanvas-nav" uk-offcanvas="overlay: true; mode: push">
61 | <div class="uk-offcanvas-bar bg-gray-50 text-gray-600 dark:bg-gray-600 dark:text-gray-100">
62 | <button class="uk-offcanvas-close" type="button" uk-close></button>
63 | <div class="font-bold text-lg text-gray-800 dark:text-gray-100 mb-4">Topic History</div>
64 |
65 | <!-- 改为黑白配色的新对话按钮 -->
66 | <div class="flex items-center justify-center mx-4 my-3 p-2.5 rounded bg-gray-500 text-white cursor-pointer transition-colors" id="new-chat-btn">
67 | <span uk-icon="icon: plus" class="text-white"></span>
68 | <span class="ml-2 text-sm font-medium">New Topic</span>
69 | </div>
70 |
71 | <div class="conversation-list mt-4" id="conversation-list"></div>
72 | </div>
73 | </div>
74 |
75 |
76 | <div id="app" class="flex flex-col h-screen">
77 | <!-- 顶部导航栏 - 固定定位 -->
78 | <header class="fixed top-0 left-0 w-full bg-white dark:bg-gray-900 z-40">
79 | <div class="uk-container uk-container-expand">
80 | <nav class="uk-navbar" uk-navbar>
81 | <div class="uk-navbar-left">
82 | <!-- 添加菜单按钮 -->
83 | <a class="uk-navbar-toggle" href="#" uk-toggle="target: #offcanvas-nav">
84 | <span uk-icon="icon: menu"></span>
85 | </a>
86 | <!-- 将固定标题改为可编辑区域 -->
87 | <div class="uk-navbar-item dark:text-gray-100">
88 | <span id="conversation-title" contenteditable="true"></span>
89 | </div>
90 | </div>
91 | <div class="uk-navbar-right">
92 | <ul class="uk-navbar-nav">
93 | <li>
94 | <a href="#" id="theme-toggle" class="theme-icon flex items-center justify-center dark:text-gray-100">
95 | <svg id="icon-moon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
96 | <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
97 | </svg>
98 | </a>
99 | </li>
100 | <li>
101 | <a href="#" uk-icon="icon: cog"></a>
102 | <div class="uk-navbar-dropdown" uk-dropdown="mode: click; animation: uk-animation-slide-top-small; duration: 100">
103 | <ul class="uk-nav uk-navbar-dropdown-nav line-height-2">
104 | <li style="margin:16px;"><a id="clear-chat" uk-icon="icon: refresh">清空记录</a></li>
105 | <li style="margin:16px;"><a id="remove-chat" uk-icon="icon: trash">删除对话</a></li>
106 | <li style="margin:16px;"><a id="api-settings" uk-icon="icon: cog">对话设置</a></li>
107 | </ul>
108 | </div>
109 | </li>
110 | </ul>
111 | </div>
112 | </nav>
113 | </div>
114 | </header>
115 |
116 | <!-- 聊天区域 - 调整顶部边距以避免被固定header覆盖 -->
117 | <main class="flex-grow pt-[80px] overflow-y-auto">
118 | <div class="uk-container uk-container-expand h-full">
119 | <div id="chat-container" class="chat-container flex flex-col mb-2.5 p-5 pb-[240px]"></div>
120 | </div>
121 | </main>
122 |
123 | <div class="fixed bottom-0 left-0 w-full bg-white dark:bg-gray-900 p-4 border-gray-200 dark:border-gray-700 z-30 shadow-md">
124 |
125 | <div class="message-toolbar uk-margin-small-bottom flex items-center gap-8 px-4">
126 |
127 | <div class="uk-inline">
128 | <div class="js-upload cursor-hand" uk-form-custom>
129 | <button class="toolbar-btn" type="button" uk-tooltip="上传文件">
130 | <span uk-icon="icon: image"></span>
131 | <input type="file" id="image-upload-input" accept="image/*" multiple>
132 | </button>
133 |
134 | </div>
135 | </div>
136 |
137 | <div class="uk-inline">
138 | <button class="toolbar-btn" id="mcp-toggle" uk-tooltip="MCP">
139 | <span uk-icon><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
140 | <path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
141 | </svg>
142 | </span>
143 |
144 |
145 | </button>
146 | </div>
147 |
148 | </div>
149 |
150 | <div class="flex items-start gap-2.5 relative mx-auto my-0">
151 | <textarea id="message-input" class="rounded-lg p-3 resize-none overflow-y-auto max-h-[120px] flex-grow shadow-sm border border-gray-200 transition-all focus:shadow-md focus:border-blue-400 dark:focus:border-blue-200 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100 uk-textarea h-[48px]" rows="1" placeholder="输入信息..." style="overflow:hidden"></textarea>
152 |
153 | <!-- 重写发送按钮,添加两种状态 -->
154 | <button id="send-button" class="w-10 h-10 rounded-full bg-ai-green text-white flex items-center justify-center cursor-pointer border-0 shadow-md transition-all duration-300 ease-in-out flex-shrink-0 mt-1.5 disabled:bg-gray-300 disabled:cursor-not-allowed" disabled>
155 | <!-- 正常状态 -->
156 | <span id="send-icon" class="block">
157 | <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
158 | <path fill-rule="evenodd" d="M10.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L12.586 11H5a1 1 0 110-2h7.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" />
159 | </svg>
160 | </span>
161 | <!-- loading状态 -->
162 | <span id="loading-icon" class="hidden">
163 | <svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
164 | <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
165 | <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
166 | </svg>
167 | </span>
168 | </button>
169 | </div>
170 |
171 | <div id="image-preview-container" class="flex flex-wrap gap-2.5 mt-2.5">
172 | <ul class="uk-thumbnav" uk-margin id="image-preview-container"></ul>
173 | </div>
174 | </div>
175 |
176 | </div>
177 |
178 | <div id="settings-modal" uk-modal>
179 | <div class="uk-modal-dialog uk-modal-body">
180 | <h2 class="uk-modal-title">对话设置</h2>
181 | <form class="uk-form-stacked">
182 | <div class="uk-margin">
183 | <label class="uk-form-label">API Key</label>
184 | <div class="uk-form-controls"><input class="uk-input" id="apiKey" type="password" placeholder="输入你的API Key"></div>
185 | </div>
186 | <div class="uk-margin">
187 | <label class="uk-form-label">MCP(SSE)</label>
188 | <div class="uk-form-controls"><input class="uk-input" id="mcp" type="text" placeholder="请输入MCP服务器地址"></div>
189 | </div>
190 | <div class="uk-margin">
191 | <label class="uk-form-label">API baseURL</label>
192 | <div class="uk-form-controls"><input class="uk-input" id="baseURL" type="text" placeholder="https://porky.toai.chat/pollination/v1"></div>
193 | </div>
194 | <div class="uk-margin">
195 | <label class="uk-form-label">模型</label>
196 | <div class="uk-form-controls">
197 | <input class="uk-input" id="model" type="text" placeholder="模型名称" value="openai">
198 | </div>
199 | <a href="https://text.pollinations.ai/models" target="_blank">see the models if you are using pollinations</a>
200 | </div>
201 | <div class="uk-margin">
202 | <label class="uk-form-label">系统提示 (System Prompt)</label>
203 | <div class="uk-form-controls">
204 | <textarea class="uk-textarea" id="systemPrompt" rows="3" placeholder="输入系统提示,指导AI的行为和角色"></textarea>
205 | </div>
206 | </div>
207 | <div class="uk-margin">
208 | <label class="uk-form-label">温度 <span id="temperature-value">0.7</span></label>
209 | <div class="uk-form-controls"><input class="uk-range" id="temperature" type="range" min="0" max="2" step="0.01" value="0.7"></div>
210 | </div>
211 | <div class="uk-margin">
212 | <label class="uk-form-label">最大令牌数 (Max Tokens)</label>
213 | <div class="uk-form-controls">
214 | <input class="uk-input" id="maxTokens" type="number" placeholder="最大令牌数" value="128000">
215 | </div>
216 | </div>
217 | <div class="uk-margin">
218 | <label class="uk-form-label">Top P <span id="top-p-value">1.0</span></label>
219 | <div class="uk-form-controls"><input class="uk-range" id="topP" type="range" min="0" max="1" step="0.01" value="1.0"></div>
220 | </div>
221 | </form>
222 | <div class="uk-text-right">
223 | <button class="uk-button uk-button-default uk-modal-close rounded-md">取消</button>
224 | <button class="uk-button uk-button-primary rounded-md" id="save-settings">保存</button>
225 | </div>
226 | </div>
227 | </div>
228 |
229 | <script>
230 |
231 | const appState = {
232 | darkMode: localStorage.getItem('darkMode') === 'true',
233 | currentChat: null,
234 |
235 | togglekMode() {
236 | this.darkMode = !this.darkMode;
237 | localStorage.setItem('darkMode', this.darkMode);
238 | document.documentElement.classList.toggle('dark')
239 | document.getElementById('markdown-light').disabled = this.darkMode;
240 | document.getElementById('markdown-dark').disabled = !this.darkMode;
241 | document.getElementById('code-light').disabled = this.darkMode;
242 | document.getElementById('code-dark').disabled = !this.darkMode;
243 | }
244 |
245 | }
246 |
247 | document.getElementById('theme-toggle').addEventListener('click', appState.togglekMode)
248 | mermaid.initialize({startOnLoad:true, theme: appState.darkMode ? 'neutral' : 'neutral', securityLevel: 'loose'});
249 |
250 |
251 | class DataBase {
252 | constructor(dbname, structure={}) {
253 | this.dbName = dbname
254 | this.structure = structure;
255 | this.db = null;
256 | this.ready = this.initDatabase();
257 | }
258 |
259 | async initDatabase() {
260 | return new Promise((resolve, reject) => {
261 | const request = indexedDB.open(this.dbName, 1);
262 |
263 | request.onupgradeneeded = (event) => {
264 | const db = event.target.result;
265 | for (const storeName in this.structure) {
266 | if (!db.objectStoreNames.contains(storeName)) {
267 | const store = db.createObjectStore(storeName, { keyPath: this.structure[storeName].keyPath });
268 | for (const index of this.structure[storeName].indexes) {
269 | store.createIndex(index.name, index.keyPath, { unique: index.unique||false });
270 | }
271 | }
272 | }
273 | };
274 |
275 | request.onsuccess = (event) => { this.db = event.target.result; resolve(true);};
276 | request.onerror = (event) => { reject(event.target.error);};
277 | });
278 | }
279 |
280 | async getCollectionData(collection, key, filter) {
281 | await this.ready;
282 | return new Promise((resolve, reject) => {
283 | const transaction = this.db.transaction([collection], 'readonly');
284 | const store = transaction.objectStore(collection);
285 | const index = store.index(key);
286 | const request = !filter ? index.getAll() : index.getAll(filter);
287 | request.onsuccess = () => resolve(request.result);
288 | request.onerror = () => reject(request.error);
289 | });
290 | }
291 |
292 | async setCollectionData(collection, data) {
293 | await this.ready;
294 | return new Promise((resolve, reject) => {
295 | const transaction = this.db.transaction([collection], 'readwrite');
296 | const store = transaction.objectStore(collection);
297 | const request = store.put(data);
298 | request.onsuccess = () => resolve(request.result);
299 | request.onerror = () => reject(request.error);
300 | });
301 | }
302 |
303 | async deleteCollectionData(collection, key) {
304 | await this.ready;
305 | return new Promise((resolve, reject) => {
306 | const transaction = this.db.transaction([collection], 'readwrite');
307 | const store = transaction.objectStore(collection);
308 | const request = store.delete(key);
309 | request.onsuccess = () => resolve(request.result);
310 | request.onerror = () => reject(request.error);
311 | });
312 | }
313 | }
314 |
315 | class MainDatabase extends DataBase {
316 | constructor() {
317 | super('MainDB', {
318 | conversations: {
319 | keyPath: 'id',
320 | indexes: [
321 | { name: 'title', keyPath: 'title' },
322 | { name: 'lastMessage', keyPath: 'lastMessage' },
323 | { name: 'id', keyPath: 'id' },
324 | { name: 'timestamp', keyPath: 'timestamp' }
325 | ]
326 | }
327 | });
328 | }
329 | async getAllConversations(id=null) {
330 | if (id) return await this.getCollectionData('conversations', 'id', id);
331 | return await this.getCollectionData('conversations', 'timestamp');
332 | }
333 | async getOrCreateLastChat() {
334 | const conversations = await this.getAllConversations();
335 | if (conversations.length > 0) {
336 | const conversation = conversations[conversations.length - 1];
337 | console.log('获取最后一个对话:', conversation);
338 | return conversation;
339 | }
340 | return this.createNewConversation(Date.now(), '');
341 | }
342 | async createNewConversation(id, title) {
343 | const conversation = { id: id, title: title,timestamp: Date.now(), lastMessage: ''};
344 | await this.setCollectionData('conversations', conversation);
345 | return conversation;
346 | }
347 | }
348 |
349 | class ChatDatabase extends DataBase {
350 | constructor(chatId) {
351 | if(!chatId) {
352 | throw new Error('Chat ID is required');
353 | }
354 | super(`ChatDB-${chatId}`, {
355 | messages: {
356 | keyPath: 'id',
357 | indexes: [
358 | { name: 'id', keyPath: 'id' },
359 | { name: 'content', keyPath: 'content' },
360 | { name: 'role', keyPath: 'role' },
361 | { name: 'timestamp', keyPath: 'timestamp' },
362 | { name: 'images', keyPath: 'images' }
363 | ]
364 | },
365 | settings: {
366 | keyPath: 'key',
367 | indexes: [
368 | { name: 'key', keyPath: 'key' },
369 | { name: 'value', keyPath: 'value' }
370 | ]
371 | }
372 | });
373 | }
374 | async getSettings() {return await this.getCollectionData('settings', 'key')}
375 | async saveMessage(message) { await this.setCollectionData('messages', message)}
376 | async getAllMessages() { return await this.getCollectionData('messages', 'timestamp') }
377 | }
378 |
379 | window.MainDatabase = new MainDatabase()
380 |
381 |
382 | class AdvancedConversation {
383 | constructor() {
384 | this.db = window.MainDatabase
385 | this.conversationList = document.getElementById('conversation-list');
386 | this.newChatButton = document.getElementById('new-chat-btn');
387 | this.deleteChatButton = document.getElementById('remove-chat');
388 | this.topicTitle = document.getElementById('conversation-title');
389 | this.init();
390 | }
391 | async init() {
392 | this.newChatButton.addEventListener('click', this.createNewConversation.bind(this));
393 | await this.loadConversations();
394 | this.deleteChatButton.addEventListener('click', this.deleteConversation.bind(this));
395 | this.topicTitle.addEventListener('blur', async (e) => {
396 | const title = e.target.innerText;
397 | if (title) { await this.setConversationTitle(window.aiui.chatId, title);}
398 | });
399 | this.topicTitle.addEventListener('keydown', (e) => {
400 | if (e.key === 'Enter') { e.preventDefault(); this.topicTitle.blur();}
401 | });
402 | }
403 | async loadConversations() {
404 | const conversations = await this.db.getAllConversations();
405 | this.conversationList.innerHTML = ''; // 清空列表
406 | for (const conversation of conversations) {
407 | const item = document.createElement('div');
408 | item.className = 'conversation-item flex items-center justify-between p-2 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors';
409 | item.id = 'conversation-' + conversation.id;
410 | item.setAttribute('data-id', conversation.id);
411 | const title = document.createElement('span')
412 | title.className = 'conversation-title truncate whitespace-nowrap'; title.innerText = conversation.title || 'Untitled Topic';
413 | item.appendChild(title);
414 | item.addEventListener('click', (e) => {this.loadConversation(conversation.id);});
415 | this.conversationList.appendChild(item);
416 | }
417 | return conversations;
418 | }
419 | async createNewConversation() {
420 | const chat = await this.db.createNewConversation(Date.now(), '');
421 | await this.loadConversations();
422 | await this.loadConversation(chat.id);
423 | }
424 | async loadConversation(id) {
425 | localStorage.setItem('last_chat_id', id);
426 | const conversations = await this.db.getAllConversations(id);
427 | if (conversations.length > 0) {
428 | const conversation = conversations[0];
429 | this.conversationList.querySelectorAll('.conversation-item').forEach(item => item.classList.remove('active'));
430 | this.conversationList.querySelector(`#conversation-${conversation.id}`).classList.add('active');
431 | window.aiui = new AIUI( {chatId: conversation.id} );
432 | await window.aiui.ready;
433 | this.topicTitle.innerText = conversation.title;
434 | UIkit.offcanvas('#offcanvas-nav').hide();
435 | }
436 | }
437 | async deleteConversation(event) {
438 | const id = window.aiui.chatId;
439 | await UIkit.modal.confirm('确定要删除此对话吗?', {labels: {ok: '删除', cancel: '取消'}}).then(async () => {
440 | await this.db.deleteCollectionData('conversations', id);
441 | await indexedDB.deleteDatabase(`ChatDB-${id}`);
442 | await this.loadConversations();
443 | window.aiui = new AIUI();
444 | })
445 | }
446 | async setConversationTitle(id, title) {
447 | const conversation = await this.db.getCollectionData('conversations', 'id', id);
448 | if (conversation.length > 0) {
449 | conversation[0].title = title;
450 | await this.db.setCollectionData('conversations', conversation[0]);
451 | this.topicTitle.innerText = title;
452 | }
453 | await this.loadConversations();
454 | }
455 | }
456 |
457 |
458 | class AdvancedContainer {
459 | // 聊天消息容器类,主要控制chat-container的消息
460 | static instance = null;
461 |
462 | static getInstance(db) {
463 | if (!AdvancedContainer.instance) {
464 | AdvancedContainer.instance = new AdvancedContainer(db);
465 | } else if (db && AdvancedContainer.instance.db !== db) {
466 | // 当数据库引用变化时,更新数据库引用并重置容器
467 | AdvancedContainer.instance.db = db;
468 | AdvancedContainer.instance.resetContainer();
469 | AdvancedContainer.instance.loadMessages(); // 自动加载新数据库的消息
470 | }
471 | return AdvancedContainer.instance;
472 | }
473 |
474 | constructor(db) {
475 | // 如果已经有实例,直接返回
476 | if (AdvancedContainer.instance) {
477 | return AdvancedContainer.instance;
478 | }
479 |
480 | this.container = document.getElementById('chat-container');
481 | this.clearChatButton = document.getElementById('clear-chat');
482 | this.container.innerHTML = ''; // 初始化的时候,清空容器
483 | this.db = db;
484 | this.initialized = false;
485 | this.ready = this.init();
486 |
487 | AdvancedContainer.instance = this;
488 | }
489 |
490 | // 重置容器,清空内容
491 | resetContainer() {
492 | this.container.innerHTML = '';
493 | }
494 |
495 | async init() {
496 | // 确保初始化和事件绑定只执行一次
497 | if (this.initialized) return;
498 |
499 | this.clearChatButton.addEventListener('click', async() => {
500 | await UIkit.modal.confirm('确定要清空聊天记录吗?', {labels: {ok: '清空', cancel: '取消'}}).then(async () => {
501 | await this.clearChat();
502 | if (window.aiui && window.aiui.ai) {
503 | window.aiui.ai.messages = [];
504 | }
505 | });
506 | });
507 |
508 | this.initialized = true;
509 | }
510 |
511 | scrollToBottom() {
512 | // 修改滚动函数,滚动整个窗口到底部
513 | window.scrollTo(0, document.body.scrollHeight);
514 | }
515 |
516 | async newMessage(role) {
517 | const msgId = `msg-${Date.now()}`;
518 | const messageDiv = document.createElement('div');
519 | messageDiv.className = 'max-w-fit mb-4 p-3 rounded-lg relative break-words shadow-sm';
520 |
521 | // 使用Tailwind类替代自定义CSS类
522 | if (role === 'user') {
523 | messageDiv.classList.add('bg-user-bg', 'text-gray-900', 'ml-auto', 'text-right','uk-animation-slide-bottom-small','uk-animation-fade');
524 | } else if (role === 'assistant') {
525 | messageDiv.classList.add('bg-chat-bg', 'text-text-light', 'dark:bg-chat-dark', 'dark:text-text-dark','uk-animation-fade','uk-animation-slide-bottom-small');
526 | }
527 |
528 | messageDiv.id = msgId;
529 |
530 | const messageContent = document.createElement('div');
531 | messageContent.className = 'message-content';
532 | messageContent.setAttribute('raw', '');
533 | if (role === 'assistant') {
534 | messageContent.setAttribute('class','message-content p-2 markdown-body rounded-md dark:[&>h4]:text-gray-100 dark:[&>h3]:text-gray-100 dark:[&>h2]:text-gray-100 dark:[&>h1]:text-gray-100');
535 | }
536 |
537 | const messageReason = document.createElement('div');
538 | messageReason.className = 'message-reason opacity-30 markdown-body max-h-[32px] overflow-hidden hover:cursor-pointer hover:opacity-60 hover:font-weight-light';
539 | messageReason.setAttribute('raw', '');
540 | messageReason.addEventListener('click', (e)=>{ messageReason.classList.toggle("max-h-[48px]")} )
541 | messageDiv.appendChild(messageReason)
542 |
543 | messageDiv.appendChild(messageReason);
544 | messageDiv.appendChild(messageContent);
545 |
546 | const add = () => { this.container.appendChild(messageDiv); this.scrollToBottom();};
547 | const update = async(content, addons={}) => {
548 | const { scroll=false, images=[], spin=false } = addons;
549 | messageContent.setAttribute('raw', content);
550 | if (spin) {
551 | content = `<div uk-spinner='ratio: 0.5'></div>`;
552 | }
553 | if (role === 'user') {
554 | messageContent.innerHTML = content;
555 | if (images.length) {
556 | messageContent.setAttribute('images', JSON.stringify(images));
557 | const imgContainer = document.createElement('div'); imgContainer.className = 'flex';
558 | images.forEach((image, index) => {
559 | const img = document.createElement('img');
560 | img.src = image; img.style.height = '80px'; img.className = 'rounded-md mx-0.5';
561 | imgContainer.appendChild(img);
562 | });
563 | messageContent.appendChild(imgContainer);
564 | }
565 | } else if (role==='assistant') {
566 | messageContent.innerHTML = marked.parse(content);
567 | }
568 |
569 | if (role === 'assistant') {setTimeout(() => mermaid.init(undefined, messageContent.querySelectorAll('.mermaid')), 0);}
570 | if (scroll) this.scrollToBottom();
571 | };
572 | const updateReason = async(reason) => {
573 | messageReason.classList.add('p-4')
574 | messageReason.innerHTML = marked.parse(reason);
575 | };
576 | const save = async( ) => {
577 | const content = messageContent.getAttribute('raw');
578 | const images = messageContent.getAttribute('images');
579 | if(!content && !images) return;
580 | await this.db.saveMessage({ id: msgId, role: role, content: messageContent.getAttribute('raw'), timestamp: Date.now(), images: messageContent.getAttribute('images') });
581 | };
582 | window.rup = updateReason;
583 | return {msgId, messageDiv, add, update, save, updateReason };
584 | }
585 |
586 | async clearChat() {
587 | const messages = await this.db.getAllMessages();
588 | for (const message of messages) { await this.db.deleteCollectionData('messages', message.id); }
589 | this.container.innerHTML = '';
590 | }
591 |
592 | async loadMessages() {
593 | const messages = await this.db.getAllMessages();
594 | for (const message of messages) {
595 | const { msgId, messageDiv, add, update } = await this.newMessage(message.role);
596 | await update(message.content, {scroll:false, images:message.images ? JSON.parse(message.images) : []});
597 | add();
598 | }
599 | }
600 | }
601 |
602 | class AdvancedInput {
603 | // 聊天输入框类,主要控制message-input的输入, 以及发送按钮的状态, 还有点击发送按钮后的回调
604 | constructor() {
605 | this.input = document.getElementById('message-input');
606 | this.sendButton = document.getElementById('send-button');
607 | this.sendIcon = document.getElementById('send-icon');
608 | this.loadingIcon = document.getElementById('loading-icon');
609 | this.imageUploadInput = document.getElementById('image-upload-input');
610 | this.imagePreviewContainer = document.getElementById('image-preview-container');
611 | this.mcpToggle = document.getElementById('mcp-toggle');
612 | this.mcpStatus = 0; // 0=>未链接, 2=>连接中, 1=>链接成功
613 | this.mcpcli = null;
614 | this.isProcessing = false;
615 | this.init()
616 | }
617 |
618 | init() {
619 | this.mcpToggle.addEventListener('click', this.mcpClient.bind(this));
620 | this.input.addEventListener('input', () => {this.sendButton.disabled = !this.input.value.trim();});
621 | this.input.addEventListener('keydown', (e) => {if (e.key === 'Enter' && e.ctrlKey) {e.preventDefault();this.sendButton.click();}});
622 | this.imageUploadInput.addEventListener('change', (e)=>{
623 | const files = e.target.files;
624 | if (files.length > 0) {
625 | for (const file of files) {
626 | const reader = new FileReader();
627 | reader.onload = (event) => {
628 | const a = document.createElement('a'); a.className = 'uk-position-relative';
629 | const img = document.createElement('img'); img.src = event.target.result; img.classList.add('rounded-md'); img.style.height='64px'
630 | a.appendChild(img);
631 | const deleteBtn = document.createElement('button');deleteBtn.className = 'uk-icon-button uk-position-top-right uk-position-small';
632 | deleteBtn.setAttribute('uk-icon', 'trash');deleteBtn.style.backgroundColor = 'rgba(255,255,255,0.4)';
633 | deleteBtn.onclick = (e) => {e.preventDefault(); a.remove();};
634 | a.appendChild(deleteBtn);
635 | this.imagePreviewContainer.appendChild(a);
636 | };
637 | reader.readAsDataURL(file);
638 | }
639 | }
640 | })
641 | this.sendButton.onclick = () => {
642 | if (this.isProcessing) return;
643 | if(!this.input.value.trim()) { return }
644 |
645 | // 显示loading状态
646 | this.isProcessing = true;
647 | this.sendButton.disabled = false;
648 | this.sendIcon.classList.add('hidden');
649 | this.loadingIcon.classList.remove('hidden');
650 | this.input.setAttribute('disabled', 'disabled');
651 | this.imageUploadInput.setAttribute('disabled', 'disabled');
652 |
653 | return new Promise((resolve, reject) => {
654 | const message = { content: this.input.value, images: [...document.getElementById('image-preview-container').querySelectorAll('img')].map(x=>x.src) };
655 | window.aiui.onSendMessage(message, resolve, reject);
656 | }).catch((error) => {
657 | console.error('Error:', error);
658 | }).finally(() => {
659 | this.isProcessing = false;
660 | this.input.value = '';
661 | this.imagePreviewContainer.innerHTML = '';
662 | this.imageUploadInput.value = '';
663 | this.input.style.height = 'auto';
664 |
665 | // 恢复正常状态
666 | this.sendIcon.classList.remove('hidden');
667 | this.loadingIcon.classList.add('hidden');
668 | this.input.removeAttribute('disabled');
669 | this.imageUploadInput.removeAttribute('disabled');
670 | this.input.focus();
671 | this.sendButton.disabled = !this.input.value.trim();
672 | });
673 | };
674 | }
675 |
676 | async mcpClient() {
677 | if(this.mcpStatus===1) {
678 | this.mcpcli.close()
679 | this.mcpcli = null
680 | window.aiui.ai.toolFn = null
681 | this.mcpStatus = false
682 | this.mcpToggle.classList.remove('uk-spinner')
683 | this.mcpToggle.classList.remove('text-cyan-400')
684 | this.mcpToggle.setAttribute('uk-tooltip', 'MCP已断开。请重新链接')
685 | return
686 | }else if(this.mcpStatus===2) {
687 | UIkit.notification({message: '正在连接MCP,请稍等',status: 'warning',pos: 'top-center',timeout: 1000});
688 | return
689 | }
690 | const mcpaddress = window.aiui.settings.settings.mcp;
691 | if(!mcpaddress) {
692 | UIkit.notification({message: '请先设置MCP服务器地址',status: 'warning',pos: 'top-center',timeout: 5000});
693 | window.aiui.settings.open()
694 | return
695 | }
696 | const mcpcli = new MCPCli(mcpaddress)
697 | this.mcpToggle.classList.add('uk-spinner')
698 | this.mcpToggle.setAttribute('uk-tooltip', '连接中...')
699 | this.mcpStatus = 2
700 | mcpcli.connect()
701 | .then(cli=>{
702 | this.mcpcli = cli
703 | this.mcpStatus = 1
704 | this.mcpToggle.setAttribute('uk-tooltip', 'MCP已连接')
705 | //this.mcpToggle.classList.remove('uk-spinner')
706 | this.mcpToggle.classList.add('text-cyan-400')
707 | UIkit.notification({message: 'MCP连接成功',status: 'success',pos: 'top-center',timeout: 5000});
708 | window.aiui.ai.toolFn = cli
709 | })
710 | .catch(err=>{
711 | console.error(err)
712 | UIkit.notification({message: 'MCP连接失败',status: 'danger',pos: 'top-center',timeout: 5000});
713 | this.mcpToggle.classList.remove('uk-spinner')
714 | this.mcpToggle.removeAttribute('uk-tooltip')
715 | this.mcpStatus = 0
716 | })
717 |
718 |
719 | }
720 | }
721 |
722 | class AdvancedSettings {
723 | static instance = null;
724 |
725 | static getInstance(db) {
726 | if (!AdvancedSettings.instance) {
727 | AdvancedSettings.instance = new AdvancedSettings(db);
728 | } else if (db) {
729 | // 如果已有实例但传入了新的db,则更新db
730 | AdvancedSettings.instance.db = db;
731 | }
732 | return AdvancedSettings.instance;
733 | }
734 |
735 | constructor(db) {
736 | // 如果已经有实例,则直接返回该实例
737 | if (AdvancedSettings.instance) {
738 | return AdvancedSettings.instance;
739 | }
740 |
741 | this.db = db;
742 | this.settingsButton = document.getElementById('api-settings');
743 | this.saveButton = document.getElementById('save-settings');
744 | this.settingsModal = document.getElementById('settings-modal');
745 | this.settings = {};
746 | this.initialized = false;
747 | this.init();
748 |
749 | AdvancedSettings.instance = this;
750 | }
751 |
752 | async init() {
753 | if (this.initialized) return;
754 |
755 | this.settingsButton.addEventListener('click', this.open.bind(this));
756 | this.saveButton.addEventListener('click', this.save.bind(this));
757 | document.getElementById('temperature').addEventListener('input', (e) => {
758 | document.getElementById('temperature-value').textContent = e.target.value;
759 | });
760 | document.getElementById('topP').addEventListener('input', (e) => {
761 | document.getElementById('top-p-value').textContent = e.target.value;
762 | });
763 |
764 | this.initialized = true;
765 | await this.get();
766 | }
767 |
768 | async open() {
769 | const settings = await this.get();
770 | document.getElementById('apiKey').value = settings.apiKey || '';
771 | document.getElementById('mcp').value = settings.mcp || '';
772 | document.getElementById('baseURL').value = settings.baseURL || 'https://text.pollinations.ai/openai';
773 | document.getElementById('model').value = settings.model || 'openai';
774 | document.getElementById('systemPrompt').value = settings.systemPrompt || '';
775 | document.getElementById('temperature').value = settings.temperature || 0.7;
776 | document.getElementById('temperature-value').textContent = settings.temperature || 0.7;
777 | document.getElementById('maxTokens').value = settings.maxTokens || 128000;
778 | document.getElementById('topP').value = settings.topP || 1.0;
779 | document.getElementById('top-p-value').textContent = settings.topP || 1.0;
780 | UIkit.modal(this.settingsModal).show();
781 | }
782 |
783 | close() { UIkit.modal(this.settingsModal).hide();}
784 |
785 | async save() {
786 | for (const setting of ['apiKey', 'mcp', 'baseURL', 'model', 'systemPrompt', 'temperature', 'maxTokens', 'topP']) {
787 | await this.db.setCollectionData('settings', { key: setting, value: document.getElementById(setting).value });
788 | }
789 | UIkit.notification({message: '设置已保存',status: 'success',pos: 'top-center',timeout: 2000});
790 | this.close();
791 | await this.get()
792 | window.dispatchEvent(new CustomEvent('settingsUpdate', {detail: this.settings}));
793 | return this.settings;
794 | }
795 |
796 | async get() {
797 | const default_settings = {
798 | 'apiKey': '', 'baseURL': 'https://text.pollinations.ai/openai','mcp':'',
799 | 'model': 'openai', 'systemPrompt': '',
800 | 'temperature': 0.61, 'maxTokens': 8192, 'topP': 0.95
801 | }
802 | const saved_settings = (await this.db.getSettings()).reduce((acc, setting) => {acc[setting.key] = setting.value;return acc;}, {});
803 | this.settings = {...default_settings, ...saved_settings};
804 | return this.settings;
805 | }
806 | }
807 |
808 | // AIUI类
809 | class AIUI {
810 | constructor(options = {}) {
811 | this.chatId = options.chatId
812 | this.ready = this.init()
813 | }
814 |
815 | async init() {
816 | if (!this.chatId) {
817 | if (localStorage.getItem('last_chat_id')) {
818 | await window.conversations.loadConversation(parseInt(localStorage.getItem('last_chat_id')));
819 | return
820 | } else {
821 | const chat = window.MainDatabase.getOrCreateLastChat();
822 | await window.conversations.loadConversation(chat.id);
823 | return
824 | }
825 | }
826 |
827 | this.db = new ChatDatabase(this.chatId);
828 | // 使用单例模式获取容器实例
829 | this.container = AdvancedContainer.getInstance(this.db);
830 | this.input = new AdvancedInput();
831 | // 使用单例模式获取设置实例
832 | this.settings = AdvancedSettings.getInstance(this.db);
833 | await this.settings.get();
834 |
835 | const messages = (await this.db.getAllMessages()).map(m => ({role:m.role, content:m.content}));
836 | this.ai = new AI({
837 | messages: messages,
838 | system_prompt: this.settings.settings.systemPrompt || '',
839 | model: this.settings.settings.model,
840 | apiKey: this.settings.settings.apiKey || '',
841 | baseURL: this.settings.settings.baseURL,
842 | completionsURI: '',
843 | opts: {
844 | temperature: parseFloat(this.settings.settings.temperature) || 0.7,
845 | max_tokens: parseInt(this.settings.settings.maxTokens) || 128000,
846 | top_p: parseFloat(this.settings.settings.topP) || 1.0
847 | }
848 | });
849 |
850 | window.addEventListener('settingsUpdate', async (e) => {
851 | this.ai = new AI({
852 | messages: this.ai.messages,
853 | system_prompt: e.detail.systemPrompt || '',
854 | model: e.detail.model,
855 | apiKey: e.detail.apiKey || '',
856 | baseURL: e.detail.baseURL,
857 | completionsURI: '',
858 | opts: {
859 | temperature: parseFloat(e.detail.temperature) || 0.7,
860 | max_tokens: parseInt(e.detail.maxTokens) || 128000,
861 | top_p: parseFloat(e.detail.topP) || 1.0
862 | }
863 | });
864 | });
865 |
866 | await this.container.loadMessages();
867 | }
868 |
869 | async onSendMessage(message, resolve, reject) {
870 | const { content, images } = message;
871 | if (!content.trim()) {reject('消息不能为空');return}
872 | if (window.conversations.topicTitle.innerText === '') {
873 | await window.conversations.setConversationTitle(this.chatId, content.slice(0, 30));
874 | }
875 | const userMessage = await this.container.newMessage('user');
876 | await userMessage.add();
877 | await userMessage.update(content.trim(), true, images);
878 | await userMessage.save();
879 |
880 | const assistantMessage = await this.container.newMessage('assistant');
881 | await assistantMessage.add();
882 | await assistantMessage.update('',{spin:true});
883 |
884 | try {
885 |
886 | // 调用AI生成回复
887 | this.currentResponse = await this.ai.create(content.trim(), {images: images});
888 | let fullResponse = '';
889 | let fullReasoning = ''
890 |
891 | // 同时处理推理和内容流
892 | await Promise.all([
893 |
894 | // 实现同时处理内容流和推理流,上次有个人讽刺说openAI在推理流时不出内容流,而实际上确实没人这么做。这个实现无非是预留效果罢了。。。。。
895 |
896 | // 处理推理流
897 | (async() => {
898 | console.log('reasoning.....');
899 | for await (const reasoning_chunk of this.currentResponse.on('reasoning')) {
900 | fullReasoning += reasoning_chunk.delta?.choices?.[0]?.delta?.reasoning || '';
901 | await assistantMessage.updateReason(fullReasoning);
902 | }
903 | //为了不增加token,推理流不写入数据库
904 | })(),
905 |
906 | // 处理内容流
907 | (async ()=> {
908 | for await (const chunk of this.currentResponse.on('content')) {
909 | if (chunk.delta) {
910 | fullResponse += chunk.delta;
911 | await assistantMessage.update(fullResponse, true);
912 | }
913 | }
914 | await assistantMessage.save();
915 | })()
916 |
917 | ])
918 |
919 | // 添加AI回复到消息历史
920 | this.ai.messages.push({
921 | role: 'assistant',
922 | content: fullResponse
923 | });
924 |
925 | resolve(fullResponse);
926 | } catch (error) {
927 | await assistantMessage.update('抱歉,哪里出现了错误了。 请稍后再试。');
928 | reject(error);
929 | }
930 | }
931 | }
932 |
933 | document.addEventListener('DOMContentLoaded', async function() {
934 |
935 | const { markedHighlight } = window.markedHighlight;
936 | marked.use(markedHighlight({
937 | langPrefix: 'hljs language-',
938 | highlight: function(code, lang) {
939 | if (lang === 'mermaid') { return '<div class="mermaid">' + code + '</div>';}
940 | const language = hljs.getLanguage(lang) ? lang : 'plaintext'
941 | return hljs.highlight(code, {language: language}).value;
942 | }
943 | }));
944 |
945 | window.conversations = new AdvancedConversation();
946 | await window.conversations.loadConversations();
947 | window.aiui = new AIUI();
948 | await window.aiui.ready
949 |
950 | window.scrollTo(0, 1);
951 | if (/()iPhone|iPad|iPod/.test(navigator.userAgent)) {
952 | document.body.style.height = '100vh';
953 | document.body.overflow = 'hidden';
954 | document.body.requestFullscreen()
955 | }
956 |
957 | });
958 | </script>
959 | </body>
960 | </html>
961 |
```