wanzi-mcp 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wanzi_mcp-0.2.0/.gitignore +11 -0
- wanzi_mcp-0.2.0/PKG-INFO +10 -0
- wanzi_mcp-0.2.0/pyproject.toml +19 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/__init__.py +3 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/__main__.py +86 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/gui/__init__.py +0 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/gui/app.py +17 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/gui/static/app.js +530 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/gui/static/index.html +61 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/gui/static/style.css +265 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/installer.py +25 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/models.py +56 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/server.py +402 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/server_ctl.py +74 -0
- wanzi_mcp-0.2.0/src/wanzi_mcp/session.py +107 -0
wanzi_mcp-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wanzi-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: 丸子MCP - Cursor AI 聊天桥接工具
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: click>=8.0.0
|
|
7
|
+
Requires-Dist: mcp>=1.0.0
|
|
8
|
+
Requires-Dist: pywebview>=5.0.0
|
|
9
|
+
Requires-Dist: starlette>=0.37.0
|
|
10
|
+
Requires-Dist: uvicorn>=0.30.0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wanzi-mcp"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "丸子MCP - Cursor AI 聊天桥接工具"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcp>=1.0.0",
|
|
12
|
+
"uvicorn>=0.30.0",
|
|
13
|
+
"starlette>=0.37.0",
|
|
14
|
+
"click>=8.0.0",
|
|
15
|
+
"pywebview>=5.0.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
wanzi = "wanzi_mcp.__main__:cli"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""CLI 入口:wanzi / wanzi serve / wanzi gui / wanzi install"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group(invoke_without_command=True)
|
|
12
|
+
@click.option("--port", default=8765, help="服务端口", show_default=True)
|
|
13
|
+
@click.pass_context
|
|
14
|
+
def cli(ctx, port: int):
|
|
15
|
+
"""丸子 MCP — Cursor AI 聊天桥接工具"""
|
|
16
|
+
ctx.ensure_object(dict)
|
|
17
|
+
ctx.obj["port"] = port
|
|
18
|
+
if ctx.invoked_subcommand is None:
|
|
19
|
+
_start_all(port)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@cli.command()
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def serve(ctx):
|
|
25
|
+
"""只启动后端服务"""
|
|
26
|
+
_start_server(ctx.obj["port"])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@cli.command()
|
|
30
|
+
@click.pass_context
|
|
31
|
+
def gui(ctx):
|
|
32
|
+
"""只启动 GUI 窗口(需后端已运行)"""
|
|
33
|
+
from .gui.app import start_gui
|
|
34
|
+
|
|
35
|
+
start_gui(ctx.obj["port"])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@cli.command()
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def install(ctx):
|
|
41
|
+
"""写入 ~/.cursor/mcp.json 注册 MCP"""
|
|
42
|
+
from .installer import install_to_cursor
|
|
43
|
+
|
|
44
|
+
install_to_cursor(ctx.obj["port"])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _free_port(port: int):
|
|
48
|
+
from .server_ctl import kill_port_occupants
|
|
49
|
+
|
|
50
|
+
killed = kill_port_occupants(port)
|
|
51
|
+
if killed:
|
|
52
|
+
click.echo(f"已释放端口 {port}(终止 PID: {killed})")
|
|
53
|
+
time.sleep(0.5)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _start_server(port: int):
|
|
57
|
+
import uvicorn
|
|
58
|
+
from .server import app
|
|
59
|
+
|
|
60
|
+
_free_port(port)
|
|
61
|
+
click.echo(f"丸子 MCP 后端启动: http://localhost:{port}")
|
|
62
|
+
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _run_uvicorn(port: int):
|
|
66
|
+
import uvicorn
|
|
67
|
+
from .server import app
|
|
68
|
+
|
|
69
|
+
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _start_all(port: int):
|
|
73
|
+
_free_port(port)
|
|
74
|
+
click.echo(f"丸子 MCP 后端启动: http://localhost:{port}")
|
|
75
|
+
t = threading.Thread(target=_run_uvicorn, args=(port,), daemon=True)
|
|
76
|
+
t.start()
|
|
77
|
+
time.sleep(1)
|
|
78
|
+
|
|
79
|
+
from .gui.app import start_gui
|
|
80
|
+
|
|
81
|
+
click.echo("正在打开 GUI 窗口…")
|
|
82
|
+
start_gui(port)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
cli()
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""pywebview GUI 窗口:包裹本地 Web 页面。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import webview
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def start_gui(port: int = 8765):
|
|
9
|
+
url = f"http://localhost:{port}/"
|
|
10
|
+
webview.create_window(
|
|
11
|
+
"丸子 MCP",
|
|
12
|
+
url,
|
|
13
|
+
width=920,
|
|
14
|
+
height=660,
|
|
15
|
+
min_size=(640, 400),
|
|
16
|
+
)
|
|
17
|
+
webview.start()
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// ── State ──────────────────────────────────
|
|
5
|
+
let ws = null;
|
|
6
|
+
let activeSessionId = null;
|
|
7
|
+
let sessions = []; // [{id, title, ...}]
|
|
8
|
+
let messageCache = {}; // sessionId -> [msg]
|
|
9
|
+
let ctxTargetId = null;
|
|
10
|
+
let pendingImages = []; // [{url, file}] waiting to send
|
|
11
|
+
|
|
12
|
+
const $ = (sel) => document.querySelector(sel);
|
|
13
|
+
const $messages = $("#messages");
|
|
14
|
+
const $input = $("#msg-input");
|
|
15
|
+
const $btnSend = $("#btn-send");
|
|
16
|
+
const $sessionList = $("#session-list");
|
|
17
|
+
const $chatTitle = $("#chat-title");
|
|
18
|
+
const $ctxMenu = $("#ctx-menu");
|
|
19
|
+
const $previewBar = $("#preview-bar");
|
|
20
|
+
const $fileInput = $("#file-input");
|
|
21
|
+
|
|
22
|
+
// ── Markdown Setup ─────────────────────────
|
|
23
|
+
marked.setOptions({
|
|
24
|
+
highlight: (code, lang) => {
|
|
25
|
+
if (lang && hljs.getLanguage(lang))
|
|
26
|
+
return hljs.highlight(code, { language: lang }).value;
|
|
27
|
+
return hljs.highlightAuto(code).value;
|
|
28
|
+
},
|
|
29
|
+
breaks: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ── WebSocket ──────────────────────────────
|
|
33
|
+
function connect() {
|
|
34
|
+
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
35
|
+
ws = new WebSocket(`${proto}//${location.host}/ws`);
|
|
36
|
+
|
|
37
|
+
ws.onopen = () => {
|
|
38
|
+
console.log("[ws] connected");
|
|
39
|
+
ws.send(JSON.stringify({ type: "list_sessions" }));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
ws.onmessage = (e) => {
|
|
43
|
+
const data = JSON.parse(e.data);
|
|
44
|
+
handleMessage(data);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
ws.onclose = () => {
|
|
48
|
+
console.log("[ws] disconnected, reconnecting in 2s…");
|
|
49
|
+
setTimeout(connect, 2000);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function send(obj) {
|
|
54
|
+
if (ws && ws.readyState === WebSocket.OPEN)
|
|
55
|
+
ws.send(JSON.stringify(obj));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Message Router ─────────────────────────
|
|
59
|
+
function handleMessage(data) {
|
|
60
|
+
switch (data.type) {
|
|
61
|
+
case "session_list":
|
|
62
|
+
sessions = data.sessions;
|
|
63
|
+
renderSidebar();
|
|
64
|
+
break;
|
|
65
|
+
|
|
66
|
+
case "session_detail":
|
|
67
|
+
messageCache[data.session.id] = data.session.messages;
|
|
68
|
+
if (data.session.id === activeSessionId) renderMessages();
|
|
69
|
+
break;
|
|
70
|
+
|
|
71
|
+
case "session_created": {
|
|
72
|
+
if (!sessions.find((s) => s.id === data.session.id)) {
|
|
73
|
+
sessions.unshift(data.session);
|
|
74
|
+
renderSidebar();
|
|
75
|
+
}
|
|
76
|
+
const createdEl = $sessionList.querySelector(`[data-id="${data.session.id}"]`);
|
|
77
|
+
if (createdEl) createdEl.classList.add("session-flash");
|
|
78
|
+
if (!activeSessionId) selectSession(data.session.id);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case "user_message":
|
|
83
|
+
case "assistant_message": {
|
|
84
|
+
const sid = data.session_id;
|
|
85
|
+
if (!messageCache[sid]) messageCache[sid] = [];
|
|
86
|
+
messageCache[sid].push(data.message);
|
|
87
|
+
if (sid === activeSessionId) {
|
|
88
|
+
appendBubble(data.message);
|
|
89
|
+
scrollToBottom();
|
|
90
|
+
}
|
|
91
|
+
updateSidebarPreview(sid, data.message);
|
|
92
|
+
if (data.type === "assistant_message") {
|
|
93
|
+
notifyNewMessage(data.message.content);
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case "session_waiting":
|
|
99
|
+
selectSession(data.session_id);
|
|
100
|
+
break;
|
|
101
|
+
|
|
102
|
+
case "session_renamed":
|
|
103
|
+
updateSessionTitle(data.session_id, data.title);
|
|
104
|
+
break;
|
|
105
|
+
|
|
106
|
+
case "message_deleted": {
|
|
107
|
+
const sid2 = data.session_id;
|
|
108
|
+
const mid = data.message_id;
|
|
109
|
+
if (messageCache[sid2]) {
|
|
110
|
+
messageCache[sid2] = messageCache[sid2].filter((m) => m.id !== mid);
|
|
111
|
+
}
|
|
112
|
+
if (sid2 === activeSessionId) {
|
|
113
|
+
const el = $messages.querySelector(`[data-msg-id="${mid}"]`);
|
|
114
|
+
if (el) el.remove();
|
|
115
|
+
if ((messageCache[sid2] || []).length === 0) {
|
|
116
|
+
$messages.innerHTML = '<div class="empty-state">发送第一条消息开始聊天</div>';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
updateSidebarPreview(sid2, (messageCache[sid2] || []).at(-1) || { content: "暂无消息" });
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case "session_deleted":
|
|
124
|
+
sessions = sessions.filter((s) => s.id !== data.session_id);
|
|
125
|
+
delete messageCache[data.session_id];
|
|
126
|
+
renderSidebar();
|
|
127
|
+
if (activeSessionId === data.session_id) {
|
|
128
|
+
activeSessionId = null;
|
|
129
|
+
$chatTitle.textContent = "选择或新建一个会话";
|
|
130
|
+
$messages.innerHTML = '<div class="empty-state">暂无会话</div>';
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Sidebar ────────────────────────────────
|
|
137
|
+
function renderSidebar() {
|
|
138
|
+
$sessionList.innerHTML = "";
|
|
139
|
+
if (sessions.length === 0) {
|
|
140
|
+
$sessionList.innerHTML = '<div class="empty-state" style="padding:40px 0;font-size:13px">暂无会话</div>';
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
sessions.forEach((s) => {
|
|
144
|
+
const el = document.createElement("div");
|
|
145
|
+
el.className = "session-item" + (s.id === activeSessionId ? " active" : "");
|
|
146
|
+
el.dataset.id = s.id;
|
|
147
|
+
|
|
148
|
+
const preview = s.last_message
|
|
149
|
+
? truncate(s.last_message.content, 30)
|
|
150
|
+
: "暂无消息";
|
|
151
|
+
|
|
152
|
+
el.innerHTML = `
|
|
153
|
+
<div class="s-title">${esc(s.title || s.id)}</div>
|
|
154
|
+
<div class="s-preview">${esc(preview)}</div>
|
|
155
|
+
<button class="s-del" title="删除会话">×</button>`;
|
|
156
|
+
|
|
157
|
+
el.addEventListener("click", (e) => {
|
|
158
|
+
if (e.target.closest(".s-del")) return;
|
|
159
|
+
selectSession(s.id);
|
|
160
|
+
});
|
|
161
|
+
el.addEventListener("dblclick", (e) => {
|
|
162
|
+
e.stopPropagation();
|
|
163
|
+
startInlineRename(el, s.id);
|
|
164
|
+
});
|
|
165
|
+
el.addEventListener("contextmenu", (e) => showCtxMenu(e, s.id));
|
|
166
|
+
$sessionList.appendChild(el);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
$sessionList.addEventListener("click", (e) => {
|
|
171
|
+
const btn = e.target.closest(".s-del");
|
|
172
|
+
if (!btn) return;
|
|
173
|
+
const item = btn.closest(".session-item");
|
|
174
|
+
const sid = item?.dataset.id;
|
|
175
|
+
if (sid) {
|
|
176
|
+
send({ type: "delete_session", session_id: sid });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
function selectSession(sid) {
|
|
181
|
+
activeSessionId = sid;
|
|
182
|
+
const s = sessions.find((x) => x.id === sid);
|
|
183
|
+
$chatTitle.textContent = s ? s.title || sid : sid;
|
|
184
|
+
|
|
185
|
+
document.querySelectorAll(".session-item").forEach((el) => {
|
|
186
|
+
el.classList.toggle("active", el.dataset.id === sid);
|
|
187
|
+
if (el.dataset.id === sid) el.classList.remove("session-flash");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (messageCache[sid]) {
|
|
191
|
+
renderMessages();
|
|
192
|
+
} else {
|
|
193
|
+
send({ type: "get_session", session_id: sid });
|
|
194
|
+
}
|
|
195
|
+
$input.focus();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function updateSidebarPreview(sid, msg) {
|
|
199
|
+
const s = sessions.find((x) => x.id === sid);
|
|
200
|
+
if (s) s.last_message = msg;
|
|
201
|
+
const el = $sessionList.querySelector(`[data-id="${sid}"] .s-preview`);
|
|
202
|
+
if (el) el.textContent = truncate(msg.content, 30);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function updateSessionTitle(sid, title) {
|
|
206
|
+
const s = sessions.find((x) => x.id === sid);
|
|
207
|
+
if (s) s.title = title;
|
|
208
|
+
const el = $sessionList.querySelector(`[data-id="${sid}"] .s-title`);
|
|
209
|
+
if (el) el.textContent = title;
|
|
210
|
+
if (sid === activeSessionId) $chatTitle.textContent = title;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Messages ───────────────────────────────
|
|
214
|
+
function renderMessages() {
|
|
215
|
+
$messages.innerHTML = "";
|
|
216
|
+
const msgs = messageCache[activeSessionId] || [];
|
|
217
|
+
if (msgs.length === 0) {
|
|
218
|
+
$messages.innerHTML = '<div class="empty-state">发送第一条消息开始聊天</div>';
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
msgs.forEach((m) => appendBubble(m));
|
|
222
|
+
scrollToBottom();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function appendBubble(msg) {
|
|
226
|
+
const empty = $messages.querySelector(".empty-state");
|
|
227
|
+
if (empty) empty.remove();
|
|
228
|
+
|
|
229
|
+
const div = document.createElement("div");
|
|
230
|
+
div.className = `msg ${msg.role}`;
|
|
231
|
+
div.dataset.msgId = msg.id;
|
|
232
|
+
|
|
233
|
+
const avatarText = msg.role === "user" ? "你" : "AI";
|
|
234
|
+
let rendered = msg.role === "assistant"
|
|
235
|
+
? marked.parse(msg.content)
|
|
236
|
+
: esc(msg.content);
|
|
237
|
+
|
|
238
|
+
const imgs = (msg.images || [])
|
|
239
|
+
.map((u) => `<img class="msg-img" src="${esc(u)}" onclick="window.open('${esc(u)}')">`)
|
|
240
|
+
.join("");
|
|
241
|
+
|
|
242
|
+
const time = formatTime(msg.timestamp);
|
|
243
|
+
|
|
244
|
+
div.innerHTML = `
|
|
245
|
+
<div class="avatar">${avatarText}</div>
|
|
246
|
+
<div class="bubble">${rendered}${imgs}</div>
|
|
247
|
+
<span class="msg-time">${time}</span>
|
|
248
|
+
<button class="msg-del" title="删除">×</button>`;
|
|
249
|
+
|
|
250
|
+
$messages.appendChild(div);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function scrollToBottom() {
|
|
254
|
+
requestAnimationFrame(() => {
|
|
255
|
+
$messages.scrollTop = $messages.scrollHeight;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Delete Message (event delegation) ───
|
|
260
|
+
$messages.addEventListener("click", (e) => {
|
|
261
|
+
const btn = e.target.closest(".msg-del");
|
|
262
|
+
if (!btn) return;
|
|
263
|
+
const msgEl = btn.closest(".msg");
|
|
264
|
+
const msgId = msgEl?.dataset.msgId;
|
|
265
|
+
if (msgId && activeSessionId) {
|
|
266
|
+
send({ type: "delete_message", session_id: activeSessionId, message_id: msgId });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ── Image Upload ────────────────────────────
|
|
271
|
+
async function uploadFile(file) {
|
|
272
|
+
const fd = new FormData();
|
|
273
|
+
fd.append("file", file);
|
|
274
|
+
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
|
275
|
+
const data = await res.json();
|
|
276
|
+
if (data.ok) return data.url;
|
|
277
|
+
throw new Error(data.message);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function addPendingImage(file) {
|
|
281
|
+
const url = URL.createObjectURL(file);
|
|
282
|
+
pendingImages.push({ url, file });
|
|
283
|
+
renderPreviews();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function removePendingImage(idx) {
|
|
287
|
+
URL.revokeObjectURL(pendingImages[idx].url);
|
|
288
|
+
pendingImages.splice(idx, 1);
|
|
289
|
+
renderPreviews();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function renderPreviews() {
|
|
293
|
+
if (pendingImages.length === 0) {
|
|
294
|
+
$previewBar.classList.add("hidden");
|
|
295
|
+
$previewBar.innerHTML = "";
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
$previewBar.classList.remove("hidden");
|
|
299
|
+
$previewBar.innerHTML = pendingImages
|
|
300
|
+
.map((p, i) => `<div class="preview-item">
|
|
301
|
+
<img src="${p.url}">
|
|
302
|
+
<button class="remove-btn" data-idx="${i}">✕</button>
|
|
303
|
+
</div>`)
|
|
304
|
+
.join("");
|
|
305
|
+
$previewBar.querySelectorAll(".remove-btn").forEach((btn) =>
|
|
306
|
+
btn.addEventListener("click", () => removePendingImage(+btn.dataset.idx))
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
$fileInput.addEventListener("change", () => {
|
|
311
|
+
for (const f of $fileInput.files) addPendingImage(f);
|
|
312
|
+
$fileInput.value = "";
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
$input.addEventListener("paste", (e) => {
|
|
316
|
+
const items = e.clipboardData?.items;
|
|
317
|
+
if (!items) return;
|
|
318
|
+
for (const item of items) {
|
|
319
|
+
if (item.type.startsWith("image/")) {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
addPendingImage(item.getAsFile());
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ── Send ───────────────────────────────────
|
|
327
|
+
async function sendUserMessage() {
|
|
328
|
+
const text = $input.value.trim();
|
|
329
|
+
if ((!text && pendingImages.length === 0) || !activeSessionId) return;
|
|
330
|
+
|
|
331
|
+
let imageUrls = [];
|
|
332
|
+
if (pendingImages.length > 0) {
|
|
333
|
+
try {
|
|
334
|
+
imageUrls = await Promise.all(pendingImages.map((p) => uploadFile(p.file)));
|
|
335
|
+
} catch (err) {
|
|
336
|
+
alert("图片上传失败:" + err.message);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
pendingImages.forEach((p) => URL.revokeObjectURL(p.url));
|
|
340
|
+
pendingImages = [];
|
|
341
|
+
renderPreviews();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
send({
|
|
345
|
+
type: "send_message",
|
|
346
|
+
session_id: activeSessionId,
|
|
347
|
+
content: text,
|
|
348
|
+
images: imageUrls,
|
|
349
|
+
});
|
|
350
|
+
$input.value = "";
|
|
351
|
+
autoResize();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
$btnSend.addEventListener("click", sendUserMessage);
|
|
355
|
+
$input.addEventListener("keydown", (e) => {
|
|
356
|
+
if (e.key === "Enter") {
|
|
357
|
+
if (e.shiftKey) return;
|
|
358
|
+
if (e.ctrlKey) {
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
const start = $input.selectionStart;
|
|
361
|
+
const end = $input.selectionEnd;
|
|
362
|
+
$input.value = $input.value.substring(0, start) + "\n" + $input.value.substring(end);
|
|
363
|
+
$input.selectionStart = $input.selectionEnd = start + 1;
|
|
364
|
+
autoResize();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
e.preventDefault();
|
|
368
|
+
sendUserMessage();
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// auto-resize textarea
|
|
373
|
+
function autoResize() {
|
|
374
|
+
$input.style.height = "auto";
|
|
375
|
+
$input.style.height = Math.min($input.scrollHeight, 120) + "px";
|
|
376
|
+
}
|
|
377
|
+
$input.addEventListener("input", autoResize);
|
|
378
|
+
|
|
379
|
+
// ── Install to Cursor ──────────────────────
|
|
380
|
+
$("#btn-install").addEventListener("click", async () => {
|
|
381
|
+
const port = location.port || "8765";
|
|
382
|
+
try {
|
|
383
|
+
const res = await fetch(`/api/install?port=${port}`, { method: "POST" });
|
|
384
|
+
const data = await res.json();
|
|
385
|
+
alert(data.ok ? "✓ 已自动写入 ~/.cursor/mcp.json" : "✗ " + data.message);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
alert("写入失败:" + e.message);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ── Inline Rename ─────────────────────────
|
|
392
|
+
function startInlineRename(el, sid) {
|
|
393
|
+
const titleEl = el.querySelector(".s-title");
|
|
394
|
+
if (!titleEl) return;
|
|
395
|
+
const oldTitle = titleEl.textContent;
|
|
396
|
+
|
|
397
|
+
const input = document.createElement("input");
|
|
398
|
+
input.type = "text";
|
|
399
|
+
input.value = oldTitle;
|
|
400
|
+
input.className = "rename-input";
|
|
401
|
+
titleEl.replaceWith(input);
|
|
402
|
+
input.focus();
|
|
403
|
+
input.select();
|
|
404
|
+
|
|
405
|
+
let committed = false;
|
|
406
|
+
function finish() {
|
|
407
|
+
if (committed) return;
|
|
408
|
+
committed = true;
|
|
409
|
+
const newTitle = input.value.trim() || oldTitle;
|
|
410
|
+
const span = document.createElement("div");
|
|
411
|
+
span.className = "s-title";
|
|
412
|
+
span.textContent = newTitle;
|
|
413
|
+
input.replaceWith(span);
|
|
414
|
+
if (newTitle !== oldTitle) {
|
|
415
|
+
send({ type: "rename_session", session_id: sid, title: newTitle });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
input.addEventListener("blur", finish);
|
|
420
|
+
input.addEventListener("keydown", (ev) => {
|
|
421
|
+
if (ev.key === "Enter") { ev.preventDefault(); input.blur(); }
|
|
422
|
+
if (ev.key === "Escape") { input.value = oldTitle; input.blur(); }
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── Context Menu ───────────────────────────
|
|
427
|
+
function showCtxMenu(e, sid) {
|
|
428
|
+
e.preventDefault();
|
|
429
|
+
ctxTargetId = sid;
|
|
430
|
+
$ctxMenu.style.left = e.clientX + "px";
|
|
431
|
+
$ctxMenu.style.top = e.clientY + "px";
|
|
432
|
+
$ctxMenu.classList.remove("hidden");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
document.addEventListener("click", () => $ctxMenu.classList.add("hidden"));
|
|
436
|
+
|
|
437
|
+
$ctxMenu.querySelectorAll(".ctx-item").forEach((el) => {
|
|
438
|
+
el.addEventListener("click", () => {
|
|
439
|
+
const action = el.dataset.action;
|
|
440
|
+
if (!ctxTargetId) return;
|
|
441
|
+
|
|
442
|
+
if (action === "rename") {
|
|
443
|
+
const title = prompt("新标题:");
|
|
444
|
+
if (title) send({ type: "rename_session", session_id: ctxTargetId, title });
|
|
445
|
+
} else if (action === "delete") {
|
|
446
|
+
if (confirm("确定删除此会话?"))
|
|
447
|
+
send({ type: "delete_session", session_id: ctxTargetId });
|
|
448
|
+
}
|
|
449
|
+
ctxTargetId = null;
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ── Notifications ──────────────────────────
|
|
454
|
+
let notifGranted = false;
|
|
455
|
+
if ("Notification" in window) {
|
|
456
|
+
Notification.requestPermission().then((p) => { notifGranted = p === "granted"; });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let titleTimer = null;
|
|
460
|
+
const origTitle = document.title;
|
|
461
|
+
|
|
462
|
+
function notifyNewMessage(text) {
|
|
463
|
+
const preview = truncate(text || "新消息", 50);
|
|
464
|
+
|
|
465
|
+
if (notifGranted && document.hidden) {
|
|
466
|
+
new Notification("丸子 MCP", { body: preview, icon: "" });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (document.hidden) {
|
|
470
|
+
clearInterval(titleTimer);
|
|
471
|
+
let flash = true;
|
|
472
|
+
titleTimer = setInterval(() => {
|
|
473
|
+
document.title = flash ? `💬 ${preview}` : origTitle;
|
|
474
|
+
flash = !flash;
|
|
475
|
+
}, 800);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
document.addEventListener("visibilitychange", () => {
|
|
480
|
+
if (!document.hidden && titleTimer) {
|
|
481
|
+
clearInterval(titleTimer);
|
|
482
|
+
titleTimer = null;
|
|
483
|
+
document.title = origTitle;
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// ── Helpers ────────────────────────────────
|
|
488
|
+
function esc(str) {
|
|
489
|
+
const d = document.createElement("div");
|
|
490
|
+
d.textContent = str;
|
|
491
|
+
return d.innerHTML;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function truncate(s, n) {
|
|
495
|
+
return s.length > n ? s.slice(0, n) + "…" : s;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function formatTime(iso) {
|
|
499
|
+
try {
|
|
500
|
+
const d = new Date(iso);
|
|
501
|
+
return d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
502
|
+
} catch {
|
|
503
|
+
return "";
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── Status Polling ─────────────────────────
|
|
508
|
+
const $statusDot = $("#status-dot");
|
|
509
|
+
const $statusText = $("#status-text");
|
|
510
|
+
|
|
511
|
+
async function pollStatus() {
|
|
512
|
+
try {
|
|
513
|
+
const res = await fetch("/api/status");
|
|
514
|
+
const data = await res.json();
|
|
515
|
+
$statusDot.className = "dot online";
|
|
516
|
+
const parts = [`${data.sse_connections} 个 Cursor 连接`];
|
|
517
|
+
if (data.sessions > 0) parts.push(`${data.sessions} 个会话`);
|
|
518
|
+
$statusText.textContent = parts.join(" · ");
|
|
519
|
+
} catch {
|
|
520
|
+
$statusDot.className = "dot offline";
|
|
521
|
+
$statusText.textContent = "服务已断开";
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
setInterval(pollStatus, 3000);
|
|
526
|
+
pollStatus();
|
|
527
|
+
|
|
528
|
+
// ── Boot ───────────────────────────────────
|
|
529
|
+
connect();
|
|
530
|
+
})();
|