# 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
version: '3.8'
services:
mcpez:
image: mcpez
build:
context: .
dockerfile: dockerfile
ports:
- "8777:80"
```
--------------------------------------------------------------------------------
/dockerfile:
--------------------------------------------------------------------------------
```dockerfile
FROM alpine:latest
RUN apk update
RUN apk add bash openresty openrc python3 py3-pip sqlite pipx nodejs npm
RUN pipx install uv
RUN pipx ensurepath
ENV PATH="/root/.local/bin:$PATH"
RUN mkdir -p /data/app
WORKDIR /data/app
RUN uv venv
RUN uv pip install tornado fastapi uvicorn sqlmodel httpx httpx-sse
RUN mkdir -p /var/run/mcpez
COPY ./mcpeasy-entrypoint.sh /mcpeasy-entrypoint.sh
COPY mcpez_ngx.conf /etc/nginx/mcpez.conf
COPY ./bin /data/app/bin
COPY ./mcpez /data/app/mcpez
COPY ./webui /data/app/webui
RUN chmod +x /mcpeasy-entrypoint.sh
ENTRYPOINT ["/mcpeasy-entrypoint.sh"]
```
--------------------------------------------------------------------------------
/mcpez_ngx.conf:
--------------------------------------------------------------------------------
```
worker_processes 1;
daemon on;
events {
worker_connections 102400;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
server {
listen 80;
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log error;
location / {
# 界面界面
root /data/app/webui;
index index.html;
}
location /api {
# API 代理设置
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
}
location /mcp/ {
# MCP 代理设置,真的mcp都会在这里被挂载
set $mcp_upstream "unix:/tmp";
set $original_uri $request_uri;
keepalive_timeout 180;
rewrite_by_lua_block {
local captures, err = ngx.re.match(ngx.var.request_uri, "^/mcp/([a-zA-Z0-9\\-_]+)(/.*)?","jio")
if not captures then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.log(ngx.ERR, "Invalid request format: ", ngx.var.request_uri)
ngx.say("Invalid request format")
return
end
local key = captures[1]
local path_suffix = captures[2] or "/"
ngx.var.mcp_upstream = "unix:/var/run/mcpez/"..key..".sock"
}
# 代理设置
proxy_pass http://$mcp_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_buffering off;
}
}
}
```
--------------------------------------------------------------------------------
/bin/libs/mcpez.py:
--------------------------------------------------------------------------------
```python
from libs.mcpcli import MCPCli
from libs.mcphandler import make_mcp_handlers
import copy
import os
class MCPEazy:
def __init__(self, name, description=''):
self.servers = {}
self.name = name
self.description = description
self.functions = {}
self.ctxStore = None
self.pathroute = None
self.apps = None
async def add_mcp_server(self, name, config):
subsrv = MCPCli(config)
await subsrv.init()
self.servers[name] = subsrv
return self.servers[name]
async def add_to_server(self, app, pathroute=''):
self.apps = app
self.pathroute = pathroute
make_mcp_handlers(app, self, pathroute=pathroute, name=self.name)
async def stop(self):
names = list(self.servers.keys())
for name in names:
srv = self.servers.pop(name, None)
srv.close()
for route in self.apps.default_router.rules[0].target.rules:
if route.target_kwargs.get('name') == self.name:
self.apps.default_router.rules[0].target.rules.remove(route)
def get_tools(self, openai=None):
tools = []
self.functions = {}
for srv in self.servers.values():
for tool in srv.tools:
namehash = os.urandom(5).hex()
self.functions[namehash] = {'name':tool['name'], 'srv':srv}
_tool = copy.deepcopy(tool)
_tool['name'] = namehash
tools.append(_tool)
return tools
async def list_tools(self, req, session):
await session.write_jsonrpc(req['id'], {'tools':self.get_tools()})
async def call_tools(self, req, session):
name = req['params'].get('name')
if name not in self.functions:
return await session.write_jsonrpc(req['id'], {'error': {'code': -32601, 'message': f"Method {name} not found"}})
_srv = self.functions[name]
try:
req['params']['name'] = _srv['name']
result = await _srv['srv'].request('tools/call', req['params'])
return await session.write_jsonrpc(req['id'], {'result': result.get('result')})
except Exception as e:
return await session.write_jsonrpc(req['id'], {'error': {'code': -32603, 'message': str(e)}})
```
--------------------------------------------------------------------------------
/bin/mcpproxy.py:
--------------------------------------------------------------------------------
```python
import asyncio
import json
import tornado.web
import argparse
import sys, os
import logging
from tornado.httpserver import HTTPServer
from tornado.netutil import bind_unix_socket
from libs.mcpez import MCPEazy
from sqlmodel import Field, SQLModel, Session, create_engine
logging.basicConfig(level=logging.DEBUG)
optparser = argparse.ArgumentParser(description='MCP代理服务,支持从文件或数据库加载配置')
optparser.add_argument('-c', '--id', type=int, default=0, help='app id')
optparser.add_argument('-s', '--socketdir', default='/var/run/mcpez', help='服务器端口号,也可以是一个uds路径,')
optparser.add_argument('-n', '--name', default='mcpproxy',help='代理服务名称')
optargs = optparser.parse_args()
class AppDB(SQLModel, table=True):
id: int = Field(default=None, primary_key=True)
name: str = Field(index=True)
description: str = Field(default='')
item_type: str = Field(index=True)
config: str
functions: str = None
create_at: int = None
modify_at: int = None
class MCPProxy(MCPEazy):
def __init__(self, name="MCPProxy"):
super().__init__(name)
async def load_config_from_db(self, app_id):
engine = create_engine('sqlite:///./mcpez.db')
with Session(engine) as session:
try:
app = session.get(AppDB, app_id)
if not app: raise Exception('Config is not Found')
if app.item_type != 'app': raise Exception('item is not a app')
self.name = app.name
self.description = app.description
return json.loads(app.config)
except Exception as e:
logging.error(e)
sys.exit(1)
async def load_config(self):
try:
config = await self.load_config_from_db(optargs.id)
active_servers = []
for name, server_config in config.get('mcpServers', {}).items():
try:
await self.add_mcp_server(name, server_config)
active_servers.append(name)
except Exception as e:
logging.error(e)
if not active_servers: raise Exception('no active servers found')
return active_servers
except Exception as e:
logging.error(f"配置加载失败: {str(e)}")
return []
@staticmethod
async def start():
proxy = MCPProxy(name=optargs.name)
await proxy.load_config()
app = tornado.web.Application(debug=True)
await proxy.add_to_server(app, pathroute=f"/mcp/{optargs.id}")
server = HTTPServer(app)
socket_file = f"{optargs.socketdir}/{optargs.id}.sock"
server.add_socket(bind_unix_socket(socket_file))
os.system(f"chmod 777 {socket_file}")
logging.info(f"HTTP Server runing at unix:{socket_file}:/mcp/{optargs.id}/sse")
server.start()
async def main():
await MCPProxy.start()
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main())
```
--------------------------------------------------------------------------------
/webui/libs/mcpcli.js:
--------------------------------------------------------------------------------
```javascript
(function(global) {
class MCPCli {
constructor(server, {fetcher=fetch, timeout=60000, format='openai'}={}) {
this.server = server||'https://mcp-main.toai.chat/sse';
this.endpoint = null;
this.jsonrpc_id = 0;
this.responses = {}
this._tools = null
this._format = format
this.tools = []
this.timeout = timeout
this.fetcher = fetcher
this.stream = null
}
eventLoop(options) {
const { resolve, reject, timeout } = options;
const Stream = new EventSource(this.server);
this.stream = Stream
setTimeout(()=>{
if(!this.endpoint) { reject('timeout');Stream.close()}
}, timeout||this.timeout||60000)
Stream.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
this.responses[data.id] && this.responses[data.id](data)
});
Stream.addEventListener('endpoint', (event) => {
this.endpoint = event.data.startsWith('/') ? new URL(event.data, new URL(this.server)).toString() : event.data;
resolve(this.endpoint)
});
}
async connect(timeout) {
await (new Promise((resolve, reject) => { this.eventLoop({resolve, reject, timeout});}))
await this.send('initialize', {'protocolVersion':'2024-11-05', 'capabilities': {}, 'clientInfo': {'name': 'JSMCPCli', 'version': '0.1.0'}})
this.send('notifications/initialized', {}, {idpp:false})
this._tools = await this.send('tools/list')
this.tools = this.transform(this._format)
return this
}
close() {
this.stream.close()
}
send(method, params={}, args={}) {
const { idpp=true, timeout=this.timeout } = args;
return new Promise((resolve, reject) => {
idpp ? this.jsonrpc_id++ : null
const bodyObject = { jsonrpc: '2.0', id: this.jsonrpc_id, method: method, params: params }
setTimeout(()=>{
if (!this.responses[this.jsonrpc_id]) return
delete this.responses[this.jsonrpc_id]
reject('timeout')
}, timeout);
this.responses[this.jsonrpc_id] = (data) => {
if (!this.responses[this.jsonrpc_id]) return
delete this.responses[this.jsonrpc_id]
resolve(data.result)
}
fetch(this.endpoint, {method: 'POST', body: JSON.stringify(bodyObject),headers: { 'Content-Type': 'application/json' }})
})
}
transform (format) {
if (!this._tools) return []
if (format === 'claude') return this._tools.tools
if (format === 'openai') {
return this._tools.tools.map(tool => {
return {
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: {
type: 'object',
properties: tool.inputSchema.properties,
required: tool.inputSchema.required,
additionalProperties: false
},
strict: true
},
}
})
}
}
execute(name, args) {
return this.send('tools/call', {name:name, arguments:args})
}
}
global.MCPCli = MCPCli;
if (typeof module !== 'undefined' && module.exports) {
module.exports = { MCPCli };
} else if (typeof define === 'function' && define.amd) {
define(function() { return { MCPCli }; });
}
})(typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : this);
//(new MCPCli()).connect().then(cli=>cli.execute('get_stock_price', {symbol:'AAPL'})).then(console.log).catch(console.error)
```
--------------------------------------------------------------------------------
/bin/libs/mcphandler.py:
--------------------------------------------------------------------------------
```python
from tornado.web import RequestHandler
import json
import os
import logging
import time
class SSEServer(RequestHandler):
def initialize(self, *args, **kwargs):
self._auto_finish = False
self.ctxStore = kwargs.get('ctxStore')
self.pathroute = kwargs.get('pathroute', '')
def set_default_headers(self):
self.set_header('Content-Type', 'text/event-stream')
self.set_header('Cache-Control', 'no-cache')
self.set_header('Connection', 'keep-alive')
self.set_header('Access-Control-Allow-Origin', '*')
self.set_header('Access-Control-Allow-Headers', 'Content-Type')
self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
def options(self): self.set_status(204)
async def get(self):
self.ctxid = os.urandom(16).hex()
self.ctxStore[self.ctxid] = self
await self.write_sse(self.pathroute+'/messages/?session_id=' + self.ctxid, 'endpoint')
async def write_sse(self, data, event='message'):
self.write('event: ' + event + '\r\ndata: ' + data + '\r\n\r\n')
await self.flush()
async def write_jsonrpc(self, req_id, result):
response = {'jsonrpc': '2.0', 'id': req_id, 'result': result}
await self.write_sse( json.dumps(response) )
def on_connection_close(self):
if not hasattr(self, 'ctxid'): return
self.ctxStore.pop(self.ctxid, None)
class RPCServer(RequestHandler):
def initialize(self, *args, **kwargs):
self.executor = kwargs.get('executor')
self.ctxStore = kwargs.get('ctxStore')
def set_default_headers(self):
self.set_header('Content-Type', 'text/event-stream')
self.set_header('Cache-Control', 'no-cache')
self.set_header('Connection', 'keep-alive')
self.set_header('Access-Control-Allow-Origin', '*')
self.set_header('Access-Control-Allow-Headers', 'Content-Type')
self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
def options(self): self.set_status(204)
async def post(self):
ctxid = self.get_argument('session_id')
session = self.ctxStore.get(ctxid)
req = json.loads(self.request.body)
req_method = req.get('method')
func = self.for_name(req_method)
if func: await func(req, session)
self.set_status(202)
self.finish('Accepted')
def for_name(self, method):
return getattr(self, 'with_' + method.replace('/', '_'), None)
async def with_initialize(self, req, session):
req_id = req.get('id')
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"}}
await session.write_jsonrpc(req_id, result)
async def with_tools_list(self, req, session):
try:
self.executor and await self.executor.list_tools(req, session)
except Exception as e:
await session.write_jsonrpc(req['id'], {'tools': []})
async def with_ping(self, req, session):
req_id = req.get('id')
result = {}
await session.write_jsonrpc(req_id, result)
async def with_tools_call(self, req, session):
try:
self.executor and await self.executor.call_tools(req, session)
except Exception as e:
await session.write_jsonrpc(req['id'], {'result': 'error'})
class ServerStatus(RequestHandler):
def initialize(self, *args, **kwargs):
self.ctxStore = kwargs.get('ctxStore')
self.init_time = kwargs.get('init_time')
self.name = kwargs.get('name')
self.executor = kwargs.get('executor')
def set_default_headers(self):
self.set_header('Content-Type', 'application/json')
self.set_header('Cache-Control', 'no-cache')
self.set_header('Connection', 'keep-alive')
self.set_header('Access-Control-Allow-Origin', '*')
self.set_header('Access-Control-Allow-Headers', 'Content-Type')
self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
def options(self): self.set_status(204)
async def get(self):
self.finish(dict(
name = self.name,
description = self.executor.description,
init_time = self.init_time,
status = 'ok',
connection_cnt = len(self.ctxStore),
tools = self.executor.get_tools()
))
def make_mcp_handlers(application, executor, pathroute='',name=''):
ctxStore = {}
executor.ctxStore = ctxStore
executor.pathroute = pathroute
application.add_handlers('.*', [
(pathroute + '/sse', SSEServer, {'ctxStore': ctxStore, 'pathroute': pathroute, 'name':name}),
(pathroute + '/messages/', RPCServer, {'executor': executor, 'ctxStore': ctxStore, 'name':name}),
(pathroute + '/server_status', ServerStatus, {'ctxStore': ctxStore, 'executor': executor, 'name': name, 'init_time': int(time.time())}),
])
```
--------------------------------------------------------------------------------
/bin/libs/mcpcli.py:
--------------------------------------------------------------------------------
```python
import asyncio
import json
import logging
class MCPCli:
def __init__(self, config, **kwargs):
self.rpcid = 0
self.rpc_responses = {}
self.config = config
self.name = kwargs.get('name')
self.timeout = kwargs.get('timeout', 60)
self.config['type'] = 'sse' if config.get('baseUrl') else 'stdio'
self.write = None
self.tools = None
self.process = None
logging.info('created Mcpcli instance')
async def request(self, method, params, with_response=True, with_timeout=None):
assert self.write, "Connection not established"
if with_response:
self.rpcid += 1
json_rpc_data = {'method': method, 'params': params, 'jsonrpc': '2.0', 'id': self.rpcid}
fut = asyncio.Future()
self.rpc_responses[self.rpcid] = fut
logging.debug(f"Sending request: {json_rpc_data}")
await self.write(json.dumps(json_rpc_data).encode() + b'\n')
try:
return await asyncio.wait_for(fut, timeout=with_timeout or self.timeout)
except asyncio.TimeoutError:
raise TimeoutError(f"Timeout waiting for response to {method}({params})")
else:
json_rpc_data = {'method': method, 'params': params, 'jsonrpc': '2.0'}
await self.write(json.dumps(json_rpc_data).encode() + b'\n')
async def init(self):
if self.config['type'] == 'stdio':
await self.start_stdio_mcp()
elif self.config['type'] == 'sse':
await self.start_sse()
def close(self):
if self.config['type'] == 'stdio':
self.process.terminate()
elif self.config['type'] == 'sse':
asyncio.ensure_future(self.process.aclose())
async def start_sse(self):
import urllib.parse
from httpx_sse import EventSource
import httpx
client = httpx.AsyncClient(verify=False, timeout=httpx.Timeout(None, connect=10.0))
session_addr = None
is_connected = asyncio.Future()
async def start_loop():
try:
async with client.stream('GET', self.config['baseUrl']) as response:
self.process = client
event_source = EventSource(response)
async for event in event_source.aiter_sse():
if client.is_closed: break
if event.event == 'endpoint':
session_addr = event.data if event.data.startswith('http') else urllib.parse.urljoin(self.config['baseUrl'], event.data)
is_connected.set_result(session_addr)
elif event.event == 'message':
try:
data = json.loads(event.data)
if data.get('id') and data.get('result'):
future = self.rpc_responses.get(data['id'])
if future: future.set_result(data)
except json.JSONDecodeError:
logging.warning(f"Failed to decode JSON: {repr(event.data)}")
except Exception as e:
await client.aclose()
def writer(data):
return client.post(session_addr, data=data)
asyncio.ensure_future(start_loop())
session_addr = await is_connected
self.write = writer
await self.request('initialize', {'protocolVersion':'2024-11-05', 'capabilities': {}, 'clientInfo': {'name': 'EzMCPCli', 'version': '0.1.2'}}, with_response=False)
await self.request('notifications/initialized', {}, with_response=False)
r = await self.request('tools/list', {}, with_timeout=10)
self.tools = r.get('result', {}).get('tools', [])
async def start_stdio_mcp(self):
proc = await asyncio.create_subprocess_exec(
self.config['command'],
*self.config['args'],
env=self.config.get('env', None),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
self.process = proc
async def writer(data):
proc.stdin.write(data)
self.write = writer
async def start_loop():
while True:
line = await proc.stdout.readline()
if not line:
break
try:
data = json.loads(line.decode())
if 'id' in data and data['id'] in self.rpc_responses:
self.rpc_responses[data['id']].set_result(data)
del self.rpc_responses[data['id']]
else:
logging.debug(f"Received notification: {data}")
except json.JSONDecodeError as e:
logging.warning(f"Failed to decode JSON: {repr(line)}")
asyncio.ensure_future(start_loop())
await self.request('initialize', {'protocolVersion':'2024-11-05', 'capabilities': {}, 'clientInfo': {'name': 'EzMCPCli', 'version': '0.1.2'}}, with_response=False)
await self.request('notifications/initialized', {}, with_response=False)
r = await self.request('tools/list', {}, with_timeout=30)
self.tools = r.get('result', {}).get('tools', [])
```
--------------------------------------------------------------------------------
/webui/libs/ai/ai.js:
--------------------------------------------------------------------------------
```javascript
// Version: 1.1.0
(function(global) {
class AI {
constructor(options = {}) {
this.api_key = options.api_key || options.apiKey || '';
this.baseURL = options.baseURL || options.endpoint || 'https://porky.toai.chat/to/openrouter';
this.completionsURI = options.completionsURI === undefined ? '/chat/completions' : '';
this.model = options.model || 'deepseek/deepseek-r1-distill-qwen-32b:free';
this.messages = options.messages || [];
this.system_prompt = options.system_prompt || options.sysprompt || '';
this.abortController = null;
this.toolFn = options.tool_fn || options.mcpserver || null;
this.opts = { temperature: 0.7, max_tokens: 128000, top_p: 1, stream: true, ...options.opts };
this.completions = { create: this.create.bind(this) };
this.filters = {};
this.add_response_filter('reasoning', data=>!!data.choices?.[0]?.delta?.reasoning);
return this;
}
add_response_filter(queue_name, filterFn) {
if (!this.filters[queue_name]) this.filters[queue_name] = [];
this.filters[queue_name].push(filterFn);
return this;
}
cancel() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
async create(prompt, args) {
const { options={}, images=[], audio=[], files=[] } = args || {};
const messages = [...(options.messages || this.messages || [])];
if (this.system_prompt) messages.unshift({ role: 'system', content: this.system_prompt });
if (prompt || images?.length || audio?.length || files?.length)
messages.push(this.prepareUserMessage(prompt, { images, audio, files }));
const reqOptions = {
model: options.model || this.model,
messages,
temperature: options.temperature || this.opts.temperature,
max_tokens: options.max_tokens || this.opts.max_tokens,
top_p: options.top_p || this.opts.top_p,
stream: options.stream !== undefined ? options.stream : this.opts.stream,
...(this.toolFn?.tools && { tools: this.toolFn.tools, tool_choice: options.tool_choice || "auto" })
};
this.abortController = new AbortController();
try {
const response = await fetch(`${this.baseURL}${this.completionsURI}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.api_key ? { 'Authorization': `Bearer ${this.api_key}` } : {})
},
body: JSON.stringify(reqOptions),
signal: this.abortController.signal
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(`API错误: ${response.status} ${error.error?.message || ''}`);
}
if (!reqOptions.stream) {
const result = await response.json();
return new ResponseIterator({
type: 'final',
message: result.choices?.[0]?.message || { role: 'assistant', content: '' },
ai: this, final: true,
filters: this.filters
});
}
const iter = new ResponseIterator({ ai: this, reqOptions, filters: this.filters });
this.processStream(response, iter);
return iter;
} catch (error) { throw error; }
}
async processStream(response, iter) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
const msg = { role: 'assistant', content: '' }; // 移除 reasoning_content 字段
const toolCalls = [];
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const event = this.parseSSELine(line);
if (!event) continue;
if (event.isDone) {
await this.finalizeResponse(msg, toolCalls, iter);
return;
}
await this.processEvent(event, msg, toolCalls, iter);
}
}
// 处理最后可能的缓冲数据
if (buffer.trim()) {
const event = this.parseSSELine(buffer);
if (event && !event.isDone) {
await this.processEvent(event, msg, toolCalls, iter);
}
}
await this.finalizeResponse(msg, toolCalls, iter);
} catch (error) {
iter.error(error);
} finally {
reader.releaseLock();
this.abortController = null;
}
}
parseSSELine(line) {
line = line.trim();
if (!line) return null;
// 检测[DONE]标记
if (line === 'data: [DONE]') {
return { isDone: true };
}
// 解析数据
let eventType = '';
let data = '';
if (line.startsWith('event:')) {
eventType = line.slice(6).trim();
} else if (line.startsWith('data:')) {
data = line.slice(5).trim();
try {
return { type: eventType || 'content', data: JSON.parse(data), isDone: false };
} catch (e) {
return { type: eventType || 'content', data, isDone: false, raw: true };
}
} else {
try {
return { type: 'content', data: JSON.parse(line), isDone: false };
} catch (e) {
return { type: 'content', data: line, isDone: false, raw: true };
}
}
return null;
}
async processEvent(event, msg, toolCalls, iter) {
const { type, data, raw } = event;
// 应用过滤器并推送到队列
Object.keys(this.filters).forEach((filter_name)=>{
const filter = this.filters[filter_name];
const shouldpush = filter.some(f=>f(data))
if (shouldpush) {
iter.push(filter_name, { type: filter_name, message: {...msg}, delta: data });
}
})
// 处理对象数据
if (!raw && typeof data === 'object') {
// 处理工具调用
if (data.choices && data.choices[0]?.delta?.tool_calls) {
this.updateToolCalls(data.choices[0].delta.tool_calls, toolCalls);
iter.push('tool_calls', { type: 'tool_calls', toolCalls: [...toolCalls], message: {...msg}, delta: data });
if (data.choices[0].finish_reason === 'tool_calls') {
msg.tool_calls = [...toolCalls];
iter.push('tool_request', { type: 'tool_request', toolCalls: [...toolCalls], message: {...msg} });
return await this.handleToolCalls(msg, toolCalls, iter);
}
return;
}
// 处理内容更新
const content = data.choices?.[0]?.delta?.content || '';
if (content) {
msg.content += content;
iter.push('content', { type: 'content', content, message: {...msg}, delta: content });
return;
}
} else {
// 处理字符串和其他类型的数据
switch (type) {
case 'tool_calls':
if (Array.isArray(data)) {
this.updateToolCalls(data, toolCalls);
iter.push('tool_calls', { type, toolCalls: [...toolCalls], message: {...msg}, delta: data });
if (toolCalls.every(t => t.function?.arguments)) {
msg.tool_calls = [...toolCalls];
iter.push('tool_request', { type: 'tool_request', toolCalls: [...toolCalls], message: {...msg} });
return await this.handleToolCalls(msg, toolCalls, iter);
}
}
break;
case 'content':
if (data) {
msg.content += data;
iter.push('content', { type, content: data, message: {...msg}, delta: data });
}
break;
}
}
}
async finalizeResponse(msg, toolCalls, iter) {
if (toolCalls.length > 0 && toolCalls.every(t => t.function?.name)) {
msg.tool_calls = [...toolCalls];
iter.push('tool_request', { type: 'tool_request', toolCalls: [...toolCalls], message: {...msg} });
return await this.handleToolCalls(msg, toolCalls, iter);
} else {
iter.push('final', { type: 'final', message: msg });
iter.complete();
}
}
async handleToolCalls(msg, toolCalls, iter) {
if (!this.toolFn) {
iter.push('final', { type: 'final', message: msg });
iter.complete();
return;
}
const results = await Promise.all(toolCalls.map(async tool => {
try {
const args = JSON.parse(tool.function.arguments);
const result = await this.toolFn.execute(tool.function.name, args);
return {
tool_call_id: tool.id,
role: "tool",
content: typeof result === 'string' ? result : JSON.stringify(result)
};
} catch (err) {
return {
tool_call_id: tool.id,
role: "tool",
content: JSON.stringify({ error: err.message })
};
}
}));
iter.push('tool_response', { type: 'tool_response', toolResults: results, toolCalls });
try {
// 继续对话,将工具结果添加到消息历史中
const nextResponse = await this.create("", {
options: {
...iter.reqOptions,
messages: [
...iter.reqOptions.messages,
{...msg},
...results.map(r => ({
role: "tool",
tool_call_id: r.tool_call_id,
content: r.content
}))
],
tool_choice: "auto"
}
});
iter.chainWith(nextResponse);
} catch (error) { iter.error(error); }
}
prepareUserMessage(message, { images, audio, files } = {}) {
if ((!images?.length) && (!audio?.length) && (!files?.length))
return { role: "user", content: message };
const content = [];
if (message?.trim()) content.push({ type: "text", text: message });
if (images?.length) images.forEach(img => content.push({
type: "image_url", image_url: { url: img.url || img, detail: img.detail || "auto" }
}));
if (audio?.length) audio.forEach(clip =>
content.push({ type: "audio", audio: { url: clip.url || clip } }));
if (files?.length) files.forEach(file =>
content.push({ type: "file", file: { url: file.url || file } }));
return { role: "user", content };
}
updateToolCalls(deltas, toolCalls) {
if (!Array.isArray(deltas)) return;
deltas.forEach(d => {
const idx = d.index;
if (!toolCalls[idx]) toolCalls[idx] = {
id: d.id || '', type: 'function', function: { name: '', arguments: '' }
};
if (d.id) toolCalls[idx].id = d.id;
if (d.function?.name) toolCalls[idx].function.name = d.function.name;
if (d.function?.arguments) toolCalls[idx].function.arguments += d.function.arguments;
});
}
}
class ResponseIterator {
constructor({ ai, reqOptions, type, message, final = false, filters = {} }) {
this.ai = ai;
this.reqOptions = reqOptions;
this.queues = new Map();
this.waiters = new Map();
this.chained = null;
this.completed = false;
this.filters = filters;
// 创建所有队列 - 移除 reasoning_content
['content', 'tool_calls', 'tool_request',
'tool_response', 'streaming', 'final', ...Object.keys(filters)].forEach(t => {
this.queues.set(t, []);
this.waiters.set(t, []);
});
if (final && type && message) {
this.push(type, { type, message });
this.complete();
}
}
push(type, data) {
if (this.completed) return;
// 仅处理基本队列
const queue = this.queues.get(type) || [];
const waiters = this.waiters.get(type) || [];
queue.push(data);
if (waiters.length) waiters.shift()();
}
complete() {
this.completed = true;
for (const waiters of this.waiters.values()) waiters.forEach(r => r());
}
chainWith(r) { this.chained = r; this.complete(); }
error(e) { this.errorObj = e; this.complete(); }
on(type) {
if (!this.queues.has(type)) throw new Error(`未知事件类型: ${type}`);
const self = this;
return {
async *[Symbol.asyncIterator]() {
try {
if (self.errorObj) throw self.errorObj;
let queue = self.queues.get(type), index = 0;
while (true) {
if (index >= queue.length) {
if (self.completed && !self.chained) break;
if (self.chained) {
for await (const item of self.chained.on(type)) yield item;
break;
}
await new Promise(r => self.waiters.get(type).push(r));
if (self.errorObj) throw self.errorObj;
queue = self.queues.get(type);
} else yield queue[index++];
}
} catch (e) { throw e; }
}
};
}
async *[Symbol.asyncIterator]() { yield* this.on('streaming'); }
}
global.AI = AI;
if (typeof module !== 'undefined' && module.exports) {
module.exports = { AI };
} else if (typeof define === 'function' && define.amd) {
define(function() { return { AI }; });
}
})(typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : this);
```
--------------------------------------------------------------------------------
/webui/index.html:
--------------------------------------------------------------------------------
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP服务状态管理</title>
<!-- 引入TailwindCSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#e3f2fd',
100: '#bbdefb',
200: '#90caf9',
300: '#64b5f6',
400: '#42a5f5',
500: '#2196f3',
600: '#1e88e5',
700: '#1976d2',
800: '#1565c0',
900: '#0d47a1'
},
secondary: '#64748b'
}
}
}
}
</script>
<!-- 引入UIkit -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/uikit.min.css" />
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit-icons.min.js"></script>
<style>
.uk-card {
border-radius: 1rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.uk-button {
border-radius: 0.5rem;
transition: all 0.2s ease;
}
.uk-button-primary {
background: linear-gradient(145deg, #1e88e5, #1976d2);
box-shadow: 0 4px 6px -1px rgba(30, 136, 229, 0.2);
}
.uk-button-primary:hover {
transform: translateY(-1px);
box-shadow: 0 10px 15px -3px rgba(30, 136, 229, 0.3);
}
.uk-input, .uk-select, .uk-textarea {
border-radius: 0.5rem;
transition: all 0.2s ease;
}
.uk-input:focus, .uk-select:focus, .uk-textarea:focus {
box-shadow: 0 0 0 3px rgba(30, 136, 229, 0.2);
}
.uk-form-label {
font-weight: 500;
margin-bottom: 0.5rem;
color: #334155;
}
.service-table tr {
transition: all 0.2s ease;
}
.service-table tr:hover {
background-color: rgba(30, 136, 229, 0.05);
}
.service-table tr.active {
background-color: rgba(30, 136, 229, 0.1);
border-left: 4px solid #1e88e5;
}
.badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
/* 定制滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
@media (max-width: 959px) {
.mobile-full-width {
width: 100% !important;
}
}
</style>
</head>
<body class="bg-slate-100 min-h-screen">
<!-- 导航栏 -->
<nav class="bg-gradient-to-r from-primary-700 to-primary-500 shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center text-white">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<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" />
</svg>
<span class="ml-2 text-xl font-bold">MCP服务状态管理</span>
</div>
<div class="flex items-center gap-6">
<div class="relative">
<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="搜索应用...">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="w-5 h-5 text-gray-300" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"/>
</svg>
</div>
</div>
<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">
<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">
<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" />
</svg>
PlayGround
</a>
</div>
</div>
</div>
</nav>
<!-- 主界面 -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white rounded-2xl shadow-xl p-6 overflow-hidden">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-800">应用服务列表</h2>
<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>
</div>
<!-- 加载指示器 -->
<div id="statusLoading" class="flex justify-center items-center py-8 hidden">
<div uk-spinner></div>
<span class="ml-3">加载服务状态...</span>
</div>
<div class="overflow-x-auto max-h-[600px] overflow-y-auto">
<table class="w-full text-left service-table">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider sticky top-0 z-10">
<tr>
<th class="px-4 py-3 rounded-tl-lg">应用ID</th>
<th class="px-4 py-3">名称</th>
<th class="px-4 py-3">描述</th>
<th class="px-4 py-3">状态</th>
<th class="px-4 py-3">详情</th>
<th class="px-4 py-3 rounded-tr-lg text-right">操作</th>
</tr>
</thead>
<tbody id="serviceTableBody" class="divide-y divide-gray-100">
<!-- 应用列表项会动态添加到这里 -->
</tbody>
</table>
</div>
<div id="noServicesMessage" class="text-center py-10 text-gray-500">
还没有可运行的MCP服务,请开始 <a href="edit.html" target="_blank">[新建服务]</a>
</div>
</div>
</div>
<!-- 应用详情模态框 -->
<div id="app-details-modal" uk-modal>
<div class="uk-modal-dialog uk-modal-body rounded-xl w-[80%]">
<button class="uk-modal-close-default" type="button" uk-close></button>
<h2 class="text-2xl font-bold mb-4" id="appModalTitle">应用详情</h2>
<div class="space-y-4">
<div>
<h3 class="text-sm font-medium text-gray-500">应用描述</h3>
<p id="appModalDescription" class="mt-1 text-gray-900">加载中...</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500">服务配置</h3>
<div class="mt-2 bg-gray-50 p-4 rounded-lg">
<pre id="appModalConfig" class="whitespace-pre-wrap overflow-x-auto text-xs">加载中...</pre>
</div>
</div>
</div>
<div class="flex justify-end space-x-4 mt-6">
<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">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5v14l11-7-11-7z" fill="currentColor"/>
</svg>
启动服务
</button>
<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">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 18L18 6M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
关闭
</button>
</div>
</div>
</div>
<!-- 服务详情模态框 -->
<div id="service-details-modal" uk-modal>
<div class="uk-modal-dialog uk-modal-body rounded-xl w-[80%]">
<button class="uk-modal-close-default" type="button" uk-close></button>
<h2 class="text-2xl font-bold mb-4">服务状态详情</h2>
<div id="serviceStatusLoading" class="flex justify-center items-center py-8">
<div uk-spinner></div>
<span class="ml-3">加载服务状态...</span>
</div>
<div id="serviceStatusContent" class="space-y-4 hidden">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h3 class="text-sm font-medium text-gray-500">服务ID</h3>
<p id="serviceModalId" class="mt-1 text-gray-900">-</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500">当前状态</h3>
<div id="serviceModalStatus" class="mt-1">-</div>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500">服务地址</h3>
<p id="serviceModalAddress" class="mt-1 text-gray-900 break-all">-</p>
</div>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500">详细信息</h3>
<div class="mt-2 bg-gray-50 p-4 rounded-lg">
<pre id="serviceModalDetails" class="whitespace-pre-wrap overflow-x-auto text-xs">无可用信息</pre>
</div>
</div>
</div>
<div id="serviceStatusError" class="bg-red-50 text-red-600 p-4 rounded-lg mb-4 hidden">
无法获取服务状态信息
</div>
<div class="flex justify-end space-x-4 mt-6">
<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">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6h12v12H6z" fill="currentColor"/>
</svg>
停止服务
</button>
<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">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"/>
</svg>
刷新
</button>
<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">
关闭
</button>
</div>
</div>
</div>
<!-- 引入控制器脚本 -->
<script src="js/serviceCtl.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 初始化服务控制器
const controller = new ServiceController();
controller.init();
});
</script>
</body>
</html>
```
--------------------------------------------------------------------------------
/webui/edit.html:
--------------------------------------------------------------------------------
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP应用编辑器</title>
<!-- TailwindCSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#e3f2fd',
100: '#bbdefb',
200: '#90caf9',
300: '#64b5f6',
400: '#42a5f5',
500: '#2196f3',
600: '#1e88e5',
700: '#1976d2',
800: '#1565c0',
900: '#0d47a1'
},
secondary: '#64748b'
}
}
}
}
</script>
<!-- UIkit CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/uikit.min.css" />
<!-- UIkit JS -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit-icons.min.js"></script>
<style>
.uk-card {
border-radius: 1rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.uk-button {
border-radius: 0.5rem;
transition: all 0.2s ease;
}
.uk-button-primary {
background: linear-gradient(145deg, #1e88e5, #1976d2);
box-shadow: 0 4px 6px -1px rgba(30, 136, 229, 0.2);
}
.uk-button-primary:hover {
transform: translateY(-1px);
box-shadow: 0 10px 15px -3px rgba(30, 136, 229, 0.3);
}
.uk-input, .uk-select, .uk-textarea {
border-radius: 0.5rem;
transition: all 0.2s ease;
}
.uk-input:focus, .uk-select:focus, .uk-textarea:focus {
box-shadow: 0 0 0 3px rgba(30, 136, 229, 0.2);
}
.uk-form-label {
font-weight: 500;
margin-bottom: 0.5rem;
color: #334155;
}
</style>
</head>
<body class="bg-slate-100 min-h-screen">
<!-- 导航栏 -->
<nav class="bg-gradient-to-r from-primary-700 to-primary-500 shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center text-white">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<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" />
</svg>
<span class="ml-2 text-xl font-bold">MCP应用编辑器</span>
</div>
<div class="flex items-center gap-6">
<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">
<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">
<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" />
</svg>
导出JSON
</button>
</div>
</div>
</div>
</nav>
<!-- 主内容区域 -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-12 gap-6">
<!-- 侧边栏 -->
<div class="col-span-12 md:col-span-3 space-y-6">
<!-- 基本信息卡片 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-lg font-bold text-gray-800 mb-4">应用信息</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">应用名称</label>
<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="输入应用名称">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">应用描述</label>
<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>
</div>
</div>
</div>
<!-- 导入/导出卡片 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-lg font-bold text-gray-800 mb-4">配置管理</h2>
<div class="space-y-3 mb-3">
<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">
<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">
<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" />
</svg>
保存配置
</button>
</div>
<div class="space-y-3">
<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">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"/>
</svg>
导入JSON
</button>
<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">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"/>
</svg>
使用模板
</button>
<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">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"/>
</svg>
删除应用
</button>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="col-span-12 md:col-span-9 space-y-6">
<!-- 服务器列表 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-bold text-gray-800">服务器配置</h2>
<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">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 6v12M18 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
MCP
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-4 py-3 rounded-tl-lg">名称</th>
<th class="px-4 py-3">类型</th>
<th class="px-4 py-3">命令</th>
<th class="px-4 py-3 rounded-tr-lg text-right">操作</th>
</tr>
</thead>
<tbody id="serverTableBody" class="divide-y divide-gray-100">
<!-- 服务器列表项会动态添加到这里 -->
</tbody>
</table>
</div>
<div id="emptyServerMessage" class="text-center py-8 text-gray-500 hidden">
还没有添加任何服务器配置
</div>
</div>
<!-- JSON预览区域 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-lg font-bold text-gray-800 mb-4">JSON预览</h2>
<div class="relative">
<pre id="jsonPreview" class="bg-gray-50 p-4 rounded-lg text-sm font-mono overflow-x-auto"></pre>
<button id="copyJsonBtn" class="absolute top-2 right-2 p-2 text-gray-500 hover:text-gray-700 bg-white rounded-lg shadow-sm">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 服务器配置模态框 -->
<div id="server-modal" uk-modal>
<div class="uk-modal-dialog uk-modal-body rounded-xl">
<button class="uk-modal-close-default" type="button" uk-close></button>
<h2 class="text-2xl font-bold mb-6" id="serverModalTitle">添加服务器</h2>
<form id="serverForm" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">服务器名称</label>
<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="输入服务器名称">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">服务器类型</label>
<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">
<option value="sse">SSE (远程服务)</option>
<option value="stdio">STDIO (本地进程)</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">服务器描述</label>
<textarea id="serverDescription" class="border w-full min-h-[48px] p-2 rounded-lg focus:border-sky-200"></textarea>
</div>
<!-- SSE配置 -->
<div id="sseConfig" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Base URL</label>
<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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Headers</label>
<div id="headersContainer" class="space-y-2">
<!-- Headers会动态添加到这里 -->
</div>
<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">
<svg class="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="none">
<path d="M12 6v12M18 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
添加Header
</button>
</div>
</div>
<!-- STDIO配置 -->
<div id="stdioConfig" class="space-y-4 hidden">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Command</label>
<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="执行的命令">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">参数列表</label>
<div id="argsContainer" class="space-y-2">
<!-- 参数会动态添加到这里 -->
</div>
<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">
<svg class="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="none">
<path d="M12 6v12M18 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
添加参数
</button>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">环境变量</label>
<div id="envContainer" class="space-y-2">
<!-- 环境变量会动态添加到这里 -->
</div>
<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">
<svg class="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="none">
<path d="M12 6v12M18 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
添加环境变量
</button>
</div>
</div>
<div class="flex justify-end space-x-4 pt-4">
<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">
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="none">
<path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
保存配置
</button>
<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">
取消
</button>
</div>
</form>
</div>
</div>
<!-- 模板选择模态框 -->
<div id="template-modal" uk-modal>
<div class="uk-modal-dialog uk-modal-body rounded-xl w-2/5">
<button class="uk-modal-close-default" type="button" uk-close></button>
<h2 class="text-2xl font-bold mb-4 ">选择模板</h2>
<div id="templateLoading" class="flex justify-center items-center py-8">
<div uk-spinner></div>
<span class="ml-3">加载模板列表...</span>
</div>
<div id="templateTableContainer" class="hidden">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center">
<input type="checkbox" id="selectAllTools" class="mr-2 h-4 w-4 rounded text-primary-600">
<label for="selectAllTools" class="text-sm font-medium text-gray-700">全选</label>
</div>
<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">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none">
<path d="M12 6v12M18 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
添加选中的模板
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-4 py-3 rounded-tl-lg w-10"></th>
<th class="px-4 py-3">名称</th>
<th class="px-4 py-3">类型</th>
<th class="px-4 py-3 rounded-tr-lg">描述</th>
</tr>
</thead>
<tbody id="templateTableBody" class="divide-y divide-gray-100">
<!-- 模板列表项会动态添加到这里 -->
</tbody>
</table>
</div>
</div>
<div id="noTemplatesMessage" class="text-center py-8 text-gray-500 hidden">
<div id="json-modal" uk-modal>
<div class="uk-modal-dialog uk-modal-body rounded-xl">
<button class="uk-modal-close-default" type="button" uk-close></button>
<h2 class="text-2xl font-bold mb-4">导入JSON配置</h2>
<div class="mb-4">
<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>
</div>
<div class="flex justify-end">
<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">
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="none">
<path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
导入配置
</button>
</div>
</div>
</div>
<script src="js/MCPEditor.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const editor = new MCPEditor();
editor.init();
});
</script>
</body>
</html>
```
--------------------------------------------------------------------------------
/webui/chat.html:
--------------------------------------------------------------------------------
```html
<!DOCTYPE html>
<html lang="zh-CN" class="scroll-smooth overflow-hidden">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<title>Playground/toai.chat</title>
<!-- 引入UIKit和TailwindCSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/uikit.min.css" />
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit-icons.min.js"></script>
<!-- 引入marked和highlight.js -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/index.umd.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css/github-markdown.css" id="markdown-light">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/github-markdown-dark.css" id="markdown-dark" disabled>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/styles/a11y-light.min.css" id="code-light">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/styles/a11y-dark.min.css" id="code-dark" disabled>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.css" id="mermaid-light">
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="ai/ai.js"></script>
<script src="mcp/mcpcli.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'ai-green': '#10a37f',
'ai-green-dark': '#0e8a6e',
'chat-bg': '#f7f7f8',
'chat-dark': '#444654',
'text-light': '#222222',
'text-dark': '#ececf1',
'user-bg': '#eeeeee'
},
boxShadow: {
'send-btn': '0 2px 5px rgba(0,0,0,0.1)'
}
}
}
}
</script>
<style>
.markdown-body { background-color: transparent; h-* { color: #eee; } }
::-webkit-scrollbar { width: 0px; }
</style>
</head>
<body class="dark:bg-gray-900 dark:text-gray-100">
<!-- 添加UIKit Offcanvas组件作为左侧抽屉 -->
<div id="offcanvas-nav" uk-offcanvas="overlay: true; mode: push">
<div class="uk-offcanvas-bar bg-gray-50 text-gray-600 dark:bg-gray-600 dark:text-gray-100">
<button class="uk-offcanvas-close" type="button" uk-close></button>
<div class="font-bold text-lg text-gray-800 dark:text-gray-100 mb-4">Topic History</div>
<!-- 改为黑白配色的新对话按钮 -->
<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">
<span uk-icon="icon: plus" class="text-white"></span>
<span class="ml-2 text-sm font-medium">New Topic</span>
</div>
<div class="conversation-list mt-4" id="conversation-list"></div>
</div>
</div>
<div id="app" class="flex flex-col h-screen">
<!-- 顶部导航栏 - 固定定位 -->
<header class="fixed top-0 left-0 w-full bg-white dark:bg-gray-900 z-40">
<div class="uk-container uk-container-expand">
<nav class="uk-navbar" uk-navbar>
<div class="uk-navbar-left">
<!-- 添加菜单按钮 -->
<a class="uk-navbar-toggle" href="#" uk-toggle="target: #offcanvas-nav">
<span uk-icon="icon: menu"></span>
</a>
<!-- 将固定标题改为可编辑区域 -->
<div class="uk-navbar-item dark:text-gray-100">
<span id="conversation-title" contenteditable="true"></span>
</div>
</div>
<div class="uk-navbar-right">
<ul class="uk-navbar-nav">
<li>
<a href="#" id="theme-toggle" class="theme-icon flex items-center justify-center dark:text-gray-100">
<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">
<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" />
</svg>
</a>
</li>
<li>
<a href="#" uk-icon="icon: cog"></a>
<div class="uk-navbar-dropdown" uk-dropdown="mode: click; animation: uk-animation-slide-top-small; duration: 100">
<ul class="uk-nav uk-navbar-dropdown-nav line-height-2">
<li style="margin:16px;"><a id="clear-chat" uk-icon="icon: refresh">清空记录</a></li>
<li style="margin:16px;"><a id="remove-chat" uk-icon="icon: trash">删除对话</a></li>
<li style="margin:16px;"><a id="api-settings" uk-icon="icon: cog">对话设置</a></li>
</ul>
</div>
</li>
</ul>
</div>
</nav>
</div>
</header>
<!-- 聊天区域 - 调整顶部边距以避免被固定header覆盖 -->
<main class="flex-grow pt-[80px] overflow-y-auto">
<div class="uk-container uk-container-expand h-full">
<div id="chat-container" class="chat-container flex flex-col mb-2.5 p-5 pb-[240px]"></div>
</div>
</main>
<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">
<div class="message-toolbar uk-margin-small-bottom flex items-center gap-8 px-4">
<div class="uk-inline">
<div class="js-upload cursor-hand" uk-form-custom>
<button class="toolbar-btn" type="button" uk-tooltip="上传文件">
<span uk-icon="icon: image"></span>
<input type="file" id="image-upload-input" accept="image/*" multiple>
</button>
</div>
</div>
<div class="uk-inline">
<button class="toolbar-btn" id="mcp-toggle" uk-tooltip="MCP">
<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">
<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" />
</svg>
</span>
</button>
</div>
</div>
<div class="flex items-start gap-2.5 relative mx-auto my-0">
<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>
<!-- 重写发送按钮,添加两种状态 -->
<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>
<!-- 正常状态 -->
<span id="send-icon" class="block">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<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" />
</svg>
</span>
<!-- loading状态 -->
<span id="loading-icon" class="hidden">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<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>
</svg>
</span>
</button>
</div>
<div id="image-preview-container" class="flex flex-wrap gap-2.5 mt-2.5">
<ul class="uk-thumbnav" uk-margin id="image-preview-container"></ul>
</div>
</div>
</div>
<div id="settings-modal" uk-modal>
<div class="uk-modal-dialog uk-modal-body">
<h2 class="uk-modal-title">对话设置</h2>
<form class="uk-form-stacked">
<div class="uk-margin">
<label class="uk-form-label">API Key</label>
<div class="uk-form-controls"><input class="uk-input" id="apiKey" type="password" placeholder="输入你的API Key"></div>
</div>
<div class="uk-margin">
<label class="uk-form-label">MCP(SSE)</label>
<div class="uk-form-controls"><input class="uk-input" id="mcp" type="text" placeholder="请输入MCP服务器地址"></div>
</div>
<div class="uk-margin">
<label class="uk-form-label">API baseURL</label>
<div class="uk-form-controls"><input class="uk-input" id="baseURL" type="text" placeholder="https://porky.toai.chat/pollination/v1"></div>
</div>
<div class="uk-margin">
<label class="uk-form-label">模型</label>
<div class="uk-form-controls">
<input class="uk-input" id="model" type="text" placeholder="模型名称" value="openai">
</div>
<a href="https://text.pollinations.ai/models" target="_blank">see the models if you are using pollinations</a>
</div>
<div class="uk-margin">
<label class="uk-form-label">系统提示 (System Prompt)</label>
<div class="uk-form-controls">
<textarea class="uk-textarea" id="systemPrompt" rows="3" placeholder="输入系统提示,指导AI的行为和角色"></textarea>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">温度 <span id="temperature-value">0.7</span></label>
<div class="uk-form-controls"><input class="uk-range" id="temperature" type="range" min="0" max="2" step="0.01" value="0.7"></div>
</div>
<div class="uk-margin">
<label class="uk-form-label">最大令牌数 (Max Tokens)</label>
<div class="uk-form-controls">
<input class="uk-input" id="maxTokens" type="number" placeholder="最大令牌数" value="128000">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Top P <span id="top-p-value">1.0</span></label>
<div class="uk-form-controls"><input class="uk-range" id="topP" type="range" min="0" max="1" step="0.01" value="1.0"></div>
</div>
</form>
<div class="uk-text-right">
<button class="uk-button uk-button-default uk-modal-close rounded-md">取消</button>
<button class="uk-button uk-button-primary rounded-md" id="save-settings">保存</button>
</div>
</div>
</div>
<script>
const appState = {
darkMode: localStorage.getItem('darkMode') === 'true',
currentChat: null,
togglekMode() {
this.darkMode = !this.darkMode;
localStorage.setItem('darkMode', this.darkMode);
document.documentElement.classList.toggle('dark')
document.getElementById('markdown-light').disabled = this.darkMode;
document.getElementById('markdown-dark').disabled = !this.darkMode;
document.getElementById('code-light').disabled = this.darkMode;
document.getElementById('code-dark').disabled = !this.darkMode;
}
}
document.getElementById('theme-toggle').addEventListener('click', appState.togglekMode)
mermaid.initialize({startOnLoad:true, theme: appState.darkMode ? 'neutral' : 'neutral', securityLevel: 'loose'});
class DataBase {
constructor(dbname, structure={}) {
this.dbName = dbname
this.structure = structure;
this.db = null;
this.ready = this.initDatabase();
}
async initDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
for (const storeName in this.structure) {
if (!db.objectStoreNames.contains(storeName)) {
const store = db.createObjectStore(storeName, { keyPath: this.structure[storeName].keyPath });
for (const index of this.structure[storeName].indexes) {
store.createIndex(index.name, index.keyPath, { unique: index.unique||false });
}
}
}
};
request.onsuccess = (event) => { this.db = event.target.result; resolve(true);};
request.onerror = (event) => { reject(event.target.error);};
});
}
async getCollectionData(collection, key, filter) {
await this.ready;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([collection], 'readonly');
const store = transaction.objectStore(collection);
const index = store.index(key);
const request = !filter ? index.getAll() : index.getAll(filter);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async setCollectionData(collection, data) {
await this.ready;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([collection], 'readwrite');
const store = transaction.objectStore(collection);
const request = store.put(data);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async deleteCollectionData(collection, key) {
await this.ready;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([collection], 'readwrite');
const store = transaction.objectStore(collection);
const request = store.delete(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
class MainDatabase extends DataBase {
constructor() {
super('MainDB', {
conversations: {
keyPath: 'id',
indexes: [
{ name: 'title', keyPath: 'title' },
{ name: 'lastMessage', keyPath: 'lastMessage' },
{ name: 'id', keyPath: 'id' },
{ name: 'timestamp', keyPath: 'timestamp' }
]
}
});
}
async getAllConversations(id=null) {
if (id) return await this.getCollectionData('conversations', 'id', id);
return await this.getCollectionData('conversations', 'timestamp');
}
async getOrCreateLastChat() {
const conversations = await this.getAllConversations();
if (conversations.length > 0) {
const conversation = conversations[conversations.length - 1];
console.log('获取最后一个对话:', conversation);
return conversation;
}
return this.createNewConversation(Date.now(), '');
}
async createNewConversation(id, title) {
const conversation = { id: id, title: title,timestamp: Date.now(), lastMessage: ''};
await this.setCollectionData('conversations', conversation);
return conversation;
}
}
class ChatDatabase extends DataBase {
constructor(chatId) {
if(!chatId) {
throw new Error('Chat ID is required');
}
super(`ChatDB-${chatId}`, {
messages: {
keyPath: 'id',
indexes: [
{ name: 'id', keyPath: 'id' },
{ name: 'content', keyPath: 'content' },
{ name: 'role', keyPath: 'role' },
{ name: 'timestamp', keyPath: 'timestamp' },
{ name: 'images', keyPath: 'images' }
]
},
settings: {
keyPath: 'key',
indexes: [
{ name: 'key', keyPath: 'key' },
{ name: 'value', keyPath: 'value' }
]
}
});
}
async getSettings() {return await this.getCollectionData('settings', 'key')}
async saveMessage(message) { await this.setCollectionData('messages', message)}
async getAllMessages() { return await this.getCollectionData('messages', 'timestamp') }
}
window.MainDatabase = new MainDatabase()
class AdvancedConversation {
constructor() {
this.db = window.MainDatabase
this.conversationList = document.getElementById('conversation-list');
this.newChatButton = document.getElementById('new-chat-btn');
this.deleteChatButton = document.getElementById('remove-chat');
this.topicTitle = document.getElementById('conversation-title');
this.init();
}
async init() {
this.newChatButton.addEventListener('click', this.createNewConversation.bind(this));
await this.loadConversations();
this.deleteChatButton.addEventListener('click', this.deleteConversation.bind(this));
this.topicTitle.addEventListener('blur', async (e) => {
const title = e.target.innerText;
if (title) { await this.setConversationTitle(window.aiui.chatId, title);}
});
this.topicTitle.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); this.topicTitle.blur();}
});
}
async loadConversations() {
const conversations = await this.db.getAllConversations();
this.conversationList.innerHTML = ''; // 清空列表
for (const conversation of conversations) {
const item = document.createElement('div');
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';
item.id = 'conversation-' + conversation.id;
item.setAttribute('data-id', conversation.id);
const title = document.createElement('span')
title.className = 'conversation-title truncate whitespace-nowrap'; title.innerText = conversation.title || 'Untitled Topic';
item.appendChild(title);
item.addEventListener('click', (e) => {this.loadConversation(conversation.id);});
this.conversationList.appendChild(item);
}
return conversations;
}
async createNewConversation() {
const chat = await this.db.createNewConversation(Date.now(), '');
await this.loadConversations();
await this.loadConversation(chat.id);
}
async loadConversation(id) {
localStorage.setItem('last_chat_id', id);
const conversations = await this.db.getAllConversations(id);
if (conversations.length > 0) {
const conversation = conversations[0];
this.conversationList.querySelectorAll('.conversation-item').forEach(item => item.classList.remove('active'));
this.conversationList.querySelector(`#conversation-${conversation.id}`).classList.add('active');
window.aiui = new AIUI( {chatId: conversation.id} );
await window.aiui.ready;
this.topicTitle.innerText = conversation.title;
UIkit.offcanvas('#offcanvas-nav').hide();
}
}
async deleteConversation(event) {
const id = window.aiui.chatId;
await UIkit.modal.confirm('确定要删除此对话吗?', {labels: {ok: '删除', cancel: '取消'}}).then(async () => {
await this.db.deleteCollectionData('conversations', id);
await indexedDB.deleteDatabase(`ChatDB-${id}`);
await this.loadConversations();
window.aiui = new AIUI();
})
}
async setConversationTitle(id, title) {
const conversation = await this.db.getCollectionData('conversations', 'id', id);
if (conversation.length > 0) {
conversation[0].title = title;
await this.db.setCollectionData('conversations', conversation[0]);
this.topicTitle.innerText = title;
}
await this.loadConversations();
}
}
class AdvancedContainer {
// 聊天消息容器类,主要控制chat-container的消息
static instance = null;
static getInstance(db) {
if (!AdvancedContainer.instance) {
AdvancedContainer.instance = new AdvancedContainer(db);
} else if (db && AdvancedContainer.instance.db !== db) {
// 当数据库引用变化时,更新数据库引用并重置容器
AdvancedContainer.instance.db = db;
AdvancedContainer.instance.resetContainer();
AdvancedContainer.instance.loadMessages(); // 自动加载新数据库的消息
}
return AdvancedContainer.instance;
}
constructor(db) {
// 如果已经有实例,直接返回
if (AdvancedContainer.instance) {
return AdvancedContainer.instance;
}
this.container = document.getElementById('chat-container');
this.clearChatButton = document.getElementById('clear-chat');
this.container.innerHTML = ''; // 初始化的时候,清空容器
this.db = db;
this.initialized = false;
this.ready = this.init();
AdvancedContainer.instance = this;
}
// 重置容器,清空内容
resetContainer() {
this.container.innerHTML = '';
}
async init() {
// 确保初始化和事件绑定只执行一次
if (this.initialized) return;
this.clearChatButton.addEventListener('click', async() => {
await UIkit.modal.confirm('确定要清空聊天记录吗?', {labels: {ok: '清空', cancel: '取消'}}).then(async () => {
await this.clearChat();
if (window.aiui && window.aiui.ai) {
window.aiui.ai.messages = [];
}
});
});
this.initialized = true;
}
scrollToBottom() {
// 修改滚动函数,滚动整个窗口到底部
window.scrollTo(0, document.body.scrollHeight);
}
async newMessage(role) {
const msgId = `msg-${Date.now()}`;
const messageDiv = document.createElement('div');
messageDiv.className = 'max-w-fit mb-4 p-3 rounded-lg relative break-words shadow-sm';
// 使用Tailwind类替代自定义CSS类
if (role === 'user') {
messageDiv.classList.add('bg-user-bg', 'text-gray-900', 'ml-auto', 'text-right','uk-animation-slide-bottom-small','uk-animation-fade');
} else if (role === 'assistant') {
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');
}
messageDiv.id = msgId;
const messageContent = document.createElement('div');
messageContent.className = 'message-content';
messageContent.setAttribute('raw', '');
if (role === 'assistant') {
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');
}
const messageReason = document.createElement('div');
messageReason.className = 'message-reason opacity-30 markdown-body max-h-[32px] overflow-hidden hover:cursor-pointer hover:opacity-60 hover:font-weight-light';
messageReason.setAttribute('raw', '');
messageReason.addEventListener('click', (e)=>{ messageReason.classList.toggle("max-h-[48px]")} )
messageDiv.appendChild(messageReason)
messageDiv.appendChild(messageReason);
messageDiv.appendChild(messageContent);
const add = () => { this.container.appendChild(messageDiv); this.scrollToBottom();};
const update = async(content, addons={}) => {
const { scroll=false, images=[], spin=false } = addons;
messageContent.setAttribute('raw', content);
if (spin) {
content = `<div uk-spinner='ratio: 0.5'></div>`;
}
if (role === 'user') {
messageContent.innerHTML = content;
if (images.length) {
messageContent.setAttribute('images', JSON.stringify(images));
const imgContainer = document.createElement('div'); imgContainer.className = 'flex';
images.forEach((image, index) => {
const img = document.createElement('img');
img.src = image; img.style.height = '80px'; img.className = 'rounded-md mx-0.5';
imgContainer.appendChild(img);
});
messageContent.appendChild(imgContainer);
}
} else if (role==='assistant') {
messageContent.innerHTML = marked.parse(content);
}
if (role === 'assistant') {setTimeout(() => mermaid.init(undefined, messageContent.querySelectorAll('.mermaid')), 0);}
if (scroll) this.scrollToBottom();
};
const updateReason = async(reason) => {
messageReason.classList.add('p-4')
messageReason.innerHTML = marked.parse(reason);
};
const save = async( ) => {
const content = messageContent.getAttribute('raw');
const images = messageContent.getAttribute('images');
if(!content && !images) return;
await this.db.saveMessage({ id: msgId, role: role, content: messageContent.getAttribute('raw'), timestamp: Date.now(), images: messageContent.getAttribute('images') });
};
window.rup = updateReason;
return {msgId, messageDiv, add, update, save, updateReason };
}
async clearChat() {
const messages = await this.db.getAllMessages();
for (const message of messages) { await this.db.deleteCollectionData('messages', message.id); }
this.container.innerHTML = '';
}
async loadMessages() {
const messages = await this.db.getAllMessages();
for (const message of messages) {
const { msgId, messageDiv, add, update } = await this.newMessage(message.role);
await update(message.content, {scroll:false, images:message.images ? JSON.parse(message.images) : []});
add();
}
}
}
class AdvancedInput {
// 聊天输入框类,主要控制message-input的输入, 以及发送按钮的状态, 还有点击发送按钮后的回调
constructor() {
this.input = document.getElementById('message-input');
this.sendButton = document.getElementById('send-button');
this.sendIcon = document.getElementById('send-icon');
this.loadingIcon = document.getElementById('loading-icon');
this.imageUploadInput = document.getElementById('image-upload-input');
this.imagePreviewContainer = document.getElementById('image-preview-container');
this.mcpToggle = document.getElementById('mcp-toggle');
this.mcpStatus = 0; // 0=>未链接, 2=>连接中, 1=>链接成功
this.mcpcli = null;
this.isProcessing = false;
this.init()
}
init() {
this.mcpToggle.addEventListener('click', this.mcpClient.bind(this));
this.input.addEventListener('input', () => {this.sendButton.disabled = !this.input.value.trim();});
this.input.addEventListener('keydown', (e) => {if (e.key === 'Enter' && e.ctrlKey) {e.preventDefault();this.sendButton.click();}});
this.imageUploadInput.addEventListener('change', (e)=>{
const files = e.target.files;
if (files.length > 0) {
for (const file of files) {
const reader = new FileReader();
reader.onload = (event) => {
const a = document.createElement('a'); a.className = 'uk-position-relative';
const img = document.createElement('img'); img.src = event.target.result; img.classList.add('rounded-md'); img.style.height='64px'
a.appendChild(img);
const deleteBtn = document.createElement('button');deleteBtn.className = 'uk-icon-button uk-position-top-right uk-position-small';
deleteBtn.setAttribute('uk-icon', 'trash');deleteBtn.style.backgroundColor = 'rgba(255,255,255,0.4)';
deleteBtn.onclick = (e) => {e.preventDefault(); a.remove();};
a.appendChild(deleteBtn);
this.imagePreviewContainer.appendChild(a);
};
reader.readAsDataURL(file);
}
}
})
this.sendButton.onclick = () => {
if (this.isProcessing) return;
if(!this.input.value.trim()) { return }
// 显示loading状态
this.isProcessing = true;
this.sendButton.disabled = false;
this.sendIcon.classList.add('hidden');
this.loadingIcon.classList.remove('hidden');
this.input.setAttribute('disabled', 'disabled');
this.imageUploadInput.setAttribute('disabled', 'disabled');
return new Promise((resolve, reject) => {
const message = { content: this.input.value, images: [...document.getElementById('image-preview-container').querySelectorAll('img')].map(x=>x.src) };
window.aiui.onSendMessage(message, resolve, reject);
}).catch((error) => {
console.error('Error:', error);
}).finally(() => {
this.isProcessing = false;
this.input.value = '';
this.imagePreviewContainer.innerHTML = '';
this.imageUploadInput.value = '';
this.input.style.height = 'auto';
// 恢复正常状态
this.sendIcon.classList.remove('hidden');
this.loadingIcon.classList.add('hidden');
this.input.removeAttribute('disabled');
this.imageUploadInput.removeAttribute('disabled');
this.input.focus();
this.sendButton.disabled = !this.input.value.trim();
});
};
}
async mcpClient() {
if(this.mcpStatus===1) {
this.mcpcli.close()
this.mcpcli = null
window.aiui.ai.toolFn = null
this.mcpStatus = false
this.mcpToggle.classList.remove('uk-spinner')
this.mcpToggle.classList.remove('text-cyan-400')
this.mcpToggle.setAttribute('uk-tooltip', 'MCP已断开。请重新链接')
return
}else if(this.mcpStatus===2) {
UIkit.notification({message: '正在连接MCP,请稍等',status: 'warning',pos: 'top-center',timeout: 1000});
return
}
const mcpaddress = window.aiui.settings.settings.mcp;
if(!mcpaddress) {
UIkit.notification({message: '请先设置MCP服务器地址',status: 'warning',pos: 'top-center',timeout: 5000});
window.aiui.settings.open()
return
}
const mcpcli = new MCPCli(mcpaddress)
this.mcpToggle.classList.add('uk-spinner')
this.mcpToggle.setAttribute('uk-tooltip', '连接中...')
this.mcpStatus = 2
mcpcli.connect()
.then(cli=>{
this.mcpcli = cli
this.mcpStatus = 1
this.mcpToggle.setAttribute('uk-tooltip', 'MCP已连接')
//this.mcpToggle.classList.remove('uk-spinner')
this.mcpToggle.classList.add('text-cyan-400')
UIkit.notification({message: 'MCP连接成功',status: 'success',pos: 'top-center',timeout: 5000});
window.aiui.ai.toolFn = cli
})
.catch(err=>{
console.error(err)
UIkit.notification({message: 'MCP连接失败',status: 'danger',pos: 'top-center',timeout: 5000});
this.mcpToggle.classList.remove('uk-spinner')
this.mcpToggle.removeAttribute('uk-tooltip')
this.mcpStatus = 0
})
}
}
class AdvancedSettings {
static instance = null;
static getInstance(db) {
if (!AdvancedSettings.instance) {
AdvancedSettings.instance = new AdvancedSettings(db);
} else if (db) {
// 如果已有实例但传入了新的db,则更新db
AdvancedSettings.instance.db = db;
}
return AdvancedSettings.instance;
}
constructor(db) {
// 如果已经有实例,则直接返回该实例
if (AdvancedSettings.instance) {
return AdvancedSettings.instance;
}
this.db = db;
this.settingsButton = document.getElementById('api-settings');
this.saveButton = document.getElementById('save-settings');
this.settingsModal = document.getElementById('settings-modal');
this.settings = {};
this.initialized = false;
this.init();
AdvancedSettings.instance = this;
}
async init() {
if (this.initialized) return;
this.settingsButton.addEventListener('click', this.open.bind(this));
this.saveButton.addEventListener('click', this.save.bind(this));
document.getElementById('temperature').addEventListener('input', (e) => {
document.getElementById('temperature-value').textContent = e.target.value;
});
document.getElementById('topP').addEventListener('input', (e) => {
document.getElementById('top-p-value').textContent = e.target.value;
});
this.initialized = true;
await this.get();
}
async open() {
const settings = await this.get();
document.getElementById('apiKey').value = settings.apiKey || '';
document.getElementById('mcp').value = settings.mcp || '';
document.getElementById('baseURL').value = settings.baseURL || 'https://text.pollinations.ai/openai';
document.getElementById('model').value = settings.model || 'openai';
document.getElementById('systemPrompt').value = settings.systemPrompt || '';
document.getElementById('temperature').value = settings.temperature || 0.7;
document.getElementById('temperature-value').textContent = settings.temperature || 0.7;
document.getElementById('maxTokens').value = settings.maxTokens || 128000;
document.getElementById('topP').value = settings.topP || 1.0;
document.getElementById('top-p-value').textContent = settings.topP || 1.0;
UIkit.modal(this.settingsModal).show();
}
close() { UIkit.modal(this.settingsModal).hide();}
async save() {
for (const setting of ['apiKey', 'mcp', 'baseURL', 'model', 'systemPrompt', 'temperature', 'maxTokens', 'topP']) {
await this.db.setCollectionData('settings', { key: setting, value: document.getElementById(setting).value });
}
UIkit.notification({message: '设置已保存',status: 'success',pos: 'top-center',timeout: 2000});
this.close();
await this.get()
window.dispatchEvent(new CustomEvent('settingsUpdate', {detail: this.settings}));
return this.settings;
}
async get() {
const default_settings = {
'apiKey': '', 'baseURL': 'https://text.pollinations.ai/openai','mcp':'',
'model': 'openai', 'systemPrompt': '',
'temperature': 0.61, 'maxTokens': 8192, 'topP': 0.95
}
const saved_settings = (await this.db.getSettings()).reduce((acc, setting) => {acc[setting.key] = setting.value;return acc;}, {});
this.settings = {...default_settings, ...saved_settings};
return this.settings;
}
}
// AIUI类
class AIUI {
constructor(options = {}) {
this.chatId = options.chatId
this.ready = this.init()
}
async init() {
if (!this.chatId) {
if (localStorage.getItem('last_chat_id')) {
await window.conversations.loadConversation(parseInt(localStorage.getItem('last_chat_id')));
return
} else {
const chat = window.MainDatabase.getOrCreateLastChat();
await window.conversations.loadConversation(chat.id);
return
}
}
this.db = new ChatDatabase(this.chatId);
// 使用单例模式获取容器实例
this.container = AdvancedContainer.getInstance(this.db);
this.input = new AdvancedInput();
// 使用单例模式获取设置实例
this.settings = AdvancedSettings.getInstance(this.db);
await this.settings.get();
const messages = (await this.db.getAllMessages()).map(m => ({role:m.role, content:m.content}));
this.ai = new AI({
messages: messages,
system_prompt: this.settings.settings.systemPrompt || '',
model: this.settings.settings.model,
apiKey: this.settings.settings.apiKey || '',
baseURL: this.settings.settings.baseURL,
completionsURI: '',
opts: {
temperature: parseFloat(this.settings.settings.temperature) || 0.7,
max_tokens: parseInt(this.settings.settings.maxTokens) || 128000,
top_p: parseFloat(this.settings.settings.topP) || 1.0
}
});
window.addEventListener('settingsUpdate', async (e) => {
this.ai = new AI({
messages: this.ai.messages,
system_prompt: e.detail.systemPrompt || '',
model: e.detail.model,
apiKey: e.detail.apiKey || '',
baseURL: e.detail.baseURL,
completionsURI: '',
opts: {
temperature: parseFloat(e.detail.temperature) || 0.7,
max_tokens: parseInt(e.detail.maxTokens) || 128000,
top_p: parseFloat(e.detail.topP) || 1.0
}
});
});
await this.container.loadMessages();
}
async onSendMessage(message, resolve, reject) {
const { content, images } = message;
if (!content.trim()) {reject('消息不能为空');return}
if (window.conversations.topicTitle.innerText === '') {
await window.conversations.setConversationTitle(this.chatId, content.slice(0, 30));
}
const userMessage = await this.container.newMessage('user');
await userMessage.add();
await userMessage.update(content.trim(), true, images);
await userMessage.save();
const assistantMessage = await this.container.newMessage('assistant');
await assistantMessage.add();
await assistantMessage.update('',{spin:true});
try {
// 调用AI生成回复
this.currentResponse = await this.ai.create(content.trim(), {images: images});
let fullResponse = '';
let fullReasoning = ''
// 同时处理推理和内容流
await Promise.all([
// 实现同时处理内容流和推理流,上次有个人讽刺说openAI在推理流时不出内容流,而实际上确实没人这么做。这个实现无非是预留效果罢了。。。。。
// 处理推理流
(async() => {
console.log('reasoning.....');
for await (const reasoning_chunk of this.currentResponse.on('reasoning')) {
fullReasoning += reasoning_chunk.delta?.choices?.[0]?.delta?.reasoning || '';
await assistantMessage.updateReason(fullReasoning);
}
//为了不增加token,推理流不写入数据库
})(),
// 处理内容流
(async ()=> {
for await (const chunk of this.currentResponse.on('content')) {
if (chunk.delta) {
fullResponse += chunk.delta;
await assistantMessage.update(fullResponse, true);
}
}
await assistantMessage.save();
})()
])
// 添加AI回复到消息历史
this.ai.messages.push({
role: 'assistant',
content: fullResponse
});
resolve(fullResponse);
} catch (error) {
await assistantMessage.update('抱歉,哪里出现了错误了。 请稍后再试。');
reject(error);
}
}
}
document.addEventListener('DOMContentLoaded', async function() {
const { markedHighlight } = window.markedHighlight;
marked.use(markedHighlight({
langPrefix: 'hljs language-',
highlight: function(code, lang) {
if (lang === 'mermaid') { return '<div class="mermaid">' + code + '</div>';}
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
return hljs.highlight(code, {language: language}).value;
}
}));
window.conversations = new AdvancedConversation();
await window.conversations.loadConversations();
window.aiui = new AIUI();
await window.aiui.ready
window.scrollTo(0, 1);
if (/()iPhone|iPad|iPod/.test(navigator.userAgent)) {
document.body.style.height = '100vh';
document.body.overflow = 'hidden';
document.body.requestFullscreen()
}
});
</script>
</body>
</html>
```